Merge pull request #224 from Sea-Haven-Industries/refactor/dispatch-detail-modal-split-v2
Some checks failed
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled

refactor(vendor-portal,settings): move state and fetch logic into hook
This commit is contained in:
npalseahaven 2026-09-22 14:17:57 +00:00 • committed by GitHub
commit 4965b8a076
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1194 additions and 297 deletions

View file

@ -0,0 +1,38 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
interface DeleteTaskTemplateDialogProps {
open: boolean;
templateName: string;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteTaskTemplateDialog({
open,
templateName,
onCancel,
onConfirm,
}: DeleteTaskTemplateDialogProps) {
return (
<Dialog open={open} onClose={onCancel}>
<DialogTitle>{TASK_TEMPLATE_COPY.deleteDialogTitle}</DialogTitle>
<DialogContent>
<DialogContentText>{TASK_TEMPLATE_COPY.deleteDialogBody(templateName)}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>{TASK_TEMPLATE_COPY.cancelButton}</Button>
<Button color="error" variant="contained" onClick={onConfirm}>
{TASK_TEMPLATE_COPY.deleteButton}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,93 @@
import type { UseFormReturn } from "react-hook-form";
import { Box, Button, Paper, Stack, TextField, Typography } from "@mui/material";
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
import type {
SelectedTaskTemplateId,
TaskTemplateFormValues,
} from "@/domain/settings/task-templates/types/task-template";
import { TaskTemplateItemsField } from "./task-template-items-field";
function getSubmitLabel(isSaving: boolean, isNew: boolean): string {
if (isSaving) return TASK_TEMPLATE_COPY.savingLabel;
return isNew ? TASK_TEMPLATE_COPY.createButton : TASK_TEMPLATE_COPY.saveButton;
}
interface TaskTemplateDetailFormProps {
form: UseFormReturn<TaskTemplateFormValues>;
isNew: boolean;
isSelected: boolean;
isSaving: boolean;
selectedId: SelectedTaskTemplateId;
onDelete: () => void;
onSubmit: () => void;
}
export function TaskTemplateDetailForm({
form,
isNew,
isSelected,
isSaving,
selectedId,
onDelete,
onSubmit,
}: TaskTemplateDetailFormProps) {
const {
register,
control,
formState: { isValid },
} = form;
if (!isSelected) {
return (
<Paper variant="outlined" className="p-4">
<Typography className="py-10 text-center" sx={{ color: "text.secondary" }}>
{TASK_TEMPLATE_COPY.emptySelection}
</Typography>
</Paper>
);
}
return (
<Paper variant="outlined" className="p-4">
<Box
component="form"
onSubmit={onSubmit}
onKeyDown={(event) => {
if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
event.preventDefault();
}
}}
>
<Stack spacing={3}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label={TASK_TEMPLATE_COPY.nameLabel}
fullWidth
required
placeholder={TASK_TEMPLATE_COPY.namePlaceholder}
{...register("name")}
/>
<TextField
label={TASK_TEMPLATE_COPY.descriptionLabel}
fullWidth
{...register("description")}
/>
</Stack>
<TaskTemplateItemsField key={String(selectedId)} control={control} />
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
{!isNew && (
<Button variant="outlined" color="error" onClick={onDelete}>
{TASK_TEMPLATE_COPY.deleteButton}
</Button>
)}
<Button type="submit" variant="contained" disabled={isSaving || !isValid}>
{getSubmitLabel(isSaving, isNew)}
</Button>
</Stack>
</Stack>
</Box>
</Paper>
);
}

View file

@ -0,0 +1,95 @@
import { useState } from "react";
import { useFieldArray, useFormState, type Control } from "react-hook-form";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import { Box, Button, IconButton, Stack, TextField, Typography } from "@mui/material";
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
import type { TaskTemplateFormValues } from "@/domain/settings/task-templates/types/task-template";
interface TaskTemplateItemsFieldProps {
control: Control<TaskTemplateFormValues>;
}
export function TaskTemplateItemsField({ control }: TaskTemplateItemsFieldProps) {
const { fields, append, remove, swap } = useFieldArray({ control, name: "items" });
const { errors } = useFormState({ control, name: "items" });
const [newItemText, setNewItemText] = useState("");
const hasInvalidItem = Array.isArray(errors.items) && errors.items.some(Boolean);
const handleAddItem = () => {
const text = newItemText.trim();
if (!text) return;
append({ text, sortOrder: fields.length });
setNewItemText("");
};
return (
<>
<Typography variant="subtitle2" className="font-semibold">
{TASK_TEMPLATE_COPY.checklistItemsLabel(fields.length)}
</Typography>
{hasInvalidItem && (
<Typography variant="caption" color="error">
{TASK_TEMPLATE_COPY.invalidItemError}
</Typography>
)}
<Stack spacing={1}>
{fields.map((field, index) => (
<Box key={field.id} className="flex items-center gap-2 rounded border border-border p-2">
<Typography variant="body2" className="w-6 text-muted-foreground">
{index + 1}
</Typography>
<Typography variant="body2" className="flex-1">
{field.text}
</Typography>
<IconButton
size="small"
disabled={index === 0}
aria-label={TASK_TEMPLATE_COPY.moveItemUpLabel(index + 1)}
onClick={() => swap(index, index - 1)}
>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
disabled={index === fields.length - 1}
aria-label={TASK_TEMPLATE_COPY.moveItemDownLabel(index + 1)}
onClick={() => swap(index, index + 1)}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
aria-label={TASK_TEMPLATE_COPY.removeItemLabel(index + 1)}
onClick={() => remove(index)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Box>
))}
</Stack>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
value={newItemText}
onChange={(event) => setNewItemText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddItem();
}
}}
placeholder={TASK_TEMPLATE_COPY.addItemPlaceholder}
/>
<Button variant="outlined" onClick={handleAddItem} disabled={!newItemText.trim()}>
{TASK_TEMPLATE_COPY.addButton}
</Button>
</Stack>
</>
);
}

View file

@ -0,0 +1,55 @@
import {
Box,
Button,
CircularProgress,
List,
ListItemButton,
ListItemText,
Paper,
} from "@mui/material";
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
import type {
SelectedTaskTemplateId,
TaskTemplate,
} from "@/domain/settings/task-templates/types/task-template";
interface TaskTemplateListProps {
isLoading: boolean;
selectedId: SelectedTaskTemplateId;
templates: TaskTemplate[];
onNew: () => void;
onSelect: (template: TaskTemplate) => void;
}
export function TaskTemplateList({
isLoading,
selectedId,
templates,
onNew,
onSelect,
}: TaskTemplateListProps) {
return (
<Paper variant="outlined" className="p-3">
<Button fullWidth variant="contained" className="mb-3" onClick={onNew}>
{TASK_TEMPLATE_COPY.newTemplateButton}
</Button>
{isLoading ? (
<Box className="flex justify-center p-4">
<CircularProgress size={24} />
</Box>
) : (
<List dense disablePadding>
{templates.map((template) => (
<ListItemButton
key={String(template.id)}
selected={selectedId === template.id}
onClick={() => onSelect(template)}
>
<ListItemText primary={template.name} secondary={`${template.items.length} items`} />
</ListItemButton>
))}
</List>
)}
</Paper>
);
}

View file

@ -1,286 +1,69 @@
import { useState } from "react";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
List,
ListItemButton,
ListItemText,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { Alert, Box, Stack, Typography } from "@mui/material";
import { SettingsNav } from "@/components/common/settings-nav";
import { mapTaskTemplateToFormValues } from "@/domain/settings/task-templates/mappers/task-template-mapper";
import type {
TaskTemplate,
TaskTemplateFormValues,
} from "@/domain/settings/task-templates/types/task-template";
import {
useCreateTaskTemplate,
useDeleteTaskTemplate,
useUpdateTaskTemplate,
} from "@/domain/settings/task-templates/use-cases/use-task-template-mutations";
import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates";
const emptyForm: TaskTemplateFormValues = { name: "", description: "", items: [] };
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
import { useTaskTemplateEditor } from "@/domain/settings/task-templates/use-cases/use-task-template-editor";
import { DeleteTaskTemplateDialog } from "./_components/task-templates/delete-task-template-dialog";
import { TaskTemplateDetailForm } from "./_components/task-templates/task-template-detail-form";
import { TaskTemplateList } from "./_components/task-templates/task-template-list";
export default function TaskTemplatesPage() {
const { data: templates = [], isLoading, error } = useTaskTemplates();
const createTemplate = useCreateTaskTemplate();
const updateTemplate = useUpdateTaskTemplate();
const deleteTemplate = useDeleteTaskTemplate();
const {
templatesQuery,
templates,
selectedId,
isNew,
isSaving,
form,
deleteConfirmOpen,
templateName,
selectTemplate,
startNewTemplate,
submitForm,
requestDelete,
cancelDelete,
confirmDelete,
} = useTaskTemplateEditor();
const [selectedId, setSelectedId] = useState<string | number | "new" | null>(null);
const [form, setForm] = useState<TaskTemplateFormValues>(emptyForm);
const [newItemText, setNewItemText] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false);
const isNew = selectedId === "new";
const isSaving = createTemplate.isPending || updateTemplate.isPending;
const handleSelect = (template: TaskTemplate) => {
setSelectedId(template.id);
setForm(mapTaskTemplateToFormValues(template));
setNewItemText("");
};
const handleNew = () => {
setSelectedId("new");
setForm(emptyForm);
setNewItemText("");
};
const handleAddItem = () => {
if (!newItemText.trim()) return;
setForm((prev) => ({
...prev,
items: [...prev.items, { text: newItemText.trim(), sortOrder: prev.items.length }],
}));
setNewItemText("");
};
const handleRemoveItem = (index: number) => {
setForm((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })),
}));
};
const handleMoveItem = (index: number, direction: -1 | 1) => {
setForm((prev) => {
const items = [...prev.items];
const target = index + direction;
if (target < 0 || target >= items.length) return prev;
[items[index], items[target]] = [items[target], items[index]];
return { ...prev, items: items.map((item, i) => ({ ...item, sortOrder: i })) };
});
};
const handleSave = () => {
if (!form.name.trim()) return;
if (isNew) {
createTemplate.mutate(form, {
onSuccess: (result) => setSelectedId(result.id),
});
return;
}
if (selectedId && selectedId !== "new") {
updateTemplate.mutate({ id: selectedId, values: form });
}
};
const handleDelete = () => {
if (!selectedId || selectedId === "new") return;
deleteTemplate.mutate(selectedId, {
onSuccess: () => {
setSelectedId(null);
setForm(emptyForm);
setConfirmDelete(false);
},
});
};
const { isLoading, error } = templatesQuery;
return (
<Box className="p-4">
<SettingsNav />
<Stack spacing={1} className="mb-4">
<Typography variant="h5">Task List Templates</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
Create reusable checklists for work order dispatches
<Typography variant="h5">{TASK_TEMPLATE_COPY.pageTitle}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{TASK_TEMPLATE_COPY.pageSubtitle}
</Typography>
</Stack>
{Boolean(error) && (
<Alert severity="error" className="mb-4">
{error instanceof Error ? error.message : "Failed to load templates"}
{error instanceof Error ? error.message : TASK_TEMPLATE_COPY.loadError}
</Alert>
)}
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
<Paper variant="outlined" className="p-3">
<Button fullWidth variant="contained" className="mb-3" onClick={handleNew}>
+ New Template
</Button>
{isLoading ? (
<Box className="flex justify-center p-4">
<CircularProgress size={24} />
</Box>
) : (
<List dense disablePadding>
{templates.map((template) => (
<ListItemButton
key={String(template.id)}
selected={selectedId === template.id}
onClick={() => handleSelect(template)}
>
<ListItemText
primary={template.name}
secondary={`${template.items.length} items`}
/>
</ListItemButton>
))}
</List>
)}
</Paper>
<Paper variant="outlined" className="p-4">
{!selectedId ? (
<Typography
className="py-10 text-center"
sx={{
color: "text.secondary",
}}
>
Select a template or create a new one
</Typography>
) : (
<Stack spacing={3}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Template Name"
fullWidth
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g., HVAC Inspection"
/>
<TextField
label="Description"
fullWidth
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</Stack>
<Typography variant="subtitle2" className="font-semibold">
Checklist Items ({form.items.length})
</Typography>
<Stack spacing={1}>
{form.items.map((item, index) => (
<Box
key={`${item.sortOrder}-${index}`}
className="flex items-center gap-2 rounded border border-border p-2"
>
<Typography variant="body2" className="w-6 text-muted-foreground">
{index + 1}
</Typography>
<Typography variant="body2" className="flex-1">
{item.text}
</Typography>
<IconButton
size="small"
disabled={index === 0}
onClick={() => handleMoveItem(index, -1)}
>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
disabled={index === form.items.length - 1}
onClick={() => handleMoveItem(index, 1)}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleRemoveItem(index)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Box>
))}
</Stack>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddItem();
}
}}
placeholder="Add checklist item..."
/>
<Button variant="outlined" onClick={handleAddItem} disabled={!newItemText.trim()}>
Add
</Button>
</Stack>
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
{!isNew && (
<Button variant="outlined" color="error" onClick={() => setConfirmDelete(true)}>
Delete
</Button>
)}
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !form.name.trim()}
>
{isSaving ? "Saving..." : isNew ? "Create Template" : "Save Changes"}
</Button>
</Stack>
</Stack>
)}
</Paper>
<TaskTemplateList
isLoading={isLoading}
selectedId={selectedId}
templates={templates}
onNew={startNewTemplate}
onSelect={selectTemplate}
/>
<TaskTemplateDetailForm
form={form}
isNew={isNew}
isSelected={selectedId !== null}
isSaving={isSaving}
selectedId={selectedId}
onDelete={requestDelete}
onSubmit={submitForm}
/>
</Box>
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)}>
<DialogTitle>Delete Template</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{form.name}&quot;?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
<Button color="error" variant="contained" onClick={handleDelete}>
Delete
</Button>
</DialogActions>
</Dialog>
<DeleteTaskTemplateDialog
open={deleteConfirmOpen}
templateName={templateName}
onCancel={cancelDelete}
onConfirm={confirmDelete}
/>
</Box>
);
}

View file

@ -1,38 +1,13 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { useParams } from "react-router";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
import { useVendorPortalSession } from "@/domain/vendor-portal/use-cases/use-vendor-portal-session";
import {
VendorPortalContext,
type VendorPortalContextValue,
type VendorPortalStatus,
} from "@/app/v/_components/vendor-portal-context";
function VendorPortalSessionLoader({ token, children }: { token: string; children: ReactNode }) {
const [vendor, setVendor] = useState<VendorPortalSession | null>(null);
const [status, setStatus] = useState<VendorPortalStatus>("loading");
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
vendorPortalApi
.session(token)
.then((data) => {
if (cancelled) return;
setVendor(data);
setStatus("ready");
})
.catch((err: Error) => {
if (cancelled) return;
setError(err);
setStatus("error");
});
return () => {
cancelled = true;
};
}, [token]);
const { vendor, status, error } = useVendorPortalSession(token);
const value = useMemo<VendorPortalContextValue>(
() => ({ token, vendor, status, error }),

View file

@ -0,0 +1,35 @@
import type { TaskTemplateFormValues } from "@/domain/settings/task-templates/types/task-template";
export const NEW_TEMPLATE_ID = "new" as const;
export const EMPTY_TASK_TEMPLATE_FORM: TaskTemplateFormValues = {
name: "",
description: "",
items: [],
};
export const TASK_TEMPLATE_COPY = {
pageTitle: "Task List Templates",
pageSubtitle: "Create reusable checklists for work order dispatches",
loadError: "Failed to load templates",
newTemplateButton: "+ New Template",
emptySelection: "Select a template or create a new one",
nameLabel: "Template Name",
namePlaceholder: "e.g., HVAC Inspection",
nameRequiredError: "Template name is required",
descriptionLabel: "Description",
deleteButton: "Delete",
savingLabel: "Saving...",
createButton: "Create Template",
saveButton: "Save Changes",
checklistItemsLabel: (count: number) => `Checklist Items (${count})`,
invalidItemError: "One or more checklist items are empty. Remove it to save.",
addItemPlaceholder: "Add checklist item...",
addButton: "Add",
moveItemUpLabel: (position: number) => `Move item ${position} up`,
moveItemDownLabel: (position: number) => `Move item ${position} down`,
removeItemLabel: (position: number) => `Remove item ${position}`,
deleteDialogTitle: "Delete Template",
deleteDialogBody: (name: string) => `Are you sure you want to delete "${name}"?`,
cancelButton: "Cancel",
} as const;

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
const taskTemplateItemSchema = z.object({
text: z.string().min(1),
@ -6,9 +7,9 @@ const taskTemplateItemSchema = z.object({
});
export const taskTemplateSchema = z.object({
name: z.string().min(1, "Template name is required"),
description: z.string().optional().default(""),
items: z.array(taskTemplateItemSchema).default([]),
name: z.string().trim().min(1, TASK_TEMPLATE_COPY.nameRequiredError),
description: z.string(),
items: z.array(taskTemplateItemSchema),
});
export type TaskTemplateSchemaValues = z.infer<typeof taskTemplateSchema>;

View file

@ -1,3 +1,5 @@
import type { NEW_TEMPLATE_ID } from "@/domain/settings/task-templates/constants/task-template-constants";
export interface TaskTemplateItem {
itemText: string;
sortOrder: number;
@ -21,3 +23,5 @@ export interface TaskTemplateFormValues {
description: string;
items: TaskTemplateFormItem[];
}
export type SelectedTaskTemplateId = string | number | typeof NEW_TEMPLATE_ID | null;

View file

@ -0,0 +1,119 @@
import { useState } from "react";
import { useForm, useWatch, type UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { UseQueryResult } from "@tanstack/react-query";
import {
EMPTY_TASK_TEMPLATE_FORM,
NEW_TEMPLATE_ID,
} from "@/domain/settings/task-templates/constants/task-template-constants";
import { mapTaskTemplateToFormValues } from "@/domain/settings/task-templates/mappers/task-template-mapper";
import { taskTemplateSchema } from "@/domain/settings/task-templates/schemas/task-template-schema";
import type {
SelectedTaskTemplateId,
TaskTemplate,
TaskTemplateFormValues,
} from "@/domain/settings/task-templates/types/task-template";
import {
useCreateTaskTemplate,
useDeleteTaskTemplate,
useUpdateTaskTemplate,
} from "@/domain/settings/task-templates/use-cases/use-task-template-mutations";
import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates";
export interface UseTaskTemplateEditorResult {
templatesQuery: UseQueryResult<TaskTemplate[], Error>;
templates: TaskTemplate[];
selectedId: SelectedTaskTemplateId;
isNew: boolean;
isSaving: boolean;
isDeleting: boolean;
form: UseFormReturn<TaskTemplateFormValues>;
deleteConfirmOpen: boolean;
templateName: string;
selectTemplate: (template: TaskTemplate) => void;
startNewTemplate: () => void;
submitForm: () => void;
requestDelete: () => void;
cancelDelete: () => void;
confirmDelete: () => void;
}
export function useTaskTemplateEditor(): UseTaskTemplateEditorResult {
const templatesQuery = useTaskTemplates();
const createTemplate = useCreateTaskTemplate();
const updateTemplate = useUpdateTaskTemplate();
const deleteTemplate = useDeleteTaskTemplate();
const [selectedId, setSelectedId] = useState<SelectedTaskTemplateId>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const form = useForm<TaskTemplateFormValues>({
resolver: zodResolver(taskTemplateSchema),
defaultValues: EMPTY_TASK_TEMPLATE_FORM,
mode: "onChange",
});
const isNew = selectedId === NEW_TEMPLATE_ID;
const templateName = useWatch({ control: form.control, name: "name" });
const selectTemplate = (template: TaskTemplate) => {
setSelectedId(template.id);
form.reset(mapTaskTemplateToFormValues(template));
void form.trigger();
};
const startNewTemplate = () => {
setSelectedId(NEW_TEMPLATE_ID);
form.reset(EMPTY_TASK_TEMPLATE_FORM);
};
const onSubmit = (values: TaskTemplateFormValues) => {
const normalized: TaskTemplateFormValues = {
...values,
items: values.items.map((item, index) => ({ ...item, sortOrder: index })),
};
if (isNew) {
createTemplate.mutate(normalized, {
onSuccess: (result) => setSelectedId(result.id),
});
return;
}
if (selectedId !== null) {
updateTemplate.mutate({ id: selectedId, values: normalized });
}
};
const submitForm = form.handleSubmit(onSubmit);
const requestDelete = () => setDeleteConfirmOpen(true);
const cancelDelete = () => setDeleteConfirmOpen(false);
const confirmDelete = () => {
if (selectedId === null || isNew) return;
deleteTemplate.mutate(selectedId, {
onSuccess: () => {
setSelectedId(null);
form.reset(EMPTY_TASK_TEMPLATE_FORM);
setDeleteConfirmOpen(false);
},
});
};
return {
templatesQuery,
templates: templatesQuery.data ?? [],
selectedId,
isNew,
isSaving: createTemplate.isPending || updateTemplate.isPending,
isDeleting: deleteTemplate.isPending,
form,
deleteConfirmOpen,
templateName,
selectTemplate,
startNewTemplate,
submitForm,
requestDelete,
cancelDelete,
confirmDelete,
};
}

View file

@ -0,0 +1,32 @@
import { useQuery, type QueryStatus } from "@tanstack/react-query";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
import type { VendorPortalStatus } from "@/app/v/_components/vendor-portal-context";
import { queryKeys } from "@/infra/query-key/query-key";
const VENDOR_PORTAL_STATUS_BY_QUERY_STATUS: Record<QueryStatus, VendorPortalStatus> = {
pending: "loading",
error: "error",
success: "ready",
};
interface UseVendorPortalSessionResult {
vendor: VendorPortalSession | null;
status: VendorPortalStatus;
error: Error | null;
}
export function useVendorPortalSession(token: string): UseVendorPortalSessionResult {
const { data, status, error } = useQuery({
queryKey: queryKeys.vendorPortal.session(token),
queryFn: () => vendorPortalApi.session(token),
retry: false,
meta: { suppressErrorToast: true },
});
return {
vendor: data ?? null,
status: VENDOR_PORTAL_STATUS_BY_QUERY_STATUS[status],
error: error ?? null,
};
}

View file

@ -0,0 +1,266 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import TaskTemplatesPage from "@/app/(protected)/settings/task-templates";
import { taskTemplatesApi } from "@/domain/settings/task-templates/api/task-templates-api";
import type { TaskTemplate } from "@/domain/settings/task-templates/types/task-template";
import { renderWithProviders } from "@/test/test-utils";
const existingTemplate: TaskTemplate = {
id: "5",
name: "HVAC Inspection",
description: "Standard checklist",
isActive: true,
items: [
{ itemText: "Check filters", sortOrder: 0 },
{ itemText: "Check refrigerant", sortOrder: 1 },
],
};
const secondTemplate: TaskTemplate = {
id: "9",
name: "Plumbing Inspection",
description: "Basic checklist",
isActive: true,
items: [{ itemText: "Check for leaks", sortOrder: 0 }],
};
afterEach(() => {
vi.restoreAllMocks();
});
function renderPage(templates: TaskTemplate[] = [existingTemplate]) {
vi.spyOn(taskTemplatesApi, "getAll").mockResolvedValue(templates);
return renderWithProviders(<TaskTemplatesPage />, { withAuth: false });
}
describe("TaskTemplatesPage", () => {
it("shows a placeholder until a template is selected or created", async () => {
renderPage();
expect(await screen.findByText("HVAC Inspection")).toBeInTheDocument();
expect(screen.getByText("Select a template or create a new one")).toBeInTheDocument();
});
it("selecting a template from the list populates the detail form", async () => {
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
expect(await screen.findByLabelText(/^Template Name/)).toHaveValue("HVAC Inspection");
expect(screen.getByLabelText("Description")).toHaveValue("Standard checklist");
expect(screen.getByText("Checklist Items (2)")).toBeInTheDocument();
expect(screen.getByText("Check filters")).toBeInTheDocument();
expect(screen.getByText("Check refrigerant")).toBeInTheDocument();
});
it("starting a new template shows an empty form with Create Template action", async () => {
renderPage();
await screen.findByText("HVAC Inspection");
fireEvent.click(screen.getByRole("button", { name: "+ New Template" }));
expect(await screen.findByLabelText(/^Template Name/)).toHaveValue("");
expect(screen.getByRole("button", { name: "Create Template" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
});
it("enables Create Template only once a name is entered", async () => {
renderPage();
await screen.findByText("HVAC Inspection");
fireEvent.click(screen.getByRole("button", { name: "+ New Template" }));
const nameInput = await screen.findByLabelText(/^Template Name/);
fireEvent.change(nameInput, { target: { value: "New Checklist" } });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Create Template" })).toBeEnabled(),
);
});
it("adds a checklist item and appends it to the end of the list", async () => {
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
await screen.findByText("Checklist Items (2)");
fireEvent.change(screen.getByPlaceholderText("Add checklist item..."), {
target: { value: "New Item" },
});
fireEvent.click(screen.getByRole("button", { name: "Add" }));
expect(await screen.findByText("Checklist Items (3)")).toBeInTheDocument();
const rows = screen.getAllByText(/^(Check filters|Check refrigerant|New Item)$/);
expect(rows.map((row) => row.textContent)).toEqual([
"Check filters",
"Check refrigerant",
"New Item",
]);
});
it("clears the unsaved add-item draft when switching to a different template", async () => {
renderPage([existingTemplate, secondTemplate]);
fireEvent.click(await screen.findByText("HVAC Inspection"));
await screen.findByText("Checklist Items (2)");
fireEvent.change(screen.getByPlaceholderText("Add checklist item..."), {
target: { value: "Check filter" },
});
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("Check filter");
fireEvent.click(screen.getByText("Plumbing Inspection"));
await screen.findByText("Checklist Items (1)");
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("");
});
it("clears the unsaved add-item draft when starting a new template", async () => {
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
await screen.findByText("Checklist Items (2)");
fireEvent.change(screen.getByPlaceholderText("Add checklist item..."), {
target: { value: "Check filter" },
});
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("Check filter");
fireEvent.click(screen.getByRole("button", { name: "+ New Template" }));
await screen.findByText("Checklist Items (0)");
expect(screen.getByPlaceholderText("Add checklist item...")).toHaveValue("");
});
it("moves an item up and down without changing the item count", async () => {
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
await screen.findByText("Checklist Items (2)");
fireEvent.click(screen.getByRole("button", { name: "Move item 2 up" }));
let rows = screen.getAllByText(/^(Check filters|Check refrigerant)$/);
expect(rows.map((row) => row.textContent)).toEqual(["Check refrigerant", "Check filters"]);
fireEvent.click(screen.getByRole("button", { name: "Move item 1 down" }));
rows = screen.getAllByText(/^(Check filters|Check refrigerant)$/);
expect(rows.map((row) => row.textContent)).toEqual(["Check filters", "Check refrigerant"]);
});
it("removes an item from the checklist", async () => {
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
await screen.findByText("Checklist Items (2)");
fireEvent.click(screen.getByRole("button", { name: "Remove item 1" }));
expect(await screen.findByText("Checklist Items (1)")).toBeInTheDocument();
expect(screen.queryByText("Check filters")).not.toBeInTheDocument();
expect(screen.getByText("Check refrigerant")).toBeInTheDocument();
});
it("saves an edited template through the update mutation", async () => {
vi.spyOn(taskTemplatesApi, "update").mockResolvedValue(existingTemplate);
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
const descriptionInput = await screen.findByLabelText("Description");
fireEvent.change(descriptionInput, { target: { value: "Updated checklist" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() =>
expect(taskTemplatesApi.update).toHaveBeenCalledWith(
"5",
expect.objectContaining({ description: "Updated checklist" }),
),
);
});
it("deletes a template after confirming in the dialog", async () => {
vi.spyOn(taskTemplatesApi, "delete").mockResolvedValue(undefined);
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/Are you sure you want to delete/)).toHaveTextContent(
"HVAC Inspection",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(taskTemplatesApi.delete).toHaveBeenCalledWith("5"));
await waitFor(() =>
expect(screen.getByText("Select a template or create a new one")).toBeInTheDocument(),
);
});
it("cancelling the delete dialog keeps the template intact", async () => {
const deleteSpy = vi.spyOn(taskTemplatesApi, "delete");
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(deleteSpy).not.toHaveBeenCalled();
expect(screen.getByLabelText(/^Template Name/)).toHaveValue("HVAC Inspection");
});
it("surfaces a load error banner when the template list fails to fetch", async () => {
vi.spyOn(taskTemplatesApi, "getAll").mockRejectedValue(new Error("Network down"));
renderWithProviders(<TaskTemplatesPage />, { withAuth: false });
expect(await screen.findByRole("alert")).toHaveTextContent("Network down");
});
it("keeps Create Template disabled for a whitespace-only name", async () => {
renderPage();
await screen.findByText("HVAC Inspection");
fireEvent.click(screen.getByRole("button", { name: "+ New Template" }));
const nameInput = await screen.findByLabelText(/^Template Name/);
fireEvent.change(nameInput, { target: { value: " " } });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Create Template" })).toBeDisabled(),
);
});
it("does not submit the form when Enter is pressed in the Template Name field", async () => {
const updateSpy = vi.spyOn(taskTemplatesApi, "update");
renderPage();
fireEvent.click(await screen.findByText("HVAC Inspection"));
const nameInput = await screen.findByLabelText(/^Template Name/);
fireEvent.keyDown(nameInput, { key: "Enter", code: "Enter" });
expect(updateSpy).not.toHaveBeenCalled();
});
it("shows a recovery message and disables Save when a loaded item has empty text", async () => {
const templateWithBlankItem: TaskTemplate = {
...existingTemplate,
items: [{ itemText: "", sortOrder: 0 }],
};
renderPage([templateWithBlankItem]);
fireEvent.click(await screen.findByText("HVAC Inspection"));
expect(
await screen.findByText("One or more checklist items are empty. Remove it to save."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Remove item 1" }));
await waitFor(() =>
expect(
screen.queryByText("One or more checklist items are empty. Remove it to save."),
).not.toBeInTheDocument(),
);
expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled();
});
});

View file

@ -0,0 +1,89 @@
import { screen } from "@testing-library/react";
import { Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
import { renderWithProviders } from "@/test/test-utils";
const useVendorPortalSession = vi.fn();
vi.mock("@/domain/vendor-portal/use-cases/use-vendor-portal-session", () => ({
useVendorPortalSession: (...args: unknown[]) => useVendorPortalSession(...args),
}));
function VendorPortalConsumer() {
const { token, vendor, status, error } = useVendorPortal();
return (
<div>
<span data-testid="token">{token}</span>
<span data-testid="status">{status}</span>
<span data-testid="vendor-name">{vendor?.companyName ?? "none"}</span>
<span data-testid="error">{error?.message ?? "none"}</span>
</div>
);
}
function renderPortal(token: string) {
return renderWithProviders(
<Routes>
<Route
path="/v/:token"
element={
<VendorPortalProvider>
<VendorPortalConsumer />
</VendorPortalProvider>
}
/>
</Routes>,
{
route: `/v/${token}`,
routerProps: { initialEntries: [`/v/${token}`] },
withAuth: false,
},
);
}
describe("VendorPortalProvider", () => {
it("exposes loading status while the session query is pending", () => {
useVendorPortalSession.mockReturnValue({ vendor: null, status: "loading", error: null });
renderPortal("token-a");
expect(screen.getByTestId("token")).toHaveTextContent("token-a");
expect(screen.getByTestId("status")).toHaveTextContent("loading");
expect(screen.getByTestId("vendor-name")).toHaveTextContent("none");
});
it("exposes ready status with the resolved vendor once the session loads", () => {
const vendor: VendorPortalSession = { companyName: "Acme Vendor", vendorId: 42 };
useVendorPortalSession.mockReturnValue({ vendor, status: "ready", error: null });
renderPortal("token-b");
expect(screen.getByTestId("status")).toHaveTextContent("ready");
expect(screen.getByTestId("vendor-name")).toHaveTextContent("Acme Vendor");
expect(screen.getByTestId("error")).toHaveTextContent("none");
});
it("exposes error status and message when the session lookup fails", () => {
useVendorPortalSession.mockReturnValue({
vendor: null,
status: "error",
error: new Error("Invalid or expired link"),
});
renderPortal("token-c");
expect(screen.getByTestId("status")).toHaveTextContent("error");
expect(screen.getByTestId("error")).toHaveTextContent("Invalid or expired link");
});
it("passes the token straight from the route param through to the hook", () => {
useVendorPortalSession.mockReturnValue({ vendor: null, status: "loading", error: null });
renderPortal("route-token-123");
expect(useVendorPortalSession).toHaveBeenCalledWith("route-token-123");
});
});

View file

@ -0,0 +1,173 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { taskTemplatesApi } from "@/domain/settings/task-templates/api/task-templates-api";
import type { TaskTemplate } from "@/domain/settings/task-templates/types/task-template";
import { useTaskTemplateEditor } from "@/domain/settings/task-templates/use-cases/use-task-template-editor";
import { createTestQueryClient } from "@/test/test-utils";
const existingTemplate: TaskTemplate = {
id: "5",
name: "HVAC Inspection",
description: "Standard checklist",
isActive: true,
items: [
{ itemText: "Check filters", sortOrder: 0 },
{ itemText: "Check refrigerant", sortOrder: 1 },
],
};
afterEach(() => {
vi.restoreAllMocks();
});
function wrapperFor() {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
function renderEditor() {
vi.spyOn(taskTemplatesApi, "getAll").mockResolvedValue([existingTemplate]);
return renderHook(() => useTaskTemplateEditor(), { wrapper: wrapperFor() });
}
describe("useTaskTemplateEditor", () => {
it("starts with nothing selected and an empty form", async () => {
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
expect(result.current.selectedId).toBeNull();
expect(result.current.isNew).toBe(false);
expect(result.current.form.getValues()).toEqual({ name: "", description: "", items: [] });
});
it("selectTemplate loads the mapped form values for that template", async () => {
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
act(() => result.current.selectTemplate(existingTemplate));
expect(result.current.selectedId).toBe("5");
expect(result.current.isNew).toBe(false);
expect(result.current.form.getValues()).toEqual({
name: "HVAC Inspection",
description: "Standard checklist",
items: [
{ text: "Check filters", sortOrder: 0 },
{ text: "Check refrigerant", sortOrder: 1 },
],
});
});
it("startNewTemplate marks isNew and resets the form to empty", async () => {
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
act(() => result.current.selectTemplate(existingTemplate));
act(() => result.current.startNewTemplate());
expect(result.current.isNew).toBe(true);
expect(result.current.form.getValues()).toEqual({ name: "", description: "", items: [] });
});
it("does not call create when the name is blank (schema validation blocks submit)", async () => {
const createSpy = vi.spyOn(taskTemplatesApi, "create");
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
act(() => result.current.startNewTemplate());
await act(async () => result.current.submitForm());
expect(createSpy).not.toHaveBeenCalled();
});
it("creates a new template and normalizes item sortOrder by array position", async () => {
const created: TaskTemplate = { ...existingTemplate, id: "9", name: "New Template" };
const createSpy = vi.spyOn(taskTemplatesApi, "create").mockResolvedValue(created);
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
act(() => result.current.startNewTemplate());
act(() => {
result.current.form.setValue("name", "New Template", { shouldValidate: true });
result.current.form.setValue(
"items",
[
{ text: "Second", sortOrder: 99 },
{ text: "First", sortOrder: 1 },
],
{ shouldValidate: true },
);
});
await act(async () => result.current.submitForm());
expect(createSpy).toHaveBeenCalledWith({
name: "New Template",
description: "",
items: [
{ text: "Second", sortOrder: 0 },
{ text: "First", sortOrder: 1 },
],
});
await waitFor(() => expect(result.current.selectedId).toBe("9"));
});
it("updates the selected template instead of creating when not new", async () => {
const updateSpy = vi.spyOn(taskTemplatesApi, "update").mockResolvedValue(existingTemplate);
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
act(() => result.current.selectTemplate(existingTemplate));
act(() => {
result.current.form.setValue("description", "Updated description", { shouldValidate: true });
});
await act(async () => result.current.submitForm());
expect(updateSpy).toHaveBeenCalledWith(
"5",
expect.objectContaining({ description: "Updated description" }),
);
});
it("toggles the delete confirmation dialog open and closed", async () => {
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
act(() => result.current.requestDelete());
expect(result.current.deleteConfirmOpen).toBe(true);
act(() => result.current.cancelDelete());
expect(result.current.deleteConfirmOpen).toBe(false);
});
it("confirmDelete removes the template and clears the selection", async () => {
const deleteSpy = vi.spyOn(taskTemplatesApi, "delete").mockResolvedValue(undefined);
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
act(() => result.current.selectTemplate(existingTemplate));
act(() => result.current.requestDelete());
await act(async () => result.current.confirmDelete());
expect(deleteSpy).toHaveBeenCalledWith("5");
await waitFor(() => expect(result.current.selectedId).toBeNull());
expect(result.current.deleteConfirmOpen).toBe(false);
expect(result.current.form.getValues()).toEqual({ name: "", description: "", items: [] });
});
it("does nothing on confirmDelete when nothing is selected", async () => {
const deleteSpy = vi.spyOn(taskTemplatesApi, "delete");
const { result } = renderEditor();
await waitFor(() => expect(result.current.templates).toHaveLength(1));
await act(async () => result.current.confirmDelete());
expect(deleteSpy).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,139 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
import { useVendorPortalSession } from "@/domain/vendor-portal/use-cases/use-vendor-portal-session";
import { queryKeys } from "@/infra/query-key/query-key";
import { createAppQueryClient } from "@/lib/query/query-client";
import { createTestQueryClient } from "@/test/test-utils";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error, success: vi.fn() },
}));
afterEach(() => {
vi.restoreAllMocks();
toastMocks.error.mockReset();
});
function wrapperFor(queryClient = createTestQueryClient()) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe("useVendorPortalSession", () => {
it("starts in loading status with a null vendor", () => {
vi.spyOn(vendorPortalApi, "session").mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useVendorPortalSession("token-1"), {
wrapper: wrapperFor(),
});
expect(result.current.status).toBe("loading");
expect(result.current.vendor).toBeNull();
expect(result.current.error).toBeNull();
});
it("maps a successful fetch to ready status with the vendor payload", async () => {
const vendor: VendorPortalSession = { companyName: "Acme Vendor", vendorId: 7 };
vi.spyOn(vendorPortalApi, "session").mockResolvedValue(vendor);
const { result } = renderHook(() => useVendorPortalSession("token-1"), {
wrapper: wrapperFor(),
});
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.vendor).toEqual(vendor);
expect(result.current.error).toBeNull();
});
it("maps a rejected fetch to error status with the error preserved", async () => {
vi.spyOn(vendorPortalApi, "session").mockRejectedValue(new Error("Invalid link"));
const { result } = renderHook(() => useVendorPortalSession("token-1"), {
wrapper: wrapperFor(),
});
await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.vendor).toBeNull();
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe("Invalid link");
});
it("caches by the centralized vendorPortal.session query key, keyed per token", async () => {
const sessionSpy = vi
.spyOn(vendorPortalApi, "session")
.mockResolvedValue({ companyName: "Acme Vendor" });
// staleTime: Infinity isolates cache-reuse from React Query's default
// background-refetch-on-mount behavior, which is a separate concern.
const queryClient = createTestQueryClient();
queryClient.setDefaultOptions({ queries: { retry: false, gcTime: 0, staleTime: Infinity } });
const wrapper = wrapperFor(queryClient);
const { result: first } = renderHook(() => useVendorPortalSession("shared-token"), { wrapper });
await waitFor(() => expect(first.current.status).toBe("ready"));
const { result: second } = renderHook(() => useVendorPortalSession("shared-token"), {
wrapper,
});
await waitFor(() => expect(second.current.status).toBe("ready"));
expect(sessionSpy).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryData(queryKeys.vendorPortal.session("shared-token"))).toEqual({
companyName: "Acme Vendor",
});
});
it("fetches independently for a different token instead of reusing the cache", async () => {
const sessionSpy = vi
.spyOn(vendorPortalApi, "session")
.mockResolvedValue({ companyName: "Acme Vendor" });
const queryClient = createTestQueryClient();
const wrapper = wrapperFor(queryClient);
const { result: first } = renderHook(() => useVendorPortalSession("token-x"), { wrapper });
await waitFor(() => expect(first.current.status).toBe("ready"));
const { result: second } = renderHook(() => useVendorPortalSession("token-y"), { wrapper });
await waitFor(() => expect(second.current.status).toBe("ready"));
expect(sessionSpy).toHaveBeenCalledTimes(2);
expect(sessionSpy).toHaveBeenNthCalledWith(1, "token-x");
expect(sessionSpy).toHaveBeenNthCalledWith(2, "token-y");
});
it("does not retry a rejected request, even under the app's default retry:1 policy", async () => {
const sessionSpy = vi
.spyOn(vendorPortalApi, "session")
.mockRejectedValue(new Error("Invalid link"));
// Uses the real app query client (default retry: 1) instead of the test
// client, so this only passes if the hook opts out of retries itself.
const queryClient = createAppQueryClient();
const { result } = renderHook(() => useVendorPortalSession("token-1"), {
wrapper: wrapperFor(queryClient),
});
await waitFor(() => expect(result.current.status).toBe("error"));
expect(sessionSpy).toHaveBeenCalledTimes(1);
});
it("does not show the global error toast, since the layout renders its own inline error UI", async () => {
vi.spyOn(vendorPortalApi, "session").mockRejectedValue(new Error("Invalid link"));
// Uses the real app query client so the QueryCache's global onError
// handler actually runs and would toast unless suppressed.
const queryClient = createAppQueryClient();
const { result } = renderHook(() => useVendorPortalSession("token-1"), {
wrapper: wrapperFor(queryClient),
});
await waitFor(() => expect(result.current.status).toBe("error"));
expect(toastMocks.error).not.toHaveBeenCalled();
});
});