@fabrika/app 0.3.2 → 0.4.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 +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
# references/spa.md — the SPA: React, the hooks over the typed client, and the component kit
|
|
2
|
+
|
|
3
|
+
The SPA of the paved road is React. `@fabrika/app/react` runs the typed client's calls and caches their answers;
|
|
4
|
+
`@fabrika/ui` holds the screens' components, a router and one theme stylesheet. Build every screen from them. Never
|
|
5
|
+
write a DOM element builder, a router, a cache or a component set of your own: when the kit lacks a piece, compose it
|
|
6
|
+
from the kit's components and the theme's tokens in the application's own stylesheet. `app-setup.md` shows the
|
|
7
|
+
dependencies, `tsconfig.json` (`"jsx": "react-jsx"`) and `build-web.ts`.
|
|
8
|
+
|
|
9
|
+
| Module | Holds |
|
|
10
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| `@fabrika/app/client` | `createClient(contract, 'api')`: one function per procedure, its answer parsed by the output schema |
|
|
12
|
+
| `@fabrika/app/react` | `FabrikaProvider`, `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction`, `useInvalidate`, `skip`, `useCaller`, `Can`, `FabrikaError` |
|
|
13
|
+
| `@fabrika/ui` | the components below, `createRouter`, and `@fabrika/ui/styles.css` |
|
|
14
|
+
|
|
15
|
+
## The provider and the routes
|
|
16
|
+
|
|
17
|
+
```ts web/routes.ts
|
|
18
|
+
import { createRouter } from '@fabrika/ui'
|
|
19
|
+
|
|
20
|
+
export const { Router, Link, href, navigate, useRoute } = createRouter({
|
|
21
|
+
mine: '/',
|
|
22
|
+
project: '/orgs/:orgId/projects/:projectId',
|
|
23
|
+
task: '/tasks/:id',
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```tsx web/start.tsx
|
|
28
|
+
import '@fabrika/ui/styles.css'
|
|
29
|
+
import './app.css'
|
|
30
|
+
import { FabrikaProvider, useCaller } from '@fabrika/app/react'
|
|
31
|
+
import { AppShell, EmptyState, ToastProvider } from '@fabrika/ui'
|
|
32
|
+
import { StrictMode } from 'react'
|
|
33
|
+
import { createRoot } from 'react-dom/client'
|
|
34
|
+
import { api } from './api'
|
|
35
|
+
import { MyTasks } from './mine'
|
|
36
|
+
import { ProjectTasks } from './project'
|
|
37
|
+
import { href, Router, useRoute } from './routes'
|
|
38
|
+
import { TaskView } from './task'
|
|
39
|
+
|
|
40
|
+
function Screen() {
|
|
41
|
+
const route = useRoute()
|
|
42
|
+
switch (route?.name) {
|
|
43
|
+
case 'mine':
|
|
44
|
+
return <MyTasks />
|
|
45
|
+
case 'project':
|
|
46
|
+
return (
|
|
47
|
+
<ProjectTasks
|
|
48
|
+
orgId={route.params.orgId}
|
|
49
|
+
projectId={route.params.projectId}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
case 'task':
|
|
53
|
+
return <TaskView key={route.params.id} id={route.params.id} />
|
|
54
|
+
case undefined:
|
|
55
|
+
return (
|
|
56
|
+
<EmptyState title="Nothing here" text="This address names no screen." />
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Shell() {
|
|
62
|
+
const route = useRoute()
|
|
63
|
+
const caller = useCaller()
|
|
64
|
+
return (
|
|
65
|
+
<AppShell
|
|
66
|
+
brand="Tasks"
|
|
67
|
+
nav={[{
|
|
68
|
+
label: 'My tasks',
|
|
69
|
+
href: href('mine'),
|
|
70
|
+
active: route?.name === 'mine',
|
|
71
|
+
}]}
|
|
72
|
+
user={caller?.label ?? caller?.email}
|
|
73
|
+
>
|
|
74
|
+
<Screen />
|
|
75
|
+
</AppShell>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const root = document.getElementById('root')
|
|
80
|
+
if (root !== null) {
|
|
81
|
+
createRoot(root).render(
|
|
82
|
+
<StrictMode>
|
|
83
|
+
<FabrikaProvider clients={[api]}>
|
|
84
|
+
<ToastProvider>
|
|
85
|
+
<Router>
|
|
86
|
+
<Shell />
|
|
87
|
+
</Router>
|
|
88
|
+
</ToastProvider>
|
|
89
|
+
</FabrikaProvider>
|
|
90
|
+
</StrictMode>,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `FabrikaProvider` holds the clients of the mounts the page calls. A hook takes a procedure of a client
|
|
96
|
+
(`api.tasks.get`) as the typed name of the call and runs it on the provider's client of that mount; a test hands the
|
|
97
|
+
provider a client over the test kit instead (below). An SPA mounted under a prefix passes it: `spa="/app"`.
|
|
98
|
+
- `createRouter` answers typed paths: `href('task', { id })`, `navigate('project', { orgId, projectId })`, `<Link
|
|
99
|
+
to="task" params={{ id }}>`, and `useRoute()`, the match or `null`. `<Router>` makes every plain `<a href>` under the
|
|
100
|
+
SPA navigate without a reload: the kit's components render links that way. Pass `{ base: '/app' }` for a prefix.
|
|
101
|
+
- `ToastProvider` once at the root; `useToast().show({ tone: 'success', message })` anywhere below it.
|
|
102
|
+
|
|
103
|
+
## Reading: useQuery and useInfiniteQuery
|
|
104
|
+
|
|
105
|
+
```ts web/api.ts
|
|
106
|
+
import { createClient } from '@fabrika/app/client'
|
|
107
|
+
import type { PageAnswer, ProcedureResult } from '@fabrika/app/react'
|
|
108
|
+
import { contract } from '../fabrika.contract'
|
|
109
|
+
|
|
110
|
+
export const api = createClient(contract, 'api', { bounceOnAuth: true })
|
|
111
|
+
|
|
112
|
+
type ItemOf<P> = P extends PageAnswer<infer Item> ? Item : never
|
|
113
|
+
|
|
114
|
+
export type Task = ProcedureResult<typeof api.tasks.get>
|
|
115
|
+
export type Comment = ItemOf<ProcedureResult<typeof api.comments.list>>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```tsx web/mine.tsx
|
|
119
|
+
import { Can, useInfiniteQuery, useMutation } from '@fabrika/app/react'
|
|
120
|
+
import {
|
|
121
|
+
Button,
|
|
122
|
+
type Column,
|
|
123
|
+
DataTable,
|
|
124
|
+
PageHeader,
|
|
125
|
+
StatusPill,
|
|
126
|
+
Tabs,
|
|
127
|
+
useSelection,
|
|
128
|
+
useToast,
|
|
129
|
+
} from '@fabrika/ui'
|
|
130
|
+
import { useState } from 'react'
|
|
131
|
+
import { api, type Task } from './api'
|
|
132
|
+
import { Link } from './routes'
|
|
133
|
+
|
|
134
|
+
type Tab = 'open' | 'doing'
|
|
135
|
+
|
|
136
|
+
const columns: readonly Column<Task>[] = [
|
|
137
|
+
{
|
|
138
|
+
key: 'title',
|
|
139
|
+
header: 'Task',
|
|
140
|
+
grow: true,
|
|
141
|
+
cell: (task) => <Link to="task" params={{ id: task.id }}>{task.title}</Link>,
|
|
142
|
+
},
|
|
143
|
+
{ key: 'due', header: 'Due', cell: (task) => task.dueOn ?? '' },
|
|
144
|
+
{
|
|
145
|
+
key: 'status',
|
|
146
|
+
header: 'Status',
|
|
147
|
+
cell: (task) => (
|
|
148
|
+
<StatusPill
|
|
149
|
+
tone={task.overdue
|
|
150
|
+
? 'danger'
|
|
151
|
+
: task.status === 'doing'
|
|
152
|
+
? 'info'
|
|
153
|
+
: 'neutral'}
|
|
154
|
+
>
|
|
155
|
+
{task.status}
|
|
156
|
+
</StatusPill>
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
export function MyTasks() {
|
|
162
|
+
const mine = useInfiniteQuery(api.tasks.mine, {})
|
|
163
|
+
const start = useMutation(api.tasks.start)
|
|
164
|
+
const toast = useToast()
|
|
165
|
+
const selection = useSelection()
|
|
166
|
+
const [tab, setTab] = useState<Tab>('open')
|
|
167
|
+
const shown = mine.items.filter((task) => task.status === tab)
|
|
168
|
+
const startAll = async (ids: readonly string[]) => {
|
|
169
|
+
for (const task of shown.filter((candidate) => ids.includes(candidate.id))) {
|
|
170
|
+
const outcome = await start.mutate({
|
|
171
|
+
id: task.id,
|
|
172
|
+
expectVersion: task.version,
|
|
173
|
+
})
|
|
174
|
+
if (!outcome.ok) {
|
|
175
|
+
toast.show({ tone: 'danger', message: outcome.error.message })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
selection.clear()
|
|
179
|
+
}
|
|
180
|
+
return (
|
|
181
|
+
<>
|
|
182
|
+
<PageHeader
|
|
183
|
+
title="My tasks"
|
|
184
|
+
lead="Open tasks assigned to you, soonest first."
|
|
185
|
+
/>
|
|
186
|
+
<Tabs
|
|
187
|
+
label="Task status"
|
|
188
|
+
items={[
|
|
189
|
+
{
|
|
190
|
+
key: 'open',
|
|
191
|
+
label: 'Open',
|
|
192
|
+
count: mine.items.filter((task) => task.status === 'open').length,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
key: 'doing',
|
|
196
|
+
label: 'In progress',
|
|
197
|
+
count: mine.items.filter((task) => task.status === 'doing').length,
|
|
198
|
+
},
|
|
199
|
+
]}
|
|
200
|
+
active={tab}
|
|
201
|
+
onChange={setTab}
|
|
202
|
+
/>
|
|
203
|
+
<DataTable
|
|
204
|
+
caption="My tasks"
|
|
205
|
+
columns={columns}
|
|
206
|
+
rows={shown}
|
|
207
|
+
rowKey={(task) => task.id}
|
|
208
|
+
status={mine.status === 'error'
|
|
209
|
+
? 'error'
|
|
210
|
+
: mine.status === 'success'
|
|
211
|
+
? 'ready'
|
|
212
|
+
: 'loading'}
|
|
213
|
+
error={mine.error?.message}
|
|
214
|
+
onRetry={() => void mine.retry()}
|
|
215
|
+
empty="Nothing assigned to you."
|
|
216
|
+
loadMore={{
|
|
217
|
+
hasMore: mine.hasMore,
|
|
218
|
+
loading: mine.loadingMore,
|
|
219
|
+
onLoadMore: () => void mine.loadMore(),
|
|
220
|
+
}}
|
|
221
|
+
selection={tab === 'open' ? selection : undefined}
|
|
222
|
+
selectRowLabel={(task) => `Select ${task.title}`}
|
|
223
|
+
selectionActions={(ids) => (
|
|
224
|
+
<Can action="tasks.write">
|
|
225
|
+
<Button
|
|
226
|
+
size="sm"
|
|
227
|
+
variant="primary"
|
|
228
|
+
loading={start.pending}
|
|
229
|
+
onClick={() => void startAll(ids)}
|
|
230
|
+
>
|
|
231
|
+
Start
|
|
232
|
+
</Button>
|
|
233
|
+
</Can>
|
|
234
|
+
)}
|
|
235
|
+
/>
|
|
236
|
+
</>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- `useQuery(api.tasks.get, { id })` answers `{ status, data, error, fetching, refetch }`: `status` is `'loading'`,
|
|
242
|
+
`'success'` (then `data` is the parsed answer, typed by the contract), `'error'` (then `error` is a `FabrikaError`)
|
|
243
|
+
or `'idle'`. Read `data` only after checking `status`; the compiler insists. A procedure without input takes none:
|
|
244
|
+
`useQuery(api.members.orgs)`.
|
|
245
|
+
- Pass `skip` as the input while a value it needs is not known yet: `useQuery(api.members.list, project ? { orgId } :
|
|
246
|
+
skip)` sends nothing until then.
|
|
247
|
+
- Every view of one call with one input shares one request and one cached answer.
|
|
248
|
+
- `useInfiniteQuery(procedure, input)` is for a list that answers `{ items, next }` and takes `after`: `items` are the
|
|
249
|
+
loaded pages, `hasMore` and `loadMore()` the next page. `{ pages: 'all' }` reads every page at once, for a picker's
|
|
250
|
+
options; use it for small lists only.
|
|
251
|
+
|
|
252
|
+
## Writing: useMutation, forms and their errors
|
|
253
|
+
|
|
254
|
+
```tsx web/edit-task.tsx
|
|
255
|
+
import { type FabrikaError, useMutation } from '@fabrika/app/react'
|
|
256
|
+
import {
|
|
257
|
+
Button,
|
|
258
|
+
DateInput,
|
|
259
|
+
Dialog,
|
|
260
|
+
Field,
|
|
261
|
+
Form,
|
|
262
|
+
FormActions,
|
|
263
|
+
type FormErrorLike,
|
|
264
|
+
Input,
|
|
265
|
+
} from '@fabrika/ui'
|
|
266
|
+
import type { Task } from './api'
|
|
267
|
+
import { api } from './api'
|
|
268
|
+
|
|
269
|
+
/** What the person reads: the field errors as they are, a stale version and a missing role in plain words. */
|
|
270
|
+
export function shown(error: FabrikaError | null): FormErrorLike | null {
|
|
271
|
+
if (error === null) return null
|
|
272
|
+
if (error.kind === 'conflict') {
|
|
273
|
+
return {
|
|
274
|
+
message: 'Someone changed this task meanwhile. Close and open it again.',
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (error.kind === 'forbidden') {
|
|
278
|
+
return { message: 'Your role does not allow this.' }
|
|
279
|
+
}
|
|
280
|
+
return error
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const text = (form: FormData, name: string): string => {
|
|
284
|
+
const value = form.get(name)
|
|
285
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function EditTask(
|
|
289
|
+
{ task, open, onClose }: {
|
|
290
|
+
readonly task: Task
|
|
291
|
+
readonly open: boolean
|
|
292
|
+
readonly onClose: () => void
|
|
293
|
+
},
|
|
294
|
+
) {
|
|
295
|
+
const edit = useMutation(api.tasks.edit)
|
|
296
|
+
const close = () => {
|
|
297
|
+
edit.reset()
|
|
298
|
+
onClose()
|
|
299
|
+
}
|
|
300
|
+
const submit = async (form: FormData) => {
|
|
301
|
+
const due = text(form, 'dueOn')
|
|
302
|
+
const outcome = await edit.mutate({
|
|
303
|
+
id: task.id,
|
|
304
|
+
expectVersion: task.version,
|
|
305
|
+
title: text(form, 'title'),
|
|
306
|
+
dueOn: due === '' ? null : due,
|
|
307
|
+
})
|
|
308
|
+
if (outcome.ok) close()
|
|
309
|
+
}
|
|
310
|
+
return (
|
|
311
|
+
<Dialog open={open} title="Edit task" onClose={close}>
|
|
312
|
+
<Form
|
|
313
|
+
onSubmit={(form) => void submit(form)}
|
|
314
|
+
error={shown(edit.error)}
|
|
315
|
+
aria-label="Edit task"
|
|
316
|
+
>
|
|
317
|
+
<Field label="Title" name="title" required>
|
|
318
|
+
<Input defaultValue={task.title} />
|
|
319
|
+
</Field>
|
|
320
|
+
<Field label="Due on" name="dueOn" hint="Leave empty for no due date.">
|
|
321
|
+
<DateInput defaultValue={task.dueOn ?? ''} />
|
|
322
|
+
</Field>
|
|
323
|
+
<FormActions>
|
|
324
|
+
<Button variant="ghost" onClick={close}>Cancel</Button>
|
|
325
|
+
<Button type="submit" variant="primary" loading={edit.pending}>
|
|
326
|
+
Save
|
|
327
|
+
</Button>
|
|
328
|
+
</FormActions>
|
|
329
|
+
</Form>
|
|
330
|
+
</Dialog>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- `mutate(input)` never throws: it answers `{ ok: true, data }` or `{ ok: false, error }`, and the hook keeps
|
|
336
|
+
`status`, `pending`, `data` and `error`. It first checks the input with the procedure's own schema: a refused input
|
|
337
|
+
is a `validation` error with its field paths, and nothing is sent.
|
|
338
|
+
- After a success, and after a `conflict`, it refetches what its group shows (`api.tasks` for `api.tasks.edit`). Key
|
|
339
|
+
a form that edits a versioned row by its version (`key={task.version}`): after a conflict it reseeds from the
|
|
340
|
+
current row, and the next save sends the current `expectVersion`.
|
|
341
|
+
`useMutation(api.comments.add, { invalidates: [api.comments, api.tasks.get] })` names other procedures or groups;
|
|
342
|
+
`invalidates: []` refetches nothing. `useInvalidate()(api.tasks)` refetches on demand.
|
|
343
|
+
- `FabrikaError` is one union for every refusal: `unauthenticated` (`loginUrl`: the client made with `bounceOnAuth`
|
|
344
|
+
sends the person there itself), `forbidden` (the role lacks the action: tell the person, never loosen the contract),
|
|
345
|
+
`conflict` (a stale `expectVersion`, or a `ConflictError` of the handler), `validation` (`fields`: input path →
|
|
346
|
+
message, also a handler's `BadRequestError` without fields), `not-found`, `other` (`status`; `0` when no answer came).
|
|
347
|
+
- `Form` shows its `error`: a summary on top, and each `fields[name]` under the `Field` of that `name`. `Field` gives
|
|
348
|
+
its control the `name`, an `id`, its label and `aria-describedby`; the control needs nothing else. `Form` hands
|
|
349
|
+
`onSubmit` the `FormData`: read the fields by name, and leave the checking to the procedure's schema.
|
|
350
|
+
- A call that is no procedure — a file collection's `api.files.<name>.upload(owner, file)`, `list`, `download` —
|
|
351
|
+
runs through `useAction(async (input) => …)`, with the same state and error union; it refetches only what
|
|
352
|
+
`invalidates` names.
|
|
353
|
+
|
|
354
|
+
## Who is signed in: useCaller and Can
|
|
355
|
+
|
|
356
|
+
```tsx web/project.tsx
|
|
357
|
+
import { Can, useCaller, useInfiniteQuery } from '@fabrika/app/react'
|
|
358
|
+
import {
|
|
359
|
+
ButtonLink,
|
|
360
|
+
Card,
|
|
361
|
+
EmptyState,
|
|
362
|
+
Field,
|
|
363
|
+
FilterBar,
|
|
364
|
+
Input,
|
|
365
|
+
PageHeader,
|
|
366
|
+
Select,
|
|
367
|
+
} from '@fabrika/ui'
|
|
368
|
+
import { useState } from 'react'
|
|
369
|
+
import { api } from './api'
|
|
370
|
+
import { href, Link } from './routes'
|
|
371
|
+
|
|
372
|
+
const STATUSES = [{ value: 'open', label: 'Open' }, {
|
|
373
|
+
value: 'doing',
|
|
374
|
+
label: 'In progress',
|
|
375
|
+
}, { value: 'done', label: 'Done' }]
|
|
376
|
+
const isStatus = (value: string): value is 'open' | 'doing' | 'done' =>
|
|
377
|
+
STATUSES.some((status) => status.value === value)
|
|
378
|
+
|
|
379
|
+
export function ProjectTasks(
|
|
380
|
+
{ orgId, projectId }: { readonly orgId: string; readonly projectId: string },
|
|
381
|
+
) {
|
|
382
|
+
const caller = useCaller()
|
|
383
|
+
const [status, setStatus] = useState('')
|
|
384
|
+
const [title, setTitle] = useState('')
|
|
385
|
+
const tasks = useInfiniteQuery(api.tasks.list, {
|
|
386
|
+
projectId,
|
|
387
|
+
...(isStatus(status) ? { status } : {}),
|
|
388
|
+
...(title.trim() === '' ? {} : { title: title.trim() }),
|
|
389
|
+
})
|
|
390
|
+
return (
|
|
391
|
+
<>
|
|
392
|
+
<PageHeader
|
|
393
|
+
title="Project tasks"
|
|
394
|
+
back={{ href: href('mine'), label: 'My tasks' }}
|
|
395
|
+
lead={caller === null ? undefined : `Signed in as ${caller.email}`}
|
|
396
|
+
actions={
|
|
397
|
+
<Can action="tasks.write" scope={{ type: 'org', value: orgId }}>
|
|
398
|
+
<ButtonLink variant="primary" href={href('mine')}>New task</ButtonLink>
|
|
399
|
+
</Can>
|
|
400
|
+
}
|
|
401
|
+
/>
|
|
402
|
+
<FilterBar
|
|
403
|
+
onReset={() => {
|
|
404
|
+
setStatus('')
|
|
405
|
+
setTitle('')
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
<Field label="Status">
|
|
409
|
+
<Select
|
|
410
|
+
value={status}
|
|
411
|
+
onChange={(event) => setStatus(event.currentTarget.value)}
|
|
412
|
+
placeholder="Any status"
|
|
413
|
+
options={STATUSES}
|
|
414
|
+
/>
|
|
415
|
+
</Field>
|
|
416
|
+
<Field label="Title">
|
|
417
|
+
<Input
|
|
418
|
+
type="search"
|
|
419
|
+
value={title}
|
|
420
|
+
onChange={(event) => setTitle(event.currentTarget.value)}
|
|
421
|
+
/>
|
|
422
|
+
</Field>
|
|
423
|
+
</FilterBar>
|
|
424
|
+
<Card title="Tasks">
|
|
425
|
+
{tasks.items.length === 0
|
|
426
|
+
? <EmptyState title="No tasks" text="No task of this project matches." />
|
|
427
|
+
: (
|
|
428
|
+
<ul>
|
|
429
|
+
{tasks.items.map((task) => (
|
|
430
|
+
<li key={task.id}>
|
|
431
|
+
<Link to="task" params={{ id: task.id }}>{task.title}</Link>
|
|
432
|
+
</li>
|
|
433
|
+
))}
|
|
434
|
+
</ul>
|
|
435
|
+
)}
|
|
436
|
+
</Card>
|
|
437
|
+
</>
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
- `useCaller()` answers `{ label, email, can(action, scope?) }` from the SPA mount's caller document, or `null` while
|
|
443
|
+
it loads and on a public SPA. `can(action)` is the action held app-wide or in any scope value; `can(action, { type,
|
|
444
|
+
value })` app-wide or in that value. A grant on a container is not seen from a value inside it: ask at the dimension
|
|
445
|
+
the person holds the role in.
|
|
446
|
+
- `<Can action scope fallback>` shows its children only then. **It is information for the page, never a check**: the
|
|
447
|
+
proxy and the endpoint decide every call again, so a hidden button protects nothing, and a shown one may still be
|
|
448
|
+
refused (`forbidden`).
|
|
449
|
+
- A filter goes into the query's input (the server filters, the cache keys by it) when the procedure takes it, as
|
|
450
|
+
`status` and `title` above; filter loaded rows in the page only for what the procedure does not take.
|
|
451
|
+
|
|
452
|
+
## A detail screen
|
|
453
|
+
|
|
454
|
+
```tsx web/task.tsx
|
|
455
|
+
import { useInfiniteQuery, useMutation, useQuery } from '@fabrika/app/react'
|
|
456
|
+
import {
|
|
457
|
+
Button,
|
|
458
|
+
Card,
|
|
459
|
+
Columns,
|
|
460
|
+
ConfirmDialog,
|
|
461
|
+
ErrorState,
|
|
462
|
+
FieldList,
|
|
463
|
+
Form,
|
|
464
|
+
FormActions,
|
|
465
|
+
LoadingState,
|
|
466
|
+
PageHeader,
|
|
467
|
+
Section,
|
|
468
|
+
SidePanel,
|
|
469
|
+
StatusPill,
|
|
470
|
+
Textarea,
|
|
471
|
+
Timeline,
|
|
472
|
+
useToast,
|
|
473
|
+
} from '@fabrika/ui'
|
|
474
|
+
import { useState } from 'react'
|
|
475
|
+
import { api, type Task } from './api'
|
|
476
|
+
import { EditTask, shown } from './edit-task'
|
|
477
|
+
import { href } from './routes'
|
|
478
|
+
|
|
479
|
+
function Comments({ task }: { readonly task: Task }) {
|
|
480
|
+
const comments = useInfiniteQuery(api.comments.list, { taskId: task.id }, {
|
|
481
|
+
pages: 'all',
|
|
482
|
+
})
|
|
483
|
+
const add = useMutation(api.comments.add)
|
|
484
|
+
const [formKey, setFormKey] = useState(0)
|
|
485
|
+
return (
|
|
486
|
+
<Section title="Comments">
|
|
487
|
+
<Timeline
|
|
488
|
+
items={comments.items.map((comment) => ({
|
|
489
|
+
id: comment.id,
|
|
490
|
+
at: comment.createdAt.slice(0, 16),
|
|
491
|
+
title: comment.createdBy,
|
|
492
|
+
body: comment.body,
|
|
493
|
+
}))}
|
|
494
|
+
empty="No comments yet."
|
|
495
|
+
/>
|
|
496
|
+
<Form
|
|
497
|
+
key={formKey}
|
|
498
|
+
error={shown(add.error)}
|
|
499
|
+
onSubmit={async (form) => {
|
|
500
|
+
const body = form.get('body')
|
|
501
|
+
if (
|
|
502
|
+
(await add.mutate({
|
|
503
|
+
taskId: task.id,
|
|
504
|
+
body: typeof body === 'string' ? body : '',
|
|
505
|
+
})).ok
|
|
506
|
+
) setFormKey(formKey + 1)
|
|
507
|
+
}}
|
|
508
|
+
>
|
|
509
|
+
<Textarea name="body" rows={2} aria-label="Comment" />
|
|
510
|
+
<FormActions>
|
|
511
|
+
<Button type="submit" loading={add.pending}>Comment</Button>
|
|
512
|
+
</FormActions>
|
|
513
|
+
</Form>
|
|
514
|
+
</Section>
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function TaskScreen({ task }: { readonly task: Task }) {
|
|
519
|
+
const close = useMutation(api.tasks.close)
|
|
520
|
+
const toast = useToast()
|
|
521
|
+
const [editing, setEditing] = useState(false)
|
|
522
|
+
const [closing, setClosing] = useState(false)
|
|
523
|
+
return (
|
|
524
|
+
<>
|
|
525
|
+
<PageHeader
|
|
526
|
+
title={task.title}
|
|
527
|
+
back={{ href: href('mine'), label: 'My tasks' }}
|
|
528
|
+
meta={
|
|
529
|
+
<StatusPill tone={task.overdue ? 'danger' : 'neutral'}>
|
|
530
|
+
{task.status}
|
|
531
|
+
</StatusPill>
|
|
532
|
+
}
|
|
533
|
+
actions={
|
|
534
|
+
<>
|
|
535
|
+
<Button onClick={() => setEditing(true)}>Edit</Button>
|
|
536
|
+
<Button variant="danger" onClick={() => setClosing(true)}>
|
|
537
|
+
Close task
|
|
538
|
+
</Button>
|
|
539
|
+
</>
|
|
540
|
+
}
|
|
541
|
+
/>
|
|
542
|
+
<Columns>
|
|
543
|
+
<Comments task={task} />
|
|
544
|
+
<SidePanel>
|
|
545
|
+
<Card title="Details">
|
|
546
|
+
<FieldList
|
|
547
|
+
items={[{ label: 'Assignee', value: task.assigneeLabel }, {
|
|
548
|
+
label: 'Due on',
|
|
549
|
+
value: task.dueOn,
|
|
550
|
+
}]}
|
|
551
|
+
/>
|
|
552
|
+
</Card>
|
|
553
|
+
</SidePanel>
|
|
554
|
+
</Columns>
|
|
555
|
+
<EditTask task={task} open={editing} onClose={() => setEditing(false)} />
|
|
556
|
+
<ConfirmDialog
|
|
557
|
+
open={closing}
|
|
558
|
+
title="Close this task?"
|
|
559
|
+
message="A closed task leaves the lists of open work."
|
|
560
|
+
confirmLabel="Close task"
|
|
561
|
+
tone="danger"
|
|
562
|
+
pending={close.pending}
|
|
563
|
+
onCancel={() => setClosing(false)}
|
|
564
|
+
onConfirm={async () => {
|
|
565
|
+
const outcome = await close.mutate({
|
|
566
|
+
id: task.id,
|
|
567
|
+
expectVersion: task.version,
|
|
568
|
+
})
|
|
569
|
+
setClosing(false)
|
|
570
|
+
toast.show(
|
|
571
|
+
outcome.ok
|
|
572
|
+
? { tone: 'success', message: 'Task closed' }
|
|
573
|
+
: { tone: 'danger', message: outcome.error.message },
|
|
574
|
+
)
|
|
575
|
+
}}
|
|
576
|
+
/>
|
|
577
|
+
</>
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function TaskView({ id }: { readonly id: string }) {
|
|
582
|
+
const task = useQuery(api.tasks.get, { id })
|
|
583
|
+
if (task.status === 'success') return <TaskScreen task={task.data} />
|
|
584
|
+
if (task.status === 'error') {
|
|
585
|
+
return (
|
|
586
|
+
<ErrorState
|
|
587
|
+
text={task.error.message}
|
|
588
|
+
onRetry={() => void task.refetch()}
|
|
589
|
+
/>
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
return <LoadingState />
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## The components
|
|
597
|
+
|
|
598
|
+
All are named exports of `@fabrika/ui`, keyboard operable, and labelled for assistive technology. Text they render
|
|
599
|
+
themselves ("Load more", "Clear selection", "Close", "Retry") is a prop with an English default.
|
|
600
|
+
|
|
601
|
+
| Component | What it is |
|
|
602
|
+
| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
603
|
+
| `AppShell({ brand, nav, user, actions })` | sidebar navigation (`{ label, href, active, count }`), top bar with the caller, the main region |
|
|
604
|
+
| `PageHeader({ title, lead, back, actions, meta })` | the screen's `h1`, a line of purpose, a back link, its buttons; one `primary` button per screen |
|
|
605
|
+
| `Tabs({ label, items, active, onChange })` | tabs with counts; arrow keys, Home and End |
|
|
606
|
+
| `FilterBar({ onReset })` + `Field` | a row of filters with a reset |
|
|
607
|
+
| `Field({ label, name, hint, error, required })` | label, hint and error around one control; the error comes from the `Form` by `name` |
|
|
608
|
+
| `Input`, `Textarea`, `Select({ options, placeholder })` | the controls; native props pass through |
|
|
609
|
+
| `DateInput`, `DateRange({ from, to, onChange })` | a day, and a from–to pair of days |
|
|
610
|
+
| `Checkbox({ label, checked, onChange, indeterminate })` | one checkbox |
|
|
611
|
+
| `RadioGroup({ label, name, options, value, onChange })` | a fieldset of radios |
|
|
612
|
+
| `Button({ variant, size, loading })`, `ButtonLink` | `primary`, `secondary` (default), `ghost`, `danger`; `loading` disables and shows a spinner |
|
|
613
|
+
| `DataTable({ columns, rows, rowKey, status, selection, selectionActions, loadMore, empty })` | compact rows, loading/error/empty rows, a selection toolbar, a load-more button |
|
|
614
|
+
| `useSelection()` | the `selection` a `DataTable` takes, with `clear()` |
|
|
615
|
+
| `StatusPill({ tone })` | `neutral`, `info`, `success`, `warning`, `danger`, `accent` |
|
|
616
|
+
| `FieldList({ items, columns })` | label/value pairs; an empty value shows a dash |
|
|
617
|
+
| `Card({ title, actions, footer })`, `Section({ title, description, actions })` | a framed block, and a titled block without the frame |
|
|
618
|
+
| `Columns` + `SidePanel` | the main column and a side column, stacked on a narrow screen |
|
|
619
|
+
| `Dialog({ open, title, onClose })`, `ConfirmDialog` | modal: focus moves in and is trapped, Escape closes, focus returns |
|
|
620
|
+
| `ToastProvider`, `useToast().show({ tone, message })` | short notices that leave by themselves |
|
|
621
|
+
| `Timeline({ items, empty })` | history and notes: `{ id, at, title, body }` |
|
|
622
|
+
| `EmptyState`, `ErrorState({ text, onRetry })`, `LoadingState`, `Spinner`, `CountBadge` | the states of a region |
|
|
623
|
+
| `Stack({ gap })`, `Inline({ gap, align, justify, wrap })` | layout with the spacing scale |
|
|
624
|
+
| `Form({ onSubmit, error })`, `FormActions` | a form that shows a hook's error: the summary and each field's message |
|
|
625
|
+
| `createRouter(routes, { base })` | `Router`, `Link`, `useRoute`, `href`, `navigate`, typed by the patterns |
|
|
626
|
+
|
|
627
|
+
## Theming
|
|
628
|
+
|
|
629
|
+
`@fabrika/ui/styles.css` holds the tokens (`theme.css`) and the components' rules, which read the tokens only. The
|
|
630
|
+
application imports it first, then its own stylesheet, which overrides tokens and styles only what the kit lacks:
|
|
631
|
+
|
|
632
|
+
```css
|
|
633
|
+
/* web/app.css */
|
|
634
|
+
:root {
|
|
635
|
+
--fui-accent: #0f766e;
|
|
636
|
+
--fui-accent-hover: #115e59;
|
|
637
|
+
--fui-accent-soft: #e6f4f2;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
body {
|
|
641
|
+
margin: 0;
|
|
642
|
+
background: var(--fui-bg);
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
- The palette is neutral with one accent (`--fui-accent*`), plus tones for status (`--fui-success`, `--fui-warning`,
|
|
647
|
+
`--fui-danger`, `--fui-info`, each with `-soft` and `-text`). Typography (`--fui-font-family`, `--fui-text-*`),
|
|
648
|
+
spacing (`--fui-space-1`…`8`), radii, borders and shadows are tokens too.
|
|
649
|
+
- Light is the default. `<html data-theme="dark">` switches to the dark token set; the page decides when (a toggle, or
|
|
650
|
+
`matchMedia('(prefers-color-scheme: dark)')`).
|
|
651
|
+
- The kit loads nothing from another origin, so it runs under the default CSP. The font token is a system stack; for
|
|
652
|
+
a typeface, put its files in the SPA's bundle (import its `@font-face` stylesheet from `web/start.tsx`) and
|
|
653
|
+
override `--fui-font-family`, or name the font service's origins in the contract's `csp` (`styleSrc`, `fontSrc`,
|
|
654
|
+
`app-setup.md`).
|
|
655
|
+
|
|
656
|
+
## Testing a screen
|
|
657
|
+
|
|
658
|
+
A screen test renders the page in happy-dom (`@happy-dom/global-registrator`, a devDependency) over the application
|
|
659
|
+
itself, behind the test kit's proxy decision: the provider gets a client whose `fetch` is the kit's browser for a
|
|
660
|
+
test caller.
|
|
661
|
+
|
|
662
|
+
```ts web/__tests__/dom.ts
|
|
663
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator'
|
|
664
|
+
import { afterAll } from 'bun:test'
|
|
665
|
+
|
|
666
|
+
const runtime = {
|
|
667
|
+
fetch: globalThis.fetch,
|
|
668
|
+
Request: globalThis.Request,
|
|
669
|
+
Response: globalThis.Response,
|
|
670
|
+
Headers: globalThis.Headers,
|
|
671
|
+
}
|
|
672
|
+
GlobalRegistrator.register({ url: 'http://tasks.test/' })
|
|
673
|
+
Object.assign(globalThis, runtime)
|
|
674
|
+
afterAll(async () => {
|
|
675
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
676
|
+
await GlobalRegistrator.unregister()
|
|
677
|
+
})
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
```tsx web/__tests__/mine.test.tsx
|
|
681
|
+
import './dom'
|
|
682
|
+
import { createClient } from '@fabrika/app/client'
|
|
683
|
+
import { FabrikaProvider } from '@fabrika/app/react'
|
|
684
|
+
import { testKit } from '@fabrika/app/testing'
|
|
685
|
+
import { testDatabase } from '@fabrika/db/testing'
|
|
686
|
+
import { ToastProvider } from '@fabrika/ui'
|
|
687
|
+
import { afterAll, expect, test } from 'bun:test'
|
|
688
|
+
import { createRoot } from 'react-dom/client'
|
|
689
|
+
import { contract } from '../../fabrika.contract'
|
|
690
|
+
import app from '../../src/app'
|
|
691
|
+
import { MyTasks } from '../mine'
|
|
692
|
+
|
|
693
|
+
const postgres = await testDatabase({
|
|
694
|
+
migrations: new URL('../../db/migrations/', import.meta.url),
|
|
695
|
+
label: 'web/mine.test.tsx',
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
if (postgres !== null) {
|
|
699
|
+
afterAll(() => postgres.close())
|
|
700
|
+
const kit = testKit(app, { database: postgres.database })
|
|
701
|
+
const member = kit.testCaller([{
|
|
702
|
+
role: 'member',
|
|
703
|
+
scope: kit.scope('org', 'acme'),
|
|
704
|
+
}])
|
|
705
|
+
|
|
706
|
+
test('a member with nothing assigned sees the empty list, and no start button', async () => {
|
|
707
|
+
const fetch = kit.browser(member)
|
|
708
|
+
const root = createRoot(
|
|
709
|
+
document.body.appendChild(document.createElement('div')),
|
|
710
|
+
)
|
|
711
|
+
root.render(
|
|
712
|
+
<FabrikaProvider
|
|
713
|
+
clients={[createClient(contract, 'api', { fetch })]}
|
|
714
|
+
fetch={fetch}
|
|
715
|
+
>
|
|
716
|
+
<ToastProvider>
|
|
717
|
+
<MyTasks />
|
|
718
|
+
</ToastProvider>
|
|
719
|
+
</FabrikaProvider>,
|
|
720
|
+
)
|
|
721
|
+
for (
|
|
722
|
+
let tries = 0;
|
|
723
|
+
tries < 200
|
|
724
|
+
&& !document.body.textContent?.includes('Nothing assigned to you.');
|
|
725
|
+
tries++
|
|
726
|
+
) {
|
|
727
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
728
|
+
}
|
|
729
|
+
expect(document.body.textContent).toContain('Nothing assigned to you.')
|
|
730
|
+
expect(document.querySelector('[role="toolbar"]')).toBeNull()
|
|
731
|
+
root.unmount()
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
- `web/__tests__/dom.ts` comes first: imports run in order, and React reads `window` when it loads. Bun's `fetch` is
|
|
737
|
+
kept, because the clients and the kit run on it. Run the tests with `bun test --isolate`, which gives each file its
|
|
738
|
+
own globals.
|
|
739
|
+
- Wait for what the page shows, as a person does; assert on text, roles and ARIA, never on layout (happy-dom lays out
|
|
740
|
+
nothing). A refused twin is a caller without the role: the button is gone, and the endpoint still answers 403.
|
|
741
|
+
- Pure mapping (a label, a tone, a field's value) is a function of `web/` with a plain test.
|