@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
- <p class="field-meta">
298
- {String(parameter.required ? "Required" : "Optional")} / {type}
299
- </p>
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
- <p class="eyebrow">{mode === "public-submit" ? "Public portal" : "Placement preview"}</p>
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
- <Badge variant={renderedPlacement ? "info" : "neutral"}>
309
- {String(renderedPlacement?.surface ?? "No placement")}
310
- </Badge>
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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",