@ultimat3/admin 22.15.0 → 24.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.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
@@ -0,0 +1,189 @@
1
+ // The keyset seek on the PAIR `(sort, id)`, for a typed handle whose chain is a conjunction: the
2
+ // row-value comparison `(sort, id) > (v, id₀)` is read as disjoint segments in walk order — the
3
+ // rest of the boundary's class by id, then everything past the class, then the NULLs — so a sort
4
+ // value shared by any number of rows, and a NULL one, is a position like any other.
5
+
6
+ import type { AdminListQuery, AdminRow, AdminTableRead, KeysetBound } from './registry';
7
+
8
+ type TableOperator = Parameters<AdminTableRead['andWhere']>[1];
9
+ type Direction = 'asc' | 'desc';
10
+
11
+ /** One `andWhere` term. `value` is absent for the two null tests. */
12
+ export interface SeekTerm {
13
+ readonly field: string;
14
+ readonly op: TableOperator;
15
+ readonly value?: unknown;
16
+ }
17
+
18
+ export interface SeekColumn {
19
+ readonly field: string;
20
+ readonly idField: string;
21
+ readonly nullable: boolean;
22
+ /**
23
+ * A `timestamptz`. The row hands it back as a `Date` — milliseconds — while Postgres keeps and
24
+ * orders microseconds, so a cursor can only name the boundary's MILLISECOND. Its class is that
25
+ * millisecond `[v, v + 1ms)`, ordered by id inside: the order both drivers can agree on.
26
+ */
27
+ readonly instant: boolean;
28
+ /** The bound's text back to what the handle compares: a `Date` for an instant. */
29
+ readonly typed: (value: string) => unknown;
30
+ }
31
+
32
+ const flip = (direction: Direction): Direction => (direction === 'asc' ? 'desc' : 'asc');
33
+
34
+ const millisOf = (row: AdminRow, field: string): number | null => {
35
+ const value = row[field];
36
+ return value instanceof Date ? value.getTime() : null;
37
+ };
38
+
39
+ /** Where one walk segment reads. */
40
+ interface Segments {
41
+ readonly read: (
42
+ where: readonly SeekTerm[],
43
+ order: 'id' | 'pair',
44
+ limit: number,
45
+ ) => Promise<AdminRow[]>;
46
+ readonly column: SeekColumn;
47
+ readonly walk: Direction;
48
+ }
49
+
50
+ /** The terms that hold the boundary's class: equal, the same millisecond, or NULL. */
51
+ const classOf = (column: SeekColumn, value: unknown): readonly SeekTerm[] => {
52
+ if (value === null) return [{ field: column.field, op: 'is-null' }];
53
+ if (column.instant && value instanceof Date) {
54
+ return [
55
+ { field: column.field, op: 'gte', value },
56
+ { field: column.field, op: 'lt', value: new Date(value.getTime() + 1) },
57
+ ];
58
+ }
59
+ return [{ field: column.field, op: 'eq', value }];
60
+ };
61
+
62
+ /** Strictly past the class of a present `value`, in the walk's direction. NULLs never match. */
63
+ const pastClass = (column: SeekColumn, value: unknown, walk: Direction): readonly SeekTerm[] => {
64
+ if (walk === 'desc') return [{ field: column.field, op: 'lt', value }];
65
+ if (column.instant && value instanceof Date) {
66
+ return [{ field: column.field, op: 'gte', value: new Date(value.getTime() + 1) }];
67
+ }
68
+ return [{ field: column.field, op: 'gt', value }];
69
+ };
70
+
71
+ /** One class, by id, after `afterId` when given — exact whatever the class's internal order. */
72
+ const classRows = (
73
+ at: Segments,
74
+ where: readonly SeekTerm[],
75
+ afterId: string | undefined,
76
+ limit: number,
77
+ ): Promise<AdminRow[]> =>
78
+ limit <= 0
79
+ ? Promise.resolve([])
80
+ : at.read(
81
+ afterId === undefined
82
+ ? where
83
+ : [
84
+ ...where,
85
+ { field: at.column.idField, op: at.walk === 'asc' ? 'gt' : 'lt', value: afterId },
86
+ ],
87
+ 'id',
88
+ limit,
89
+ );
90
+
91
+ /**
92
+ * Present values past `where`, `limit` of them in `(sort, id)` order. For an instant the LAST
93
+ * millisecond read may be cut part-way through and its rows are in microsecond order, so it is
94
+ * dropped and re-read whole by id — the order the next page's seek resumes in. Every earlier
95
+ * millisecond on the page is complete, and is put in that same order.
96
+ */
97
+ async function valueRows(
98
+ at: Segments,
99
+ where: readonly SeekTerm[],
100
+ limit: number,
101
+ ): Promise<AdminRow[]> {
102
+ if (limit <= 0) return [];
103
+ const { column } = at;
104
+ const present: readonly SeekTerm[] =
105
+ where.length === 0 && column.nullable ? [{ field: column.field, op: 'is-not-null' }] : where;
106
+ if (!column.instant) return at.read(present, 'pair', limit);
107
+ const rows = await at.read(present, 'pair', limit + 1);
108
+ if (rows.length <= limit) return byMillisecondThenId(rows, column, at.walk);
109
+ const tail = millisOf(rows[limit - 1] ?? {}, column.field);
110
+ const extra = millisOf(rows[limit] ?? {}, column.field);
111
+ const kept = rows.slice(0, limit);
112
+ if (tail === null || tail !== extra) return byMillisecondThenId(kept, column, at.walk);
113
+ const whole = kept.filter((row) => millisOf(row, column.field) !== tail);
114
+ const rest = await classRows(
115
+ at,
116
+ classOf(column, new Date(tail)),
117
+ undefined,
118
+ limit - whole.length,
119
+ );
120
+ return [...byMillisecondThenId(whole, column, at.walk), ...rest];
121
+ }
122
+
123
+ /**
124
+ * Rows of one millisecond in id order. Code-unit order: it agrees with the database for a `uuid`
125
+ * key, which is the framework's; a text key under a non-C collation can disagree inside one
126
+ * millisecond, and only there.
127
+ */
128
+ const byMillisecondThenId = (
129
+ rows: readonly AdminRow[],
130
+ column: SeekColumn,
131
+ walk: Direction,
132
+ ): AdminRow[] => {
133
+ const sign = walk === 'asc' ? 1 : -1;
134
+ return [...rows].sort((a, b) => {
135
+ const at = (millisOf(a, column.field) ?? 0) - (millisOf(b, column.field) ?? 0);
136
+ if (at !== 0) return sign * at;
137
+ const left = String(a[column.idField]);
138
+ const right = String(b[column.idField]);
139
+ return sign * (left < right ? -1 : left > right ? 1 : 0);
140
+ });
141
+ };
142
+
143
+ /**
144
+ * The page a query asks for, read in WALK order — forward from an `after` bound (or from the top),
145
+ * backward from a `before` one — and answered in the query's own sort order either way.
146
+ *
147
+ * NULL is the largest value, as both drivers order it: last ascending, first descending.
148
+ */
149
+ export async function seekPage(
150
+ query: AdminListQuery,
151
+ column: SeekColumn,
152
+ read: (
153
+ where: readonly SeekTerm[],
154
+ order: { readonly by: 'id' | 'pair'; readonly direction: Direction },
155
+ limit: number,
156
+ ) => Promise<AdminRow[]>,
157
+ ): Promise<readonly AdminRow[]> {
158
+ const backward = query.before !== undefined;
159
+ const walk = backward ? flip(query.sort.direction) : query.sort.direction;
160
+ const at: Segments = {
161
+ column,
162
+ walk,
163
+ read: (where, by, limit) => read(where, { by, direction: walk }, limit),
164
+ };
165
+ const out: AdminRow[] = [];
166
+ const room = (): number => query.limit - out.length;
167
+ const bound: KeysetBound | undefined = query.after ?? query.before;
168
+ const nulls = (afterId?: string): Promise<AdminRow[]> =>
169
+ column.nullable ? classRows(at, classOf(column, null), afterId, room()) : Promise.resolve([]);
170
+
171
+ if (bound === undefined) {
172
+ if (walk === 'desc') out.push(...(await nulls()));
173
+ out.push(...(await valueRows(at, [], room())));
174
+ if (walk === 'asc') out.push(...(await nulls()));
175
+ } else if (bound.value === null) {
176
+ out.push(...(await nulls(bound.id)));
177
+ if (walk === 'desc') out.push(...(await valueRows(at, [], room())));
178
+ } else {
179
+ const value = column.typed(bound.value);
180
+ // The id IS the sort: its class is one row, so only the strict half is read.
181
+ if (column.field !== column.idField) {
182
+ out.push(...(await classRows(at, classOf(column, value), bound.id, room())));
183
+ }
184
+ out.push(...(await valueRows(at, pastClass(column, value, walk), room())));
185
+ if (walk === 'asc') out.push(...(await nulls()));
186
+ }
187
+ const page = out.slice(0, query.limit);
188
+ return backward ? page.reverse() : page;
189
+ }
package/src/layout.tsx CHANGED
@@ -1,11 +1,18 @@
1
- // The admin shell: skip link, header, permission-filtered nav, main region. Landmarks and a
2
- // visible focus order are the accessibility contract; the theme attributes come from the
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
- import { type Locale, registeredLocales, t } from '@ultimat3/i18n';
6
- import { LocaleSwitcher, ThemeToggle } from '@ultimat3/ui';
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
- readonly onSearch?: (term: string) => void;
17
- readonly onLocaleChange?: (locale: Locale) => void;
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="x-admin"
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
- <a class="x-admin-skip" href="#x-admin-main">
31
- {t('admin.a11y.skip-to-content')}
32
- </a>
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
- <header class="x-admin-header">
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
- <search class="x-admin-search">
47
- <form
48
- onSubmit={(event: SubmitEvent) => {
49
- event.preventDefault();
50
- const field = (event.currentTarget as HTMLFormElement).elements.namedItem('term');
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
- <input
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
- <div class="x-admin-header-tools">
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
- href={`${props.app.basePath}${item.href}`}
88
+ class={styles['navLink']}
89
+ href={`${base}${item.href}`}
83
90
  aria-current={
84
- props.currentPath === `${props.app.basePath}${item.href}`
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
- <main id="x-admin-main" class="x-admin-main" tabindex={-1}>
99
- {props.children}
100
- </main>
101
- </div>
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
+ }