mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 11:39:00 +00:00
fix(team-members): harden team member flows (SH-325)
This commit is contained in:
parent
b6f4590bfa
commit
0ac8c4473e
5 changed files with 98 additions and 17 deletions
|
|
@ -204,6 +204,12 @@ async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectWorkOrderPageReady(page: Page) {
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||||
|
timeout: 90_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function expectStableScreenshot(page: Page, name: string) {
|
async function expectStableScreenshot(page: Page, name: string) {
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
|
|
@ -217,15 +223,18 @@ async function expectStableScreenshot(page: Page, name: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Work Orders deterministic pixel regression", () => {
|
test.describe("Work Orders deterministic pixel regression", () => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
test("list", async ({ page }) => {
|
test("list", async ({ page }) => {
|
||||||
await openWorkOrderPage(page);
|
await openWorkOrderPage(page);
|
||||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
await expectWorkOrderPageReady(page);
|
||||||
await expect(page.getByText("WO-501").first()).toBeVisible();
|
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||||
await expectStableScreenshot(page, "wo-list.png");
|
await expectStableScreenshot(page, "wo-list.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filters", async ({ page }) => {
|
test("filters", async ({ page }) => {
|
||||||
await openWorkOrderPage(page);
|
await openWorkOrderPage(page);
|
||||||
|
await expectWorkOrderPageReady(page);
|
||||||
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||||
await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible();
|
||||||
await expectStableScreenshot(page, "wo-filters.png");
|
await expectStableScreenshot(page, "wo-filters.png");
|
||||||
|
|
@ -233,6 +242,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
|
||||||
|
|
||||||
test("new", async ({ page }) => {
|
test("new", async ({ page }) => {
|
||||||
await openWorkOrderPage(page);
|
await openWorkOrderPage(page);
|
||||||
|
await expectWorkOrderPageReady(page);
|
||||||
await page.getByRole("button", { name: "New WO" }).click();
|
await page.getByRole("button", { name: "New WO" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
||||||
await expectStableScreenshot(page, "wo-new.png");
|
await expectStableScreenshot(page, "wo-new.png");
|
||||||
|
|
@ -240,6 +250,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
|
||||||
|
|
||||||
test("detail", async ({ page }) => {
|
test("detail", async ({ page }) => {
|
||||||
await openWorkOrderPage(page);
|
await openWorkOrderPage(page);
|
||||||
|
await expectWorkOrderPageReady(page);
|
||||||
const row = page.locator("#wo-row-1");
|
const row = page.locator("#wo-row-1");
|
||||||
await row.hover();
|
await row.hover();
|
||||||
await row.getByRole("button", { name: "View details" }).click();
|
await row.getByRole("button", { name: "View details" }).click();
|
||||||
|
|
@ -249,7 +260,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
|
||||||
|
|
||||||
test("empty", async ({ page }) => {
|
test("empty", async ({ page }) => {
|
||||||
await openWorkOrderPage(page, "empty");
|
await openWorkOrderPage(page, "empty");
|
||||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
await expectWorkOrderPageReady(page);
|
||||||
await page.getByLabel("Search work orders").fill("zz");
|
await page.getByLabel("Search work orders").fill("zz");
|
||||||
await expect(page.getByText("No work orders match your search")).toBeVisible();
|
await expect(page.getByText("No work orders match your search")).toBeVisible();
|
||||||
await expectStableScreenshot(page, "wo-empty.png");
|
await expectStableScreenshot(page, "wo-empty.png");
|
||||||
|
|
@ -257,7 +268,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
|
||||||
|
|
||||||
test("error", async ({ page }) => {
|
test("error", async ({ page }) => {
|
||||||
await openWorkOrderPage(page, "error");
|
await openWorkOrderPage(page, "error");
|
||||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
await expectWorkOrderPageReady(page);
|
||||||
const alert = page.getByRole("main").getByRole("alert");
|
const alert = page.getByRole("main").getByRole("alert");
|
||||||
await expect(alert).toBeVisible();
|
await expect(alert).toBeVisible();
|
||||||
await expect(alert).toContainText(/server error/i);
|
await expect(alert).toContainText(/server error/i);
|
||||||
|
|
@ -267,6 +278,7 @@ test.describe("Work Orders deterministic pixel regression", () => {
|
||||||
test("mobile", async ({ page }) => {
|
test("mobile", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await openWorkOrderPage(page);
|
await openWorkOrderPage(page);
|
||||||
|
await expectWorkOrderPageReady(page);
|
||||||
await expect(page.getByText("WO-501").first()).toBeVisible();
|
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||||
await expectStableScreenshot(page, "wo-mobile.png");
|
await expectStableScreenshot(page, "wo-mobile.png");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
|
|
@ -357,15 +357,25 @@ function TeamMemberFormFields({
|
||||||
export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const createTeamMember = useCreateTeamMember();
|
const submissionId = useRef(0);
|
||||||
const mutationError = createTeamMember.error;
|
const { error: mutationError, isPending, mutate, reset } = useCreateTeamMember();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
submissionId.current += 1;
|
||||||
|
reset();
|
||||||
if (open) {
|
if (open) {
|
||||||
setForm(emptyForm());
|
setForm(emptyForm());
|
||||||
setSubmitted(false);
|
setSubmitted(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, reset]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isPending) return;
|
||||||
|
|
||||||
|
submissionId.current += 1;
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim());
|
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim());
|
||||||
const errors = useMemo(
|
const errors = useMemo(
|
||||||
|
|
@ -419,11 +429,18 @@ export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose:
|
||||||
serviceAreas: form.serviceAreas,
|
serviceAreas: form.serviceAreas,
|
||||||
permissionOverrides: permissionOverrides(form.role, form.permissions),
|
permissionOverrides: permissionOverrides(form.role, form.permissions),
|
||||||
};
|
};
|
||||||
createTeamMember.mutate(input, { onSuccess: onClose });
|
const currentSubmissionId = submissionId.current;
|
||||||
|
mutate(input, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (submissionId.current !== currentSubmissionId) return;
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md" scroll="paper">
|
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="md" scroll="paper">
|
||||||
<DialogTitle>Add Member</DialogTitle>
|
<DialogTitle>Add Member</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
|
@ -444,15 +461,11 @@ export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose:
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={createTeamMember.isPending}>
|
<Button onClick={handleClose} disabled={isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={submit} disabled={createTeamMember.isPending}>
|
<Button variant="contained" onClick={submit} disabled={isPending}>
|
||||||
{createTeamMember.isPending ? (
|
{isPending ? <CircularProgress size={20} color="inherit" /> : "Add Member"}
|
||||||
<CircularProgress size={20} color="inherit" />
|
|
||||||
) : (
|
|
||||||
"Add Member"
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@ function memberStatusLabel(member: TeamMemberListItem) {
|
||||||
export function TeamMembersTable({
|
export function TeamMembersTable({
|
||||||
rows,
|
rows,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
hasError,
|
||||||
tab,
|
tab,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
rows: TeamMemberListItem[];
|
rows: TeamMemberListItem[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
hasError: boolean;
|
||||||
tab: "active" | "inactive";
|
tab: "active" | "inactive";
|
||||||
onOpen: (member: TeamMemberListItem) => void;
|
onOpen: (member: TeamMemberListItem) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -52,6 +54,15 @@ export function TeamMembersTable({
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
) : hasError ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Unable to load team members</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Check your connection and try again.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
|
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,13 @@ export default function TeamMembersListPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflow: "auto", p: 2 }}>
|
<Box sx={{ flex: 1, overflow: "auto", p: 2 }}>
|
||||||
<TeamMembersTable rows={rows} isLoading={isLoading} tab={tab} onOpen={setSelectedMember} />
|
<TeamMembersTable
|
||||||
|
rows={rows}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasError={Boolean(error)}
|
||||||
|
tab={tab}
|
||||||
|
onOpen={setSelectedMember}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@ vi.mock("@/domain/team-members/use-cases/use-create-team-member", () => ({
|
||||||
|
|
||||||
describe("AddTeamMemberDialog", () => {
|
describe("AddTeamMemberDialog", () => {
|
||||||
const mutate = vi.fn();
|
const mutate = vi.fn();
|
||||||
|
const reset = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mutate.mockReset();
|
mutate.mockReset();
|
||||||
|
reset.mockReset();
|
||||||
vi.mocked(useCreateTeamMember).mockReturnValue({
|
vi.mocked(useCreateTeamMember).mockReturnValue({
|
||||||
mutate,
|
mutate,
|
||||||
|
reset,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as unknown as ReturnType<typeof useCreateTeamMember>);
|
} as unknown as ReturnType<typeof useCreateTeamMember>);
|
||||||
|
|
@ -62,4 +65,40 @@ describe("AddTeamMemberDialog", () => {
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
|
it("does not close while a member is being created", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
vi.mocked(useCreateTeamMember).mockReturnValue({
|
||||||
|
mutate,
|
||||||
|
reset,
|
||||||
|
isPending: true,
|
||||||
|
error: null,
|
||||||
|
} as unknown as ReturnType<typeof useCreateTeamMember>);
|
||||||
|
renderWithProviders(<AddTeamMemberDialog open onClose={onClose} />, { withAuth: false });
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a previous mutation error when reopened", () => {
|
||||||
|
const mutation = {
|
||||||
|
mutate,
|
||||||
|
reset,
|
||||||
|
isPending: false,
|
||||||
|
error: new Error("Email is already in use."),
|
||||||
|
} as unknown as ReturnType<typeof useCreateTeamMember>;
|
||||||
|
vi.mocked(useCreateTeamMember).mockReturnValue(mutation);
|
||||||
|
const { rerender } = renderWithProviders(<AddTeamMemberDialog open onClose={vi.fn()} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Email is already in use.")).toBeInTheDocument();
|
||||||
|
mutation.error = null;
|
||||||
|
rerender(<AddTeamMemberDialog open={false} onClose={vi.fn()} />);
|
||||||
|
rerender(<AddTeamMemberDialog open onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Email is already in use.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue