@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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component MultiSelectCombobox
|
|
3
|
+
|
|
4
|
+
Multi-value picking: removable Tag chips over a search-to-add Combobox
|
|
5
|
+
(the Carbon MultiSelect / Polaris tag-combobox pattern). Presentation-only:
|
|
6
|
+
the parent owns the selection list AND the async item source — the widget
|
|
7
|
+
never holds the selection in its input (a single-pick replace of a
|
|
8
|
+
multi-value field is impossible by construction).
|
|
9
|
+
|
|
10
|
+
@example
|
|
11
|
+
<MultiSelectCombobox
|
|
12
|
+
label="INSPECTORS"
|
|
13
|
+
items={searchResults}
|
|
14
|
+
selected={[{ value: 'u-1', label: 'Ana Silva' }]}
|
|
15
|
+
onsearch={(q) => search(q)}
|
|
16
|
+
onadd={(value) => add(value)}
|
|
17
|
+
onremove={(value) => remove(value)}
|
|
18
|
+
/>
|
|
19
|
+
-->
|
|
20
|
+
<script module>
|
|
21
|
+
let _mscUid = 0;
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script>
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ value: string, label: string, group?: string, description?: string }} ComboboxItem
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import Combobox from './Combobox.svelte';
|
|
30
|
+
import Tag from './Tag.svelte';
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
/** @type {ComboboxItem[]} — the search-result pool (parent-owned; async via onsearch) */
|
|
34
|
+
items = [],
|
|
35
|
+
/** @type {ComboboxItem[]} — the current selection, WITH display labels */
|
|
36
|
+
selected = [],
|
|
37
|
+
/** @type {string | undefined} */
|
|
38
|
+
label = undefined,
|
|
39
|
+
/** @type {string | undefined} */
|
|
40
|
+
placeholder = 'Search...',
|
|
41
|
+
/** @type {boolean} */
|
|
42
|
+
disabled = false,
|
|
43
|
+
/** @type {boolean} */
|
|
44
|
+
loading = false,
|
|
45
|
+
/** @type {string | undefined} */
|
|
46
|
+
error = undefined,
|
|
47
|
+
/** @type {string | undefined} */
|
|
48
|
+
help = undefined,
|
|
49
|
+
/** @type {boolean} */
|
|
50
|
+
required = false,
|
|
51
|
+
/** @type {string} */
|
|
52
|
+
size = 'md',
|
|
53
|
+
/** @type {((value: string) => void) | undefined} — a NEW value was picked */
|
|
54
|
+
onadd = undefined,
|
|
55
|
+
/** @type {((value: string) => void) | undefined} — a chip was removed */
|
|
56
|
+
onremove = undefined,
|
|
57
|
+
/** @type {((query: string) => void) | undefined} — async search; parent owns items */
|
|
58
|
+
onsearch = undefined,
|
|
59
|
+
/** @type {string} */
|
|
60
|
+
class: className = '',
|
|
61
|
+
...rest
|
|
62
|
+
} = $props();
|
|
63
|
+
|
|
64
|
+
const mscId = `msc-${_mscUid++}`;
|
|
65
|
+
|
|
66
|
+
// The draft is WIDGET-internal: it exists only to drive the Combobox and is
|
|
67
|
+
// cleared after every pick, so the input never displays a "selection".
|
|
68
|
+
let draft = $state('');
|
|
69
|
+
|
|
70
|
+
// An already-picked value never re-offers (no double-add by construction).
|
|
71
|
+
const available = $derived(
|
|
72
|
+
items.filter((item) => !selected.some((entry) => entry.value === item.value)),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/** @param {string} value */
|
|
76
|
+
function handlePick(value) {
|
|
77
|
+
if (value && !selected.some((entry) => entry.value === value)) {
|
|
78
|
+
onadd?.(value);
|
|
79
|
+
}
|
|
80
|
+
draft = '';
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<div
|
|
85
|
+
class="msc {className}"
|
|
86
|
+
role="group"
|
|
87
|
+
aria-labelledby={label ? `${mscId}-label` : undefined}
|
|
88
|
+
{...rest}
|
|
89
|
+
>
|
|
90
|
+
{#if label}
|
|
91
|
+
<span id={`${mscId}-label`} class="msc-label"
|
|
92
|
+
>{label}{required ? ' (required)' : ''}</span
|
|
93
|
+
>
|
|
94
|
+
{/if}
|
|
95
|
+
|
|
96
|
+
{#if selected.length}
|
|
97
|
+
<div class="msc-chips">
|
|
98
|
+
{#each selected as entry (entry.value)}
|
|
99
|
+
<Tag removable={!disabled} onremove={() => onremove?.(entry.value)}>
|
|
100
|
+
{entry.label}
|
|
101
|
+
</Tag>
|
|
102
|
+
{/each}
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
<Combobox
|
|
107
|
+
items={available}
|
|
108
|
+
bind:value={draft}
|
|
109
|
+
{placeholder}
|
|
110
|
+
{disabled}
|
|
111
|
+
{loading}
|
|
112
|
+
{error}
|
|
113
|
+
{help}
|
|
114
|
+
{size}
|
|
115
|
+
onsearch={onsearch}
|
|
116
|
+
onchange={handlePick}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
.msc {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-direction: column;
|
|
124
|
+
gap: var(--space-xs);
|
|
125
|
+
width: 100%;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.msc-label {
|
|
129
|
+
font-family: var(--input-label-font);
|
|
130
|
+
font-size: var(--input-label-size);
|
|
131
|
+
letter-spacing: var(--input-label-tracking);
|
|
132
|
+
color: var(--input-label-color);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.msc-chips {
|
|
136
|
+
display: flex;
|
|
137
|
+
flex-wrap: wrap;
|
|
138
|
+
gap: var(--space-xs);
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export default MultiSelectCombobox;
|
|
2
|
+
export type ComboboxItem = {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
group?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
};
|
|
8
|
+
type MultiSelectCombobox = {
|
|
9
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
10
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* MultiSelectCombobox
|
|
14
|
+
*
|
|
15
|
+
* Multi-value picking: removable Tag chips over a search-to-add Combobox
|
|
16
|
+
* (the Carbon MultiSelect / Polaris tag-combobox pattern). Presentation-only:
|
|
17
|
+
* the parent owns the selection list AND the async item source — the widget
|
|
18
|
+
* never holds the selection in its input (a single-pick replace of a
|
|
19
|
+
* multi-value field is impossible by construction).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <MultiSelectCombobox
|
|
23
|
+
* label="INSPECTORS"
|
|
24
|
+
* items={searchResults}
|
|
25
|
+
* selected={[{ value: 'u-1', label: 'Ana Silva' }]}
|
|
26
|
+
* onsearch={(q) => search(q)}
|
|
27
|
+
* onadd={(value) => add(value)}
|
|
28
|
+
* onremove={(value) => remove(value)}
|
|
29
|
+
* />
|
|
30
|
+
*/
|
|
31
|
+
declare const MultiSelectCombobox: import("svelte").Component<{
|
|
32
|
+
items?: any[];
|
|
33
|
+
selected?: any[];
|
|
34
|
+
label?: any;
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
error?: any;
|
|
39
|
+
help?: any;
|
|
40
|
+
required?: boolean;
|
|
41
|
+
size?: string;
|
|
42
|
+
onadd?: any;
|
|
43
|
+
onremove?: any;
|
|
44
|
+
onsearch?: any;
|
|
45
|
+
class?: string;
|
|
46
|
+
} & Record<string, any>, {}, "">;
|
|
47
|
+
type $$ComponentProps = {
|
|
48
|
+
items?: any[];
|
|
49
|
+
selected?: any[];
|
|
50
|
+
label?: any;
|
|
51
|
+
placeholder?: string;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
loading?: boolean;
|
|
54
|
+
error?: any;
|
|
55
|
+
help?: any;
|
|
56
|
+
required?: boolean;
|
|
57
|
+
size?: string;
|
|
58
|
+
onadd?: any;
|
|
59
|
+
onremove?: any;
|
|
60
|
+
onsearch?: any;
|
|
61
|
+
class?: string;
|
|
62
|
+
} & Record<string, any>;
|
package/components/Tag.svelte
CHANGED
|
@@ -33,7 +33,11 @@
|
|
|
33
33
|
{@render children()}
|
|
34
34
|
{/if}
|
|
35
35
|
{#if removable}
|
|
36
|
+
<!-- type="button": inside a <form> (e.g. ActionFormRenderer m2m chips) a
|
|
37
|
+
typeless button is a SUBMIT button — removing a chip must never
|
|
38
|
+
submit the form. -->
|
|
36
39
|
<button
|
|
40
|
+
type="button"
|
|
37
41
|
class="tag-remove"
|
|
38
42
|
aria-label="Remove tag"
|
|
39
43
|
onclick={(e) => {
|
|
@@ -34,7 +34,27 @@ export interface LayoutEntry {
|
|
|
34
34
|
export interface LayoutSection {
|
|
35
35
|
name: string;
|
|
36
36
|
items: Array<Record<string, unknown>>;
|
|
37
|
+
/** #634 S3 — stable contract key (schema.sections[].key), when present. */
|
|
38
|
+
key?: string;
|
|
39
|
+
/** #634 S3 — declared section density (1|2). Layouts that arrange rows
|
|
40
|
+
* vertically render a two-column grid when 2; other layouts may ignore
|
|
41
|
+
* it (their arrangement is their own contract). Default 1. */
|
|
42
|
+
columns?: number;
|
|
43
|
+
/** #634 S4 — the section's optional `visible_when` predicate, carried
|
|
44
|
+
* from the contract for live show/hide (see action-form-visibility). */
|
|
45
|
+
visibleWhen?: Record<string, unknown> | null;
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* #634 S3 — whether a parameter's cell spans the full row inside a
|
|
49
|
+
* `columns: 2` section. An explicit `span: "full"` on the parameter wins;
|
|
50
|
+
* wide WIDGET KINDS (map canvas, upload list, textarea, json editor —
|
|
51
|
+
* resolved widget-first per #36, so both the field summary's `widget` and
|
|
52
|
+
* a bare legacy `type` clamp) go full regardless; everything else flows
|
|
53
|
+
* half-width (so a bare `columns: 2` declaration visibly two-columns its
|
|
54
|
+
* fields). In a single-column section every cell is trivially full.
|
|
55
|
+
* Mirrors the CRUD form-surface compilers' FULL_WIDTH_WIDGETS clamp.
|
|
56
|
+
*/
|
|
57
|
+
export declare function fieldSpansFull(parameter: Record<string, unknown>, columns: number | undefined): boolean;
|
|
38
58
|
import type { Snippet } from "svelte";
|
|
39
59
|
export interface LayoutComponentProps {
|
|
40
60
|
sections: LayoutSection[];
|
|
@@ -41,6 +41,39 @@ export interface LayoutEntry {
|
|
|
41
41
|
export interface LayoutSection {
|
|
42
42
|
name: string;
|
|
43
43
|
items: Array<Record<string, unknown>>;
|
|
44
|
+
/** #634 S3 — stable contract key (schema.sections[].key), when present. */
|
|
45
|
+
key?: string;
|
|
46
|
+
/** #634 S3 — declared section density (1|2). Layouts that arrange rows
|
|
47
|
+
* vertically render a two-column grid when 2; other layouts may ignore
|
|
48
|
+
* it (their arrangement is their own contract). Default 1. */
|
|
49
|
+
columns?: number;
|
|
50
|
+
/** #634 S4 — the section's optional `visible_when` predicate, carried
|
|
51
|
+
* from the contract for live show/hide (see action-form-visibility). */
|
|
52
|
+
visibleWhen?: Record<string, unknown> | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
FULL_WIDTH_WIDGET_KINDS,
|
|
57
|
+
widgetKind,
|
|
58
|
+
} from "./action-form-renderer-widgets";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* #634 S3 — whether a parameter's cell spans the full row inside a
|
|
62
|
+
* `columns: 2` section. An explicit `span: "full"` on the parameter wins;
|
|
63
|
+
* wide WIDGET KINDS (map canvas, upload list, textarea, json editor —
|
|
64
|
+
* resolved widget-first per #36, so both the field summary's `widget` and
|
|
65
|
+
* a bare legacy `type` clamp) go full regardless; everything else flows
|
|
66
|
+
* half-width (so a bare `columns: 2` declaration visibly two-columns its
|
|
67
|
+
* fields). In a single-column section every cell is trivially full.
|
|
68
|
+
* Mirrors the CRUD form-surface compilers' FULL_WIDTH_WIDGETS clamp.
|
|
69
|
+
*/
|
|
70
|
+
export function fieldSpansFull(
|
|
71
|
+
parameter: Record<string, unknown>,
|
|
72
|
+
columns: number | undefined,
|
|
73
|
+
): boolean {
|
|
74
|
+
if ((columns ?? 1) < 2) return true;
|
|
75
|
+
if (parameter.span === "full") return true;
|
|
76
|
+
return FULL_WIDTH_WIDGET_KINDS.has(widgetKind(parameter));
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
import type { Snippet } from "svelte";
|
|
@@ -56,9 +89,11 @@ export interface LayoutComponentProps {
|
|
|
56
89
|
/** The registry. Order is the order shown to operators in the
|
|
57
90
|
* AddPlacementPanel select; the first entry is the default. */
|
|
58
91
|
const REGISTRY: Record<LayoutKey, Component<LayoutComponentProps>> = {
|
|
59
|
-
"stacked-default":
|
|
92
|
+
"stacked-default":
|
|
93
|
+
LayoutStackedDefault as unknown as Component<LayoutComponentProps>,
|
|
60
94
|
"inline-row": LayoutInlineRow as unknown as Component<LayoutComponentProps>,
|
|
61
|
-
"compact-mobile":
|
|
95
|
+
"compact-mobile":
|
|
96
|
+
LayoutCompactMobile as unknown as Component<LayoutComponentProps>,
|
|
62
97
|
};
|
|
63
98
|
|
|
64
99
|
const DEFAULT_KEY: LayoutKey = "stacked-default";
|
|
@@ -79,4 +114,6 @@ export function resolveLayout(key: unknown): LayoutEntry {
|
|
|
79
114
|
}
|
|
80
115
|
|
|
81
116
|
/** All known keys, in registry order. Useful for picker UIs. */
|
|
82
|
-
export const LAYOUT_KEYS: readonly LayoutKey[] = Object.keys(
|
|
117
|
+
export const LAYOUT_KEYS: readonly LayoutKey[] = Object.keys(
|
|
118
|
+
REGISTRY,
|
|
119
|
+
) as LayoutKey[];
|
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
* Svelte component owns the reactive plumbing; this module owns the shape.
|
|
16
16
|
*/
|
|
17
17
|
export type RendererMode = "admin-preview" | "admin-execute" | "public-submit" | "adapter-preview";
|
|
18
|
+
/** #41 (atelier#669 V1, operator decision D3) — readonly across the two param
|
|
19
|
+
* shapes: the form-surface.v1 summary says `visibility: "readonly"` (a
|
|
20
|
+
* STRING); the action lane says `visibility: {editable: false}` (an object,
|
|
21
|
+
* mirrored onto `editable` by the schema builders). */
|
|
22
|
+
export declare function isReadonlyParam(parameter: Record<string, unknown>): boolean;
|
|
23
|
+
/** #41 / D3 — whether a param's value belongs in `form.raw_values`:
|
|
24
|
+
* an explicit `payload: "include" | "exclude"` always wins; the
|
|
25
|
+
* form-surface.v1 lane (string visibility) defaults readonly OUT of the
|
|
26
|
+
* wire; the LEGACY action lane (object visibility, no payload key) keeps
|
|
27
|
+
* its #252 ride-the-payload behavior — flipping it would silently drop
|
|
28
|
+
* citizen identity prefills from deployed intake forms. */
|
|
29
|
+
export declare function isPayloadIncluded(parameter: Record<string, unknown>): boolean;
|
|
18
30
|
/**
|
|
19
31
|
* Minimal action/placement references the payload builder reads. Callers may
|
|
20
32
|
* pass a full ontology Entity; this narrower shape is what we actually rely
|
|
@@ -14,7 +14,38 @@
|
|
|
14
14
|
* source_schema, raw_values, mode), returns the canonical payload. The
|
|
15
15
|
* Svelte component owns the reactive plumbing; this module owns the shape.
|
|
16
16
|
*/
|
|
17
|
-
export type RendererMode =
|
|
17
|
+
export type RendererMode =
|
|
18
|
+
"admin-preview" | "admin-execute" | "public-submit" | "adapter-preview";
|
|
19
|
+
|
|
20
|
+
/** #41 (atelier#669 V1, operator decision D3) — readonly across the two param
|
|
21
|
+
* shapes: the form-surface.v1 summary says `visibility: "readonly"` (a
|
|
22
|
+
* STRING); the action lane says `visibility: {editable: false}` (an object,
|
|
23
|
+
* mirrored onto `editable` by the schema builders). */
|
|
24
|
+
export function isReadonlyParam(parameter: Record<string, unknown>): boolean {
|
|
25
|
+
if (parameter.editable === false) return true;
|
|
26
|
+
const visibility = parameter.visibility;
|
|
27
|
+
if (typeof visibility === "string") return visibility === "readonly";
|
|
28
|
+
if (
|
|
29
|
+
visibility &&
|
|
30
|
+
typeof visibility === "object" &&
|
|
31
|
+
!Array.isArray(visibility)
|
|
32
|
+
) {
|
|
33
|
+
return (visibility as Record<string, unknown>).editable === false;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** #41 / D3 — whether a param's value belongs in `form.raw_values`:
|
|
39
|
+
* an explicit `payload: "include" | "exclude"` always wins; the
|
|
40
|
+
* form-surface.v1 lane (string visibility) defaults readonly OUT of the
|
|
41
|
+
* wire; the LEGACY action lane (object visibility, no payload key) keeps
|
|
42
|
+
* its #252 ride-the-payload behavior — flipping it would silently drop
|
|
43
|
+
* citizen identity prefills from deployed intake forms. */
|
|
44
|
+
export function isPayloadIncluded(parameter: Record<string, unknown>): boolean {
|
|
45
|
+
if (parameter.payload === "include") return true;
|
|
46
|
+
if (parameter.payload === "exclude") return false;
|
|
47
|
+
return parameter.visibility !== "readonly";
|
|
48
|
+
}
|
|
18
49
|
|
|
19
50
|
/**
|
|
20
51
|
* Minimal action/placement references the payload builder reads. Callers may
|
|
@@ -106,7 +137,9 @@ function defaultSurfaceFor(mode: RendererMode): string {
|
|
|
106
137
|
return mode === "public-submit" ? "public_submit" : "admin_preview";
|
|
107
138
|
}
|
|
108
139
|
|
|
109
|
-
function pickScope(
|
|
140
|
+
function pickScope(
|
|
141
|
+
targetConfig: Record<string, unknown>,
|
|
142
|
+
): Record<string, unknown> {
|
|
110
143
|
const scope: Record<string, unknown> = {};
|
|
111
144
|
for (const [key, value] of Object.entries(targetConfig)) {
|
|
112
145
|
if (RESERVED_TARGET_KEYS.has(key)) continue;
|
|
@@ -116,7 +149,17 @@ function pickScope(targetConfig: Record<string, unknown>): Record<string, unknow
|
|
|
116
149
|
}
|
|
117
150
|
|
|
118
151
|
export function buildActionPayload(args: BuildArgs): ActionPayload {
|
|
119
|
-
const {
|
|
152
|
+
const {
|
|
153
|
+
action,
|
|
154
|
+
placement,
|
|
155
|
+
targetConfig,
|
|
156
|
+
sourceSchema,
|
|
157
|
+
rawValues,
|
|
158
|
+
schemaVersion,
|
|
159
|
+
mode,
|
|
160
|
+
attachmentKeys,
|
|
161
|
+
attachmentsByParam,
|
|
162
|
+
} = args;
|
|
120
163
|
|
|
121
164
|
const sourceFromSchema = nullableString(sourceSchema.source);
|
|
122
165
|
const targetModel =
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type RelationshipMeta = {
|
|
2
|
+
relCode?: string;
|
|
3
|
+
targetTypeCode: string;
|
|
4
|
+
cardinality?: string;
|
|
5
|
+
};
|
|
6
|
+
type Entity = Record<string, unknown>;
|
|
7
|
+
/** The relationship meta, from the field summary's `relationship` or the
|
|
8
|
+
* derived-action param's `ui_schema.relationship`. Null for non-rel params. */
|
|
9
|
+
export declare function relationshipMeta(parameter: Entity): RelationshipMeta | null;
|
|
10
|
+
/** True for both rel lanes: the form-surface `relationship` summary and the
|
|
11
|
+
* action-lane `object_reference` param. */
|
|
12
|
+
export declare function isRelationshipParam(parameter: Entity): boolean;
|
|
13
|
+
/** Multi-value (chips) when the param says `multiple: true` (derived-action
|
|
14
|
+
* lane) or the relationship meta says many_to_many (form-surface lane). */
|
|
15
|
+
export declare function isMultiRelationship(parameter: Entity): boolean;
|
|
16
|
+
/** The entity type code the picker searches/hydrates against. */
|
|
17
|
+
export declare function relationshipTypeCode(parameter: Entity): string;
|
|
18
|
+
/** Normalize a rel value-bag entry onto the ID-list wire shape. */
|
|
19
|
+
export declare function normalizeRelIds(value: unknown): string[];
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// #34 (atelier#669 V1, operator decision D6) — cardinality-aware relationship
|
|
2
|
+
// helpers for ActionFormRenderer. Pure functions over the two param shapes the
|
|
3
|
+
// renderer meets:
|
|
4
|
+
// form-surface.v1 field summary — `type: "relationship"` with a top-level
|
|
5
|
+
// `relationship: {relCode, targetTypeCode, cardinality}`;
|
|
6
|
+
// derived-action param — `type: "object_reference"` with `object_type`,
|
|
7
|
+
// `multiple: true` for many_to_many, and the same relationship meta under
|
|
8
|
+
// `ui_schema.relationship` (see atelier derived_crud._member_to_param).
|
|
9
|
+
|
|
10
|
+
export type RelationshipMeta = {
|
|
11
|
+
relCode?: string;
|
|
12
|
+
targetTypeCode: string;
|
|
13
|
+
cardinality?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Entity = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
19
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? (value as Record<string, unknown>)
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The relationship meta, from the field summary's `relationship` or the
|
|
25
|
+
* derived-action param's `ui_schema.relationship`. Null for non-rel params. */
|
|
26
|
+
export function relationshipMeta(parameter: Entity): RelationshipMeta | null {
|
|
27
|
+
const carrier =
|
|
28
|
+
asRecord(parameter.relationship) ??
|
|
29
|
+
asRecord(asRecord(parameter.ui_schema)?.relationship);
|
|
30
|
+
if (!carrier) return null;
|
|
31
|
+
const targetTypeCode = String(
|
|
32
|
+
carrier.targetTypeCode ?? carrier.target_type_code ?? "",
|
|
33
|
+
);
|
|
34
|
+
if (!targetTypeCode) return null;
|
|
35
|
+
const relCode = carrier.relCode ?? carrier.rel_code;
|
|
36
|
+
const cardinality = carrier.cardinality;
|
|
37
|
+
return {
|
|
38
|
+
relCode: relCode ? String(relCode) : undefined,
|
|
39
|
+
targetTypeCode,
|
|
40
|
+
cardinality: cardinality ? String(cardinality) : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True for both rel lanes: the form-surface `relationship` summary and the
|
|
45
|
+
* action-lane `object_reference` param. */
|
|
46
|
+
export function isRelationshipParam(parameter: Entity): boolean {
|
|
47
|
+
const type = String(parameter.type ?? "");
|
|
48
|
+
return (
|
|
49
|
+
type === "relationship" ||
|
|
50
|
+
type === "object_reference" ||
|
|
51
|
+
relationshipMeta(parameter) !== null
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Multi-value (chips) when the param says `multiple: true` (derived-action
|
|
56
|
+
* lane) or the relationship meta says many_to_many (form-surface lane). */
|
|
57
|
+
export function isMultiRelationship(parameter: Entity): boolean {
|
|
58
|
+
if (parameter.multiple === true) return true;
|
|
59
|
+
return relationshipMeta(parameter)?.cardinality === "many_to_many";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The entity type code the picker searches/hydrates against. */
|
|
63
|
+
export function relationshipTypeCode(parameter: Entity): string {
|
|
64
|
+
return (
|
|
65
|
+
relationshipMeta(parameter)?.targetTypeCode ??
|
|
66
|
+
String(parameter.object_type ?? "")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Normalize a rel value-bag entry onto the ID-list wire shape. */
|
|
71
|
+
export function normalizeRelIds(value: unknown): string[] {
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
return value.map((entry) => String(entry ?? "")).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === "string" && value) return [value];
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
type Entity = Record<string, unknown>;
|
|
2
|
+
/** The render intent for a param: `widget` → `ui_schema.widget` → `type`. */
|
|
3
|
+
export declare function widgetKind(parameter: Entity): string;
|
|
4
|
+
/** Widget kinds that can never sit half-width in a two-column section —
|
|
5
|
+
* they carry their own wide UI. Mirrors the CRUD form-surface compilers'
|
|
6
|
+
* FULL_WIDTH_WIDGETS clamp (form-surface.ts / derived_crud.py). */
|
|
7
|
+
export declare const FULL_WIDTH_WIDGET_KINDS: Set<string>;
|
|
8
|
+
/** #37 — parse the platform's date-only wire shape (YYYY-MM-DD) onto a LOCAL
|
|
9
|
+
* calendar date. Never routes through Date.parse/toISOString: a local
|
|
10
|
+
* midnight serialized via UTC shifts a day across DST/negative-offset
|
|
11
|
+
* timezones — the classic off-by-one. */
|
|
12
|
+
export declare function dateOnlyToDate(value: unknown): Date | null;
|
|
13
|
+
/** #37 — serialize a picked Date back onto YYYY-MM-DD from its LOCAL
|
|
14
|
+
* calendar parts (no time component, no timezone shift). */
|
|
15
|
+
export declare function dateToDateOnly(date: Date): string;
|
|
16
|
+
export type GeoJsonPoint = {
|
|
17
|
+
type: "Point";
|
|
18
|
+
coordinates: [number, number];
|
|
19
|
+
};
|
|
20
|
+
/** #39 — hydrate the contract's GeoJSON value onto the MapPicker's
|
|
21
|
+
* [lon, lat] prop edge. Empty → no pin, no error. A non-Point (or
|
|
22
|
+
* malformed Point) fails LOUD: null coords + a named error the field
|
|
23
|
+
* renders — never a silently-empty map over real data. */
|
|
24
|
+
export declare function geoJsonPointToLonLat(value: unknown): {
|
|
25
|
+
coords: [number, number] | null;
|
|
26
|
+
error: string | null;
|
|
27
|
+
};
|
|
28
|
+
/** #39 — serialize a placed pin back onto the GeoJSON wire shape. */
|
|
29
|
+
export declare function lonLatToGeoJsonPoint(coords: [number, number]): GeoJsonPoint;
|
|
30
|
+
export type StoredFile = {
|
|
31
|
+
name: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
};
|
|
34
|
+
/** #40 — normalize a file param's stored-current value (the edit form's
|
|
35
|
+
* prefill) onto {name, url?}. Accepts a bare name, a URL string (name =
|
|
36
|
+
* last path segment), or a {name?, url?} descriptor. Anything else → null
|
|
37
|
+
* (no stored file → the legacy append-mode upload field). */
|
|
38
|
+
export declare function storedFileDescriptor(value: unknown): StoredFile | null;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// #36/#37 (atelier#669 V1) — widget-first dispatch vocabulary + date-only
|
|
2
|
+
// serialization for ActionFormRenderer. Pure functions.
|
|
3
|
+
//
|
|
4
|
+
// The form-surface.v1 field summary carries render intent in `widget`
|
|
5
|
+
// (derived from the field type + legal refinements, both compilers);
|
|
6
|
+
// derived-action params carry the same under `ui_schema.widget`. The
|
|
7
|
+
// renderer dispatches on this kind and falls back to the param `type`, so
|
|
8
|
+
// legacy action-lane params (no widget key) render byte-identically.
|
|
9
|
+
|
|
10
|
+
type Entity = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
/** The render intent for a param: `widget` → `ui_schema.widget` → `type`. */
|
|
13
|
+
export function widgetKind(parameter: Entity): string {
|
|
14
|
+
const direct = parameter.widget;
|
|
15
|
+
if (typeof direct === "string" && direct) return direct;
|
|
16
|
+
const uiSchema = parameter.ui_schema;
|
|
17
|
+
if (uiSchema && typeof uiSchema === "object" && !Array.isArray(uiSchema)) {
|
|
18
|
+
const widget = (uiSchema as Entity).widget;
|
|
19
|
+
if (typeof widget === "string" && widget) return widget;
|
|
20
|
+
}
|
|
21
|
+
return String(parameter.type ?? "string");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Widget kinds that can never sit half-width in a two-column section —
|
|
25
|
+
* they carry their own wide UI. Mirrors the CRUD form-surface compilers'
|
|
26
|
+
* FULL_WIDTH_WIDGETS clamp (form-surface.ts / derived_crud.py). */
|
|
27
|
+
export const FULL_WIDTH_WIDGET_KINDS = new Set([
|
|
28
|
+
"geo",
|
|
29
|
+
"file",
|
|
30
|
+
"json",
|
|
31
|
+
"textarea",
|
|
32
|
+
"geometry",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** #37 — parse the platform's date-only wire shape (YYYY-MM-DD) onto a LOCAL
|
|
36
|
+
* calendar date. Never routes through Date.parse/toISOString: a local
|
|
37
|
+
* midnight serialized via UTC shifts a day across DST/negative-offset
|
|
38
|
+
* timezones — the classic off-by-one. */
|
|
39
|
+
export function dateOnlyToDate(value: unknown): Date | null {
|
|
40
|
+
if (typeof value !== "string") return null;
|
|
41
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
|
42
|
+
if (!match) return null;
|
|
43
|
+
const [, year, month, day] = match;
|
|
44
|
+
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
45
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** #37 — serialize a picked Date back onto YYYY-MM-DD from its LOCAL
|
|
49
|
+
* calendar parts (no time component, no timezone shift). */
|
|
50
|
+
export function dateToDateOnly(date: Date): string {
|
|
51
|
+
const year = String(date.getFullYear()).padStart(4, "0");
|
|
52
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
53
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
54
|
+
return `${year}-${month}-${day}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type GeoJsonPoint = { type: "Point"; coordinates: [number, number] };
|
|
58
|
+
|
|
59
|
+
/** #39 — hydrate the contract's GeoJSON value onto the MapPicker's
|
|
60
|
+
* [lon, lat] prop edge. Empty → no pin, no error. A non-Point (or
|
|
61
|
+
* malformed Point) fails LOUD: null coords + a named error the field
|
|
62
|
+
* renders — never a silently-empty map over real data. */
|
|
63
|
+
export function geoJsonPointToLonLat(value: unknown): {
|
|
64
|
+
coords: [number, number] | null;
|
|
65
|
+
error: string | null;
|
|
66
|
+
} {
|
|
67
|
+
if (value === null || value === undefined || value === "") {
|
|
68
|
+
return { coords: null, error: null };
|
|
69
|
+
}
|
|
70
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
71
|
+
const row = value as Record<string, unknown>;
|
|
72
|
+
if (row.type === "Point" && Array.isArray(row.coordinates)) {
|
|
73
|
+
const [lon, lat] = row.coordinates as unknown[];
|
|
74
|
+
if (typeof lon === "number" && typeof lat === "number") {
|
|
75
|
+
return { coords: [lon, lat], error: null };
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
coords: null,
|
|
79
|
+
error: "Unsupported geometry: malformed Point coordinates",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
coords: null,
|
|
84
|
+
error: `Unsupported geometry: expected a GeoJSON Point, got ${String(row.type ?? "unknown")}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
coords: null,
|
|
89
|
+
error: "Unsupported geometry: expected a GeoJSON Point",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** #39 — serialize a placed pin back onto the GeoJSON wire shape. */
|
|
94
|
+
export function lonLatToGeoJsonPoint(coords: [number, number]): GeoJsonPoint {
|
|
95
|
+
return { type: "Point", coordinates: [coords[0], coords[1]] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type StoredFile = { name: string; url?: string };
|
|
99
|
+
|
|
100
|
+
/** #40 — normalize a file param's stored-current value (the edit form's
|
|
101
|
+
* prefill) onto {name, url?}. Accepts a bare name, a URL string (name =
|
|
102
|
+
* last path segment), or a {name?, url?} descriptor. Anything else → null
|
|
103
|
+
* (no stored file → the legacy append-mode upload field). */
|
|
104
|
+
export function storedFileDescriptor(value: unknown): StoredFile | null {
|
|
105
|
+
if (typeof value === "string") {
|
|
106
|
+
const trimmed = value.trim();
|
|
107
|
+
if (!trimmed) return null;
|
|
108
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
109
|
+
const segments = trimmed.split("?")[0].split("/").filter(Boolean);
|
|
110
|
+
return { name: segments[segments.length - 1] || trimmed, url: trimmed };
|
|
111
|
+
}
|
|
112
|
+
return { name: trimmed };
|
|
113
|
+
}
|
|
114
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
115
|
+
const row = value as Record<string, unknown>;
|
|
116
|
+
const url = typeof row.url === "string" && row.url ? row.url : undefined;
|
|
117
|
+
const name =
|
|
118
|
+
typeof row.name === "string" && row.name
|
|
119
|
+
? row.name
|
|
120
|
+
: url
|
|
121
|
+
? (url.split("?")[0].split("/").filter(Boolean).pop() ?? url)
|
|
122
|
+
: null;
|
|
123
|
+
if (!name) return null;
|
|
124
|
+
return url ? { name, url } : { name };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|