@aiaiai-pt/design-system 0.44.1 → 0.46.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/components/ActionFormRenderer.svelte +446 -48
- package/components/ActionFormRenderer.svelte.d.ts +15 -0
- package/components/DateRangePicker.svelte.d.ts +1 -1
- package/components/JsonEditor.svelte +152 -0
- package/components/JsonEditor.svelte.d.ts +44 -0
- package/components/LayoutStackedDefault.svelte +36 -3
- package/components/LayoutStackedDefault.svelte.d.ts +1 -1
- package/components/MoneyInput.svelte +219 -0
- package/components/MoneyInput.svelte.d.ts +52 -0
- package/components/MultiSelectCombobox.svelte +140 -0
- package/components/MultiSelectCombobox.svelte.d.ts +62 -0
- package/components/Tag.svelte +4 -0
- package/components/action-form-renderer-layouts.d.ts +20 -0
- package/components/action-form-renderer-layouts.ts +40 -3
- package/components/action-form-renderer-payload.d.ts +12 -0
- package/components/action-form-renderer-payload.ts +46 -3
- package/components/action-form-renderer-relationships.d.ts +20 -0
- package/components/action-form-renderer-relationships.ts +77 -0
- package/components/action-form-renderer-widgets.d.ts +39 -0
- package/components/action-form-renderer-widgets.ts +127 -0
- package/components/action-form-visibility.d.ts +48 -0
- package/components/action-form-visibility.ts +171 -0
- package/components/index.d.ts +4 -0
- package/components/index.js +11 -0
- package/components/json-editor-contract.d.ts +11 -0
- package/components/json-editor-contract.ts +33 -0
- package/package.json +17 -1
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
import Alert from "./Alert.svelte";
|
|
7
7
|
import CodeBlock from "./CodeBlock.svelte";
|
|
8
8
|
import Combobox from "./Combobox.svelte";
|
|
9
|
+
import DatePicker from "./DatePicker.svelte";
|
|
9
10
|
import DateTimePicker from "./DateTimePicker.svelte";
|
|
10
11
|
import Input from "./Input.svelte";
|
|
12
|
+
import JsonEditor from "./JsonEditor.svelte";
|
|
13
|
+
import Textarea from "./Textarea.svelte";
|
|
14
|
+
import Toggle from "./Toggle.svelte";
|
|
11
15
|
import MapPicker from "./MapPicker.svelte";
|
|
16
|
+
import MoneyInput from "./MoneyInput.svelte";
|
|
17
|
+
import MultiSelectCombobox from "./MultiSelectCombobox.svelte";
|
|
12
18
|
import FileUpload from "./FileUpload.svelte";
|
|
13
19
|
import FileUploadItem from "./FileUploadItem.svelte";
|
|
14
20
|
import Select from "./Select.svelte";
|
|
@@ -16,6 +22,8 @@
|
|
|
16
22
|
import type { Snippet } from "svelte";
|
|
17
23
|
import {
|
|
18
24
|
buildActionPayload,
|
|
25
|
+
isPayloadIncluded,
|
|
26
|
+
isReadonlyParam,
|
|
19
27
|
placementConsequenceRows as buildPlacementConsequenceRows,
|
|
20
28
|
type RendererMode,
|
|
21
29
|
} from "./action-form-renderer-payload";
|
|
@@ -24,7 +32,31 @@
|
|
|
24
32
|
// `target_config.layout_key` and validated through `resolveLayout`,
|
|
25
33
|
// so an operator-supplied string never reaches the DOM verbatim
|
|
26
34
|
// (RH#11 / R-SEC-07 / TH-08).
|
|
27
|
-
import { resolveLayout } from "./action-form-renderer-layouts";
|
|
35
|
+
import { resolveLayout, type LayoutSection } from "./action-form-renderer-layouts";
|
|
36
|
+
// #634 S4 — live section show/hide from the contract's `visible_when`
|
|
37
|
+
// predicate (pure client evaluation over the value bag; the server owns
|
|
38
|
+
// enforcement).
|
|
39
|
+
import { sectionVisible } from "./action-form-visibility";
|
|
40
|
+
// #34 (atelier#669 V1, D6) — cardinality-aware relationship picking over the
|
|
41
|
+
// two rel param shapes (form-surface.v1 `relationship` summaries and
|
|
42
|
+
// derived-action `object_reference` + `multiple` params).
|
|
43
|
+
import {
|
|
44
|
+
isMultiRelationship,
|
|
45
|
+
isRelationshipParam,
|
|
46
|
+
normalizeRelIds,
|
|
47
|
+
relationshipTypeCode,
|
|
48
|
+
} from "./action-form-renderer-relationships";
|
|
49
|
+
// #36/#37 — widget-first dispatch (field summaries carry render intent in
|
|
50
|
+
// `widget`; derived-action params in `ui_schema.widget`; legacy params fall
|
|
51
|
+
// back to their `type`) + the date-only wire serialization.
|
|
52
|
+
import {
|
|
53
|
+
dateOnlyToDate,
|
|
54
|
+
dateToDateOnly,
|
|
55
|
+
geoJsonPointToLonLat,
|
|
56
|
+
lonLatToGeoJsonPoint,
|
|
57
|
+
storedFileDescriptor,
|
|
58
|
+
widgetKind,
|
|
59
|
+
} from "./action-form-renderer-widgets";
|
|
28
60
|
|
|
29
61
|
/**
|
|
30
62
|
* The renderer treats every parameter/action/placement as a loose record
|
|
@@ -42,7 +74,15 @@
|
|
|
42
74
|
placement?: Entity | null;
|
|
43
75
|
target?: Record<string, unknown> | null;
|
|
44
76
|
source?: Record<string, unknown> | null;
|
|
45
|
-
sections?: Array<{
|
|
77
|
+
sections?: Array<{
|
|
78
|
+
key?: string;
|
|
79
|
+
label?: string;
|
|
80
|
+
order?: number | null;
|
|
81
|
+
columns?: number | null;
|
|
82
|
+
/** Read defensively (hosts carry concrete precondition types). */
|
|
83
|
+
visible_when?: unknown;
|
|
84
|
+
parameters?: Entity[];
|
|
85
|
+
}>;
|
|
46
86
|
parameters?: Entity[];
|
|
47
87
|
criteria?: Entity[];
|
|
48
88
|
};
|
|
@@ -82,6 +122,20 @@
|
|
|
82
122
|
query: string,
|
|
83
123
|
filter?: Record<string, string>,
|
|
84
124
|
) => Promise<SelectOption[]>;
|
|
125
|
+
/** Edit-time label hydration for relationship params (#34): receives the
|
|
126
|
+
* target entity type code and the currently-linked ids, returns
|
|
127
|
+
* `{value, label}` items so the picker renders labels, never raw ids.
|
|
128
|
+
* Same philosophy as searchEntities — the CONSUMER owns transport/auth;
|
|
129
|
+
* omitted → the picker falls back to showing the ids. Best-effort: a
|
|
130
|
+
* failed hydration never blocks the form. */
|
|
131
|
+
hydrateEntities?: (
|
|
132
|
+
typeCode: string,
|
|
133
|
+
ids: string[],
|
|
134
|
+
) => Promise<SelectOption[]>;
|
|
135
|
+
/** #41 — per-field server-error binding: `{param_key: message}` rendered
|
|
136
|
+
* as each field's inline error (the V2a adapter re-binds proxy 4xx field
|
|
137
|
+
* errors here). Display-only — the HOST owns when errors set and clear. */
|
|
138
|
+
fieldErrors?: Record<string, string>;
|
|
85
139
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
86
140
|
* in submit modes. */
|
|
87
141
|
captcha?: Snippet;
|
|
@@ -126,6 +180,8 @@
|
|
|
126
180
|
onApply = undefined,
|
|
127
181
|
uploadFile = undefined,
|
|
128
182
|
searchEntities = undefined,
|
|
183
|
+
hydrateEntities = undefined,
|
|
184
|
+
fieldErrors = undefined,
|
|
129
185
|
captcha = undefined,
|
|
130
186
|
submitLabel = "Submit",
|
|
131
187
|
boundary = undefined,
|
|
@@ -193,12 +249,21 @@
|
|
|
193
249
|
);
|
|
194
250
|
const schemaSections = $derived(schemaSectionsFromContract());
|
|
195
251
|
const sections = $derived(schemaSections ?? groupIntoSections(visibleParameters));
|
|
252
|
+
// #634 S4 — LIVE conditional sections: a section whose `visible_when`
|
|
253
|
+
// predicate fails against the current value bag is hidden (and re-appears
|
|
254
|
+
// the moment the driving value changes). Sections without a predicate —
|
|
255
|
+
// including every legacy fold section — always pass, so this is inert
|
|
256
|
+
// until a sheet declares `visible_when`. The value bag still spans ALL
|
|
257
|
+
// parameters (hidden sections keep their answers).
|
|
258
|
+
const liveSections = $derived(
|
|
259
|
+
sections.filter((section) => sectionVisible(section, values)),
|
|
260
|
+
);
|
|
196
261
|
// Wizard pagination (#105 P6): when a section is active, render only it. The
|
|
197
262
|
// value bag is unaffected — every parameter is still seeded and submitted.
|
|
198
263
|
const displaySections = $derived(
|
|
199
264
|
activeSectionKey == null
|
|
200
|
-
?
|
|
201
|
-
:
|
|
265
|
+
? liveSections
|
|
266
|
+
: liveSections.filter((section) => section.name === activeSectionKey),
|
|
202
267
|
);
|
|
203
268
|
const payload = $derived(buildPayload());
|
|
204
269
|
const payloadJson = $derived(JSON.stringify(payload, null, 2));
|
|
@@ -214,16 +279,25 @@
|
|
|
214
279
|
// parameter must have a non-empty value. Server-side submission criteria are
|
|
215
280
|
// enforced by the BFF on submit and surfaced via `submitError`.
|
|
216
281
|
function isEmpty(value: unknown): boolean {
|
|
282
|
+
// #34 — an empty ID list (required m2m with zero picks) must block submit.
|
|
283
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
217
284
|
return value === undefined || value === null || value === "";
|
|
218
285
|
}
|
|
286
|
+
// #634 S4 — the gate counts only parameters whose section is LIVE-visible:
|
|
287
|
+
// a required field inside a `visible_when`-hidden section must not block
|
|
288
|
+
// submit (it is unreachable). In the legacy fold path this flattens back
|
|
289
|
+
// to exactly `visibleParameters`.
|
|
290
|
+
const gateParameters = $derived(liveSections.flatMap((section) => section.items));
|
|
219
291
|
const canSubmit = $derived(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
292
|
+
// #38 — any invalid json draft blocks submit, required or not.
|
|
293
|
+
!Object.values(jsonInvalid).some((message) => message !== null) &&
|
|
294
|
+
gateParameters
|
|
295
|
+
.filter((parameter) => parameter.required)
|
|
296
|
+
.every((parameter) =>
|
|
297
|
+
String(parameter.type ?? "") === "file"
|
|
298
|
+
? (fileUploads[parameterKey(parameter)] ?? []).length > 0
|
|
299
|
+
: !isEmpty(values[parameterKey(parameter)]),
|
|
300
|
+
),
|
|
227
301
|
);
|
|
228
302
|
|
|
229
303
|
async function handleSubmit(): Promise<void> {
|
|
@@ -306,18 +380,34 @@
|
|
|
306
380
|
return [...byName.entries()].map(([name, sectionItems]) => ({ name, items: sectionItems }));
|
|
307
381
|
}
|
|
308
382
|
|
|
309
|
-
function schemaSectionsFromContract():
|
|
383
|
+
function schemaSectionsFromContract(): LayoutSection[] | null {
|
|
310
384
|
if (!Array.isArray(schema?.sections)) return null;
|
|
311
385
|
const next = schema.sections
|
|
312
|
-
.map((section) => {
|
|
386
|
+
.map((section, index) => {
|
|
313
387
|
const items = Array.isArray(section.parameters)
|
|
314
388
|
? section.parameters
|
|
315
389
|
.filter((parameter) => parameter.is_active !== false && isVisible(parameter))
|
|
316
390
|
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
|
|
317
391
|
: [];
|
|
318
|
-
|
|
392
|
+
// #634 S3/S4 — carry the contract's presentation keys through to the
|
|
393
|
+
// layouts: stable `key`, declared `columns`, the `visible_when`
|
|
394
|
+
// predicate, and `order` (the server emits sections pre-sorted; the
|
|
395
|
+
// sort below is a defensive mirror, stable via declaration index).
|
|
396
|
+
return {
|
|
397
|
+
name: String(section.label ?? section.key ?? "Details"),
|
|
398
|
+
key: section.key ? String(section.key) : undefined,
|
|
399
|
+
columns: Number(section.columns ?? 1),
|
|
400
|
+
visibleWhen:
|
|
401
|
+
section.visible_when && typeof section.visible_when === "object"
|
|
402
|
+
? (section.visible_when as Record<string, unknown>)
|
|
403
|
+
: null,
|
|
404
|
+
order: Number(section.order ?? 0),
|
|
405
|
+
index,
|
|
406
|
+
items,
|
|
407
|
+
};
|
|
319
408
|
})
|
|
320
|
-
.filter((section) => section.items.length)
|
|
409
|
+
.filter((section) => section.items.length)
|
|
410
|
+
.sort((a, b) => a.order - b.order || a.index - b.index);
|
|
321
411
|
return next.length ? next : null;
|
|
322
412
|
}
|
|
323
413
|
|
|
@@ -346,6 +436,11 @@
|
|
|
346
436
|
}
|
|
347
437
|
|
|
348
438
|
function initialValue(parameter: Entity): unknown {
|
|
439
|
+
// #34 — a multi-value rel param holds an ID LIST in the bag (the m2m wire
|
|
440
|
+
// shape), normalized even when the default arrives as a scalar.
|
|
441
|
+
if (isRelationshipParam(parameter) && isMultiRelationship(parameter)) {
|
|
442
|
+
return normalizeRelIds(parameter.default_value);
|
|
443
|
+
}
|
|
349
444
|
if (parameter.default_value !== null && parameter.default_value !== undefined) {
|
|
350
445
|
return parameter.default_value;
|
|
351
446
|
}
|
|
@@ -353,6 +448,12 @@
|
|
|
353
448
|
if (type === "bool" || type === "boolean") return false;
|
|
354
449
|
if (type === "number" || type === "integer") return "";
|
|
355
450
|
if (type === "enum" || type === "select") return enumOptions(parameter)[0]?.value ?? "";
|
|
451
|
+
// #34 — an unset single `relationship` summary is null on the wire
|
|
452
|
+
// (string|null contract). Legacy `object_reference` keeps its "" seed.
|
|
453
|
+
if (type === "relationship") return null;
|
|
454
|
+
// #38 — an unset json field is null (empty editor), never the ""
|
|
455
|
+
// string (which would render as a literal '""' draft).
|
|
456
|
+
if (type === "json" || widgetKind(parameter) === "json") return null;
|
|
356
457
|
return "";
|
|
357
458
|
}
|
|
358
459
|
|
|
@@ -365,6 +466,10 @@
|
|
|
365
466
|
for (const parameter of orderedParameters) {
|
|
366
467
|
const key = parameterKey(parameter);
|
|
367
468
|
if (!key) continue;
|
|
469
|
+
// #41 / D3 — payload include|exclude: excluded params render but never
|
|
470
|
+
// reach the wire (readonly defaults OUT on the form-surface.v1 lane;
|
|
471
|
+
// `payload: "include"` declares prefill-and-send).
|
|
472
|
+
if (!isPayloadIncluded(parameter)) continue;
|
|
368
473
|
bag[key] = values[key] ?? initialValue(parameter);
|
|
369
474
|
}
|
|
370
475
|
return bag;
|
|
@@ -392,13 +497,17 @@
|
|
|
392
497
|
: {};
|
|
393
498
|
}
|
|
394
499
|
|
|
395
|
-
|
|
500
|
+
// #40 — `replace` (a stored current exists on the edit form): SINGLE slot,
|
|
501
|
+
// a new upload supersedes the pending one; the stored file itself is only
|
|
502
|
+
// superseded host-side when the new key rides the payload. Default
|
|
503
|
+
// (append) keeps the legacy up-to-3 behavior byte-identical.
|
|
504
|
+
async function handleFiles(key: string, files: File[], replace = false) {
|
|
396
505
|
if (!uploadFile) return;
|
|
397
506
|
fileError = { ...fileError, [key]: "" };
|
|
398
|
-
const existing = fileUploads[key] ?? [];
|
|
399
|
-
const room = FILE_MAX_COUNT - existing.length;
|
|
507
|
+
const existing = replace ? [] : (fileUploads[key] ?? []);
|
|
508
|
+
const room = replace ? 1 : FILE_MAX_COUNT - existing.length;
|
|
400
509
|
const batch = files.slice(0, room);
|
|
401
|
-
if (files.length > room) {
|
|
510
|
+
if (!replace && files.length > room) {
|
|
402
511
|
fileError = { ...fileError, [key]: `Up to ${FILE_MAX_COUNT} files` };
|
|
403
512
|
}
|
|
404
513
|
if (batch.length === 0) return;
|
|
@@ -409,7 +518,9 @@
|
|
|
409
518
|
if ("key" in result) {
|
|
410
519
|
fileUploads = {
|
|
411
520
|
...fileUploads,
|
|
412
|
-
[key]:
|
|
521
|
+
[key]: replace
|
|
522
|
+
? [{ key: result.key, name: file.name }]
|
|
523
|
+
: [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
|
|
413
524
|
};
|
|
414
525
|
} else {
|
|
415
526
|
fileError = { ...fileError, [key]: result.error };
|
|
@@ -441,8 +552,10 @@
|
|
|
441
552
|
const gen = (referenceSearchGen[key] = (referenceSearchGen[key] ?? 0) + 1);
|
|
442
553
|
referenceLoading = { ...referenceLoading, [key]: true };
|
|
443
554
|
try {
|
|
555
|
+
// #34 — the type code comes from the relationship meta when present
|
|
556
|
+
// (form-surface lane), else the param's own object_type (action lane).
|
|
444
557
|
const items = await searchEntities(
|
|
445
|
-
|
|
558
|
+
relationshipTypeCode(parameter),
|
|
446
559
|
query,
|
|
447
560
|
objectFilter(parameter),
|
|
448
561
|
);
|
|
@@ -456,6 +569,96 @@
|
|
|
456
569
|
}
|
|
457
570
|
}
|
|
458
571
|
|
|
572
|
+
// #34 — relationship label bookkeeping. `relLabels` maps, per param key, a
|
|
573
|
+
// linked entity id → its display label, fed by (a) edit-time hydration
|
|
574
|
+
// through the `hydrateEntities` seam and (b) pick-time capture from the
|
|
575
|
+
// search results — so a chip / selected value never degrades to a raw id
|
|
576
|
+
// when a later search replaces the Combobox items. (The m2m search-to-add
|
|
577
|
+
// draft is MultiSelectCombobox-internal — the renderer never sees it.)
|
|
578
|
+
let relLabels = $state<Record<string, Record<string, string>>>({});
|
|
579
|
+
const relHydrateRequested: Record<string, Set<string>> = {};
|
|
580
|
+
|
|
581
|
+
// #38 — per-param json parse invalidity (JsonEditor oninvalid seam). A
|
|
582
|
+
// non-null message anywhere blocks submit: invalid text must never reach
|
|
583
|
+
// the wire OR be silently dropped by submitting around it.
|
|
584
|
+
let jsonInvalid = $state<Record<string, string | null>>({});
|
|
585
|
+
|
|
586
|
+
$effect(() => {
|
|
587
|
+
if (!hydrateEntities) return;
|
|
588
|
+
for (const parameter of orderedParameters) {
|
|
589
|
+
if (!isRelationshipParam(parameter)) continue;
|
|
590
|
+
const key = parameterKey(parameter);
|
|
591
|
+
if (!key) continue;
|
|
592
|
+
const ids = normalizeRelIds(values[key] ?? initialValue(parameter));
|
|
593
|
+
const known = relLabels[key] ?? {};
|
|
594
|
+
const requested = (relHydrateRequested[key] ??= new Set());
|
|
595
|
+
const missing = ids.filter((id) => !(id in known) && !requested.has(id));
|
|
596
|
+
if (!missing.length) continue;
|
|
597
|
+
for (const id of missing) requested.add(id);
|
|
598
|
+
hydrateEntities(relationshipTypeCode(parameter), missing)
|
|
599
|
+
.then((items) => {
|
|
600
|
+
if (!Array.isArray(items) || !items.length) return;
|
|
601
|
+
const next = { ...(relLabels[key] ?? {}) };
|
|
602
|
+
for (const item of items) {
|
|
603
|
+
const value = String(item.value ?? "");
|
|
604
|
+
if (value) next[value] = String(item.label ?? value);
|
|
605
|
+
}
|
|
606
|
+
relLabels = { ...relLabels, [key]: next };
|
|
607
|
+
})
|
|
608
|
+
.catch(() => {
|
|
609
|
+
// Best-effort: the picker falls back to raw ids.
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
function relLabel(key: string, id: string): string {
|
|
615
|
+
return relLabels[key]?.[id] ?? id;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function captureRelLabel(key: string, id: string) {
|
|
619
|
+
const item = (referenceItems[key] ?? []).find((entry) => entry.value === id);
|
|
620
|
+
if (!item) return;
|
|
621
|
+
relLabels = {
|
|
622
|
+
...relLabels,
|
|
623
|
+
[key]: { ...(relLabels[key] ?? {}), [id]: item.label },
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Combobox items for a rel param. Multi: the raw search results —
|
|
628
|
+
* MultiSelectCombobox itself excludes already-picked ids. Single: prepends
|
|
629
|
+
* the current selection when a later search dropped it (label
|
|
630
|
+
* persistence). */
|
|
631
|
+
function relComboItems(parameter: Entity): SelectOption[] {
|
|
632
|
+
const key = parameterKey(parameter);
|
|
633
|
+
const searched = referenceItems[key] ?? [];
|
|
634
|
+
if (isMultiRelationship(parameter)) return searched;
|
|
635
|
+
const current = typeof values[key] === "string" ? String(values[key]) : "";
|
|
636
|
+
if (!current || searched.some((item) => item.value === current)) {
|
|
637
|
+
return searched;
|
|
638
|
+
}
|
|
639
|
+
return [{ value: current, label: relLabel(key, current) }, ...searched];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function pickSingleRel(key: string, id: string) {
|
|
643
|
+
captureRelLabel(key, id);
|
|
644
|
+
setValue(key, id);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function addRelId(key: string, id: string) {
|
|
648
|
+
if (!id) return;
|
|
649
|
+
const current = normalizeRelIds(values[key]);
|
|
650
|
+
if (current.includes(id)) return;
|
|
651
|
+
captureRelLabel(key, id);
|
|
652
|
+
setValue(key, [...current, id]);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function removeRelId(key: string, id: string) {
|
|
656
|
+
setValue(
|
|
657
|
+
key,
|
|
658
|
+
normalizeRelIds(values[key]).filter((value) => value !== id),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
459
662
|
// `datetime` params keep an ISO 8601 STRING in the value bag (the platform's
|
|
460
663
|
// datetime wire vocabulary — `$now` resolves to one) so the payload stays
|
|
461
664
|
// plain JSON; the DateTimePicker speaks Date on its prop edge.
|
|
@@ -523,6 +726,7 @@
|
|
|
523
726
|
{@const parameter = rawParameter as Entity}
|
|
524
727
|
{@const key = parameterKey(parameter)}
|
|
525
728
|
{@const type = parameterType(parameter)}
|
|
729
|
+
{@const kind = widgetKind(parameter)}
|
|
526
730
|
<!-- A11y (#244 C7 / Selo item 4): required fields are marked
|
|
527
731
|
`aria-required` on the control itself, so a screen reader announces
|
|
528
732
|
"required" on the field — not just the disconnected visual hint below.
|
|
@@ -530,17 +734,113 @@
|
|
|
530
734
|
{@const ariaRequired = parameter.required ? "true" : undefined}
|
|
531
735
|
<!-- #252 — a parameter with `editable: false` (visibility.editable === false)
|
|
532
736
|
renders READ-ONLY: its value (e.g. a logged-in citizen's prefilled
|
|
533
|
-
identity — name/email) is shown but not editable.
|
|
534
|
-
the payload; the
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
{
|
|
737
|
+
identity — name/email) is shown but not editable. On the ACTION lane the
|
|
738
|
+
value still rides the payload; on the form-surface.v1 lane
|
|
739
|
+
(`visibility: "readonly"` string) it defaults OFF the wire per D3 —
|
|
740
|
+
see isPayloadIncluded. -->
|
|
741
|
+
{@const editable = !isReadonlyParam(parameter)}
|
|
742
|
+
<!-- #41 — first-class field states: `disabled` and `loading` are host/
|
|
743
|
+
contract-declared inertness (loading = options/context not resolved
|
|
744
|
+
yet); `fieldError` is the per-field server-error binding. -->
|
|
745
|
+
{@const fieldDisabled = parameter.disabled === true || parameter.loading === true}
|
|
746
|
+
{@const fieldError = fieldErrors?.[key]}
|
|
747
|
+
{#if kind === "currency"}
|
|
748
|
+
<!-- #35 — money: the bag carries the RAW number ("" when empty — the
|
|
749
|
+
number-field empty sentinel); formatting is display-only. -->
|
|
750
|
+
<MoneyInput
|
|
751
|
+
label={String(parameter.label ?? key)}
|
|
752
|
+
name={key}
|
|
753
|
+
value={typeof values[key] === "number" ? (values[key] as number) : null}
|
|
754
|
+
readonly={!editable}
|
|
755
|
+
disabled={fieldDisabled}
|
|
756
|
+
error={fieldError}
|
|
757
|
+
onchange={(amount: number | null) => setValue(key, amount === null ? "" : amount)}
|
|
758
|
+
aria-required={ariaRequired}
|
|
759
|
+
/>
|
|
760
|
+
{:else if kind === "textarea"}
|
|
761
|
+
<!-- #36 — long text. Full-width-only (FULL_WIDTH_WIDGET_KINDS clamp). -->
|
|
762
|
+
<Textarea
|
|
763
|
+
label={String(parameter.label ?? key)}
|
|
764
|
+
name={key}
|
|
765
|
+
rows={4}
|
|
766
|
+
value={String(values[key] ?? initialValue(parameter) ?? "")}
|
|
767
|
+
readonly={!editable}
|
|
768
|
+
disabled={fieldDisabled}
|
|
769
|
+
error={fieldError}
|
|
770
|
+
oninput={(event: Event) =>
|
|
771
|
+
setValue(key, (event.target as HTMLTextAreaElement).value)}
|
|
772
|
+
aria-required={ariaRequired}
|
|
773
|
+
/>
|
|
774
|
+
{:else if kind === "toggle"}
|
|
775
|
+
<!-- #36 — boolean as the DS switch; the bag carries a BOOLEAN, never a
|
|
776
|
+
"yes"/"no" string. A bare bool param WITHOUT the widget keeps the
|
|
777
|
+
legacy Yes/No select (byte-identical action lane). -->
|
|
778
|
+
<Toggle
|
|
779
|
+
label={String(parameter.label ?? key)}
|
|
780
|
+
checked={values[key] === true}
|
|
781
|
+
disabled={fieldDisabled || !editable}
|
|
782
|
+
onchange={(checked: boolean) => setValue(key, checked)}
|
|
783
|
+
aria-required={ariaRequired}
|
|
784
|
+
/>
|
|
785
|
+
{:else if kind === "slug"}
|
|
786
|
+
<!-- #36 — slug input-type refinement: machine-name typing affordances;
|
|
787
|
+
the value stays whatever the operator types (validation is the
|
|
788
|
+
server's). -->
|
|
789
|
+
<Input
|
|
790
|
+
label={String(parameter.label ?? key)}
|
|
791
|
+
name={key}
|
|
792
|
+
value={String(values[key] ?? initialValue(parameter) ?? "")}
|
|
793
|
+
readonly={!editable}
|
|
794
|
+
disabled={fieldDisabled}
|
|
795
|
+
error={fieldError}
|
|
796
|
+
spellcheck="false"
|
|
797
|
+
autocapitalize="none"
|
|
798
|
+
autocorrect="off"
|
|
799
|
+
oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
|
|
800
|
+
aria-required={ariaRequired}
|
|
801
|
+
/>
|
|
802
|
+
{:else if kind === "json"}
|
|
803
|
+
<!-- #38 — json editor with the invalid-state contract: invalid text
|
|
804
|
+
surfaces its parse error, emits nothing, and blocks submit via the
|
|
805
|
+
jsonInvalid gate. -->
|
|
806
|
+
<JsonEditor
|
|
807
|
+
label={String(parameter.label ?? key)}
|
|
808
|
+
value={values[key] ?? initialValue(parameter)}
|
|
809
|
+
readonly={!editable}
|
|
810
|
+
disabled={fieldDisabled}
|
|
811
|
+
error={fieldError}
|
|
812
|
+
onchange={(value: unknown) => setValue(key, value)}
|
|
813
|
+
oninvalid={(message: string | null) =>
|
|
814
|
+
(jsonInvalid = { ...jsonInvalid, [key]: message })}
|
|
815
|
+
/>
|
|
816
|
+
{:else if kind === "date"}
|
|
817
|
+
<!-- #37 — date-only picker; the bag carries the YYYY-MM-DD wire string
|
|
818
|
+
(LOCAL calendar parts — no toISOString, no DST off-by-one). -->
|
|
819
|
+
<DatePicker
|
|
820
|
+
label={String(parameter.label ?? key)}
|
|
821
|
+
value={dateOnlyToDate(values[key])}
|
|
822
|
+
readonly={!editable}
|
|
823
|
+
disabled={fieldDisabled}
|
|
824
|
+
error={fieldError}
|
|
825
|
+
onchange={(date: Date | null) => setValue(key, date ? dateToDateOnly(date) : "")}
|
|
826
|
+
/>
|
|
827
|
+
{:else if type === "enum" || type === "select" || enumOptions(parameter).length}
|
|
828
|
+
{@const options = enumOptions(parameter)}
|
|
829
|
+
<!-- #41 — empty state: an option-driven field with ZERO resolved options
|
|
830
|
+
is inert with an explicit hint, never a dead control. -->
|
|
538
831
|
<Select
|
|
539
832
|
label={String(parameter.label ?? key)}
|
|
540
833
|
name={key}
|
|
541
834
|
value={String(values[key] ?? initialValue(parameter) ?? "")}
|
|
542
|
-
options
|
|
835
|
+
{options}
|
|
543
836
|
placeholder="Select value"
|
|
837
|
+
disabled={fieldDisabled || !editable || options.length === 0}
|
|
838
|
+
error={fieldError}
|
|
839
|
+
help={parameter.loading === true
|
|
840
|
+
? "Loading options..."
|
|
841
|
+
: options.length === 0
|
|
842
|
+
? "No options available"
|
|
843
|
+
: undefined}
|
|
544
844
|
onchange={(value: string) => setValue(key, value)}
|
|
545
845
|
aria-required={ariaRequired}
|
|
546
846
|
/>
|
|
@@ -551,6 +851,8 @@
|
|
|
551
851
|
type="number"
|
|
552
852
|
value={String(values[key] ?? "")}
|
|
553
853
|
readonly={!editable}
|
|
854
|
+
disabled={fieldDisabled}
|
|
855
|
+
error={fieldError}
|
|
554
856
|
oninput={(event: Event) => {
|
|
555
857
|
const value = (event.target as HTMLInputElement).value;
|
|
556
858
|
setValue(key, value === "" ? "" : Number(value));
|
|
@@ -566,6 +868,8 @@
|
|
|
566
868
|
{ value: "true", label: "Yes" },
|
|
567
869
|
{ value: "false", label: "No" },
|
|
568
870
|
]}
|
|
871
|
+
disabled={fieldDisabled || !editable}
|
|
872
|
+
error={fieldError}
|
|
569
873
|
onchange={(value: string) => setValue(key, value === "true")}
|
|
570
874
|
aria-required={ariaRequired}
|
|
571
875
|
/>
|
|
@@ -577,50 +881,102 @@
|
|
|
577
881
|
label={String(parameter.label ?? key)}
|
|
578
882
|
value={datetimeValue(values[key])}
|
|
579
883
|
readonly={!editable}
|
|
884
|
+
disabled={fieldDisabled}
|
|
885
|
+
error={fieldError}
|
|
580
886
|
onchange={(date: Date) => setValue(key, date.toISOString())}
|
|
581
887
|
/>
|
|
582
|
-
{:else if
|
|
583
|
-
<!-- `object_reference`
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
888
|
+
{:else if isRelationshipParam(parameter)}
|
|
889
|
+
<!-- Relationship / `object_reference` picking, cardinality-aware (#34,
|
|
890
|
+
atelier#669 D6). Covers the form-surface.v1 `relationship` summary
|
|
891
|
+
AND the derived-action `object_reference` (+ `multiple`) param.
|
|
892
|
+
`object_filter` rides the injected searchEntities seam; no seam →
|
|
893
|
+
disabled (the file-param convention for a missing transport). -->
|
|
894
|
+
{#if isMultiRelationship(parameter)}
|
|
895
|
+
<!-- many_to_many: the DS MultiSelectCombobox widget (chips +
|
|
896
|
+
search-to-add). The renderer contributes only SEMANTICS: the value
|
|
897
|
+
bag carries the ID LIST, labels come from hydration/pick capture —
|
|
898
|
+
a single-pick replace of an m2m value is impossible by
|
|
899
|
+
construction (the widget never holds the selection). -->
|
|
900
|
+
<MultiSelectCombobox
|
|
901
|
+
data-testid="afr-rel-multi"
|
|
902
|
+
label={String(parameter.label ?? key)}
|
|
903
|
+
required={parameter.required === true}
|
|
904
|
+
items={relComboItems(parameter)}
|
|
905
|
+
selected={normalizeRelIds(values[key]).map((id) => ({
|
|
906
|
+
value: id,
|
|
907
|
+
label: relLabel(key, id),
|
|
908
|
+
}))}
|
|
909
|
+
placeholder={relationshipTypeCode(parameter)
|
|
910
|
+
? `Search ${relationshipTypeCode(parameter)}...`
|
|
911
|
+
: "Search..."}
|
|
912
|
+
disabled={!searchEntities || !editable || fieldDisabled}
|
|
913
|
+
loading={(referenceLoading[key] ?? false) || parameter.loading === true}
|
|
914
|
+
error={fieldError}
|
|
915
|
+
onsearch={(query: string) => handleReferenceSearch(parameter, query)}
|
|
916
|
+
onadd={(value: string) => addRelId(key, value)}
|
|
917
|
+
onremove={(value: string) => removeRelId(key, value)}
|
|
918
|
+
/>
|
|
919
|
+
{:else}
|
|
920
|
+
<Combobox
|
|
921
|
+
label={String(parameter.label ?? key)}
|
|
922
|
+
items={relComboItems(parameter)}
|
|
923
|
+
value={String(values[key] ?? "")}
|
|
924
|
+
placeholder={relationshipTypeCode(parameter)
|
|
925
|
+
? `Search ${relationshipTypeCode(parameter)}...`
|
|
926
|
+
: "Search..."}
|
|
927
|
+
disabled={!searchEntities || !editable || fieldDisabled}
|
|
928
|
+
loading={(referenceLoading[key] ?? false) || parameter.loading === true}
|
|
929
|
+
error={fieldError}
|
|
930
|
+
onsearch={(query: string) => handleReferenceSearch(parameter, query)}
|
|
931
|
+
onchange={(value: string) => pickSingleRel(key, value)}
|
|
932
|
+
/>
|
|
933
|
+
{/if}
|
|
599
934
|
{:else if type === "file"}
|
|
600
935
|
<!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
|
|
601
936
|
ride payload.attachment_keys; raw_values never sees this param. -->
|
|
602
937
|
<!-- A11y: the file param is a labelled group (the FileUpload's own input
|
|
603
938
|
can't take a `for`/label from here), so SR users get the field name +
|
|
604
939
|
required state when they enter it. -->
|
|
940
|
+
{@const storedFile = storedFileDescriptor(parameter.default_value)}
|
|
941
|
+
{@const replaceMode = storedFile !== null}
|
|
605
942
|
<div class="afr-file-param" role="group" aria-labelledby={`${key}-file-label`}>
|
|
606
943
|
<span id={`${key}-file-label`} class="afr-file-label"
|
|
607
944
|
>{String(parameter.label ?? key)}{parameter.required
|
|
608
945
|
? " (required)"
|
|
609
946
|
: ""}</span
|
|
610
947
|
>
|
|
948
|
+
<!-- #40 — REPLACE semantics: the stored current shows until a pending
|
|
949
|
+
replacement exists; untouched emits NO attachment key (the host
|
|
950
|
+
keeps the stored file); removing the pending replacement restores
|
|
951
|
+
untouched. -->
|
|
952
|
+
{#if replaceMode && storedFile && !(fileUploads[key] ?? []).length}
|
|
953
|
+
<div class="afr-file-current" data-testid="afr-file-current">
|
|
954
|
+
{#if storedFile.url}
|
|
955
|
+
<a
|
|
956
|
+
class="afr-file-current-name"
|
|
957
|
+
href={storedFile.url}
|
|
958
|
+
target="_blank"
|
|
959
|
+
rel="noopener noreferrer">{storedFile.name}</a
|
|
960
|
+
>
|
|
961
|
+
{:else}
|
|
962
|
+
<span class="afr-file-current-name">{storedFile.name}</span>
|
|
963
|
+
{/if}
|
|
964
|
+
<span class="afr-file-current-hint">Current file — uploading replaces it</span>
|
|
965
|
+
</div>
|
|
966
|
+
{/if}
|
|
611
967
|
{#each fileUploads[key] ?? [] as f (f.key)}
|
|
612
968
|
<FileUploadItem
|
|
613
969
|
name={f.name}
|
|
614
970
|
onremove={() => removeFile(key, f.key)}
|
|
615
971
|
/>
|
|
616
972
|
{/each}
|
|
617
|
-
{#if (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
|
|
973
|
+
{#if replaceMode || (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
|
|
618
974
|
<FileUpload
|
|
619
975
|
accept={fileAccept(parameter)}
|
|
620
976
|
maxSize={fileMaxBytes(parameter)}
|
|
621
|
-
multiple
|
|
977
|
+
multiple={!replaceMode}
|
|
622
978
|
disabled={!uploadFile || fileBusy[key]}
|
|
623
|
-
onfiles={(files: File[]) => handleFiles(key, files)}
|
|
979
|
+
onfiles={(files: File[]) => handleFiles(key, files, replaceMode)}
|
|
624
980
|
onreject={() => {
|
|
625
981
|
fileError = {
|
|
626
982
|
...fileError,
|
|
@@ -631,8 +987,29 @@
|
|
|
631
987
|
{/if}
|
|
632
988
|
{#if fileError[key]}
|
|
633
989
|
<p class="afr-file-error" role="alert">{fileError[key]}</p>
|
|
990
|
+
{:else if fieldError}
|
|
991
|
+
<p class="afr-file-error" role="alert">{fieldError}</p>
|
|
634
992
|
{/if}
|
|
635
993
|
</div>
|
|
994
|
+
{:else if kind === "geometry"}
|
|
995
|
+
<!-- #39 — the form-surface.v1 geometry widget: the value bag carries a
|
|
996
|
+
GeoJSON Point (the CRUD wire shape); the MapPicker keeps speaking
|
|
997
|
+
[lon, lat] at its prop edge; the pure adapters translate. A
|
|
998
|
+
non-Point value fails LOUD on the field instead of rendering a
|
|
999
|
+
silently-empty map over real data. The legacy `geo` param (bare
|
|
1000
|
+
tuple) is the branch below, untouched. -->
|
|
1001
|
+
{@const hydrated = geoJsonPointToLonLat(values[key])}
|
|
1002
|
+
<MapPicker
|
|
1003
|
+
mode="point"
|
|
1004
|
+
height="20rem"
|
|
1005
|
+
{boundary}
|
|
1006
|
+
{layers}
|
|
1007
|
+
{onoutofbounds}
|
|
1008
|
+
error={fieldError ?? hydrated.error ?? geoError}
|
|
1009
|
+
label={String(parameter.label ?? key)}
|
|
1010
|
+
value={hydrated.coords ?? undefined}
|
|
1011
|
+
onchange={(coords: [number, number]) => setValue(key, lonLatToGeoJsonPoint(coords))}
|
|
1012
|
+
/>
|
|
636
1013
|
{:else if type === "geo"}
|
|
637
1014
|
<!-- A `geo` parameter renders the DS map-picker; its value is the
|
|
638
1015
|
[lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
|
|
@@ -648,7 +1025,7 @@
|
|
|
648
1025
|
{boundary}
|
|
649
1026
|
{layers}
|
|
650
1027
|
{onoutofbounds}
|
|
651
|
-
error={geoError}
|
|
1028
|
+
error={fieldError ?? geoError}
|
|
652
1029
|
label={String(parameter.label ?? key)}
|
|
653
1030
|
center={Array.isArray(parameter.default_value)
|
|
654
1031
|
? (parameter.default_value as [number, number])
|
|
@@ -669,6 +1046,8 @@
|
|
|
669
1046
|
name={key}
|
|
670
1047
|
value={String(values[key] ?? initialValue(parameter) ?? "")}
|
|
671
1048
|
readonly={!editable}
|
|
1049
|
+
disabled={fieldDisabled}
|
|
1050
|
+
error={fieldError}
|
|
672
1051
|
oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
|
|
673
1052
|
aria-required={ariaRequired}
|
|
674
1053
|
/>
|
|
@@ -886,6 +1265,25 @@
|
|
|
886
1265
|
color: var(--input-error-text);
|
|
887
1266
|
}
|
|
888
1267
|
|
|
1268
|
+
.afr-file-current {
|
|
1269
|
+
display: flex;
|
|
1270
|
+
align-items: baseline;
|
|
1271
|
+
gap: var(--space-sm);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.afr-file-current-name {
|
|
1275
|
+
font-family: var(--input-font);
|
|
1276
|
+
font-size: var(--input-font-size);
|
|
1277
|
+
color: var(--color-text);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
.afr-file-current-hint {
|
|
1281
|
+
font-family: var(--input-help-font);
|
|
1282
|
+
font-size: var(--input-help-size);
|
|
1283
|
+
color: var(--input-help-color);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
|
|
889
1287
|
.submit-captcha {
|
|
890
1288
|
min-height: var(--space-4xl);
|
|
891
1289
|
}
|