mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +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 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 "{form.name}"?
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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 { 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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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