@ultimat3/admin 1.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/LICENSE +21 -0
- package/README.md +115 -0
- package/package.json +48 -0
- package/src/action-gate.ts +202 -0
- package/src/actions.tsx +94 -0
- package/src/admin.ts +186 -0
- package/src/ai-panes.ts +139 -0
- package/src/audit.ts +183 -0
- package/src/authz.ts +129 -0
- package/src/crud.ts +278 -0
- package/src/detail.tsx +121 -0
- package/src/dev/data.ts +344 -0
- package/src/dev/facts.ts +180 -0
- package/src/dev/index.ts +47 -0
- package/src/dev/panel-cache.ts +47 -0
- package/src/dev/panel-db.ts +59 -0
- package/src/dev/panel-jobs.ts +66 -0
- package/src/dev/panel-live.ts +46 -0
- package/src/dev/panel-mail.ts +51 -0
- package/src/dev/panel-manifest.ts +39 -0
- package/src/dev/panel-policy.ts +61 -0
- package/src/dev/panel-routes.ts +43 -0
- package/src/dev/panel-timeline.ts +82 -0
- package/src/dev/panel.ts +58 -0
- package/src/dev/server.ts +189 -0
- package/src/entity-columns.ts +95 -0
- package/src/errors.ts +145 -0
- package/src/fields.ts +151 -0
- package/src/form.tsx +97 -0
- package/src/index.ts +214 -0
- package/src/layout.tsx +104 -0
- package/src/list.tsx +120 -0
- package/src/mcp-tools.ts +201 -0
- package/src/mcp.ts +304 -0
- package/src/nav.ts +97 -0
- package/src/pagination.ts +152 -0
- package/src/permissions.ts +92 -0
- package/src/policy-bridge.ts +64 -0
- package/src/registry.ts +181 -0
- package/src/resource.ts +321 -0
- package/src/routes.ts +35 -0
- package/src/search.ts +108 -0
- package/src/theme.ts +59 -0
- package/src/validate.ts +65 -0
- package/src/widget-value.ts +217 -0
- package/src/widgets.tsx +262 -0
package/src/widgets.tsx
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The one widget dispatch. List cells, detail rows, and form inputs all render through this
|
|
2
|
+
// switch, so money is a Money widget and a timestamp is a DateTime widget with a zone in
|
|
3
|
+
// every one of those places — there is no second place to get it wrong.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import {
|
|
7
|
+
Checkbox,
|
|
8
|
+
DateTime,
|
|
9
|
+
type DateTimeFormatter,
|
|
10
|
+
type FieldControl,
|
|
11
|
+
Input,
|
|
12
|
+
Money,
|
|
13
|
+
Select,
|
|
14
|
+
Textarea,
|
|
15
|
+
} from '@ultimat3/ui';
|
|
16
|
+
import type { JSX } from 'solid-js';
|
|
17
|
+
import type { AdminField } from './fields';
|
|
18
|
+
import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
|
|
19
|
+
|
|
20
|
+
export interface WidgetInput {
|
|
21
|
+
readonly field: AdminField;
|
|
22
|
+
readonly value: unknown;
|
|
23
|
+
readonly ctx: WidgetContext;
|
|
24
|
+
/** `read` renders a value, `edit` renders a control. */
|
|
25
|
+
readonly mode: 'read' | 'edit';
|
|
26
|
+
/** Id and description wiring from the surrounding `<Field>`, when there is one. */
|
|
27
|
+
readonly control?: FieldControl;
|
|
28
|
+
readonly onInput?: (field: string, value: unknown) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const emit = (input: WidgetInput, value: unknown): void => {
|
|
32
|
+
input.onInput?.(input.field.name, value);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The part of a `FieldControl` every ui control accepts. Empty when rendered bare. */
|
|
36
|
+
interface ControlWiring {
|
|
37
|
+
readonly id?: string | undefined;
|
|
38
|
+
readonly 'aria-describedby'?: string | undefined;
|
|
39
|
+
readonly 'aria-invalid'?: boolean | undefined;
|
|
40
|
+
readonly required?: boolean | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const wiring = (control: FieldControl | undefined): ControlWiring =>
|
|
44
|
+
control === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: {
|
|
47
|
+
id: control.id,
|
|
48
|
+
'aria-describedby': control['aria-describedby'],
|
|
49
|
+
'aria-invalid': control['aria-invalid'],
|
|
50
|
+
required: control.required,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A `date` column has no time of day, and ui's default formatter always renders one. A
|
|
55
|
+
* rendered midnight is wrong in every zone but the one the value was stored in.
|
|
56
|
+
*/
|
|
57
|
+
const dateOnly: DateTimeFormatter = (at, options) =>
|
|
58
|
+
new Intl.DateTimeFormat(options.locale, {
|
|
59
|
+
timeZone: options.zone,
|
|
60
|
+
dateStyle: 'medium',
|
|
61
|
+
}).format(at);
|
|
62
|
+
|
|
63
|
+
/** `<input type="date">` wants `YYYY-MM-DD`; `datetime-local` wants `YYYY-MM-DDTHH:mm`. */
|
|
64
|
+
const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
|
|
65
|
+
iso.slice(0, precision === 'date' ? 10 : 16);
|
|
66
|
+
|
|
67
|
+
/** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
|
|
68
|
+
function readView(props: WidgetProps, field: AdminField): JSX.Element {
|
|
69
|
+
switch (props.widget) {
|
|
70
|
+
case 'money':
|
|
71
|
+
return props.value === null ? (
|
|
72
|
+
<span>{t('admin.value.empty')}</span>
|
|
73
|
+
) : (
|
|
74
|
+
<Money value={props.value} />
|
|
75
|
+
);
|
|
76
|
+
case 'datetime':
|
|
77
|
+
return props.value === null ? (
|
|
78
|
+
<span>{t('admin.value.empty')}</span>
|
|
79
|
+
) : (
|
|
80
|
+
<DateTime
|
|
81
|
+
value={props.value}
|
|
82
|
+
timeZone={props.timeZone}
|
|
83
|
+
format={props.precision === 'date' ? dateOnly : undefined}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
case 'checkbox':
|
|
87
|
+
return <span>{t(props.value ? 'admin.value.true' : 'admin.value.false')}</span>;
|
|
88
|
+
case 'select':
|
|
89
|
+
return (
|
|
90
|
+
<span>
|
|
91
|
+
{props.value === null
|
|
92
|
+
? t('admin.value.empty')
|
|
93
|
+
: t(`${field.labelKey}.option.${props.value}`)}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
case 'json-editor':
|
|
97
|
+
return <pre class="x-admin-json">{props.value}</pre>;
|
|
98
|
+
case 'reference':
|
|
99
|
+
return props.value === null ? (
|
|
100
|
+
<span>{t('admin.value.empty')}</span>
|
|
101
|
+
) : (
|
|
102
|
+
<a href={`/${props.entity}s/${props.value}`}>{props.value}</a>
|
|
103
|
+
);
|
|
104
|
+
case 'upload':
|
|
105
|
+
return props.value === null ? (
|
|
106
|
+
<span>{t('admin.value.empty')}</span>
|
|
107
|
+
) : (
|
|
108
|
+
<a href={props.value.url}>{props.value.name}</a>
|
|
109
|
+
);
|
|
110
|
+
default:
|
|
111
|
+
return <span>{String(props.value ?? '')}</span>;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
|
|
116
|
+
const disabled = input.field.readOnly;
|
|
117
|
+
const shared = wiring(input.control);
|
|
118
|
+
switch (props.widget) {
|
|
119
|
+
case 'textarea':
|
|
120
|
+
return (
|
|
121
|
+
<Textarea
|
|
122
|
+
{...shared}
|
|
123
|
+
name={props.field}
|
|
124
|
+
value={props.value}
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
case 'number-input':
|
|
130
|
+
// `inputmode` on a text field, never `type="number"`: a locale decimal separator has to
|
|
131
|
+
// survive the round trip, and the entity's schema is what decides it is a number.
|
|
132
|
+
return (
|
|
133
|
+
<Input
|
|
134
|
+
{...shared}
|
|
135
|
+
name={props.field}
|
|
136
|
+
inputmode="decimal"
|
|
137
|
+
value={props.value === null ? '' : String(props.value)}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
onInput={(event) => {
|
|
140
|
+
const next = event.currentTarget.value;
|
|
141
|
+
emit(input, next === '' ? null : Number(next));
|
|
142
|
+
}}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
case 'money': {
|
|
146
|
+
// Minor units, not a decimal: turning "12,34" into cents is currency- and locale-aware
|
|
147
|
+
// work owned by @ultimat3/money, and the design system has no money input to host it.
|
|
148
|
+
const currency = props.value?.currency ?? input.field.currency ?? '';
|
|
149
|
+
return (
|
|
150
|
+
<Input
|
|
151
|
+
{...shared}
|
|
152
|
+
name={props.field}
|
|
153
|
+
inputmode="numeric"
|
|
154
|
+
value={props.value === null ? '' : String(props.value.minor)}
|
|
155
|
+
suffix={currency}
|
|
156
|
+
disabled={disabled}
|
|
157
|
+
onInput={(event) => {
|
|
158
|
+
const next = event.currentTarget.value;
|
|
159
|
+
emit(input, next === '' ? null : { minor: Number(next), currency });
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
case 'checkbox':
|
|
165
|
+
return (
|
|
166
|
+
<Checkbox
|
|
167
|
+
{...shared}
|
|
168
|
+
label={t(input.field.labelKey)}
|
|
169
|
+
name={props.field}
|
|
170
|
+
checked={props.value}
|
|
171
|
+
disabled={disabled}
|
|
172
|
+
onChange={(event) => emit(input, event.currentTarget.checked)}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
case 'select':
|
|
176
|
+
return (
|
|
177
|
+
<Select
|
|
178
|
+
{...shared}
|
|
179
|
+
name={props.field}
|
|
180
|
+
value={props.value ?? ''}
|
|
181
|
+
disabled={disabled}
|
|
182
|
+
options={props.options.map((option) => ({
|
|
183
|
+
value: option.value,
|
|
184
|
+
label: t(option.labelKey),
|
|
185
|
+
}))}
|
|
186
|
+
onChange={(event) => emit(input, event.currentTarget.value)}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
case 'datetime':
|
|
190
|
+
// The stored value is the UTC instant, so the control edits UTC and says so beside the
|
|
191
|
+
// box: an operator editing a timestamp must know which zone they typed it in.
|
|
192
|
+
return (
|
|
193
|
+
<Input
|
|
194
|
+
{...shared}
|
|
195
|
+
name={props.field}
|
|
196
|
+
type={props.precision === 'date' ? 'date' : 'datetime-local'}
|
|
197
|
+
value={props.value === null ? '' : inputValueFor(props.value, props.precision)}
|
|
198
|
+
suffix={props.precision === 'date' ? undefined : 'UTC'}
|
|
199
|
+
disabled={disabled}
|
|
200
|
+
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
201
|
+
/>
|
|
202
|
+
);
|
|
203
|
+
case 'timezone-picker':
|
|
204
|
+
return (
|
|
205
|
+
<Select
|
|
206
|
+
{...shared}
|
|
207
|
+
name={props.field}
|
|
208
|
+
value={props.value ?? ''}
|
|
209
|
+
disabled={disabled}
|
|
210
|
+
options={ianaZones().map((zone) => ({ value: zone, label: zone }))}
|
|
211
|
+
onChange={(event) => emit(input, event.currentTarget.value)}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
case 'locale-picker':
|
|
215
|
+
return (
|
|
216
|
+
<Select
|
|
217
|
+
{...shared}
|
|
218
|
+
name={props.field}
|
|
219
|
+
value={props.value ?? ''}
|
|
220
|
+
disabled={disabled}
|
|
221
|
+
options={locales().map((locale) => ({ value: locale, label: locale }))}
|
|
222
|
+
onChange={(event) => emit(input, event.currentTarget.value)}
|
|
223
|
+
/>
|
|
224
|
+
);
|
|
225
|
+
case 'json-editor':
|
|
226
|
+
return (
|
|
227
|
+
<Textarea
|
|
228
|
+
{...shared}
|
|
229
|
+
class="x-admin-json"
|
|
230
|
+
name={props.field}
|
|
231
|
+
value={props.value}
|
|
232
|
+
disabled={disabled}
|
|
233
|
+
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
default:
|
|
237
|
+
return (
|
|
238
|
+
<Input
|
|
239
|
+
{...shared}
|
|
240
|
+
name={props.field}
|
|
241
|
+
value={String(props.value ?? '')}
|
|
242
|
+
disabled={disabled}
|
|
243
|
+
onInput={(event) => emit(input, event.currentTarget.value)}
|
|
244
|
+
/>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `Intl.supportedValuesOf` is the runtime's own IANA list — never a bundled copy. */
|
|
250
|
+
function ianaZones(): readonly string[] {
|
|
251
|
+
const intl = Intl as { supportedValuesOf?: (key: 'timeZone') => readonly string[] };
|
|
252
|
+
return intl.supportedValuesOf?.('timeZone') ?? ['UTC'];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function locales(): readonly string[] {
|
|
256
|
+
return ['en', 'es', 'de', 'fr', 'pt', 'ja'];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function Widget(input: WidgetInput): JSX.Element {
|
|
260
|
+
const props = widgetProps(input.field, input.value, input.ctx);
|
|
261
|
+
return input.mode === 'read' ? readView(props, input.field) : editView(props, input);
|
|
262
|
+
}
|