@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
|
@@ -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:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
28
|
-
readonly
|
|
29
|
-
/**
|
|
30
|
-
readonly
|
|
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(
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
100
|
+
caption={t(resource.titleKey)}
|
|
66
101
|
columns={[
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
}
|