@fougere/admin 0.3.0-alpha.0 → 0.4.0-alpha.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/src/react.tsx ADDED
@@ -0,0 +1,522 @@
1
+ 'use client';
2
+ /**
3
+ * The rendering half — a column becomes a field, a form field becomes an input.
4
+ *
5
+ * Two maps and a loop. Everything that DECIDES lives in `resources.ts`; what is here
6
+ * only spells the decision in react-admin's vocabulary, which is why this file has no
7
+ * conditional beyond the two lookups: a `render` that had to be interpreted would mean
8
+ * the interpretation belonged upstream, in the projection that produced it.
9
+ */
10
+ import {
11
+ Admin, Resource, Datagrid, List, SimpleForm, Edit, Create, Show, SimpleShowLayout,
12
+ TextField, NumberField, BooleanField, DateField, ReferenceField, FunctionField,
13
+ TextInput, NumberInput, BooleanInput, DateTimeInput, SelectInput,
14
+ Toolbar, SaveButton, EditButton, DeleteButton, WrapperField, CustomRoutes,
15
+ useTranslate, useRecordContext, useDataProvider, useRefresh, useNotify,
16
+ } from 'react-admin';
17
+ import { Route } from 'react-router-dom';
18
+ import { Box, Button, Card, CardContent, Dialog, DialogActions, DialogContent, DialogTitle, TextField as MuiTextField, Typography } from '@mui/material';
19
+ import { cloneElement, useMemo, useState, type ComponentProps, type ComponentType, type ReactElement } from 'react';
20
+ import { createAdminRuntime } from './runtime.js';
21
+ import type { AdminExtension } from './extensions.js';
22
+ import { actionsOf, type AdminOperation, type AdminResource } from './resources.js';
23
+ import type { EditorialFacet, UsersFacet } from './facets.js';
24
+ import type { Fetcher } from '@fougere/app/client';
25
+ import { formFieldsOf, type FormField, type TableColumn } from '@fougere/app/client';
26
+ import { Card as SchemaCard } from '@fougere/schema';
27
+ import { FougereLayout, fougereDarkTheme, fougereLightTheme } from './theme.js';
28
+ import { FougereTopology } from './topology-page.js';
29
+ export { FougereTopology, type FougereTopologyProps } from './topology-page.js';
30
+ import {
31
+ FougereContentIcon,
32
+ FougereDashboard,
33
+ FougereUsersIcon,
34
+ type FougereDashboardExtension,
35
+ type FougereResourceOptions,
36
+ } from './dashboard.js';
37
+
38
+ export {
39
+ FougereAppBar,
40
+ FougereLayout,
41
+ FougereMark,
42
+ fougereDarkTheme,
43
+ fougereLightTheme,
44
+ } from './theme.js';
45
+ export {
46
+ FOUGERE_DASHBOARD_WIDGETS,
47
+ FougereContentIcon,
48
+ FougereDashboard,
49
+ FougereUsersIcon,
50
+ applyDashboardExtensions,
51
+ useFougereDashboard,
52
+ type FougereDashboardContextValue,
53
+ type FougereDashboardExtension,
54
+ type FougereDashboardMetrics,
55
+ type FougereDashboardResource,
56
+ type FougereDashboardWidget,
57
+ type FougereDashboardZone,
58
+ type FougereResourceOptions,
59
+ } from './dashboard.js';
60
+
61
+ const FIELDS = {
62
+ text: TextField, number: NumberField, boolean: BooleanField, date: DateField,
63
+ } as const;
64
+
65
+ /**
66
+ * A field's visible name — its i18n key, and the derived name when nothing fills it.
67
+ *
68
+ * `labelKey` is `entity.field`, the convention `FormField` states, and both projections
69
+ * carry it beside the fallback. Passing `label` alone (which is what this file did until
70
+ * 2026-08-21) throws the key away at the last step: a translated admin was impossible
71
+ * from a contract that had said how to translate it all along.
72
+ *
73
+ * react-admin re-translates whatever `label` receives, with itself as the default, so
74
+ * handing it a resolved string is a no-op rather than a second lookup.
75
+ */
76
+ type Translate = (key: string, options: Record<string, unknown>) => string;
77
+ const labelOf = (t: Translate, field: { labelKey: string; label: string }): string =>
78
+ t(field.labelKey, { _: field.label });
79
+
80
+ function defaultFieldFor(column: TableColumn, t: Translate): ReactElement {
81
+ if (column.render === 'link') {
82
+ // `to` is the target's registration key, which is also its resource name — the
83
+ // card writes both with `registrationKeyOf`, so no mapping is needed here.
84
+ // No child on purpose: with one, `ReferenceField` prints whatever the child names —
85
+ // here the foreign id, twice over. Without one it renders the target's
86
+ // `recordRepresentation`, which is the human name, and brings a loading and an error
87
+ // state for free.
88
+ return (
89
+ <ReferenceField key={column.name} source={column.name} reference={column.to!} label={labelOf(t, column)} />
90
+ );
91
+ }
92
+ if (column.render === 'json') {
93
+ return (
94
+ <FunctionField
95
+ key={column.name} source={column.name} label={labelOf(t, column)}
96
+ render={(row: Record<string, unknown>) => JSON.stringify(row[column.name])}
97
+ />
98
+ );
99
+ }
100
+ const Field = FIELDS[column.render];
101
+ return <Field key={column.name} source={column.name} label={labelOf(t, column)} />;
102
+ }
103
+
104
+ /**
105
+ * The bounds ride along under the names a browser already enforces — the same
106
+ * `attrs` a Vue form spreads onto its input. The judge reads the same shape either
107
+ * way; stating them here only means the refusal arrives while typing.
108
+ */
109
+ function defaultInputFor(field: FormField, t: Translate): ReactElement {
110
+ if (field.control === 'select') {
111
+ return (
112
+ <SelectInput
113
+ source={field.name} label={labelOf(t, field)} isRequired={field.required}
114
+ choices={(field.options ?? []).map((id) => ({ id, name: id }))}
115
+ />
116
+ );
117
+ }
118
+ const common = {
119
+ source: field.name,
120
+ label: labelOf(t, field),
121
+ isRequired: field.required,
122
+ defaultValue: field.default,
123
+ };
124
+ if (field.control === 'number') {
125
+ return <NumberInput {...common} min={field.attrs?.min} max={field.attrs?.max} />;
126
+ }
127
+ if (field.control === 'boolean') return <BooleanInput {...common} />;
128
+ if (field.control === 'date') return <DateTimeInput {...common} />;
129
+
130
+ const { minlength, maxlength, ...attrs } = field.attrs ?? {};
131
+ return (
132
+ <TextInput
133
+ {...common}
134
+ type={field.control}
135
+ slotProps={{
136
+ htmlInput: {
137
+ ...attrs,
138
+ ...(minlength !== undefined ? { minLength: minlength } : {}),
139
+ ...(maxlength !== undefined ? { maxLength: maxlength } : {}),
140
+ },
141
+ }}
142
+ />
143
+ );
144
+ }
145
+
146
+ export interface ReactAdminFieldContext {
147
+ resource: AdminResource;
148
+ column: TableColumn;
149
+ /** The maintained Fougere renderer. Call it to wrap rather than replace it. */
150
+ defaultRender(): ReactElement;
151
+ }
152
+
153
+ export interface ReactAdminInputContext {
154
+ resource: AdminResource;
155
+ field: FormField;
156
+ defaultRender(): ReactElement;
157
+ }
158
+
159
+ export type ReactAdminFieldRenderer = (context: ReactAdminFieldContext) => ReactElement;
160
+ export type ReactAdminInputRenderer = (context: ReactAdminInputContext) => ReactElement;
161
+
162
+ export interface ReactAdminRenderers {
163
+ /** Exact `resource.field` keys. Unmentioned and future fields keep the default renderer. */
164
+ fields?: Record<string, ReactAdminFieldRenderer>;
165
+ inputs?: Record<string, ReactAdminInputRenderer>;
166
+ }
167
+
168
+ export interface ReactAdminResourceComponents {
169
+ list?: ComponentType;
170
+ show?: ComponentType;
171
+ edit?: ComponentType;
172
+ create?: ComponentType;
173
+ icon?: ComponentType;
174
+ }
175
+
176
+ export interface ResourceRenderOptions {
177
+ renderers?: ReactAdminRenderers;
178
+ components?: ReactAdminResourceComponents;
179
+ }
180
+
181
+ function fieldFor(resource: AdminResource, column: TableColumn, t: Translate, renderers?: ReactAdminRenderers): ReactElement {
182
+ const defaultRender = () => defaultFieldFor(column, t);
183
+ const renderer = renderers?.fields?.[`${resource.name}.${column.name}`];
184
+ const rendered = renderer ? renderer({ resource, column, defaultRender }) : defaultRender();
185
+ return cloneElement(rendered, { key: column.name });
186
+ }
187
+
188
+ function inputFor(resource: AdminResource, field: FormField, t: Translate, renderers?: ReactAdminRenderers): ReactElement {
189
+ const defaultRender = () => defaultInputFor(field, t);
190
+ const renderer = renderers?.inputs?.[`${resource.name}.${field.name}`];
191
+ const rendered = renderer ? renderer({ resource, field, defaultRender }) : defaultRender();
192
+ return cloneElement(rendered, { key: field.name });
193
+ }
194
+
195
+ /**
196
+ * A business operation, as a button.
197
+ *
198
+ * This is the only thing the panel has that a generic CRUD admin has not, and until now
199
+ * it had no reader at all: `AdminResource.operations` fed the stats widget and nothing
200
+ * else. In the demo that was demonstrable — `publish` is announced by the card, the
201
+ * extension gives it a label and a confirmation sentence, and there was no way to publish
202
+ * an article from the back-office.
203
+ *
204
+ * Three things are derived and none is declared per entity: WHICH ops (everything the
205
+ * door serves beyond the five verbs), WHETHER it asks for input (`op.input` — then the
206
+ * dialog's form is `formFieldsOf` over the reconstructed schema, the same projection an
207
+ * ordinary form uses), and WHETHER it confirms (`op.confirm`, from an extension).
208
+ */
209
+ function OperationButton({
210
+ resource,
211
+ operation,
212
+ }: {
213
+ resource: AdminResource;
214
+ operation: AdminOperation;
215
+ }): ReactElement {
216
+ const record = useRecordContext();
217
+ const dataProvider = useDataProvider();
218
+ const refresh = useRefresh();
219
+ const notify = useNotify();
220
+ const t = useTranslate();
221
+ const [open, setOpen] = useState(false);
222
+ const [busy, setBusy] = useState(false);
223
+ const [values, setValues] = useState<Record<string, unknown>>({});
224
+
225
+ const fields = useMemo(
226
+ () => (
227
+ operation.input
228
+ ? formFieldsOf(SchemaCard.fromDescriptor(operation.input).toSchema() as never, operation.name)
229
+ : []
230
+ ),
231
+ [operation],
232
+ );
233
+ const asks = fields.length > 0 || !!operation.confirm;
234
+ const label = t(operation.label, { _: operation.label });
235
+
236
+ const run = async () => {
237
+ setBusy(true);
238
+ try {
239
+ await (dataProvider as unknown as {
240
+ invoke: (r: string, p: { op: string; id?: string | number; data?: Record<string, unknown> }) => Promise<unknown>;
241
+ }).invoke(resource.name, {
242
+ op: operation.name,
243
+ ...(record?.id !== undefined ? { id: record.id } : {}),
244
+ ...(fields.length ? { data: values } : {}),
245
+ });
246
+ notify(t('fougere.admin.action.done', { _: '%{name} done', name: label }), { type: 'success' });
247
+ setOpen(false);
248
+ refresh();
249
+ } catch (error) {
250
+ notify((error as Error)?.message ?? String(error), { type: 'error' });
251
+ } finally {
252
+ setBusy(false);
253
+ }
254
+ };
255
+
256
+ return (
257
+ <>
258
+ <Button size="small" onClick={() => (asks ? setOpen(true) : void run())} disabled={busy}>
259
+ {label}
260
+ </Button>
261
+ <Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="xs">
262
+ <DialogTitle>{label}</DialogTitle>
263
+ <DialogContent>
264
+ {operation.confirm && (
265
+ <Typography variant="body2" color="text.secondary" sx={{ mb: fields.length ? 2 : 0 }}>
266
+ {t(operation.confirm, { _: operation.confirm })}
267
+ </Typography>
268
+ )}
269
+ {fields.map((field) => (
270
+ <MuiTextField
271
+ key={field.name}
272
+ label={t(field.labelKey, { _: field.label })}
273
+ required={field.required}
274
+ fullWidth
275
+ margin="dense"
276
+ onChange={(event) => setValues((v) => ({ ...v, [field.name]: event.target.value }))}
277
+ />
278
+ ))}
279
+ </DialogContent>
280
+ <DialogActions>
281
+ <Button onClick={() => setOpen(false)}>{t('ra.action.cancel', { _: 'Cancel' })}</Button>
282
+ <Button variant="contained" onClick={() => void run()} disabled={busy}>{label}</Button>
283
+ </DialogActions>
284
+ </Dialog>
285
+ </>
286
+ );
287
+ }
288
+
289
+ /** Row-level actions: the verbs the door serves, then everything else it serves. */
290
+ function RowActions({ resource }: { resource: AdminResource }): ReactElement {
291
+ const actions = useMemo(() => actionsOf(resource.operations), [resource]);
292
+ return (
293
+ <Box sx={{ display: 'flex', gap: .5, justifyContent: 'flex-end' }}>
294
+ {actions.filter((op) => op.kind === 'command').map((op) => (
295
+ <OperationButton key={op.name} resource={resource} operation={op} />
296
+ ))}
297
+ {resource.can.edit && <EditButton />}
298
+ {resource.can.delete && <DeleteButton />}
299
+ </Box>
300
+ );
301
+ }
302
+
303
+ const listFor = (r: AdminResource, renderers?: ReactAdminRenderers) => function ResourceList() {
304
+ const t = useTranslate();
305
+ return (
306
+ <List>
307
+ {/* `can.delete` had no reader, so a door serving no `delete` still showed the
308
+ selection checkboxes and the bulk Delete button — every one of them a 404. And
309
+ a door serving `update` but not `findById` had an inert row. */}
310
+ <Datagrid
311
+ rowClick={r.can.show ? 'show' : r.can.edit ? 'edit' : false}
312
+ bulkActionButtons={r.can.delete ? undefined : false}
313
+ >
314
+ {r.columns.map((column) => fieldFor(r, column, t, renderers))}
315
+ {/* Real links and real buttons — a Datagrid row is a bare `<tr onClick>` with no
316
+ tabIndex, so this column is also the only way to reach a row from a keyboard. */}
317
+ <WrapperField label="" source="__actions"><RowActions resource={r} /></WrapperField>
318
+ </Datagrid>
319
+ </List>
320
+ );
321
+ };
322
+ const showFor = (r: AdminResource, renderers?: ReactAdminRenderers) => function ResourceShow() {
323
+ const t = useTranslate();
324
+ return (
325
+ <Show>
326
+ <SimpleShowLayout>{r.columns.map((column) => fieldFor(r, column, t, renderers))}</SimpleShowLayout>
327
+ </Show>
328
+ );
329
+ };
330
+ /**
331
+ * `pessimistic`, and the reason is the provider's own work.
332
+ *
333
+ * react-admin edits `undoable` by default: it redirects at once, sends the call five
334
+ * seconds later, and reports a failure in a toast — by which time the form is unmounted.
335
+ * `useEditController` says it outright (`if (!hasValidationErrors || mutationMode !==
336
+ * 'pessimistic')`), so the per-field refusals `asAdminError` builds from
337
+ * `VALIDATION_FAILED` were computed, sent, and thrown away on every edit. Create was
338
+ * always pessimistic, which is why the same form judged differently depending on whether
339
+ * the row existed.
340
+ */
341
+ const editFor = (r: AdminResource, renderers?: ReactAdminRenderers) => function ResourceEdit() {
342
+ const t = useTranslate();
343
+ return (
344
+ <Edit mutationMode="pessimistic">
345
+ <SimpleForm toolbar={r.can.delete ? undefined : <Toolbar><SaveButton /></Toolbar>}>
346
+ {r.fields.map((field) => inputFor(r, field, t, renderers))}
347
+ </SimpleForm>
348
+ </Edit>
349
+ );
350
+ };
351
+ const createFor = (r: AdminResource, renderers?: ReactAdminRenderers) => function ResourceCreate() {
352
+ const t = useTranslate();
353
+ return <Create><SimpleForm>{r.fields.map((field) => inputFor(r, field, t, renderers))}</SimpleForm></Create>;
354
+ };
355
+
356
+ /** One door, as the four pages the card says it serves. */
357
+ export function resourceFor(r: AdminResource, options: ResourceRenderOptions = {}): ReactElement {
358
+ const { renderers, components = {} } = options;
359
+ const resourceOptions: FougereResourceOptions = {
360
+ label: r.label,
361
+ primary: r.primary,
362
+ facets: r.facets,
363
+ // The frond it belongs to and what it answers — the card said both, and a widget
364
+ // reporting on the app's own shape had no other way to reach them.
365
+ frond: r.frond,
366
+ operations: r.operations.map(({ name, kind }) => ({ name, kind })),
367
+ fieldCount: r.columns.length,
368
+ };
369
+ return (
370
+ <Resource
371
+ key={r.name}
372
+ name={r.name}
373
+ list={components.list ?? (r.can.list ? listFor(r, renderers) : undefined)}
374
+ show={components.show ?? (r.can.show ? showFor(r, renderers) : undefined)}
375
+ edit={components.edit ?? (r.can.edit ? editFor(r, renderers) : undefined)}
376
+ create={components.create ?? (r.can.create ? createFor(r, renderers) : undefined)}
377
+ icon={components.icon ?? (r.facets.users
378
+ ? FougereUsersIcon
379
+ : r.facets.editorial
380
+ ? FougereContentIcon
381
+ : undefined)}
382
+ options={resourceOptions}
383
+ // What names a row everywhere react-admin needs a name: the Show/Edit page title,
384
+ // an autocomplete label, the target of a reference. The declared facet says which
385
+ // field that is; the key is the fallback, and it was the only value used before —
386
+ // so every one of those places printed `post_1`.
387
+ recordRepresentation={
388
+ (r.facets.editorial as EditorialFacet | undefined)?.title
389
+ ?? (r.facets.users as UsersFacet | undefined)?.name
390
+ ?? r.primary
391
+ }
392
+ />
393
+ );
394
+ }
395
+
396
+ /**
397
+ * The whole back-office — derived defaults first, deltas and renderers second.
398
+ *
399
+ * `<Admin>` accepts a function child returning a promise of resources, so the menu is
400
+ * built at LOAD time from `rpc.discover` rather than at build time from a generator.
401
+ * Which is what lets this bundle be compiled once and shipped: no entity of the host
402
+ * app enters it, so there is nothing per-project left to build.
403
+ */
404
+ type BaseAdminProps = ComponentProps<typeof Admin>;
405
+
406
+ export type FougereAdminProps = Omit<BaseAdminProps, 'children' | 'dataProvider'> & {
407
+ endpoint?: string;
408
+ fetcher?: Fetcher;
409
+ extensions?: readonly AdminExtension[];
410
+ renderers?: ReactAdminRenderers;
411
+ /** Add, move, resize, replace or hide widgets without snapshotting the dashboard. */
412
+ dashboardExtensions?: readonly FougereDashboardExtension[];
413
+ /** Explicit page-level escape hatches, scoped to one resource and one view. */
414
+ resourceComponents?: Record<string, ReactAdminResourceComponents>;
415
+ };
416
+
417
+ const EMPTY_EXTENSIONS: readonly AdminExtension[] = [];
418
+
419
+ /**
420
+ * What the panel shows when it could not ask what to show.
421
+ *
422
+ * The card IS the application as far as this bundle is concerned, so a refused
423
+ * `rpc.discover` leaves nothing to render — and react-admin's async children have no
424
+ * rejection path of their own: the promise rejects, `status` stays `'loading'`, and the
425
+ * operator watches a spinner with no message and no way out. That is the only state
426
+ * where the panel says nothing at all, so it is the one worth building by hand.
427
+ */
428
+ function DiscoveryError({ error, onRetry }: { error: unknown; onRetry: () => void }): ReactElement {
429
+ const t = useTranslate();
430
+ const label = (key: string, fallback: string) => t(`fougere.admin.${key}`, { _: fallback });
431
+ return (
432
+ <Box sx={{ display: 'grid', placeItems: 'center', minHeight: '60vh', p: 3 }}>
433
+ <Card sx={{ maxWidth: 460, width: '100%' }}>
434
+ <CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
435
+ <Typography variant="h3" sx={{ mb: 1 }}>
436
+ {label('error.discoveryTitle', 'This app did not answer')}
437
+ </Typography>
438
+ <Typography variant="body2" color="text.secondary">
439
+ {label('error.discoveryBody', 'The panel asks the app what it hosts before it can render anything. Check that the endpoint serves a Fougere app, and that it is running.')}
440
+ </Typography>
441
+ <Typography
442
+ variant="caption"
443
+ component="pre"
444
+ sx={{ mt: 2, p: 1.5, borderRadius: 1.5, bgcolor: 'action.hover', color: 'text.secondary', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}
445
+ >
446
+ {(error as Error)?.message ?? String(error)}
447
+ </Typography>
448
+ <Button variant="contained" onClick={onRetry} sx={{ mt: 2.5 }}>
449
+ {label('error.retry', 'Try again')}
450
+ </Button>
451
+ </CardContent>
452
+ </Card>
453
+ </Box>
454
+ );
455
+ }
456
+
457
+ export function FougereAdmin({
458
+ endpoint,
459
+ fetcher,
460
+ extensions = EMPTY_EXTENSIONS,
461
+ renderers,
462
+ dashboardExtensions,
463
+ resourceComponents,
464
+ theme = fougereLightTheme,
465
+ darkTheme = fougereDarkTheme,
466
+ layout = FougereLayout,
467
+ dashboard: dashboardOverride,
468
+ ...adminProps
469
+ }: FougereAdminProps): ReactElement {
470
+ // A retry rebuilds the runtime, which is what makes it a retry: the previous one
471
+ // holds a cleared slot, but react-admin only re-runs its async child when the tree
472
+ // changes, and this key is the change.
473
+ const [attempt, setAttempt] = useState(0);
474
+ const runtime = useMemo(
475
+ () => createAdminRuntime({ endpoint, fetcher, extensions }),
476
+ [endpoint, fetcher, extensions, attempt],
477
+ );
478
+ const dashboard = useMemo<BaseAdminProps['dashboard']>(() => {
479
+ if (dashboardOverride !== undefined) return dashboardOverride;
480
+ const DerivedDashboard = () => <FougereDashboard extensions={dashboardExtensions} />;
481
+ return DerivedDashboard;
482
+ }, [dashboardOverride, dashboardExtensions]);
483
+
484
+ return (
485
+ <Admin
486
+ {...adminProps}
487
+ dataProvider={runtime.dataProvider as BaseAdminProps['dataProvider']}
488
+ theme={theme}
489
+ darkTheme={darkTheme}
490
+ layout={layout}
491
+ dashboard={dashboard}
492
+ >
493
+ {async () => {
494
+ try {
495
+ return [
496
+ ...(await runtime.load()).resources.map((resource) => resourceFor(resource, {
497
+ renderers,
498
+ components: resourceComponents?.[resource.name],
499
+ })),
500
+ /*
501
+ * The one page that renders the APP rather than a door. It is not a resource —
502
+ * there is no row behind it — so it rides a route, and its data comes from
503
+ * `rpc.topology` rather than from the card.
504
+ */
505
+ <CustomRoutes key="fougere.routes">
506
+ <Route
507
+ path="/topology"
508
+ element={<FougereTopology {...(endpoint ? { endpoint } : {})} {...(fetcher ? { fetcher } : {})} />}
509
+ />
510
+ </CustomRoutes>,
511
+ ];
512
+ } catch (error) {
513
+ return (
514
+ <CustomRoutes>
515
+ <Route path="*" element={<DiscoveryError error={error} onRetry={() => setAttempt((n) => n + 1)} />} />
516
+ </CustomRoutes>
517
+ );
518
+ }
519
+ }}
520
+ </Admin>
521
+ );
522
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What the app hosts, read as a back-office.
3
+ *
4
+ * The card (`rpc.discover`) already answers every question a back-office asks: which
5
+ * doors exist, what each one stores, and which of the five verbs it serves. So there
6
+ * is nothing to generate and nothing to configure — a new entity appears in the menu
7
+ * because the card grew a door, not because a file was written.
8
+ *
9
+ * Two projections of the same shape, and they are the pair `form.ts` already draws:
10
+ * `tableColumnsOf` for what a list SHOWS, `formFieldsOf` for what a form SUPPLIES.
11
+ */
12
+ import { Card, FieldSet, type SchemaView } from '@fougere/schema';
13
+ import {
14
+ CALL_ENDPOINT,
15
+ fetcher as browserFetcher,
16
+ formFieldsOf,
17
+ sendCall,
18
+ tableColumnsOf,
19
+ type Fetcher,
20
+ type FormField,
21
+ type TableColumn,
22
+ } from '@fougere/app/client';
23
+ import { EMPTY_INVOCATION, type CardOp, type IdentityCard } from '@fougere/core/contract';
24
+ import type { ResourceKey } from './provider.js';
25
+ import type { AdminFacets } from './facets.js';
26
+
27
+ /** One operation as the admin meets it, before a renderer decides its widget. */
28
+ export interface AdminOperation extends CardOp {
29
+ /** Display fallback. An extension may replace it without renaming the call. */
30
+ label: string;
31
+ /** Optional confirmation sentence, interpreted by renderers that support actions. */
32
+ confirm?: string;
33
+ }
34
+
35
+ /** One door, everything the UI needs to render it. */
36
+ export interface AdminResource extends ResourceKey {
37
+ /** The frond it belongs to — the menu groups by it, as the card does. */
38
+ frond: string;
39
+ /** Display fallback. The registration key in `name` never changes. */
40
+ label: string;
41
+ /**
42
+ * Declared semantic notions, empty until an extension states one. Nothing is
43
+ * inferred: what a closed set's member MEANS is not in its shape.
44
+ */
45
+ facets: AdminFacets;
46
+ /** What a row shows in a list, and what a reference points at. */
47
+ columns: TableColumn[];
48
+ /** What a create/edit form is made of, with its browser-enforced bounds. */
49
+ fields: FormField[];
50
+ /** Every callable operation, including the five CRUD verbs. */
51
+ operations: AdminOperation[];
52
+ /**
53
+ * Which of the five the door actually serves. A door answering only `list` gets a
54
+ * list and no buttons — the card says so, so the UI never offers what would 404.
55
+ */
56
+ can: { list: boolean; show: boolean; create: boolean; edit: boolean; delete: boolean };
57
+ }
58
+
59
+ function labelOf(name: string): string {
60
+ const words = name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ');
61
+ return words.charAt(0).toUpperCase() + words.slice(1);
62
+ }
63
+
64
+ /**
65
+ * The five verbs react-admin already has pages for. Everything else a door serves is a
66
+ * business operation, and gets a button rather than a page.
67
+ */
68
+ export const CRUD_OPS = ['list', 'findById', 'create', 'update', 'delete'] as const;
69
+
70
+ /** What a door serves beyond CRUD — the only thing this panel has that a generic one has not. */
71
+ export function actionsOf(operations: readonly AdminOperation[]): AdminOperation[] {
72
+ return operations.filter((op) => !(CRUD_OPS as readonly string[]).includes(op.name));
73
+ }
74
+
75
+ /** Capabilities are a reading of the visible operations, never a second list to maintain. */
76
+ export function capabilitiesOf(operations: readonly Pick<AdminOperation, 'name'>[]): AdminResource['can'] {
77
+ const serves = new Set(operations.map((op) => op.name));
78
+ return {
79
+ list: serves.has('list'),
80
+ show: serves.has('findById'),
81
+ create: serves.has('create'),
82
+ edit: serves.has('update'),
83
+ delete: serves.has('delete'),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * A door with no schema is not a resource.
89
+ *
90
+ * The card omits `schema` when nothing is stored under that name, and that is
91
+ * ordinary — a health check, a search across several shapes. There is no table to
92
+ * draw for it, and no form. It stays reachable by hand; it is simply not furniture.
93
+ */
94
+ export function resourcesOf(card: IdentityCard): AdminResource[] {
95
+ const out: AdminResource[] = [];
96
+ for (const frond of card.fronds) {
97
+ for (const door of frond.doors) {
98
+ if (!door.schema) continue;
99
+ const entity = Card.fromDescriptor(door.schema).toSchema() as unknown as SchemaView;
100
+ const primary = FieldSet.of(entity.getFields()).primary;
101
+ // No primary means no row identity — a list could be drawn, but nothing could be
102
+ // opened, edited or deleted. Refusing here is the same answer `FieldSet.primary`
103
+ // gives by not defaulting to 'id': the caller decides, and this caller declines.
104
+ if (!primary) continue;
105
+ const operations = door.ops.map((op) => ({ ...op, label: labelOf(op.name) }));
106
+ const columns = tableColumnsOf(entity, door.name);
107
+ const fields = formFieldsOf(entity, door.name);
108
+ out.push({
109
+ name: door.name,
110
+ frond: frond.name,
111
+ label: labelOf(door.name),
112
+ facets: {},
113
+ primary,
114
+ columns,
115
+ fields,
116
+ operations,
117
+ can: capabilitiesOf(operations),
118
+ });
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** The provider's index — the half of a resource it needs, keyed by door name. */
125
+ export function keysOf(resources: AdminResource[]): Record<string, ResourceKey> {
126
+ return Object.fromEntries(resources.map((r) => [r.name, { name: r.name, primary: r.primary }]));
127
+ }
128
+
129
+ /**
130
+ * Ask the running app what it hosts.
131
+ *
132
+ * `rpc.discover` travels on the same wire as every other call — it is a reserved op,
133
+ * not a second endpoint — so a back-office needs no configuration to find its
134
+ * subject, and gets the same answer whether the frond is in this process or behind
135
+ * an address in `remotes:`.
136
+ */
137
+ export async function fetchCard(
138
+ endpoint = CALL_ENDPOINT,
139
+ fetcher: Fetcher = browserFetcher,
140
+ ): Promise<IdentityCard> {
141
+ return await sendCall(
142
+ fetcher,
143
+ { entity: 'rpc', op: 'discover' },
144
+ EMPTY_INVOCATION,
145
+ endpoint,
146
+ ) as IdentityCard;
147
+ }