@aiaiai-pt/design-system 0.10.0 → 0.12.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.
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
// DS-internal sibling imports (this component lives in the DS now, #73 73f —
|
|
3
3
|
// converged so admin preview AND portal runtime mount the SAME renderer).
|
|
4
4
|
import Badge from "./Badge.svelte";
|
|
5
|
+
import Button from "./Button.svelte";
|
|
6
|
+
import Alert from "./Alert.svelte";
|
|
5
7
|
import CodeBlock from "./CodeBlock.svelte";
|
|
6
8
|
import Input from "./Input.svelte";
|
|
9
|
+
import MapPicker from "./MapPicker.svelte";
|
|
7
10
|
import Select from "./Select.svelte";
|
|
8
11
|
import Tag from "./Tag.svelte";
|
|
12
|
+
import type { Snippet } from "svelte";
|
|
9
13
|
import {
|
|
10
14
|
buildActionPayload,
|
|
11
15
|
placementConsequenceRows as buildPlacementConsequenceRows,
|
|
@@ -39,6 +43,15 @@
|
|
|
39
43
|
criteria?: Entity[];
|
|
40
44
|
};
|
|
41
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The `apply` seam (see dev_docs/adl/action-form-apply-seam.md). The renderer
|
|
48
|
+
* owns the form + validation + submit button + state; the CONSUMER injects how
|
|
49
|
+
* to actually send it. Foundry's `applyAction` model: every consumer converges
|
|
50
|
+
* on the one action engine; only transport/auth/captcha differ. Receives the
|
|
51
|
+
* normalized ActionPayload; resolves `{ ok }` (and may navigate on success).
|
|
52
|
+
*/
|
|
53
|
+
type ApplyResult = { ok: boolean; status?: number; error?: string };
|
|
54
|
+
|
|
42
55
|
interface Props {
|
|
43
56
|
action: Entity | null;
|
|
44
57
|
placement?: Entity | null;
|
|
@@ -46,6 +59,13 @@
|
|
|
46
59
|
criteria?: Entity[];
|
|
47
60
|
mode?: RendererMode;
|
|
48
61
|
schema?: ActionSchema | null;
|
|
62
|
+
/** Injected apply. Required for a working submit button; absent → no button
|
|
63
|
+
* (so admin-preview / adapter-preview stay read-only). */
|
|
64
|
+
onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
|
|
65
|
+
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
66
|
+
* in submit modes. */
|
|
67
|
+
captcha?: Snippet;
|
|
68
|
+
submitLabel?: string;
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
let {
|
|
@@ -55,10 +75,24 @@
|
|
|
55
75
|
criteria = [],
|
|
56
76
|
mode = "admin-preview",
|
|
57
77
|
schema = null,
|
|
78
|
+
onApply = undefined,
|
|
79
|
+
captcha = undefined,
|
|
80
|
+
submitLabel = "Submit",
|
|
58
81
|
}: Props = $props();
|
|
59
82
|
|
|
60
83
|
let values = $state<Record<string, unknown>>({});
|
|
61
84
|
|
|
85
|
+
// Submit state (apply seam). Only meaningful in submit modes with an onApply.
|
|
86
|
+
let submitting = $state(false);
|
|
87
|
+
let submitError = $state<string | null>(null);
|
|
88
|
+
let submitted = $state(false);
|
|
89
|
+
|
|
90
|
+
// A form is a SUBMIT form (button shown) only in the submit modes AND when the
|
|
91
|
+
// consumer wired an apply. Preview modes (admin-preview/adapter-preview) and a
|
|
92
|
+
// missing onApply stay read-only — the button never renders.
|
|
93
|
+
const isSubmitMode = $derived(mode === "public-submit" || mode === "admin-execute");
|
|
94
|
+
const showSubmit = $derived(isSubmitMode && onApply !== undefined);
|
|
95
|
+
|
|
62
96
|
const renderedAction = $derived((schema?.action as Entity | null | undefined) ?? action);
|
|
63
97
|
const renderedPlacement = $derived((schema?.placement as Entity | null | undefined) ?? placement);
|
|
64
98
|
const renderedParameters = $derived((schema?.parameters as Entity[] | undefined) ?? parameters);
|
|
@@ -80,6 +114,36 @@
|
|
|
80
114
|
const sections = $derived(schemaSections ?? groupIntoSections(visibleParameters));
|
|
81
115
|
const payload = $derived(buildPayload());
|
|
82
116
|
const payloadJson = $derived(JSON.stringify(payload, null, 2));
|
|
117
|
+
|
|
118
|
+
// Client-side submit gate (layer 1 — see the ADL): every required, visible
|
|
119
|
+
// parameter must have a non-empty value. Server-side submission criteria are
|
|
120
|
+
// enforced by the BFF on submit and surfaced via `submitError`.
|
|
121
|
+
function isEmpty(value: unknown): boolean {
|
|
122
|
+
return value === undefined || value === null || value === "";
|
|
123
|
+
}
|
|
124
|
+
const canSubmit = $derived(
|
|
125
|
+
visibleParameters
|
|
126
|
+
.filter((parameter) => parameter.required)
|
|
127
|
+
.every((parameter) => !isEmpty(values[parameterKey(parameter)])),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
async function handleSubmit(): Promise<void> {
|
|
131
|
+
if (!onApply || submitting || !canSubmit) return;
|
|
132
|
+
submitError = null;
|
|
133
|
+
submitting = true;
|
|
134
|
+
try {
|
|
135
|
+
const result = await onApply(buildPayload());
|
|
136
|
+
if (result.ok) {
|
|
137
|
+
submitted = true; // a consumer that navigates (portal → tracker) supersedes this
|
|
138
|
+
} else {
|
|
139
|
+
submitError = result.error ?? "We couldn't submit your form. Please try again.";
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
submitError = "Something went wrong. Please try again.";
|
|
143
|
+
} finally {
|
|
144
|
+
submitting = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
83
147
|
const criteriaSummary = $derived(
|
|
84
148
|
renderedCriteria
|
|
85
149
|
.filter((criterion) => criterion.is_active !== false)
|
|
@@ -286,6 +350,23 @@
|
|
|
286
350
|
]}
|
|
287
351
|
onchange={(value: string) => setValue(key, value === "true")}
|
|
288
352
|
/>
|
|
353
|
+
{:else if type === "geo"}
|
|
354
|
+
<!-- A `geo` parameter renders the DS map-picker; its value is the
|
|
355
|
+
[lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
|
|
356
|
+
The renderer stays generic — geo is just another param type, the
|
|
357
|
+
occurrence/location coupling lives entirely in the ontology binding. -->
|
|
358
|
+
<MapPicker
|
|
359
|
+
mode="point"
|
|
360
|
+
height="20rem"
|
|
361
|
+
label={String(parameter.label ?? key)}
|
|
362
|
+
center={Array.isArray(parameter.default_value)
|
|
363
|
+
? (parameter.default_value as [number, number])
|
|
364
|
+
: undefined}
|
|
365
|
+
value={Array.isArray(values[key])
|
|
366
|
+
? (values[key] as [number, number])
|
|
367
|
+
: undefined}
|
|
368
|
+
onchange={(coords: [number, number]) => setValue(key, coords)}
|
|
369
|
+
/>
|
|
289
370
|
{:else}
|
|
290
371
|
<Input
|
|
291
372
|
label={String(parameter.label ?? key)}
|
|
@@ -294,20 +375,31 @@
|
|
|
294
375
|
oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
|
|
295
376
|
/>
|
|
296
377
|
{/if}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
378
|
+
{#if mode === "public-submit"}
|
|
379
|
+
<!-- Citizen-facing: just a quiet required hint, no operator type debug. -->
|
|
380
|
+
{#if parameter.required}<p class="field-meta">Required</p>{/if}
|
|
381
|
+
{:else}
|
|
382
|
+
<p class="field-meta">
|
|
383
|
+
{String(parameter.required ? "Required" : "Optional")} / {type}
|
|
384
|
+
</p>
|
|
385
|
+
{/if}
|
|
300
386
|
{/snippet}
|
|
301
387
|
|
|
302
388
|
<div class="renderer" data-testid={`action-form-renderer-${mode}`} data-layout={resolvedLayout.key}>
|
|
303
389
|
<div class="renderer-header">
|
|
304
390
|
<div>
|
|
305
|
-
|
|
391
|
+
<!-- The placement-preview eyebrow + surface badge are operator chrome —
|
|
392
|
+
hidden in public-submit so a citizen sees a clean form title. -->
|
|
393
|
+
{#if mode !== "public-submit"}
|
|
394
|
+
<p class="eyebrow">Placement preview</p>
|
|
395
|
+
{/if}
|
|
306
396
|
<h3>{String(renderedAction?.label ?? renderedAction?.key ?? "Select an action")}</h3>
|
|
307
397
|
</div>
|
|
308
|
-
|
|
309
|
-
{
|
|
310
|
-
|
|
398
|
+
{#if mode !== "public-submit"}
|
|
399
|
+
<Badge variant={renderedPlacement ? "info" : "neutral"}>
|
|
400
|
+
{String(renderedPlacement?.surface ?? "No placement")}
|
|
401
|
+
</Badge>
|
|
402
|
+
{/if}
|
|
311
403
|
</div>
|
|
312
404
|
|
|
313
405
|
{#if !renderedAction}
|
|
@@ -321,6 +413,31 @@
|
|
|
321
413
|
</form>
|
|
322
414
|
{/if}
|
|
323
415
|
|
|
416
|
+
<!-- Submit (apply seam) — only in submit modes with an injected onApply, so
|
|
417
|
+
preview modes never render a button. Captcha snippet sits above it. -->
|
|
418
|
+
{#if showSubmit && renderedAction && orderedParameters.length > 0}
|
|
419
|
+
<div class="submit-area">
|
|
420
|
+
{#if submitted}
|
|
421
|
+
<Alert variant="success">Your submission was received.</Alert>
|
|
422
|
+
{:else}
|
|
423
|
+
{#if submitError}
|
|
424
|
+
<Alert variant="error">{submitError}</Alert>
|
|
425
|
+
{/if}
|
|
426
|
+
{#if captcha}
|
|
427
|
+
<div class="submit-captcha">{@render captcha()}</div>
|
|
428
|
+
{/if}
|
|
429
|
+
<Button
|
|
430
|
+
variant="primary"
|
|
431
|
+
loading={submitting}
|
|
432
|
+
disabled={submitting || !canSubmit}
|
|
433
|
+
onclick={handleSubmit}
|
|
434
|
+
>
|
|
435
|
+
{submitLabel}
|
|
436
|
+
</Button>
|
|
437
|
+
{/if}
|
|
438
|
+
</div>
|
|
439
|
+
{/if}
|
|
440
|
+
|
|
324
441
|
{#if mode === "admin-preview"}
|
|
325
442
|
<div class="admin-preview">
|
|
326
443
|
<section class="preview-block">
|
|
@@ -409,12 +526,22 @@
|
|
|
409
526
|
|
|
410
527
|
.rendered-form,
|
|
411
528
|
.admin-preview,
|
|
412
|
-
.preview-block
|
|
529
|
+
.preview-block,
|
|
530
|
+
.submit-area {
|
|
413
531
|
display: flex;
|
|
414
532
|
flex-direction: column;
|
|
415
533
|
gap: var(--space-md);
|
|
416
534
|
}
|
|
417
535
|
|
|
536
|
+
.submit-area {
|
|
537
|
+
align-items: flex-start;
|
|
538
|
+
gap: var(--space-lg);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.submit-captcha {
|
|
542
|
+
min-height: var(--space-4xl);
|
|
543
|
+
}
|
|
544
|
+
|
|
418
545
|
.preview-block h4 {
|
|
419
546
|
margin: 0;
|
|
420
547
|
font-size: var(--type-body-size);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
1
2
|
import { type RendererMode } from "./action-form-renderer-payload";
|
|
2
3
|
/**
|
|
3
4
|
* The renderer treats every parameter/action/placement as a loose record
|
|
@@ -22,6 +23,18 @@ type ActionSchema = {
|
|
|
22
23
|
parameters?: Entity[];
|
|
23
24
|
criteria?: Entity[];
|
|
24
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* The `apply` seam (see dev_docs/adl/action-form-apply-seam.md). The renderer
|
|
28
|
+
* owns the form + validation + submit button + state; the CONSUMER injects how
|
|
29
|
+
* to actually send it. Foundry's `applyAction` model: every consumer converges
|
|
30
|
+
* on the one action engine; only transport/auth/captcha differ. Receives the
|
|
31
|
+
* normalized ActionPayload; resolves `{ ok }` (and may navigate on success).
|
|
32
|
+
*/
|
|
33
|
+
type ApplyResult = {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
status?: number;
|
|
36
|
+
error?: string;
|
|
37
|
+
};
|
|
25
38
|
interface Props {
|
|
26
39
|
action: Entity | null;
|
|
27
40
|
placement?: Entity | null;
|
|
@@ -29,6 +42,13 @@ interface Props {
|
|
|
29
42
|
criteria?: Entity[];
|
|
30
43
|
mode?: RendererMode;
|
|
31
44
|
schema?: ActionSchema | null;
|
|
45
|
+
/** Injected apply. Required for a working submit button; absent → no button
|
|
46
|
+
* (so admin-preview / adapter-preview stay read-only). */
|
|
47
|
+
onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
|
|
48
|
+
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
49
|
+
* in submit modes. */
|
|
50
|
+
captcha?: Snippet;
|
|
51
|
+
submitLabel?: string;
|
|
32
52
|
}
|
|
33
53
|
declare const ActionFormRenderer: import("svelte").Component<Props, {}, "">;
|
|
34
54
|
type ActionFormRenderer = ReturnType<typeof ActionFormRenderer>;
|