diff --git a/.cursor/rules/english-only-comments.md b/.cursor/rules/english-only-comments.md new file mode 100644 index 0000000..19c0245 --- /dev/null +++ b/.cursor/rules/english-only-comments.md @@ -0,0 +1,26 @@ +--- +description: All code comments must be written in English +alwaysApply: true +--- + +# English-only comments + +- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `` in templates, and similar. +- **Do not** add or keep Chinese (or other non-English) text in comments when editing files. +- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**. + +```typescript +// BAD — non-English comment +// Initialiser le cache (example: any non-English language) + +// GOOD +// Initialize the cache from the last session snapshot. +``` + +```tsx +// BAD +{/* Chargement… */} + +// GOOD +{/* Loading spinner */} +``` diff --git a/.cursor/rules/with-lingui-add-resource.mdc b/.cursor/rules/with-lingui-add-resource.mdc new file mode 100644 index 0000000..21272da --- /dev/null +++ b/.cursor/rules/with-lingui-add-resource.mdc @@ -0,0 +1,15 @@ +--- +description: "Add a new CRUD resource / scaffold (with-lingui)." +globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts +alwaysApply: false +--- + +# with-lingui · Add resource (thin rule) + +**Single source of truth:** + +`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. + +`@apps/with-lingui/.github/instructions/add-resource.instructions.md` diff --git a/.cursor/rules/with-lingui-api.mdc b/.cursor/rules/with-lingui-api.mdc new file mode 100644 index 0000000..0df0532 --- /dev/null +++ b/.cursor/rules/with-lingui-api.mdc @@ -0,0 +1,15 @@ +--- +description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)." +globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts +alwaysApply: false +--- + +# with-lingui · API(薄规则) + +**唯一细则来源**: + +`apps/with-lingui/.github/instructions/api.instructions.md` + +编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。 + +`@apps/with-lingui/.github/instructions/api.instructions.md` diff --git a/.cursor/rules/with-lingui-frontend.mdc b/.cursor/rules/with-lingui-frontend.mdc new file mode 100644 index 0000000..f848906 --- /dev/null +++ b/.cursor/rules/with-lingui-frontend.mdc @@ -0,0 +1,15 @@ +--- +description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)." +globs: apps/with-lingui/src/**/*.{ts,tsx,css} +alwaysApply: false +--- + +# with-lingui · Frontend(薄规则) + +**唯一细则来源**(勿在此文件重复维护长文): + +`apps/with-lingui/.github/instructions/frontend.instructions.md` + +编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。 + +需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。 diff --git a/.cursor/rules/with-lingui-refactor.mdc b/.cursor/rules/with-lingui-refactor.mdc new file mode 100644 index 0000000..5963e5c --- /dev/null +++ b/.cursor/rules/with-lingui-refactor.mdc @@ -0,0 +1,15 @@ +--- +description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)." +globs: apps/with-lingui/src/**/*.{ts,tsx} +alwaysApply: false +--- + +# with-lingui · Refactor(薄规则) + +**唯一细则来源**: + +`apps/with-lingui/.github/instructions/refactor.instructions.md` + +做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。 + +`@apps/with-lingui/.github/instructions/refactor.instructions.md` diff --git a/.cursor/rules/with-lingui-testing.mdc b/.cursor/rules/with-lingui-testing.mdc new file mode 100644 index 0000000..ea60c0a --- /dev/null +++ b/.cursor/rules/with-lingui-testing.mdc @@ -0,0 +1,15 @@ +--- +description: "Playwright e2e, MSW mocks, test config (with-lingui)." +globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts +alwaysApply: false +--- + +# with-lingui · Testing(薄规则) + +**唯一细则来源**: + +`apps/with-lingui/.github/instructions/testing.instructions.md` + +编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。 + +`@apps/with-lingui/.github/instructions/testing.instructions.md` diff --git a/.github/workflows/with-lingui-ci.yml b/.github/workflows/with-lingui-ci.yml new file mode 100644 index 0000000..048525c --- /dev/null +++ b/.github/workflows/with-lingui-ci.yml @@ -0,0 +1,47 @@ +name: with-lingui + +on: + push: + paths: + - "apps/with-lingui/**" + - ".github/workflows/with-lingui-ci.yml" + - "pnpm-lock.yaml" + pull_request: + paths: + - "apps/with-lingui/**" + - ".github/workflows/with-lingui-ci.yml" + - "pnpm-lock.yaml" + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + working-directory: apps/with-lingui + run: pnpm exec playwright install chromium + + - name: Typecheck and lint + working-directory: apps/with-lingui + run: pnpm exec vp check --no-fmt + + - name: E2E (core) + working-directory: apps/with-lingui + run: pnpm run test:e2e:core + + - name: Build + working-directory: apps/with-lingui + run: pnpm run build diff --git a/.gitignore b/.gitignore index 2f18189..f63e72f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ yarn-error.log* # Git worktrees .worktrees/ -.cursor + +# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions) +.cursor/* +!.cursor/rules/ +!.cursor/rules/** ./docs/superpowers diff --git a/apps/basic/.github/instructions/api.instructions.md b/apps/basic/.github/instructions/api.instructions.md index 7ece6bd..4b98d73 100644 --- a/apps/basic/.github/instructions/api.instructions.md +++ b/apps/basic/.github/instructions/api.instructions.md @@ -1,7 +1,7 @@ --- -applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts" + +## applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts" description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users." ---- # API Instructions @@ -26,4 +26,4 @@ Maintain strict API contracts and consistent server/client mock behavior. ## Validation - Verify TypeScript and schema parsing paths compile cleanly. -- Run: `vp check --no-fmt` +- Run: `vp check --no-fmt` \ No newline at end of file diff --git a/apps/basic/.github/instructions/frontend.instructions.md b/apps/basic/.github/instructions/frontend.instructions.md index f58a288..36f05ca 100644 --- a/apps/basic/.github/instructions/frontend.instructions.md +++ b/apps/basic/.github/instructions/frontend.instructions.md @@ -1,7 +1,7 @@ --- -applyTo: "src/**/*.{ts,tsx,css}" + +## applyTo: "src/**/*.{ts,tsx,css}" description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n." ---- # Frontend Instructions @@ -28,4 +28,4 @@ Keep frontend changes predictable, minimal, and reusable for AI-assisted edits. ## Validation - Run: `vp check --no-fmt` -- For UI flow changes, run focused e2e where possible. +- For UI flow changes, run focused e2e where possible. \ No newline at end of file diff --git a/apps/basic/.github/instructions/refactor.instructions.md b/apps/basic/.github/instructions/refactor.instructions.md index 03cf607..66e9042 100644 --- a/apps/basic/.github/instructions/refactor.instructions.md +++ b/apps/basic/.github/instructions/refactor.instructions.md @@ -1,6 +1,6 @@ --- -## applyTo: "src/\*_/_.{ts,tsx}" +## applyTo: "src/*/*.{ts,tsx}" description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability." @@ -34,4 +34,4 @@ Reduce duplication while preserving behavior and public contracts. - Confirm no new diagnostics in touched files. - Run: `vp check --no-fmt` -- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"` +- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"` \ No newline at end of file diff --git a/apps/basic/.github/instructions/testing.instructions.md b/apps/basic/.github/instructions/testing.instructions.md index 10dcfce..c1ce878 100644 --- a/apps/basic/.github/instructions/testing.instructions.md +++ b/apps/basic/.github/instructions/testing.instructions.md @@ -1,6 +1,6 @@ --- -## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts" +## applyTo: "tests/**/.ts,tests/**/*.tsx,src/mocks//*.ts,playwright.config.ts" description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage." @@ -27,4 +27,4 @@ Keep tests fast, deterministic, and aligned with core business flows. ## Validation - Run focused tests first, then broader checks if needed. -- Run: `vp check --no-fmt` +- Run: `vp check --no-fmt` \ No newline at end of file diff --git a/apps/basic/AGENTS.md b/apps/basic/AGENTS.md index 49ea51f..77fb37a 100644 --- a/apps/basic/AGENTS.md +++ b/apps/basic/AGENTS.md @@ -81,4 +81,4 @@ This repository also uses scoped AI instruction files in `.github/instructions/` - `api.instructions.md` for API/schema/handler changes - `refactor.instructions.md` for behavior-preserving cleanup -See `.github/instructions/README.md` for usage and authoring rules. +See `.github/instructions/README.md` for usage and authoring rules. \ No newline at end of file diff --git a/apps/with-lingui/.github/instructions/README.md b/apps/with-lingui/.github/instructions/README.md index 27850d7..e85b35e 100644 --- a/apps/with-lingui/.github/instructions/README.md +++ b/apps/with-lingui/.github/instructions/README.md @@ -8,6 +8,7 @@ This directory contains scoped instruction files used by AI coding agents. - `testing.instructions.md`: Playwright/MSW/test changes - `api.instructions.md`: API client/schema/handler changes - `refactor.instructions.md`: behavior-preserving cleanup and deduplication +- `add-resource.instructions.md`: add a new CRUD resource (manual / Agent checklist; mirror `users` + `orders`) ## Authoring Rules diff --git a/apps/with-lingui/.github/instructions/add-resource.instructions.md b/apps/with-lingui/.github/instructions/add-resource.instructions.md new file mode 100644 index 0000000..7de54e5 --- /dev/null +++ b/apps/with-lingui/.github/instructions/add-resource.instructions.md @@ -0,0 +1,69 @@ +--- +applyTo: "src/api/**/*.ts,src/mocks/**/*.ts,src/routes/_auth/**/*.tsx,e2e/**/*.spec.ts" +description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page." +--- + +# Add Resource Recipe + +Add a list CRUD flow in `apps/with-lingui` that mirrors **Users** (TanStack Router + Query + MSW + Lingui). There is **no** codegen script: **copy and adapt by hand** using the steps below, or have an Agent follow this checklist. Primary references: `src/routes/_auth/users/`, the sample `src/routes/_auth/orders/`, and the matching `e2e/*.spec.ts` files. + +## 0. Verification commands (after your changes) + +```bash +cd apps/with-lingui +pnpm run build # refresh TanStack routeTree when routes change +pnpm exec vp check --no-fmt +pnpm run test:e2e -- e2e/.spec.ts +``` + +## 1. Zod and types (`src/api/schemas.ts`) + +- Define `Schema`, `CreateRequestSchema`, `UpdateRequestSchema` (`Update` = `Create.partial()`). +- Reuse `PaginatedResponseSchema(Schema)` for list responses. +- Export `type = z.infer`. + +## 2. Endpoint constants (`src/api/.ts`) + +- Export `_ENDPOINTS`: `list` / `create` / `update(id)` / `delete(id)` with paths like `/api/`. + +## 3. Mock data (`src/mocks/data.ts`) + +- Append a `MOCK_` array. For **admin** users, `permissions` must include `:view` (and create/edit/delete if you add writes), aligned with `GET /api/auth/permissions`. + +## 4. MSW (`src/mocks/handlers/.ts` + `handlers/index.ts`) + +- Follow `handlers/user.ts` / `handlers/orders.ts`: paginated `GET` + optional `keyword`, `POST` / `PUT` / `DELETE`; use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` for success and `errorResponse` for errors. +- Register `...Handlers` in `handlers/index.ts`. + +## 5. Route page (`src/routes/_auth//`) + +- `createFileRoute("/_auth//")`, wiring `useResourceCRUD` (recommended: `optimistic: { update: true, delete: true }`), `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`). +- **Lingui**: run `pnpm run i18n:extract && pnpm run i18n:compile` for new copy (or `vp exec lingui …`). + +## 6. Menu and permissions (`src/utils/appMenu.ts`) + +- Add a menu entry to `APP_MENU_TREE`: `path: "/"`, `permissions: [":view"]`, `icon` using a Sidebar-registered key such as `IconLucidePackage` (see existing `IconLucide*` entries in `Sidebar`). +- Keep behavior consistent with `canAccessPath` / 403. + +## 6b. Sidebar labels and icons (`src/components/Layout/Sidebar/index.tsx`) + +- In `MENU_LABELS`, add a Lingui `msg` entry for the menu item `name` (must match the `name` string from `appMenu`). +- In `MENU_ICON_MAP`, map the new `icon` key to a `lucide-react` icon and add the matching `import`. +- Then run `pnpm run i18n:extract && pnpm run i18n:compile` and fill in `zh` (and other locale) translations. + +> In this template repo, the `orders` sample is already aligned with the Sidebar next to `appMenu`. When adding a resource from scratch, double-check this section. + +## 7. E2E (`e2e/.spec.ts`) + +- Copy structure from `users.spec.ts` / `orders.spec.ts`: `loginAsAdmin` → `goto /`, assert headers / search placeholder, etc. + +## 8. Verification + +- `pnpm exec vp check --no-fmt` +- `pnpm run test:e2e:core` (if you add the new spec to core) or `pnpm exec playwright test e2e/.spec.ts` +- `pnpm run build` and watch chunk sizes in the log + +## Notes + +- Before adding or renaming files, confirm nothing already exists at that path so you do not overwrite hand-written code. +- After manual schema edits, treat the diff as source of truth; there is no automatic merge. diff --git a/apps/with-lingui/.github/instructions/api.instructions.md b/apps/with-lingui/.github/instructions/api.instructions.md index 7ece6bd..dbf2253 100644 --- a/apps/with-lingui/.github/instructions/api.instructions.md +++ b/apps/with-lingui/.github/instructions/api.instructions.md @@ -14,6 +14,7 @@ Maintain strict API contracts and consistent server/client mock behavior. - Keep request/response shapes schema-driven and type-safe. - Reuse endpoint constants and shared response helpers. - Keep handler response envelope consistent (`code`, `data`, `message`). +- MSW success payloads: use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` from `src/mocks/createHandler.ts` so responses match Zod contracts before `HttpResponse.json`. - Ensure mock handlers reflect real API behavior and error semantics. - Keep naming stable for endpoint keys and mutation/query keys. @@ -26,4 +27,5 @@ Maintain strict API contracts and consistent server/client mock behavior. ## Validation - Verify TypeScript and schema parsing paths compile cleanly. -- Run: `vp check --no-fmt` +- Run: `pnpm exec vp check --no-fmt` +- After changing `createHandler` or handler envelopes: `pnpm run test:unit` (includes `src/mocks/createHandler.test.ts`). diff --git a/apps/with-lingui/.github/instructions/frontend.instructions.md b/apps/with-lingui/.github/instructions/frontend.instructions.md index f58a288..263fda0 100644 --- a/apps/with-lingui/.github/instructions/frontend.instructions.md +++ b/apps/with-lingui/.github/instructions/frontend.instructions.md @@ -27,5 +27,5 @@ Keep frontend changes predictable, minimal, and reusable for AI-assisted edits. ## Validation -- Run: `vp check --no-fmt` +- Run: `pnpm exec vp check --no-fmt` - For UI flow changes, run focused e2e where possible. diff --git a/apps/with-lingui/.github/instructions/refactor.instructions.md b/apps/with-lingui/.github/instructions/refactor.instructions.md index 03cf607..de28975 100644 --- a/apps/with-lingui/.github/instructions/refactor.instructions.md +++ b/apps/with-lingui/.github/instructions/refactor.instructions.md @@ -1,8 +1,7 @@ --- - -## applyTo: "src/\*_/_.{ts,tsx}" - +applyTo: "src/**/*.{ts,tsx}" description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability." +--- # Refactor Instructions @@ -33,5 +32,5 @@ Reduce duplication while preserving behavior and public contracts. ## Validation - Confirm no new diagnostics in touched files. -- Run: `vp check --no-fmt` -- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"` +- Run: `pnpm exec vp check --no-fmt` +- For significant changes, verify bundle size impact: `pnpm run build 2>&1 | grep "kB"` diff --git a/apps/with-lingui/.github/instructions/testing.instructions.md b/apps/with-lingui/.github/instructions/testing.instructions.md index 10dcfce..76c844f 100644 --- a/apps/with-lingui/.github/instructions/testing.instructions.md +++ b/apps/with-lingui/.github/instructions/testing.instructions.md @@ -1,8 +1,7 @@ --- - -## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts" - +applyTo: "e2e/**/*.ts,e2e/**/*.tsx,src/mocks/**/*.ts,playwright.config.ts" description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage." +--- # Testing Instructions @@ -27,4 +26,5 @@ Keep tests fast, deterministic, and aligned with core business flows. ## Validation - Run focused tests first, then broader checks if needed. -- Run: `vp check --no-fmt` +- Run: `pnpm exec vp check --no-fmt` +- Unit / hook logic (Vitest): `pnpm run test:unit` — tests live under `src/**/*.test.ts`. diff --git a/apps/with-lingui/.gitignore b/apps/with-lingui/.gitignore index a96778c..ed748ed 100644 --- a/apps/with-lingui/.gitignore +++ b/apps/with-lingui/.gitignore @@ -28,7 +28,6 @@ src/locales/*/messages.js .agent .pnpm-store -.cursor .tanstack # Playwright diff --git a/apps/with-lingui/AGENTS.md b/apps/with-lingui/AGENTS.md index 49ea51f..5b23aa0 100644 --- a/apps/with-lingui/AGENTS.md +++ b/apps/with-lingui/AGENTS.md @@ -80,5 +80,6 @@ This repository also uses scoped AI instruction files in `.github/instructions/` - `testing.instructions.md` for Playwright/MSW/test updates - `api.instructions.md` for API/schema/handler changes - `refactor.instructions.md` for behavior-preserving cleanup +- `add-resource.instructions.md` for adding a new CRUD resource (checklist; mirror `users` / `orders`) -See `.github/instructions/README.md` for usage and authoring rules. +See `.github/instructions/README.md` for usage and authoring rules. \ No newline at end of file diff --git a/apps/with-lingui/README.md b/apps/with-lingui/README.md index ee2a31e..c74f4b0 100644 --- a/apps/with-lingui/README.md +++ b/apps/with-lingui/README.md @@ -10,6 +10,8 @@ Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, scoped AI instructions, and type-safe contracts at every boundary. +**AI rules (Cursor):** `.cursor/rules/with-lingui-*.mdc` at the repo root mirrors `apps/with-lingui/.github/instructions/*.instructions.md` (same intent; `globs` align with each file’s `applyTo`). Prefer updating **both** when you change guidance. + [![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design) [![react](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)](https://react.dev) [![vite](https://img.shields.io/badge/Vite+-Latest-646cff?style=flat-square&logo=vite)](https://viteplus.dev) @@ -80,16 +82,18 @@ vp run test:e2e:core vp run test:e2e:ui # interactive UI mode ``` -`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD. +`test:e2e:core` runs the scaffold's highest-value flows: login, users CRUD, auth refresh, RBAC, URL state (see `package.json` script list). ## Project Structure ``` +.github/instructions/ # Scoped AI recipes (mirrored in ../../.cursor/rules/with-lingui-*.mdc) src/ ├── api/ # Zod schemas, endpoint constants, type exports │ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.) │ ├── auth.ts # Auth endpoint constants -│ └── user.ts # User CRUD endpoint constants +│ ├── user.ts # User CRUD endpoint constants +│ └── orders.ts # Example second resource API (mirror `user.ts`) ├── components/ │ ├── Aurora/ # Login background effect │ ├── Auth/ # Auth (permission gate): index.tsx @@ -119,12 +123,13 @@ src/ │ ├── createHandler.ts # Shared MSW success/error/delay helpers │ ├── data.ts # Mock seed data (users, menus) │ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs) -│ └── handlers/ # Request handlers (auth, user CRUD) +│ └── handlers/ # Request handlers (auth, users, orders, …) ├── routes/ # TanStack Router file-based routes │ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n) │ ├── _auth.tsx # Auth guard layout (redirects to /login) │ ├── _auth/dashboard/index.tsx │ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params +│ ├── _auth/orders/ # Example second resource (mirror users CRUD) │ ├── _auth/403/index.tsx │ ├── login/index.tsx │ ├── 404/index.tsx @@ -135,11 +140,12 @@ src/ │ └── settings.ts # Settings store (darkMode, locale, sidebar) └── main.tsx # Entry point (MSW init → React render) -tests/ -└── e2e/ # Playwright E2E tests - ├── helpers.ts - ├── login.spec.ts - └── users.spec.ts +e2e/ # Playwright E2E tests +├── helpers.ts +├── login.spec.ts +├── users.spec.ts +└── orders.spec.ts # Example resource smoke test + ``` ## Internationalization (i18n) @@ -157,9 +163,16 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx` ## Extending the template +- **New CRUD resource:** See `.github/instructions/add-resource.instructions.md` (checklist; copy from `users` / `orders`), then `pnpm run i18n:extract && pnpm run i18n:compile`, wire Sidebar labels/icons if needed (§6b in that doc), and `pnpm exec vp check --no-fmt`. - **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks. - **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor). +### AI instruction files + +**细则单源**:`apps/with-lingui/.github/instructions/`(`*.instructions.md`),人类与 Agent 以该目录为准维护、评审。 + +**Cursor**:仓库根 `.cursor/rules/with-lingui-*.mdc` 为**薄规则**(方案 A):只写范围与指向路径,`globs` 与上述文件的 `applyTo` 对齐;全文请在对话中 `@` 对应 `.instructions.md` 或自行打开该文件。 + ## Developer Notes - Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`. @@ -173,6 +186,7 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx` | `/login` | Login form with validation | | `/dashboard` | Statistics overview cards | | `/users` | User CRUD table with search, pagination, create/edit/delete | +| `/orders` | Example orders list (title/status) — second CRUD sample alongside `users` | | `/403` | Forbidden error page | | `/404` | Not found error page | diff --git a/apps/with-lingui/e2e/auth-refresh.spec.ts b/apps/with-lingui/e2e/auth-refresh.spec.ts new file mode 100644 index 0000000..8f5be0f --- /dev/null +++ b/apps/with-lingui/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/with-lingui/e2e/helpers.ts b/apps/with-lingui/e2e/helpers.ts index ae5ec6b..f1ec09a 100644 --- a/apps/with-lingui/e2e/helpers.ts +++ b/apps/with-lingui/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/with-lingui/e2e/orders.spec.ts b/apps/with-lingui/e2e/orders.spec.ts new file mode 100644 index 0000000..ae1f182 --- /dev/null +++ b/apps/with-lingui/e2e/orders.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from "@playwright/test"; +import { loginAsAdmin } from "./helpers"; + +test.describe("Order list", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto("/orders"); + await expect(page.getByPlaceholder(/Search Order|搜索/)).toBeVisible({ timeout: 15_000 }); + }); + + test("should display table", async ({ page }) => { + await expect(page.locator(".ant-table-thead").getByText(/Title|标题/)).toBeVisible(); + }); +}); diff --git a/apps/with-lingui/e2e/rbac.spec.ts b/apps/with-lingui/e2e/rbac.spec.ts new file mode 100644 index 0000000..9763800 --- /dev/null +++ b/apps/with-lingui/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/with-lingui/e2e/url-state.spec.ts b/apps/with-lingui/e2e/url-state.spec.ts new file mode 100644 index 0000000..5bb531c --- /dev/null +++ b/apps/with-lingui/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/with-lingui/e2e/users.spec.ts b/apps/with-lingui/e2e/users.spec.ts index e187e5e..69bf5eb 100644 --- a/apps/with-lingui/e2e/users.spec.ts +++ b/apps/with-lingui/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/with-lingui/package.json b/apps/with-lingui/package.json index 62fd7d9..1e7534e 100644 --- a/apps/with-lingui/package.json +++ b/apps/with-lingui/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 tests/e2e/login.spec.ts tests/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", "i18n:extract": "vp exec lingui extract", "i18n:compile": "vp exec lingui compile" @@ -41,7 +42,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/with-lingui/playwright.config.ts b/apps/with-lingui/playwright.config.ts index b04afbf..361d1c2 100644 --- a/apps/with-lingui/playwright.config.ts +++ b/apps/with-lingui/playwright.config.ts @@ -1,12 +1,11 @@ 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); export default defineConfig({ - testDir: "./tests/e2e", + testDir: "./e2e", fullyParallel: true, forbidOnly: ci, retries: ci ? 2 : 0, diff --git a/apps/with-lingui/src/api/orders.ts b/apps/with-lingui/src/api/orders.ts new file mode 100644 index 0000000..1b7afaa --- /dev/null +++ b/apps/with-lingui/src/api/orders.ts @@ -0,0 +1,10 @@ +import type { CreateOrderRequest, UpdateOrderRequest, Order } from "./schemas"; + +export const ORDER_ENDPOINTS = { + list: "/api/orders", + create: "/api/orders", + update: (id: string) => `/api/orders/${id}`, + delete: (id: string) => `/api/orders/${id}`, +} as const; + +export type { CreateOrderRequest, UpdateOrderRequest, Order }; diff --git a/apps/with-lingui/src/api/schemas.ts b/apps/with-lingui/src/api/schemas.ts index 336b57b..31c0865 100644 --- a/apps/with-lingui/src/api/schemas.ts +++ b/apps/with-lingui/src/api/schemas.ts @@ -11,7 +11,6 @@ export const UserSchema = z.object({ export type User = z.infer; -/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */ export const AuthUserResponseSchema = UserSchema.omit({ permissions: true }); export type AuthUserResponse = z.infer; @@ -22,6 +21,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), @@ -129,3 +134,33 @@ export type CreateUserRequest = z.infer; export const UpdateUserRequestSchema = CreateUserRequestSchema.partial(); export type UpdateUserRequest = z.infer; + +export const CreateTestRequestSchema = z.object({ + title: z.string().min(1), + status: z.enum(["draft", "open", "done"]), +}); + +export type CreateTestRequest = z.infer; + +export const UpdateTestRequestSchema = CreateTestRequestSchema.partial(); + +export type UpdateTestRequest = z.infer; + +export const OrderSchema = z.object({ + id: z.string(), + title: z.string(), + status: z.enum(["draft", "open", "done"]), +}); + +export type Order = z.infer; + +export const CreateOrderRequestSchema = z.object({ + title: z.string().min(1), + status: z.enum(["draft", "open", "done"]), +}); + +export type CreateOrderRequest = z.infer; + +export const UpdateOrderRequestSchema = CreateOrderRequestSchema.partial(); + +export type UpdateOrderRequest = z.infer; diff --git a/apps/with-lingui/src/components/DataTable/DataTableEmpty.tsx b/apps/with-lingui/src/components/DataTable/DataTableEmpty.tsx index 029e2f3..a22b96b 100644 --- a/apps/with-lingui/src/components/DataTable/DataTableEmpty.tsx +++ b/apps/with-lingui/src/components/DataTable/DataTableEmpty.tsx @@ -3,9 +3,6 @@ import { useLingui } from "@lingui/react/macro"; import { BarChart3 } from "lucide-react"; import type { ReactElement } from "react"; -/** - * Table empty state: icon in soft tile + bold title + secondary description (dashboard-style). - */ export function DataTableEmpty(): ReactElement { const { t } = useLingui(); const { token } = theme.useToken(); diff --git a/apps/with-lingui/src/components/DataTable/DataTableSkeleton.tsx b/apps/with-lingui/src/components/DataTable/DataTableSkeleton.tsx index 9554425..20a048b 100644 --- a/apps/with-lingui/src/components/DataTable/DataTableSkeleton.tsx +++ b/apps/with-lingui/src/components/DataTable/DataTableSkeleton.tsx @@ -29,10 +29,6 @@ export type DataTableSkeletonProps = Pick< TableProps, "columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style" > & { - /** - * Fixed skeleton row count. When omitted, rows are derived from `scroll.y` (if numeric) - * or from the table frame height so the body roughly fills the visible area. - */ rowCount?: number; }; @@ -54,7 +50,6 @@ function isColumnGroup( return "children" in col && Array.isArray(col.children) && col.children.length > 0; } -/** ~ text icon button, capped small */ function actionSkeletonDiameter(col: ColumnType): number { if (typeof col.width === "number") { return Math.min(24, Math.max(20, col.width - 36)); diff --git a/apps/with-lingui/src/components/DataTable/index.tsx b/apps/with-lingui/src/components/DataTable/index.tsx index a8388bd..5a48327 100644 --- a/apps/with-lingui/src/components/DataTable/index.tsx +++ b/apps/with-lingui/src/components/DataTable/index.tsx @@ -6,24 +6,14 @@ import { DataTableEmpty } from "./DataTableEmpty"; import { DataTableSkeleton } from "./DataTableSkeleton"; import "./index.css"; -/** Merged onto `Table` `rootClassName` with the flex layout class. */ export const DATA_TABLE_ROOT_CLASS = "data-table__table"; export type DataTableProps = { - /** - * When the table uses `scroll.y`, set true so the frame does not shrink in the flex column - * and optional `frameHeight` can pin the outer height. - */ lockScrollHeight?: boolean; - /** Outer `max-height` (px) */ maxHeight?: number; - /** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */ frameHeight?: number; - /** Flex column wrapping frame + optional `bottomExtra` */ layoutRef?: Ref; - /** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */ frameRef?: Ref; - /** Optional content below the bordered table frame */ bottomExtra?: ReactNode; } & TableProps; diff --git a/apps/with-lingui/src/components/FilterToolbar/index.tsx b/apps/with-lingui/src/components/FilterToolbar/index.tsx index 0d67b72..33c241b 100644 --- a/apps/with-lingui/src/components/FilterToolbar/index.tsx +++ b/apps/with-lingui/src/components/FilterToolbar/index.tsx @@ -4,18 +4,14 @@ import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } f export type FilterToolbarSlot = { key: string; - /** Used to decide how many filters fit in one row before the rest go into the popover */ minWidth: number; children: React.ReactNode; }; export type FilterToolbarProps = { slots: FilterToolbarSlot[]; - /** Right side: primary actions (not collapsed) */ actions: React.ReactNode; - /** Reserve width for the “more filters” control when some slots are in the popover */ collapseTriggerReserve?: number; - /** Accessible label + optional popover title */ moreFiltersLabel: React.ReactNode; moreFiltersTitle?: React.ReactNode; }; diff --git a/apps/with-lingui/src/components/Layout/Header/index.tsx b/apps/with-lingui/src/components/Layout/Header/index.tsx index 5e849ca..182a925 100644 --- a/apps/with-lingui/src/components/Layout/Header/index.tsx +++ b/apps/with-lingui/src/components/Layout/Header/index.tsx @@ -22,10 +22,6 @@ function normalizePath(pathname: string): string { } export type HeaderProps = { - /** - * When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned. - * Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins). - */ showBreadcrumb?: boolean; }; diff --git a/apps/with-lingui/src/components/Layout/Sidebar/index.tsx b/apps/with-lingui/src/components/Layout/Sidebar/index.tsx index c59e653..e90dadc 100644 --- a/apps/with-lingui/src/components/Layout/Sidebar/index.tsx +++ b/apps/with-lingui/src/components/Layout/Sidebar/index.tsx @@ -9,6 +9,7 @@ import { CircleDashed, Folder, Home, + Package, PanelLeft, SlidersHorizontal, Star, @@ -26,12 +27,12 @@ import { UserMenu } from "../UserMenu"; import "./index.css"; const { Sider } = Layout; -/** API menu `name` → Lingui descriptors (extracted into catalogs). */ const MENU_LABELS: Record> = { Platform: msg`Platform`, Projects: msg`Projects`, Dashboard: msg`Dashboard`, Users: msg`Users`, + Orders: msg`Orders`, "Design Engineering": msg`Design Engineering`, "Sales & Marketing": msg`Sales & Marketing`, }; @@ -47,7 +48,6 @@ const MENU_ICON_MAP: Record = { IconLucideLayoutDashboard: Home, IconLucideUsers: User, IconLucideUserList: Users, - /** Back-compat for older menu payloads still using IconLucideHistory */ IconLucideHistory: Users, IconLucideStar: Star, IconLucideSettings: SlidersHorizontal, @@ -55,6 +55,7 @@ const MENU_ICON_MAP: Record = { IconLucideBookOpen: Book, IconLucideFolderKanban: Folder, IconLucideSparkles: Zap, + IconLucidePackage: Package, }; function renderMenuIcon(icon: string | null, size = 16) { @@ -179,7 +180,6 @@ export function Sidebar() { } }, [isMobile, setSidebarCollapsed]); - /** Merge open keys required by the route with any submenu the user already expanded. */ useEffect(() => { setOpenKeys((prev) => { const next = new Set(prev); @@ -219,7 +219,6 @@ export function Sidebar() { align={isCollapsed ? "center" : "stretch"} style={{ paddingBlock: token.paddingSM, - /* Collapsed rail is 64px: keep horizontal padding minimal so 40px brand / icons are not clipped by Sider overflow. */ paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM, minHeight: 64, flexShrink: 0, @@ -237,7 +236,6 @@ export function Sidebar() { minWidth: 0, maxWidth: "100%", boxSizing: "border-box", - /* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */ paddingInline: isCollapsed ? 0 : token.paddingXS, minHeight: 40, justifyContent: isCollapsed ? "center" : "flex-start", @@ -348,7 +346,12 @@ export function Sidebar() { background: "transparent", }} /> - + ); diff --git a/apps/with-lingui/src/components/Layout/UserMenu/index.tsx b/apps/with-lingui/src/components/Layout/UserMenu/index.tsx index 040a25c..f066de7 100644 --- a/apps/with-lingui/src/components/Layout/UserMenu/index.tsx +++ b/apps/with-lingui/src/components/Layout/UserMenu/index.tsx @@ -10,9 +10,10 @@ interface UserMenuProps { collapsed: boolean; user: User | null; userMenuItems: MenuProps["items"]; + accountMenuTriggerAriaLabel: string; } -export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) { +export function UserMenu({ collapsed, user, userMenuItems, accountMenuTriggerAriaLabel }: UserMenuProps) { const { token } = theme.useToken(); const avatarSrc = (user?.avatar ?? "").trim() || undefined; @@ -33,7 +34,12 @@ export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) { ); const moreButton = ( - + } + /> + + ); +} diff --git a/apps/with-lingui/src/hooks/tokenBuilders.ts b/apps/with-lingui/src/hooks/tokenBuilders.ts index 5c31292..1d67d58 100644 --- a/apps/with-lingui/src/hooks/tokenBuilders.ts +++ b/apps/with-lingui/src/hooks/tokenBuilders.ts @@ -1,10 +1,15 @@ import { theme } from "antd"; import type { ConfigProviderProps, ThemeConfig } from "antd"; -/** - * Common theme token builders - * Reduces duplication between light and dark theme definitions - */ +export function readBrandPrimary(): string { + const raw = import.meta.env.VITE_BRAND_PRIMARY as string | undefined; + if (raw && /^#[0-9a-fA-F]{6}$/.test(raw.trim())) { + return raw.trim(); + } + return "#1677ff"; +} + +const BRAND_PRIMARY = readBrandPrimary(); export const SHARED_DESIGN_TOKENS = { fontFamily: @@ -26,9 +31,6 @@ export const CONTROL_COMPONENTS = { }, } as const; -/** - * Calculate table row selected color based on theme algorithm - */ export function buildTableTokens(cfg: ThemeConfig) { const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter; return { @@ -37,9 +39,6 @@ export function buildTableTokens(cfg: ThemeConfig) { }; } -/** - * Build menu tokens for light theme - */ export const MENU_LIGHT = { itemSelectedBg: "rgba(0, 0, 0, 0.06)", itemSelectedColor: "rgba(0, 0, 0, 0.88)", @@ -53,9 +52,6 @@ export const MENU_LIGHT = { horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)", } as const; -/** - * Build menu tokens for dark theme - */ export const MENU_DARK = { itemSelectedBg: "rgba(255, 255, 255, 0.08)", itemSelectedColor: "rgba(255, 255, 255, 0.85)", @@ -69,12 +65,12 @@ export const MENU_DARK = { horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)", } as const; -/** - * Build light theme config - */ export function buildLightThemeConfig(): ConfigProviderProps { const lightSeed: ThemeConfig["token"] = { colorBgLayout: "#ffffff", + colorPrimary: BRAND_PRIMARY, + colorLink: BRAND_PRIMARY, + colorInfo: BRAND_PRIMARY, ...SHARED_DESIGN_TOKENS, }; @@ -94,11 +90,11 @@ export function buildLightThemeConfig(): ConfigProviderProps { }; } -/** - * Build dark theme config - */ export function buildDarkThemeConfig(): ConfigProviderProps { const darkSeed: ThemeConfig["token"] = { + colorPrimary: BRAND_PRIMARY, + colorLink: BRAND_PRIMARY, + colorInfo: BRAND_PRIMARY, ...SHARED_DESIGN_TOKENS, }; diff --git a/apps/with-lingui/src/hooks/useAppTheme.ts b/apps/with-lingui/src/hooks/useAppTheme.ts index 504e61d..f4fb596 100644 --- a/apps/with-lingui/src/hooks/useAppTheme.ts +++ b/apps/with-lingui/src/hooks/useAppTheme.ts @@ -3,10 +3,6 @@ import type { ConfigProviderProps } from "antd"; import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders"; import { useSettingsStore } from "@/stores/settings"; -/** - * Theme selection for ConfigProvider: light/dark share radius, font, control tweaks. - * @see https://ant.design/docs/react/customize-theme - */ export function useAppTheme(): ConfigProviderProps { const darkMode = useSettingsStore((s) => s.darkMode); diff --git a/apps/with-lingui/src/hooks/useCrudToasts.ts b/apps/with-lingui/src/hooks/useCrudToasts.ts new file mode 100644 index 0000000..ae52fc9 --- /dev/null +++ b/apps/with-lingui/src/hooks/useCrudToasts.ts @@ -0,0 +1,68 @@ +import { useMemo } from "react"; +import type { MessageInstance } from "antd/es/message/interface"; +import { useLingui } from "@lingui/react/macro"; + +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 { t } = useLingui(); + + const keys = useMemo( + () => ({ + create: `${resourceKey}-mutation-create`, + update: `${resourceKey}-mutation-update`, + delete: `${resourceKey}-mutation-delete`, + }), + [resourceKey], + ); + + return useMemo( + () => ({ + createLifecycle: { + onSuccess: () => { + message.success({ content: t`Created successfully`, key: keys.create }); + }, + onError: () => { + message.error({ content: t`Create failed`, key: keys.create }); + }, + }, + updateLifecycle: { + onMutate: () => { + message.loading({ content: t`Updating…`, key: keys.update, duration: 0 }); + }, + onSuccess: () => { + message.success({ content: t`Updated successfully`, key: keys.update }); + }, + onError: () => { + message.error({ content: t`Update failed`, key: keys.update }); + }, + }, + deleteLifecycle: { + onMutate: () => { + message.loading({ content: t`Deleting…`, key: keys.delete, duration: 0 }); + }, + onSuccess: () => { + message.success({ content: t`Deleted successfully`, key: keys.delete }); + }, + onError: () => { + message.error({ content: t`Delete failed`, key: keys.delete }); + }, + }, + }), + [keys.create, keys.delete, keys.update, message, t], + ); +} diff --git a/apps/with-lingui/src/hooks/useResourceCRUD.test.ts b/apps/with-lingui/src/hooks/useResourceCRUD.test.ts new file mode 100644 index 0000000..2d6f3e8 --- /dev/null +++ b/apps/with-lingui/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/with-lingui/src/hooks/useResourceCRUD.ts b/apps/with-lingui/src/hooks/useResourceCRUD.ts index 22d92f1..2078e9c 100644 --- a/apps/with-lingui/src/hooks/useResourceCRUD.ts +++ b/apps/with-lingui/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( + 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,59 @@ 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/with-lingui/src/hooks/useTableFitHeight.ts b/apps/with-lingui/src/hooks/useTableFitHeight.ts new file mode 100644 index 0000000..08aa764 --- /dev/null +++ b/apps/with-lingui/src/hooks/useTableFitHeight.ts @@ -0,0 +1,113 @@ +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/with-lingui/src/hooks/useUrlSearchState.ts b/apps/with-lingui/src/hooks/useUrlSearchState.ts new file mode 100644 index 0000000..3726687 --- /dev/null +++ b/apps/with-lingui/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/with-lingui/src/locales/en/messages.po b/apps/with-lingui/src/locales/en/messages.po index 886db19..d0cdf8b 100644 --- a/apps/with-lingui/src/locales/en/messages.po +++ b/apps/with-lingui/src/locales/en/messages.po @@ -17,62 +17,65 @@ msgstr "" #~ msgid "{n} rows" #~ msgstr "{n} rows" -#: src/routes/_auth/users/index.tsx:386 +#: src/routes/_auth/orders/index.tsx:186 +#: src/routes/_auth/users/index.tsx:262 msgid "{total} rows" msgstr "{total} rows" -#: src/routes/_auth/dashboard/index.tsx:35 +#: src/routes/_auth/dashboard/index.tsx:144 msgid "+180.1% from last month" msgstr "+180.1% from last month" -#: src/routes/_auth/dashboard/index.tsx:41 +#: src/routes/_auth/dashboard/index.tsx:150 msgid "+19% from last month" msgstr "+19% from last month" -#: src/routes/_auth/dashboard/index.tsx:29 +#: src/routes/_auth/dashboard/index.tsx:138 msgid "+20.1% from last month" msgstr "+20.1% from last month" -#: src/routes/_auth/dashboard/index.tsx:47 +#: src/routes/_auth/dashboard/index.tsx:156 msgid "+201 since last hour" msgstr "+201 since last hour" -#: src/components/Layout/Header/index.tsx:78 +#: src/components/Layout/Header/index.tsx:83 msgid "403" msgstr "403" -#: src/routes/_auth/dashboard/index.tsx:45 +#: src/routes/_auth/dashboard/index.tsx:154 msgid "Active Now" msgstr "Active Now" -#: src/routes/_auth/users/-FormModal.tsx:58 +#: src/routes/_auth/users/-FormModal.tsx:52 #: src/routes/_auth/users/-Toolbar.tsx:64 msgid "Admin" msgstr "Admin" -#: src/routes/_auth/users/index.tsx:164 +#: src/routes/_auth/orders/index.tsx:112 +#: src/routes/_auth/users/index.tsx:124 msgid "Are you absolutely sure?" msgstr "Are you absolutely sure?" -#: src/routes/login/index.tsx:185 +#: src/routes/login/index.tsx:181 msgid "Auto login" msgstr "Auto login" -#: src/components/NotFound/index.tsx:54 -#: src/routes/_auth/403/index.tsx:40 +#: src/components/NotFound/index.tsx:59 +#: src/routes/_auth/403/index.tsx:35 msgid "Back to Home" msgstr "Back to Home" -#: src/routes/_auth/users/-FormModal.tsx:32 -#: src/routes/_auth/users/index.tsx:168 +#: src/routes/_auth/orders/index.tsx:116 +#: src/routes/_auth/users/-FormModal.tsx:31 +#: src/routes/_auth/users/index.tsx:128 msgid "Cancel" msgstr "Cancel" -#: src/routes/_auth/dashboard/index.tsx:134 +#: src/routes/_auth/dashboard/index.tsx:250 msgid "Chart Placeholder" msgstr "Chart Placeholder" -#: src/routes/_auth/users/index.tsx:106 +#: src/hooks/useCrudToasts.ts:43 msgid "Create failed" msgstr "Create failed" @@ -80,53 +83,68 @@ msgstr "Create failed" msgid "Create User" msgstr "Create User" -#: src/routes/_auth/users/index.tsx:100 +#: src/hooks/useCrudToasts.ts:40 msgid "Created successfully" msgstr "Created successfully" -#: src/components/Layout/Header/index.tsx:74 -#: src/components/Layout/Header/index.tsx:87 -#: src/components/Layout/Header/index.tsx:91 -#: src/components/Layout/Sidebar/index.tsx:32 +#: src/components/Layout/Header/index.tsx:79 +#: src/components/Layout/Header/index.tsx:92 +#: src/components/Layout/Header/index.tsx:96 +#: src/components/Layout/Sidebar/index.tsx:34 msgid "Dashboard" msgstr "Dashboard" -#: src/routes/_auth/users/index.tsx:166 -#: src/routes/_auth/users/index.tsx:257 +#: src/routes/_auth/orders/index.tsx:114 +#: src/routes/_auth/orders/index.tsx:152 +#: src/routes/_auth/users/index.tsx:126 +#: src/routes/_auth/users/index.tsx:223 msgid "Delete" msgstr "Delete" -#: src/routes/_auth/users/index.tsx:156 +#: src/hooks/useCrudToasts.ts:65 msgid "Delete failed" msgstr "Delete failed" -#: src/routes/_auth/users/index.tsx:149 +#: src/hooks/useCrudToasts.ts:62 msgid "Deleted successfully" msgstr "Deleted successfully" -#: src/routes/_auth/users/index.tsx:142 +#: src/hooks/useCrudToasts.ts:59 msgid "Deleting…" msgstr "Deleting…" -#: src/components/Layout/Sidebar/index.tsx:35 +#: src/components/Layout/Sidebar/index.tsx:37 msgid "Design Engineering" msgstr "Design Engineering" -#: src/routes/_auth/users/index.tsx:247 +#: src/routes/_auth/orders/-FormModal.tsx:36 +msgid "Done" +msgstr "Done" + +#: src/routes/_auth/orders/-FormModal.tsx:34 +msgid "Draft" +msgstr "Draft" + +#: src/routes/_auth/orders/index.tsx:142 +#: src/routes/_auth/users/index.tsx:213 msgid "Edit" msgstr "Edit" +#: src/routes/_auth/orders/-FormModal.tsx:19 +msgid "Edit Order" +msgstr "Edit Order" + #: src/routes/_auth/users/-FormModal.tsx:29 msgid "Edit User" msgstr "Edit User" -#: src/routes/_auth/users/-FormModal.tsx:59 +#: src/routes/_auth/users/-FormModal.tsx:53 #: src/routes/_auth/users/-Toolbar.tsx:65 msgid "Editor" msgstr "Editor" -#: src/routes/_auth/users/-FormModal.tsx:63 -#: src/routes/_auth/users/index.tsx:202 +#: src/routes/_auth/users/-FormModal.tsx:57 +#: src/routes/_auth/users/index.tsx:168 msgid "Email" msgstr "Email" @@ -134,27 +152,34 @@ msgstr "Email" #~ msgid "Filter by role" #~ msgstr "Filter by role" -#: src/routes/login/index.tsx:192 +#: src/routes/login/index.tsx:188 msgid "Forgot password?" msgstr "Forgot password?" -#: src/components/NotFound/index.tsx:57 +#: src/components/NotFound/index.tsx:62 msgid "Go back" msgstr "Go back" -#: src/routes/login/index.tsx:68 +#: src/routes/login/index.tsx:54 msgid "Login failed" msgstr "Login failed" -#: src/routes/login/index.tsx:64 +#: src/routes/login/index.tsx:50 msgid "Login successful" msgstr "Login successful" +#: src/routes/_auth/orders/-Toolbar.tsx:54 +#: src/routes/_auth/orders/-Toolbar.tsx:55 #: src/routes/_auth/users/-Toolbar.tsx:92 #: src/routes/_auth/users/-Toolbar.tsx:93 msgid "More filters" msgstr "More filters" +#: src/routes/_auth/orders/-FormModal.tsx:19 +#: src/routes/_auth/orders/-Toolbar.tsx:51 +msgid "New Order" +msgstr "New Order" + #: src/routes/_auth/users/-FormModal.tsx:29 msgid "New User" msgstr "New User" @@ -167,11 +192,23 @@ msgstr "No data" msgid "Nothing to show in this list yet" msgstr "Nothing to show in this list yet" -#: src/routes/_auth/users/-FormModal.tsx:31 +#: src/routes/_auth/users/-FormModal.tsx:30 msgid "OK" msgstr "OK" -#: src/routes/_auth/dashboard/index.tsx:115 +#: src/routes/_auth/orders/-FormModal.tsx:35 +msgid "Open" +msgstr "Open" + +#: src/components/Layout/Sidebar/index.tsx:358 +msgid "Open account menu" +msgstr "Open account menu" + +#: src/components/Layout/Sidebar/index.tsx:36 +msgid "Orders" +msgstr "Orders" + +#: src/routes/_auth/dashboard/index.tsx:233 msgid "Overview" msgstr "Overview" @@ -179,56 +216,74 @@ msgstr "Overview" #~ msgid "Page not found" #~ msgstr "Page not found" -#: src/routes/login/index.tsx:171 +#: src/routes/login/index.tsx:162 +#: src/routes/login/index.tsx:168 msgid "Password" msgstr "Password" -#: src/components/Layout/Sidebar/index.tsx:30 +#: src/components/Layout/Sidebar/index.tsx:32 msgid "Platform" msgstr "Platform" -#: src/routes/login/index.tsx:172 +#: src/routes/login/index.tsx:163 msgid "Please enter password" msgstr "Please enter password" -#: src/routes/_auth/users/-FormModal.tsx:46 -#: src/routes/login/index.tsx:164 +#: src/routes/_auth/users/-FormModal.tsx:40 +#: src/routes/login/index.tsx:150 msgid "Please enter username" msgstr "Please enter username" -#: src/routes/_auth/users/-FormModal.tsx:53 +#: src/routes/_auth/users/-FormModal.tsx:47 msgid "Please select roles" msgstr "Please select roles" -#: src/components/Layout/AppFooter/index.tsx:36 +#: src/components/RouteError.tsx:17 +msgid "Please try again or return to the dashboard." +msgstr "Please try again or return to the dashboard." + +#: src/components/Layout/AppFooter/index.tsx:23 msgid "Powered by" msgstr "Powered by" -#: src/components/Layout/Sidebar/index.tsx:31 +#: src/components/Layout/Sidebar/index.tsx:33 msgid "Projects" msgstr "Projects" -#: src/routes/_auth/dashboard/index.tsx:142 +#: src/routes/_auth/dashboard/index.tsx:259 msgid "Recent Sales" msgstr "Recent Sales" +#: src/components/RouteError.tsx:20 +msgid "Retry" +msgstr "Retry" + #: src/routes/_auth/users/-Toolbar.tsx:58 msgid "Role" msgstr "Role" -#: src/routes/_auth/users/-FormModal.tsx:52 -#: src/routes/_auth/users/index.tsx:209 +#: src/routes/_auth/users/-FormModal.tsx:46 +#: src/routes/_auth/users/index.tsx:175 msgid "Roles" msgstr "Roles" -#: src/routes/_auth/dashboard/index.tsx:39 +#: src/routes/_auth/orders/index.tsx:160 +#: src/routes/_auth/users/index.tsx:234 +msgid "Row actions" +msgstr "Row actions" + +#: src/routes/_auth/dashboard/index.tsx:148 msgid "Sales" msgstr "Sales" -#: src/components/Layout/Sidebar/index.tsx:36 +#: src/components/Layout/Sidebar/index.tsx:38 msgid "Sales & Marketing" msgstr "Sales & Marketing" +#: src/routes/_auth/orders/-Toolbar.tsx:32 +msgid "Search Order" +msgstr "Search Order" + #: src/routes/_auth/users/-Toolbar.tsx:43 msgid "Search User" msgstr "Search User" @@ -237,7 +292,7 @@ msgstr "Search User" #~ msgid "Search username or email" #~ msgstr "Search username or email" -#: src/routes/login/index.tsx:204 +#: src/routes/login/index.tsx:200 msgid "Sign In" msgstr "Sign In" @@ -245,70 +300,89 @@ msgstr "Sign In" #~ msgid "Sign in with your credentials." #~ msgstr "Sign in with your credentials." -#: src/components/Layout/Sidebar/index.tsx:194 +#: src/components/Layout/Sidebar/index.tsx:197 msgid "Sign Out" msgstr "Sign Out" -#: src/components/NotFound/index.tsx:50 +#: src/components/RouteError.tsx:16 +msgid "Something went wrong" +msgstr "Something went wrong" + +#: src/components/NotFound/index.tsx:55 msgid "Sorry, the page you visited does not exist." msgstr "Sorry, the page you visited does not exist." -#: src/routes/_auth/403/index.tsx:37 +#: src/routes/_auth/403/index.tsx:32 msgid "Sorry, you don't have permission to access this page." msgstr "Sorry, you don't have permission to access this page." -#: src/routes/_auth/dashboard/index.tsx:33 +#: src/routes/_auth/orders/-FormModal.tsx:31 +#: src/routes/_auth/orders/index.tsx:125 +msgid "Status" +msgstr "Status" + +#: src/routes/_auth/dashboard/index.tsx:142 msgid "Subscriptions" msgstr "Subscriptions" -#: src/components/Layout/Header/index.tsx:145 -#: src/routes/login/index.tsx:225 +#: src/components/Layout/Header/index.tsx:153 +#: src/routes/login/index.tsx:221 msgid "Switch language" msgstr "Switch language" -#: src/routes/_auth/users/index.tsx:165 +#: src/routes/_auth/orders/index.tsx:113 +msgid "This action cannot be undone." +msgstr "This action cannot be undone." + +#: src/routes/_auth/users/index.tsx:125 msgid "This action cannot be undone. This will permanently delete the user." msgstr "This action cannot be undone. This will permanently delete the user." -#: src/components/Layout/Header/index.tsx:133 -#: src/components/Layout/Sidebar/index.tsx:263 -#: src/components/Layout/Sidebar/index.tsx:311 +#: src/routes/_auth/orders/-FormModal.tsx:28 +#: src/routes/_auth/orders/index.tsx:123 +msgid "Title" +msgstr "Title" + +#: src/components/Layout/Header/index.tsx:138 +#: src/components/Layout/Sidebar/index.tsx:271 +#: src/components/Layout/Sidebar/index.tsx:319 msgid "Toggle sidebar" msgstr "Toggle sidebar" -#: src/components/Layout/Header/index.tsx:151 -#: src/routes/login/index.tsx:232 +#: src/components/Layout/Header/index.tsx:159 +#: src/routes/login/index.tsx:228 msgid "Toggle Theme" msgstr "Toggle Theme" -#: src/routes/_auth/dashboard/index.tsx:27 +#: src/routes/_auth/dashboard/index.tsx:136 msgid "Total Revenue" msgstr "Total Revenue" -#: src/routes/_auth/users/index.tsx:132 +#: src/hooks/useCrudToasts.ts:54 msgid "Update failed" msgstr "Update failed" -#: src/routes/_auth/users/index.tsx:122 +#: src/hooks/useCrudToasts.ts:51 msgid "Updated successfully" msgstr "Updated successfully" -#: src/routes/_auth/users/index.tsx:115 +#: src/hooks/useCrudToasts.ts:48 msgid "Updating…" msgstr "Updating…" #: src/components/Layout/Sidebar/index.tsx:34 -msgid "User List" -msgstr "User List" +#~ msgid "User List" +#~ msgstr "User List" -#: src/routes/_auth/users/-FormModal.tsx:45 -#: src/routes/_auth/users/index.tsx:182 -#: src/routes/login/index.tsx:163 +#: src/routes/_auth/users/-FormModal.tsx:39 +#: src/routes/_auth/users/index.tsx:142 +#: src/routes/login/index.tsx:149 +#: src/routes/login/index.tsx:154 msgid "Username" msgstr "Username" -#: src/components/Layout/Header/index.tsx:76 -#: src/components/Layout/Sidebar/index.tsx:33 +#: src/components/Layout/Header/index.tsx:81 +#: src/components/Layout/Sidebar/index.tsx:35 msgid "Users" msgstr "Users" diff --git a/apps/with-lingui/src/locales/loadLocaleCatalog.ts b/apps/with-lingui/src/locales/loadLocaleCatalog.ts index 744ba51..078ccf1 100644 --- a/apps/with-lingui/src/locales/loadLocaleCatalog.ts +++ b/apps/with-lingui/src/locales/loadLocaleCatalog.ts @@ -3,10 +3,6 @@ import type { Locale } from "@/stores/settings"; const catalogCache: Partial> = {}; -/** - * Dynamically loads compiled Lingui messages for the given locale. - * Results are cached so repeated calls (e.g. main + root) do not refetch chunks. - */ export async function loadLocaleCatalog(locale: Locale): Promise { const hit = catalogCache[locale]; if (hit) { diff --git a/apps/with-lingui/src/locales/zh/messages.po b/apps/with-lingui/src/locales/zh/messages.po index 8b1850f..ca64adc 100644 --- a/apps/with-lingui/src/locales/zh/messages.po +++ b/apps/with-lingui/src/locales/zh/messages.po @@ -17,62 +17,65 @@ msgstr "" #~ msgid "{n} rows" #~ msgstr "{n} 行" -#: src/routes/_auth/users/index.tsx:386 +#: src/routes/_auth/orders/index.tsx:186 +#: src/routes/_auth/users/index.tsx:262 msgid "{total} rows" msgstr "共 {total} 条" -#: src/routes/_auth/dashboard/index.tsx:35 +#: src/routes/_auth/dashboard/index.tsx:144 msgid "+180.1% from last month" msgstr "较上月 +180.1%" -#: src/routes/_auth/dashboard/index.tsx:41 +#: src/routes/_auth/dashboard/index.tsx:150 msgid "+19% from last month" msgstr "较上月 +19%" -#: src/routes/_auth/dashboard/index.tsx:29 +#: src/routes/_auth/dashboard/index.tsx:138 msgid "+20.1% from last month" msgstr "较上月 +20.1%" -#: src/routes/_auth/dashboard/index.tsx:47 +#: src/routes/_auth/dashboard/index.tsx:156 msgid "+201 since last hour" msgstr "较上一小时 +201" -#: src/components/Layout/Header/index.tsx:78 +#: src/components/Layout/Header/index.tsx:83 msgid "403" msgstr "403" -#: src/routes/_auth/dashboard/index.tsx:45 +#: src/routes/_auth/dashboard/index.tsx:154 msgid "Active Now" msgstr "当前在线" -#: src/routes/_auth/users/-FormModal.tsx:58 +#: src/routes/_auth/users/-FormModal.tsx:52 #: src/routes/_auth/users/-Toolbar.tsx:64 msgid "Admin" msgstr "管理员" -#: src/routes/_auth/users/index.tsx:164 +#: src/routes/_auth/orders/index.tsx:112 +#: src/routes/_auth/users/index.tsx:124 msgid "Are you absolutely sure?" msgstr "确定要执行此操作吗?" -#: src/routes/login/index.tsx:185 +#: src/routes/login/index.tsx:181 msgid "Auto login" msgstr "自动登录" -#: src/components/NotFound/index.tsx:54 -#: src/routes/_auth/403/index.tsx:40 +#: src/components/NotFound/index.tsx:59 +#: src/routes/_auth/403/index.tsx:35 msgid "Back to Home" msgstr "返回首页" -#: src/routes/_auth/users/-FormModal.tsx:32 -#: src/routes/_auth/users/index.tsx:168 +#: src/routes/_auth/orders/index.tsx:116 +#: src/routes/_auth/users/-FormModal.tsx:31 +#: src/routes/_auth/users/index.tsx:128 msgid "Cancel" msgstr "取消" -#: src/routes/_auth/dashboard/index.tsx:134 +#: src/routes/_auth/dashboard/index.tsx:250 msgid "Chart Placeholder" msgstr "图表占位" -#: src/routes/_auth/users/index.tsx:106 +#: src/hooks/useCrudToasts.ts:43 msgid "Create failed" msgstr "创建失败" @@ -80,53 +83,68 @@ msgstr "创建失败" msgid "Create User" msgstr "创建用户" -#: src/routes/_auth/users/index.tsx:100 +#: src/hooks/useCrudToasts.ts:40 msgid "Created successfully" msgstr "创建成功" -#: src/components/Layout/Header/index.tsx:74 -#: src/components/Layout/Header/index.tsx:87 -#: src/components/Layout/Header/index.tsx:91 -#: src/components/Layout/Sidebar/index.tsx:32 +#: src/components/Layout/Header/index.tsx:79 +#: src/components/Layout/Header/index.tsx:92 +#: src/components/Layout/Header/index.tsx:96 +#: src/components/Layout/Sidebar/index.tsx:34 msgid "Dashboard" msgstr "仪表盘" -#: src/routes/_auth/users/index.tsx:166 -#: src/routes/_auth/users/index.tsx:257 +#: src/routes/_auth/orders/index.tsx:114 +#: src/routes/_auth/orders/index.tsx:152 +#: src/routes/_auth/users/index.tsx:126 +#: src/routes/_auth/users/index.tsx:223 msgid "Delete" msgstr "删除" -#: src/routes/_auth/users/index.tsx:156 +#: src/hooks/useCrudToasts.ts:65 msgid "Delete failed" msgstr "删除失败" -#: src/routes/_auth/users/index.tsx:149 +#: src/hooks/useCrudToasts.ts:62 msgid "Deleted successfully" msgstr "删除成功" -#: src/routes/_auth/users/index.tsx:142 +#: src/hooks/useCrudToasts.ts:59 msgid "Deleting…" msgstr "正在删除…" -#: src/components/Layout/Sidebar/index.tsx:35 +#: src/components/Layout/Sidebar/index.tsx:37 msgid "Design Engineering" msgstr "设计工程" -#: src/routes/_auth/users/index.tsx:247 +#: src/routes/_auth/orders/-FormModal.tsx:36 +msgid "Done" +msgstr "已完成" + +#: src/routes/_auth/orders/-FormModal.tsx:34 +msgid "Draft" +msgstr "草稿" + +#: src/routes/_auth/orders/index.tsx:142 +#: src/routes/_auth/users/index.tsx:213 msgid "Edit" msgstr "编辑" +#: src/routes/_auth/orders/-FormModal.tsx:19 +msgid "Edit Order" +msgstr "编辑订单" + #: src/routes/_auth/users/-FormModal.tsx:29 msgid "Edit User" msgstr "编辑用户" -#: src/routes/_auth/users/-FormModal.tsx:59 +#: src/routes/_auth/users/-FormModal.tsx:53 #: src/routes/_auth/users/-Toolbar.tsx:65 msgid "Editor" msgstr "编辑者" -#: src/routes/_auth/users/-FormModal.tsx:63 -#: src/routes/_auth/users/index.tsx:202 +#: src/routes/_auth/users/-FormModal.tsx:57 +#: src/routes/_auth/users/index.tsx:168 msgid "Email" msgstr "邮箱" @@ -134,27 +152,34 @@ msgstr "邮箱" #~ msgid "Filter by role" #~ msgstr "按角色筛选" -#: src/routes/login/index.tsx:192 +#: src/routes/login/index.tsx:188 msgid "Forgot password?" msgstr "忘记密码?" -#: src/components/NotFound/index.tsx:57 +#: src/components/NotFound/index.tsx:62 msgid "Go back" msgstr "返回上一页" -#: src/routes/login/index.tsx:68 +#: src/routes/login/index.tsx:54 msgid "Login failed" msgstr "登录失败" -#: src/routes/login/index.tsx:64 +#: src/routes/login/index.tsx:50 msgid "Login successful" msgstr "登录成功" +#: src/routes/_auth/orders/-Toolbar.tsx:54 +#: src/routes/_auth/orders/-Toolbar.tsx:55 #: src/routes/_auth/users/-Toolbar.tsx:92 #: src/routes/_auth/users/-Toolbar.tsx:93 msgid "More filters" msgstr "更多筛选" +#: src/routes/_auth/orders/-FormModal.tsx:19 +#: src/routes/_auth/orders/-Toolbar.tsx:51 +msgid "New Order" +msgstr "新建订单" + #: src/routes/_auth/users/-FormModal.tsx:29 msgid "New User" msgstr "新建用户" @@ -167,11 +192,23 @@ msgstr "暂无数据" msgid "Nothing to show in this list yet" msgstr "当前列表为空,暂无可展示的记录" -#: src/routes/_auth/users/-FormModal.tsx:31 +#: src/routes/_auth/users/-FormModal.tsx:30 msgid "OK" msgstr "确定" -#: src/routes/_auth/dashboard/index.tsx:115 +#: src/routes/_auth/orders/-FormModal.tsx:35 +msgid "Open" +msgstr "进行中" + +#: src/components/Layout/Sidebar/index.tsx:358 +msgid "Open account menu" +msgstr "打开账户菜单" + +#: src/components/Layout/Sidebar/index.tsx:36 +msgid "Orders" +msgstr "订单" + +#: src/routes/_auth/dashboard/index.tsx:233 msgid "Overview" msgstr "概览" @@ -179,56 +216,74 @@ msgstr "概览" #~ msgid "Page not found" #~ msgstr "页面不存在" -#: src/routes/login/index.tsx:171 +#: src/routes/login/index.tsx:162 +#: src/routes/login/index.tsx:168 msgid "Password" msgstr "密码" -#: src/components/Layout/Sidebar/index.tsx:30 +#: src/components/Layout/Sidebar/index.tsx:32 msgid "Platform" msgstr "平台" -#: src/routes/login/index.tsx:172 +#: src/routes/login/index.tsx:163 msgid "Please enter password" msgstr "请输入密码" -#: src/routes/_auth/users/-FormModal.tsx:46 -#: src/routes/login/index.tsx:164 +#: src/routes/_auth/users/-FormModal.tsx:40 +#: src/routes/login/index.tsx:150 msgid "Please enter username" msgstr "请输入用户名" -#: src/routes/_auth/users/-FormModal.tsx:53 +#: src/routes/_auth/users/-FormModal.tsx:47 msgid "Please select roles" msgstr "请选择角色" -#: src/components/Layout/AppFooter/index.tsx:36 +#: src/components/RouteError.tsx:17 +msgid "Please try again or return to the dashboard." +msgstr "请重试或返回控制台。" + +#: src/components/Layout/AppFooter/index.tsx:23 msgid "Powered by" msgstr "由" -#: src/components/Layout/Sidebar/index.tsx:31 +#: src/components/Layout/Sidebar/index.tsx:33 msgid "Projects" msgstr "项目" -#: src/routes/_auth/dashboard/index.tsx:142 +#: src/routes/_auth/dashboard/index.tsx:259 msgid "Recent Sales" msgstr "近期销售" +#: src/components/RouteError.tsx:20 +msgid "Retry" +msgstr "重试" + #: src/routes/_auth/users/-Toolbar.tsx:58 msgid "Role" msgstr "角色" -#: src/routes/_auth/users/-FormModal.tsx:52 -#: src/routes/_auth/users/index.tsx:209 +#: src/routes/_auth/users/-FormModal.tsx:46 +#: src/routes/_auth/users/index.tsx:175 msgid "Roles" msgstr "角色" -#: src/routes/_auth/dashboard/index.tsx:39 +#: src/routes/_auth/orders/index.tsx:160 +#: src/routes/_auth/users/index.tsx:234 +msgid "Row actions" +msgstr "行操作" + +#: src/routes/_auth/dashboard/index.tsx:148 msgid "Sales" msgstr "销售额" -#: src/components/Layout/Sidebar/index.tsx:36 +#: src/components/Layout/Sidebar/index.tsx:38 msgid "Sales & Marketing" msgstr "销售与市场" +#: src/routes/_auth/orders/-Toolbar.tsx:32 +msgid "Search Order" +msgstr "搜索订单" + #: src/routes/_auth/users/-Toolbar.tsx:43 msgid "Search User" msgstr "搜索用户" @@ -237,7 +292,7 @@ msgstr "搜索用户" #~ msgid "Search username or email" #~ msgstr "搜索用户名或邮箱" -#: src/routes/login/index.tsx:204 +#: src/routes/login/index.tsx:200 msgid "Sign In" msgstr "登录" @@ -245,70 +300,89 @@ msgstr "登录" #~ msgid "Sign in with your credentials." #~ msgstr "凭用户名与密码登录" -#: src/components/Layout/Sidebar/index.tsx:194 +#: src/components/Layout/Sidebar/index.tsx:197 msgid "Sign Out" msgstr "退出登录" -#: src/components/NotFound/index.tsx:50 +#: src/components/RouteError.tsx:16 +msgid "Something went wrong" +msgstr "出了点问题" + +#: src/components/NotFound/index.tsx:55 msgid "Sorry, the page you visited does not exist." msgstr "抱歉,您访问的页面不存在。" -#: src/routes/_auth/403/index.tsx:37 +#: src/routes/_auth/403/index.tsx:32 msgid "Sorry, you don't have permission to access this page." msgstr "抱歉,您没有权限访问此页面。" -#: src/routes/_auth/dashboard/index.tsx:33 +#: src/routes/_auth/orders/-FormModal.tsx:31 +#: src/routes/_auth/orders/index.tsx:125 +msgid "Status" +msgstr "状态" + +#: src/routes/_auth/dashboard/index.tsx:142 msgid "Subscriptions" msgstr "订阅" -#: src/components/Layout/Header/index.tsx:145 -#: src/routes/login/index.tsx:225 +#: src/components/Layout/Header/index.tsx:153 +#: src/routes/login/index.tsx:221 msgid "Switch language" msgstr "切换语言" -#: src/routes/_auth/users/index.tsx:165 +#: src/routes/_auth/orders/index.tsx:113 +msgid "This action cannot be undone." +msgstr "此操作无法撤销。" + +#: src/routes/_auth/users/index.tsx:125 msgid "This action cannot be undone. This will permanently delete the user." msgstr "此操作无法撤销,将永久删除该用户。" -#: src/components/Layout/Header/index.tsx:133 -#: src/components/Layout/Sidebar/index.tsx:263 -#: src/components/Layout/Sidebar/index.tsx:311 +#: src/routes/_auth/orders/-FormModal.tsx:28 +#: src/routes/_auth/orders/index.tsx:123 +msgid "Title" +msgstr "标题" + +#: src/components/Layout/Header/index.tsx:138 +#: src/components/Layout/Sidebar/index.tsx:271 +#: src/components/Layout/Sidebar/index.tsx:319 msgid "Toggle sidebar" msgstr "折叠侧栏" -#: src/components/Layout/Header/index.tsx:151 -#: src/routes/login/index.tsx:232 +#: src/components/Layout/Header/index.tsx:159 +#: src/routes/login/index.tsx:228 msgid "Toggle Theme" msgstr "切换主题" -#: src/routes/_auth/dashboard/index.tsx:27 +#: src/routes/_auth/dashboard/index.tsx:136 msgid "Total Revenue" msgstr "总收入" -#: src/routes/_auth/users/index.tsx:132 +#: src/hooks/useCrudToasts.ts:54 msgid "Update failed" msgstr "更新失败" -#: src/routes/_auth/users/index.tsx:122 +#: src/hooks/useCrudToasts.ts:51 msgid "Updated successfully" msgstr "更新成功" -#: src/routes/_auth/users/index.tsx:115 +#: src/hooks/useCrudToasts.ts:48 msgid "Updating…" msgstr "正在更新…" #: src/components/Layout/Sidebar/index.tsx:34 -msgid "User List" -msgstr "用户列表" +#~ msgid "User List" +#~ msgstr "用户列表" -#: src/routes/_auth/users/-FormModal.tsx:45 -#: src/routes/_auth/users/index.tsx:182 -#: src/routes/login/index.tsx:163 +#: src/routes/_auth/users/-FormModal.tsx:39 +#: src/routes/_auth/users/index.tsx:142 +#: src/routes/login/index.tsx:149 +#: src/routes/login/index.tsx:154 msgid "Username" msgstr "用户名" -#: src/components/Layout/Header/index.tsx:76 -#: src/components/Layout/Sidebar/index.tsx:33 +#: src/components/Layout/Header/index.tsx:81 +#: src/components/Layout/Sidebar/index.tsx:35 msgid "Users" msgstr "用户" diff --git a/apps/with-lingui/src/main.tsx b/apps/with-lingui/src/main.tsx index fad1834..63b9a42 100644 --- a/apps/with-lingui/src/main.tsx +++ b/apps/with-lingui/src/main.tsx @@ -6,9 +6,10 @@ import { i18n } from "@lingui/core"; import { loadLocaleCatalog } from "./locales/loadLocaleCatalog"; import { useSettingsStore } from "./stores/settings"; import { useAuthStore } from "./stores/auth"; -import { fetchSessionAndApplyToStore } from "./utils/session"; +import { installHttpRouter } from "./utils/http"; const router = createRouter({ routeTree }); +installHttpRouter(router); declare module "@tanstack/react-router" { interface Register { @@ -25,22 +26,17 @@ async function enableMocking() { enableMocking() .then(async () => { - await useSettingsStore.persist.rehydrate(); + await Promise.all([ + useSettingsStore.persist.rehydrate(), + useAuthStore.persist.rehydrate(), + ]); + const initialLocale = useSettingsStore.getState().locale; const messages = await loadLocaleCatalog(initialLocale); i18n.load(initialLocale, messages); i18n.activate(initialLocale); document.documentElement.lang = initialLocale === "zh" ? "zh" : "en"; - - await useAuthStore.persist.rehydrate(); - const { isAuthenticated, tokens } = useAuthStore.getState(); - if (isAuthenticated && tokens) { - try { - await fetchSessionAndApplyToStore(); - } catch { - useAuthStore.getState().logout(); - } - } + document.documentElement.dir = "ltr"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/with-lingui/src/mocks/createHandler.test.ts b/apps/with-lingui/src/mocks/createHandler.test.ts new file mode 100644 index 0000000..3d8232c --- /dev/null +++ b/apps/with-lingui/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/with-lingui/src/mocks/createHandler.ts b/apps/with-lingui/src/mocks/createHandler.ts index 76ebc68..b75187a 100644 --- a/apps/with-lingui/src/mocks/createHandler.ts +++ b/apps/with-lingui/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,10 +20,42 @@ export function successResponse(data: T, message: string = "ok") { }); } -/** - * Create an error response - */ -export function errorResponse(code: number, message: string, data: unknown = null) { +/** 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, data, @@ -44,9 +63,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/with-lingui/src/mocks/data.ts b/apps/with-lingui/src/mocks/data.ts index 22ed8e5..92edcce 100644 --- a/apps/with-lingui/src/mocks/data.ts +++ b/apps/with-lingui/src/mocks/data.ts @@ -1,7 +1,6 @@ -import type { User } from "@/api/schemas"; +import type { User, Order } 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,11 +15,25 @@ 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, avatar: vercelAvatarUrl(username), email, roles: i === 0 ? ["admin"] : ["editor"], - permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"], + permissions: i === 0 ? ["orders:view","orders:create","orders:edit","orders:delete","user:view", "user:create", "user:edit", "user:delete"] : ["user:view"], })); + +export const MOCK_ORDERS: Order[] = [ + { id: "1", title: "Alpha", status: "open" }, + { id: "2", title: "Beta", status: "draft" }, + { id: "3", title: "Gamma", status: "done" }, +]; diff --git a/apps/with-lingui/src/mocks/handlers/auth.ts b/apps/with-lingui/src/mocks/handlers/auth.ts index 6af2709..8976cc7 100644 --- a/apps/with-lingui/src/mocks/handlers/auth.ts +++ b/apps/with-lingui/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/with-lingui/src/mocks/handlers/index.ts b/apps/with-lingui/src/mocks/handlers/index.ts index dbb3817..14007ca 100644 --- a/apps/with-lingui/src/mocks/handlers/index.ts +++ b/apps/with-lingui/src/mocks/handlers/index.ts @@ -1,4 +1,5 @@ import { authHandlers } from "./auth"; import { userHandlers } from "./user"; +import { ordersHandlers } from "./orders"; -export const handlers = [...authHandlers, ...userHandlers]; +export const handlers = [...authHandlers, ...userHandlers, ...ordersHandlers]; diff --git a/apps/with-lingui/src/mocks/handlers/orders.ts b/apps/with-lingui/src/mocks/handlers/orders.ts new file mode 100644 index 0000000..739a1fa --- /dev/null +++ b/apps/with-lingui/src/mocks/handlers/orders.ts @@ -0,0 +1,61 @@ +import { http } from "msw"; +import { OrderSchema } from "@/api/schemas"; +import { MOCK_ORDERS } from "../data"; +import { paginateList, parsePaginationParams } from "../utils"; +import { + withDelay, + errorResponse, + ERROR_CODES, + paginatedWithSchema, + successWithSchema, + successWithNullBody, +} from "../createHandler"; + +let rows = [...MOCK_ORDERS]; + +function filterRows(items: typeof rows, keyword: string) { + if (!keyword.trim()) return items; + const k = keyword.toLowerCase(); + return items.filter((r) => r.title.toLowerCase().includes(k)); +} + +export const ordersHandlers = [ + http.get("/api/orders", async ({ request }) => { + await withDelay(150); + const url = new URL(request.url); + const { limit, offset } = parsePaginationParams(url.searchParams); + const keyword = url.searchParams.get("keyword") ?? ""; + const filtered = filterRows(rows, keyword); + const list = paginateList(filtered, limit, offset); + return paginatedWithSchema(OrderSchema, { list, total: filtered.length }); + }), + + http.post("/api/orders", async ({ request }) => { + await withDelay(150); + const body = (await request.json()) as Record; + const next: (typeof rows)[number] = { + id: String(rows.length + 1), + title: typeof body.title === "string" ? body.title : "", + status: (body.status as (typeof rows)[number]["status"]) ?? "draft", + }; + rows.push(next); + return successWithSchema(OrderSchema, next); + }), + + http.put("/api/orders/:id", async ({ params, request }) => { + await withDelay(150); + const body = (await request.json()) as Record; + const idx = rows.findIndex((r) => r.id === params.id); + if (idx === -1) { + return errorResponse(ERROR_CODES.NOT_FOUND, "Not found"); + } + rows[idx] = { ...rows[idx], ...body } as (typeof rows)[number]; + return successWithSchema(OrderSchema, rows[idx]); + }), + + http.delete("/api/orders/:id", async ({ params }) => { + await withDelay(150); + rows = rows.filter((r) => r.id !== params.id); + return successWithNullBody(); + }), +]; diff --git a/apps/with-lingui/src/mocks/handlers/user.ts b/apps/with-lingui/src/mocks/handlers/user.ts index 97679c6..bc81598 100644 --- a/apps/with-lingui/src/mocks/handlers/user.ts +++ b/apps/with-lingui/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/with-lingui/src/mocks/utils.ts b/apps/with-lingui/src/mocks/utils.ts index 978fd16..4fb5068 100644 --- a/apps/with-lingui/src/mocks/utils.ts +++ b/apps/with-lingui/src/mocks/utils.ts @@ -1,8 +1,3 @@ -/** - * Mock-only helpers: demo avatars, list filtering, pagination. - */ - -/** Deterministic demo avatar URL from username (mock seed data only). */ export function vercelAvatarUrl(username: string): string { return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`; } diff --git a/apps/with-lingui/src/routeTree.gen.ts b/apps/with-lingui/src/routeTree.gen.ts index e3430b5..769f033 100644 --- a/apps/with-lingui/src/routeTree.gen.ts +++ b/apps/with-lingui/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as LoginIndexRouteImport } from './routes/login/index' import { Route as R404IndexRouteImport } from './routes/404/index' import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index' +import { Route as AuthOrdersIndexRouteImport } from './routes/_auth/orders/index' import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index' import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index' @@ -41,6 +42,11 @@ const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({ path: '/users/', getParentRoute: () => AuthRoute, } as any) +const AuthOrdersIndexRoute = AuthOrdersIndexRouteImport.update({ + id: '/orders/', + path: '/orders/', + getParentRoute: () => AuthRoute, +} as any) const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({ id: '/dashboard/', path: '/dashboard/', @@ -58,6 +64,7 @@ export interface FileRoutesByFullPath { '/login/': typeof LoginIndexRoute '/403/': typeof Auth403IndexRoute '/dashboard/': typeof AuthDashboardIndexRoute + '/orders/': typeof AuthOrdersIndexRoute '/users/': typeof AuthUsersIndexRoute } export interface FileRoutesByTo { @@ -66,6 +73,7 @@ export interface FileRoutesByTo { '/login': typeof LoginIndexRoute '/403': typeof Auth403IndexRoute '/dashboard': typeof AuthDashboardIndexRoute + '/orders': typeof AuthOrdersIndexRoute '/users': typeof AuthUsersIndexRoute } export interface FileRoutesById { @@ -76,13 +84,21 @@ export interface FileRoutesById { '/login/': typeof LoginIndexRoute '/_auth/403/': typeof Auth403IndexRoute '/_auth/dashboard/': typeof AuthDashboardIndexRoute + '/_auth/orders/': typeof AuthOrdersIndexRoute '/_auth/users/': typeof AuthUsersIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/404/' | '/login/' | '/403/' | '/dashboard/' | '/users/' + fullPaths: + | '/' + | '/404/' + | '/login/' + | '/403/' + | '/dashboard/' + | '/orders/' + | '/users/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users' + to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/orders' | '/users' id: | '__root__' | '/' @@ -91,6 +107,7 @@ export interface FileRouteTypes { | '/login/' | '/_auth/403/' | '/_auth/dashboard/' + | '/_auth/orders/' | '/_auth/users/' fileRoutesById: FileRoutesById } @@ -138,6 +155,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthUsersIndexRouteImport parentRoute: typeof AuthRoute } + '/_auth/orders/': { + id: '/_auth/orders/' + path: '/orders' + fullPath: '/orders/' + preLoaderRoute: typeof AuthOrdersIndexRouteImport + parentRoute: typeof AuthRoute + } '/_auth/dashboard/': { id: '/_auth/dashboard/' path: '/dashboard' @@ -158,12 +182,14 @@ declare module '@tanstack/react-router' { interface AuthRouteChildren { Auth403IndexRoute: typeof Auth403IndexRoute AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute + AuthOrdersIndexRoute: typeof AuthOrdersIndexRoute AuthUsersIndexRoute: typeof AuthUsersIndexRoute } const AuthRouteChildren: AuthRouteChildren = { Auth403IndexRoute: Auth403IndexRoute, AuthDashboardIndexRoute: AuthDashboardIndexRoute, + AuthOrdersIndexRoute: AuthOrdersIndexRoute, AuthUsersIndexRoute: AuthUsersIndexRoute, } diff --git a/apps/with-lingui/src/routes/__root.tsx b/apps/with-lingui/src/routes/__root.tsx index 62d7292..e3490eb 100644 --- a/apps/with-lingui/src/routes/__root.tsx +++ b/apps/with-lingui/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { createRootRoute, Outlet } from "@tanstack/react-router"; import { ConfigProvider, App } from "antd"; import enUS from "antd/locale/en_US"; @@ -10,15 +10,31 @@ import { loadLocaleCatalog } from "@/locales/loadLocaleCatalog"; import { useSettingsStore } from "@/stores/settings"; import { useAppTheme } from "@/hooks/useAppTheme"; import { NotFound } from "@/components/NotFound"; +import { RouteError } from "@/components/RouteError"; +import type { Locale } from "@/stores/settings"; import "@/index.css"; const antdLocaleMap = { en: enUS, zh: zhCN } as const; -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 locale = useSettingsStore((s) => s.locale); @@ -39,6 +55,7 @@ function RootComponent() { i18n.load(locale, messages); i18n.activate(locale); document.documentElement.lang = locale === "zh" ? "zh" : "en"; + document.documentElement.dir = "ltr"; })(); return () => { @@ -46,24 +63,48 @@ function RootComponent() { }; }, [locale]); + useEffect(() => { + const other: Locale = locale === "en" ? "zh" : "en"; + let idleId: number | undefined; + let timeoutId: number | undefined; + if (typeof window.requestIdleCallback === "function") { + idleId = window.requestIdleCallback(() => { + void loadLocaleCatalog(other); + }); + } else { + timeoutId = window.setTimeout(() => { + void loadLocaleCatalog(other); + }, 200); + } + return () => { + if (idleId !== undefined && typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(idleId); + } + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; + }, [locale]); + useEffect(() => { document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light"); }, [darkMode]); return ( - - - - + + + + - - - - + + + + ); } export const Route = createRootRoute({ component: RootComponent, notFoundComponent: NotFound, + errorComponent: RouteError, }); diff --git a/apps/with-lingui/src/routes/_auth.tsx b/apps/with-lingui/src/routes/_auth.tsx index 2265dd4..3417be4 100644 --- a/apps/with-lingui/src/routes/_auth.tsx +++ b/apps/with-lingui/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/with-lingui/src/routes/_auth/dashboard/index.tsx b/apps/with-lingui/src/routes/_auth/dashboard/index.tsx index c535d39..061a4ca 100644 --- a/apps/with-lingui/src/routes/_auth/dashboard/index.tsx +++ b/apps/with-lingui/src/routes/_auth/dashboard/index.tsx @@ -20,10 +20,8 @@ async function fetchDashboardShell() { type AntToken = ReturnType["token"]; -/** Matches loaded stat card body height: title row, value, description. */ function StatCardSkeleton({ token }: { token: AntToken }) { const titleLine = Math.round(token.fontSizeSM * token.lineHeight); - /** Lucide default icon box 24px to align with the first row of real cards. */ const iconBox = 24; const titleRowHeight = Math.max(titleLine, iconBox); const valueLine = Math.round(24 * 1); @@ -123,7 +121,6 @@ function DashboardPage() { staleTime: 60_000, }); - /* Hover: emphasize border; keep background matching the card so light theme doesn't gray the whole block */ const cardHoverStyle = { ["--dash-card-hover-bg" as string]: token.colorBgContainer, ["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover, diff --git a/apps/with-lingui/src/routes/_auth/orders/-FormModal.tsx b/apps/with-lingui/src/routes/_auth/orders/-FormModal.tsx new file mode 100644 index 0000000..679cd05 --- /dev/null +++ b/apps/with-lingui/src/routes/_auth/orders/-FormModal.tsx @@ -0,0 +1,43 @@ +import { Form, Input, Modal, Select } from "antd"; +import { useLingui } from "@lingui/react/macro"; +import type { Order, CreateOrderRequest } from "@/api/schemas"; + +export type FormModalProps = { + open: boolean; + editing: Order | null; + form: ReturnType>[0]; + confirmLoading: boolean; + onCancel: () => void; + onFinish: (values: CreateOrderRequest) => void; +}; + +export function FormModal({ open, editing, form, confirmLoading, onCancel, onFinish }: FormModalProps) { + const { t } = useLingui(); + return ( + { + form.submit(); + }} + confirmLoading={confirmLoading} + destroyOnHidden + > +
+ + + + +