mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 01:52:04 +00:00
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
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:
commit
4965b8a076
15 changed files with 1194 additions and 297 deletions
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,286 +1,69 @@
|
||||||
import { useState } from "react";
|
import { Alert, Box, Stack, Typography } from "@mui/material";
|
||||||
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 { SettingsNav } from "@/components/common/settings-nav";
|
import { SettingsNav } from "@/components/common/settings-nav";
|
||||||
import { mapTaskTemplateToFormValues } from "@/domain/settings/task-templates/mappers/task-template-mapper";
|
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
|
||||||
import type {
|
import { useTaskTemplateEditor } from "@/domain/settings/task-templates/use-cases/use-task-template-editor";
|
||||||
TaskTemplate,
|
import { DeleteTaskTemplateDialog } from "./_components/task-templates/delete-task-template-dialog";
|
||||||
TaskTemplateFormValues,
|
import { TaskTemplateDetailForm } from "./_components/task-templates/task-template-detail-form";
|
||||||
} from "@/domain/settings/task-templates/types/task-template";
|
import { TaskTemplateList } from "./_components/task-templates/task-template-list";
|
||||||
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: [] };
|
|
||||||
|
|
||||||
export default function TaskTemplatesPage() {
|
export default function TaskTemplatesPage() {
|
||||||
const { data: templates = [], isLoading, error } = useTaskTemplates();
|
const {
|
||||||
const createTemplate = useCreateTaskTemplate();
|
templatesQuery,
|
||||||
const updateTemplate = useUpdateTaskTemplate();
|
templates,
|
||||||
const deleteTemplate = useDeleteTaskTemplate();
|
selectedId,
|
||||||
|
isNew,
|
||||||
|
isSaving,
|
||||||
|
form,
|
||||||
|
deleteConfirmOpen,
|
||||||
|
templateName,
|
||||||
|
selectTemplate,
|
||||||
|
startNewTemplate,
|
||||||
|
submitForm,
|
||||||
|
requestDelete,
|
||||||
|
cancelDelete,
|
||||||
|
confirmDelete,
|
||||||
|
} = useTaskTemplateEditor();
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string | number | "new" | null>(null);
|
const { isLoading, error } = templatesQuery;
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="p-4">
|
<Box className="p-4">
|
||||||
<SettingsNav />
|
<SettingsNav />
|
||||||
<Stack spacing={1} className="mb-4">
|
<Stack spacing={1} className="mb-4">
|
||||||
<Typography variant="h5">Task List Templates</Typography>
|
<Typography variant="h5">{TASK_TEMPLATE_COPY.pageTitle}</Typography>
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
variant="body2"
|
{TASK_TEMPLATE_COPY.pageSubtitle}
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create reusable checklists for work order dispatches
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
{Boolean(error) && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error" className="mb-4">
|
<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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
|
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
|
||||||
<Paper variant="outlined" className="p-3">
|
<TaskTemplateList
|
||||||
<Button fullWidth variant="contained" className="mb-3" onClick={handleNew}>
|
isLoading={isLoading}
|
||||||
+ New Template
|
selectedId={selectedId}
|
||||||
</Button>
|
templates={templates}
|
||||||
{isLoading ? (
|
onNew={startNewTemplate}
|
||||||
<Box className="flex justify-center p-4">
|
onSelect={selectTemplate}
|
||||||
<CircularProgress size={24} />
|
/>
|
||||||
</Box>
|
<TaskTemplateDetailForm
|
||||||
) : (
|
form={form}
|
||||||
<List dense disablePadding>
|
isNew={isNew}
|
||||||
{templates.map((template) => (
|
isSelected={selectedId !== null}
|
||||||
<ListItemButton
|
isSaving={isSaving}
|
||||||
key={String(template.id)}
|
selectedId={selectedId}
|
||||||
selected={selectedId === template.id}
|
onDelete={requestDelete}
|
||||||
onClick={() => handleSelect(template)}
|
onSubmit={submitForm}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)}>
|
<DeleteTaskTemplateDialog
|
||||||
<DialogTitle>Delete Template</DialogTitle>
|
open={deleteConfirmOpen}
|
||||||
<DialogContent>
|
templateName={templateName}
|
||||||
<DialogContentText>
|
onCancel={cancelDelete}
|
||||||
Are you sure you want to delete "{form.name}"?
|
onConfirm={confirmDelete}
|
||||||
</DialogContentText>
|
/>
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
|
|
||||||
<Button color="error" variant="contained" onClick={handleDelete}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,13 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
import { useVendorPortalSession } from "@/domain/vendor-portal/use-cases/use-vendor-portal-session";
|
||||||
import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
|
|
||||||
import {
|
import {
|
||||||
VendorPortalContext,
|
VendorPortalContext,
|
||||||
type VendorPortalContextValue,
|
type VendorPortalContextValue,
|
||||||
type VendorPortalStatus,
|
|
||||||
} from "@/app/v/_components/vendor-portal-context";
|
} from "@/app/v/_components/vendor-portal-context";
|
||||||
|
|
||||||
function VendorPortalSessionLoader({ token, children }: { token: string; children: ReactNode }) {
|
function VendorPortalSessionLoader({ token, children }: { token: string; children: ReactNode }) {
|
||||||
const [vendor, setVendor] = useState<VendorPortalSession | null>(null);
|
const { vendor, status, error } = useVendorPortalSession(token);
|
||||||
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 value = useMemo<VendorPortalContextValue>(
|
const value = useMemo<VendorPortalContextValue>(
|
||||||
() => ({ token, vendor, status, error }),
|
() => ({ token, vendor, status, error }),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants";
|
||||||
|
|
||||||
const taskTemplateItemSchema = z.object({
|
const taskTemplateItemSchema = z.object({
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
|
|
@ -6,9 +7,9 @@ const taskTemplateItemSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const taskTemplateSchema = z.object({
|
export const taskTemplateSchema = z.object({
|
||||||
name: z.string().min(1, "Template name is required"),
|
name: z.string().trim().min(1, TASK_TEMPLATE_COPY.nameRequiredError),
|
||||||
description: z.string().optional().default(""),
|
description: z.string(),
|
||||||
items: z.array(taskTemplateItemSchema).default([]),
|
items: z.array(taskTemplateItemSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TaskTemplateSchemaValues = z.infer<typeof taskTemplateSchema>;
|
export type TaskTemplateSchemaValues = z.infer<typeof taskTemplateSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { NEW_TEMPLATE_ID } from "@/domain/settings/task-templates/constants/task-template-constants";
|
||||||
|
|
||||||
export interface TaskTemplateItem {
|
export interface TaskTemplateItem {
|
||||||
itemText: string;
|
itemText: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
|
@ -21,3 +23,5 @@ export interface TaskTemplateFormValues {
|
||||||
description: string;
|
description: string;
|
||||||
items: TaskTemplateFormItem[];
|
items: TaskTemplateFormItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SelectedTaskTemplateId = string | number | typeof NEW_TEMPLATE_ID | null;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
266
src/test/app/(protected)/settings/task-templates.test.tsx
Normal file
266
src/test/app/(protected)/settings/task-templates.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/test/app/v/vendor-portal-provider.test.tsx
Normal file
89
src/test/app/v/vendor-portal-provider.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue