@ultimat3/admin 23.0.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.
- package/CLAUDE.md +22 -23
- package/README.md +12 -8
- package/package.json +16 -16
- package/src/action-gate.ts +15 -41
- package/src/batch-matching.ts +26 -31
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +8 -43
- package/src/crud-outcome.ts +96 -38
- package/src/crud.ts +48 -4
- package/src/dev/data.ts +3 -6
- package/src/dev/facts.ts +1 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +4 -2
- package/src/dev/server.ts +1 -4
- package/src/dev/sql-runner.ts +31 -0
- package/src/errors.ts +9 -5
- package/src/form-decode.ts +42 -1
- package/src/form.tsx +19 -5
- package/src/index.ts +3 -2
- package/src/jobs/fleet-repo.ts +15 -3
- package/src/jobs/job-repo.ts +51 -17
- package/src/jobs/operator.ts +3 -3
- package/src/keyset-seek.ts +189 -0
- package/src/list-filters.ts +30 -5
- package/src/list-request.ts +8 -6
- package/src/mcp.ts +4 -0
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +10 -8
- package/src/registry.ts +2 -1
- package/src/relations.ts +25 -9
- package/src/repo-entity.ts +37 -46
- package/src/resource-list.ts +9 -0
- package/src/row-version.ts +20 -0
- package/src/screen-resource.tsx +56 -8
- package/src/screen-system.tsx +8 -2
- package/src/search.ts +10 -2
- package/src/widgets.tsx +2 -6
package/src/repo-entity.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { isUltimateError } from '@ultimat3/core';
|
|
6
6
|
import { getEntity, invariantViolated } from '@ultimat3/entity';
|
|
7
7
|
import { adminColumnsOf } from './entity-columns';
|
|
8
|
+
import { seekPage } from './keyset-seek';
|
|
8
9
|
import type {
|
|
9
10
|
AdminDb,
|
|
10
11
|
AdminEntity,
|
|
@@ -41,18 +42,21 @@ const literal = (text: string): string => text.replace(/[\\%_]/g, (wild) => `\\$
|
|
|
41
42
|
* A comparison value, in the shape the column holds. An instant travels as ISO text — in a URL, in
|
|
42
43
|
* a cursor — and the handle is given the `Date`: Postgres would cast the text, the memory driver
|
|
43
44
|
* compares a `Date` to a string by its characters, and a keyset over `createdAt` (the admin's
|
|
44
|
-
* default sort) then answers page one for every page.
|
|
45
|
+
* default sort) then answers page one for every page. An `integer` travels as text in a cursor
|
|
46
|
+
* too, and the memory driver's `eq` holds `'7'` and `7` to be two values where Postgres binds one.
|
|
45
47
|
*/
|
|
46
48
|
type Typed = (field: string, value: unknown) => unknown;
|
|
47
49
|
|
|
48
50
|
const typedBy = (entity: AdminEntity): Typed => {
|
|
49
|
-
const
|
|
50
|
-
adminColumnsOf(entity)
|
|
51
|
-
.filter((column) => column.kind === 'timestamptz')
|
|
52
|
-
.map((column) => column.name),
|
|
53
|
-
);
|
|
51
|
+
const kinds = new Map(adminColumnsOf(entity).map((column) => [column.name, column.kind]));
|
|
54
52
|
return (field, value) => {
|
|
55
|
-
if (typeof value !== 'string'
|
|
53
|
+
if (typeof value !== 'string') return value;
|
|
54
|
+
const kind = kinds.get(field);
|
|
55
|
+
if (kind === 'integer') {
|
|
56
|
+
const count = Number(value);
|
|
57
|
+
return value.trim() !== '' && Number.isSafeInteger(count) ? count : value;
|
|
58
|
+
}
|
|
59
|
+
if (kind !== 'timestamptz') return value;
|
|
56
60
|
const at = new Date(value);
|
|
57
61
|
return Number.isNaN(at.getTime()) ? value : at;
|
|
58
62
|
};
|
|
@@ -81,37 +85,6 @@ const filtered = (
|
|
|
81
85
|
typed: Typed,
|
|
82
86
|
): AdminTableRead => where.reduce(predicate(typed), read);
|
|
83
87
|
|
|
84
|
-
/**
|
|
85
|
-
* `after`+asc and `before`+desc walk forward; the other two walk back. Written as one XOR rather
|
|
86
|
-
* than a four-branch table, because the four branches are the same fact said twice.
|
|
87
|
-
*
|
|
88
|
-
* `gte`/`lte`, not `gt`/`lt`: rows sharing the boundary's sort value are still on the far side of
|
|
89
|
-
* it, and `dropThroughTie` below is what removes the ones already served.
|
|
90
|
-
*/
|
|
91
|
-
const sought = (read: AdminTableRead, query: AdminListQuery, typed: Typed): AdminTableRead => {
|
|
92
|
-
const bound = query.after ?? query.before;
|
|
93
|
-
if (bound === undefined) return read;
|
|
94
|
-
const forward = (query.after !== undefined) !== (query.sort.direction === 'desc');
|
|
95
|
-
// The bound is the STRING the cursor carried (`pagination.ts` writes a Date as ISO).
|
|
96
|
-
return read.andWhere(bound.field, forward ? 'gte' : 'lte', typed(bound.field, bound.value));
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Drop everything up to and including the cursor row. A typed chain cannot express the row-value
|
|
101
|
-
* comparison `(sort, id) > (value, id)` a keyset needs, and the alternative — a strict `gt` on the
|
|
102
|
-
* sort column alone — silently skips every row that ties with the boundary.
|
|
103
|
-
*/
|
|
104
|
-
const dropThroughTie = (
|
|
105
|
-
rows: readonly AdminRow[],
|
|
106
|
-
query: AdminListQuery,
|
|
107
|
-
idField: string,
|
|
108
|
-
): readonly AdminRow[] => {
|
|
109
|
-
const bound = query.after ?? query.before;
|
|
110
|
-
if (bound === undefined) return rows;
|
|
111
|
-
const at = rows.findIndex((row) => String(row[idField]) === bound.id);
|
|
112
|
-
return at === -1 ? rows : rows.slice(at + 1);
|
|
113
|
-
};
|
|
114
|
-
|
|
115
88
|
/**
|
|
116
89
|
* One entity + its table → one `AdminRepo`.
|
|
117
90
|
*
|
|
@@ -124,6 +97,7 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
|
|
|
124
97
|
const idField = entity.$primaryKey[0] ?? 'id';
|
|
125
98
|
const idColumn = Object.hasOwn(entity.$columns, idField) ? entity.$columns[idField] : undefined;
|
|
126
99
|
const typed = typedBy(entity);
|
|
100
|
+
const columns = adminColumnsOf(entity);
|
|
127
101
|
/**
|
|
128
102
|
* `AdminRepo` carries `id: string` — a URL param, untyped by nature. The primary key column's
|
|
129
103
|
* OWN `$parse` is what earns the crossing: `/posts/nope` is judged here, at the door, instead
|
|
@@ -152,14 +126,31 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
|
|
|
152
126
|
|
|
153
127
|
return {
|
|
154
128
|
async list(query: AdminListQuery): Promise<readonly AdminRow[]> {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
129
|
+
const base = filtered(table, query.where ?? [], typed);
|
|
130
|
+
const sortField = query.sort.field;
|
|
131
|
+
const facts = columns.find((column) => column.name === sortField);
|
|
132
|
+
return seekPage(
|
|
133
|
+
query,
|
|
134
|
+
{
|
|
135
|
+
field: sortField,
|
|
136
|
+
idField,
|
|
137
|
+
nullable: facts?.nullable ?? true,
|
|
138
|
+
instant: facts?.kind === 'timestamptz',
|
|
139
|
+
typed: (value) => typed(sortField, value),
|
|
140
|
+
},
|
|
141
|
+
async (where, order, limit) => {
|
|
142
|
+
const read = where.reduce(
|
|
143
|
+
(chain, term) =>
|
|
144
|
+
term.value === undefined
|
|
145
|
+
? chain.andWhere(term.field, term.op)
|
|
146
|
+
: chain.andWhere(term.field, term.op, term.value),
|
|
147
|
+
base,
|
|
148
|
+
);
|
|
149
|
+
// The tie-break, always: a page boundary on a partial order repeats or drops a row.
|
|
150
|
+
const sorted = order.by === 'pair' ? read.orderBy(sortField, order.direction) : read;
|
|
151
|
+
return [...(await sorted.orderBy(idField, order.direction).limit(limit).all())];
|
|
152
|
+
},
|
|
153
|
+
);
|
|
163
154
|
},
|
|
164
155
|
// `async`, so a refused id arrives as a rejection: these return promises, and a caller that
|
|
165
156
|
// chains `.catch()` instead of `await`ing would never see a synchronous throw.
|
package/src/resource-list.ts
CHANGED
|
@@ -94,6 +94,12 @@ interface Declared {
|
|
|
94
94
|
readonly entity: AdminEntity;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The list URL's spelling of "no scope, not even the default" — `?scope=*`, what a related card's
|
|
99
|
+
* "all" link opens. Reserved: a scope declared under it is refused, so the word means one thing.
|
|
100
|
+
*/
|
|
101
|
+
export const NO_SCOPE = '*';
|
|
102
|
+
|
|
97
103
|
/** The declared scopes, in declaration order — which is the order the tabs are drawn in. */
|
|
98
104
|
export function scopesOf(
|
|
99
105
|
resource: Declared,
|
|
@@ -105,6 +111,9 @@ export function scopesOf(
|
|
|
105
111
|
const refuse = (asked: string, cause: string): never => {
|
|
106
112
|
throw new AdminFilterInvalidError({ entity: resource.name, asked, cause, known: names });
|
|
107
113
|
};
|
|
114
|
+
if (Object.hasOwn(scopes ?? {}, NO_SCOPE)) {
|
|
115
|
+
refuse(`scopes.${NO_SCOPE}`, `is the list URL's word for "no scope" and cannot name one`);
|
|
116
|
+
}
|
|
108
117
|
const defaults = entries.filter(([, scope]) => scope.default === true).map(([name]) => name);
|
|
109
118
|
if (defaults.length > 1) {
|
|
110
119
|
refuse(
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The version an edit form is rendered against: a keyed fingerprint of the row as it was READ,
|
|
2
|
+
// posted back in a hidden field, so `adminUpdate` can tell "the row I was shown" from "the row as
|
|
3
|
+
// it is now" and refuse a write that would silently overwrite a concurrent edit.
|
|
4
|
+
|
|
5
|
+
import { keyedFingerprint } from '@ultimat3/core';
|
|
6
|
+
import type { AdminRow } from './registry';
|
|
7
|
+
|
|
8
|
+
/** The posted name of the version, beside `_operation`: never a column, never an input field. */
|
|
9
|
+
export const VERSION_FIELD = '_version';
|
|
10
|
+
|
|
11
|
+
/** The key a write against a stale version is refused and audited with. */
|
|
12
|
+
export const ROW_CHANGED_REASON = 'admin.error.row-changed';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every value the row enumerates — a sealed one is non-enumerable and is not in it, so a password
|
|
16
|
+
* change is not a "concurrent edit" and no sealed value is ever folded into page text. Keyed: the
|
|
17
|
+
* page carries it, and an unkeyed hash of a row is an offline oracle for a short value in a column
|
|
18
|
+
* the form does not draw.
|
|
19
|
+
*/
|
|
20
|
+
export const rowVersion = (row: AdminRow): string => keyedFingerprint(row, 'admin.row-version');
|
package/src/screen-resource.tsx
CHANGED
|
@@ -36,6 +36,7 @@ import type { AdminRow } from './registry';
|
|
|
36
36
|
import { relatedLists, relatedOf } from './related';
|
|
37
37
|
import { type RelationNeed, relationNeeds, relationsFor } from './relations';
|
|
38
38
|
import type { AdminResource } from './resource';
|
|
39
|
+
import { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
|
|
39
40
|
import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
|
|
40
41
|
import {
|
|
41
42
|
type AdminRouteRequest,
|
|
@@ -250,6 +251,9 @@ export function detailScreen(app: AdminApp, resource: AdminResource): AdminScree
|
|
|
250
251
|
entityId: id,
|
|
251
252
|
changes: true,
|
|
252
253
|
limit: HISTORY_PAGE,
|
|
254
|
+
// The actor's tenant's entries only, as on the audit screen — a row two orgs may open
|
|
255
|
+
// does not show one org who in the other changed it.
|
|
256
|
+
...(request.ctx.actor.orgId === undefined ? {} : { orgId: request.ctx.actor.orgId }),
|
|
253
257
|
...(before === undefined ? {} : { before }),
|
|
254
258
|
}),
|
|
255
259
|
]);
|
|
@@ -310,6 +314,7 @@ async function form(
|
|
|
310
314
|
mode: 'create' | 'edit',
|
|
311
315
|
values: Readonly<Record<string, unknown>>,
|
|
312
316
|
issues: readonly ValidationIssue[],
|
|
317
|
+
version: string | null,
|
|
313
318
|
): Promise<JSX.Element> {
|
|
314
319
|
const list = listHref(app.basePath, resource);
|
|
315
320
|
return (
|
|
@@ -323,10 +328,18 @@ async function form(
|
|
|
323
328
|
ctx={await relationsOf(app, resource, request, values, true)}
|
|
324
329
|
action={new URL(request.url).pathname}
|
|
325
330
|
cancelHref={mode === 'create' ? list : rowHref(app, resource, idOf(request))}
|
|
331
|
+
version={version}
|
|
326
332
|
/>
|
|
327
333
|
);
|
|
328
334
|
}
|
|
329
335
|
|
|
336
|
+
/** The form-level issue a write against a stale version is re-rendered with. */
|
|
337
|
+
const ROW_CHANGED_ISSUE: ValidationIssue = {
|
|
338
|
+
path: VERSION_FIELD,
|
|
339
|
+
message: 'the row was changed after this form was opened',
|
|
340
|
+
messageKey: ROW_CHANGED_REASON,
|
|
341
|
+
};
|
|
342
|
+
|
|
330
343
|
export function createScreen(
|
|
331
344
|
app: AdminApp,
|
|
332
345
|
route: AdminRoute,
|
|
@@ -340,7 +353,7 @@ export function createScreen(
|
|
|
340
353
|
app,
|
|
341
354
|
request,
|
|
342
355
|
resource.titleKey,
|
|
343
|
-
await form(app, resource, request, 'create', {}, []),
|
|
356
|
+
await form(app, resource, request, 'create', {}, [], null),
|
|
344
357
|
);
|
|
345
358
|
}
|
|
346
359
|
const input = decodeForm(resource, request.form ?? {});
|
|
@@ -350,13 +363,14 @@ export function createScreen(
|
|
|
350
363
|
return { kind: 'redirect', location: rowHref(app, resource, id) };
|
|
351
364
|
}
|
|
352
365
|
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
353
|
-
|
|
366
|
+
// A create has no row to have gone missing or stale: neither is an answer it can give.
|
|
367
|
+
if (result.kind !== 'invalid') return notFound(app, resource, request);
|
|
354
368
|
// 422, with what was typed: a refused form that came back empty is a form filled in twice.
|
|
355
369
|
return framed(
|
|
356
370
|
app,
|
|
357
371
|
request,
|
|
358
372
|
resource.titleKey,
|
|
359
|
-
await form(app, resource, request, 'create', input, result.issues),
|
|
373
|
+
await form(app, resource, request, 'create', input, result.issues, null),
|
|
360
374
|
422,
|
|
361
375
|
);
|
|
362
376
|
};
|
|
@@ -365,8 +379,8 @@ export function createScreen(
|
|
|
365
379
|
export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminResource): AdminScreen {
|
|
366
380
|
return async (request) => {
|
|
367
381
|
const id = idOf(request);
|
|
382
|
+
const row = await findRow(resource, request.ctx.actor, id);
|
|
368
383
|
if (request.method === 'GET') {
|
|
369
|
-
const row = await findRow(resource, request.ctx.actor, id);
|
|
370
384
|
const decision = decideOperation(resource, 'update', request.ctx, id, row);
|
|
371
385
|
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
372
386
|
if (row === null) return notFound(app, resource, request);
|
|
@@ -374,19 +388,53 @@ export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminReso
|
|
|
374
388
|
app,
|
|
375
389
|
request,
|
|
376
390
|
resource.titleKey,
|
|
377
|
-
await form(app, resource, request, 'edit', row, []),
|
|
391
|
+
await form(app, resource, request, 'edit', row, [], rowVersion(row)),
|
|
378
392
|
);
|
|
379
393
|
}
|
|
380
|
-
const
|
|
381
|
-
|
|
394
|
+
const submitted = request.form ?? {};
|
|
395
|
+
// Decoded against the row as it is: an instant posted back as the minute it was drawn at is
|
|
396
|
+
// left out, not rewritten to that minute. A row that moved on since the form was rendered is
|
|
397
|
+
// refused below on its version, so "as it is" and "as it was drawn" are the same row here.
|
|
398
|
+
const input = decodeForm(resource, submitted, row ?? undefined);
|
|
399
|
+
// A post with no version is not a form this admin rendered: it is refused as stale, never run
|
|
400
|
+
// as an unversioned write — the version is how the form says which row it was looking at.
|
|
401
|
+
const version = posted(submitted, VERSION_FIELD) ?? '';
|
|
402
|
+
const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input, { version });
|
|
382
403
|
if (result.ok) return { kind: 'redirect', location: rowHref(app, resource, id) };
|
|
383
404
|
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
384
405
|
if (result.kind === 'missing') return notFound(app, resource, request);
|
|
406
|
+
if (result.kind === 'stale') {
|
|
407
|
+
// What they typed, over the row as it is NOW, under its current version: saving again is a
|
|
408
|
+
// decision taken with the other edit on screen, not an accident.
|
|
409
|
+
return framed(
|
|
410
|
+
app,
|
|
411
|
+
request,
|
|
412
|
+
resource.titleKey,
|
|
413
|
+
await form(
|
|
414
|
+
app,
|
|
415
|
+
resource,
|
|
416
|
+
request,
|
|
417
|
+
'edit',
|
|
418
|
+
{ ...result.row, ...input },
|
|
419
|
+
[ROW_CHANGED_ISSUE],
|
|
420
|
+
result.version,
|
|
421
|
+
),
|
|
422
|
+
409,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
385
425
|
return framed(
|
|
386
426
|
app,
|
|
387
427
|
request,
|
|
388
428
|
resource.titleKey,
|
|
389
|
-
await form(
|
|
429
|
+
await form(
|
|
430
|
+
app,
|
|
431
|
+
resource,
|
|
432
|
+
request,
|
|
433
|
+
'edit',
|
|
434
|
+
{ ...(row ?? {}), ...input },
|
|
435
|
+
result.issues,
|
|
436
|
+
version,
|
|
437
|
+
),
|
|
390
438
|
422,
|
|
391
439
|
);
|
|
392
440
|
};
|
package/src/screen-system.tsx
CHANGED
|
@@ -14,8 +14,14 @@ import { adminSearch } from './search';
|
|
|
14
14
|
const AUDIT_PAGE = 100;
|
|
15
15
|
|
|
16
16
|
export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
17
|
-
return guardedScreen(app, route, async () => {
|
|
18
|
-
|
|
17
|
+
return guardedScreen(app, route, async (request) => {
|
|
18
|
+
// The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
|
|
19
|
+
// their ids or what they touched. An actor with no org — the platform — reads every tenant's.
|
|
20
|
+
const orgId = request.ctx.actor.orgId;
|
|
21
|
+
const entries = await app.audit.entries({
|
|
22
|
+
limit: AUDIT_PAGE,
|
|
23
|
+
...(orgId === undefined ? {} : { orgId }),
|
|
24
|
+
});
|
|
19
25
|
return (
|
|
20
26
|
<Card>
|
|
21
27
|
{entries.length === 0 ? (
|
package/src/search.ts
CHANGED
|
@@ -105,6 +105,7 @@ async function searchResource(
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
|
|
108
|
+
const SKIPPED_FAILED = 'admin.search.skipped.failed';
|
|
108
109
|
|
|
109
110
|
export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
|
|
110
111
|
const { ctx } = input;
|
|
@@ -160,7 +161,6 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
160
161
|
skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-repo' });
|
|
161
162
|
continue;
|
|
162
163
|
}
|
|
163
|
-
searched.push(resource.name);
|
|
164
164
|
// Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
|
|
165
165
|
// asked for — `audit.ts`: if it isn't logged, it didn't happen.
|
|
166
166
|
audit.push(
|
|
@@ -176,7 +176,15 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
176
176
|
reason: decision.reason,
|
|
177
177
|
}),
|
|
178
178
|
);
|
|
179
|
-
|
|
179
|
+
try {
|
|
180
|
+
hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
|
|
181
|
+
searched.push(resource.name);
|
|
182
|
+
} catch {
|
|
183
|
+
// One resource whose repo fails is a resource this search could not read — listed, so the
|
|
184
|
+
// operator is never silently shown a subset — never a 500 for every other one. Nothing is
|
|
185
|
+
// read off the thrown value: a reason is a key.
|
|
186
|
+
skipped.push({ entity: resource.name, reason: SKIPPED_FAILED });
|
|
187
|
+
}
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
return { term, hits, searched, skipped, audit };
|
package/src/widgets.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '@ultimat3/ui';
|
|
18
18
|
import type { JSX } from 'solid-js';
|
|
19
19
|
import type { AdminField } from './fields';
|
|
20
|
-
import { currencyFieldOf } from './form-decode';
|
|
20
|
+
import { currencyFieldOf, datetimeInputValue } from './form-decode';
|
|
21
21
|
import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
|
|
22
22
|
|
|
23
23
|
export interface WidgetInput {
|
|
@@ -70,10 +70,6 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
|
|
|
70
70
|
dateStyle: 'medium',
|
|
71
71
|
}).format(at);
|
|
72
72
|
|
|
73
|
-
/** `<input type="date">` wants `YYYY-MM-DD`; `datetime-local` wants `YYYY-MM-DDTHH:mm`. */
|
|
74
|
-
const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
|
|
75
|
-
iso.slice(0, precision === 'date' ? 10 : 16);
|
|
76
|
-
|
|
77
73
|
/**
|
|
78
74
|
* An enum value's label: its translation when the catalog has one, the value itself when it does
|
|
79
75
|
* not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
|
|
@@ -238,7 +234,7 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
238
234
|
{...shared}
|
|
239
235
|
name={props.field}
|
|
240
236
|
type={props.precision === 'date' ? 'date' : 'datetime-local'}
|
|
241
|
-
value={props.value === null ? '' :
|
|
237
|
+
value={props.value === null ? '' : datetimeInputValue(props.value, props.precision)}
|
|
242
238
|
suffix={props.precision === 'date' ? undefined : 'UTC'}
|
|
243
239
|
disabled={disabled}
|
|
244
240
|
onInput={(event) => emit(input, event.currentTarget.value)}
|