Compare commits

...

9 commits

Author SHA1 Message Date
renovate[bot]
0c12b4f624
Merge 7a6b89ac79 into 4965b8a076 2026-09-22 11:22:09 -03:00
npalseahaven
4965b8a076
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
2026-09-22 14:17:57 +00:00
npal
86e43b66f4 fix: reset add-item draft on template selection change (SH-378)
TaskTemplateItemsField owns newItemText locally but stays mounted
across selectTemplate/startNewTemplate (which only call form.reset()),
so a typed draft survived switching templates - the base page cleared
this draft explicitly in both handleSelect and handleNew.

Remount TaskTemplateItemsField on selection change via key={selectedId}
instead of lifting the draft into the editor hook: it's transient
input-only state, not form data, so this keeps the fix local and lets
React's own remount semantics reset it. Confirmed the two new
regression tests fail without the key and pass with it.
2026-09-21 20:04:33 -05:00
cb6fd48916
Merge branch 'main' into refactor/dispatch-detail-modal-split-v2 2026-09-21 16:54:40 -04:00
renovate[bot]
7a6b89ac79
chore(deps): update aws-actions/configure-aws-credentials action to v6.3.0 2026-09-21 07:35:26 +00:00
Adam Moussa
999f1f03fa
Merge branch 'main' into refactor/dispatch-detail-modal-split-v2 2026-09-18 17:49:02 -04:00
npal
62e5d46b0f fix: address PR #224 review findings and extract task-template copy (SH-378)
Three real behavior deltas flagged in review, each closing a gap
against the "no behavior change" claim:

- use-vendor-portal-session.ts: add retry:false and
  meta:{suppressErrorToast:true} so an invalid/expired token fails
  fast with a single request and no duplicate global toast (this was
  also the root cause of the failing e2e test - the toast and the
  inline error message both had role="alert", tripping a strict-mode
  locator match)
- task-template-schema.ts: trim the name before validating so a
  whitespace-only name is rejected, matching the old manual
  form.name.trim() check
- task-template-detail-form.tsx: block Enter-triggered implicit
  submission on the Template Name / Description inputs, since the
  original page had no <form> element and Enter did nothing
- task-template-items-field.tsx: surface a visible error when a
  loaded item has empty text, since the schema already blocked save
  in that case but gave no way to see why; use-task-template-editor.ts
  now eagerly validates after loading a template so this reflects
  reality immediately instead of only after an unrelated edit

Also extract every user-facing string in the task-templates feature
into TASK_TEMPLATE_COPY (task-template-constants.ts) instead of
inline literals scattered across 5 files.

6 new regression tests cover all of the above; full suite (36 tests
across the 2 refactored areas) still green.
2026-09-18 10:46:16 -05:00
npal
5645c7e02b Merge remote-tracking branch 'origin/dev' into refactor/dispatch-detail-modal-split-v2 2026-09-17 18:10:34 -05:00
npal
a93747f119 refactor(vendor-portal,settings): move state and fetch logic into hooks (SH-378)
vendor-portal-provider.tsx and task-templates.tsx mixed data-fetching,
state, and rendering in one file, with no caching and hand-rolled form
state. This is a pure refactor with no behavior change.

- vendor-portal-provider.tsx: replace manual useEffect/useState fetch
  with useVendorPortalSession (useQuery), closing the token race
  condition the old key={token} remount was working around
- task-templates.tsx: split into useTaskTemplateEditor hook plus 4
  presentational components; replace hand-rolled form state with
  react-hook-form + zod validation; replace the "new" string sentinel
  with a NEW_TEMPLATE_ID constant
- add 29 tests (hook, component, and full-page integration) confirming
  identical behavior to the original code
2026-09-17 18:05:06 -05:00
16 changed files with 1195 additions and 298 deletions

View file

@ -233,7 +233,7 @@ jobs:
retention-days: 7
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1

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();
});
});