create-m5kdev 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/minimal-app/.cursor/rules/frontend-component-guide.mdc +95 -95
- package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +103 -103
- package/templates/minimal-app/.cursor/rules/frontend-form-guide.mdc +80 -80
- package/templates/minimal-app/.cursor/rules/frontend-hook-guide.mdc +79 -79
- package/templates/minimal-app/.cursor/rules/frontend-i18n-guide.mdc +45 -45
- package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -60
- package/templates/minimal-app/.cursor/rules/module-prompt-guide.mdc +214 -214
- package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +167 -167
- package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -53
- package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +367 -367
- package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -71
- package/templates/minimal-app/.github/workflows/check.yml.tpl +21 -0
- package/templates/minimal-app/AGENTS.md.tpl +22 -31
- package/templates/minimal-app/CLAUDE.md.tpl +17 -3
- package/templates/minimal-app/GLOSSARY.md.tpl +181 -0
- package/templates/minimal-app/README.md.tpl +14 -6
- package/templates/minimal-app/apps/.gitignore.tpl +13 -13
- package/templates/minimal-app/apps/e2e/package.json.tpl +23 -23
- package/templates/minimal-app/apps/e2e/servers.ts.tpl +22 -6
- package/templates/minimal-app/apps/email/package.json.tpl +34 -34
- package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -44
- package/templates/minimal-app/apps/expo/.prettierignore +13 -13
- package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -9
- package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -156
- package/templates/minimal-app/apps/expo/README.md.tpl +44 -44
- package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
- package/templates/minimal-app/apps/expo/package.json.tpl +63 -63
- package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
- package/templates/minimal-app/apps/landing/package.json.tpl +34 -34
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +1 -1
- package/templates/minimal-app/apps/server/drizzle/0000_whole_echo.sql +366 -366
- package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +46 -46
- package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +3 -3
- package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +98 -98
- package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +1 -1
- package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +158 -158
- package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +1 -1
- package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +86 -86
- package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2624 -2624
- package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2670 -2670
- package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2715 -2715
- package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +2687 -2687
- package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2497 -2497
- package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2483 -2483
- package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3602 -3602
- package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3619 -3619
- package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3619 -3619
- package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +76 -76
- package/templates/minimal-app/apps/server/drizzle-postgresql/0000_glorious_warhawk.sql +522 -0
- package/templates/minimal-app/apps/server/drizzle-postgresql/meta/0000_snapshot.json.tpl +3555 -0
- package/templates/minimal-app/apps/server/drizzle-postgresql/meta/_journal.json.tpl +13 -0
- package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +1 -1
- package/templates/minimal-app/apps/server/mastra.ts.tpl +42 -0
- package/templates/minimal-app/apps/server/package.json.tpl +9 -4
- package/templates/minimal-app/apps/server/src/app.ts.tpl +21 -16
- package/templates/minimal-app/apps/server/src/drizzle.schema.ts.tpl +2 -0
- package/templates/minimal-app/apps/server/src/modules/conversation/conversation.mastra.ts.tpl +48 -0
- package/templates/minimal-app/apps/server/src/modules/conversation/conversation.persistence.ts.tpl +47 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +7 -6
- package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +2 -2
- package/templates/minimal-app/apps/shared/.env.example.tpl +6 -3
- package/templates/minimal-app/apps/shared/.env.production.example.tpl +7 -2
- package/templates/minimal-app/apps/shared/.env.tpl +6 -3
- package/templates/minimal-app/apps/shared/Dockerfile.pgbouncer +6 -0
- package/templates/minimal-app/apps/shared/compose.yaml.tpl +27 -0
- package/templates/minimal-app/apps/shared/fly.toml.tpl +0 -4
- package/templates/minimal-app/apps/shared/package.json.tpl +27 -27
- package/templates/minimal-app/apps/shared/pgbouncer.ini +13 -0
- package/templates/minimal-app/apps/shared/pgbouncer.users +1 -0
- package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +24 -24
- package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
- package/templates/minimal-app/apps/webapp/package.json.tpl +53 -53
- package/templates/minimal-app/apps/webapp/public/logo.svg.tpl +6 -6
- package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +1 -1
- package/templates/minimal-app/biome.json.tpl +1 -0
- package/templates/minimal-app/docs/adr/.gitkeep +0 -0
- package/templates/minimal-app/docs/agents/domain.md.tpl +38 -0
- package/templates/minimal-app/docs/agents/issue-tracker.md.tpl +45 -0
- package/templates/minimal-app/docs/agents/triage-labels.md.tpl +15 -0
- package/templates/minimal-app/package.json.tpl +5 -0
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +16 -13
- package/templates/minimal-app/template.manifest.json +21 -0
package/package.json
CHANGED
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
---
|
|
2
|
-
globs: apps/**/webapp/src/**/*.tsx
|
|
3
|
-
alwaysApply: false
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Frontend Component Style Guide
|
|
7
|
-
|
|
8
|
-
Conventions for React components in the webapp. The stack is fixed: React 19,
|
|
9
|
-
HeroUI v3, Tailwind CSS v4, react-router v7, nuqs, TanStack Query over tRPC,
|
|
10
|
-
i18next. Do not introduce alternatives.
|
|
11
|
-
|
|
12
|
-
## HeroUI before raw Tailwind
|
|
13
|
-
|
|
14
|
-
When a HeroUI component exists for the element you are building, use it instead
|
|
15
|
-
of hand-rolled HTML + Tailwind: `Button`, `Input`, `TextField`, `TextArea`,
|
|
16
|
-
`Select`, `ListBox`, `Modal`, `Card`, `Chip`, `Skeleton`, `Form`, `Label`,
|
|
17
|
-
`FieldError`, `toast`. Tailwind utilities are for layout, spacing, and visual
|
|
18
|
-
styling on top of these primitives — not for rebuilding them.
|
|
19
|
-
|
|
20
|
-
```tsx
|
|
21
|
-
// GOOD
|
|
22
|
-
<Button variant="primary" isPending={isSaving} onPress={openCreate}>...</Button>
|
|
23
|
-
|
|
24
|
-
// BAD — hand-rolled button
|
|
25
|
-
<div className="cursor-pointer rounded bg-blue-600 px-4 py-2" onClick={...}>
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## Components stay readable, logic lives in hooks
|
|
29
|
-
|
|
30
|
-
The measure is readability, not dogma: someone editing the JSX should not have
|
|
31
|
-
to scroll through business logic to find it. Data fetching, mutations, URL
|
|
32
|
-
state, and effect chains belong in custom hooks (see the frontend hook guide).
|
|
33
|
-
|
|
34
|
-
Allowed directly in a component:
|
|
35
|
-
|
|
36
|
-
- `useTranslation` and small formatting helpers (dates, reading time).
|
|
37
|
-
- `useId` for accessibility wiring.
|
|
38
|
-
- Ephemeral UI state that never leaves the component: modal open/closed,
|
|
39
|
-
selected row, hover/expanded toggles.
|
|
40
|
-
- A few small hooks that comfortably fit before the returned JSX.
|
|
41
|
-
|
|
42
|
-
Not allowed directly in feature/route components:
|
|
43
|
-
|
|
44
|
-
- `useQuery` / `useMutation` clusters, `queryClient` calls, tRPC wiring.
|
|
45
|
-
- `useEffect` chains that synchronize state.
|
|
46
|
-
- nuqs URL-state definitions for filters/pagination/tabs.
|
|
47
|
-
|
|
48
|
-
```tsx
|
|
49
|
-
// GOOD — one feature hook, ephemeral UI state local, display-focused JSX
|
|
50
|
-
export function PostsRoute() {
|
|
51
|
-
const { t } = useTranslation("blog");
|
|
52
|
-
const posts = usePostsRoute();
|
|
53
|
-
const [selectedPostId, setSelectedPostId] = useState<string>();
|
|
54
|
-
const selectedPost = posts.rows.find((r) => r.id === selectedPostId) ?? posts.rows[0];
|
|
55
|
-
return (/* JSX built from `posts` + local UI state */);
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## Derive, don't sync
|
|
60
|
-
|
|
61
|
-
Compute values from data during render instead of mirroring them into state
|
|
62
|
-
with `useEffect`.
|
|
63
|
-
|
|
64
|
-
```tsx
|
|
65
|
-
// GOOD
|
|
66
|
-
const selectedPost = rows.find((r) => r.id === selectedPostId) ?? rows[0];
|
|
67
|
-
|
|
68
|
-
// BAD — effect that re-syncs state after every data change
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
if (!rows.some((r) => r.id === selectedPostId)) setSelectedPostId(rows[0]?.id);
|
|
71
|
-
}, [rows, selectedPostId]);
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
## React 19 idioms
|
|
75
|
-
|
|
76
|
-
- No `forwardRef` — pass `ref` as a regular prop.
|
|
77
|
-
- No `defaultProps` — use parameter defaults.
|
|
78
|
-
- Use `startTransition` / `useDeferredValue` for low-priority updates such as
|
|
79
|
-
search inputs that feed queries.
|
|
80
|
-
|
|
81
|
-
## Required states
|
|
82
|
-
|
|
83
|
-
Every data-driven view renders explicit loading (`Skeleton`), empty, and error
|
|
84
|
-
states. Never return `null` while data is loading.
|
|
85
|
-
|
|
86
|
-
## Subcomponents
|
|
87
|
-
|
|
88
|
-
Presentational subcomponents receive everything via props — no data fetching,
|
|
89
|
-
no feature hooks inside them. Co-locate them under
|
|
90
|
-
`modules/<feature>/components/` (or in-file when tiny).
|
|
91
|
-
|
|
92
|
-
## Text
|
|
93
|
-
|
|
94
|
-
All user-facing strings go through `useTranslation` — no hardcoded copy. See
|
|
95
|
-
the frontend i18n guide.
|
|
1
|
+
---
|
|
2
|
+
globs: apps/**/webapp/src/**/*.tsx
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Frontend Component Style Guide
|
|
7
|
+
|
|
8
|
+
Conventions for React components in the webapp. The stack is fixed: React 19,
|
|
9
|
+
HeroUI v3, Tailwind CSS v4, react-router v7, nuqs, TanStack Query over tRPC,
|
|
10
|
+
i18next. Do not introduce alternatives.
|
|
11
|
+
|
|
12
|
+
## HeroUI before raw Tailwind
|
|
13
|
+
|
|
14
|
+
When a HeroUI component exists for the element you are building, use it instead
|
|
15
|
+
of hand-rolled HTML + Tailwind: `Button`, `Input`, `TextField`, `TextArea`,
|
|
16
|
+
`Select`, `ListBox`, `Modal`, `Card`, `Chip`, `Skeleton`, `Form`, `Label`,
|
|
17
|
+
`FieldError`, `toast`. Tailwind utilities are for layout, spacing, and visual
|
|
18
|
+
styling on top of these primitives — not for rebuilding them.
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
// GOOD
|
|
22
|
+
<Button variant="primary" isPending={isSaving} onPress={openCreate}>...</Button>
|
|
23
|
+
|
|
24
|
+
// BAD — hand-rolled button
|
|
25
|
+
<div className="cursor-pointer rounded bg-blue-600 px-4 py-2" onClick={...}>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Components stay readable, logic lives in hooks
|
|
29
|
+
|
|
30
|
+
The measure is readability, not dogma: someone editing the JSX should not have
|
|
31
|
+
to scroll through business logic to find it. Data fetching, mutations, URL
|
|
32
|
+
state, and effect chains belong in custom hooks (see the frontend hook guide).
|
|
33
|
+
|
|
34
|
+
Allowed directly in a component:
|
|
35
|
+
|
|
36
|
+
- `useTranslation` and small formatting helpers (dates, reading time).
|
|
37
|
+
- `useId` for accessibility wiring.
|
|
38
|
+
- Ephemeral UI state that never leaves the component: modal open/closed,
|
|
39
|
+
selected row, hover/expanded toggles.
|
|
40
|
+
- A few small hooks that comfortably fit before the returned JSX.
|
|
41
|
+
|
|
42
|
+
Not allowed directly in feature/route components:
|
|
43
|
+
|
|
44
|
+
- `useQuery` / `useMutation` clusters, `queryClient` calls, tRPC wiring.
|
|
45
|
+
- `useEffect` chains that synchronize state.
|
|
46
|
+
- nuqs URL-state definitions for filters/pagination/tabs.
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
// GOOD — one feature hook, ephemeral UI state local, display-focused JSX
|
|
50
|
+
export function PostsRoute() {
|
|
51
|
+
const { t } = useTranslation("blog");
|
|
52
|
+
const posts = usePostsRoute();
|
|
53
|
+
const [selectedPostId, setSelectedPostId] = useState<string>();
|
|
54
|
+
const selectedPost = posts.rows.find((r) => r.id === selectedPostId) ?? posts.rows[0];
|
|
55
|
+
return (/* JSX built from `posts` + local UI state */);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Derive, don't sync
|
|
60
|
+
|
|
61
|
+
Compute values from data during render instead of mirroring them into state
|
|
62
|
+
with `useEffect`.
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
// GOOD
|
|
66
|
+
const selectedPost = rows.find((r) => r.id === selectedPostId) ?? rows[0];
|
|
67
|
+
|
|
68
|
+
// BAD — effect that re-syncs state after every data change
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!rows.some((r) => r.id === selectedPostId)) setSelectedPostId(rows[0]?.id);
|
|
71
|
+
}, [rows, selectedPostId]);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## React 19 idioms
|
|
75
|
+
|
|
76
|
+
- No `forwardRef` — pass `ref` as a regular prop.
|
|
77
|
+
- No `defaultProps` — use parameter defaults.
|
|
78
|
+
- Use `startTransition` / `useDeferredValue` for low-priority updates such as
|
|
79
|
+
search inputs that feed queries.
|
|
80
|
+
|
|
81
|
+
## Required states
|
|
82
|
+
|
|
83
|
+
Every data-driven view renders explicit loading (`Skeleton`), empty, and error
|
|
84
|
+
states. Never return `null` while data is loading.
|
|
85
|
+
|
|
86
|
+
## Subcomponents
|
|
87
|
+
|
|
88
|
+
Presentational subcomponents receive everything via props — no data fetching,
|
|
89
|
+
no feature hooks inside them. Co-locate them under
|
|
90
|
+
`modules/<feature>/components/` (or in-file when tiny).
|
|
91
|
+
|
|
92
|
+
## Text
|
|
93
|
+
|
|
94
|
+
All user-facing strings go through `useTranslation` — no hardcoded copy. See
|
|
95
|
+
the frontend i18n guide.
|
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
---
|
|
2
|
-
globs: apps/**/webapp/src/**/*.ts*
|
|
3
|
-
alwaysApply: false
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Frontend Data Fetching Style Guide
|
|
7
|
-
|
|
8
|
-
All server communication goes through tRPC with the TanStack React Query
|
|
9
|
-
integration. No `fetch`, no axios, no hand-written API clients.
|
|
10
|
-
|
|
11
|
-
## Types come from the server — never hand-mirror them
|
|
12
|
-
|
|
13
|
-
Row and payload types are **inferred from the router**, not redeclared:
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
import type { inferRouterOutputs } from "@trpc/server";
|
|
17
|
-
import type { AppRouter } from "{{PACKAGE_SCOPE}}/server/types";
|
|
18
|
-
|
|
19
|
-
type PostsListOutput = inferRouterOutputs<AppRouter>["posts"]["list"];
|
|
20
|
-
export type PostRow = PostsListOutput["rows"][number];
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Shared Zod schemas (`apps/shared`) are only for runtime needs — form
|
|
24
|
-
validation, or schemas the server DTO also imports. Never write a shared
|
|
25
|
-
schema that just mirrors a server response; it will drift (see the shared
|
|
26
|
-
schema guide).
|
|
27
|
-
|
|
28
|
-
## Queries
|
|
29
|
-
|
|
30
|
-
Use `useTRPC()` and the generated `queryOptions`:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
const trpc = useTRPC();
|
|
34
|
-
const { data, isLoading, isFetching } = useQuery(
|
|
35
|
-
trpc.posts.list.queryOptions(listInput) as unknown as UseQueryOptions<PostsListOutput>
|
|
36
|
-
);
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
- Search inputs that feed queries go through `useDeferredValue`, and URL-state
|
|
40
|
-
updates are wrapped in `startTransition`.
|
|
41
|
-
|
|
42
|
-
## List endpoints speak the generic query contract
|
|
43
|
-
|
|
44
|
-
Module list procedures accept `QueryInput` from
|
|
45
|
-
`@m5kdev/commons/modules/schemas/query.schema` — `page`, `limit`, `sort`,
|
|
46
|
-
`order`, `q` (global search), and `filters`. Do not invent per-module fields
|
|
47
|
-
like `search` or `status` at the top level; they will be stripped by input
|
|
48
|
-
validation and silently ignored.
|
|
49
|
-
|
|
50
|
-
```ts
|
|
51
|
-
const listInput = useMemo<QueryInput>(
|
|
52
|
-
() => ({
|
|
53
|
-
page,
|
|
54
|
-
limit: PAGE_SIZE,
|
|
55
|
-
q: deferredSearch || undefined,
|
|
56
|
-
filters:
|
|
57
|
-
status === "all"
|
|
58
|
-
? undefined
|
|
59
|
-
: [{ columnId: "status", type: "enum", method: "equals", value: status }],
|
|
60
|
-
sort: "updatedAt",
|
|
61
|
-
order: "desc",
|
|
62
|
-
}),
|
|
63
|
-
[deferredSearch, page, status]
|
|
64
|
-
);
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Mutations
|
|
68
|
-
|
|
69
|
-
Use `mutationOptions` with success/error handling declared where the mutation
|
|
70
|
-
is created (inside a custom hook):
|
|
71
|
-
|
|
72
|
-
```ts
|
|
73
|
-
const createMutation = useMutation(
|
|
74
|
-
trpc.posts.create.mutationOptions({
|
|
75
|
-
onSuccess: async () => {
|
|
76
|
-
toast.success(t("posts.toast.created"));
|
|
77
|
-
await queryClient.invalidateQueries(trpc.posts.list.queryFilter());
|
|
78
|
-
},
|
|
79
|
-
onError: (error) => toast.error(error.message),
|
|
80
|
-
})
|
|
81
|
-
);
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Mutation input shapes are enforced by the router's inferred types — no
|
|
85
|
-
`satisfies SomeSchema` casts needed for simple `{ id }` payloads.
|
|
86
|
-
|
|
87
|
-
## Invalidation discipline
|
|
88
|
-
|
|
89
|
-
- Invalidate intentionally with the matching `queryFilter()` after mutations.
|
|
90
|
-
- Never call `queryClient.clear()` or invalidate everything.
|
|
91
|
-
- Background Workflow completions: invalidate from a `ServerEventProvider` handler. The trigger mutation only returns `jobId`. See `.cursor/rules/server-event-emit.mdc`.
|
|
92
|
-
|
|
93
|
-
## Pending state
|
|
94
|
-
|
|
95
|
-
- Screen-level: `mutation.isPending`.
|
|
96
|
-
- Row-level (a list where one item is being deleted):
|
|
97
|
-
`mutation.isPending && mutation.variables?.id === row.id`, exposed from the
|
|
98
|
-
hook as `deletingId` / `publishingId`.
|
|
99
|
-
|
|
100
|
-
## Placement
|
|
101
|
-
|
|
102
|
-
Queries and mutations live in custom hooks under
|
|
103
|
-
`modules/<feature>/hooks/`, not in components — see the frontend hook guide.
|
|
1
|
+
---
|
|
2
|
+
globs: apps/**/webapp/src/**/*.ts*
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Frontend Data Fetching Style Guide
|
|
7
|
+
|
|
8
|
+
All server communication goes through tRPC with the TanStack React Query
|
|
9
|
+
integration. No `fetch`, no axios, no hand-written API clients.
|
|
10
|
+
|
|
11
|
+
## Types come from the server — never hand-mirror them
|
|
12
|
+
|
|
13
|
+
Row and payload types are **inferred from the router**, not redeclared:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import type { inferRouterOutputs } from "@trpc/server";
|
|
17
|
+
import type { AppRouter } from "{{PACKAGE_SCOPE}}/server/types";
|
|
18
|
+
|
|
19
|
+
type PostsListOutput = inferRouterOutputs<AppRouter>["posts"]["list"];
|
|
20
|
+
export type PostRow = PostsListOutput["rows"][number];
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Shared Zod schemas (`apps/shared`) are only for runtime needs — form
|
|
24
|
+
validation, or schemas the server DTO also imports. Never write a shared
|
|
25
|
+
schema that just mirrors a server response; it will drift (see the shared
|
|
26
|
+
schema guide).
|
|
27
|
+
|
|
28
|
+
## Queries
|
|
29
|
+
|
|
30
|
+
Use `useTRPC()` and the generated `queryOptions`:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const trpc = useTRPC();
|
|
34
|
+
const { data, isLoading, isFetching } = useQuery(
|
|
35
|
+
trpc.posts.list.queryOptions(listInput) as unknown as UseQueryOptions<PostsListOutput>
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- Search inputs that feed queries go through `useDeferredValue`, and URL-state
|
|
40
|
+
updates are wrapped in `startTransition`.
|
|
41
|
+
|
|
42
|
+
## List endpoints speak the generic query contract
|
|
43
|
+
|
|
44
|
+
Module list procedures accept `QueryInput` from
|
|
45
|
+
`@m5kdev/commons/modules/schemas/query.schema` — `page`, `limit`, `sort`,
|
|
46
|
+
`order`, `q` (global search), and `filters`. Do not invent per-module fields
|
|
47
|
+
like `search` or `status` at the top level; they will be stripped by input
|
|
48
|
+
validation and silently ignored.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const listInput = useMemo<QueryInput>(
|
|
52
|
+
() => ({
|
|
53
|
+
page,
|
|
54
|
+
limit: PAGE_SIZE,
|
|
55
|
+
q: deferredSearch || undefined,
|
|
56
|
+
filters:
|
|
57
|
+
status === "all"
|
|
58
|
+
? undefined
|
|
59
|
+
: [{ columnId: "status", type: "enum", method: "equals", value: status }],
|
|
60
|
+
sort: "updatedAt",
|
|
61
|
+
order: "desc",
|
|
62
|
+
}),
|
|
63
|
+
[deferredSearch, page, status]
|
|
64
|
+
);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Mutations
|
|
68
|
+
|
|
69
|
+
Use `mutationOptions` with success/error handling declared where the mutation
|
|
70
|
+
is created (inside a custom hook):
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const createMutation = useMutation(
|
|
74
|
+
trpc.posts.create.mutationOptions({
|
|
75
|
+
onSuccess: async () => {
|
|
76
|
+
toast.success(t("posts.toast.created"));
|
|
77
|
+
await queryClient.invalidateQueries(trpc.posts.list.queryFilter());
|
|
78
|
+
},
|
|
79
|
+
onError: (error) => toast.error(error.message),
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Mutation input shapes are enforced by the router's inferred types — no
|
|
85
|
+
`satisfies SomeSchema` casts needed for simple `{ id }` payloads.
|
|
86
|
+
|
|
87
|
+
## Invalidation discipline
|
|
88
|
+
|
|
89
|
+
- Invalidate intentionally with the matching `queryFilter()` after mutations.
|
|
90
|
+
- Never call `queryClient.clear()` or invalidate everything.
|
|
91
|
+
- Background Workflow completions: invalidate from a `ServerEventProvider` handler. The trigger mutation only returns `jobId`. See `.cursor/rules/server-event-emit.mdc`.
|
|
92
|
+
|
|
93
|
+
## Pending state
|
|
94
|
+
|
|
95
|
+
- Screen-level: `mutation.isPending`.
|
|
96
|
+
- Row-level (a list where one item is being deleted):
|
|
97
|
+
`mutation.isPending && mutation.variables?.id === row.id`, exposed from the
|
|
98
|
+
hook as `deletingId` / `publishingId`.
|
|
99
|
+
|
|
100
|
+
## Placement
|
|
101
|
+
|
|
102
|
+
Queries and mutations live in custom hooks under
|
|
103
|
+
`modules/<feature>/hooks/`, not in components — see the frontend hook guide.
|
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
---
|
|
2
|
-
globs: apps/**/webapp/src/**/*.tsx
|
|
3
|
-
alwaysApply: false
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Frontend Form Style Guide
|
|
7
|
-
|
|
8
|
-
Forms are uncontrolled HeroUI v3 forms with native HTML validation. The server
|
|
9
|
-
already validates every input through tRPC + Zod — the client only needs the
|
|
10
|
-
built-in validators, which HeroUI renders nicely.
|
|
11
|
-
|
|
12
|
-
## Never use form libraries
|
|
13
|
-
|
|
14
|
-
Do not add react-hook-form, formik, react-final-form, or any controlled-form
|
|
15
|
-
state manager. No `useState` per field, no `onChange` mirroring.
|
|
16
|
-
|
|
17
|
-
## The pattern
|
|
18
|
-
|
|
19
|
-
`Form` + `TextField`/`TextArea` with `name`, `defaultValue`, and `isRequired` /
|
|
20
|
-
`type` / `minLength` for validation; `FieldError` renders the native message.
|
|
21
|
-
Read values from `FormData` on submit.
|
|
22
|
-
|
|
23
|
-
```tsx
|
|
24
|
-
export function PostEditorForm({ post, isSaving, onSave, onClose }: Props) {
|
|
25
|
-
const { t } = useTranslation("blog");
|
|
26
|
-
|
|
27
|
-
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
28
|
-
event.preventDefault();
|
|
29
|
-
const formData = new FormData(event.currentTarget);
|
|
30
|
-
const payload = {
|
|
31
|
-
title: String(formData.get("title") ?? "").trim(),
|
|
32
|
-
content: String(formData.get("content") ?? "").trim(),
|
|
33
|
-
};
|
|
34
|
-
if (await onSave(payload, post?.id)) onClose();
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
// key remounts the form so defaultValue updates when editing another row
|
|
39
|
-
<Form key={post?.id ?? "new"} onSubmit={onSubmit}>
|
|
40
|
-
<TextField isRequired name="title" defaultValue={post?.title ?? ""}>
|
|
41
|
-
<Label>{t("posts.editor.fields.title")}</Label>
|
|
42
|
-
<Input />
|
|
43
|
-
<FieldError />
|
|
44
|
-
</TextField>
|
|
45
|
-
<TextField isRequired name="content" defaultValue={post?.content ?? ""}>
|
|
46
|
-
<Label>{t("posts.editor.fields.content")}</Label>
|
|
47
|
-
<TextArea rows={10} />
|
|
48
|
-
<FieldError />
|
|
49
|
-
</TextField>
|
|
50
|
-
<Button type="submit" variant="primary" isPending={isSaving}>
|
|
51
|
-
{t("posts.editor.save")}
|
|
52
|
-
</Button>
|
|
53
|
-
</Form>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
The HeroUI `Form` blocks submission and shows `FieldError` messages until
|
|
59
|
-
native constraints pass — no validation code needed for required fields,
|
|
60
|
-
email formats, or lengths.
|
|
61
|
-
|
|
62
|
-
## Extra validation on submit only
|
|
63
|
-
|
|
64
|
-
When a rule cannot be expressed with native HTML validation (for example,
|
|
65
|
-
password and confirm-password matching), check it inside the submit handler
|
|
66
|
-
after reading `FormData`, and surface the failure with a toast or field-level
|
|
67
|
-
message. Do not reach for a validation library for this.
|
|
68
|
-
|
|
69
|
-
```tsx
|
|
70
|
-
if (formData.get("password") !== formData.get("confirmPassword")) {
|
|
71
|
-
toast.error(t("auth.errors.passwordMismatch"));
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
## Submission state
|
|
77
|
-
|
|
78
|
-
- The submit `Button` gets `isPending` from the mutation's pending flag.
|
|
79
|
-
- The mutation itself (and its success/error toasts) lives in a custom hook;
|
|
80
|
-
the form component only parses `FormData` and calls the action.
|
|
1
|
+
---
|
|
2
|
+
globs: apps/**/webapp/src/**/*.tsx
|
|
3
|
+
alwaysApply: false
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Frontend Form Style Guide
|
|
7
|
+
|
|
8
|
+
Forms are uncontrolled HeroUI v3 forms with native HTML validation. The server
|
|
9
|
+
already validates every input through tRPC + Zod — the client only needs the
|
|
10
|
+
built-in validators, which HeroUI renders nicely.
|
|
11
|
+
|
|
12
|
+
## Never use form libraries
|
|
13
|
+
|
|
14
|
+
Do not add react-hook-form, formik, react-final-form, or any controlled-form
|
|
15
|
+
state manager. No `useState` per field, no `onChange` mirroring.
|
|
16
|
+
|
|
17
|
+
## The pattern
|
|
18
|
+
|
|
19
|
+
`Form` + `TextField`/`TextArea` with `name`, `defaultValue`, and `isRequired` /
|
|
20
|
+
`type` / `minLength` for validation; `FieldError` renders the native message.
|
|
21
|
+
Read values from `FormData` on submit.
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
export function PostEditorForm({ post, isSaving, onSave, onClose }: Props) {
|
|
25
|
+
const { t } = useTranslation("blog");
|
|
26
|
+
|
|
27
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
const formData = new FormData(event.currentTarget);
|
|
30
|
+
const payload = {
|
|
31
|
+
title: String(formData.get("title") ?? "").trim(),
|
|
32
|
+
content: String(formData.get("content") ?? "").trim(),
|
|
33
|
+
};
|
|
34
|
+
if (await onSave(payload, post?.id)) onClose();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
// key remounts the form so defaultValue updates when editing another row
|
|
39
|
+
<Form key={post?.id ?? "new"} onSubmit={onSubmit}>
|
|
40
|
+
<TextField isRequired name="title" defaultValue={post?.title ?? ""}>
|
|
41
|
+
<Label>{t("posts.editor.fields.title")}</Label>
|
|
42
|
+
<Input />
|
|
43
|
+
<FieldError />
|
|
44
|
+
</TextField>
|
|
45
|
+
<TextField isRequired name="content" defaultValue={post?.content ?? ""}>
|
|
46
|
+
<Label>{t("posts.editor.fields.content")}</Label>
|
|
47
|
+
<TextArea rows={10} />
|
|
48
|
+
<FieldError />
|
|
49
|
+
</TextField>
|
|
50
|
+
<Button type="submit" variant="primary" isPending={isSaving}>
|
|
51
|
+
{t("posts.editor.save")}
|
|
52
|
+
</Button>
|
|
53
|
+
</Form>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The HeroUI `Form` blocks submission and shows `FieldError` messages until
|
|
59
|
+
native constraints pass — no validation code needed for required fields,
|
|
60
|
+
email formats, or lengths.
|
|
61
|
+
|
|
62
|
+
## Extra validation on submit only
|
|
63
|
+
|
|
64
|
+
When a rule cannot be expressed with native HTML validation (for example,
|
|
65
|
+
password and confirm-password matching), check it inside the submit handler
|
|
66
|
+
after reading `FormData`, and surface the failure with a toast or field-level
|
|
67
|
+
message. Do not reach for a validation library for this.
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
if (formData.get("password") !== formData.get("confirmPassword")) {
|
|
71
|
+
toast.error(t("auth.errors.passwordMismatch"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Submission state
|
|
77
|
+
|
|
78
|
+
- The submit `Button` gets `isPending` from the mutation's pending flag.
|
|
79
|
+
- The mutation itself (and its success/error toasts) lives in a custom hook;
|
|
80
|
+
the form component only parses `FormData` and calls the action.
|