@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.
Files changed (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  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 +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
@@ -0,0 +1,127 @@
1
+ // URL ⇄ the list a resource is asked for. A list page's whole state lives in its query string —
2
+ // cursor, sort, scope and filters — so paging, sorting, scoping and filtering are links and one
3
+ // GET form, and a list needs no script. One parser and one builder, so the two cannot disagree
4
+ // about a parameter's name. A parameter the resource does not derive is refused, never ignored:
5
+ // a typo that silently showed every row is how an operator acts on the wrong list.
6
+ //
7
+ // ?cursor=<signed> keyset position (pagination.ts)
8
+ // ?sort=<field>:<asc|desc> a sortable field; the direction defaults to desc
9
+ // ?scope=<name> a declared scope; absent is the resource's default
10
+ // ?f.<field>=<value> a filter, by the field's default operator
11
+ // ?f.<field>.<op>=<value> a filter, by a named operator; repeat the parameter for `in`
12
+ //
13
+ // An EMPTY value is no parameter at all: a GET form submits every input it holds.
14
+
15
+ import { AdminFilterInvalidError } from './errors';
16
+ import { checkedFilter, FILTER_PREFIX, filterParam, knownFilterParams } from './list-filters';
17
+ import type { AdminListRequest } from './list-scope';
18
+ import { scopeNamed } from './list-scope';
19
+ import type { AdminFilter, AdminSort } from './registry';
20
+ import type { AdminResource } from './resource';
21
+
22
+ export const CURSOR_PARAM = 'cursor';
23
+ export const SORT_PARAM = 'sort';
24
+ export const SCOPE_PARAM = 'scope';
25
+
26
+ const sortable = (resource: AdminResource): readonly string[] =>
27
+ resource.fields.filter((field) => field.sortable).map((field) => `${SORT_PARAM}=${field.name}`);
28
+
29
+ /** `createdAt:desc`. A bare field sorts descending — the default everywhere. */
30
+ const sortOf = (resource: AdminResource, raw: string): AdminSort => {
31
+ const at = raw.lastIndexOf(':');
32
+ const name = at < 0 ? raw : raw.slice(0, at);
33
+ const direction = at < 0 ? 'desc' : raw.slice(at + 1);
34
+ const refuse = (cause: string): never => {
35
+ throw new AdminFilterInvalidError({
36
+ entity: resource.name,
37
+ asked: `${SORT_PARAM}=${raw}`,
38
+ cause,
39
+ known: sortable(resource),
40
+ });
41
+ };
42
+ // A column the resource derives as sortable: an arbitrary name here would be an `order by` on a
43
+ // column with no index, chosen by whoever typed the URL.
44
+ const field = resource.fields.find((known) => known.name === name && known.sortable);
45
+ if (field === undefined) return refuse('names a field this resource cannot sort by');
46
+ if (direction !== 'asc' && direction !== 'desc') {
47
+ return refuse('names a direction that is neither asc nor desc');
48
+ }
49
+ return { field: field.name, direction };
50
+ };
51
+
52
+ /** `f.title.eq` → `{ field: 'title', op: 'eq' }`. A field name never holds a dot. */
53
+ const filterKey = (key: string): { readonly field: string; readonly op?: string } => {
54
+ const rest = key.slice(FILTER_PREFIX.length);
55
+ const dot = rest.indexOf('.');
56
+ return dot < 0 ? { field: rest } : { field: rest.slice(0, dot), op: rest.slice(dot + 1) };
57
+ };
58
+
59
+ /** The list a URL asks for. Absent parameters are the resource's own defaults. */
60
+ export function pageRequestOf(resource: AdminResource, url: URL): AdminListRequest {
61
+ const filters: AdminFilter[] = [];
62
+ let cursor: string | undefined;
63
+ let sort: AdminSort | undefined;
64
+ let scope: string | undefined;
65
+
66
+ for (const key of new Set(url.searchParams.keys())) {
67
+ const values = url.searchParams.getAll(key).filter((value) => value !== '');
68
+ const last = values[values.length - 1];
69
+ if (last === undefined) continue;
70
+ if (key === CURSOR_PARAM) cursor = last;
71
+ else if (key === SORT_PARAM) sort = sortOf(resource, last);
72
+ // Resolved here for its refusal alone: the name is what travels, the actor decides its rows.
73
+ else if (key === SCOPE_PARAM) scope = scopeNamed(resource, last)?.name;
74
+ else if (key.startsWith(FILTER_PREFIX)) {
75
+ filters.push(checkedFilter(resource, { ...filterKey(key), value: values }));
76
+ } else {
77
+ throw new AdminFilterInvalidError({
78
+ entity: resource.name,
79
+ asked: key,
80
+ cause: 'is not a parameter a list reads',
81
+ known: [CURSOR_PARAM, SORT_PARAM, SCOPE_PARAM, ...knownFilterParams(resource)],
82
+ });
83
+ }
84
+ }
85
+
86
+ return {
87
+ ...(cursor === undefined ? {} : { cursor }),
88
+ ...(sort === undefined ? {} : { sort }),
89
+ ...(scope === undefined ? {} : { scope }),
90
+ ...(filters.length === 0 ? {} : { filters }),
91
+ };
92
+ }
93
+
94
+ export interface ListLocation {
95
+ readonly cursor?: string | null;
96
+ readonly sort?: AdminSort;
97
+ readonly scope?: string | null;
98
+ readonly filters?: readonly AdminFilter[];
99
+ }
100
+
101
+ const written = (value: AdminFilter['value']): readonly string[] =>
102
+ value === null ? [] : typeof value === 'object' ? value : [String(value)];
103
+
104
+ /** The URL of one list state, rooted at the admin's own base path. */
105
+ export function listHref(
106
+ basePath: string,
107
+ resource: Pick<AdminResource, 'path' | 'filters'>,
108
+ location: ListLocation = {},
109
+ ): string {
110
+ const query = new URLSearchParams();
111
+ if (location.scope !== undefined && location.scope !== null) {
112
+ query.set(SCOPE_PARAM, location.scope);
113
+ }
114
+ for (const filter of location.filters ?? []) {
115
+ const field = resource.filters.find((known) => known.name === filter.field);
116
+ if (field === undefined) continue;
117
+ for (const value of written(filter.value)) query.append(filterParam(field, filter.op), value);
118
+ }
119
+ if (location.sort !== undefined) {
120
+ query.set(SORT_PARAM, `${location.sort.field}:${location.sort.direction}`);
121
+ }
122
+ if (location.cursor !== undefined && location.cursor !== null) {
123
+ query.set(CURSOR_PARAM, location.cursor);
124
+ }
125
+ const search = query.toString();
126
+ return `${basePath}${resource.path}${search === '' ? '' : `?${search}`}`;
127
+ }
@@ -0,0 +1,60 @@
1
+ // Scope tabs, as links: each tab is the list's URL under that scope, with the filters and the
2
+ // sort kept. A count is drawn only on the tabs that asked for one — the page read exactly those.
3
+
4
+ import { t } from '@ultimat3/i18n';
5
+ import { Badge } from '@ultimat3/ui';
6
+ import type { JSX } from 'solid-js';
7
+ import styles from './admin.module.scss';
8
+ import type { AdminResource } from './resource';
9
+ import type { AdminScope } from './resource-list';
10
+
11
+ export interface AdminScopeTabsProps {
12
+ readonly resource: AdminResource;
13
+ /** The scope the page was read under, or `null` for the unscoped list. */
14
+ readonly active: AdminScope | null;
15
+ readonly counts: ReadonlyMap<string, number>;
16
+ /** The list's URL under a scope — `null` is the unscoped list. */
17
+ readonly hrefFor: (scope: string | null) => string;
18
+ readonly locale: string;
19
+ }
20
+
21
+ export function AdminScopeTabs(props: AdminScopeTabsProps): JSX.Element | null {
22
+ const scopes = props.resource.scopes;
23
+ if (scopes.length === 0) return null;
24
+
25
+ const tab = (name: string | null, label: string, current: boolean): JSX.Element => {
26
+ const count = name === null ? undefined : props.counts.get(name);
27
+ return (
28
+ <li>
29
+ <a
30
+ class={styles['navLink']}
31
+ href={props.hrefFor(name)}
32
+ aria-current={current ? 'page' : undefined}
33
+ >
34
+ {label}
35
+ {count === undefined ? null : (
36
+ <>
37
+ {' '}
38
+ <Badge size="sm">{new Intl.NumberFormat(props.locale).format(count)}</Badge>
39
+ </>
40
+ )}
41
+ </a>
42
+ </li>
43
+ );
44
+ };
45
+
46
+ return (
47
+ <nav aria-label={t('admin.scope.label')}>
48
+ <ul class={styles['scopes']}>
49
+ {/* With no default scope the bare list is every row, and it needs a tab to return to.
50
+ With one, "all rows" is a scope the app declares or does not offer. */}
51
+ {scopes.some((scope) => scope.default)
52
+ ? null
53
+ : tab(null, t('admin.scope.all'), props.active === null)}
54
+ {scopes.map((scope) =>
55
+ tab(scope.name, t(scope.labelKey), props.active?.name === scope.name),
56
+ )}
57
+ </ul>
58
+ </nav>
59
+ );
60
+ }
@@ -0,0 +1,113 @@
1
+ // The predicates one read of a resource carries, composed in one place: the resource's row scope
2
+ // (who may see which rows at all), then the list's scope, then the caller's filters. Every read
3
+ // the admin issues — list, search, detail, lookup, labels, the MCP tools — builds its `where`
4
+ // here, so "this actor never sees that row" is one function and not five call sites agreeing.
5
+
6
+ import type { AdminActor } from './authz';
7
+ import { AdminFilterInvalidError } from './errors';
8
+ import type { AdminFilter, AdminRow, AdminSort } from './registry';
9
+ import { type AdminResource, repoOf } from './resource';
10
+ import type { AdminScope } from './resource-list';
11
+
12
+ /** One list, as a caller asks for it. `scope` and `filters` are names the resource must derive. */
13
+ export interface AdminListRequest {
14
+ readonly cursor?: string | null;
15
+ readonly limit?: number;
16
+ readonly sort?: AdminSort;
17
+ /**
18
+ * A declared scope's name. Absent is the resource's default scope, or none. `null` is "no scope,
19
+ * not even the default" — what a lookup asks, because a label is owed for a row in ANY scope.
20
+ */
21
+ readonly scope?: string | null;
22
+ /** Already checked — `checkedFilter` is the only thing that builds one from a caller's input. */
23
+ readonly filters?: readonly AdminFilter[];
24
+ }
25
+
26
+ /** The rows of `resource` this actor may see at all — empty when the resource declares no `rows`. */
27
+ export function rowWhere(resource: AdminResource, actor: AdminActor): readonly AdminFilter[] {
28
+ return resource.rowScope?.(actor) ?? [];
29
+ }
30
+
31
+ /**
32
+ * The scope a request names; the default one when it names none; `undefined` when the resource
33
+ * has no default. A name nobody declared is refused — a typo must not read as "every row".
34
+ */
35
+ export function scopeNamed(
36
+ resource: AdminResource,
37
+ name: string | null | undefined,
38
+ ): AdminScope | undefined {
39
+ if (name === null) return undefined;
40
+ if (name === undefined || name === '') return resource.scopes.find((scope) => scope.default);
41
+ const found = resource.scopes.find((scope) => scope.name === name);
42
+ if (found !== undefined) return found;
43
+ throw new AdminFilterInvalidError({
44
+ entity: resource.name,
45
+ asked: `scope=${name}`,
46
+ cause: 'is not a scope of this resource',
47
+ known: resource.scopes.map((scope) => `scope=${scope.name}`),
48
+ });
49
+ }
50
+
51
+ export interface ListWhere {
52
+ readonly scope: AdminScope | undefined;
53
+ readonly where: readonly AdminFilter[];
54
+ }
55
+
56
+ /** Row scope, then scope, then filters — a conjunction, in the order they narrow. */
57
+ export function listWhere(
58
+ resource: AdminResource,
59
+ actor: AdminActor,
60
+ request: Pick<AdminListRequest, 'scope' | 'filters'>,
61
+ ): ListWhere {
62
+ const scope = scopeNamed(resource, request.scope);
63
+ return {
64
+ scope,
65
+ where: [
66
+ ...rowWhere(resource, actor),
67
+ ...(scope?.where(actor) ?? []),
68
+ ...(request.filters ?? []),
69
+ ],
70
+ };
71
+ }
72
+
73
+ /**
74
+ * One row by id, or `null` — for a row that does not exist AND for one this actor's row scope
75
+ * leaves out, which are the same answer on purpose: a 404 that distinguished them would confirm
76
+ * the row exists.
77
+ *
78
+ * The membership question is put to the repo, never re-evaluated here: a second implementation of
79
+ * `contains` or `in` in this file would be a second opinion about what a predicate means. It costs
80
+ * one extra read, and only for a resource that declares `rows`.
81
+ */
82
+ export async function findRow<Row extends AdminRow>(
83
+ resource: AdminResource<Row>,
84
+ actor: AdminActor,
85
+ id: string,
86
+ ): Promise<Row | null> {
87
+ const repo = repoOf(resource);
88
+ const row = await repo.find(id);
89
+ if (row === null || resource.rowScope === undefined) return row;
90
+ const visible = await repo.list({
91
+ where: [...resource.rowScope(actor), { field: resource.idField, op: 'eq', value: id }],
92
+ sort: resource.defaultSort,
93
+ limit: 1,
94
+ });
95
+ return visible.length === 0 ? null : row;
96
+ }
97
+
98
+ /**
99
+ * The count behind each tab that asked for one — `count: true` — and no other. One query per
100
+ * counted scope: the row scope and the scope's own predicates, never the caller's filters, so a
101
+ * tab's number is the size of the scope and does not move as the operator types.
102
+ */
103
+ export async function scopeCounts(
104
+ resource: AdminResource,
105
+ actor: AdminActor,
106
+ ): Promise<ReadonlyMap<string, number>> {
107
+ const counted = resource.scopes.filter((scope) => scope.count);
108
+ const count = repoOf(resource).count;
109
+ if (counted.length === 0 || count === undefined) return new Map();
110
+ const rows = rowWhere(resource, actor);
111
+ const totals = await Promise.all(counted.map((scope) => count([...rows, ...scope.where(actor)])));
112
+ return new Map(counted.map((scope, index) => [scope.name, totals[index] ?? 0]));
113
+ }
package/src/list.tsx CHANGED
@@ -1,33 +1,46 @@
1
- // The list view: derived columns, keyset pagination, and the three states a table actually
2
- // has (loading, empty, error) instead of the one it usually ships with. Prev/Next are the
3
- // only navigation — there is no page number, because there is no offset.
1
+ // The list view: scope tabs, the filter bar, the batch bar, derived and computed columns, keyset
2
+ // pagination, and the states a server-rendered table has (rows, empty, error). Every control is a
3
+ // link or a native form — a row opens through a real `<a href>`, a header sorts through one,
4
+ // paging is `<Pagination hrefFor>`, a row is selected by a checkbox the batch form owns — so the
5
+ // page ships no script and there is nothing to hydrate.
6
+ // Prev/Next are the only navigation: there is no page number, because there is no offset.
4
7
 
5
8
  import { t } from '@ultimat3/i18n';
6
9
  import { Card, DataTable, ErrorState } from '@ultimat3/ui';
7
10
  import type { JSX } from 'solid-js';
11
+ import { decideAction } from './action-gate';
8
12
  import { AdminActions } from './actions';
9
13
  import type { AdminActor, AdminAuthz } from './authz';
14
+ import { AdminBatchBar, BatchCheckbox } from './batch-bar';
10
15
  import { type AdminErrorParts, adminErrorFrom } from './errors';
16
+ import { resourceColumns } from './list-columns';
17
+ import { AdminFilterBar } from './list-filter-bar';
18
+ import type { ListLocation } from './list-request';
19
+ import type { AdminListRequest } from './list-scope';
20
+ import { AdminScopeTabs } from './list-scope-tabs';
11
21
  import type { AdminPage } from './pagination';
12
- import type { AdminRow, AdminSort } from './registry';
22
+ import type { AdminRow } from './registry';
13
23
  import { rowId } from './registry';
14
24
  import type { AdminResource } from './resource';
25
+ import type { AdminScope } from './resource-list';
15
26
  import type { WidgetContext } from './widget-value';
16
- import { Widget } from './widgets';
17
27
 
18
28
  export interface AdminListProps<Row extends AdminRow> {
19
29
  readonly resource: AdminResource<Row>;
20
- readonly page: AdminPage<Row> | null;
21
- readonly loading: boolean;
30
+ readonly page: AdminPage<Row>;
22
31
  readonly error: AdminErrorParts | null;
23
32
  readonly ctx: WidgetContext;
24
33
  readonly actor: AdminActor;
25
34
  readonly authz: AdminAuthz;
26
35
  readonly basePath: string;
27
- readonly onCursor: (cursor: string | null) => void;
28
- readonly onOpen: (id: string) => void;
29
- /** Absent means the table renders its current order without sortable headers. */
30
- readonly onSort?: (sort: AdminSort) => void;
36
+ /** The list as it was asked for — its scope, filters and sort ride every link on the page. */
37
+ readonly request: AdminListRequest;
38
+ /** The scope the page was READ under: the request's, or the resource's default. */
39
+ readonly scope: AdminScope | null;
40
+ /** The count of each scope that declared `count: true`, and of no other. */
41
+ readonly counts: ReadonlyMap<string, number>;
42
+ /** The URL of one list state — `listHref()`, bound to this admin and this resource. */
43
+ readonly hrefFor: (location: ListLocation) => string;
31
44
  }
32
45
 
33
46
  export function AdminList<Row extends AdminRow>(props: AdminListProps<Row>): JSX.Element {
@@ -38,83 +51,109 @@ export function AdminList<Row extends AdminRow>(props: AdminListProps<Row>): JSX
38
51
  return <ErrorState error={adminErrorFrom(props.error)} />;
39
52
  }
40
53
 
41
- if (props.loading || props.page === null) {
42
- return (
43
- <Card header={<h2>{t(props.resource.titleKey)}</h2>}>
44
- <p aria-busy="true">{t('admin.list.loading')}</p>
45
- </Card>
46
- );
47
- }
48
-
49
54
  const page = props.page;
55
+ // The base `AdminResource`: this component's own `Row` is the app's, the pieces below read rows
56
+ // as `AdminRow`, and a resource is the same object either way.
57
+ const resource: AdminResource = props.resource as unknown as AdminResource;
58
+ const acts = resource.actions.length > 0;
59
+ const sealed = resource.secretFields.map((field) => field.name);
60
+ // The batch actions THIS actor may run — the gate's own answer, asked once for the list. A row
61
+ // the action's `when` excludes is still selectable: the server counts it `refused`, by name.
62
+ const batch = resource.actions.filter(
63
+ (action) =>
64
+ action.batch !== undefined &&
65
+ decideAction(action, props.actor, props.authz, { entity: resource.name }).allowed,
66
+ );
67
+ const { scope, filters, sort } = props.request;
68
+ /** Everything but the cursor: a cursor is a position IN this state, never part of it. */
69
+ const state: ListLocation = {
70
+ ...(scope === undefined ? {} : { scope }),
71
+ ...(filters === undefined ? {} : { filters }),
72
+ ...(sort === undefined ? {} : { sort }),
73
+ };
50
74
 
51
75
  return (
52
- <Card header={<h2>{t(props.resource.titleKey)}</h2>}>
53
- <AdminActions
54
- actions={props.resource.actions}
55
- actor={props.actor}
56
- authz={props.authz}
57
- subject={{ entity: props.resource.name }}
58
- onRun={() => props.onCursor(null)}
76
+ <Card header={<h2>{t(resource.titleKey)}</h2>}>
77
+ <AdminScopeTabs
78
+ resource={resource}
79
+ active={props.scope}
80
+ counts={props.counts}
81
+ locale={props.ctx.locale}
82
+ hrefFor={(name) => props.hrefFor({ ...state, scope: name })}
59
83
  />
60
-
84
+ <AdminFilterBar
85
+ resource={resource}
86
+ request={props.request}
87
+ ctx={props.ctx}
88
+ action={props.hrefFor({})}
89
+ clearHref={props.hrefFor({
90
+ ...(scope === undefined ? {} : { scope }),
91
+ ...(sort === undefined ? {} : { sort }),
92
+ })}
93
+ returnTo={props.hrefFor(state)}
94
+ />
95
+ <AdminBatchBar actions={batch} href={props.hrefFor(state)} />
61
96
  {page.rows.length === 0 ? (
62
97
  <p class="x-admin-empty">{t('admin.list.empty')}</p>
63
98
  ) : (
64
99
  <DataTable
65
- caption={t(props.resource.titleKey)}
100
+ caption={t(resource.titleKey)}
66
101
  columns={[
67
- // A real link, not a row click handler: the detail view has to survive a
68
- // middle-click, a keyboard, and a crawler that never runs the onClick.
69
- {
70
- key: 'open',
71
- header: t('admin.list.open'),
72
- cell: (row: Row) => (
73
- <a
74
- href={href(idOf(row))}
75
- onClick={(event) => {
76
- event.preventDefault();
77
- props.onOpen(idOf(row));
78
- }}
79
- >
80
- {idOf(row)}
81
- </a>
82
- ),
83
- },
84
- ...props.resource.listFields.map((field) => ({
85
- key: field.name,
86
- header: t(field.labelKey),
87
- sortable: field.sortable && props.onSort !== undefined,
88
- cell: (row: Row) => (
89
- <Widget field={field} value={row[field.name]} ctx={props.ctx} mode="read" />
90
- ),
91
- })),
102
+ ...(batch.length === 0
103
+ ? []
104
+ : [
105
+ {
106
+ key: 'select',
107
+ header: t('admin.batch.select'),
108
+ cell: (row: Row) => (
109
+ <BatchCheckbox
110
+ id={idOf(row)}
111
+ label={t('admin.batch.select-row', { id: idOf(row) })}
112
+ />
113
+ ),
114
+ },
115
+ ]),
116
+ // The resource's columns, the same ones a related card on another detail page draws.
117
+ ...resourceColumns({ resource, ctx: props.ctx, hrefOf: href }),
118
+ // Decided per ROW, with the row on the subject: a rule that reads it (ownership, a
119
+ // state) answers for this row and not for the table.
120
+ ...(acts
121
+ ? [
122
+ {
123
+ key: 'actions',
124
+ header: t('admin.actions.label'),
125
+ cell: (row: Row) => (
126
+ <AdminActions
127
+ actions={resource.actions}
128
+ actor={props.actor}
129
+ authz={props.authz}
130
+ subject={{ entity: resource.name, id: idOf(row), row }}
131
+ sealed={sealed}
132
+ href={href(idOf(row))}
133
+ />
134
+ ),
135
+ },
136
+ ]
137
+ : []),
92
138
  ]}
93
139
  rows={page.rows}
94
140
  rowKey={idOf}
95
141
  sort={{ key: page.sort.field, direction: page.sort.direction }}
96
- onSortChange={(next) => {
97
- if (next !== undefined) props.onSort?.({ field: next.key, direction: next.direction });
142
+ nextCursor={page.hasMore ? (page.nextCursor ?? undefined) : undefined}
143
+ prevCursor={page.prevCursor ?? undefined}
144
+ hrefFor={(cursor) => props.hrefFor({ ...state, cursor })}
145
+ // The header's NEXT state, as a URL. "Unsorted" is the list with no sort in it — the
146
+ // resource's own default order.
147
+ sortHrefFor={(next) => {
148
+ const { sort: _current, ...rest } = state;
149
+ return props.hrefFor(
150
+ next === undefined
151
+ ? rest
152
+ : { ...rest, sort: { field: next.key, direction: next.direction } },
153
+ );
98
154
  }}
99
155
  />
100
156
  )}
101
-
102
- <nav class="x-admin-pager" aria-label={t('admin.list.pagination')}>
103
- <button
104
- type="button"
105
- disabled={page.prevCursor === null}
106
- onClick={() => props.onCursor(page.prevCursor)}
107
- >
108
- {t('admin.list.previous')}
109
- </button>
110
- <button
111
- type="button"
112
- disabled={!page.hasMore}
113
- onClick={() => props.onCursor(page.nextCursor)}
114
- >
115
- {t('admin.list.next')}
116
- </button>
117
- </nav>
118
157
  </Card>
119
158
  );
120
159
  }