@ultimat3/admin 22.14.0 → 23.0.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/CLAUDE.md +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +103 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -12
package/src/layout.tsx
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
// The admin shell: skip link,
|
|
2
|
-
//
|
|
3
|
-
// token system, so nothing here knows a colour.
|
|
1
|
+
// The admin shell: skip link, sidebar (brand, who is acting, search, permission-filtered nav, the
|
|
2
|
+
// way back to the app), main region. Landmarks and a visible focus order are the accessibility
|
|
3
|
+
// contract; the theme attributes come from the token system, so nothing here knows a colour.
|
|
4
|
+
// Every control is a link or a native form — the shell ships no script.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
// why: `*.module.scss` is typed by ONE ambient declaration, and it is `@ultimat3/ui`'s. A copy
|
|
7
|
+
// here would be a `.d.ts` in `src/`, which the package-shape gate reads as a build artifact.
|
|
8
|
+
/// <reference path="../../ui/src/scss.d.ts" />
|
|
9
|
+
|
|
10
|
+
import { t } from '@ultimat3/i18n';
|
|
11
|
+
import { Input } from '@ultimat3/ui';
|
|
7
12
|
import type { JSX } from 'solid-js';
|
|
8
13
|
import type { AdminApp } from './admin';
|
|
14
|
+
import styles from './admin.module.scss';
|
|
15
|
+
import type { AdminActor } from './authz';
|
|
9
16
|
import type { NavGroup } from './nav';
|
|
10
17
|
|
|
11
18
|
export interface AdminLayoutProps {
|
|
@@ -13,26 +20,36 @@ export interface AdminLayoutProps {
|
|
|
13
20
|
/** Already filtered by `app.navFor(ctx)`. The layout does not decide visibility. */
|
|
14
21
|
readonly nav: readonly NavGroup[];
|
|
15
22
|
readonly currentPath: string;
|
|
16
|
-
|
|
17
|
-
readonly
|
|
23
|
+
/** Who is acting. Absent or anonymous reads as "not signed in" — never as an empty line. */
|
|
24
|
+
readonly actor?: AdminActor | null;
|
|
25
|
+
/** The screen's heading, already translated. One `<h1>` per screen, and this is it. */
|
|
26
|
+
readonly title?: string;
|
|
18
27
|
readonly children: JSX.Element;
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
/** The one rendering of "who am I acting as", so no two screens can disagree about it. */
|
|
31
|
+
export const actorLabel = (actor: AdminActor | null | undefined): string =>
|
|
32
|
+
actor === null || actor === undefined || actor.id === 'anonymous'
|
|
33
|
+
? t('admin.actor.anonymous')
|
|
34
|
+
: t('admin.actor.signedIn', { id: actor.id, roles: (actor.roles ?? []).join(', ') });
|
|
35
|
+
|
|
21
36
|
export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
22
37
|
const theme = props.app.theme;
|
|
38
|
+
const base = props.app.basePath;
|
|
23
39
|
return (
|
|
24
40
|
<div
|
|
25
|
-
class=
|
|
41
|
+
class={styles['shell']}
|
|
42
|
+
data-admin=""
|
|
26
43
|
data-theme={theme['data-theme']}
|
|
27
44
|
data-density={theme['data-density']}
|
|
28
45
|
style={theme.style}
|
|
29
46
|
>
|
|
30
|
-
<
|
|
31
|
-
{
|
|
32
|
-
|
|
47
|
+
<aside class={styles['side']}>
|
|
48
|
+
<a class={styles['skip']} href="#x-admin-main">
|
|
49
|
+
{t('admin.a11y.skip-to-content')}
|
|
50
|
+
</a>
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
<a class="x-admin-brand" href={props.app.basePath}>
|
|
52
|
+
<a class={styles['brand']} href={base}>
|
|
36
53
|
{props.app.branding.logo === undefined ? null : (
|
|
37
54
|
<img
|
|
38
55
|
src={props.app.branding.logo.src}
|
|
@@ -42,19 +59,16 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
|
42
59
|
)}
|
|
43
60
|
<span>{t(props.app.branding.nameKey)}</span>
|
|
44
61
|
</a>
|
|
62
|
+
<p class={styles['actor']}>{actorLabel(props.actor)}</p>
|
|
45
63
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
props.onSearch?.(field instanceof HTMLInputElement ? field.value : '');
|
|
52
|
-
}}
|
|
53
|
-
>
|
|
54
|
-
<label class="x-admin-visually-hidden" for="x-admin-search-input">
|
|
64
|
+
{/* A native GET at the search route: the term rides the URL, so a result page is a link
|
|
65
|
+
an operator can send and the form needs no handler. */}
|
|
66
|
+
<search class={styles['search']}>
|
|
67
|
+
<form method="get" action={`${base}/search`}>
|
|
68
|
+
<label class={styles['hidden']} for="x-admin-search-input">
|
|
55
69
|
{t('admin.search.label')}
|
|
56
70
|
</label>
|
|
57
|
-
<
|
|
71
|
+
<Input
|
|
58
72
|
id="x-admin-search-input"
|
|
59
73
|
name="term"
|
|
60
74
|
type="search"
|
|
@@ -63,27 +77,18 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
|
63
77
|
</form>
|
|
64
78
|
</search>
|
|
65
79
|
|
|
66
|
-
<
|
|
67
|
-
{/* The locales with a catalog: an option nobody translated is a broken page. */}
|
|
68
|
-
<LocaleSwitcher locales={registeredLocales()} onLocaleChange={props.onLocaleChange} />
|
|
69
|
-
<ThemeToggle />
|
|
70
|
-
</div>
|
|
71
|
-
</header>
|
|
72
|
-
|
|
73
|
-
<div class="x-admin-body">
|
|
74
|
-
<nav class="x-admin-nav" aria-label={t('admin.nav.label')}>
|
|
80
|
+
<nav class={styles['group']} aria-label={t('admin.nav.label')}>
|
|
75
81
|
{props.nav.map((group) => (
|
|
76
|
-
<section>
|
|
77
|
-
<h2>{t(group.labelKey)}</h2>
|
|
78
|
-
<ul>
|
|
82
|
+
<section class={styles['group']}>
|
|
83
|
+
<h2 class={styles['groupTitle']}>{t(group.labelKey)}</h2>
|
|
84
|
+
<ul class={styles['navList']}>
|
|
79
85
|
{group.items.map((item) => (
|
|
80
86
|
<li>
|
|
81
87
|
<a
|
|
82
|
-
|
|
88
|
+
class={styles['navLink']}
|
|
89
|
+
href={`${base}${item.href}`}
|
|
83
90
|
aria-current={
|
|
84
|
-
props.currentPath === `${
|
|
85
|
-
? 'page'
|
|
86
|
-
: undefined
|
|
91
|
+
props.currentPath === `${base}${item.href}` ? 'page' : undefined
|
|
87
92
|
}
|
|
88
93
|
>
|
|
89
94
|
{t(item.labelKey)}
|
|
@@ -95,10 +100,17 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
|
95
100
|
))}
|
|
96
101
|
</nav>
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
{/* The way out. The admin's route table never points back at the app, so without this an
|
|
104
|
+
operator who opened the dashboard has no link home and edits the URL bar. */}
|
|
105
|
+
<a class={styles['navLink']} href="/">
|
|
106
|
+
{t('admin.backToApp')}
|
|
107
|
+
</a>
|
|
108
|
+
</aside>
|
|
109
|
+
|
|
110
|
+
<main id="x-admin-main" class={styles['main']} tabindex={-1}>
|
|
111
|
+
{props.title === undefined ? null : <h1 class={styles['title']}>{props.title}</h1>}
|
|
112
|
+
{props.children}
|
|
113
|
+
</main>
|
|
102
114
|
</div>
|
|
103
115
|
);
|
|
104
116
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The columns a resource's table has, wherever it is drawn: its own list page, and the related
|
|
2
|
+
// card on another resource's detail page. One definition — the link that opens the row, the
|
|
3
|
+
// derived list fields, the computed columns — so a related list IS the resource's list and never
|
|
4
|
+
// a second table somebody declared beside it.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/i18n';
|
|
7
|
+
import type { Column } from '@ultimat3/ui';
|
|
8
|
+
import { type AdminRow, computedRow, rowId } from './registry';
|
|
9
|
+
import { ComputedCell } from './renderers';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
import type { WidgetContext } from './widget-value';
|
|
12
|
+
import { Widget } from './widgets';
|
|
13
|
+
|
|
14
|
+
export interface ResourceColumnsInput {
|
|
15
|
+
readonly resource: AdminResource;
|
|
16
|
+
readonly ctx: WidgetContext;
|
|
17
|
+
/** The URL of one row's detail page. */
|
|
18
|
+
readonly hrefOf: (id: string) => string;
|
|
19
|
+
/** A list field to leave out — the foreign key a related card is already filtered by. */
|
|
20
|
+
readonly omit?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resourceColumns(input: ResourceColumnsInput): readonly Column<AdminRow>[] {
|
|
24
|
+
const { resource, ctx } = input;
|
|
25
|
+
const idOf = (row: AdminRow): string => rowId(row, resource.idField);
|
|
26
|
+
const sealed = resource.secretFields.map((field) => field.name);
|
|
27
|
+
return [
|
|
28
|
+
// A real link, not a row click handler: the detail view has to survive a middle-click, a
|
|
29
|
+
// keyboard, and a crawler that never runs an onClick.
|
|
30
|
+
{
|
|
31
|
+
key: 'open',
|
|
32
|
+
header: t('admin.list.open'),
|
|
33
|
+
cell: (row) => <a href={input.hrefOf(idOf(row))}>{idOf(row)}</a>,
|
|
34
|
+
},
|
|
35
|
+
...resource.listFields
|
|
36
|
+
.filter((field) => field.name !== input.omit)
|
|
37
|
+
.map((field) => ({
|
|
38
|
+
key: field.name,
|
|
39
|
+
header: t(field.labelKey),
|
|
40
|
+
sortable: field.sortable,
|
|
41
|
+
cell: (row: AdminRow) => (
|
|
42
|
+
<Widget field={field} value={row[field.name]} ctx={ctx} mode="read" />
|
|
43
|
+
),
|
|
44
|
+
})),
|
|
45
|
+
...resource.columns.map((column) => ({
|
|
46
|
+
key: column.name,
|
|
47
|
+
header: t(column.labelKey),
|
|
48
|
+
cell: (row: AdminRow) => (
|
|
49
|
+
<ComputedCell column={column} row={computedRow(row, sealed)} ctx={ctx} />
|
|
50
|
+
),
|
|
51
|
+
})),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The filter bar: one control per derived filter, in one native GET form. Submitting it IS the
|
|
2
|
+
// navigation — every input is named for the URL parameter it sets (`list-filters.ts`), an empty
|
|
3
|
+
// one is no parameter, and the list's scope and sort ride along hidden. No script, no handler.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Button, Field, type FieldControl, Input, Link, Select } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import styles from './admin.module.scss';
|
|
9
|
+
import type { AdminField, AdminFilterKind } from './fields';
|
|
10
|
+
import { filterParam } from './list-filters';
|
|
11
|
+
import { SCOPE_PARAM, SORT_PARAM } from './list-request';
|
|
12
|
+
import type { AdminListRequest } from './list-scope';
|
|
13
|
+
import type { AdminFilter, FilterOp } from './registry';
|
|
14
|
+
import type { AdminResource } from './resource';
|
|
15
|
+
import type { WidgetContext } from './widget-value';
|
|
16
|
+
import { optionLabel } from './widgets';
|
|
17
|
+
|
|
18
|
+
export interface AdminFilterBarProps {
|
|
19
|
+
readonly resource: AdminResource;
|
|
20
|
+
/** The list as it was asked for: what each control is prefilled from. */
|
|
21
|
+
readonly request: AdminListRequest;
|
|
22
|
+
readonly ctx: WidgetContext;
|
|
23
|
+
/** The bare list URL — the form's `action`, and where Clear goes with the scope kept. */
|
|
24
|
+
readonly action: string;
|
|
25
|
+
readonly clearHref: string;
|
|
26
|
+
/** This page's own URL: where a lookup screen sends the operator back with the id chosen. */
|
|
27
|
+
readonly returnTo: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The operators a shape's CONTROLS write. Any other active filter rides the form hidden. */
|
|
31
|
+
const CONTROL_OPS: Readonly<Record<AdminFilterKind, readonly FilterOp[]>> = {
|
|
32
|
+
text: ['contains'],
|
|
33
|
+
exact: ['eq'],
|
|
34
|
+
choice: ['eq'],
|
|
35
|
+
boolean: ['eq'],
|
|
36
|
+
range: ['gte', 'lte'],
|
|
37
|
+
reference: ['eq'],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Read through a Map: an unknown shape draws no control rather than a prototype member. */
|
|
41
|
+
const CONTROLS: ReadonlyMap<string, readonly FilterOp[]> = new Map(Object.entries(CONTROL_OPS));
|
|
42
|
+
|
|
43
|
+
const controlOps = (field: AdminField): readonly FilterOp[] =>
|
|
44
|
+
field.filterKind === undefined ? [] : (CONTROLS.get(field.filterKind) ?? []);
|
|
45
|
+
|
|
46
|
+
const written = (value: AdminFilter['value'] | undefined): readonly string[] =>
|
|
47
|
+
value === undefined || value === null ? [] : typeof value === 'object' ? value : [String(value)];
|
|
48
|
+
|
|
49
|
+
const wiring = (control: FieldControl): { id: string; 'aria-describedby': string | undefined } => ({
|
|
50
|
+
id: control.id,
|
|
51
|
+
'aria-describedby': control['aria-describedby'],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function AdminFilterBar(props: AdminFilterBarProps): JSX.Element | null {
|
|
55
|
+
const { resource, request } = props;
|
|
56
|
+
if (resource.filters.length === 0) return null;
|
|
57
|
+
const active = request.filters ?? [];
|
|
58
|
+
const current = (field: AdminField, op: FilterOp): string =>
|
|
59
|
+
written(active.find((one) => one.field === field.name && one.op === op)?.value)[0] ?? '';
|
|
60
|
+
|
|
61
|
+
const any = { value: '', label: t('admin.filter.any') };
|
|
62
|
+
|
|
63
|
+
const control = (field: AdminField, own: FieldControl): JSX.Element => {
|
|
64
|
+
const name = filterParam(field, controlOps(field)[0] ?? 'eq');
|
|
65
|
+
const value = current(field, controlOps(field)[0] ?? 'eq');
|
|
66
|
+
switch (field.filterKind) {
|
|
67
|
+
case 'boolean':
|
|
68
|
+
return (
|
|
69
|
+
<Select
|
|
70
|
+
{...wiring(own)}
|
|
71
|
+
name={name}
|
|
72
|
+
value={value}
|
|
73
|
+
options={[
|
|
74
|
+
any,
|
|
75
|
+
{ value: 'true', label: t('admin.value.true') },
|
|
76
|
+
{ value: 'false', label: t('admin.value.false') },
|
|
77
|
+
]}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
case 'choice':
|
|
81
|
+
return field.values === undefined ? (
|
|
82
|
+
<Input {...wiring(own)} name={name} value={value} />
|
|
83
|
+
) : (
|
|
84
|
+
<Select
|
|
85
|
+
{...wiring(own)}
|
|
86
|
+
name={name}
|
|
87
|
+
value={value}
|
|
88
|
+
// The entity's own values, labelled by the catalog where it has them: no option list
|
|
89
|
+
// is written in the app.
|
|
90
|
+
options={[
|
|
91
|
+
any,
|
|
92
|
+
...field.values.map((one) => ({ value: one, label: optionLabel(field, one) })),
|
|
93
|
+
]}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
case 'range': {
|
|
97
|
+
const type =
|
|
98
|
+
field.type === 'date' ? 'date' : field.type === 'timestamptz' ? 'datetime-local' : 'text';
|
|
99
|
+
// `datetime-local` shows minutes; the stored filter is the ISO instant it was parsed to.
|
|
100
|
+
const shown = (op: FilterOp): string =>
|
|
101
|
+
field.type === 'timestamptz' ? current(field, op).slice(0, 16) : current(field, op);
|
|
102
|
+
return (
|
|
103
|
+
<span class={styles['range']}>
|
|
104
|
+
<Input
|
|
105
|
+
{...wiring(own)}
|
|
106
|
+
name={filterParam(field, 'gte')}
|
|
107
|
+
type={type}
|
|
108
|
+
inputmode={field.type === 'number' ? 'decimal' : undefined}
|
|
109
|
+
aria-label={t('admin.filter.from', { field: t(field.labelKey) })}
|
|
110
|
+
value={shown('gte')}
|
|
111
|
+
/>
|
|
112
|
+
<Input
|
|
113
|
+
name={filterParam(field, 'lte')}
|
|
114
|
+
type={type}
|
|
115
|
+
inputmode={field.type === 'number' ? 'decimal' : undefined}
|
|
116
|
+
aria-label={t('admin.filter.to', { field: t(field.labelKey) })}
|
|
117
|
+
// An instant is typed in UTC and says so beside the box, as the edit form does.
|
|
118
|
+
suffix={field.type === 'timestamptz' ? 'UTC' : undefined}
|
|
119
|
+
value={shown('lte')}
|
|
120
|
+
/>
|
|
121
|
+
</span>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
case 'reference': {
|
|
125
|
+
const entity = field.relation?.entity ?? '';
|
|
126
|
+
const options = props.ctx.optionsFor?.(entity) ?? null;
|
|
127
|
+
if (options !== null) {
|
|
128
|
+
return (
|
|
129
|
+
<Select
|
|
130
|
+
{...wiring(own)}
|
|
131
|
+
name={name}
|
|
132
|
+
value={value}
|
|
133
|
+
options={[any, ...options.map((one) => ({ value: one.id, label: one.label }))]}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// Too many rows for a select: the id is typed, or found on the target's lookup screen,
|
|
138
|
+
// which links back here with this parameter set.
|
|
139
|
+
const lookup = props.ctx.lookupHref?.(entity) ?? null;
|
|
140
|
+
const find =
|
|
141
|
+
lookup === null
|
|
142
|
+
? null
|
|
143
|
+
: `${lookup}?${new URLSearchParams({ return: props.returnTo, as: name }).toString()}`;
|
|
144
|
+
return (
|
|
145
|
+
<span class={styles['range']}>
|
|
146
|
+
<Input {...wiring(own)} name={name} value={value} />
|
|
147
|
+
{find === null ? null : <a href={find}>{t('admin.filter.find')}</a>}
|
|
148
|
+
</span>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
default:
|
|
152
|
+
return (
|
|
153
|
+
<Input
|
|
154
|
+
{...wiring(own)}
|
|
155
|
+
name={name}
|
|
156
|
+
type={field.filterKind === 'text' ? 'search' : 'text'}
|
|
157
|
+
value={value}
|
|
158
|
+
/>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// A filter the URL carries that no control above writes (`f.status.in`, `f.stock.gt`): kept, so
|
|
164
|
+
// submitting the form narrows the list the operator is looking at instead of widening it.
|
|
165
|
+
const carried = active.filter((one) => {
|
|
166
|
+
const field = resource.filters.find((known) => known.name === one.field);
|
|
167
|
+
return field !== undefined && !controlOps(field).includes(one.op);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<search>
|
|
172
|
+
<form class={styles['filters']} method="get" action={props.action}>
|
|
173
|
+
{request.scope === undefined || request.scope === null ? null : (
|
|
174
|
+
<input type="hidden" name={SCOPE_PARAM} value={request.scope} />
|
|
175
|
+
)}
|
|
176
|
+
{request.sort === undefined ? null : (
|
|
177
|
+
<input
|
|
178
|
+
type="hidden"
|
|
179
|
+
name={SORT_PARAM}
|
|
180
|
+
value={`${request.sort.field}:${request.sort.direction}`}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
{carried.flatMap((one) => {
|
|
184
|
+
const field = resource.filters.find((known) => known.name === one.field);
|
|
185
|
+
return field === undefined
|
|
186
|
+
? []
|
|
187
|
+
: written(one.value).map((value) => (
|
|
188
|
+
<input type="hidden" name={filterParam(field, one.op)} value={value} />
|
|
189
|
+
));
|
|
190
|
+
})}
|
|
191
|
+
{resource.filters.map((field) => (
|
|
192
|
+
<Field label={t(field.labelKey)}>{(own) => control(field, own)}</Field>
|
|
193
|
+
))}
|
|
194
|
+
<div class={styles['actions']}>
|
|
195
|
+
<Button type="submit" variant="secondary">
|
|
196
|
+
{t('admin.filter.apply')}
|
|
197
|
+
</Button>
|
|
198
|
+
{active.length === 0 ? null : (
|
|
199
|
+
<Link appearance="button" variant="ghost" tone="neutral" href={props.clearHref}>
|
|
200
|
+
{t('admin.filter.clear')}
|
|
201
|
+
</Link>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</form>
|
|
205
|
+
</search>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// What a resource's filters ARE. Per filter shape: the operators a caller may name and the
|
|
2
|
+
// control the filter bar draws. One validator — the URL parser, the MCP list tool and a `scopes:`
|
|
3
|
+
// or `rows:` declaration all pass through it — so a predicate the admin sends a repo is one the
|
|
4
|
+
// resource derived, on a column that is not sealed, with a value of the column's own type.
|
|
5
|
+
|
|
6
|
+
import { adminColumnsOf, adminSealedColumnsOf } from './entity-columns';
|
|
7
|
+
import { AdminFilterInvalidError } from './errors';
|
|
8
|
+
import type { AdminField, AdminFilterKind } from './fields';
|
|
9
|
+
import { type AdminFilter, FILTER_OPS, type FilterOp } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
|
|
12
|
+
/** The URL prefix of a filter parameter: `f.<field>` or `f.<field>.<op>`. */
|
|
13
|
+
export const FILTER_PREFIX = 'f.';
|
|
14
|
+
|
|
15
|
+
/** Per shape, the operators it answers. The FIRST is what a bare `f.<field>=` means. */
|
|
16
|
+
const OPS_BY_KIND: Readonly<Record<AdminFilterKind, readonly FilterOp[]>> = {
|
|
17
|
+
text: ['contains', 'eq', 'neq', 'is-null'],
|
|
18
|
+
exact: ['eq', 'neq', 'in', 'is-null'],
|
|
19
|
+
choice: ['eq', 'neq', 'in', 'is-null'],
|
|
20
|
+
boolean: ['eq'],
|
|
21
|
+
range: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'is-null'],
|
|
22
|
+
reference: ['eq', 'neq', 'in', 'is-null'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The same table as a Map: a shape that is not one of the six has no operators, not a prototype's. */
|
|
26
|
+
const OPS: ReadonlyMap<string, readonly FilterOp[]> = new Map(Object.entries(OPS_BY_KIND));
|
|
27
|
+
|
|
28
|
+
/** The operators one field's filter answers; empty for a field with no filter shape. */
|
|
29
|
+
export function filterOpsOf(field: AdminField): readonly FilterOp[] {
|
|
30
|
+
return field.filterKind === undefined ? [] : (OPS.get(field.filterKind) ?? []);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The parameter one filter is spelled as: the default operator is left off. */
|
|
34
|
+
export function filterParam(field: AdminField, op: FilterOp): string {
|
|
35
|
+
return op === filterOpsOf(field)[0]
|
|
36
|
+
? `${FILTER_PREFIX}${field.name}`
|
|
37
|
+
: `${FILTER_PREFIX}${field.name}.${op}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every parameter spelling a resource answers — what a refusal names. */
|
|
41
|
+
export function knownFilterParams(resource: AdminResource): readonly string[] {
|
|
42
|
+
return resource.filters.map((field) => `${FILTER_PREFIX}${field.name}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isOp = (value: string): value is FilterOp =>
|
|
46
|
+
(FILTER_OPS as readonly string[]).includes(value);
|
|
47
|
+
|
|
48
|
+
/** A value for a cause line: what was typed when it is text, its type when it is not. */
|
|
49
|
+
const shown = (value: unknown): string =>
|
|
50
|
+
typeof value === 'string' ? `"${value}"` : `a ${value === null ? 'null' : typeof value}`;
|
|
51
|
+
|
|
52
|
+
const TRUTH: ReadonlyMap<unknown, boolean> = new Map<unknown, boolean>([
|
|
53
|
+
['true', true],
|
|
54
|
+
['false', false],
|
|
55
|
+
[true, true],
|
|
56
|
+
[false, false],
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
60
|
+
const LOCAL_MINUTE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
|
61
|
+
/** An instant names its offset. A bare local time has no zone, and the admin never invents one. */
|
|
62
|
+
const ZONED = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A typed instant → the ISO string a driver compares. `datetime-local` posts `YYYY-MM-DDTHH:mm`
|
|
66
|
+
* and the control says UTC beside the box — the same contract the edit form states — and a bare
|
|
67
|
+
* date is that day's first instant in UTC.
|
|
68
|
+
*/
|
|
69
|
+
const instantOf = (raw: string): string | undefined => {
|
|
70
|
+
const text = DATE.test(raw)
|
|
71
|
+
? `${raw}T00:00:00.000Z`
|
|
72
|
+
: LOCAL_MINUTE.test(raw)
|
|
73
|
+
? `${raw}:00.000Z`
|
|
74
|
+
: raw;
|
|
75
|
+
if (!ZONED.test(text)) return undefined;
|
|
76
|
+
const at = new Date(text);
|
|
77
|
+
return Number.isNaN(at.getTime()) ? undefined : at.toISOString();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
interface Refusal {
|
|
81
|
+
readonly cause: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Scalar = string | number | boolean;
|
|
85
|
+
|
|
86
|
+
/** One value, as the column's own type — or why it is not one. */
|
|
87
|
+
function scalarOf(field: AdminField, raw: unknown): Scalar | Refusal {
|
|
88
|
+
switch (field.filterKind) {
|
|
89
|
+
case 'boolean': {
|
|
90
|
+
const truth = TRUTH.get(raw);
|
|
91
|
+
return truth ?? { cause: `takes true or false, got ${shown(raw)}` };
|
|
92
|
+
}
|
|
93
|
+
case 'range': {
|
|
94
|
+
if (field.type === 'number') {
|
|
95
|
+
const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
|
|
96
|
+
return raw !== '' && Number.isFinite(parsed)
|
|
97
|
+
? parsed
|
|
98
|
+
: { cause: `takes a number, got ${shown(raw)}` };
|
|
99
|
+
}
|
|
100
|
+
if (typeof raw !== 'string') return { cause: `takes a date as text, got ${shown(raw)}` };
|
|
101
|
+
if (field.type === 'date') {
|
|
102
|
+
return DATE.test(raw) ? raw : { cause: `takes a YYYY-MM-DD date, got ${shown(raw)}` };
|
|
103
|
+
}
|
|
104
|
+
return (
|
|
105
|
+
instantOf(raw) ?? {
|
|
106
|
+
cause: `takes a UTC instant (YYYY-MM-DDTHH:mm, or ISO-8601 with an offset), got ${shown(raw)}`,
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
case 'choice': {
|
|
111
|
+
if (typeof raw !== 'string') return { cause: `takes one of its values, got ${shown(raw)}` };
|
|
112
|
+
// An enum names its values; a locale or a zone column has no list to hold one against.
|
|
113
|
+
if (field.values !== undefined && !field.values.includes(raw)) {
|
|
114
|
+
return { cause: `takes one of ${field.values.join(', ')} — got ${shown(raw)}` };
|
|
115
|
+
}
|
|
116
|
+
return raw;
|
|
117
|
+
}
|
|
118
|
+
default:
|
|
119
|
+
if (typeof raw === 'string') return raw;
|
|
120
|
+
return typeof raw === 'number' && Number.isFinite(raw)
|
|
121
|
+
? String(raw)
|
|
122
|
+
: { cause: `takes text, got ${shown(raw)}` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isRefusal = (value: unknown): value is Refusal =>
|
|
127
|
+
typeof value === 'object' && value !== null && 'cause' in value;
|
|
128
|
+
|
|
129
|
+
const filterValueOf = (
|
|
130
|
+
field: AdminField,
|
|
131
|
+
op: FilterOp,
|
|
132
|
+
raw: unknown,
|
|
133
|
+
): AdminFilter['value'] | Refusal => {
|
|
134
|
+
if (op === 'in') {
|
|
135
|
+
const list: readonly unknown[] = Array.isArray(raw) ? raw : [raw];
|
|
136
|
+
const out: string[] = [];
|
|
137
|
+
for (const one of list) {
|
|
138
|
+
const scalar = scalarOf(field, one);
|
|
139
|
+
if (isRefusal(scalar)) return scalar;
|
|
140
|
+
out.push(String(scalar));
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
// A scalar operator given a repeated parameter reads the LAST one, as a form field does.
|
|
145
|
+
const last: unknown = Array.isArray(raw) ? raw[raw.length - 1] : raw;
|
|
146
|
+
if (op === 'is-null') {
|
|
147
|
+
return TRUTH.get(last) ?? { cause: `takes true or false, got ${shown(last)}` };
|
|
148
|
+
}
|
|
149
|
+
return scalarOf(field, last);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export interface AskedFilter {
|
|
153
|
+
readonly field: string;
|
|
154
|
+
/** Absent is the field's default operator — `contains` for text, `eq` for everything else. */
|
|
155
|
+
readonly op?: string;
|
|
156
|
+
readonly value: unknown;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One filter a CALLER asked for — a URL parameter, an MCP argument — checked against what the
|
|
161
|
+
* resource derives. Refused by name: the field when it is not a filter, the operator when the
|
|
162
|
+
* field's shape has no meaning for it, the value when it is not the column's type.
|
|
163
|
+
*/
|
|
164
|
+
export function checkedFilter(resource: AdminResource, asked: AskedFilter): AdminFilter {
|
|
165
|
+
const spelled = `${FILTER_PREFIX}${asked.field}${asked.op === undefined ? '' : `.${asked.op}`}`;
|
|
166
|
+
const refuse = (cause: string, known: readonly string[]): never => {
|
|
167
|
+
throw new AdminFilterInvalidError({ entity: resource.name, asked: spelled, cause, known });
|
|
168
|
+
};
|
|
169
|
+
const field = resource.filters.find((known) => known.name === asked.field);
|
|
170
|
+
if (field === undefined) {
|
|
171
|
+
return refuse('is not a filter of this resource', knownFilterParams(resource));
|
|
172
|
+
}
|
|
173
|
+
const ops = filterOpsOf(field);
|
|
174
|
+
const op = asked.op ?? ops[0];
|
|
175
|
+
if (op === undefined || !isOp(op) || !ops.includes(op)) {
|
|
176
|
+
return refuse(
|
|
177
|
+
`names an operator "${field.name}" does not answer`,
|
|
178
|
+
ops.map((known) => filterParam(field, known)),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const value = filterValueOf(field, op, asked.value);
|
|
182
|
+
if (isRefusal(value)) return refuse(value.cause, [filterParam(field, op)]);
|
|
183
|
+
return { field: field.name, op, value };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Predicates the APP declared — a scope's `where`, a resource's `rows` — held to a narrower rule
|
|
188
|
+
* than a caller's: any column of the entity may be named (the app knows which of its columns are
|
|
189
|
+
* indexed), but never one that is not a column and never a sealed one, whose ciphertext no
|
|
190
|
+
* predicate can be written against.
|
|
191
|
+
*/
|
|
192
|
+
export function declaredFilters(
|
|
193
|
+
resource: Pick<AdminResource, 'name' | 'entity'>,
|
|
194
|
+
where: readonly AdminFilter[],
|
|
195
|
+
declaredAs: string,
|
|
196
|
+
): readonly AdminFilter[] {
|
|
197
|
+
// Through the one flattener: it is the only reader of a column's declaration, and its two lists
|
|
198
|
+
// are exactly the two answers this needs — what may be named, and what is sealed.
|
|
199
|
+
const open = adminColumnsOf(resource.entity).map((column) => column.name);
|
|
200
|
+
const sealed = adminSealedColumnsOf(resource.entity).map((column) => column.name);
|
|
201
|
+
for (const filter of where) {
|
|
202
|
+
const refuse = (cause: string, known: readonly string[]): never => {
|
|
203
|
+
throw new AdminFilterInvalidError({
|
|
204
|
+
entity: resource.name,
|
|
205
|
+
asked: `${declaredAs}: ${filter.field} ${filter.op}`,
|
|
206
|
+
cause,
|
|
207
|
+
known,
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
if (sealed.includes(filter.field)) {
|
|
211
|
+
refuse('names a sealed column, which no predicate can read', open);
|
|
212
|
+
}
|
|
213
|
+
if (!open.includes(filter.field)) refuse('names a field that is not a column', open);
|
|
214
|
+
if (!isOp(filter.op)) refuse('names an operator the admin does not have', FILTER_OPS);
|
|
215
|
+
}
|
|
216
|
+
return where;
|
|
217
|
+
}
|