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