@aiaiai-pt/design-system 0.45.0 → 0.46.1
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 +396 -40
- package/components/ActionFormRenderer.svelte.d.ts +11 -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/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 +6 -3
- package/components/action-form-renderer-layouts.ts +11 -8
- 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/index.d.ts +3 -0
- package/components/index.js +3 -0
- package/components/json-editor-contract.d.ts +11 -0
- package/components/json-editor-contract.ts +33 -0
- package/components/renderer/display.d.ts +16 -4
- package/components/renderer/display.ts +36 -7
- package/package.json +13 -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";
|
|
@@ -29,6 +37,26 @@
|
|
|
29
37
|
// predicate (pure client evaluation over the value bag; the server owns
|
|
30
38
|
// enforcement).
|
|
31
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";
|
|
32
60
|
|
|
33
61
|
/**
|
|
34
62
|
* The renderer treats every parameter/action/placement as a loose record
|
|
@@ -94,6 +122,20 @@
|
|
|
94
122
|
query: string,
|
|
95
123
|
filter?: Record<string, string>,
|
|
96
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>;
|
|
97
139
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
98
140
|
* in submit modes. */
|
|
99
141
|
captcha?: Snippet;
|
|
@@ -138,6 +180,8 @@
|
|
|
138
180
|
onApply = undefined,
|
|
139
181
|
uploadFile = undefined,
|
|
140
182
|
searchEntities = undefined,
|
|
183
|
+
hydrateEntities = undefined,
|
|
184
|
+
fieldErrors = undefined,
|
|
141
185
|
captcha = undefined,
|
|
142
186
|
submitLabel = "Submit",
|
|
143
187
|
boundary = undefined,
|
|
@@ -235,6 +279,8 @@
|
|
|
235
279
|
// parameter must have a non-empty value. Server-side submission criteria are
|
|
236
280
|
// enforced by the BFF on submit and surfaced via `submitError`.
|
|
237
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;
|
|
238
284
|
return value === undefined || value === null || value === "";
|
|
239
285
|
}
|
|
240
286
|
// #634 S4 — the gate counts only parameters whose section is LIVE-visible:
|
|
@@ -243,13 +289,15 @@
|
|
|
243
289
|
// to exactly `visibleParameters`.
|
|
244
290
|
const gateParameters = $derived(liveSections.flatMap((section) => section.items));
|
|
245
291
|
const canSubmit = $derived(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
),
|
|
253
301
|
);
|
|
254
302
|
|
|
255
303
|
async function handleSubmit(): Promise<void> {
|
|
@@ -388,6 +436,11 @@
|
|
|
388
436
|
}
|
|
389
437
|
|
|
390
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
|
+
}
|
|
391
444
|
if (parameter.default_value !== null && parameter.default_value !== undefined) {
|
|
392
445
|
return parameter.default_value;
|
|
393
446
|
}
|
|
@@ -395,6 +448,12 @@
|
|
|
395
448
|
if (type === "bool" || type === "boolean") return false;
|
|
396
449
|
if (type === "number" || type === "integer") return "";
|
|
397
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;
|
|
398
457
|
return "";
|
|
399
458
|
}
|
|
400
459
|
|
|
@@ -407,6 +466,10 @@
|
|
|
407
466
|
for (const parameter of orderedParameters) {
|
|
408
467
|
const key = parameterKey(parameter);
|
|
409
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;
|
|
410
473
|
bag[key] = values[key] ?? initialValue(parameter);
|
|
411
474
|
}
|
|
412
475
|
return bag;
|
|
@@ -434,13 +497,17 @@
|
|
|
434
497
|
: {};
|
|
435
498
|
}
|
|
436
499
|
|
|
437
|
-
|
|
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) {
|
|
438
505
|
if (!uploadFile) return;
|
|
439
506
|
fileError = { ...fileError, [key]: "" };
|
|
440
|
-
const existing = fileUploads[key] ?? [];
|
|
441
|
-
const room = FILE_MAX_COUNT - existing.length;
|
|
507
|
+
const existing = replace ? [] : (fileUploads[key] ?? []);
|
|
508
|
+
const room = replace ? 1 : FILE_MAX_COUNT - existing.length;
|
|
442
509
|
const batch = files.slice(0, room);
|
|
443
|
-
if (files.length > room) {
|
|
510
|
+
if (!replace && files.length > room) {
|
|
444
511
|
fileError = { ...fileError, [key]: `Up to ${FILE_MAX_COUNT} files` };
|
|
445
512
|
}
|
|
446
513
|
if (batch.length === 0) return;
|
|
@@ -451,7 +518,9 @@
|
|
|
451
518
|
if ("key" in result) {
|
|
452
519
|
fileUploads = {
|
|
453
520
|
...fileUploads,
|
|
454
|
-
[key]:
|
|
521
|
+
[key]: replace
|
|
522
|
+
? [{ key: result.key, name: file.name }]
|
|
523
|
+
: [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
|
|
455
524
|
};
|
|
456
525
|
} else {
|
|
457
526
|
fileError = { ...fileError, [key]: result.error };
|
|
@@ -483,8 +552,10 @@
|
|
|
483
552
|
const gen = (referenceSearchGen[key] = (referenceSearchGen[key] ?? 0) + 1);
|
|
484
553
|
referenceLoading = { ...referenceLoading, [key]: true };
|
|
485
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).
|
|
486
557
|
const items = await searchEntities(
|
|
487
|
-
|
|
558
|
+
relationshipTypeCode(parameter),
|
|
488
559
|
query,
|
|
489
560
|
objectFilter(parameter),
|
|
490
561
|
);
|
|
@@ -498,6 +569,96 @@
|
|
|
498
569
|
}
|
|
499
570
|
}
|
|
500
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
|
+
|
|
501
662
|
// `datetime` params keep an ISO 8601 STRING in the value bag (the platform's
|
|
502
663
|
// datetime wire vocabulary — `$now` resolves to one) so the payload stays
|
|
503
664
|
// plain JSON; the DateTimePicker speaks Date on its prop edge.
|
|
@@ -565,6 +726,7 @@
|
|
|
565
726
|
{@const parameter = rawParameter as Entity}
|
|
566
727
|
{@const key = parameterKey(parameter)}
|
|
567
728
|
{@const type = parameterType(parameter)}
|
|
729
|
+
{@const kind = widgetKind(parameter)}
|
|
568
730
|
<!-- A11y (#244 C7 / Selo item 4): required fields are marked
|
|
569
731
|
`aria-required` on the control itself, so a screen reader announces
|
|
570
732
|
"required" on the field — not just the disconnected visual hint below.
|
|
@@ -572,17 +734,113 @@
|
|
|
572
734
|
{@const ariaRequired = parameter.required ? "true" : undefined}
|
|
573
735
|
<!-- #252 — a parameter with `editable: false` (visibility.editable === false)
|
|
574
736
|
renders READ-ONLY: its value (e.g. a logged-in citizen's prefilled
|
|
575
|
-
identity — name/email) is shown but not editable.
|
|
576
|
-
the payload; the
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
{
|
|
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. -->
|
|
580
831
|
<Select
|
|
581
832
|
label={String(parameter.label ?? key)}
|
|
582
833
|
name={key}
|
|
583
834
|
value={String(values[key] ?? initialValue(parameter) ?? "")}
|
|
584
|
-
options
|
|
835
|
+
{options}
|
|
585
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}
|
|
586
844
|
onchange={(value: string) => setValue(key, value)}
|
|
587
845
|
aria-required={ariaRequired}
|
|
588
846
|
/>
|
|
@@ -593,6 +851,8 @@
|
|
|
593
851
|
type="number"
|
|
594
852
|
value={String(values[key] ?? "")}
|
|
595
853
|
readonly={!editable}
|
|
854
|
+
disabled={fieldDisabled}
|
|
855
|
+
error={fieldError}
|
|
596
856
|
oninput={(event: Event) => {
|
|
597
857
|
const value = (event.target as HTMLInputElement).value;
|
|
598
858
|
setValue(key, value === "" ? "" : Number(value));
|
|
@@ -608,6 +868,8 @@
|
|
|
608
868
|
{ value: "true", label: "Yes" },
|
|
609
869
|
{ value: "false", label: "No" },
|
|
610
870
|
]}
|
|
871
|
+
disabled={fieldDisabled || !editable}
|
|
872
|
+
error={fieldError}
|
|
611
873
|
onchange={(value: string) => setValue(key, value === "true")}
|
|
612
874
|
aria-required={ariaRequired}
|
|
613
875
|
/>
|
|
@@ -619,50 +881,102 @@
|
|
|
619
881
|
label={String(parameter.label ?? key)}
|
|
620
882
|
value={datetimeValue(values[key])}
|
|
621
883
|
readonly={!editable}
|
|
884
|
+
disabled={fieldDisabled}
|
|
885
|
+
error={fieldError}
|
|
622
886
|
onchange={(date: Date) => setValue(key, date.toISOString())}
|
|
623
887
|
/>
|
|
624
|
-
{:else if
|
|
625
|
-
<!-- `object_reference`
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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}
|
|
641
934
|
{:else if type === "file"}
|
|
642
935
|
<!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
|
|
643
936
|
ride payload.attachment_keys; raw_values never sees this param. -->
|
|
644
937
|
<!-- A11y: the file param is a labelled group (the FileUpload's own input
|
|
645
938
|
can't take a `for`/label from here), so SR users get the field name +
|
|
646
939
|
required state when they enter it. -->
|
|
940
|
+
{@const storedFile = storedFileDescriptor(parameter.default_value)}
|
|
941
|
+
{@const replaceMode = storedFile !== null}
|
|
647
942
|
<div class="afr-file-param" role="group" aria-labelledby={`${key}-file-label`}>
|
|
648
943
|
<span id={`${key}-file-label`} class="afr-file-label"
|
|
649
944
|
>{String(parameter.label ?? key)}{parameter.required
|
|
650
945
|
? " (required)"
|
|
651
946
|
: ""}</span
|
|
652
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}
|
|
653
967
|
{#each fileUploads[key] ?? [] as f (f.key)}
|
|
654
968
|
<FileUploadItem
|
|
655
969
|
name={f.name}
|
|
656
970
|
onremove={() => removeFile(key, f.key)}
|
|
657
971
|
/>
|
|
658
972
|
{/each}
|
|
659
|
-
{#if (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
|
|
973
|
+
{#if replaceMode || (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
|
|
660
974
|
<FileUpload
|
|
661
975
|
accept={fileAccept(parameter)}
|
|
662
976
|
maxSize={fileMaxBytes(parameter)}
|
|
663
|
-
multiple
|
|
977
|
+
multiple={!replaceMode}
|
|
664
978
|
disabled={!uploadFile || fileBusy[key]}
|
|
665
|
-
onfiles={(files: File[]) => handleFiles(key, files)}
|
|
979
|
+
onfiles={(files: File[]) => handleFiles(key, files, replaceMode)}
|
|
666
980
|
onreject={() => {
|
|
667
981
|
fileError = {
|
|
668
982
|
...fileError,
|
|
@@ -673,8 +987,29 @@
|
|
|
673
987
|
{/if}
|
|
674
988
|
{#if fileError[key]}
|
|
675
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>
|
|
676
992
|
{/if}
|
|
677
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
|
+
/>
|
|
678
1013
|
{:else if type === "geo"}
|
|
679
1014
|
<!-- A `geo` parameter renders the DS map-picker; its value is the
|
|
680
1015
|
[lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
|
|
@@ -690,7 +1025,7 @@
|
|
|
690
1025
|
{boundary}
|
|
691
1026
|
{layers}
|
|
692
1027
|
{onoutofbounds}
|
|
693
|
-
error={geoError}
|
|
1028
|
+
error={fieldError ?? geoError}
|
|
694
1029
|
label={String(parameter.label ?? key)}
|
|
695
1030
|
center={Array.isArray(parameter.default_value)
|
|
696
1031
|
? (parameter.default_value as [number, number])
|
|
@@ -711,6 +1046,8 @@
|
|
|
711
1046
|
name={key}
|
|
712
1047
|
value={String(values[key] ?? initialValue(parameter) ?? "")}
|
|
713
1048
|
readonly={!editable}
|
|
1049
|
+
disabled={fieldDisabled}
|
|
1050
|
+
error={fieldError}
|
|
714
1051
|
oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
|
|
715
1052
|
aria-required={ariaRequired}
|
|
716
1053
|
/>
|
|
@@ -928,6 +1265,25 @@
|
|
|
928
1265
|
color: var(--input-error-text);
|
|
929
1266
|
}
|
|
930
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
|
+
|
|
931
1287
|
.submit-captcha {
|
|
932
1288
|
min-height: var(--space-4xl);
|
|
933
1289
|
}
|
|
@@ -69,6 +69,17 @@ interface Props {
|
|
|
69
69
|
* transport/auth/tenancy (the uploadFile seam pattern — the DS stays
|
|
70
70
|
* presentation-only). Omitted → object_reference params render disabled. */
|
|
71
71
|
searchEntities?: (typeCode: string, query: string, filter?: Record<string, string>) => Promise<SelectOption[]>;
|
|
72
|
+
/** Edit-time label hydration for relationship params (#34): receives the
|
|
73
|
+
* target entity type code and the currently-linked ids, returns
|
|
74
|
+
* `{value, label}` items so the picker renders labels, never raw ids.
|
|
75
|
+
* Same philosophy as searchEntities — the CONSUMER owns transport/auth;
|
|
76
|
+
* omitted → the picker falls back to showing the ids. Best-effort: a
|
|
77
|
+
* failed hydration never blocks the form. */
|
|
78
|
+
hydrateEntities?: (typeCode: string, ids: string[]) => Promise<SelectOption[]>;
|
|
79
|
+
/** #41 — per-field server-error binding: `{param_key: message}` rendered
|
|
80
|
+
* as each field's inline error (the V2a adapter re-binds proxy 4xx field
|
|
81
|
+
* errors here). Display-only — the HOST owns when errors set and clear. */
|
|
82
|
+
fieldErrors?: Record<string, string>;
|
|
72
83
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
73
84
|
* in submit modes. */
|
|
74
85
|
captcha?: Snippet;
|
|
@@ -35,7 +35,7 @@ declare const DateRangePicker: import("svelte").Component<{
|
|
|
35
35
|
onchange?: any;
|
|
36
36
|
id?: any;
|
|
37
37
|
class?: string;
|
|
38
|
-
} & Record<string, any>, {}, "
|
|
38
|
+
} & Record<string, any>, {}, "end" | "start">;
|
|
39
39
|
type $$ComponentProps = {
|
|
40
40
|
start?: any;
|
|
41
41
|
end?: any;
|