@ultimat3/admin 22.14.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +103 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -12
package/src/search.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
import { finiteCount } from '@ultimat3/core';
|
|
6
6
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
7
7
|
import { type AuditEntry, deniedDraft } from './audit';
|
|
8
|
+
import type { AdminActor } from './authz';
|
|
8
9
|
import { denied } from './authz';
|
|
9
10
|
import type { CrudCtx } from './crud';
|
|
10
11
|
import { decideOperation } from './crud';
|
|
11
|
-
import
|
|
12
|
+
import { rowWhere } from './list-scope';
|
|
13
|
+
import type { AdminFilter } from './registry';
|
|
12
14
|
import { rowId } from './registry';
|
|
15
|
+
import { labelOf } from './relations';
|
|
13
16
|
import { type AdminResource, repoOf } from './resource';
|
|
14
17
|
|
|
15
18
|
/** Per resource, and per field within it. Search is a jump box, not a report. */
|
|
@@ -61,10 +64,14 @@ export interface AdminSearchInput {
|
|
|
61
64
|
*/
|
|
62
65
|
async function searchResource(
|
|
63
66
|
resource: AdminResource,
|
|
67
|
+
actor: AdminActor,
|
|
64
68
|
term: string,
|
|
65
69
|
limit: number,
|
|
66
70
|
): Promise<readonly AdminSearchHit[]> {
|
|
67
71
|
const repo = repoOf(resource);
|
|
72
|
+
// The resource's row scope rides every lookup: a hit is a row, and a row this actor's `rows`
|
|
73
|
+
// leaves out is not one they may be told exists.
|
|
74
|
+
const visible = rowWhere(resource, actor);
|
|
68
75
|
const fields = resource.searchFields.slice(0, MAX_FIELDS_PER_RESOURCE);
|
|
69
76
|
const seen = new Set<string>();
|
|
70
77
|
const hits: AdminSearchHit[] = [];
|
|
@@ -73,7 +80,10 @@ async function searchResource(
|
|
|
73
80
|
'admin search runs one indexed lookup per text field, which beats one unindexed OR',
|
|
74
81
|
async () => {
|
|
75
82
|
for (const field of fields) {
|
|
76
|
-
const where: readonly AdminFilter[] = [
|
|
83
|
+
const where: readonly AdminFilter[] = [
|
|
84
|
+
...visible,
|
|
85
|
+
{ field: field.name, op: 'contains', value: term },
|
|
86
|
+
];
|
|
77
87
|
const rows = await repo.list({ where, sort: resource.defaultSort, limit });
|
|
78
88
|
for (const row of rows) {
|
|
79
89
|
const id = rowId(row, resource.idField);
|
|
@@ -94,12 +104,6 @@ async function searchResource(
|
|
|
94
104
|
);
|
|
95
105
|
}
|
|
96
106
|
|
|
97
|
-
function labelOf(row: AdminRow, resource: AdminResource): string {
|
|
98
|
-
const value = row[resource.labelField];
|
|
99
|
-
if (typeof value === 'string' && value !== '') return value;
|
|
100
|
-
return rowId(row, resource.idField);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
107
|
const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
|
|
104
108
|
|
|
105
109
|
export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
|
|
@@ -172,7 +176,7 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
172
176
|
reason: decision.reason,
|
|
173
177
|
}),
|
|
174
178
|
);
|
|
175
|
-
hits.push(...(await searchResource(resource, term, limit)));
|
|
179
|
+
hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
return { term, hits, searched, skipped, audit };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Every stylesheet of this package belongs to the `app/` surface. The admin's screens are served
|
|
2
|
+
// there and nowhere else, and a package sheet nobody claims rides BOTH surface bundles — so an
|
|
3
|
+
// app that declared an admin shipped the dashboard's CSS in every static `site/` document
|
|
4
|
+
// (axiom 6: the static path never pays for the app path). Imported for this one side effect.
|
|
5
|
+
|
|
6
|
+
import { claimStylesheets } from '@ultimat3/render/server';
|
|
7
|
+
|
|
8
|
+
claimStylesheets(import.meta.dir, 'app');
|
package/src/test-ctx.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A `CrudCtx` for a test: an actor, the grants it holds, and an audit log to read back. A custom
|
|
2
|
+
// page's component is typed to need one (`AdminPageProps.ctx`), and until this existed the only
|
|
3
|
+
// thing that minted one was the app's whole admin — so a generated page shipped with no render
|
|
4
|
+
// test. Server-only, like everything in this barrel: never imported by an island.
|
|
5
|
+
|
|
6
|
+
import { memoryAuditLog } from './audit';
|
|
7
|
+
import { type AdminActor, staticAuthz } from './authz';
|
|
8
|
+
import type { CrudCtx } from './crud';
|
|
9
|
+
|
|
10
|
+
export interface AdminTestCtxInput {
|
|
11
|
+
/** Who is acting. Omitted: an actor with no role and no tenant. */
|
|
12
|
+
readonly actor?: AdminActor;
|
|
13
|
+
/** The permissions the actor holds. Omitted: none — every gate refuses, by name. */
|
|
14
|
+
readonly granted?: readonly string[];
|
|
15
|
+
readonly requestId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The handle a page or a CRUD call is given, built from a grant LIST. `staticAuthz` decides it, so
|
|
20
|
+
* the admin's own implications hold (`admin:write` reads) and nothing else does — a test says
|
|
21
|
+
* exactly what its actor may do, and a rule that reads the row or the tenant belongs in a test
|
|
22
|
+
* that builds the app's real authz.
|
|
23
|
+
*/
|
|
24
|
+
export function adminTestCtx(input: AdminTestCtxInput = {}): CrudCtx {
|
|
25
|
+
return {
|
|
26
|
+
actor: input.actor ?? { id: 'test-admin' },
|
|
27
|
+
authz: staticAuthz(input.granted ?? []),
|
|
28
|
+
audit: memoryAuditLog(),
|
|
29
|
+
requestId: input.requestId ?? 'test-admin-request',
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
export interface ValidationIssue {
|
|
6
6
|
readonly path: string;
|
|
7
|
+
/** The schema's own words, or the literal an agent reads for an issue the admin raised. */
|
|
7
8
|
readonly message: string;
|
|
9
|
+
/** Set only on an issue the ADMIN raised: the i18n key a screen renders instead of `message`. */
|
|
10
|
+
readonly messageKey?: string;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
interface StandardResult {
|
package/src/widget-value.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Money } from '@ultimat3/money';
|
|
|
7
7
|
import { isCurrencyCode } from '@ultimat3/schema';
|
|
8
8
|
import { AdminFieldUnsupportedError } from './errors';
|
|
9
9
|
import type { AdminField } from './fields';
|
|
10
|
+
import type { AdminOption } from './relations';
|
|
10
11
|
|
|
11
12
|
export interface WidgetContext {
|
|
12
13
|
/** IANA zone. Required — there is no "server local time" in Ultimate. */
|
|
@@ -25,6 +26,15 @@ export interface WidgetContext {
|
|
|
25
26
|
* caller that builds this context can. A wrong link is worse than no link.
|
|
26
27
|
*/
|
|
27
28
|
readonly hrefFor?: (entity: string, id: string) => string | null;
|
|
29
|
+
/**
|
|
30
|
+
* The three things a reference widget shows that only the PAGE can know, because each is a read
|
|
31
|
+
* of another resource: a referenced row's label, a small target's whole option list, and where
|
|
32
|
+
* the target's lookup lives. Absent, a reference renders its id and edits as a text box — the
|
|
33
|
+
* widget never reads a row itself, so a cell cannot become a query.
|
|
34
|
+
*/
|
|
35
|
+
readonly labelFor?: (entity: string, id: string) => string | undefined;
|
|
36
|
+
readonly optionsFor?: (entity: string) => readonly AdminOption[] | null;
|
|
37
|
+
readonly lookupHref?: (entity: string) => string | null;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export interface SelectOption {
|
|
@@ -60,15 +70,25 @@ export type WidgetProps =
|
|
|
60
70
|
readonly field: string;
|
|
61
71
|
readonly value: string | null;
|
|
62
72
|
readonly entity: string;
|
|
63
|
-
|
|
73
|
+
/** The target row's label, when the page read it. `null` renders the id itself. */
|
|
74
|
+
readonly label: string | null;
|
|
75
|
+
/** Every row of a small target — a `<select>`. `null`: the picker is the lookup screen. */
|
|
76
|
+
readonly options: readonly AdminOption[] | null;
|
|
77
|
+
/** Where an operator finds a row of the target, or `null` when it is not an admin resource. */
|
|
78
|
+
readonly lookupHref: string | null;
|
|
64
79
|
}
|
|
65
80
|
| {
|
|
66
81
|
readonly widget: 'upload';
|
|
67
82
|
readonly field: string;
|
|
68
83
|
readonly value: { readonly url: string; readonly name: string } | null;
|
|
69
|
-
}
|
|
84
|
+
}
|
|
85
|
+
/** No `value`, by type: a sealed column is written, never rendered and never prefilled. */
|
|
86
|
+
| { readonly widget: 'secret-input'; readonly field: string };
|
|
70
87
|
|
|
71
|
-
|
|
88
|
+
/** What a value guard names in its refusal — a field, or a computed column standing in for one. */
|
|
89
|
+
export type GuardedField = Pick<AdminField, 'entity' | 'name'> & { readonly currency?: string };
|
|
90
|
+
|
|
91
|
+
const fail = (field: GuardedField, cause: string, fix: string): never => {
|
|
72
92
|
throw new AdminFieldUnsupportedError({ entity: field.entity, field: field.name, cause, fix });
|
|
73
93
|
};
|
|
74
94
|
|
|
@@ -86,7 +106,7 @@ const minorUnits = (value: unknown): number | null => {
|
|
|
86
106
|
};
|
|
87
107
|
|
|
88
108
|
/** `Money = { minor: number; currency: string }` or nothing. A float is a bug, not a value. */
|
|
89
|
-
export function assertMoney(field:
|
|
109
|
+
export function assertMoney(field: GuardedField, value: unknown): Money | null {
|
|
90
110
|
if (value === null || value === undefined) return null;
|
|
91
111
|
if (typeof value === 'number') {
|
|
92
112
|
return fail(
|
|
@@ -207,16 +227,24 @@ export function widgetProps(field: AdminField, value: unknown, ctx: WidgetContex
|
|
|
207
227
|
field: field.name,
|
|
208
228
|
value: value === undefined ? '' : JSON.stringify(value, null, 2),
|
|
209
229
|
};
|
|
210
|
-
case 'reference':
|
|
230
|
+
case 'reference': {
|
|
231
|
+
const entity = field.relation?.entity ?? field.name;
|
|
232
|
+
const id = value === null || value === undefined || value === '' ? null : String(value);
|
|
211
233
|
return {
|
|
212
234
|
widget: 'reference',
|
|
213
235
|
field: field.name,
|
|
214
|
-
value:
|
|
215
|
-
entity
|
|
216
|
-
|
|
236
|
+
value: id,
|
|
237
|
+
entity,
|
|
238
|
+
label: id === null ? null : (ctx.labelFor?.(entity, id) ?? null),
|
|
239
|
+
options: ctx.optionsFor?.(entity) ?? null,
|
|
240
|
+
lookupHref: ctx.lookupHref?.(entity) ?? null,
|
|
217
241
|
};
|
|
242
|
+
}
|
|
218
243
|
case 'upload':
|
|
219
244
|
return { widget: 'upload', field: field.name, value: uploadValue(field, value) };
|
|
245
|
+
case 'secret-input':
|
|
246
|
+
// The row value is not read at all — not even to test it for null.
|
|
247
|
+
return { widget: 'secret-input', field: field.name };
|
|
220
248
|
}
|
|
221
249
|
}
|
|
222
250
|
|
package/src/widgets.tsx
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// every one of those places — there is no second place to get it wrong.
|
|
4
4
|
|
|
5
5
|
import { safeUrl } from '@ultimat3/core';
|
|
6
|
-
import { registeredLocales, t } from '@ultimat3/i18n';
|
|
6
|
+
import { isMiss, registeredLocales, t } from '@ultimat3/i18n';
|
|
7
7
|
import {
|
|
8
|
+
Badge,
|
|
8
9
|
Checkbox,
|
|
9
10
|
DateTime,
|
|
10
11
|
type DateTimeFormatter,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
} from '@ultimat3/ui';
|
|
17
18
|
import type { JSX } from 'solid-js';
|
|
18
19
|
import type { AdminField } from './fields';
|
|
20
|
+
import { currencyFieldOf } from './form-decode';
|
|
19
21
|
import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
|
|
20
22
|
|
|
21
23
|
export interface WidgetInput {
|
|
@@ -72,6 +74,17 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
|
|
|
72
74
|
const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
|
|
73
75
|
iso.slice(0, precision === 'date' ? 10 : 16);
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* An enum value's label: its translation when the catalog has one, the value itself when it does
|
|
79
|
+
* not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
|
|
80
|
+
* a `⟦admin.posts.field.audience.option.public⟧` in every cell of a derived list is not, and the
|
|
81
|
+
* key is built at runtime, so no i18n gate could have asked the app for it.
|
|
82
|
+
*/
|
|
83
|
+
export const optionLabel = (field: AdminField, value: string): string => {
|
|
84
|
+
const label = t(`${field.labelKey}.option.${value}`);
|
|
85
|
+
return isMiss(label) ? value : label;
|
|
86
|
+
};
|
|
87
|
+
|
|
75
88
|
/** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
|
|
76
89
|
function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JSX.Element {
|
|
77
90
|
switch (props.widget) {
|
|
@@ -79,7 +92,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
79
92
|
return props.value === null ? (
|
|
80
93
|
<span>{t('admin.value.empty')}</span>
|
|
81
94
|
) : (
|
|
82
|
-
|
|
95
|
+
// The ACTOR's locale, handed over: ui's own default is the document's, and an operator
|
|
96
|
+
// whose dashboard is otherwise in their language read every amount and date in another.
|
|
97
|
+
<Money value={props.value} locale={ctx.locale} />
|
|
83
98
|
);
|
|
84
99
|
case 'datetime':
|
|
85
100
|
return props.value === null ? (
|
|
@@ -87,6 +102,7 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
87
102
|
) : (
|
|
88
103
|
<DateTime
|
|
89
104
|
value={props.value}
|
|
105
|
+
locale={ctx.locale}
|
|
90
106
|
timeZone={props.timeZone}
|
|
91
107
|
format={props.precision === 'date' ? formatCalendarDate : undefined}
|
|
92
108
|
/>
|
|
@@ -94,21 +110,25 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
94
110
|
case 'checkbox':
|
|
95
111
|
return <span>{t(props.value ? 'admin.value.true' : 'admin.value.false')}</span>;
|
|
96
112
|
case 'select':
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
// A value from a closed set is a state, and a state reads as a badge. Only an enum: a
|
|
114
|
+
// locale or a zone is an identifier, and stays text.
|
|
115
|
+
if (props.value === null) return <span>{t('admin.value.empty')}</span>;
|
|
116
|
+
return field.type === 'enum' ? (
|
|
117
|
+
<Badge>{optionLabel(field, props.value)}</Badge>
|
|
118
|
+
) : (
|
|
119
|
+
<span>{optionLabel(field, props.value)}</span>
|
|
103
120
|
);
|
|
104
121
|
case 'json-editor':
|
|
105
122
|
return <pre class="x-admin-json">{props.value}</pre>;
|
|
106
123
|
case 'reference': {
|
|
107
124
|
if (props.value === null) return <span>{t('admin.value.empty')}</span>;
|
|
125
|
+
// The target's label when the page read it, the id when it did not — a row this actor may
|
|
126
|
+
// not see keeps its id and gains no name.
|
|
127
|
+
const shown = props.label ?? props.value;
|
|
108
128
|
// `ctx.hrefFor` or nothing. The route table lives on `AdminApp`; this file only ever knew
|
|
109
129
|
// the target entity's NAME, and turning that into a URL by appending an `s` is a guess.
|
|
110
130
|
const href = ctx.hrefFor?.(props.entity, props.value) ?? null;
|
|
111
|
-
return href === null ? <span>{
|
|
131
|
+
return href === null ? <span>{shown}</span> : <a href={href}>{shown}</a>;
|
|
112
132
|
}
|
|
113
133
|
case 'upload':
|
|
114
134
|
return props.value === null ? (
|
|
@@ -116,6 +136,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
116
136
|
) : (
|
|
117
137
|
<a href={safeUrl(props.value.url, 'href') ?? undefined}>{props.value.name}</a>
|
|
118
138
|
);
|
|
139
|
+
case 'secret-input':
|
|
140
|
+
// Nothing to show, by type: `WidgetProps` carries no value for a sealed column.
|
|
141
|
+
return <span>{t('admin.value.sealed')}</span>;
|
|
119
142
|
default:
|
|
120
143
|
return <span>{String(props.value ?? '')}</span>;
|
|
121
144
|
}
|
|
@@ -155,7 +178,9 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
155
178
|
// Minor units, not a decimal: turning "12,34" into cents is currency- and locale-aware
|
|
156
179
|
// work owned by @ultimat3/money, and the design system has no money input to host it.
|
|
157
180
|
const currency = props.value?.currency ?? input.field.currency ?? '';
|
|
158
|
-
|
|
181
|
+
// The other half of the value, posted beside the amount: a known currency rides hidden, an
|
|
182
|
+
// unknown one is asked for — money is always both.
|
|
183
|
+
return [
|
|
159
184
|
<Input
|
|
160
185
|
{...shared}
|
|
161
186
|
name={props.field}
|
|
@@ -167,8 +192,18 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
167
192
|
const next = event.currentTarget.value;
|
|
168
193
|
emit(input, next === '' ? null : { minor: Number(next), currency });
|
|
169
194
|
}}
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
/>,
|
|
196
|
+
currency === '' ? (
|
|
197
|
+
<Input
|
|
198
|
+
name={currencyFieldOf(props.field)}
|
|
199
|
+
maxlength={3}
|
|
200
|
+
placeholder={t('admin.value.currency')}
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<input type="hidden" name={currencyFieldOf(props.field)} value={currency} />
|
|
205
|
+
),
|
|
206
|
+
];
|
|
172
207
|
}
|
|
173
208
|
case 'checkbox':
|
|
174
209
|
return (
|
|
@@ -242,6 +277,43 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
|
242
277
|
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
243
278
|
/>
|
|
244
279
|
);
|
|
280
|
+
case 'reference':
|
|
281
|
+
// A small target is a `<select>` of its rows, by label. A large one is the id in a text
|
|
282
|
+
// box, with its label and the target's lookup beside it — both work with scripting off.
|
|
283
|
+
if (props.options !== null) {
|
|
284
|
+
return (
|
|
285
|
+
<Select
|
|
286
|
+
{...shared}
|
|
287
|
+
name={props.field}
|
|
288
|
+
value={props.value ?? ''}
|
|
289
|
+
disabled={disabled}
|
|
290
|
+
options={[
|
|
291
|
+
{ value: '', label: t('admin.value.empty') },
|
|
292
|
+
...props.options.map((option) => ({ value: option.id, label: option.label })),
|
|
293
|
+
]}
|
|
294
|
+
onChange={(event) => emit(input, event.currentTarget.value)}
|
|
295
|
+
/>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return [
|
|
299
|
+
<Input
|
|
300
|
+
{...shared}
|
|
301
|
+
name={props.field}
|
|
302
|
+
value={props.value ?? ''}
|
|
303
|
+
disabled={disabled}
|
|
304
|
+
suffix={props.label ?? undefined}
|
|
305
|
+
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
306
|
+
/>,
|
|
307
|
+
props.lookupHref === null ? null : (
|
|
308
|
+
<a href={props.lookupHref} target="_blank" rel="noopener">
|
|
309
|
+
{t('admin.filter.find')}
|
|
310
|
+
</a>
|
|
311
|
+
),
|
|
312
|
+
];
|
|
313
|
+
case 'secret-input':
|
|
314
|
+
// Write-only: never a `value`, and `new-password` so a browser offers no saved credential
|
|
315
|
+
// of the operator's own. Empty on an edit means "unchanged" (`form-decode.ts`).
|
|
316
|
+
return <Input {...shared} name={props.field} type="password" autocomplete="new-password" />;
|
|
245
317
|
default:
|
|
246
318
|
return (
|
|
247
319
|
<Input
|