@dennisrongo/skills 0.1.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/LICENSE +21 -0
- package/README.md +230 -0
- package/bin/claude-skills.js +189 -0
- package/lib/install.js +111 -0
- package/lib/list.js +63 -0
- package/lib/paths.js +39 -0
- package/lib/remove.js +52 -0
- package/lib/skills.js +121 -0
- package/package.json +48 -0
- package/skills/_template/SKILL.md +34 -0
- package/skills/conventional-commits/SKILL.md +136 -0
- package/skills/diagnose/SKILL.md +140 -0
- package/skills/dotnet-onion-api/SKILL.md +267 -0
- package/skills/dotnet-onion-api/references/anti-patterns.md +155 -0
- package/skills/dotnet-onion-api/references/solution-layout.md +113 -0
- package/skills/dotnet-onion-api/references/templates/appsettings.md +75 -0
- package/skills/dotnet-onion-api/references/templates/base-controller.cs.md +90 -0
- package/skills/dotnet-onion-api/references/templates/csproj-files.md +178 -0
- package/skills/dotnet-onion-api/references/templates/dbcontext.cs.md +149 -0
- package/skills/dotnet-onion-api/references/templates/exception-middleware.cs.md +101 -0
- package/skills/dotnet-onion-api/references/templates/feature-slice.md +349 -0
- package/skills/dotnet-onion-api/references/templates/program-cs.md +171 -0
- package/skills/dotnet-onion-api/references/templates/worker-program.cs.md +166 -0
- package/skills/grill-with-docs/SKILL.md +203 -0
- package/skills/handoff/SKILL.md +155 -0
- package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/improve-codebase-architecture/SKILL.md +121 -0
- package/skills/nextjs-app-router/SKILL.md +328 -0
- package/skills/nextjs-app-router/references/anti-patterns.md +203 -0
- package/skills/nextjs-app-router/references/folder-layout.md +151 -0
- package/skills/nextjs-app-router/references/good-patterns.md +212 -0
- package/skills/nextjs-app-router/references/templates/api-base.md +62 -0
- package/skills/nextjs-app-router/references/templates/api-slice.md +75 -0
- package/skills/nextjs-app-router/references/templates/auth-slice.md +95 -0
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +94 -0
- package/skills/nextjs-app-router/references/templates/components-json.md +41 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +110 -0
- package/skills/nextjs-app-router/references/templates/eslint-prettier.md +82 -0
- package/skills/nextjs-app-router/references/templates/feature-slice.md +184 -0
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +192 -0
- package/skills/nextjs-app-router/references/templates/middleware.md +88 -0
- package/skills/nextjs-app-router/references/templates/next-config.md +42 -0
- package/skills/nextjs-app-router/references/templates/package.md +99 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +79 -0
- package/skills/nextjs-app-router/references/templates/root-layout.md +146 -0
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +90 -0
- package/skills/nextjs-app-router/references/templates/tailwind-config.md +89 -0
- package/skills/nextjs-app-router/references/templates/testing.md +137 -0
- package/skills/nextjs-app-router/references/templates/tsconfig.md +40 -0
- package/skills/plan-and-build/SKILL.md +214 -0
- package/skills/pr-review/SKILL.md +132 -0
- package/skills/write-a-skill/SKILL.md +191 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# CI workflow + Husky + lint-staged
|
|
2
|
+
|
|
3
|
+
## `.github/workflows/ci.yml`
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
name: ci
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
branches: [main]
|
|
11
|
+
pull_request:
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
15
|
+
cancel-in-progress: true
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
timeout-minutes: 15
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- uses: pnpm/action-setup@v4
|
|
25
|
+
with:
|
|
26
|
+
version: 9
|
|
27
|
+
|
|
28
|
+
- uses: actions/setup-node@v4
|
|
29
|
+
with:
|
|
30
|
+
node-version: 20
|
|
31
|
+
cache: pnpm
|
|
32
|
+
|
|
33
|
+
- run: pnpm install --frozen-lockfile
|
|
34
|
+
|
|
35
|
+
- run: pnpm lint --max-warnings=0
|
|
36
|
+
- run: pnpm typecheck
|
|
37
|
+
- run: pnpm test
|
|
38
|
+
- run: pnpm build
|
|
39
|
+
env:
|
|
40
|
+
NEXT_PUBLIC_API_BASE_URL: http://localhost:5000
|
|
41
|
+
|
|
42
|
+
# Optional separate job for E2E — only run on push to main to save CI minutes.
|
|
43
|
+
e2e:
|
|
44
|
+
if: github.event_name == 'push'
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
timeout-minutes: 20
|
|
47
|
+
needs: build
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
- uses: pnpm/action-setup@v4
|
|
51
|
+
with: { version: 9 }
|
|
52
|
+
- uses: actions/setup-node@v4
|
|
53
|
+
with: { node-version: 20, cache: pnpm }
|
|
54
|
+
- run: pnpm install --frozen-lockfile
|
|
55
|
+
- run: pnpm exec playwright install --with-deps chromium
|
|
56
|
+
- run: pnpm test:e2e
|
|
57
|
+
env:
|
|
58
|
+
NEXT_PUBLIC_API_BASE_URL: http://localhost:5000
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Notes:**
|
|
62
|
+
- `--frozen-lockfile` ensures the lockfile is honored — CI fails if `package.json` and lockfile drift.
|
|
63
|
+
- `--max-warnings=0` on lint means even warnings fail CI. On a fresh scaffold there are none; this catches regressions early.
|
|
64
|
+
- E2E is split into its own job behind `if: github.event_name == 'push'` so PRs stay fast.
|
|
65
|
+
|
|
66
|
+
## Husky + lint-staged
|
|
67
|
+
|
|
68
|
+
After `pnpm install`, run:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pnpm exec husky init
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then write `.husky/pre-commit`:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
#!/usr/bin/env sh
|
|
78
|
+
. "$(dirname -- "$0")/_/husky.sh"
|
|
79
|
+
|
|
80
|
+
pnpm exec lint-staged
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The `lint-staged` config lives in `package.json` (see [`package.md`](package.md)):
|
|
84
|
+
|
|
85
|
+
```jsonc
|
|
86
|
+
"lint-staged": {
|
|
87
|
+
"*.{ts,tsx}": ["prettier --write", "eslint --fix"],
|
|
88
|
+
"*.{json,md,css,yml,yaml}": ["prettier --write"]
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Notes:**
|
|
93
|
+
- Do not put `tsc --noEmit` in lint-staged — it's per-staged-file and TypeScript is project-wide; let CI handle it.
|
|
94
|
+
- Do not skip hooks (`--no-verify`) as a workflow. If a hook is annoying, fix the underlying issue.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# `components.json` template (shadcn config)
|
|
2
|
+
|
|
3
|
+
```jsonc
|
|
4
|
+
{
|
|
5
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
6
|
+
"style": "default",
|
|
7
|
+
"rsc": true,
|
|
8
|
+
"tsx": true,
|
|
9
|
+
"tailwind": {
|
|
10
|
+
"config": "tailwind.config.ts",
|
|
11
|
+
"css": "src/app/globals.css",
|
|
12
|
+
"baseColor": "neutral",
|
|
13
|
+
"cssVariables": true,
|
|
14
|
+
"prefix": ""
|
|
15
|
+
},
|
|
16
|
+
"aliases": {
|
|
17
|
+
"components": "@/components",
|
|
18
|
+
"utils": "@/lib/utils",
|
|
19
|
+
"ui": "@/components/ui",
|
|
20
|
+
"lib": "@/lib",
|
|
21
|
+
"hooks": "@/lib/hooks"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Initial components to generate** (only what the scaffolded templates actually import — add more on demand via `pnpm dlx shadcn@latest add <name>`):
|
|
27
|
+
|
|
28
|
+
- `button`
|
|
29
|
+
- `input`
|
|
30
|
+
- `label`
|
|
31
|
+
- `form` (the RHF-aware wrapper)
|
|
32
|
+
- `dialog`
|
|
33
|
+
- `dropdown-menu`
|
|
34
|
+
- `select`
|
|
35
|
+
- `popover`
|
|
36
|
+
- `calendar` (only if forms need a date picker)
|
|
37
|
+
- `toast` + `toaster` + `use-toast`
|
|
38
|
+
- `card`
|
|
39
|
+
- `separator`
|
|
40
|
+
|
|
41
|
+
**Do not** bulk-install every shadcn primitive at scaffold time. Each one is owned code that needs to be reviewed and maintained.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Env validation, `cn()`, and shared utils
|
|
2
|
+
|
|
3
|
+
## `src/config/env.ts` (runtime-validated env)
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
const envSchema = z.object({
|
|
9
|
+
NEXT_PUBLIC_API_BASE_URL: z.string().url(),
|
|
10
|
+
// Add more vars here as needed. Keep client-readable ones prefixed with NEXT_PUBLIC_.
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function loadEnv() {
|
|
14
|
+
const parsed = envSchema.safeParse({
|
|
15
|
+
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
|
16
|
+
});
|
|
17
|
+
if (!parsed.success) {
|
|
18
|
+
// Fail fast at module load.
|
|
19
|
+
console.error('Invalid environment configuration:', parsed.error.flatten().fieldErrors);
|
|
20
|
+
throw new Error('Invalid environment configuration. See .env.example for required variables.');
|
|
21
|
+
}
|
|
22
|
+
return parsed.data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const env = loadEnv();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Why:** misconfiguration (`NEXT_PUBLIC_API_BASE_URL` undefined, typo'd) blows up at startup with a clear error, not via a confusing 404 on the first network request.
|
|
29
|
+
|
|
30
|
+
## `.env.example` (committed)
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
# Backend API base URL. Required.
|
|
34
|
+
NEXT_PUBLIC_API_BASE_URL=http://localhost:5000
|
|
35
|
+
|
|
36
|
+
# Optional: real-time
|
|
37
|
+
# NEXT_PUBLIC_PUSHER_KEY=
|
|
38
|
+
# NEXT_PUBLIC_PUSHER_CLUSTER=
|
|
39
|
+
|
|
40
|
+
# Server-only: JWT verification key (only for Variant B middleware).
|
|
41
|
+
# JWT_VERIFY_KEY=
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`.env`, `.env.local`, `.env.*.local` go in `.gitignore`. Only `.env.example` is committed.
|
|
45
|
+
|
|
46
|
+
## `src/lib/utils.ts`
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
50
|
+
import { twMerge } from 'tailwind-merge';
|
|
51
|
+
|
|
52
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
53
|
+
return twMerge(clsx(inputs));
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's the entire file. No bag of unrelated helpers — those go in `src/lib/formatters/` or feature-local utility files.
|
|
58
|
+
|
|
59
|
+
## `src/lib/formatters/date.ts`
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
|
63
|
+
|
|
64
|
+
export function formatDate(value: string | Date, pattern = 'PP'): string {
|
|
65
|
+
const d = typeof value === 'string' ? parseISO(value) : value;
|
|
66
|
+
return format(d, pattern);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatRelative(value: string | Date): string {
|
|
70
|
+
const d = typeof value === 'string' ? parseISO(value) : value;
|
|
71
|
+
return formatDistanceToNow(d, { addSuffix: true });
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**No `moment`. Anywhere. Ever.** ESLint's `no-restricted-imports` blocks new imports of it.
|
|
76
|
+
|
|
77
|
+
## `src/components/UnsavedChangesWarning.tsx`
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
'use client';
|
|
81
|
+
|
|
82
|
+
import { useEffect } from 'react';
|
|
83
|
+
|
|
84
|
+
interface Props {
|
|
85
|
+
when: boolean;
|
|
86
|
+
message?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function UnsavedChangesWarning({ when, message = 'You have unsaved changes. Leave anyway?' }: Props) {
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!when) return;
|
|
92
|
+
|
|
93
|
+
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
e.returnValue = message;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
99
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
100
|
+
}, [when, message]);
|
|
101
|
+
|
|
102
|
+
// Next's App Router does not yet expose a stable router-events API for intercepting client-side navigation.
|
|
103
|
+
// The pattern below (overriding history.pushState) is a workaround — review and adjust per the Next version
|
|
104
|
+
// resolved at scaffold time (use context7 to check whether the official `useBeforeUnload` / `unstable_*` API has landed).
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Adjust at scaffold time:** if the Next.js version resolved in Step 1 exposes a stable navigation-intercept hook, use it instead of the `beforeunload`-only fallback. Verify via context7.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ESLint + Prettier templates
|
|
2
|
+
|
|
3
|
+
## `.eslintrc.json`
|
|
4
|
+
|
|
5
|
+
```jsonc
|
|
6
|
+
{
|
|
7
|
+
"root": true,
|
|
8
|
+
"extends": [
|
|
9
|
+
"next/core-web-vitals",
|
|
10
|
+
"next/typescript",
|
|
11
|
+
"plugin:@typescript-eslint/recommended",
|
|
12
|
+
"plugin:react-hooks/recommended",
|
|
13
|
+
"plugin:jsx-a11y/recommended",
|
|
14
|
+
"prettier"
|
|
15
|
+
],
|
|
16
|
+
"plugins": ["@typescript-eslint", "react-hooks", "jsx-a11y"],
|
|
17
|
+
"rules": {
|
|
18
|
+
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
|
19
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
20
|
+
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
|
21
|
+
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports" }],
|
|
22
|
+
"react-hooks/exhaustive-deps": "error",
|
|
23
|
+
"prefer-const": "error",
|
|
24
|
+
"eqeqeq": ["error", "always", { "null": "ignore" }],
|
|
25
|
+
"no-restricted-imports": [
|
|
26
|
+
"error",
|
|
27
|
+
{
|
|
28
|
+
"paths": [
|
|
29
|
+
{ "name": "moment", "message": "Use date-fns. moment is in maintenance mode." },
|
|
30
|
+
{ "name": "styled-components", "message": "This project uses Tailwind. Do not add CSS-in-JS." },
|
|
31
|
+
{ "name": "lodash", "message": "Import the specific lodash function: 'lodash/debounce'. Or write a one-line helper." }
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"overrides": [
|
|
37
|
+
{
|
|
38
|
+
"files": ["tests/**/*", "**/*.test.{ts,tsx}"],
|
|
39
|
+
"rules": {
|
|
40
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
41
|
+
"no-console": "off"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## `.prettierrc`
|
|
49
|
+
|
|
50
|
+
```jsonc
|
|
51
|
+
{
|
|
52
|
+
"singleQuote": true,
|
|
53
|
+
"semi": true,
|
|
54
|
+
"trailingComma": "all",
|
|
55
|
+
"printWidth": 100,
|
|
56
|
+
"tabWidth": 2,
|
|
57
|
+
"plugins": ["prettier-plugin-tailwindcss"]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## `.prettierignore`
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
.next/
|
|
65
|
+
node_modules/
|
|
66
|
+
out/
|
|
67
|
+
dist/
|
|
68
|
+
build/
|
|
69
|
+
coverage/
|
|
70
|
+
playwright-report/
|
|
71
|
+
test-results/
|
|
72
|
+
*.lock
|
|
73
|
+
*.lockb
|
|
74
|
+
pnpm-lock.yaml
|
|
75
|
+
package-lock.json
|
|
76
|
+
yarn.lock
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Notes:**
|
|
80
|
+
- `prettier-plugin-tailwindcss` sorts Tailwind class names automatically — keep class lists alphabetized without bikeshedding.
|
|
81
|
+
- The `no-restricted-imports` rule is the scaffolder's enforcement of the date library / styling system choices. Removing these rules requires a discussion, not a unilateral edit.
|
|
82
|
+
- Keep ESLint warnings *off* a fresh scaffold; warnings only show up after the project adds something that triggers them. CI runs `--max-warnings=0` so they fail loud.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Feature slice template (Mode 2 — `add-feature`)
|
|
2
|
+
|
|
3
|
+
A feature is a route folder under a route group, with its own private `_components/` and `_hooks/`, a local Zod `schema.ts`, and (usually) one or more RTK Query endpoints injected into the appropriate domain slice.
|
|
4
|
+
|
|
5
|
+
## Layout for feature `{{feature}}` under `(app)`
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/app/(app)/{{feature}}/
|
|
9
|
+
├── page.tsx # SERVER component (renders client child if interactive)
|
|
10
|
+
├── loading.tsx
|
|
11
|
+
├── error.tsx # optional but recommended
|
|
12
|
+
├── schema.ts # Zod schemas owned by this feature
|
|
13
|
+
├── _components/
|
|
14
|
+
│ ├── {{Feature}}Table.tsx # 'use client' if it has interactions
|
|
15
|
+
│ └── {{Feature}}Form.tsx # 'use client'
|
|
16
|
+
└── _hooks/
|
|
17
|
+
└── use{{Feature}}Filters.ts
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For a feature with sub-routes (list + detail + new):
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/app/(app)/{{feature}}/
|
|
24
|
+
├── page.tsx # list
|
|
25
|
+
├── new/
|
|
26
|
+
│ └── page.tsx # SERVER renders <{{Feature}}Form mode="create" />
|
|
27
|
+
├── [id]/
|
|
28
|
+
│ ├── page.tsx # SERVER renders <{{Feature}}Form mode="edit" id={id} />
|
|
29
|
+
│ ├── loading.tsx
|
|
30
|
+
│ └── _components/{{Feature}}DetailPanel.tsx
|
|
31
|
+
├── schema.ts
|
|
32
|
+
├── _components/
|
|
33
|
+
│ ├── {{Feature}}Table.tsx
|
|
34
|
+
│ └── {{Feature}}Form.tsx
|
|
35
|
+
└── _hooks/
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## `page.tsx` for the list view (SERVER)
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { Suspense } from 'react';
|
|
42
|
+
import { {{Feature}}Table } from './_components/{{Feature}}Table';
|
|
43
|
+
|
|
44
|
+
export const metadata = { title: '{{Feature}}s' };
|
|
45
|
+
|
|
46
|
+
export default function {{Feature}}sPage() {
|
|
47
|
+
return (
|
|
48
|
+
<section className="space-y-4">
|
|
49
|
+
<header className="flex items-center justify-between">
|
|
50
|
+
<h1 className="text-2xl font-semibold">{{Feature}}s</h1>
|
|
51
|
+
</header>
|
|
52
|
+
<Suspense fallback={<div>Loading…</div>}>
|
|
53
|
+
<{{Feature}}Table />
|
|
54
|
+
</Suspense>
|
|
55
|
+
</section>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## `_components/{{Feature}}Table.tsx` (CLIENT)
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
'use client';
|
|
64
|
+
|
|
65
|
+
import { use{{Feature}}sQuery } from '@/redux/api/{{feature}}sApi';
|
|
66
|
+
|
|
67
|
+
export function {{Feature}}sTable() {
|
|
68
|
+
const { data, isLoading, error } = use{{Feature}}sQuery();
|
|
69
|
+
|
|
70
|
+
if (isLoading) return <p className="text-muted-foreground">Loading…</p>;
|
|
71
|
+
if (error) return <p className="text-destructive">Failed to load.</p>;
|
|
72
|
+
if (!data?.length) return <p className="text-muted-foreground">No {{feature}}s yet.</p>;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<table className="w-full text-left">
|
|
76
|
+
<thead>
|
|
77
|
+
<tr className="border-b">
|
|
78
|
+
<th className="py-2">Name</th>
|
|
79
|
+
<th className="py-2">Created</th>
|
|
80
|
+
</tr>
|
|
81
|
+
</thead>
|
|
82
|
+
<tbody>
|
|
83
|
+
{data.map((row) => (
|
|
84
|
+
<tr key={row.id} className="border-b">
|
|
85
|
+
<td className="py-2">{row.name}</td>
|
|
86
|
+
<td className="py-2 text-muted-foreground">{row.createdAt}</td>
|
|
87
|
+
</tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## `schema.ts`
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { z } from 'zod';
|
|
99
|
+
|
|
100
|
+
export const {{feature}}Schema = z.object({
|
|
101
|
+
name: z.string().min(1).max(120),
|
|
102
|
+
email: z.string().email(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export type {{Feature}}FormValues = z.infer<typeof {{feature}}Schema>;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## `_components/{{Feature}}Form.tsx` (CLIENT)
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
'use client';
|
|
112
|
+
|
|
113
|
+
import { useRouter } from 'next/navigation';
|
|
114
|
+
import { useForm } from 'react-hook-form';
|
|
115
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
116
|
+
import { Form } from '@/components/ui/form';
|
|
117
|
+
import { Button } from '@/components/ui/button';
|
|
118
|
+
import { FormInputField } from '@/components/forms/FormInputField';
|
|
119
|
+
import { UnsavedChangesWarning } from '@/components/UnsavedChangesWarning';
|
|
120
|
+
import { useToast } from '@/components/ui/use-toast';
|
|
121
|
+
import { useCreate{{Feature}}Mutation } from '@/redux/api/{{feature}}sApi';
|
|
122
|
+
import { getDefaultValuesFromSchema, unwrapZodEffects } from '@/lib/zod-utils';
|
|
123
|
+
import { {{feature}}Schema, type {{Feature}}FormValues } from '../schema';
|
|
124
|
+
|
|
125
|
+
export function {{Feature}}Form() {
|
|
126
|
+
const router = useRouter();
|
|
127
|
+
const { toast } = useToast();
|
|
128
|
+
const [create, { isLoading }] = useCreate{{Feature}}Mutation();
|
|
129
|
+
|
|
130
|
+
const form = useForm<{{Feature}}FormValues>({
|
|
131
|
+
resolver: zodResolver({{feature}}Schema),
|
|
132
|
+
defaultValues: getDefaultValuesFromSchema(unwrapZodEffects({{feature}}Schema)) as {{Feature}}FormValues,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const onSubmit = async (values: {{Feature}}FormValues) => {
|
|
136
|
+
try {
|
|
137
|
+
const created = await create(values).unwrap();
|
|
138
|
+
toast({ title: '{{Feature}} created.' });
|
|
139
|
+
router.replace(`/app/{{feature}}s/${created.id}`);
|
|
140
|
+
} catch {
|
|
141
|
+
toast({ title: 'Could not save', variant: 'destructive' });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Form {...form}>
|
|
147
|
+
<UnsavedChangesWarning when={form.formState.isDirty && !form.formState.isSubmitting} />
|
|
148
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-lg space-y-4">
|
|
149
|
+
<FormInputField form={form} schema={{feature}}Schema} fieldName="name" label="Name" />
|
|
150
|
+
<FormInputField form={form} schema={{feature}}Schema} fieldName="email" label="Email" type="email" />
|
|
151
|
+
<div className="flex gap-2">
|
|
152
|
+
<Button type="submit" disabled={isLoading}>{isLoading ? 'Saving…' : 'Save'}</Button>
|
|
153
|
+
<Button type="button" variant="ghost" onClick={() => router.back()}>Cancel</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</form>
|
|
156
|
+
</Form>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Schema unit test — `tests/unit/{{feature}}.schema.test.ts`
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { describe, expect, it } from 'vitest';
|
|
165
|
+
import { {{feature}}Schema } from '@/app/(app)/{{feature}}s/schema';
|
|
166
|
+
|
|
167
|
+
describe('{{feature}}Schema', () => {
|
|
168
|
+
it('accepts a valid payload', () => {
|
|
169
|
+
expect({{feature}}Schema.safeParse({ name: 'Acme', email: 'ops@acme.test' }).success).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('rejects empty name', () => {
|
|
173
|
+
expect({{feature}}Schema.safeParse({ name: '', email: 'ops@acme.test' }).success).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects invalid email', () => {
|
|
177
|
+
expect({{feature}}Schema.safeParse({ name: 'Acme', email: 'nope' }).success).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Endpoint additions
|
|
183
|
+
|
|
184
|
+
If the domain slice doesn't exist yet, run Mode 3 first (`add-api-slice`). Then add the feature's query/mutation pair to that slice — see [`api-slice.md`](api-slice.md).
|