From 39a6339c59d601140b019eb8d8873e175c65e4b8 Mon Sep 17 00:00:00 2001 From: zuiidea Date: Mon, 27 Apr 2026 15:53:47 +0800 Subject: [PATCH] feat(templates): remove orders sample, align basic with with-lingui - Drop orders routes, API, MSW, and e2e; users-only CRUD template - Port HTTP refresh, hooks, RouteError, and core e2e to basic - Lingui extract --clean in script; refresh catalogs - Update add-resource docs, cursor rule, and optimization spec Made-with: Cursor --- .cursor/rules/with-lingui-add-resource.mdc | 2 +- apps/basic/e2e/auth-refresh.spec.ts | 39 +++ apps/basic/e2e/helpers.ts | 8 + apps/basic/e2e/rbac.spec.ts | 10 + apps/basic/e2e/url-state.spec.ts | 19 ++ apps/basic/e2e/users.spec.ts | 2 - apps/basic/package.json | 6 +- apps/basic/playwright.config.ts | 1 - apps/basic/src/api/schemas.ts | 6 + apps/basic/src/components/RouteError.tsx | 24 ++ apps/basic/src/hooks/useCrudToasts.ts | 66 +++++ apps/basic/src/hooks/useResourceCRUD.test.ts | 67 +++++ apps/basic/src/hooks/useResourceCRUD.ts | 121 +++++++- apps/basic/src/hooks/useTableFitHeight.ts | 133 +++++++++ apps/basic/src/hooks/useUrlSearchState.ts | 29 ++ apps/basic/src/main.tsx | 5 +- apps/basic/src/mocks/createHandler.test.ts | 71 +++++ apps/basic/src/mocks/createHandler.ts | 54 ++-- apps/basic/src/mocks/data.ts | 9 +- apps/basic/src/mocks/handlers/auth.ts | 86 +++++- apps/basic/src/mocks/handlers/user.ts | 30 +- apps/basic/src/routes/__root.tsx | 42 ++- apps/basic/src/routes/_auth.tsx | 17 +- apps/basic/src/routes/_auth/users/index.tsx | 227 ++++----------- apps/basic/src/stores/auth.ts | 8 + apps/basic/src/utils/appMenu.ts | 9 - apps/basic/src/utils/http.ts | 111 ++++++- apps/basic/vitest.config.ts | 15 + .../.github/instructions/README.md | 2 +- .../instructions/add-resource.instructions.md | 12 +- .../instructions/testing.instructions.md | 5 +- apps/with-lingui/AGENTS.md | 2 +- apps/with-lingui/README.md | 12 +- apps/with-lingui/e2e/orders.spec.ts | 14 - apps/with-lingui/package.json | 4 +- apps/with-lingui/src/api/orders.ts | 10 - apps/with-lingui/src/api/schemas.ts | 30 -- .../src/components/Layout/Sidebar/index.tsx | 3 - apps/with-lingui/src/locales/en/messages.po | 196 ++++--------- apps/with-lingui/src/locales/zh/messages.po | 196 ++++--------- apps/with-lingui/src/mocks/data.ts | 10 +- apps/with-lingui/src/mocks/handlers/index.ts | 3 +- apps/with-lingui/src/mocks/handlers/orders.ts | 61 ---- apps/with-lingui/src/routeTree.gen.ts | 270 ++++++++---------- .../src/routes/_auth/orders/-FormModal.tsx | 43 --- .../src/routes/_auth/orders/-Toolbar.tsx | 58 ---- .../src/routes/_auth/orders/index.tsx | 262 ----------------- apps/with-lingui/src/utils/appMenu.ts | 11 - ...026-04-24-with-lingui-optimization-plan.md | 79 ++--- pnpm-lock.yaml | 3 + 50 files changed, 1236 insertions(+), 1267 deletions(-) create mode 100644 apps/basic/e2e/auth-refresh.spec.ts create mode 100644 apps/basic/e2e/rbac.spec.ts create mode 100644 apps/basic/e2e/url-state.spec.ts create mode 100644 apps/basic/src/components/RouteError.tsx create mode 100644 apps/basic/src/hooks/useCrudToasts.ts create mode 100644 apps/basic/src/hooks/useResourceCRUD.test.ts create mode 100644 apps/basic/src/hooks/useTableFitHeight.ts create mode 100644 apps/basic/src/hooks/useUrlSearchState.ts create mode 100644 apps/basic/src/mocks/createHandler.test.ts create mode 100644 apps/basic/vitest.config.ts delete mode 100644 apps/with-lingui/e2e/orders.spec.ts delete mode 100644 apps/with-lingui/src/api/orders.ts delete mode 100644 apps/with-lingui/src/mocks/handlers/orders.ts delete mode 100644 apps/with-lingui/src/routes/_auth/orders/-FormModal.tsx delete mode 100644 apps/with-lingui/src/routes/_auth/orders/-Toolbar.tsx delete mode 100644 apps/with-lingui/src/routes/_auth/orders/index.tsx diff --git a/.cursor/rules/with-lingui-add-resource.mdc b/.cursor/rules/with-lingui-add-resource.mdc index 21272da..7148017 100644 --- a/.cursor/rules/with-lingui-add-resource.mdc +++ b/.cursor/rules/with-lingui-add-resource.mdc @@ -10,6 +10,6 @@ alwaysApply: false `apps/with-lingui/.github/instructions/add-resource.instructions.md` -When adding a resource, follow that recipe (mirror the `users` / `orders` examples). On conflict, the `.instructions.md` file wins. +When adding a resource, follow that recipe (mirror the `users` example). On conflict, the `.instructions.md` file wins. `@apps/with-lingui/.github/instructions/add-resource.instructions.md` diff --git a/apps/basic/e2e/auth-refresh.spec.ts b/apps/basic/e2e/auth-refresh.spec.ts new file mode 100644 index 0000000..8f5be0f --- /dev/null +++ b/apps/basic/e2e/auth-refresh.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "@playwright/test"; +import { loginAsAdmin } from "./helpers"; + +const expiredAuthStorage = () => + JSON.stringify({ + state: { + tokens: { + accessToken: "__EXPIRED_ACCESS__", + refreshToken: "mock-refresh-token", + }, + isAuthenticated: true, + }, + version: 0, + }); + +test.describe("Auth refresh", () => { + test.describe.configure({ retries: 2 }); + + test("401 on list triggers refresh then succeeds", async ({ page }) => { + await loginAsAdmin(page); + + await page.evaluate((payload) => { + localStorage.setItem("auth-storage", payload); + }, expiredAuthStorage()); + + await page.reload({ waitUntil: "load" }); + + const usersListOk = page.waitForResponse( + (res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(), + { timeout: 60_000 }, + ); + await page.goto("/users", { waitUntil: "domcontentloaded" }); + await usersListOk; + + await expect(page).toHaveURL(/\/users(\/|\?|$)/); + const thead = page.locator(".ant-table-thead"); + await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/apps/basic/e2e/helpers.ts b/apps/basic/e2e/helpers.ts index ae5ec6b..f1ec09a 100644 --- a/apps/basic/e2e/helpers.ts +++ b/apps/basic/e2e/helpers.ts @@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) { await page.goto("/users"); await expect(page).toHaveURL(/users/); } + +export async function loginAsGuest(page: Page) { + await page.goto("/login"); + await page.getByLabel(/Username|用户名/).fill("guest"); + await page.getByLabel(/Password|密码/).fill("guest"); + await page.getByRole("button", { name: /Sign In|登录/ }).click(); + await expect(page).toHaveURL(/dashboard/); +} diff --git a/apps/basic/e2e/rbac.spec.ts b/apps/basic/e2e/rbac.spec.ts new file mode 100644 index 0000000..9763800 --- /dev/null +++ b/apps/basic/e2e/rbac.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from "@playwright/test"; +import { loginAsGuest } from "./helpers"; + +test.describe("RBAC", () => { + test("guest without user:view cannot open /users", async ({ page }) => { + await loginAsGuest(page); + await page.goto("/users"); + await expect(page).toHaveURL(/403/); + }); +}); diff --git a/apps/basic/e2e/url-state.spec.ts b/apps/basic/e2e/url-state.spec.ts new file mode 100644 index 0000000..5bb531c --- /dev/null +++ b/apps/basic/e2e/url-state.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test"; +import { loginAsAdmin } from "./helpers"; + +test.describe("URL search state", () => { + test("persists keyword after reload", async ({ page }) => { + await loginAsAdmin(page); + await page.goto("/users"); + await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 }); + + const searchInput = page.getByPlaceholder(/Search User|搜索用户/); + await searchInput.fill("zhao.ming"); + await searchInput.press("Enter"); + await expect(page).toHaveURL(/keyword=zhao\.ming/); + + await page.reload(); + await expect(page).toHaveURL(/keyword=zhao\.ming/); + await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible(); + }); +}); diff --git a/apps/basic/e2e/users.spec.ts b/apps/basic/e2e/users.spec.ts index e187e5e..69bf5eb 100644 --- a/apps/basic/e2e/users.spec.ts +++ b/apps/basic/e2e/users.spec.ts @@ -18,7 +18,6 @@ test.describe("User Management", () => { await searchInput.fill("zhao.ming"); await searchInput.press("Enter"); await expect(page).toHaveURL(/keyword=zhao\.ming/); - /* Avoid strict-mode match on email cell containing substring "zhao.ming" */ await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible(); }); @@ -28,7 +27,6 @@ test.describe("User Management", () => { }); }); -/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */ test.describe("User Management — role in URL", () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); diff --git a/apps/basic/package.json b/apps/basic/package.json index be722d3..f8dc2bf 100644 --- a/apps/basic/package.json +++ b/apps/basic/package.json @@ -11,8 +11,9 @@ "fmt": "vp fmt", "lint": "vp lint", "check": "vp check --no-fmt", + "test:unit": "vitest run", "test:e2e": "playwright test", - "test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts", + "test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts", "test:e2e:ui": "playwright test --ui" }, "dependencies": { @@ -34,7 +35,8 @@ "msw": "^2.12.12", "typescript": "~5.9.3", "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite-plus": "latest", + "vitest": "3.0.5" }, "packageManager": "pnpm@10.32.1", "pnpm": { diff --git a/apps/basic/playwright.config.ts b/apps/basic/playwright.config.ts index 4f095ca..361d1c2 100644 --- a/apps/basic/playwright.config.ts +++ b/apps/basic/playwright.config.ts @@ -1,6 +1,5 @@ import { defineConfig, devices } from "@playwright/test"; -/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */ declare const process: { env: { CI?: string } }; const ci = Boolean(process.env.CI); diff --git a/apps/basic/src/api/schemas.ts b/apps/basic/src/api/schemas.ts index 336b57b..724308d 100644 --- a/apps/basic/src/api/schemas.ts +++ b/apps/basic/src/api/schemas.ts @@ -22,6 +22,12 @@ export const AuthTokensSchema = z.object({ export type AuthTokens = z.infer; +export const RefreshTokenRequestSchema = z.object({ + refreshToken: z.string().min(1), +}); + +export type RefreshTokenRequest = z.infer; + export const LoginRequestSchema = z.object({ username: z.string().min(1), password: z.string().min(1), diff --git a/apps/basic/src/components/RouteError.tsx b/apps/basic/src/components/RouteError.tsx new file mode 100644 index 0000000..6d44bb3 --- /dev/null +++ b/apps/basic/src/components/RouteError.tsx @@ -0,0 +1,24 @@ +import { Button, Result, theme } from "antd"; +import { useRouter, type ErrorComponentProps } from "@tanstack/react-router"; + +export function RouteError({ error }: ErrorComponentProps) { + const router = useRouter(); + const { token } = theme.useToken(); + + const detail = error instanceof Error ? error.message : String(error); + + return ( +
+ void router.invalidate()}> + Retry + + } + /> +
+ ); +} diff --git a/apps/basic/src/hooks/useCrudToasts.ts b/apps/basic/src/hooks/useCrudToasts.ts new file mode 100644 index 0000000..ef2c95c --- /dev/null +++ b/apps/basic/src/hooks/useCrudToasts.ts @@ -0,0 +1,66 @@ +import { useMemo } from "react"; +import type { MessageInstance } from "antd/es/message/interface"; + +type MutationLifecycle = { + onMutate?: (values: TValues) => void; + onSuccess?: (values: TValues) => void; + onError?: (values: TValues) => void; +}; + +export type CrudToastLifecycles = { + createLifecycle?: MutationLifecycle; + updateLifecycle?: MutationLifecycle; + deleteLifecycle?: MutationLifecycle; +}; + +export function useCrudToasts(options: { + message: MessageInstance; + resourceKey: string; +}): CrudToastLifecycles { + const { message, resourceKey } = options; + + const keys = useMemo( + () => ({ + create: `${resourceKey}-mutation-create`, + update: `${resourceKey}-mutation-update`, + delete: `${resourceKey}-mutation-delete`, + }), + [resourceKey], + ); + + return useMemo( + () => ({ + createLifecycle: { + onSuccess: () => { + message.success({ content: "Created successfully", key: keys.create }); + }, + onError: () => { + message.error({ content: "Create failed", key: keys.create }); + }, + }, + updateLifecycle: { + onMutate: () => { + message.loading({ content: "Updating…", key: keys.update, duration: 0 }); + }, + onSuccess: () => { + message.success({ content: "Updated successfully", key: keys.update }); + }, + onError: () => { + message.error({ content: "Update failed", key: keys.update }); + }, + }, + deleteLifecycle: { + onMutate: () => { + message.loading({ content: "Deleting…", key: keys.delete, duration: 0 }); + }, + onSuccess: () => { + message.success({ content: "Deleted successfully", key: keys.delete }); + }, + onError: () => { + message.error({ content: "Delete failed", key: keys.delete }); + }, + }, + }), + [keys.create, keys.delete, keys.update, message], + ); +} diff --git a/apps/basic/src/hooks/useResourceCRUD.test.ts b/apps/basic/src/hooks/useResourceCRUD.test.ts new file mode 100644 index 0000000..2d6f3e8 --- /dev/null +++ b/apps/basic/src/hooks/useResourceCRUD.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD"; + +type Row = { id: string; title: string; n: number }; + +describe("applyOptimisticListUpdate", () => { + it("merges patch into the matching row by id", () => { + const data: { list: Row[]; total: number } = { + list: [ + { id: "1", title: "A", n: 1 }, + { id: "2", title: "B", n: 2 }, + ], + total: 2, + }; + const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" }); + expect(next.list).toEqual([ + { id: "1", title: "A", n: 1 }, + { id: "2", title: "B2", n: 2 }, + ]); + expect(next.total).toBe(2); + }); +}); + +describe("applyOptimisticListDelete", () => { + it("removes row and decrements total", () => { + const data: { list: Row[]; total: number } = { + list: [ + { id: "1", title: "A", n: 1 }, + { id: "2", title: "B", n: 2 }, + ], + total: 2, + }; + const next = applyOptimisticListDelete(data, "1"); + expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]); + expect(next.total).toBe(1); + }); + + it("does not take total below zero", () => { + const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 }; + const next = applyOptimisticListDelete(data, "1"); + expect(next.list).toEqual([]); + expect(next.total).toBe(0); + }); +}); + +describe("rollback contract (QueryClient simulation)", () => { + it("restore snapshot after failed optimistic update", () => { + const key = ["items", 10, 0] as const; + const before = { + list: [ + { id: "1", title: "A", n: 1 }, + { id: "2", title: "B", n: 2 }, + ], + total: 2, + }; + const snapshot = structuredClone(before); + let cache: typeof before = structuredClone(before); + const setData = (next: typeof before) => { + cache = next; + }; + setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" })); + expect(cache.list[1]?.title).toBe("BAD"); + setData(snapshot); + expect(cache).toEqual(before); + expect(key[0]).toBe("items"); + }); +}); diff --git a/apps/basic/src/hooks/useResourceCRUD.ts b/apps/basic/src/hooks/useResourceCRUD.ts index 22d92f1..e19ee16 100644 --- a/apps/basic/src/hooks/useResourceCRUD.ts +++ b/apps/basic/src/hooks/useResourceCRUD.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { UseMutationResult } from "@tanstack/react-query"; type MutationLifecycle = { onMutate?: (values: TValues) => void; @@ -6,9 +7,23 @@ type MutationLifecycle = { onError?: (values: TValues) => void; }; -type UseResourceCRUDOptions = { +export type ResourceListData = { + list: readonly TItem[]; + total: number; +}; + +type OptimisticFlags = { + update?: boolean; + delete?: boolean; +}; + +type UseResourceCRUDOptions< + TListData extends ResourceListData<{ id: string }>, + TCreateValues, + TUpdateValues extends { id: string }, +> = { queryKey: readonly unknown[]; - invalidateKey: readonly unknown[]; + invalidateKey?: readonly unknown[]; queryFn: () => Promise; select: (raw: unknown) => TListData; createFn: (values: TCreateValues) => Promise; @@ -17,15 +32,63 @@ type UseResourceCRUDOptions = { createLifecycle?: MutationLifecycle; updateLifecycle?: MutationLifecycle; deleteLifecycle?: MutationLifecycle; + optimistic?: OptimisticFlags; }; -export function useResourceCRUD( +export type ResourceCRUDResult = { + data: TListData | undefined; + isLoading: boolean; + createMutation: UseMutationResult; + updateMutation: UseMutationResult; + deleteMutation: UseMutationResult; +}; + +/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */ +export type CrudResult = ResourceCRUDResult< + TListData, + TCreateValues, + TUpdateValues +>; + +type RollbackCtx = { snapshot: TListData | undefined }; + +export function applyOptimisticListUpdate< + TItem extends { id: string }, + TUpdate extends { id: string }, +>(data: ResourceListData, values: TUpdate): { list: TItem[]; total: number } { + const { id, ...patch } = values as TUpdate & Record; + return { + list: data.list.map((row) => + row.id === id ? ({ ...(row as object), ...patch } as TItem) : row, + ), + total: data.total, + }; +} + +export function applyOptimisticListDelete( + data: ResourceListData, + id: string, +): { list: TItem[]; total: number } { + return { + list: data.list.filter((row) => row.id !== id), + total: Math.max(0, data.total - 1), + }; +} + +export function useResourceCRUD< + TListData extends ResourceListData<{ id: string }>, + TCreateValues, + TUpdateValues extends { id: string }, +>( options: UseResourceCRUDOptions, -) { +): ResourceCRUDResult { const queryClient = useQueryClient(); + const invalidateKey = options.invalidateKey ?? [options.queryKey[0]]; + const queryKey = options.queryKey; + const opt = options.optimistic; const query = useQuery({ - queryKey: options.queryKey, + queryKey, queryFn: options.queryFn, select: options.select, }); @@ -36,7 +99,7 @@ export function useResourceCRUD( options.createLifecycle?.onMutate?.(values); }, onSuccess: (_data, values) => { - void queryClient.invalidateQueries({ queryKey: options.invalidateKey }); + void queryClient.invalidateQueries({ queryKey: invalidateKey }); options.createLifecycle?.onSuccess?.(values); }, onError: (_error, values) => { @@ -46,28 +109,62 @@ export function useResourceCRUD( const updateMutation = useMutation({ mutationFn: options.updateFn, - onMutate: (values) => { + onMutate: async (values): Promise> => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + const applied = Boolean(opt?.update && previous); + if (applied) { + queryClient.setQueryData( + queryKey, + applyOptimisticListUpdate( + previous as ResourceListData<{ id: string }>, + values as { id: string } & Record, + ) as unknown as TListData, + ); + } options.updateLifecycle?.onMutate?.(values); + return { snapshot: applied ? previous : undefined }; }, onSuccess: (_data, values) => { - void queryClient.invalidateQueries({ queryKey: options.invalidateKey }); + void queryClient.invalidateQueries({ queryKey: invalidateKey }); options.updateLifecycle?.onSuccess?.(values); }, - onError: (_error, values) => { + onError: (_error, values, context) => { + const snap = context?.snapshot; + if (snap !== undefined) { + queryClient.setQueryData(queryKey, snap); + } options.updateLifecycle?.onError?.(values); }, }); const deleteMutation = useMutation({ mutationFn: options.deleteFn, - onMutate: (id) => { + onMutate: async (id): Promise> => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + const applied = Boolean(opt?.delete && previous); + if (applied) { + queryClient.setQueryData( + queryKey, + applyOptimisticListDelete( + previous as ResourceListData<{ id: string }>, + id, + ) as unknown as TListData, + ); + } options.deleteLifecycle?.onMutate?.(id); + return { snapshot: applied ? previous : undefined }; }, onSuccess: (_data, id) => { - void queryClient.invalidateQueries({ queryKey: options.invalidateKey }); + void queryClient.invalidateQueries({ queryKey: invalidateKey }); options.deleteLifecycle?.onSuccess?.(id); }, - onError: (_error, id) => { + onError: (_error, id, context) => { + const snap = context?.snapshot; + if (snap !== undefined) { + queryClient.setQueryData(queryKey, snap); + } options.deleteLifecycle?.onError?.(id); }, }); diff --git a/apps/basic/src/hooks/useTableFitHeight.ts b/apps/basic/src/hooks/useTableFitHeight.ts new file mode 100644 index 0000000..ea7384c --- /dev/null +++ b/apps/basic/src/hooks/useTableFitHeight.ts @@ -0,0 +1,133 @@ +import { useLayoutEffect, useRef, useState, type RefObject } from "react"; + +export type UseTableFitHeightArgs = { + pageShellRef: RefObject; + toolbarRef: RefObject; + middleRef: RefObject; + tableFrameRef: RefObject; + marginLG: number; + rowCount: number; + isLoading: boolean; + showPagination: boolean; +}; + +export function useTableFitHeight(args: UseTableFitHeightArgs) { + const { + pageShellRef, + toolbarRef, + middleRef, + tableFrameRef, + marginLG, + rowCount, + isLoading, + showPagination, + } = args; + + const tableAvailableRef = useRef(0); + const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState(() => + typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined, + ); + const [tableScrollY, setTableScrollY] = useState(() => { + if (typeof window === "undefined") return undefined; + const maxH = Math.max(240, Math.floor(window.innerHeight - 280)); + return Math.max(120, maxH - 40); + }); + + useLayoutEffect(() => { + const shell = pageShellRef.current; + const toolbarEl = toolbarRef.current; + const mid = middleRef.current; + if (!shell || !toolbarEl || !mid) return; + + const headReserve = 40; + const rowEstimate = 44; + const paginationBlock = 56; + + let rafRetry = 0; + const maxRafRetries = 8; + + const measure = () => { + const toolbarRect = toolbarEl.getBoundingClientRect(); + const mainEl = shell.closest(".main-layout-main"); + const mainRect = mainEl?.getBoundingClientRect(); + const viewportBottom = + typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY; + const clipBottom = mainRect + ? mainRect.bottom + : Math.min(shell.getBoundingClientRect().bottom, viewportBottom); + + const maxMiddleFromShell = Math.max( + 0, + Math.floor(clipBottom - toolbarRect.bottom - marginLG), + ); + + const midHRaw = mid.clientHeight; + const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell; + + if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) { + rafRetry += 1; + requestAnimationFrame(() => requestAnimationFrame(measure)); + return; + } + + const frameInner = Math.max(0, Math.floor(midH)); + tableAvailableRef.current = frameInner; + + setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined); + + const pagReserve = showPagination ? paginationBlock : 0; + const bodyMax = Math.max(120, frameInner - headReserve - pagReserve); + + if (isLoading && rowCount === 0) { + setTableScrollY(bodyMax); + return; + } + + if (rowCount === 0 && !isLoading) { + setTableScrollY(undefined); + return; + } + + const naturalBodyH = headReserve + rowCount * rowEstimate; + const naturalTotalH = naturalBodyH + pagReserve; + const slack = 12; + + if (naturalTotalH + slack > frameInner) { + setTableScrollY(bodyMax); + return; + } + + setTableScrollY(undefined); + requestAnimationFrame(() => { + const frame = tableFrameRef.current; + const avail = tableAvailableRef.current; + const minFrame = headReserve + pagReserve; + if (!frame || avail <= minFrame) return; + if (frame.scrollHeight > frame.clientHeight + 1) { + setTableScrollY(Math.max(120, avail - headReserve - pagReserve)); + } + }); + }; + + measure(); + const ro = new ResizeObserver(measure); + ro.observe(shell); + ro.observe(mid); + return () => ro.disconnect(); + }, [ + showPagination, + rowCount, + isLoading, + marginLG, + pageShellRef, + toolbarRef, + middleRef, + tableFrameRef, + ]); + + return { + tableAreaMaxHeight, + tableScrollY, + lockScrollHeight: tableScrollY != null, + }; +} diff --git a/apps/basic/src/hooks/useUrlSearchState.ts b/apps/basic/src/hooks/useUrlSearchState.ts new file mode 100644 index 0000000..3726687 --- /dev/null +++ b/apps/basic/src/hooks/useUrlSearchState.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useState } from "react"; + +export function useUrlSearchState(args: { + search: TSearch; + setSearch: (next: TSearch) => void; +}) { + const { search, setSearch } = args; + const [keywordInput, setKeywordInput] = useState(search.keyword); + + useEffect(() => { + setKeywordInput(search.keyword); + }, [search.keyword]); + + const commitKeyword = useCallback(() => { + const trimmed = keywordInput.trim(); + setSearch({ ...search, keyword: trimmed, offset: 0 }); + }, [keywordInput, search, setSearch]); + + const applyKeyword = useCallback( + (kw: string) => { + const trimmed = kw.trim(); + setKeywordInput(trimmed); + setSearch({ ...search, keyword: trimmed, offset: 0 }); + }, + [search, setSearch], + ); + + return { keywordInput, setKeywordInput, commitKeyword, applyKeyword }; +} diff --git a/apps/basic/src/main.tsx b/apps/basic/src/main.tsx index fd65c60..15e368d 100644 --- a/apps/basic/src/main.tsx +++ b/apps/basic/src/main.tsx @@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; import { useAuthStore } from "./stores/auth"; +import { useSettingsStore } from "./stores/settings"; import { fetchSessionAndApplyToStore } from "./utils/session"; +import { installHttpRouter } from "./utils/http"; const router = createRouter({ routeTree }); +installHttpRouter(router); declare module "@tanstack/react-router" { interface Register { @@ -22,7 +25,7 @@ async function enableMocking() { enableMocking() .then(async () => { - await useAuthStore.persist.rehydrate(); + await Promise.all([useSettingsStore.persist.rehydrate(), useAuthStore.persist.rehydrate()]); const { isAuthenticated, tokens } = useAuthStore.getState(); if (isAuthenticated && tokens) { try { diff --git a/apps/basic/src/mocks/createHandler.test.ts b/apps/basic/src/mocks/createHandler.test.ts new file mode 100644 index 0000000..e6a64ac --- /dev/null +++ b/apps/basic/src/mocks/createHandler.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; +import { + paginatedListSchema, + paginatedWithSchema, + successWithNullBody, + successWithSchema, +} from "./createHandler"; +import { UserSchema } from "@/api/schemas"; + +describe("successWithSchema", () => { + it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => { + expect(() => + successWithSchema(UserSchema, { + id: "1", + username: "x", + } as unknown), + ).toThrow(z.ZodError); + }); + + it("returns JSON body with parsed data when valid", async () => { + const row = { + id: "1", + username: "u", + avatar: null, + email: null, + roles: [] as string[], + permissions: [] as string[], + }; + const res = successWithSchema(UserSchema, row); + const body = (await res.json()) as { code: number; data: typeof row }; + expect(body.code).toBe(0); + expect(body.data.username).toBe("u"); + }); +}); + +describe("paginatedListSchema / paginatedWithSchema", () => { + it("rejects invalid list shape", () => { + const schema = paginatedListSchema(UserSchema); + expect(() => + schema.parse({ + list: [{ id: "1" }], + total: 1, + }), + ).toThrow(z.ZodError); + }); + + it("accepts valid paginated payload via paginatedWithSchema", async () => { + const row = { + id: "1", + username: "u", + avatar: null, + email: null, + roles: [] as string[], + permissions: [] as string[], + }; + const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 }); + const body = (await res.json()) as { data: { list: (typeof row)[]; total: number } }; + expect(body.data.total).toBe(1); + expect(body.data.list).toHaveLength(1); + }); +}); + +describe("successWithNullBody", () => { + it("wraps null data with schema validation", async () => { + const res = successWithNullBody(); + const body = (await res.json()) as { code: number; data: null }; + expect(body.data).toBeNull(); + expect(body.code).toBe(0); + }); +}); diff --git a/apps/basic/src/mocks/createHandler.ts b/apps/basic/src/mocks/createHandler.ts index 76ebc68..be97492 100644 --- a/apps/basic/src/mocks/createHandler.ts +++ b/apps/basic/src/mocks/createHandler.ts @@ -1,30 +1,17 @@ import { HttpResponse, delay as mswDelay } from "msw"; +import { z } from "zod/v4"; +import type { ZodTypeAny } from "zod/v4"; -/** - * Standard API response format - */ export interface ApiResponse { code: number; data: T; message: string; } -/** - * MSW handler factory functions - * Provides consistent response wrapping and error handling - */ - -/** - * Wrap a handler function with standard response format - * Automatically handles delay, error responses, and data wrapping - */ export async function withDelay(ms: number = 200): Promise { await mswDelay(ms); } -/** - * Create a success response - */ export function successResponse(data: T, message: string = "ok") { return HttpResponse.json({ code: 0, @@ -33,9 +20,37 @@ export function successResponse(data: T, message: string = "ok") { }); } -/** - * Create an error response - */ +/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */ +export function successWithSchema( + schema: T, + data: unknown, + message: string = "ok", +) { + const parsed = schema.parse(data); + return successResponse(parsed, message); +} + +export function paginatedListSchema(itemSchema: T) { + return z.object({ + list: z.array(itemSchema), + total: z.number().int().nonnegative(), + }); +} + +export function paginatedWithSchema( + itemSchema: T, + data: unknown, + message: string = "ok", +) { + return successWithSchema(paginatedListSchema(itemSchema), data, message); +} + +const NullBodySchema = z.null(); + +export function successWithNullBody(message: string = "ok") { + return successWithSchema(NullBodySchema, null, message); +} + export function errorResponse(code: number, message: string, data: unknown = null) { return HttpResponse.json({ code, @@ -44,9 +59,6 @@ export function errorResponse(code: number, message: string, data: unknown = nul }); } -/** - * Predefined error codes - */ export const ERROR_CODES = { INVALID_CREDENTIALS: 1001, NOT_FOUND: 1002, diff --git a/apps/basic/src/mocks/data.ts b/apps/basic/src/mocks/data.ts index 22ed8e5..1d39ccd 100644 --- a/apps/basic/src/mocks/data.ts +++ b/apps/basic/src/mocks/data.ts @@ -1,7 +1,6 @@ import type { User } from "@/api/schemas"; import { vercelAvatarUrl } from "./utils"; -/** Demo logins look like real org accounts (first.last @ northstar.io). Index 0 stays `admin` for mock login. */ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [ ["admin", "ops.admin@northstar.io"], ["zhao.ming", "zhao.ming@northstar.io"], @@ -16,6 +15,14 @@ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [ ["mia.silva", "mia.silva@northstar.io"], ]; +export const GUEST_AUTH_USER_BODY = { + id: "99", + username: "guest", + avatar: null as null, + email: "guest@example.com", + roles: [] as string[], +}; + export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({ id: String(i + 1), username, diff --git a/apps/basic/src/mocks/handlers/auth.ts b/apps/basic/src/mocks/handlers/auth.ts index 6af2709..8976cc7 100644 --- a/apps/basic/src/mocks/handlers/auth.ts +++ b/apps/basic/src/mocks/handlers/auth.ts @@ -1,16 +1,48 @@ import { http } from "msw"; -import { MOCK_USERS } from "../data"; -import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler"; +import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data"; +import { + withDelay, + errorResponse, + ERROR_CODES, + successWithSchema, + successWithNullBody, +} from "../createHandler"; +import { + AuthTokensSchema, + AuthUserResponseSchema, + LoginRequestSchema, + PermissionsListSchema, + RefreshTokenRequestSchema, +} from "@/api/schemas"; + +const GUEST_REFRESH = "mock-guest-refresh"; + +function isGuestAuth(authorization: string | null): boolean { + return (authorization ?? "").includes("mock-guest-access"); +} export const authHandlers = [ http.post("/api/auth/login", async ({ request }) => { await withDelay(300); - const body = (await request.json()) as { - username: string; - password: string; - }; - if (body.username === "admin" && body.password === "admin") { - return successResponse({ + let json: unknown; + try { + json = await request.json(); + } catch { + return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body"); + } + const parsed = LoginRequestSchema.safeParse(json); + if (!parsed.success) { + return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body"); + } + const { username, password } = parsed.data; + if (username === "guest" && password === "guest") { + return successWithSchema(AuthTokensSchema, { + accessToken: "mock-guest-access", + refreshToken: GUEST_REFRESH, + }); + } + if (username === "admin" && password === "admin") { + return successWithSchema(AuthTokensSchema, { accessToken: "mock-access-token", refreshToken: "mock-refresh-token", }); @@ -18,20 +50,46 @@ export const authHandlers = [ return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password"); }), - http.post("/api/auth/refresh", async () => { + http.post("/api/auth/refresh", async ({ request }) => { await withDelay(100); - return successResponse({ + let json: unknown; + try { + json = await request.json(); + } catch { + return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body"); + } + const parsed = RefreshTokenRequestSchema.safeParse(json); + if (!parsed.success) { + return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required"); + } + if (parsed.data.refreshToken === GUEST_REFRESH) { + return successWithSchema(AuthTokensSchema, { + accessToken: "mock-guest-access-refreshed", + refreshToken: "mock-guest-refresh-refreshed", + }); + } + return successWithSchema(AuthTokensSchema, { accessToken: "mock-new-access-token", refreshToken: "mock-new-refresh-token", }); }), - http.post("/api/auth/logout", () => successResponse(null)), + http.post("/api/auth/logout", () => successWithNullBody()), - http.get("/api/auth/user", () => { + http.get("/api/auth/user", ({ request }) => { + const auth = request.headers.get("authorization"); + if (isGuestAuth(auth)) { + return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY); + } const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!; - return successResponse(userWithoutPermissions); + return successWithSchema(AuthUserResponseSchema, userWithoutPermissions); }), - http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)), + http.get("/api/auth/permissions", ({ request }) => { + const auth = request.headers.get("authorization"); + if (isGuestAuth(auth)) { + return successWithSchema(PermissionsListSchema, [] as string[]); + } + return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions); + }), ]; diff --git a/apps/basic/src/mocks/handlers/user.ts b/apps/basic/src/mocks/handlers/user.ts index 97679c6..bc81598 100644 --- a/apps/basic/src/mocks/handlers/user.ts +++ b/apps/basic/src/mocks/handlers/user.ts @@ -1,13 +1,27 @@ -import { http } from "msw"; +import { http, HttpResponse } from "msw"; +import { UserSchema } from "@/api/schemas"; import { MOCK_USERS } from "../data"; import { filterUsers, paginateList, parsePaginationParams } from "../utils"; -import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler"; +import { + withDelay, + errorResponse, + ERROR_CODES, + paginatedWithSchema, + successWithSchema, + successWithNullBody, +} from "../createHandler"; let users = [...MOCK_USERS]; +export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__"; + export const userHandlers = [ http.get("/api/users", async ({ request }) => { await withDelay(200); + const auth = request.headers.get("authorization"); + if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) { + return new HttpResponse(null, { status: 401 }); + } const url = new URL(request.url); const { limit, offset } = parsePaginationParams(url.searchParams); const keyword = url.searchParams.get("keyword") ?? ""; @@ -16,7 +30,7 @@ export const userHandlers = [ const filtered = filterUsers(users, { keyword, role }); const list = paginateList(filtered, limit, offset); - return successResponse({ list, total: filtered.length }); + return paginatedWithSchema(UserSchema, { list, total: filtered.length }); }), http.post("/api/users", async ({ request }) => { @@ -28,10 +42,10 @@ export const userHandlers = [ avatar: null, email: typeof body.email === "string" ? body.email : null, roles: (body.roles as string[]) ?? [], - permissions: [], + permissions: [] as string[], }; users.push(newUser); - return successResponse(newUser); + return successWithSchema(UserSchema, newUser); }), http.put("/api/users/:id", async ({ params, request }) => { @@ -41,13 +55,13 @@ export const userHandlers = [ if (idx === -1) { return errorResponse(ERROR_CODES.NOT_FOUND, "User not found"); } - users[idx] = { ...users[idx], ...body }; - return successResponse(users[idx]); + users[idx] = { ...users[idx], ...body } as (typeof users)[number]; + return successWithSchema(UserSchema, users[idx]); }), http.delete("/api/users/:id", async ({ params }) => { await withDelay(200); users = users.filter((u) => u.id !== params.id); - return successResponse(null); + return successWithNullBody(); }), ]; diff --git a/apps/basic/src/routes/__root.tsx b/apps/basic/src/routes/__root.tsx index 30ea5a0..a1725ed 100644 --- a/apps/basic/src/routes/__root.tsx +++ b/apps/basic/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect } from "react"; +import { useEffect, useMemo } from "react"; import { createRootRoute, Outlet } from "@tanstack/react-router"; import { ConfigProvider, App } from "antd"; import enUS from "antd/locale/en_US"; @@ -6,19 +6,34 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useSettingsStore } from "@/stores/settings"; import { useAppTheme } from "@/hooks/useAppTheme"; import { NotFound } from "@/components/NotFound"; +import { RouteError } from "@/components/RouteError"; import "@/index.css"; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: 1, refetchOnWindowFocus: false }, - }, -}); +function AppQueryBridge({ children }: { children: React.ReactNode }) { + const { message } = App.useApp(); + const queryClient = useMemo( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: 1, refetchOnWindowFocus: false }, + mutations: { + onError: (err) => { + message.error(err instanceof Error ? err.message : String(err)); + }, + }, + }, + }), + [message], + ); + + return {children}; +} function RootComponent() { const darkMode = useSettingsStore((s) => s.darkMode); const configProviderProps = useAppTheme(); - useLayoutEffect(() => { + useEffect(() => { document.documentElement.lang = "en"; }, []); @@ -27,17 +42,18 @@ function RootComponent() { }, [darkMode]); return ( - - - + + + - - - + + + ); } export const Route = createRootRoute({ component: RootComponent, notFoundComponent: NotFound, + errorComponent: RouteError, }); diff --git a/apps/basic/src/routes/_auth.tsx b/apps/basic/src/routes/_auth.tsx index 2265dd4..3417be4 100644 --- a/apps/basic/src/routes/_auth.tsx +++ b/apps/basic/src/routes/_auth.tsx @@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { useAuthStore } from "@/stores/auth"; import { MainLayout } from "@/components/Layout/MainLayout"; import { canAccessPath, normalizeAppPath } from "@/utils/appMenu"; +import { fetchSessionAndApplyToStore } from "@/utils/session"; export const Route = createFileRoute("/_auth")({ - beforeLoad: ({ location }) => { - const { isAuthenticated, user } = useAuthStore.getState(); + beforeLoad: async ({ location }) => { + const { isAuthenticated, user, tokens } = useAuthStore.getState(); if (!isAuthenticated) { throw redirect({ to: "/login" }); } + if (tokens && !user) { + try { + await fetchSessionAndApplyToStore(); + } catch { + useAuthStore.getState().logout(); + throw redirect({ to: "/login" }); + } + } + + const { user: nextUser } = useAuthStore.getState(); const path = normalizeAppPath(location.pathname); if (path === "/403") return; - if (!canAccessPath(location.pathname, user?.permissions)) { + if (!canAccessPath(location.pathname, nextUser?.permissions)) { throw redirect({ to: "/403" }); } }, diff --git a/apps/basic/src/routes/_auth/users/index.tsx b/apps/basic/src/routes/_auth/users/index.tsx index 4752756..11f8895 100644 --- a/apps/basic/src/routes/_auth/users/index.tsx +++ b/apps/basic/src/routes/_auth/users/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd"; import type { TablePaginationConfig } from "antd/es/table/interface"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { httpClient } from "@/utils/http"; import { USER_ENDPOINTS } from "@/api/user"; import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas"; @@ -10,6 +10,9 @@ import { z } from "zod/v4"; import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { DataTable } from "@/components/DataTable"; import { useResourceCRUD } from "@/hooks/useResourceCRUD"; +import { useTableFitHeight } from "@/hooks/useTableFitHeight"; +import { useCrudToasts } from "@/hooks/useCrudToasts"; +import { useUrlSearchState } from "@/hooks/useUrlSearchState"; import { Toolbar } from "./-Toolbar"; import { FormModal } from "./-FormModal"; @@ -22,6 +25,8 @@ const UserSearchParamsSchema = z.object({ role: z.string().catch(""), }); +type UserSearch = z.infer; + export const Route = createFileRoute("/_auth/users/")({ validateSearch: (search) => UserSearchParamsSchema.parse(search), component: UsersPage, @@ -29,10 +34,6 @@ export const Route = createFileRoute("/_auth/users/")({ const paginatedUserSchema = PaginatedResponseSchema(UserSchema); -const MESSAGE_KEY_USER_CREATE = "user-mutation-create"; -const MESSAGE_KEY_USER_UPDATE = "user-mutation-update"; -const MESSAGE_KEY_USER_DELETE = "user-mutation-delete"; - function UsersPage() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); @@ -43,27 +44,23 @@ function UsersPage() { const toolbarRowRef = useRef(null); const middleSectionRef = useRef(null); const tableFrameRef = useRef(null); - const tableAvailableRef = useRef(0); - /** - * If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto) - * and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861). - */ - const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState(() => - typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined, - ); - const [tableScrollY, setTableScrollY] = useState(() => { - if (typeof window === "undefined") return undefined; - const maxH = Math.max(240, Math.floor(window.innerHeight - 280)); - return Math.max(120, maxH - 40); - }); const [editingUser, setEditingUser] = useState(null); const [form] = Form.useForm(); - const [keywordInput, setKeywordInput] = useState(search.keyword); const currentPage = Math.floor(search.offset / search.limit) + 1; - useEffect(() => { - setKeywordInput(search.keyword); - }, [search.keyword]); + const setSearch = (next: UserSearch) => { + void navigate({ search: next }); + }; + + const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({ + search, + setSearch, + }); + + const crudToasts = useCrudToasts({ + message, + resourceKey: "users", + }); const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD< { list: User[]; total: number }, @@ -79,7 +76,6 @@ function UsersPage() { search.sortField, search.sortOrder, ], - invalidateKey: ["users"], queryFn: () => httpClient.get(USER_ENDPOINTS.list, { params: { @@ -96,66 +92,29 @@ function UsersPage() { httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)), updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values), deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)), + optimistic: { update: true, delete: true }, createLifecycle: { - onSuccess: () => { - message.success({ - content: "Created successfully", - key: MESSAGE_KEY_USER_CREATE, - }); + onSuccess: (values) => { + crudToasts.createLifecycle?.onSuccess?.(values); setModalOpen(false); form.resetFields(); }, - onError: () => { - message.error({ - content: "Create failed", - key: MESSAGE_KEY_USER_CREATE, - }); - }, + onError: crudToasts.createLifecycle?.onError, }, updateLifecycle: { - onMutate: () => { - message.loading({ - content: "Updating…", - key: MESSAGE_KEY_USER_UPDATE, - duration: 0, - }); - }, - onSuccess: () => { - message.success({ - content: "Updated successfully", - key: MESSAGE_KEY_USER_UPDATE, - }); + onMutate: crudToasts.updateLifecycle?.onMutate, + onSuccess: (values) => { + crudToasts.updateLifecycle?.onSuccess?.(values); setModalOpen(false); setEditingUser(null); form.resetFields(); }, - onError: () => { - message.error({ - content: "Update failed", - key: MESSAGE_KEY_USER_UPDATE, - }); - }, + onError: crudToasts.updateLifecycle?.onError, }, deleteLifecycle: { - onMutate: () => { - message.loading({ - content: "Deleting…", - key: MESSAGE_KEY_USER_DELETE, - duration: 0, - }); - }, - onSuccess: () => { - message.success({ - content: "Deleted successfully", - key: MESSAGE_KEY_USER_DELETE, - }); - }, - onError: () => { - message.error({ - content: "Delete failed", - key: MESSAGE_KEY_USER_DELETE, - }); - }, + onMutate: crudToasts.deleteLifecycle?.onMutate, + onSuccess: crudToasts.deleteLifecycle?.onSuccess, + onError: crudToasts.deleteLifecycle?.onError, }, }); @@ -268,118 +227,28 @@ function UsersPage() { }} placement="bottomRight" > -