diff --git a/src/app/(protected)/settings/_components/task-templates/delete-task-template-dialog.tsx b/src/app/(protected)/settings/_components/task-templates/delete-task-template-dialog.tsx index 0432d943..d8ac7285 100644 --- a/src/app/(protected)/settings/_components/task-templates/delete-task-template-dialog.tsx +++ b/src/app/(protected)/settings/_components/task-templates/delete-task-template-dialog.tsx @@ -6,6 +6,7 @@ import { DialogContentText, DialogTitle, } from "@mui/material"; +import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants"; interface DeleteTaskTemplateDialogProps { open: boolean; @@ -22,16 +23,14 @@ export function DeleteTaskTemplateDialog({ }: DeleteTaskTemplateDialogProps) { return ( - Delete Template + {TASK_TEMPLATE_COPY.deleteDialogTitle} - - Are you sure you want to delete "{templateName}"? - + {TASK_TEMPLATE_COPY.deleteDialogBody(templateName)} - + diff --git a/src/app/(protected)/settings/_components/task-templates/task-template-detail-form.tsx b/src/app/(protected)/settings/_components/task-templates/task-template-detail-form.tsx index d8b8781a..889be812 100644 --- a/src/app/(protected)/settings/_components/task-templates/task-template-detail-form.tsx +++ b/src/app/(protected)/settings/_components/task-templates/task-template-detail-form.tsx @@ -1,8 +1,14 @@ 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 { 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; isNew: boolean; @@ -30,7 +36,7 @@ export function TaskTemplateDetailForm({ return ( - Select a template or create a new one + {TASK_TEMPLATE_COPY.emptySelection} ); @@ -38,17 +44,29 @@ export function TaskTemplateDetailForm({ return ( - + { + if (event.key === "Enter" && event.target instanceof HTMLInputElement) { + event.preventDefault(); + } + }} + > - + @@ -56,11 +74,11 @@ export function TaskTemplateDetailForm({ {!isNew && ( )} diff --git a/src/app/(protected)/settings/_components/task-templates/task-template-items-field.tsx b/src/app/(protected)/settings/_components/task-templates/task-template-items-field.tsx index 1b36d983..384b229c 100644 --- a/src/app/(protected)/settings/_components/task-templates/task-template-items-field.tsx +++ b/src/app/(protected)/settings/_components/task-templates/task-template-items-field.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; -import { useFieldArray, type Control } from "react-hook-form"; +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 { @@ -12,7 +13,9 @@ interface TaskTemplateItemsFieldProps { 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(); @@ -24,8 +27,13 @@ export function TaskTemplateItemsField({ control }: TaskTemplateItemsFieldProps) return ( <> - Checklist Items ({fields.length}) + {TASK_TEMPLATE_COPY.checklistItemsLabel(fields.length)} + {hasInvalidItem && ( + + {TASK_TEMPLATE_COPY.invalidItemError} + + )} {fields.map((field, index) => ( @@ -39,7 +47,7 @@ export function TaskTemplateItemsField({ control }: TaskTemplateItemsFieldProps) swap(index, index - 1)} > @@ -47,7 +55,7 @@ export function TaskTemplateItemsField({ control }: TaskTemplateItemsFieldProps) swap(index, index + 1)} > @@ -55,7 +63,7 @@ export function TaskTemplateItemsField({ control }: TaskTemplateItemsFieldProps) remove(index)} > @@ -76,10 +84,10 @@ export function TaskTemplateItemsField({ control }: TaskTemplateItemsFieldProps) handleAddItem(); } }} - placeholder="Add checklist item..." + placeholder={TASK_TEMPLATE_COPY.addItemPlaceholder} /> diff --git a/src/app/(protected)/settings/_components/task-templates/task-template-list.tsx b/src/app/(protected)/settings/_components/task-templates/task-template-list.tsx index 54d62e2a..0afab3ca 100644 --- a/src/app/(protected)/settings/_components/task-templates/task-template-list.tsx +++ b/src/app/(protected)/settings/_components/task-templates/task-template-list.tsx @@ -7,6 +7,7 @@ import { ListItemText, Paper, } from "@mui/material"; +import { TASK_TEMPLATE_COPY } from "@/domain/settings/task-templates/constants/task-template-constants"; import type { SelectedTaskTemplateId, TaskTemplate, @@ -30,7 +31,7 @@ export function TaskTemplateList({ return ( {isLoading ? ( diff --git a/src/app/(protected)/settings/task-templates.tsx b/src/app/(protected)/settings/task-templates.tsx index ed1b00f9..f368d828 100644 --- a/src/app/(protected)/settings/task-templates.tsx +++ b/src/app/(protected)/settings/task-templates.tsx @@ -1,5 +1,6 @@ import { Alert, Box, Stack, Typography } from "@mui/material"; import { SettingsNav } from "@/components/common/settings-nav"; +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"; @@ -29,14 +30,14 @@ export default function TaskTemplatesPage() { - Task List Templates + {TASK_TEMPLATE_COPY.pageTitle} - Create reusable checklists for work order dispatches + {TASK_TEMPLATE_COPY.pageSubtitle} {Boolean(error) && ( - {error instanceof Error ? error.message : "Failed to load templates"} + {error instanceof Error ? error.message : TASK_TEMPLATE_COPY.loadError} )} diff --git a/src/domain/settings/task-templates/constants/task-template-constants.ts b/src/domain/settings/task-templates/constants/task-template-constants.ts index 4c5263e6..c59dec9a 100644 --- a/src/domain/settings/task-templates/constants/task-template-constants.ts +++ b/src/domain/settings/task-templates/constants/task-template-constants.ts @@ -7,3 +7,29 @@ export const EMPTY_TASK_TEMPLATE_FORM: TaskTemplateFormValues = { 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; diff --git a/src/domain/settings/task-templates/schemas/task-template-schema.ts b/src/domain/settings/task-templates/schemas/task-template-schema.ts index 3695f5ba..dfc56f7f 100644 --- a/src/domain/settings/task-templates/schemas/task-template-schema.ts +++ b/src/domain/settings/task-templates/schemas/task-template-schema.ts @@ -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,7 +7,7 @@ const taskTemplateItemSchema = 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(), items: z.array(taskTemplateItemSchema), }); diff --git a/src/domain/settings/task-templates/use-cases/use-task-template-editor.ts b/src/domain/settings/task-templates/use-cases/use-task-template-editor.ts index 7dc7be13..c056fbc2 100644 --- a/src/domain/settings/task-templates/use-cases/use-task-template-editor.ts +++ b/src/domain/settings/task-templates/use-cases/use-task-template-editor.ts @@ -59,6 +59,7 @@ export function useTaskTemplateEditor(): UseTaskTemplateEditorResult { const selectTemplate = (template: TaskTemplate) => { setSelectedId(template.id); form.reset(mapTaskTemplateToFormValues(template)); + void form.trigger(); }; const startNewTemplate = () => { diff --git a/src/domain/vendor-portal/use-cases/use-vendor-portal-session.ts b/src/domain/vendor-portal/use-cases/use-vendor-portal-session.ts index 63c440a0..54be29b9 100644 --- a/src/domain/vendor-portal/use-cases/use-vendor-portal-session.ts +++ b/src/domain/vendor-portal/use-cases/use-vendor-portal-session.ts @@ -20,6 +20,8 @@ export function useVendorPortalSession(token: string): UseVendorPortalSessionRes const { data, status, error } = useQuery({ queryKey: queryKeys.vendorPortal.session(token), queryFn: () => vendorPortalApi.session(token), + retry: false, + meta: { suppressErrorToast: true }, }); return { diff --git a/src/test/app/(protected)/settings/task-templates.test.tsx b/src/test/app/(protected)/settings/task-templates.test.tsx index 876a5cd1..785185c8 100644 --- a/src/test/app/(protected)/settings/task-templates.test.tsx +++ b/src/test/app/(protected)/settings/task-templates.test.tsx @@ -175,4 +175,52 @@ describe("TaskTemplatesPage", () => { 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(); + }); }); diff --git a/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx b/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx index bd924f33..a91312bc 100644 --- a/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx +++ b/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx @@ -6,10 +6,18 @@ 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()) { @@ -98,4 +106,34 @@ describe("useVendorPortalSession", () => { 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(); + }); });