@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/dist/dashboard.d.ts +3 -3
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/facets.d.ts.map +1 -1
- package/dist/facets.js +2 -24
- package/dist/facets.js.map +1 -1
- package/dist/provider.d.ts +4 -4
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js.map +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +4 -2
- package/dist/react.js.map +1 -1
- package/dist/resources.d.ts +1 -1
- package/dist/resources.d.ts.map +1 -1
- package/dist/resources.js +4 -4
- package/dist/resources.js.map +1 -1
- package/package.json +6 -5
- package/src/dashboard.tsx +611 -0
- package/src/extensions.ts +125 -0
- package/src/facets.ts +79 -0
- package/src/index.ts +59 -0
- package/src/provider.ts +269 -0
- package/src/react.tsx +522 -0
- package/src/resources.ts +147 -0
- package/src/runtime.ts +68 -0
- package/src/theme.tsx +527 -0
- package/src/topology-page.tsx +193 -0
- package/src/topology.ts +87 -0
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
|
+
}
|
package/src/resources.ts
ADDED
|
@@ -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
|
+
}
|