create-simone-app 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/README.md +70 -0
- package/bin/create-simone-app.ts +6 -0
- package/package.json +61 -0
- package/src/cli.ts +97 -0
- package/src/matrix.ts +39 -0
- package/src/options.ts +137 -0
- package/src/prompts.ts +108 -0
- package/src/scaffold.ts +304 -0
- package/src/templates.ts +77 -0
- package/templates/api/base/.agents/skills/ai-rules/SKILL.md +12 -0
- package/templates/api/base/.agents/skills/plan-feature/SKILL.md +13 -0
- package/templates/api/base/.agents/skills/review-diff/SKILL.md +12 -0
- package/templates/api/base/.agents/skills/tdd-step/SKILL.md +14 -0
- package/templates/api/base/.claude/agents/reviewer.md +20 -0
- package/templates/api/base/.claude/hooks/format.sh +22 -0
- package/templates/api/base/.claude/settings.json +38 -0
- package/templates/api/base/.codex/agents/reviewer.toml +7 -0
- package/templates/api/base/.codex/config.toml +10 -0
- package/templates/api/base/.codex/hooks.json +12 -0
- package/templates/api/base/.env.example +2 -0
- package/templates/api/base/.github/workflows/ci.yml +18 -0
- package/templates/api/base/.prettierignore +10 -0
- package/templates/api/base/.prettierrc +20 -0
- package/templates/api/base/CLAUDE.md +1 -0
- package/templates/api/base/README.md +43 -0
- package/templates/api/base/TASK.md.template +30 -0
- package/templates/api/base/_gitignore +8 -0
- package/templates/api/base/docs/agents/base.md +54 -0
- package/templates/api/base/eslint.config.js +21 -0
- package/templates/api/base/openapi.yaml +233 -0
- package/templates/api/base/package.json +41 -0
- package/templates/api/base/scripts/agents-build.ts +26 -0
- package/templates/api/base/scripts/rules-check.ts +48 -0
- package/templates/api/base/scripts/rules-sync.ts +35 -0
- package/templates/api/base/scripts/rules.config.ts +28 -0
- package/templates/api/base/scripts/seed-regen.ts +64 -0
- package/templates/api/base/src/data/orders.json +187 -0
- package/templates/api/base/src/data/products.json +142 -0
- package/templates/api/base/src/data/repository.ts +55 -0
- package/templates/api/base/src/data/schemas.ts +44 -0
- package/templates/api/base/src/data/users.json +352 -0
- package/templates/api/base/src/lib/appName.ts +2 -0
- package/templates/api/base/src/lib/http-error.ts +14 -0
- package/templates/api/base/src/lib/pagination.ts +55 -0
- package/templates/api/base/src/modules/orders/orders.repository.ts +26 -0
- package/templates/api/base/src/modules/orders/orders.schema.ts +11 -0
- package/templates/api/base/src/modules/products/products.repository.ts +17 -0
- package/templates/api/base/src/modules/products/products.schema.ts +21 -0
- package/templates/api/base/src/modules/stats/stats.repository.ts +37 -0
- package/templates/api/base/src/modules/users/users.repository.ts +26 -0
- package/templates/api/base/src/modules/users/users.schema.ts +15 -0
- package/templates/api/base/test/docs.test.ts +23 -0
- package/templates/api/base/test/health.test.ts +19 -0
- package/templates/api/base/test/orders.test.ts +82 -0
- package/templates/api/base/test/products.test.ts +54 -0
- package/templates/api/base/test/repository.test.ts +45 -0
- package/templates/api/base/test/smoke.test.ts +7 -0
- package/templates/api/base/test/stats.test.ts +40 -0
- package/templates/api/base/test/users.test.ts +84 -0
- package/templates/api/base/tsconfig.json +22 -0
- package/templates/api/base/vitest.config.ts +16 -0
- package/templates/api/variants/features/no-github-ci/variant.json +1 -0
- package/templates/api/variants/framework/express/docs/agents/framework-express.md +9 -0
- package/templates/api/variants/framework/express/src/app.ts +37 -0
- package/templates/api/variants/framework/express/src/middleware/errorHandler.ts +13 -0
- package/templates/api/variants/framework/express/src/middleware/validate.ts +59 -0
- package/templates/api/variants/framework/express/src/modules/docs/docs.router.ts +15 -0
- package/templates/api/variants/framework/express/src/modules/health/health.router.ts +7 -0
- package/templates/api/variants/framework/express/src/modules/orders/orders.router.ts +14 -0
- package/templates/api/variants/framework/express/src/modules/products/products.router.ts +24 -0
- package/templates/api/variants/framework/express/src/modules/stats/stats.router.ts +9 -0
- package/templates/api/variants/framework/express/src/modules/users/users.router.ts +24 -0
- package/templates/api/variants/framework/express/src/server.ts +8 -0
- package/templates/api/variants/framework/express/test/errorHandler.test.ts +89 -0
- package/templates/api/variants/framework/express/variant.json +15 -0
- package/templates/fe/base/.agents/skills/ai-rules/SKILL.md +12 -0
- package/templates/fe/base/.agents/skills/plan-feature/SKILL.md +13 -0
- package/templates/fe/base/.agents/skills/review-diff/SKILL.md +12 -0
- package/templates/fe/base/.agents/skills/tdd-step/SKILL.md +14 -0
- package/templates/fe/base/.claude/agents/reviewer.md +21 -0
- package/templates/fe/base/.claude/hooks/format.sh +22 -0
- package/templates/fe/base/.claude/settings.json +41 -0
- package/templates/fe/base/.codex/agents/reviewer.toml +7 -0
- package/templates/fe/base/.codex/config.toml +10 -0
- package/templates/fe/base/.codex/hooks.json +12 -0
- package/templates/fe/base/.env.example +2 -0
- package/templates/fe/base/.github/workflows/ci.yml +37 -0
- package/templates/fe/base/.prettierignore +14 -0
- package/templates/fe/base/.prettierrc +20 -0
- package/templates/fe/base/CLAUDE.md +1 -0
- package/templates/fe/base/README.md +37 -0
- package/templates/fe/base/TASK.md.template +31 -0
- package/templates/fe/base/_gitignore +11 -0
- package/templates/fe/base/docs/agents/base.md +69 -0
- package/templates/fe/base/e2e/users.spec.ts +82 -0
- package/templates/fe/base/eslint.config.js +169 -0
- package/templates/fe/base/index.html +13 -0
- package/templates/fe/base/package.json +63 -0
- package/templates/fe/base/playwright.config.ts +17 -0
- package/templates/fe/base/public/favicon.svg +1 -0
- package/templates/fe/base/public/icons.svg +24 -0
- package/templates/fe/base/scripts/agents-build.ts +26 -0
- package/templates/fe/base/scripts/rules-check.ts +48 -0
- package/templates/fe/base/scripts/rules-sync.ts +35 -0
- package/templates/fe/base/scripts/rules.config.ts +35 -0
- package/templates/fe/base/src/components/shared/EmptyState.tsx +23 -0
- package/templates/fe/base/src/components/shared/ErrorState.tsx +37 -0
- package/templates/fe/base/src/components/shared/PageHeader.tsx +21 -0
- package/templates/fe/base/src/features/users/UserSearch.tsx +48 -0
- package/templates/fe/base/src/features/users/UsersPage.tsx +58 -0
- package/templates/fe/base/src/features/users/UsersTable.tsx +245 -0
- package/templates/fe/base/src/features/users/UsersTableSkeleton.tsx +16 -0
- package/templates/fe/base/src/features/users/__tests__/UserSearch.test.tsx +30 -0
- package/templates/fe/base/src/features/users/__tests__/UsersPage.test.tsx +69 -0
- package/templates/fe/base/src/features/users/__tests__/UsersTable.test.tsx +120 -0
- package/templates/fe/base/src/features/users/__tests__/useUsers.test.tsx +62 -0
- package/templates/fe/base/src/features/users/useUsers.ts +22 -0
- package/templates/fe/base/src/features/users/users.api.ts +22 -0
- package/templates/fe/base/src/features/users/users.schema.ts +43 -0
- package/templates/fe/base/src/lib/apiClient.test.ts +56 -0
- package/templates/fe/base/src/lib/apiClient.ts +42 -0
- package/templates/fe/base/src/lib/appName.ts +2 -0
- package/templates/fe/base/src/lib/env.test.ts +21 -0
- package/templates/fe/base/src/lib/env.ts +26 -0
- package/templates/fe/base/src/lib/queryClient.ts +15 -0
- package/templates/fe/base/src/lib/router.ts +11 -0
- package/templates/fe/base/src/main.tsx +21 -0
- package/templates/fe/base/src/routeTree.gen.ts +77 -0
- package/templates/fe/base/src/routes/__root.tsx +83 -0
- package/templates/fe/base/src/routes/index.tsx +26 -0
- package/templates/fe/base/src/routes/users/index.tsx +24 -0
- package/templates/fe/base/src/stores/seam.test.ts +21 -0
- package/templates/fe/base/src/test/api-mock.ts +22 -0
- package/templates/fe/base/src/test/fixtures/users.ts +39 -0
- package/templates/fe/base/src/test/msw/handlers.ts +9 -0
- package/templates/fe/base/src/test/msw/server.ts +5 -0
- package/templates/fe/base/src/test/render.tsx +45 -0
- package/templates/fe/base/src/test/setup.ts +15 -0
- package/templates/fe/base/tsconfig.json +25 -0
- package/templates/fe/base/vite.config.ts +22 -0
- package/templates/fe/base/vitest.config.ts +21 -0
- package/templates/fe/variants/features/no-github-ci/variant.json +1 -0
- package/templates/fe/variants/features/no-msw/src/test/api-mock.ts +52 -0
- package/templates/fe/variants/features/no-msw/src/test/setup.ts +13 -0
- package/templates/fe/variants/features/no-msw/variant.json +4 -0
- package/templates/fe/variants/features/no-playwright/.github/workflows/ci.yml +19 -0
- package/templates/fe/variants/features/no-playwright/variant.json +5 -0
- package/templates/fe/variants/state/jotai/docs/agents/state-jotai.md +5 -0
- package/templates/fe/variants/state/jotai/src/stores/index.ts +15 -0
- package/templates/fe/variants/state/jotai/src/stores/ui.atoms.test.tsx +29 -0
- package/templates/fe/variants/state/jotai/src/stores/ui.atoms.ts +39 -0
- package/templates/fe/variants/state/jotai/variant.json +12 -0
- package/templates/fe/variants/state/zustand/docs/agents/state-zustand.md +5 -0
- package/templates/fe/variants/state/zustand/src/stores/index.ts +12 -0
- package/templates/fe/variants/state/zustand/src/stores/ui.store.test.ts +34 -0
- package/templates/fe/variants/state/zustand/src/stores/ui.store.ts +62 -0
- package/templates/fe/variants/state/zustand/variant.json +5 -0
- package/templates/fe/variants/ui/base-ui/components.json +25 -0
- package/templates/fe/variants/ui/base-ui/docs/agents/ui-base-ui.md +7 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/alert.tsx +75 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/button.tsx +57 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/input.tsx +19 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/skeleton.tsx +13 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/table.tsx +113 -0
- package/templates/fe/variants/ui/base-ui/src/index.css +130 -0
- package/templates/fe/variants/ui/base-ui/src/lib/utils.test.ts +10 -0
- package/templates/fe/variants/ui/base-ui/src/lib/utils.ts +1 -0
- package/templates/fe/variants/ui/base-ui/variant.json +23 -0
- package/templates/fe/variants/ui/radix/components.json +25 -0
- package/templates/fe/variants/ui/radix/docs/agents/ui-radix.md +7 -0
- package/templates/fe/variants/ui/radix/src/components/ui/alert.tsx +75 -0
- package/templates/fe/variants/ui/radix/src/components/ui/button.tsx +66 -0
- package/templates/fe/variants/ui/radix/src/components/ui/input.tsx +18 -0
- package/templates/fe/variants/ui/radix/src/components/ui/skeleton.tsx +13 -0
- package/templates/fe/variants/ui/radix/src/components/ui/table.tsx +113 -0
- package/templates/fe/variants/ui/radix/src/index.css +130 -0
- package/templates/fe/variants/ui/radix/src/lib/utils.test.ts +10 -0
- package/templates/fe/variants/ui/radix/src/lib/utils.ts +1 -0
- package/templates/fe/variants/ui/radix/variant.json +23 -0
- package/templates/fullstack/.github/workflows/ci.yml +18 -0
- package/templates/fullstack/AGENTS.md +7 -0
- package/templates/fullstack/CLAUDE.md +1 -0
- package/templates/fullstack/README.md +16 -0
- package/templates/fullstack/_gitignore +11 -0
- package/templates/fullstack/package.json +16 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const USER_ROLES = ['admin', 'editor', 'viewer'] as const
|
|
4
|
+
export type UserRole = (typeof USER_ROLES)[number]
|
|
5
|
+
|
|
6
|
+
export const UserSchema = z.object({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
name: z.string(),
|
|
9
|
+
email: z.string(),
|
|
10
|
+
role: z.enum(USER_ROLES),
|
|
11
|
+
createdAt: z.string(),
|
|
12
|
+
})
|
|
13
|
+
export type User = z.infer<typeof UserSchema>
|
|
14
|
+
|
|
15
|
+
/** API list contract shared with simone-api-template. */
|
|
16
|
+
export const UsersResponseSchema = z.object({
|
|
17
|
+
data: z.array(UserSchema),
|
|
18
|
+
page: z.number().int(),
|
|
19
|
+
limit: z.number().int(),
|
|
20
|
+
total: z.number().int(),
|
|
21
|
+
})
|
|
22
|
+
export type UsersResponse = z.infer<typeof UsersResponseSchema>
|
|
23
|
+
|
|
24
|
+
export const USER_SORT_FIELDS = ['name', 'email', 'role', 'createdAt'] as const
|
|
25
|
+
export type UserSortField = (typeof USER_SORT_FIELDS)[number]
|
|
26
|
+
|
|
27
|
+
/** Route search params (tanstack §3.4: table state lives in the URL). */
|
|
28
|
+
export const UsersQuerySchema = z.object({
|
|
29
|
+
page: z.number().int().min(1).catch(1),
|
|
30
|
+
limit: z.number().int().min(1).max(100).catch(20),
|
|
31
|
+
q: z.string().trim().catch(''),
|
|
32
|
+
sort: z.enum(USER_SORT_FIELDS).catch('name'),
|
|
33
|
+
order: z.enum(['asc', 'desc']).catch('asc'),
|
|
34
|
+
})
|
|
35
|
+
export type UsersQuery = z.infer<typeof UsersQuerySchema>
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_USERS_QUERY: UsersQuery = {
|
|
38
|
+
page: 1,
|
|
39
|
+
limit: 20,
|
|
40
|
+
q: '',
|
|
41
|
+
sort: 'name',
|
|
42
|
+
order: 'asc',
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { ApiError, apiClient } from './apiClient'
|
|
4
|
+
|
|
5
|
+
const originalAdapter = apiClient.defaults.adapter
|
|
6
|
+
|
|
7
|
+
/** Adapter that fails like axios does (duck-typed AxiosError: no axios import outside apiClient.ts). */
|
|
8
|
+
const failWith = (status: number, data: unknown): void => {
|
|
9
|
+
apiClient.defaults.adapter = async (config) => {
|
|
10
|
+
throw Object.assign(
|
|
11
|
+
new Error(`Request failed with status code ${status}`),
|
|
12
|
+
{
|
|
13
|
+
isAxiosError: true,
|
|
14
|
+
config,
|
|
15
|
+
response: { data, status, statusText: '', headers: {}, config },
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('apiClient response interceptor', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
apiClient.defaults.adapter = originalAdapter
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('maps the API error body to ApiError', async () => {
|
|
27
|
+
failWith(404, { error: 'User 42 not found', details: [{ path: 'id' }] })
|
|
28
|
+
const error = await apiClient.get('/users/42').catch((e: unknown) => e)
|
|
29
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
30
|
+
expect(error).toMatchObject({
|
|
31
|
+
status: 404,
|
|
32
|
+
message: 'User 42 not found',
|
|
33
|
+
details: [{ path: 'id' }],
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('falls back to the axios message when the body has no error field', async () => {
|
|
38
|
+
failWith(502, 'gateway down')
|
|
39
|
+
const error = await apiClient.get('/users').catch((e: unknown) => e)
|
|
40
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
41
|
+
expect((error as ApiError).status).toBe(502)
|
|
42
|
+
expect((error as ApiError).message).toMatch(/502/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('passes non-axios errors through untouched', async () => {
|
|
46
|
+
apiClient.defaults.adapter = async () => {
|
|
47
|
+
throw new TypeError('boom')
|
|
48
|
+
}
|
|
49
|
+
const error = await apiClient.get('/users').catch((e: unknown) => e)
|
|
50
|
+
expect(error).toBeInstanceOf(TypeError)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('uses the env base url', () => {
|
|
54
|
+
expect(apiClient.defaults.baseURL).toBe('/api')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import axios, { isAxiosError } from 'axios'
|
|
2
|
+
|
|
3
|
+
import { env } from '@/lib/env'
|
|
4
|
+
|
|
5
|
+
/** Normalised HTTP error: the API's `{ error, details? }` shape becomes message/details. */
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
readonly status: number,
|
|
9
|
+
message: string,
|
|
10
|
+
readonly details?: unknown,
|
|
11
|
+
) {
|
|
12
|
+
super(message)
|
|
13
|
+
this.name = 'ApiError'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ErrorBody {
|
|
18
|
+
error?: string
|
|
19
|
+
details?: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// core-rules §8.1: single axios instance; nothing else imports axios (enforced by ESLint).
|
|
23
|
+
export const apiClient = axios.create({
|
|
24
|
+
baseURL: env.apiBaseUrl,
|
|
25
|
+
timeout: 30_000,
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
apiClient.interceptors.response.use(
|
|
30
|
+
(response) => response,
|
|
31
|
+
(error: unknown) => {
|
|
32
|
+
if (!isAxiosError(error)) return Promise.reject(error)
|
|
33
|
+
const body = error.response?.data as ErrorBody | undefined
|
|
34
|
+
return Promise.reject(
|
|
35
|
+
new ApiError(
|
|
36
|
+
error.response?.status ?? 0,
|
|
37
|
+
body?.error ?? error.message,
|
|
38
|
+
body?.details,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { parseEnv } from './env'
|
|
4
|
+
|
|
5
|
+
describe('parseEnv', () => {
|
|
6
|
+
it('defaults VITE_API_BASE_URL to /api', () => {
|
|
7
|
+
expect(parseEnv({})).toEqual({ apiBaseUrl: '/api' })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('reads the configured base url', () => {
|
|
11
|
+
expect(
|
|
12
|
+
parseEnv({ VITE_API_BASE_URL: 'https://api.example.test' }).apiBaseUrl,
|
|
13
|
+
).toBe('https://api.example.test')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('fails fast on an empty value', () => {
|
|
17
|
+
expect(() => parseEnv({ VITE_API_BASE_URL: '' })).toThrow(
|
|
18
|
+
/VITE_API_BASE_URL/,
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const EnvSchema = z.object({
|
|
4
|
+
VITE_API_BASE_URL: z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1, 'VITE_API_BASE_URL must not be empty')
|
|
7
|
+
.default('/api'),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export interface Env {
|
|
11
|
+
apiBaseUrl: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Fail fast: an invalid env throws at module load instead of producing broken requests later. */
|
|
15
|
+
export const parseEnv = (raw: Record<string, unknown>): Env => {
|
|
16
|
+
const result = EnvSchema.safeParse(raw)
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
const issues = result.error.issues
|
|
19
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
20
|
+
.join('; ')
|
|
21
|
+
throw new Error(`Invalid environment: ${issues}`)
|
|
22
|
+
}
|
|
23
|
+
return { apiBaseUrl: result.data.VITE_API_BASE_URL }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const env: Env = parseEnv(import.meta.env)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import { ApiError } from '@/lib/apiClient'
|
|
4
|
+
|
|
5
|
+
export const queryClient = new QueryClient({
|
|
6
|
+
defaultOptions: {
|
|
7
|
+
queries: {
|
|
8
|
+
staleTime: 60_000,
|
|
9
|
+
// 4xx will not heal on retry; only retry network/5xx once.
|
|
10
|
+
retry: (failureCount, error) =>
|
|
11
|
+
failureCount < 1 &&
|
|
12
|
+
!(error instanceof ApiError && error.status > 0 && error.status < 500),
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createRouter } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { routeTree } from '@/routeTree.gen'
|
|
4
|
+
|
|
5
|
+
export const router = createRouter({ routeTree, defaultPreload: 'intent' })
|
|
6
|
+
|
|
7
|
+
declare module '@tanstack/react-router' {
|
|
8
|
+
interface Register {
|
|
9
|
+
router: typeof router
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
|
|
4
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import { RouterProvider } from '@tanstack/react-router'
|
|
6
|
+
|
|
7
|
+
import { queryClient } from '@/lib/queryClient'
|
|
8
|
+
import { router } from '@/lib/router'
|
|
9
|
+
|
|
10
|
+
import './index.css'
|
|
11
|
+
|
|
12
|
+
const root = document.getElementById('root')
|
|
13
|
+
if (!root) throw new Error('#root not found')
|
|
14
|
+
|
|
15
|
+
createRoot(root).render(
|
|
16
|
+
<StrictMode>
|
|
17
|
+
<QueryClientProvider client={queryClient}>
|
|
18
|
+
<RouterProvider router={router} />
|
|
19
|
+
</QueryClientProvider>
|
|
20
|
+
</StrictMode>,
|
|
21
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
// noinspection JSUnusedGlobalSymbols
|
|
6
|
+
|
|
7
|
+
// This file was automatically generated by TanStack Router.
|
|
8
|
+
// You should NOT make any changes in this file as it will be overwritten.
|
|
9
|
+
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
|
10
|
+
|
|
11
|
+
import { Route as rootRouteImport } from './routes/__root'
|
|
12
|
+
import { Route as IndexRouteImport } from './routes/index'
|
|
13
|
+
import { Route as UsersIndexRouteImport } from './routes/users/index'
|
|
14
|
+
|
|
15
|
+
const IndexRoute = IndexRouteImport.update({
|
|
16
|
+
id: '/',
|
|
17
|
+
path: '/',
|
|
18
|
+
getParentRoute: () => rootRouteImport,
|
|
19
|
+
} as any)
|
|
20
|
+
const UsersIndexRoute = UsersIndexRouteImport.update({
|
|
21
|
+
id: '/users/',
|
|
22
|
+
path: '/users/',
|
|
23
|
+
getParentRoute: () => rootRouteImport,
|
|
24
|
+
} as any)
|
|
25
|
+
|
|
26
|
+
export interface FileRoutesByFullPath {
|
|
27
|
+
'/': typeof IndexRoute
|
|
28
|
+
'/users/': typeof UsersIndexRoute
|
|
29
|
+
}
|
|
30
|
+
export interface FileRoutesByTo {
|
|
31
|
+
'/': typeof IndexRoute
|
|
32
|
+
'/users': typeof UsersIndexRoute
|
|
33
|
+
}
|
|
34
|
+
export interface FileRoutesById {
|
|
35
|
+
__root__: typeof rootRouteImport
|
|
36
|
+
'/': typeof IndexRoute
|
|
37
|
+
'/users/': typeof UsersIndexRoute
|
|
38
|
+
}
|
|
39
|
+
export interface FileRouteTypes {
|
|
40
|
+
fileRoutesByFullPath: FileRoutesByFullPath
|
|
41
|
+
fullPaths: '/' | '/users/'
|
|
42
|
+
fileRoutesByTo: FileRoutesByTo
|
|
43
|
+
to: '/' | '/users'
|
|
44
|
+
id: '__root__' | '/' | '/users/'
|
|
45
|
+
fileRoutesById: FileRoutesById
|
|
46
|
+
}
|
|
47
|
+
export interface RootRouteChildren {
|
|
48
|
+
IndexRoute: typeof IndexRoute
|
|
49
|
+
UsersIndexRoute: typeof UsersIndexRoute
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
declare module '@tanstack/react-router' {
|
|
53
|
+
interface FileRoutesByPath {
|
|
54
|
+
'/': {
|
|
55
|
+
id: '/'
|
|
56
|
+
path: '/'
|
|
57
|
+
fullPath: '/'
|
|
58
|
+
preLoaderRoute: typeof IndexRouteImport
|
|
59
|
+
parentRoute: typeof rootRouteImport
|
|
60
|
+
}
|
|
61
|
+
'/users/': {
|
|
62
|
+
id: '/users/'
|
|
63
|
+
path: '/users'
|
|
64
|
+
fullPath: '/users/'
|
|
65
|
+
preLoaderRoute: typeof UsersIndexRouteImport
|
|
66
|
+
parentRoute: typeof rootRouteImport
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const rootRouteChildren: RootRouteChildren = {
|
|
72
|
+
IndexRoute: IndexRoute,
|
|
73
|
+
UsersIndexRoute: UsersIndexRoute,
|
|
74
|
+
}
|
|
75
|
+
export const routeTree = rootRouteImport
|
|
76
|
+
._addFileChildren(rootRouteChildren)
|
|
77
|
+
._addFileTypes<FileRouteTypes>()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
HeadContent,
|
|
5
|
+
Link,
|
|
6
|
+
Outlet,
|
|
7
|
+
createRootRoute,
|
|
8
|
+
} from '@tanstack/react-router'
|
|
9
|
+
|
|
10
|
+
import { Moon, Sun } from 'lucide-react'
|
|
11
|
+
|
|
12
|
+
import { Button } from '@/components/ui/button'
|
|
13
|
+
import { APP_NAME } from '@/lib/appName'
|
|
14
|
+
import { useTheme } from '@/stores'
|
|
15
|
+
|
|
16
|
+
export const Route = createRootRoute({
|
|
17
|
+
head: () => ({ meta: [{ title: APP_NAME }] }),
|
|
18
|
+
component: RootLayout,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function RootLayout() {
|
|
22
|
+
const { theme, setTheme } = useTheme()
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
26
|
+
}, [theme])
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<HeadContent />
|
|
31
|
+
{/* a11y §4.1: skip link is the first focusable element */}
|
|
32
|
+
<a
|
|
33
|
+
href="#main"
|
|
34
|
+
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:outline-2 focus:outline-offset-2 focus:outline-ring"
|
|
35
|
+
data-testid="skip-link"
|
|
36
|
+
>
|
|
37
|
+
Salta al contenuto principale
|
|
38
|
+
</a>
|
|
39
|
+
<header className="border-b">
|
|
40
|
+
<nav
|
|
41
|
+
aria-label="Principale"
|
|
42
|
+
className="mx-auto flex max-w-5xl items-center gap-6 px-4 py-3"
|
|
43
|
+
data-testid="nav-main"
|
|
44
|
+
>
|
|
45
|
+
<Link to="/" className="font-semibold" data-testid="nav-home-link">
|
|
46
|
+
{APP_NAME}
|
|
47
|
+
</Link>
|
|
48
|
+
<Link
|
|
49
|
+
to="/users"
|
|
50
|
+
className="text-muted-foreground hover:text-foreground [&.active]:text-foreground"
|
|
51
|
+
data-testid="nav-users-link"
|
|
52
|
+
>
|
|
53
|
+
Utenti
|
|
54
|
+
</Link>
|
|
55
|
+
<Button
|
|
56
|
+
variant="ghost"
|
|
57
|
+
size="icon-sm"
|
|
58
|
+
className="ml-auto"
|
|
59
|
+
aria-label={
|
|
60
|
+
theme === 'dark' ? 'Attiva tema chiaro' : 'Attiva tema scuro'
|
|
61
|
+
}
|
|
62
|
+
aria-pressed={theme === 'dark'}
|
|
63
|
+
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
64
|
+
data-testid="nav-theme-button"
|
|
65
|
+
>
|
|
66
|
+
{theme === 'dark' ? (
|
|
67
|
+
<Sun aria-hidden="true" />
|
|
68
|
+
) : (
|
|
69
|
+
<Moon aria-hidden="true" />
|
|
70
|
+
)}
|
|
71
|
+
</Button>
|
|
72
|
+
</nav>
|
|
73
|
+
</header>
|
|
74
|
+
<main
|
|
75
|
+
id="main"
|
|
76
|
+
tabIndex={-1}
|
|
77
|
+
className="mx-auto max-w-5xl px-4 py-8 outline-none"
|
|
78
|
+
>
|
|
79
|
+
<Outlet />
|
|
80
|
+
</main>
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Link, createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { APP_NAME } from '@/lib/appName'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/')({
|
|
6
|
+
head: () => ({ meta: [{ title: `Home — ${APP_NAME}` }] }),
|
|
7
|
+
component: HomePage,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function HomePage() {
|
|
11
|
+
return (
|
|
12
|
+
<section data-testid="home-page">
|
|
13
|
+
<h1 className="text-2xl font-semibold">{APP_NAME}</h1>
|
|
14
|
+
<p className="mt-2 text-muted-foreground">
|
|
15
|
+
Template React 19 + TanStack + Tailwind v4 + shadcn (Base UI).
|
|
16
|
+
</p>
|
|
17
|
+
<Link
|
|
18
|
+
to="/users"
|
|
19
|
+
className="mt-4 inline-block underline"
|
|
20
|
+
data-testid="home-users-link"
|
|
21
|
+
>
|
|
22
|
+
Vai agli utenti
|
|
23
|
+
</Link>
|
|
24
|
+
</section>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SearchSchemaInput,
|
|
3
|
+
createFileRoute,
|
|
4
|
+
stripSearchParams,
|
|
5
|
+
} from '@tanstack/react-router'
|
|
6
|
+
|
|
7
|
+
import { UsersPage } from '@/features/users/UsersPage'
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_USERS_QUERY,
|
|
10
|
+
type UsersQuery,
|
|
11
|
+
UsersQuerySchema,
|
|
12
|
+
} from '@/features/users/users.schema'
|
|
13
|
+
import { APP_NAME } from '@/lib/appName'
|
|
14
|
+
|
|
15
|
+
export const Route = createFileRoute('/users/')({
|
|
16
|
+
// tanstack §3.4 / core-rules §7.3: cross-route state (page, sort, filter) lives in the URL.
|
|
17
|
+
// SearchSchemaInput: every param is optional for <Link>, always present after validation.
|
|
18
|
+
validateSearch: (
|
|
19
|
+
search: Partial<UsersQuery> & SearchSchemaInput,
|
|
20
|
+
): UsersQuery => UsersQuerySchema.parse(search),
|
|
21
|
+
search: { middlewares: [stripSearchParams<UsersQuery>(DEFAULT_USERS_QUERY)] },
|
|
22
|
+
head: () => ({ meta: [{ title: `Utenti — ${APP_NAME}` }] }),
|
|
23
|
+
component: UsersPage,
|
|
24
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
/** State seam: only src/stores may import zustand/jotai; consumers go through `@/stores`. */
|
|
6
|
+
const walk = (dir: string): string[] =>
|
|
7
|
+
readdirSync(dir).flatMap((name) => {
|
|
8
|
+
const path = join(dir, name)
|
|
9
|
+
return statSync(path).isDirectory() ? walk(path) : [path]
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('state seam', () => {
|
|
13
|
+
it('no zustand/jotai import outside src/stores', () => {
|
|
14
|
+
const offenders = walk('src')
|
|
15
|
+
.filter(
|
|
16
|
+
(f) => /\.(ts|tsx)$/.test(f) && !f.startsWith(join('src', 'stores')),
|
|
17
|
+
)
|
|
18
|
+
.filter((f) => /from ['"](zustand|jotai)/.test(readFileSync(f, 'utf8')))
|
|
19
|
+
expect(offenders).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { HttpResponse, type JsonBodyType, http } from 'msw'
|
|
2
|
+
|
|
3
|
+
import { server } from './msw/server'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Test seam over the HTTP mock: feature tests call these instead of touching MSW directly,
|
|
7
|
+
* so the `no-msw` variant can swap the implementation (axios adapter) without changing tests.
|
|
8
|
+
*/
|
|
9
|
+
export const apiMock = {
|
|
10
|
+
/** Next GET /api/users answers `status` with `body`. */
|
|
11
|
+
failOnce: (status: number, body: JsonBodyType) =>
|
|
12
|
+
server.use(
|
|
13
|
+
http.get('/api/users', () => HttpResponse.json(body, { status }), {
|
|
14
|
+
once: true,
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
/** Next GET /api/users answers 200 with `body`. */
|
|
18
|
+
respondOnce: (body: JsonBodyType) =>
|
|
19
|
+
server.use(
|
|
20
|
+
http.get('/api/users', () => HttpResponse.json(body), { once: true }),
|
|
21
|
+
),
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { User, UsersResponse } from '@/features/users/users.schema'
|
|
2
|
+
|
|
3
|
+
const ROLES = ['admin', 'editor', 'viewer'] as const
|
|
4
|
+
|
|
5
|
+
/** Deterministic fixture: 45 users → 3 pages of 20. Shared by unit tests (MSW) and e2e (page.route). */
|
|
6
|
+
export const mockUsers: User[] = Array.from({ length: 45 }, (_, i) => ({
|
|
7
|
+
id: `u-${String(i + 1).padStart(3, '0')}`,
|
|
8
|
+
name: `User ${String(i + 1).padStart(3, '0')}`,
|
|
9
|
+
email: `user${i + 1}@example.test`,
|
|
10
|
+
role: ROLES[i % 3] ?? 'viewer',
|
|
11
|
+
createdAt: new Date(Date.UTC(2025, 0, 1 + i)).toISOString(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
/** Mirrors the API's GET /api/users semantics (q, sort=-field, page, limit). */
|
|
15
|
+
export const listUsers = (url: URL): UsersResponse => {
|
|
16
|
+
const page = Number(url.searchParams.get('page') ?? 1)
|
|
17
|
+
const limit = Number(url.searchParams.get('limit') ?? 20)
|
|
18
|
+
const q = (url.searchParams.get('q') ?? '').toLowerCase()
|
|
19
|
+
const sort = url.searchParams.get('sort') ?? 'name'
|
|
20
|
+
const desc = sort.startsWith('-')
|
|
21
|
+
const field = (desc ? sort.slice(1) : sort) as keyof User
|
|
22
|
+
const filtered = mockUsers
|
|
23
|
+
.filter(
|
|
24
|
+
(u) =>
|
|
25
|
+
!q ||
|
|
26
|
+
u.name.toLowerCase().includes(q) ||
|
|
27
|
+
u.email.toLowerCase().includes(q),
|
|
28
|
+
)
|
|
29
|
+
.sort(
|
|
30
|
+
(a, b) =>
|
|
31
|
+
String(a[field]).localeCompare(String(b[field])) * (desc ? -1 : 1),
|
|
32
|
+
)
|
|
33
|
+
return {
|
|
34
|
+
data: filtered.slice((page - 1) * limit, page * limit),
|
|
35
|
+
page,
|
|
36
|
+
limit,
|
|
37
|
+
total: filtered.length,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
4
|
+
import {
|
|
5
|
+
RouterProvider,
|
|
6
|
+
createMemoryHistory,
|
|
7
|
+
createRouter,
|
|
8
|
+
} from '@tanstack/react-router'
|
|
9
|
+
|
|
10
|
+
import { render, renderHook } from '@testing-library/react'
|
|
11
|
+
|
|
12
|
+
import { routeTree } from '@/routeTree.gen'
|
|
13
|
+
import { StoreProvider } from '@/stores'
|
|
14
|
+
|
|
15
|
+
/** Fresh QueryClient per test: no retries, no cache bleed. */
|
|
16
|
+
export const createTestQueryClient = () =>
|
|
17
|
+
new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
|
|
18
|
+
|
|
19
|
+
/** Fresh state store + QueryClient per render. */
|
|
20
|
+
export const createWrapper = (client = createTestQueryClient()) =>
|
|
21
|
+
function Wrapper({ children }: { children: ReactNode }) {
|
|
22
|
+
return (
|
|
23
|
+
<StoreProvider>
|
|
24
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
25
|
+
</StoreProvider>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const renderWithQuery = (ui: ReactElement) =>
|
|
30
|
+
render(ui, { wrapper: createWrapper() })
|
|
31
|
+
|
|
32
|
+
export const renderHookWithQuery = <T,>(hook: () => T) =>
|
|
33
|
+
renderHook(hook, { wrapper: createWrapper() })
|
|
34
|
+
|
|
35
|
+
/** Full app on an in-memory history: exercises routes, validateSearch and providers. */
|
|
36
|
+
export const renderApp = (path: string) => {
|
|
37
|
+
const router = createRouter({
|
|
38
|
+
routeTree,
|
|
39
|
+
history: createMemoryHistory({ initialEntries: [path] }),
|
|
40
|
+
})
|
|
41
|
+
const utils = render(<RouterProvider router={router} />, {
|
|
42
|
+
wrapper: createWrapper(),
|
|
43
|
+
})
|
|
44
|
+
return { ...utils, router }
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { cleanup } from '@testing-library/react'
|
|
3
|
+
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { resetStores } from '@/stores'
|
|
6
|
+
|
|
7
|
+
import { server } from './msw/server'
|
|
8
|
+
|
|
9
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
server.resetHandlers()
|
|
12
|
+
resetStores()
|
|
13
|
+
cleanup()
|
|
14
|
+
})
|
|
15
|
+
afterAll(() => server.close())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"types": ["vite/client", "bun"],
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"noUnusedLocals": true,
|
|
13
|
+
"noUnusedParameters": true,
|
|
14
|
+
"noImplicitOverride": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"resolveJsonModule": true,
|
|
18
|
+
"allowImportingTsExtensions": true,
|
|
19
|
+
"noEmit": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"baseUrl": ".",
|
|
22
|
+
"paths": { "@/*": ["./src/*"] }
|
|
23
|
+
},
|
|
24
|
+
"include": ["src", "scripts", "e2e", "*.ts", "eslint.config.js"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
|
2
|
+
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
4
|
+
import react from '@vitejs/plugin-react'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { defineConfig } from 'vite'
|
|
7
|
+
|
|
8
|
+
// core-rules §9: port 3000 strict; mkcert/OIDC/MFE omitted (standalone template, see AGENTS.md).
|
|
9
|
+
// tanstack-ecosystem §1.1: router plugin FIRST, autoCodeSplitting on.
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
plugins: [
|
|
12
|
+
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
|
|
13
|
+
react(),
|
|
14
|
+
tailwindcss(),
|
|
15
|
+
],
|
|
16
|
+
resolve: { alias: { '@': path.resolve(import.meta.dirname, './src') } },
|
|
17
|
+
server: {
|
|
18
|
+
port: 3000,
|
|
19
|
+
strictPort: true,
|
|
20
|
+
proxy: { '/api': 'http://localhost:4000' },
|
|
21
|
+
},
|
|
22
|
+
})
|