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.
Files changed (185) hide show
  1. package/README.md +70 -0
  2. package/bin/create-simone-app.ts +6 -0
  3. package/package.json +61 -0
  4. package/src/cli.ts +97 -0
  5. package/src/matrix.ts +39 -0
  6. package/src/options.ts +137 -0
  7. package/src/prompts.ts +108 -0
  8. package/src/scaffold.ts +304 -0
  9. package/src/templates.ts +77 -0
  10. package/templates/api/base/.agents/skills/ai-rules/SKILL.md +12 -0
  11. package/templates/api/base/.agents/skills/plan-feature/SKILL.md +13 -0
  12. package/templates/api/base/.agents/skills/review-diff/SKILL.md +12 -0
  13. package/templates/api/base/.agents/skills/tdd-step/SKILL.md +14 -0
  14. package/templates/api/base/.claude/agents/reviewer.md +20 -0
  15. package/templates/api/base/.claude/hooks/format.sh +22 -0
  16. package/templates/api/base/.claude/settings.json +38 -0
  17. package/templates/api/base/.codex/agents/reviewer.toml +7 -0
  18. package/templates/api/base/.codex/config.toml +10 -0
  19. package/templates/api/base/.codex/hooks.json +12 -0
  20. package/templates/api/base/.env.example +2 -0
  21. package/templates/api/base/.github/workflows/ci.yml +18 -0
  22. package/templates/api/base/.prettierignore +10 -0
  23. package/templates/api/base/.prettierrc +20 -0
  24. package/templates/api/base/CLAUDE.md +1 -0
  25. package/templates/api/base/README.md +43 -0
  26. package/templates/api/base/TASK.md.template +30 -0
  27. package/templates/api/base/_gitignore +8 -0
  28. package/templates/api/base/docs/agents/base.md +54 -0
  29. package/templates/api/base/eslint.config.js +21 -0
  30. package/templates/api/base/openapi.yaml +233 -0
  31. package/templates/api/base/package.json +41 -0
  32. package/templates/api/base/scripts/agents-build.ts +26 -0
  33. package/templates/api/base/scripts/rules-check.ts +48 -0
  34. package/templates/api/base/scripts/rules-sync.ts +35 -0
  35. package/templates/api/base/scripts/rules.config.ts +28 -0
  36. package/templates/api/base/scripts/seed-regen.ts +64 -0
  37. package/templates/api/base/src/data/orders.json +187 -0
  38. package/templates/api/base/src/data/products.json +142 -0
  39. package/templates/api/base/src/data/repository.ts +55 -0
  40. package/templates/api/base/src/data/schemas.ts +44 -0
  41. package/templates/api/base/src/data/users.json +352 -0
  42. package/templates/api/base/src/lib/appName.ts +2 -0
  43. package/templates/api/base/src/lib/http-error.ts +14 -0
  44. package/templates/api/base/src/lib/pagination.ts +55 -0
  45. package/templates/api/base/src/modules/orders/orders.repository.ts +26 -0
  46. package/templates/api/base/src/modules/orders/orders.schema.ts +11 -0
  47. package/templates/api/base/src/modules/products/products.repository.ts +17 -0
  48. package/templates/api/base/src/modules/products/products.schema.ts +21 -0
  49. package/templates/api/base/src/modules/stats/stats.repository.ts +37 -0
  50. package/templates/api/base/src/modules/users/users.repository.ts +26 -0
  51. package/templates/api/base/src/modules/users/users.schema.ts +15 -0
  52. package/templates/api/base/test/docs.test.ts +23 -0
  53. package/templates/api/base/test/health.test.ts +19 -0
  54. package/templates/api/base/test/orders.test.ts +82 -0
  55. package/templates/api/base/test/products.test.ts +54 -0
  56. package/templates/api/base/test/repository.test.ts +45 -0
  57. package/templates/api/base/test/smoke.test.ts +7 -0
  58. package/templates/api/base/test/stats.test.ts +40 -0
  59. package/templates/api/base/test/users.test.ts +84 -0
  60. package/templates/api/base/tsconfig.json +22 -0
  61. package/templates/api/base/vitest.config.ts +16 -0
  62. package/templates/api/variants/features/no-github-ci/variant.json +1 -0
  63. package/templates/api/variants/framework/express/docs/agents/framework-express.md +9 -0
  64. package/templates/api/variants/framework/express/src/app.ts +37 -0
  65. package/templates/api/variants/framework/express/src/middleware/errorHandler.ts +13 -0
  66. package/templates/api/variants/framework/express/src/middleware/validate.ts +59 -0
  67. package/templates/api/variants/framework/express/src/modules/docs/docs.router.ts +15 -0
  68. package/templates/api/variants/framework/express/src/modules/health/health.router.ts +7 -0
  69. package/templates/api/variants/framework/express/src/modules/orders/orders.router.ts +14 -0
  70. package/templates/api/variants/framework/express/src/modules/products/products.router.ts +24 -0
  71. package/templates/api/variants/framework/express/src/modules/stats/stats.router.ts +9 -0
  72. package/templates/api/variants/framework/express/src/modules/users/users.router.ts +24 -0
  73. package/templates/api/variants/framework/express/src/server.ts +8 -0
  74. package/templates/api/variants/framework/express/test/errorHandler.test.ts +89 -0
  75. package/templates/api/variants/framework/express/variant.json +15 -0
  76. package/templates/fe/base/.agents/skills/ai-rules/SKILL.md +12 -0
  77. package/templates/fe/base/.agents/skills/plan-feature/SKILL.md +13 -0
  78. package/templates/fe/base/.agents/skills/review-diff/SKILL.md +12 -0
  79. package/templates/fe/base/.agents/skills/tdd-step/SKILL.md +14 -0
  80. package/templates/fe/base/.claude/agents/reviewer.md +21 -0
  81. package/templates/fe/base/.claude/hooks/format.sh +22 -0
  82. package/templates/fe/base/.claude/settings.json +41 -0
  83. package/templates/fe/base/.codex/agents/reviewer.toml +7 -0
  84. package/templates/fe/base/.codex/config.toml +10 -0
  85. package/templates/fe/base/.codex/hooks.json +12 -0
  86. package/templates/fe/base/.env.example +2 -0
  87. package/templates/fe/base/.github/workflows/ci.yml +37 -0
  88. package/templates/fe/base/.prettierignore +14 -0
  89. package/templates/fe/base/.prettierrc +20 -0
  90. package/templates/fe/base/CLAUDE.md +1 -0
  91. package/templates/fe/base/README.md +37 -0
  92. package/templates/fe/base/TASK.md.template +31 -0
  93. package/templates/fe/base/_gitignore +11 -0
  94. package/templates/fe/base/docs/agents/base.md +69 -0
  95. package/templates/fe/base/e2e/users.spec.ts +82 -0
  96. package/templates/fe/base/eslint.config.js +169 -0
  97. package/templates/fe/base/index.html +13 -0
  98. package/templates/fe/base/package.json +63 -0
  99. package/templates/fe/base/playwright.config.ts +17 -0
  100. package/templates/fe/base/public/favicon.svg +1 -0
  101. package/templates/fe/base/public/icons.svg +24 -0
  102. package/templates/fe/base/scripts/agents-build.ts +26 -0
  103. package/templates/fe/base/scripts/rules-check.ts +48 -0
  104. package/templates/fe/base/scripts/rules-sync.ts +35 -0
  105. package/templates/fe/base/scripts/rules.config.ts +35 -0
  106. package/templates/fe/base/src/components/shared/EmptyState.tsx +23 -0
  107. package/templates/fe/base/src/components/shared/ErrorState.tsx +37 -0
  108. package/templates/fe/base/src/components/shared/PageHeader.tsx +21 -0
  109. package/templates/fe/base/src/features/users/UserSearch.tsx +48 -0
  110. package/templates/fe/base/src/features/users/UsersPage.tsx +58 -0
  111. package/templates/fe/base/src/features/users/UsersTable.tsx +245 -0
  112. package/templates/fe/base/src/features/users/UsersTableSkeleton.tsx +16 -0
  113. package/templates/fe/base/src/features/users/__tests__/UserSearch.test.tsx +30 -0
  114. package/templates/fe/base/src/features/users/__tests__/UsersPage.test.tsx +69 -0
  115. package/templates/fe/base/src/features/users/__tests__/UsersTable.test.tsx +120 -0
  116. package/templates/fe/base/src/features/users/__tests__/useUsers.test.tsx +62 -0
  117. package/templates/fe/base/src/features/users/useUsers.ts +22 -0
  118. package/templates/fe/base/src/features/users/users.api.ts +22 -0
  119. package/templates/fe/base/src/features/users/users.schema.ts +43 -0
  120. package/templates/fe/base/src/lib/apiClient.test.ts +56 -0
  121. package/templates/fe/base/src/lib/apiClient.ts +42 -0
  122. package/templates/fe/base/src/lib/appName.ts +2 -0
  123. package/templates/fe/base/src/lib/env.test.ts +21 -0
  124. package/templates/fe/base/src/lib/env.ts +26 -0
  125. package/templates/fe/base/src/lib/queryClient.ts +15 -0
  126. package/templates/fe/base/src/lib/router.ts +11 -0
  127. package/templates/fe/base/src/main.tsx +21 -0
  128. package/templates/fe/base/src/routeTree.gen.ts +77 -0
  129. package/templates/fe/base/src/routes/__root.tsx +83 -0
  130. package/templates/fe/base/src/routes/index.tsx +26 -0
  131. package/templates/fe/base/src/routes/users/index.tsx +24 -0
  132. package/templates/fe/base/src/stores/seam.test.ts +21 -0
  133. package/templates/fe/base/src/test/api-mock.ts +22 -0
  134. package/templates/fe/base/src/test/fixtures/users.ts +39 -0
  135. package/templates/fe/base/src/test/msw/handlers.ts +9 -0
  136. package/templates/fe/base/src/test/msw/server.ts +5 -0
  137. package/templates/fe/base/src/test/render.tsx +45 -0
  138. package/templates/fe/base/src/test/setup.ts +15 -0
  139. package/templates/fe/base/tsconfig.json +25 -0
  140. package/templates/fe/base/vite.config.ts +22 -0
  141. package/templates/fe/base/vitest.config.ts +21 -0
  142. package/templates/fe/variants/features/no-github-ci/variant.json +1 -0
  143. package/templates/fe/variants/features/no-msw/src/test/api-mock.ts +52 -0
  144. package/templates/fe/variants/features/no-msw/src/test/setup.ts +13 -0
  145. package/templates/fe/variants/features/no-msw/variant.json +4 -0
  146. package/templates/fe/variants/features/no-playwright/.github/workflows/ci.yml +19 -0
  147. package/templates/fe/variants/features/no-playwright/variant.json +5 -0
  148. package/templates/fe/variants/state/jotai/docs/agents/state-jotai.md +5 -0
  149. package/templates/fe/variants/state/jotai/src/stores/index.ts +15 -0
  150. package/templates/fe/variants/state/jotai/src/stores/ui.atoms.test.tsx +29 -0
  151. package/templates/fe/variants/state/jotai/src/stores/ui.atoms.ts +39 -0
  152. package/templates/fe/variants/state/jotai/variant.json +12 -0
  153. package/templates/fe/variants/state/zustand/docs/agents/state-zustand.md +5 -0
  154. package/templates/fe/variants/state/zustand/src/stores/index.ts +12 -0
  155. package/templates/fe/variants/state/zustand/src/stores/ui.store.test.ts +34 -0
  156. package/templates/fe/variants/state/zustand/src/stores/ui.store.ts +62 -0
  157. package/templates/fe/variants/state/zustand/variant.json +5 -0
  158. package/templates/fe/variants/ui/base-ui/components.json +25 -0
  159. package/templates/fe/variants/ui/base-ui/docs/agents/ui-base-ui.md +7 -0
  160. package/templates/fe/variants/ui/base-ui/src/components/ui/alert.tsx +75 -0
  161. package/templates/fe/variants/ui/base-ui/src/components/ui/button.tsx +57 -0
  162. package/templates/fe/variants/ui/base-ui/src/components/ui/input.tsx +19 -0
  163. package/templates/fe/variants/ui/base-ui/src/components/ui/skeleton.tsx +13 -0
  164. package/templates/fe/variants/ui/base-ui/src/components/ui/table.tsx +113 -0
  165. package/templates/fe/variants/ui/base-ui/src/index.css +130 -0
  166. package/templates/fe/variants/ui/base-ui/src/lib/utils.test.ts +10 -0
  167. package/templates/fe/variants/ui/base-ui/src/lib/utils.ts +1 -0
  168. package/templates/fe/variants/ui/base-ui/variant.json +23 -0
  169. package/templates/fe/variants/ui/radix/components.json +25 -0
  170. package/templates/fe/variants/ui/radix/docs/agents/ui-radix.md +7 -0
  171. package/templates/fe/variants/ui/radix/src/components/ui/alert.tsx +75 -0
  172. package/templates/fe/variants/ui/radix/src/components/ui/button.tsx +66 -0
  173. package/templates/fe/variants/ui/radix/src/components/ui/input.tsx +18 -0
  174. package/templates/fe/variants/ui/radix/src/components/ui/skeleton.tsx +13 -0
  175. package/templates/fe/variants/ui/radix/src/components/ui/table.tsx +113 -0
  176. package/templates/fe/variants/ui/radix/src/index.css +130 -0
  177. package/templates/fe/variants/ui/radix/src/lib/utils.test.ts +10 -0
  178. package/templates/fe/variants/ui/radix/src/lib/utils.ts +1 -0
  179. package/templates/fe/variants/ui/radix/variant.json +23 -0
  180. package/templates/fullstack/.github/workflows/ci.yml +18 -0
  181. package/templates/fullstack/AGENTS.md +7 -0
  182. package/templates/fullstack/CLAUDE.md +1 -0
  183. package/templates/fullstack/README.md +16 -0
  184. package/templates/fullstack/_gitignore +11 -0
  185. package/templates/fullstack/package.json +16 -0
@@ -0,0 +1,58 @@
1
+ import { getRouteApi } from '@tanstack/react-router'
2
+
3
+ import { EmptyState } from '@/components/shared/EmptyState'
4
+ import { ErrorState } from '@/components/shared/ErrorState'
5
+ import { PageHeader } from '@/components/shared/PageHeader'
6
+
7
+ import { UserSearch } from './UserSearch'
8
+ import { UsersTable } from './UsersTable'
9
+ import { UsersTableSkeleton } from './UsersTableSkeleton'
10
+ import { useUsers } from './useUsers'
11
+ import type { UsersQuery } from './users.schema'
12
+
13
+ // getRouteApi avoids the route ↔ page circular import.
14
+ const routeApi = getRouteApi('/users/')
15
+
16
+ export function UsersPage() {
17
+ const search = routeApi.useSearch()
18
+ const navigate = routeApi.useNavigate()
19
+ const { data, isPending, isError, error, isFetching, refetch } =
20
+ useUsers(search)
21
+
22
+ const update = (patch: Partial<UsersQuery>) =>
23
+ void navigate({ to: '/users', search: (prev) => ({ ...prev, ...patch }) })
24
+
25
+ return (
26
+ <section aria-busy={isFetching} data-testid="users-page">
27
+ <PageHeader
28
+ title="Utenti"
29
+ description="Ricerca, ordinamento e paginazione lato server."
30
+ />
31
+ <UserSearch value={search.q} onChange={(q) => update({ q, page: 1 })} />
32
+ {isPending ? (
33
+ <UsersTableSkeleton />
34
+ ) : isError ? (
35
+ <ErrorState
36
+ message={error.message}
37
+ onRetry={() => void refetch()}
38
+ testId="users-error-state"
39
+ />
40
+ ) : data.total === 0 ? (
41
+ <EmptyState
42
+ title="Nessun utente trovato"
43
+ description={
44
+ search.q ? `Nessun risultato per “${search.q}”.` : undefined
45
+ }
46
+ testId="users-empty-state"
47
+ />
48
+ ) : (
49
+ <UsersTable
50
+ users={data.data}
51
+ total={data.total}
52
+ query={search}
53
+ onQueryChange={update}
54
+ />
55
+ )}
56
+ </section>
57
+ )
58
+ }
@@ -0,0 +1,245 @@
1
+ import {
2
+ type SortingState,
3
+ createColumnHelper,
4
+ flexRender,
5
+ getCoreRowModel,
6
+ useReactTable,
7
+ } from '@tanstack/react-table'
8
+
9
+ import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'
10
+
11
+ import { Button } from '@/components/ui/button'
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCaption,
16
+ TableCell,
17
+ TableHead,
18
+ TableHeader,
19
+ TableRow,
20
+ } from '@/components/ui/table'
21
+ import { useTableDensity } from '@/stores'
22
+
23
+ import {
24
+ USER_SORT_FIELDS,
25
+ type User,
26
+ type UserSortField,
27
+ type UsersQuery,
28
+ } from './users.schema'
29
+
30
+ interface UsersTableProps {
31
+ users: User[]
32
+ total: number
33
+ query: UsersQuery
34
+ onQueryChange: (patch: Partial<UsersQuery>) => void
35
+ }
36
+
37
+ const columnHelper = createColumnHelper<User>()
38
+ const dateFormatter = new Intl.DateTimeFormat('it-IT', { dateStyle: 'medium' })
39
+
40
+ const columns = [
41
+ columnHelper.accessor('name', { header: 'Nome' }),
42
+ columnHelper.accessor('email', { header: 'Email' }),
43
+ columnHelper.accessor('role', {
44
+ header: 'Ruolo',
45
+ cell: (info) => (
46
+ <span
47
+ className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium capitalize"
48
+ data-testid={`user-role-badge-${info.row.original.id}`}
49
+ >
50
+ {info.getValue()}
51
+ </span>
52
+ ),
53
+ }),
54
+ columnHelper.accessor('createdAt', {
55
+ header: 'Creato il',
56
+ cell: (info) => dateFormatter.format(new Date(info.getValue())),
57
+ }),
58
+ ]
59
+
60
+ const isSortField = (id: string): id is UserSortField =>
61
+ (USER_SORT_FIELDS as readonly string[]).includes(id)
62
+
63
+ /** Server-side table (tanstack §3.4): sorting/pagination live in the route search params. */
64
+ export function UsersTable({
65
+ users,
66
+ total,
67
+ query,
68
+ onQueryChange,
69
+ }: UsersTableProps) {
70
+ const { density, cellClass, toggle: toggleDensity } = useTableDensity()
71
+ const sorting: SortingState = [
72
+ { id: query.sort, desc: query.order === 'desc' },
73
+ ]
74
+ const pageCount = Math.max(1, Math.ceil(total / query.limit))
75
+
76
+ const table = useReactTable({
77
+ data: users,
78
+ columns,
79
+ rowCount: total,
80
+ state: {
81
+ sorting,
82
+ pagination: { pageIndex: query.page - 1, pageSize: query.limit },
83
+ },
84
+ manualSorting: true,
85
+ manualPagination: true,
86
+ enableSortingRemoval: false,
87
+ onSortingChange: (updater) => {
88
+ const next = typeof updater === 'function' ? updater(sorting) : updater
89
+ const first = next[0]
90
+ if (first && isSortField(first.id)) {
91
+ onQueryChange({
92
+ sort: first.id,
93
+ order: first.desc ? 'desc' : 'asc',
94
+ page: 1,
95
+ })
96
+ }
97
+ },
98
+ getCoreRowModel: getCoreRowModel(),
99
+ })
100
+
101
+ // ponytail: all page buttons rendered; add a window when a list exceeds ~10 pages.
102
+ const pages = Array.from({ length: pageCount }, (_, i) => i + 1)
103
+
104
+ return (
105
+ <div className="flex flex-col gap-3">
106
+ <div className="flex justify-end">
107
+ <Button
108
+ variant="outline"
109
+ size="sm"
110
+ aria-pressed={density === 'compact'}
111
+ onClick={toggleDensity}
112
+ data-testid="users-density-button"
113
+ >
114
+ Vista compatta
115
+ </Button>
116
+ </div>
117
+
118
+ <Table data-testid="users-table">
119
+ <TableCaption className="sr-only">
120
+ Elenco utenti: {total} risultati, pagina {query.page} di {pageCount}
121
+ </TableCaption>
122
+ <TableHeader>
123
+ {table.getHeaderGroups().map((headerGroup) => (
124
+ <TableRow key={headerGroup.id}>
125
+ {headerGroup.headers.map((header) => {
126
+ const sorted = header.column.getIsSorted()
127
+ const label = String(header.column.columnDef.header)
128
+ return (
129
+ <TableHead
130
+ key={header.id}
131
+ scope="col"
132
+ aria-sort={
133
+ sorted === 'asc'
134
+ ? 'ascending'
135
+ : sorted === 'desc'
136
+ ? 'descending'
137
+ : 'none'
138
+ }
139
+ className={cellClass}
140
+ >
141
+ <button
142
+ type="button"
143
+ onClick={header.column.getToggleSortingHandler()}
144
+ aria-label={
145
+ sorted === 'asc'
146
+ ? `Ordinato per ${label} crescente. Premere per invertire.`
147
+ : sorted === 'desc'
148
+ ? `Ordinato per ${label} decrescente. Premere per invertire.`
149
+ : `Ordina per ${label}`
150
+ }
151
+ className="flex items-center gap-1 font-medium"
152
+ data-testid={`users-table-sort-${header.column.id}`}
153
+ >
154
+ {flexRender(
155
+ header.column.columnDef.header,
156
+ header.getContext(),
157
+ )}
158
+ {sorted === 'asc' && (
159
+ <ArrowUp aria-hidden="true" className="size-4" />
160
+ )}
161
+ {sorted === 'desc' && (
162
+ <ArrowDown aria-hidden="true" className="size-4" />
163
+ )}
164
+ {!sorted && (
165
+ <ArrowUpDown
166
+ aria-hidden="true"
167
+ className="size-4 opacity-50"
168
+ />
169
+ )}
170
+ </button>
171
+ </TableHead>
172
+ )
173
+ })}
174
+ </TableRow>
175
+ ))}
176
+ </TableHeader>
177
+ <TableBody>
178
+ {table.getRowModel().rows.map((row) => (
179
+ <TableRow key={row.id} data-testid={`user-row-${row.original.id}`}>
180
+ {row.getVisibleCells().map((cell) => (
181
+ <TableCell key={cell.id} className={cellClass}>
182
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
183
+ </TableCell>
184
+ ))}
185
+ </TableRow>
186
+ ))}
187
+ </TableBody>
188
+ </Table>
189
+
190
+ <nav
191
+ aria-label="Paginazione utenti"
192
+ className="flex flex-wrap items-center justify-between gap-3"
193
+ data-testid="users-pagination"
194
+ >
195
+ <p
196
+ className="text-sm text-muted-foreground"
197
+ aria-live="polite"
198
+ data-testid="users-pagination-status"
199
+ >
200
+ Pagina {query.page} di {pageCount} · {total} utenti
201
+ </p>
202
+ <ul className="flex items-center gap-1">
203
+ <li>
204
+ <Button
205
+ variant="outline"
206
+ size="sm"
207
+ disabled={query.page <= 1}
208
+ onClick={() => onQueryChange({ page: query.page - 1 })}
209
+ aria-label="Pagina precedente"
210
+ data-testid="users-pagination-prev-button"
211
+ >
212
+ Precedente
213
+ </Button>
214
+ </li>
215
+ {pages.map((page) => (
216
+ <li key={page}>
217
+ <Button
218
+ variant={page === query.page ? 'default' : 'outline'}
219
+ size="sm"
220
+ aria-current={page === query.page ? 'page' : undefined}
221
+ aria-label={`Pagina ${page}${page === query.page ? ', corrente' : ''}`}
222
+ onClick={() => onQueryChange({ page })}
223
+ data-testid={`users-pagination-page-${page}`}
224
+ >
225
+ {page}
226
+ </Button>
227
+ </li>
228
+ ))}
229
+ <li>
230
+ <Button
231
+ variant="outline"
232
+ size="sm"
233
+ disabled={query.page >= pageCount}
234
+ onClick={() => onQueryChange({ page: query.page + 1 })}
235
+ aria-label="Pagina successiva"
236
+ data-testid="users-pagination-next-button"
237
+ >
238
+ Successiva
239
+ </Button>
240
+ </li>
241
+ </ul>
242
+ </nav>
243
+ </div>
244
+ )
245
+ }
@@ -0,0 +1,16 @@
1
+ import { Skeleton } from '@/components/ui/skeleton'
2
+
3
+ export function UsersTableSkeleton({ rows = 8 }: { rows?: number }) {
4
+ return (
5
+ <div
6
+ role="status"
7
+ aria-label="Caricamento utenti"
8
+ className="flex flex-col gap-2"
9
+ data-testid="users-table-skeleton"
10
+ >
11
+ {Array.from({ length: rows }, (_, i) => (
12
+ <Skeleton key={i} className="h-10 w-full" />
13
+ ))}
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,30 @@
1
+ import { render, screen, waitFor } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { UserSearch } from '../UserSearch'
6
+
7
+ describe('UserSearch', () => {
8
+ it('has an associated label and debounces keystrokes into one onChange', async () => {
9
+ const user = userEvent.setup()
10
+ const onChange = vi.fn()
11
+ render(<UserSearch value="" onChange={onChange} delay={50} />)
12
+
13
+ const input = screen.getByLabelText('Cerca')
14
+ expect(input).toBe(screen.getByTestId('users-search-input'))
15
+
16
+ await user.type(input, ' mar ')
17
+ expect(onChange).not.toHaveBeenCalled()
18
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith('mar'))
19
+ expect(onChange).toHaveBeenCalledTimes(1)
20
+ })
21
+
22
+ it('adopts an external value change', () => {
23
+ const { rerender } = render(
24
+ <UserSearch value="a" onChange={() => undefined} />,
25
+ )
26
+ expect(screen.getByTestId('users-search-input')).toHaveValue('a')
27
+ rerender(<UserSearch value="b" onChange={() => undefined} />)
28
+ expect(screen.getByTestId('users-search-input')).toHaveValue('b')
29
+ })
30
+ })
@@ -0,0 +1,69 @@
1
+ import { screen, waitFor } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { apiMock } from '@/test/api-mock'
6
+ import { renderApp } from '@/test/render'
7
+
8
+ describe('/users page', () => {
9
+ it('shows the skeleton, then the rows', async () => {
10
+ renderApp('/users')
11
+ expect(
12
+ await screen.findByTestId('users-table-skeleton'),
13
+ ).toBeInTheDocument()
14
+ expect(await screen.findByTestId('user-row-u-001')).toBeInTheDocument()
15
+ expect(screen.queryByTestId('users-table-skeleton')).not.toBeInTheDocument()
16
+ expect(screen.getAllByTestId(/^user-row-/)).toHaveLength(20)
17
+ })
18
+
19
+ it('reads page/sort/order from the URL and updates aria-sort on header click', async () => {
20
+ const user = userEvent.setup()
21
+ const { router } = renderApp('/users?page=2&sort=email&order=desc')
22
+ expect(await screen.findByTestId('user-row-u-025')).toBeInTheDocument()
23
+ expect(screen.getByTestId('users-pagination-page-2')).toHaveAttribute(
24
+ 'aria-current',
25
+ 'page',
26
+ )
27
+ const emailHeader = screen
28
+ .getByTestId('users-table-sort-email')
29
+ .closest('th')
30
+ expect(emailHeader).toHaveAttribute('aria-sort', 'descending')
31
+
32
+ await user.click(screen.getByTestId('users-table-sort-name'))
33
+ // sort=name/order=asc/page=1 are defaults → stripSearchParams leaves a clean URL
34
+ await waitFor(() => expect(router.state.location.search).toEqual({}))
35
+ expect(await screen.findByTestId('user-row-u-001')).toBeInTheDocument()
36
+ expect(
37
+ screen.getByTestId('users-table-sort-name').closest('th'),
38
+ ).toHaveAttribute('aria-sort', 'ascending')
39
+ })
40
+
41
+ it('falls back to defaults on garbage search params', async () => {
42
+ const { router } = renderApp(
43
+ '/users?page=-3&limit=999&sort=password&order=sideways',
44
+ )
45
+ expect(await screen.findByTestId('user-row-u-001')).toBeInTheDocument()
46
+ expect(router.state.location.search).toEqual({})
47
+ })
48
+
49
+ it('shows the empty state when the search has no match', async () => {
50
+ const user = userEvent.setup()
51
+ const { router } = renderApp('/users')
52
+ await screen.findByTestId('user-row-u-001')
53
+ await user.type(screen.getByTestId('users-search-input'), 'zzz-nope')
54
+ expect(await screen.findByTestId('users-empty-state')).toHaveTextContent(
55
+ 'Nessun risultato per “zzz-nope”',
56
+ )
57
+ expect(router.state.location.search).toEqual({ q: 'zzz-nope' })
58
+ })
59
+
60
+ it('shows the error state with a working retry', async () => {
61
+ const user = userEvent.setup()
62
+ apiMock.failOnce(503, { error: 'Upstream down' })
63
+ renderApp('/users')
64
+ const error = await screen.findByTestId('users-error-state')
65
+ expect(error).toHaveTextContent('Upstream down')
66
+ await user.click(screen.getByTestId('users-error-state-retry-button'))
67
+ expect(await screen.findByTestId('user-row-u-001')).toBeInTheDocument()
68
+ })
69
+ })
@@ -0,0 +1,120 @@
1
+ import { screen, within } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { mockUsers } from '@/test/fixtures/users'
6
+ import { renderWithQuery } from '@/test/render'
7
+
8
+ import { UsersTable } from '../UsersTable'
9
+ import { DEFAULT_USERS_QUERY } from '../users.schema'
10
+
11
+ const users = mockUsers.slice(0, 20)
12
+
13
+ describe('UsersTable', () => {
14
+ it('renders caption, one row per user, sortable headers with aria-sort', () => {
15
+ renderWithQuery(
16
+ <UsersTable
17
+ users={users}
18
+ total={45}
19
+ query={DEFAULT_USERS_QUERY}
20
+ onQueryChange={vi.fn()}
21
+ />,
22
+ )
23
+ const table = screen.getByTestId('users-table')
24
+ expect(
25
+ within(table).getByText(/Elenco utenti: 45 risultati, pagina 1 di 3/),
26
+ ).toBeInTheDocument()
27
+ expect(screen.getAllByTestId(/^user-row-/)).toHaveLength(20)
28
+ expect(screen.getByTestId('user-row-u-001')).toBeInTheDocument()
29
+ const headers = within(table).getAllByRole('columnheader')
30
+ expect(headers).toHaveLength(4)
31
+ expect(headers[0]).toHaveAttribute('aria-sort', 'ascending')
32
+ expect(headers[1]).toHaveAttribute('aria-sort', 'none')
33
+ })
34
+
35
+ it('toggles sort direction through the search params', async () => {
36
+ const user = userEvent.setup()
37
+ const onQueryChange = vi.fn()
38
+ renderWithQuery(
39
+ <UsersTable
40
+ users={users}
41
+ total={45}
42
+ query={DEFAULT_USERS_QUERY}
43
+ onQueryChange={onQueryChange}
44
+ />,
45
+ )
46
+ await user.click(screen.getByTestId('users-table-sort-name'))
47
+ expect(onQueryChange).toHaveBeenCalledWith({
48
+ sort: 'name',
49
+ order: 'desc',
50
+ page: 1,
51
+ })
52
+ await user.click(screen.getByTestId('users-table-sort-email'))
53
+ expect(onQueryChange).toHaveBeenLastCalledWith({
54
+ sort: 'email',
55
+ order: 'asc',
56
+ page: 1,
57
+ })
58
+ })
59
+
60
+ it('paginates with prev/next/page buttons and aria-current', async () => {
61
+ const user = userEvent.setup()
62
+ const onQueryChange = vi.fn()
63
+ renderWithQuery(
64
+ <UsersTable
65
+ users={users}
66
+ total={45}
67
+ query={{ ...DEFAULT_USERS_QUERY, page: 2 }}
68
+ onQueryChange={onQueryChange}
69
+ />,
70
+ )
71
+ expect(screen.getByTestId('users-pagination-page-2')).toHaveAttribute(
72
+ 'aria-current',
73
+ 'page',
74
+ )
75
+ expect(screen.getByTestId('users-pagination-status')).toHaveTextContent(
76
+ 'Pagina 2 di 3 · 45 utenti',
77
+ )
78
+ await user.click(screen.getByTestId('users-pagination-next-button'))
79
+ expect(onQueryChange).toHaveBeenLastCalledWith({ page: 3 })
80
+ await user.click(screen.getByTestId('users-pagination-prev-button'))
81
+ expect(onQueryChange).toHaveBeenLastCalledWith({ page: 1 })
82
+ await user.click(screen.getByTestId('users-pagination-page-3'))
83
+ expect(onQueryChange).toHaveBeenLastCalledWith({ page: 3 })
84
+ })
85
+
86
+ it('disables prev on the first page and next on the last', () => {
87
+ renderWithQuery(
88
+ <UsersTable
89
+ users={users}
90
+ total={20}
91
+ query={DEFAULT_USERS_QUERY}
92
+ onQueryChange={vi.fn()}
93
+ />,
94
+ )
95
+ expect(screen.getByTestId('users-pagination-prev-button')).toBeDisabled()
96
+ expect(screen.getByTestId('users-pagination-next-button')).toBeDisabled()
97
+ })
98
+
99
+ it('switches cell density via the Jotai atom', async () => {
100
+ const user = userEvent.setup()
101
+ renderWithQuery(
102
+ <UsersTable
103
+ users={users}
104
+ total={45}
105
+ query={DEFAULT_USERS_QUERY}
106
+ onQueryChange={vi.fn()}
107
+ />,
108
+ )
109
+ const cell = within(screen.getByTestId('user-row-u-001')).getAllByRole(
110
+ 'cell',
111
+ )[0]
112
+ expect(cell).toHaveClass('px-4', 'py-3')
113
+ await user.click(screen.getByTestId('users-density-button'))
114
+ expect(screen.getByTestId('users-density-button')).toHaveAttribute(
115
+ 'aria-pressed',
116
+ 'true',
117
+ )
118
+ expect(cell).toHaveClass('px-2', 'py-1')
119
+ })
120
+ })
@@ -0,0 +1,62 @@
1
+ import { waitFor } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { ApiError } from '@/lib/apiClient'
5
+ import { apiMock } from '@/test/api-mock'
6
+ import { renderHookWithQuery } from '@/test/render'
7
+
8
+ import { useUsers, userKeys } from '../useUsers'
9
+ import { DEFAULT_USERS_QUERY } from '../users.schema'
10
+
11
+ describe('useUsers', () => {
12
+ it('loads the first page', async () => {
13
+ const { result } = renderHookWithQuery(() => useUsers(DEFAULT_USERS_QUERY))
14
+ expect(result.current.isPending).toBe(true)
15
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
16
+ expect(result.current.data).toMatchObject({ page: 1, limit: 20, total: 45 })
17
+ expect(result.current.data?.data).toHaveLength(20)
18
+ })
19
+
20
+ it('sends q and sort=-field to the API', async () => {
21
+ const { result } = renderHookWithQuery(() =>
22
+ useUsers({
23
+ ...DEFAULT_USERS_QUERY,
24
+ q: 'user 04',
25
+ sort: 'name',
26
+ order: 'desc',
27
+ }),
28
+ )
29
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
30
+ const names = result.current.data?.data.map((u) => u.name) ?? []
31
+ expect(names).toEqual([
32
+ 'User 045',
33
+ 'User 044',
34
+ 'User 043',
35
+ 'User 042',
36
+ 'User 041',
37
+ 'User 040',
38
+ ])
39
+ })
40
+
41
+ it('surfaces API failures as ApiError', async () => {
42
+ apiMock.failOnce(500, { error: 'boom' })
43
+ const { result } = renderHookWithQuery(() => useUsers(DEFAULT_USERS_QUERY))
44
+ await waitFor(() => expect(result.current.isError).toBe(true))
45
+ expect(result.current.error).toBeInstanceOf(ApiError)
46
+ expect(result.current.error).toMatchObject({ status: 500, message: 'boom' })
47
+ })
48
+
49
+ it('rejects a malformed payload', async () => {
50
+ apiMock.respondOnce({ rows: [] })
51
+ const { result } = renderHookWithQuery(() => useUsers(DEFAULT_USERS_QUERY))
52
+ await waitFor(() => expect(result.current.isError).toBe(true))
53
+ })
54
+
55
+ it('keys include the full query', () => {
56
+ expect(userKeys.list(DEFAULT_USERS_QUERY)).toEqual([
57
+ 'users',
58
+ 'list',
59
+ DEFAULT_USERS_QUERY,
60
+ ])
61
+ })
62
+ })
@@ -0,0 +1,22 @@
1
+ import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query'
2
+
3
+ import { getUserList } from './users.api'
4
+ import type { UsersQuery } from './users.schema'
5
+
6
+ // tanstack §2.2: key factory + queryOptions factory, never inline in components.
7
+ export const userKeys = {
8
+ all: ['users'] as const,
9
+ lists: () => [...userKeys.all, 'list'] as const,
10
+ list: (query: UsersQuery) => [...userKeys.lists(), query] as const,
11
+ }
12
+
13
+ export const usersQueryOptions = (query: UsersQuery) =>
14
+ queryOptions({
15
+ queryKey: userKeys.list(query),
16
+ queryFn: () => getUserList(query),
17
+ placeholderData: keepPreviousData,
18
+ staleTime: 2 * 60 * 1000,
19
+ })
20
+
21
+ export const useUsers = (query: UsersQuery) =>
22
+ useQuery(usersQueryOptions(query))
@@ -0,0 +1,22 @@
1
+ import { apiClient } from '@/lib/apiClient'
2
+
3
+ import {
4
+ type UsersQuery,
5
+ type UsersResponse,
6
+ UsersResponseSchema,
7
+ } from './users.schema'
8
+
9
+ /** GET /users — maps `{ sort, order }` to the API's `sort=-field` convention. */
10
+ export const getUserList = async (
11
+ query: UsersQuery,
12
+ ): Promise<UsersResponse> => {
13
+ const response = await apiClient.get<unknown>('/users', {
14
+ params: {
15
+ page: query.page,
16
+ limit: query.limit,
17
+ q: query.q || undefined,
18
+ sort: query.order === 'desc' ? `-${query.sort}` : query.sort,
19
+ },
20
+ })
21
+ return UsersResponseSchema.parse(response.data)
22
+ }