@aiaiai-pt/design-system 0.33.0 → 0.34.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/renderer/ResultsChartWidget.svelte +77 -0
- package/components/renderer/ResultsChartWidget.svelte.d.ts +4 -0
- package/components/renderer/StatGridWidget.svelte +33 -0
- package/components/renderer/StatGridWidget.svelte.d.ts +4 -0
- package/components/renderer/aggregate.d.ts +34 -0
- package/components/renderer/aggregate.ts +72 -0
- package/components/renderer/data-provider.d.ts +82 -0
- package/components/renderer/data-provider.ts +162 -0
- package/components/renderer/dispatch.d.ts +63 -0
- package/components/renderer/dispatch.ts +99 -0
- package/components/renderer/registry.d.ts +48 -0
- package/components/renderer/registry.ts +99 -0
- package/components/renderer/resolve-data.d.ts +109 -0
- package/components/renderer/resolve-data.ts +619 -0
- package/components/renderer/types.d.ts +164 -0
- package/components/renderer/types.ts +250 -0
- package/components/renderer/vote.d.ts +121 -0
- package/components/renderer/vote.ts +253 -0
- package/package.json +15 -3
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import ResultsChart from "../ResultsChart.svelte";
|
|
3
|
+
import type { WidgetProps } from "./types";
|
|
4
|
+
import { toRankedItems } from "./aggregate";
|
|
5
|
+
|
|
6
|
+
// `results-chart` widget (#105 Phase 4) — a bar chart over a public aggregate
|
|
7
|
+
// VIEW that SHIPS its data table (ARTE #3/#6: a chart is never the only
|
|
8
|
+
// encoding). Type-ranked on `kind: "aggregate"` (out-ranks the board default)
|
|
9
|
+
// so a `type: "results-chart"` block renders the chart while the bare kind
|
|
10
|
+
// renders the board. Vertical-agnostic: `props.label_field` / `value_field`
|
|
11
|
+
// name the view columns; `props.label_header` / `value_header` localise the
|
|
12
|
+
// table headers. Soft-empty on no rows (optional slot). Flush band.
|
|
13
|
+
let { data, props, ownsH1, locale }: WidgetProps = $props();
|
|
14
|
+
|
|
15
|
+
const rows = $derived(
|
|
16
|
+
data && typeof data === "object" && Array.isArray((data as { items?: unknown }).items)
|
|
17
|
+
? ((data as { items: Record<string, unknown>[] }).items)
|
|
18
|
+
: [],
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const labelField = $derived(
|
|
22
|
+
typeof props.label_field === "string" && props.label_field
|
|
23
|
+
? props.label_field
|
|
24
|
+
: "label",
|
|
25
|
+
);
|
|
26
|
+
const valueField = $derived(
|
|
27
|
+
typeof props.value_field === "string" && props.value_field
|
|
28
|
+
? props.value_field
|
|
29
|
+
: "value",
|
|
30
|
+
);
|
|
31
|
+
const limit = $derived(
|
|
32
|
+
typeof props.limit === "number" && props.limit > 0 ? props.limit : 0,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const items = $derived(
|
|
36
|
+
toRankedItems(rows, { labelField, valueField, limit }),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const heading = $derived(
|
|
40
|
+
typeof props.title === "string" && props.title ? props.title : "",
|
|
41
|
+
);
|
|
42
|
+
const caption = $derived(heading || "Results");
|
|
43
|
+
const labelHeader = $derived(
|
|
44
|
+
typeof props.label_header === "string" && props.label_header
|
|
45
|
+
? props.label_header
|
|
46
|
+
: "Item",
|
|
47
|
+
);
|
|
48
|
+
const valueHeader = $derived(
|
|
49
|
+
typeof props.value_header === "string" && props.value_header
|
|
50
|
+
? props.value_header
|
|
51
|
+
: "Value",
|
|
52
|
+
);
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#if items.length > 0}
|
|
56
|
+
<section class="widget-band">
|
|
57
|
+
{#if heading}
|
|
58
|
+
{#if ownsH1}<h1>{heading}</h1>{:else}<h2>{heading}</h2>{/if}
|
|
59
|
+
{/if}
|
|
60
|
+
<ResultsChart
|
|
61
|
+
{items}
|
|
62
|
+
{caption}
|
|
63
|
+
{labelHeader}
|
|
64
|
+
{valueHeader}
|
|
65
|
+
locale={locale ?? "en"}
|
|
66
|
+
/>
|
|
67
|
+
</section>
|
|
68
|
+
{/if}
|
|
69
|
+
|
|
70
|
+
<style>
|
|
71
|
+
.widget-band {
|
|
72
|
+
width: 100%;
|
|
73
|
+
max-width: var(--content-width-narrow);
|
|
74
|
+
margin-inline: auto;
|
|
75
|
+
padding-inline: var(--content-padding-x);
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import StatGrid from "../StatGrid.svelte";
|
|
3
|
+
import StatCard from "../StatCard.svelte";
|
|
4
|
+
import type { WidgetProps } from "./types";
|
|
5
|
+
|
|
6
|
+
// `kpi` widget — public stat grid (counts, KPIs). Adapts a list of stat
|
|
7
|
+
// items into DS StatCards inside a StatGrid. The public KPI read endpoint is
|
|
8
|
+
// not wired yet (resolveData fails closed for `kind: "kpi"`); registered here
|
|
9
|
+
// and fed once a slice adds the surface. Defensive over `data` (live) /
|
|
10
|
+
// `props` (seeded preview).
|
|
11
|
+
let { data, props }: WidgetProps = $props();
|
|
12
|
+
|
|
13
|
+
interface Stat {
|
|
14
|
+
label?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
variant?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const view = $derived((data ?? {}) as Record<string, unknown>);
|
|
20
|
+
const stats = $derived<Stat[]>(
|
|
21
|
+
(Array.isArray(view.stats)
|
|
22
|
+
? view.stats
|
|
23
|
+
: Array.isArray(props.stats)
|
|
24
|
+
? props.stats
|
|
25
|
+
: []) as Stat[],
|
|
26
|
+
);
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<StatGrid>
|
|
30
|
+
{#each stats as stat (stat.label)}
|
|
31
|
+
<StatCard label={stat.label} value={stat.value} variant={stat.variant} />
|
|
32
|
+
{/each}
|
|
33
|
+
</StatGrid>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate-view row shaping (#105 Phase 4) — PURE, vertical-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Turns the rows of a public aggregate VIEW (served by the BFF public-view lane,
|
|
5
|
+
* #250 M3 — `{ items: [{ <label_field>, <value_field>, … }] }`) into the
|
|
6
|
+
* `{ label, value }` shape the DS `RankingBoard` / `ResultsChart` render, sorted
|
|
7
|
+
* by value descending and optionally capped. Nothing here is civic-specific:
|
|
8
|
+
* the label/value columns are config (`label_field` / `value_field`), so any
|
|
9
|
+
* `group_by + count/sum` view (votes per proposal, reports per category,
|
|
10
|
+
* consumption per meter) becomes a chart/board from config alone.
|
|
11
|
+
*
|
|
12
|
+
* Coercion is deliberate and fail-soft: a missing/blank label falls back to the
|
|
13
|
+
* empty string (the widget decides whether to drop it), a non-numeric value
|
|
14
|
+
* coerces to 0 — an aggregate read never throws on a stray row.
|
|
15
|
+
*/
|
|
16
|
+
export interface RankedItem {
|
|
17
|
+
label: string;
|
|
18
|
+
value: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ToRankedOptions {
|
|
21
|
+
/** Key in each row holding the category label. Default `label`. */
|
|
22
|
+
labelField?: string;
|
|
23
|
+
/** Key holding the numeric aggregate. Default `value`. */
|
|
24
|
+
valueField?: string;
|
|
25
|
+
/** Cap the result to the top-N after sorting. 0/absent → no cap. */
|
|
26
|
+
limit?: number;
|
|
27
|
+
/** Drop rows whose label coerces to empty. Default true. */
|
|
28
|
+
dropBlankLabels?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Map raw view rows → sorted `{ label, value }`. Stable sort by value desc
|
|
32
|
+
* (ties keep input order); rows are never mutated.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toRankedItems(rows: ReadonlyArray<Record<string, unknown>>, opts?: ToRankedOptions): RankedItem[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate-view row shaping (#105 Phase 4) — PURE, vertical-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Turns the rows of a public aggregate VIEW (served by the BFF public-view lane,
|
|
5
|
+
* #250 M3 — `{ items: [{ <label_field>, <value_field>, … }] }`) into the
|
|
6
|
+
* `{ label, value }` shape the DS `RankingBoard` / `ResultsChart` render, sorted
|
|
7
|
+
* by value descending and optionally capped. Nothing here is civic-specific:
|
|
8
|
+
* the label/value columns are config (`label_field` / `value_field`), so any
|
|
9
|
+
* `group_by + count/sum` view (votes per proposal, reports per category,
|
|
10
|
+
* consumption per meter) becomes a chart/board from config alone.
|
|
11
|
+
*
|
|
12
|
+
* Coercion is deliberate and fail-soft: a missing/blank label falls back to the
|
|
13
|
+
* empty string (the widget decides whether to drop it), a non-numeric value
|
|
14
|
+
* coerces to 0 — an aggregate read never throws on a stray row.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface RankedItem {
|
|
18
|
+
label: string;
|
|
19
|
+
value: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToRankedOptions {
|
|
23
|
+
/** Key in each row holding the category label. Default `label`. */
|
|
24
|
+
labelField?: string;
|
|
25
|
+
/** Key holding the numeric aggregate. Default `value`. */
|
|
26
|
+
valueField?: string;
|
|
27
|
+
/** Cap the result to the top-N after sorting. 0/absent → no cap. */
|
|
28
|
+
limit?: number;
|
|
29
|
+
/** Drop rows whose label coerces to empty. Default true. */
|
|
30
|
+
dropBlankLabels?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toNumber(value: unknown): number {
|
|
34
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
35
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
36
|
+
const n = Number(value);
|
|
37
|
+
return Number.isFinite(n) ? n : 0;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Map raw view rows → sorted `{ label, value }`. Stable sort by value desc
|
|
44
|
+
* (ties keep input order); rows are never mutated.
|
|
45
|
+
*/
|
|
46
|
+
export function toRankedItems(
|
|
47
|
+
rows: ReadonlyArray<Record<string, unknown>>,
|
|
48
|
+
opts: ToRankedOptions = {},
|
|
49
|
+
): RankedItem[] {
|
|
50
|
+
const labelField = opts.labelField || "label";
|
|
51
|
+
const valueField = opts.valueField || "value";
|
|
52
|
+
const dropBlank = opts.dropBlankLabels !== false;
|
|
53
|
+
|
|
54
|
+
const mapped = rows.map((row) => ({
|
|
55
|
+
label: row[labelField] == null ? "" : String(row[labelField]),
|
|
56
|
+
value: toNumber(row[valueField]),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const kept = dropBlank ? mapped.filter((it) => it.label !== "") : mapped;
|
|
60
|
+
|
|
61
|
+
// Stable desc sort: decorate with the original index so equal values keep
|
|
62
|
+
// their input order (Array.prototype.sort is spec-stable, but the index tie-
|
|
63
|
+
// break documents the intent and is robust to value-equality edge cases).
|
|
64
|
+
const sorted = kept
|
|
65
|
+
.map((it, i) => ({ it, i }))
|
|
66
|
+
.sort((a, b) => b.it.value - a.it.value || a.i - b.i)
|
|
67
|
+
.map(({ it }) => it);
|
|
68
|
+
|
|
69
|
+
return opts.limit && opts.limit > 0
|
|
70
|
+
? sorted.slice(0, Math.floor(opts.limit))
|
|
71
|
+
: sorted;
|
|
72
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataProvider — the host-injected data transport seam (#492 P0).
|
|
3
|
+
*
|
|
4
|
+
* Each host (portal, admin) owns its own DataProvider. The portal constructs a
|
|
5
|
+
* `createPublicDataProvider` that routes through the auth-optional `/bff` proxy.
|
|
6
|
+
* A future admin host constructs its own provider over the RLS-authenticated
|
|
7
|
+
* entity-proxy lane. `resolveData` depends only on this interface, never on
|
|
8
|
+
* the proxy prefix or the tenant/app segment directly.
|
|
9
|
+
*/
|
|
10
|
+
import type { Binding } from "./types";
|
|
11
|
+
import type { FetchLike } from "./resolve-data";
|
|
12
|
+
import type { VoteReadConfig } from "./vote";
|
|
13
|
+
export interface DataUrlOpts {
|
|
14
|
+
/** Calendar window injected from `now` (73e) — applied only for the `calendar` kind. */
|
|
15
|
+
calendarWindow?: {
|
|
16
|
+
start: string;
|
|
17
|
+
end: string;
|
|
18
|
+
};
|
|
19
|
+
/** URL facet/search params forwarded from the page URL (#308) — applied for `list`/`map`. */
|
|
20
|
+
filterParams?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* SECURITY INVARIANT (never remove this comment):
|
|
24
|
+
* Each host owns its own DataProvider and its own security boundary. The
|
|
25
|
+
* portal's DataProvider routes through the public /bff proxy (auth-optional,
|
|
26
|
+
* SSRF-pinned to /{app}/public/). A future admin DataProvider routes through
|
|
27
|
+
* the RLS-authenticated entity-proxy lane. NEVER share one transport across
|
|
28
|
+
* both hosts. NEVER let a public widget read through an authed token, nor
|
|
29
|
+
* expose the authed lane through the public proxy. A "simplification" that
|
|
30
|
+
* collapses the two providers is a tenant/RLS leak.
|
|
31
|
+
*/
|
|
32
|
+
export interface DataProvider {
|
|
33
|
+
/**
|
|
34
|
+
* The raw data path for the binding, with calendar window and filter params
|
|
35
|
+
* applied. Returns `null` for unmappable bindings (caller fails closed).
|
|
36
|
+
* The path is the PAGEABLE surface: widgets re-fetch it client-side with
|
|
37
|
+
* `?after=` for "load more". It does NOT include the proxy prefix — the
|
|
38
|
+
* provider's `fetch` adds the prefix on the outbound call.
|
|
39
|
+
*/
|
|
40
|
+
dataUrl(binding: Binding, opts?: DataUrlOpts): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The entity schema path for the binding. Returns `null` for kinds that
|
|
43
|
+
* carry no schema (content, map, calendar, …). The path does NOT include
|
|
44
|
+
* the proxy prefix.
|
|
45
|
+
*/
|
|
46
|
+
schemaUrl(binding: Binding): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* The authed account lane URL for the given path segment.
|
|
49
|
+
* Returns `/me/{segment}` — NO `/bff` prefix. This is a different security
|
|
50
|
+
* boundary (require_auth + OWN-scoped) from the public `/bff` surface.
|
|
51
|
+
*/
|
|
52
|
+
accountUrl(segment: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* The option-view read URL (for subscription scope dropdowns, #252).
|
|
55
|
+
* Returns the FULL URL including the proxy prefix.
|
|
56
|
+
*/
|
|
57
|
+
optionViewUrl(view: string, limit: number): string;
|
|
58
|
+
/** Edition read path for the `vote` ballot composer. Full URL. */
|
|
59
|
+
voteEditionUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
60
|
+
/** Options read path for the `vote` ballot composer. Full URL. */
|
|
61
|
+
voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
62
|
+
/** The fetch transport. Accepts any URL returned by the methods above. */
|
|
63
|
+
fetch: FetchLike;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Construct the portal's public DataProvider.
|
|
67
|
+
*
|
|
68
|
+
* Reproduces today's `resolveData` URL behavior byte-for-byte:
|
|
69
|
+
* - `dataUrl` / `schemaUrl` return the raw `/{app}/public/...` path (no
|
|
70
|
+
* `/bff` prefix). The provider's `fetch` adds `/bff` on the outbound call
|
|
71
|
+
* so the raw path is also the pageable `dataPath` the widgets store.
|
|
72
|
+
* - `accountUrl` returns `/me/{segment}` — the authed lane, never proxied
|
|
73
|
+
* through `/bff`. The provider's `fetch` passes `/me/...` through as-is.
|
|
74
|
+
* - `optionViewUrl`, `voteEditionUrl`, `voteOptionsUrl` return the full URL
|
|
75
|
+
* already including `/bff`. The provider's `fetch` passes them through.
|
|
76
|
+
*
|
|
77
|
+
* The `fetch` wrapper decides by prefix: `/bff/` and `/me/` are passed
|
|
78
|
+
* through unchanged; anything else (raw `/{app}/public/...` paths) gets
|
|
79
|
+
* `/bff` prepended. This keeps the `fetch` self-contained in the provider —
|
|
80
|
+
* `resolveData` never sees or constructs a proxy prefix.
|
|
81
|
+
*/
|
|
82
|
+
export declare function createPublicDataProvider(app: string, fetchImpl: FetchLike): DataProvider;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataProvider — the host-injected data transport seam (#492 P0).
|
|
3
|
+
*
|
|
4
|
+
* Each host (portal, admin) owns its own DataProvider. The portal constructs a
|
|
5
|
+
* `createPublicDataProvider` that routes through the auth-optional `/bff` proxy.
|
|
6
|
+
* A future admin host constructs its own provider over the RLS-authenticated
|
|
7
|
+
* entity-proxy lane. `resolveData` depends only on this interface, never on
|
|
8
|
+
* the proxy prefix or the tenant/app segment directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Binding } from "./types";
|
|
12
|
+
import type { FetchLike } from "./resolve-data";
|
|
13
|
+
import type { VoteReadConfig } from "./vote";
|
|
14
|
+
import {
|
|
15
|
+
bffPathForBinding,
|
|
16
|
+
schemaPathForBinding,
|
|
17
|
+
appendQuery,
|
|
18
|
+
} from "./resolve-data";
|
|
19
|
+
import { editionReadPath, optionsReadPath } from "./vote";
|
|
20
|
+
|
|
21
|
+
export interface DataUrlOpts {
|
|
22
|
+
/** Calendar window injected from `now` (73e) — applied only for the `calendar` kind. */
|
|
23
|
+
calendarWindow?: { start: string; end: string };
|
|
24
|
+
/** URL facet/search params forwarded from the page URL (#308) — applied for `list`/`map`. */
|
|
25
|
+
filterParams?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SECURITY INVARIANT (never remove this comment):
|
|
30
|
+
* Each host owns its own DataProvider and its own security boundary. The
|
|
31
|
+
* portal's DataProvider routes through the public /bff proxy (auth-optional,
|
|
32
|
+
* SSRF-pinned to /{app}/public/). A future admin DataProvider routes through
|
|
33
|
+
* the RLS-authenticated entity-proxy lane. NEVER share one transport across
|
|
34
|
+
* both hosts. NEVER let a public widget read through an authed token, nor
|
|
35
|
+
* expose the authed lane through the public proxy. A "simplification" that
|
|
36
|
+
* collapses the two providers is a tenant/RLS leak.
|
|
37
|
+
*/
|
|
38
|
+
export interface DataProvider {
|
|
39
|
+
/**
|
|
40
|
+
* The raw data path for the binding, with calendar window and filter params
|
|
41
|
+
* applied. Returns `null` for unmappable bindings (caller fails closed).
|
|
42
|
+
* The path is the PAGEABLE surface: widgets re-fetch it client-side with
|
|
43
|
+
* `?after=` for "load more". It does NOT include the proxy prefix — the
|
|
44
|
+
* provider's `fetch` adds the prefix on the outbound call.
|
|
45
|
+
*/
|
|
46
|
+
dataUrl(binding: Binding, opts?: DataUrlOpts): string | null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The entity schema path for the binding. Returns `null` for kinds that
|
|
50
|
+
* carry no schema (content, map, calendar, …). The path does NOT include
|
|
51
|
+
* the proxy prefix.
|
|
52
|
+
*/
|
|
53
|
+
schemaUrl(binding: Binding): string | null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The authed account lane URL for the given path segment.
|
|
57
|
+
* Returns `/me/{segment}` — NO `/bff` prefix. This is a different security
|
|
58
|
+
* boundary (require_auth + OWN-scoped) from the public `/bff` surface.
|
|
59
|
+
*/
|
|
60
|
+
accountUrl(segment: string): string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The option-view read URL (for subscription scope dropdowns, #252).
|
|
64
|
+
* Returns the FULL URL including the proxy prefix.
|
|
65
|
+
*/
|
|
66
|
+
optionViewUrl(view: string, limit: number): string;
|
|
67
|
+
|
|
68
|
+
/** Edition read path for the `vote` ballot composer. Full URL. */
|
|
69
|
+
voteEditionUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
70
|
+
|
|
71
|
+
/** Options read path for the `vote` ballot composer. Full URL. */
|
|
72
|
+
voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
73
|
+
|
|
74
|
+
/** The fetch transport. Accepts any URL returned by the methods above. */
|
|
75
|
+
fetch: FetchLike;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function enc(value: string): string {
|
|
79
|
+
return encodeURIComponent(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Construct the portal's public DataProvider.
|
|
84
|
+
*
|
|
85
|
+
* Reproduces today's `resolveData` URL behavior byte-for-byte:
|
|
86
|
+
* - `dataUrl` / `schemaUrl` return the raw `/{app}/public/...` path (no
|
|
87
|
+
* `/bff` prefix). The provider's `fetch` adds `/bff` on the outbound call
|
|
88
|
+
* so the raw path is also the pageable `dataPath` the widgets store.
|
|
89
|
+
* - `accountUrl` returns `/me/{segment}` — the authed lane, never proxied
|
|
90
|
+
* through `/bff`. The provider's `fetch` passes `/me/...` through as-is.
|
|
91
|
+
* - `optionViewUrl`, `voteEditionUrl`, `voteOptionsUrl` return the full URL
|
|
92
|
+
* already including `/bff`. The provider's `fetch` passes them through.
|
|
93
|
+
*
|
|
94
|
+
* The `fetch` wrapper decides by prefix: `/bff/` and `/me/` are passed
|
|
95
|
+
* through unchanged; anything else (raw `/{app}/public/...` paths) gets
|
|
96
|
+
* `/bff` prepended. This keeps the `fetch` self-contained in the provider —
|
|
97
|
+
* `resolveData` never sees or constructs a proxy prefix.
|
|
98
|
+
*/
|
|
99
|
+
export function createPublicDataProvider(
|
|
100
|
+
app: string,
|
|
101
|
+
fetchImpl: FetchLike,
|
|
102
|
+
): DataProvider {
|
|
103
|
+
/**
|
|
104
|
+
* Portal fetch transport. Paths that already carry their proxy prefix
|
|
105
|
+
* (`/bff/...` for the public lane, `/me/...` for the authed account lane)
|
|
106
|
+
* are forwarded unchanged. Raw `/{app}/public/...` paths (returned by
|
|
107
|
+
* `dataUrl` and `schemaUrl`) are proxied through `/bff`.
|
|
108
|
+
*/
|
|
109
|
+
const fetch: FetchLike = (url: string) => {
|
|
110
|
+
if (url.startsWith("/bff/") || url.startsWith("/me/")) {
|
|
111
|
+
return fetchImpl(url);
|
|
112
|
+
}
|
|
113
|
+
return fetchImpl(`/bff${url}`);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
dataUrl(binding: Binding, opts?: DataUrlOpts): string | null {
|
|
118
|
+
const path = bffPathForBinding(binding, app);
|
|
119
|
+
if (path === null) return null;
|
|
120
|
+
let url = path;
|
|
121
|
+
// Calendar requires start/end (73e); the window is pre-computed in
|
|
122
|
+
// resolveData from `now` and passed in via opts.
|
|
123
|
+
if (binding.kind === "calendar" && opts?.calendarWindow) {
|
|
124
|
+
const { start, end } = opts.calendarWindow;
|
|
125
|
+
url = `${path}?start=${start}&end=${end}`;
|
|
126
|
+
}
|
|
127
|
+
// Forward URL facet/search params (#308) for list + map feeds so the
|
|
128
|
+
// SSR result is server-FILTERED. The BFF drops undeclared params.
|
|
129
|
+
if (
|
|
130
|
+
(binding.kind === "list" || binding.kind === "map") &&
|
|
131
|
+
opts?.filterParams
|
|
132
|
+
) {
|
|
133
|
+
url = appendQuery(url, opts.filterParams);
|
|
134
|
+
}
|
|
135
|
+
return url;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
schemaUrl(binding: Binding): string | null {
|
|
139
|
+
return schemaPathForBinding(binding, app);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
accountUrl(segment: string): string {
|
|
143
|
+
return `/me/${segment}`;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
optionViewUrl(view: string, limit: number): string {
|
|
147
|
+
return `/bff/${enc(app)}/public/${enc(view)}?limit=${limit}`;
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
voteEditionUrl(cfg: VoteReadConfig, editionId: string): string {
|
|
151
|
+
// Delegates to the unchanged vote.ts helper — guarantees byte-identical
|
|
152
|
+
// vote URLs (vote.ts must NOT be modified, spec §P0).
|
|
153
|
+
return editionReadPath(app, cfg, editionId);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string {
|
|
157
|
+
return optionsReadPath(app, cfg, editionId);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
fetch,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget dispatcher — tester/priority, not a flat map (D4, JSONForms model).
|
|
3
|
+
*
|
|
4
|
+
* Generalises the admin app's XSS-hardened layout registry
|
|
5
|
+
* (`admin/src/lib/renderer-layouts/resolve.ts`) from form-row LayoutKeys to
|
|
6
|
+
* widget `kind`s. Each entry carries a `tester(binding, schema) → score`; the
|
|
7
|
+
* highest score wins. A more specific tester (e.g. "list of `occurrence`")
|
|
8
|
+
* outranks the generic "any list" and overrides one widget for one entity
|
|
9
|
+
* WITHOUT editing the catalog (open/closed).
|
|
10
|
+
*
|
|
11
|
+
* The ranking core here is generic over the payload `P` so it stays
|
|
12
|
+
* component-free and fully unit/mutation-testable; the real registry (73b)
|
|
13
|
+
* instantiates it with Svelte `Component`s.
|
|
14
|
+
*
|
|
15
|
+
* TH-08 (R-SEC-07) preserved: the resolved `key` is a known-good literal from
|
|
16
|
+
* the matched entry — the operator's raw `binding.kind`/`entity`/block `type`
|
|
17
|
+
* is NEVER returned as the key, so it never reaches `class`/`data-*`/`style`.
|
|
18
|
+
*/
|
|
19
|
+
import type { Binding, Block, OntologySchema } from "./types";
|
|
20
|
+
/** Tester sentinel — "this widget does not apply to this binding". */
|
|
21
|
+
export declare const NOT_APPLICABLE = -1;
|
|
22
|
+
/**
|
|
23
|
+
* A tester ranks a registry entry against a binding (+ optional schema and the
|
|
24
|
+
* block's authoring `type` hint). JSONForms model: testers see the full
|
|
25
|
+
* element, so a type-specific widget (e.g. `filter-bar`) can out-rank the
|
|
26
|
+
* generic kind widget (`entity-list`) for the SAME `binding.kind` without
|
|
27
|
+
* editing the kind entry. `type` is UNTRUSTED — it only influences ranking; the
|
|
28
|
+
* resolved `key` still comes from the matched entry (TH-08), never from `type`.
|
|
29
|
+
*/
|
|
30
|
+
export type WidgetTester = (binding: Binding, schema: OntologySchema | null, type?: string) => number;
|
|
31
|
+
export interface RegistryEntry<P> {
|
|
32
|
+
/** Known-good literal. Safe to render as `data-widget={key}` (TH-08). */
|
|
33
|
+
key: string;
|
|
34
|
+
payload: P;
|
|
35
|
+
tester: WidgetTester;
|
|
36
|
+
}
|
|
37
|
+
export interface Match<P> {
|
|
38
|
+
key: string;
|
|
39
|
+
payload: P;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run every tester against the binding; the highest applicable score wins.
|
|
43
|
+
* Ties resolve to the first-registered entry (stable, deterministic). Returns
|
|
44
|
+
* `null` when no tester is applicable — the caller fails closed per blast
|
|
45
|
+
* radius; nothing about the binding is interpolated.
|
|
46
|
+
*/
|
|
47
|
+
export declare function selectEntry<P>(entries: ReadonlyArray<RegistryEntry<P>>, binding: Binding, schema: OntologySchema | null, type?: string): Match<P> | null;
|
|
48
|
+
export type RenderDecision = {
|
|
49
|
+
render: "widget";
|
|
50
|
+
} | {
|
|
51
|
+
render: "empty";
|
|
52
|
+
} | {
|
|
53
|
+
render: "error";
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Fail-closed per blast radius (§14.8). Given whether a widget matched AND
|
|
57
|
+
* whether its data resolved, decide how the slot renders:
|
|
58
|
+
* - matched + data ok → render the widget
|
|
59
|
+
* - failed, optional slot → soft-empty (render nothing)
|
|
60
|
+
* - failed, structural slot → visible error (MUST NOT silently vanish)
|
|
61
|
+
* The operator's raw `type`/`kind` is never interpolated in any branch.
|
|
62
|
+
*/
|
|
63
|
+
export declare function decideRender(block: Pick<Block, "importance">, matched: boolean, dataOk: boolean): RenderDecision;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget dispatcher — tester/priority, not a flat map (D4, JSONForms model).
|
|
3
|
+
*
|
|
4
|
+
* Generalises the admin app's XSS-hardened layout registry
|
|
5
|
+
* (`admin/src/lib/renderer-layouts/resolve.ts`) from form-row LayoutKeys to
|
|
6
|
+
* widget `kind`s. Each entry carries a `tester(binding, schema) → score`; the
|
|
7
|
+
* highest score wins. A more specific tester (e.g. "list of `occurrence`")
|
|
8
|
+
* outranks the generic "any list" and overrides one widget for one entity
|
|
9
|
+
* WITHOUT editing the catalog (open/closed).
|
|
10
|
+
*
|
|
11
|
+
* The ranking core here is generic over the payload `P` so it stays
|
|
12
|
+
* component-free and fully unit/mutation-testable; the real registry (73b)
|
|
13
|
+
* instantiates it with Svelte `Component`s.
|
|
14
|
+
*
|
|
15
|
+
* TH-08 (R-SEC-07) preserved: the resolved `key` is a known-good literal from
|
|
16
|
+
* the matched entry — the operator's raw `binding.kind`/`entity`/block `type`
|
|
17
|
+
* is NEVER returned as the key, so it never reaches `class`/`data-*`/`style`.
|
|
18
|
+
*/
|
|
19
|
+
import type { Binding, Block, OntologySchema } from "./types";
|
|
20
|
+
|
|
21
|
+
/** Tester sentinel — "this widget does not apply to this binding". */
|
|
22
|
+
export const NOT_APPLICABLE = -1;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A tester ranks a registry entry against a binding (+ optional schema and the
|
|
26
|
+
* block's authoring `type` hint). JSONForms model: testers see the full
|
|
27
|
+
* element, so a type-specific widget (e.g. `filter-bar`) can out-rank the
|
|
28
|
+
* generic kind widget (`entity-list`) for the SAME `binding.kind` without
|
|
29
|
+
* editing the kind entry. `type` is UNTRUSTED — it only influences ranking; the
|
|
30
|
+
* resolved `key` still comes from the matched entry (TH-08), never from `type`.
|
|
31
|
+
*/
|
|
32
|
+
export type WidgetTester = (
|
|
33
|
+
binding: Binding,
|
|
34
|
+
schema: OntologySchema | null,
|
|
35
|
+
type?: string,
|
|
36
|
+
) => number;
|
|
37
|
+
|
|
38
|
+
export interface RegistryEntry<P> {
|
|
39
|
+
/** Known-good literal. Safe to render as `data-widget={key}` (TH-08). */
|
|
40
|
+
key: string;
|
|
41
|
+
payload: P;
|
|
42
|
+
tester: WidgetTester;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Match<P> {
|
|
46
|
+
key: string;
|
|
47
|
+
payload: P;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run every tester against the binding; the highest applicable score wins.
|
|
52
|
+
* Ties resolve to the first-registered entry (stable, deterministic). Returns
|
|
53
|
+
* `null` when no tester is applicable — the caller fails closed per blast
|
|
54
|
+
* radius; nothing about the binding is interpolated.
|
|
55
|
+
*/
|
|
56
|
+
export function selectEntry<P>(
|
|
57
|
+
entries: ReadonlyArray<RegistryEntry<P>>,
|
|
58
|
+
binding: Binding,
|
|
59
|
+
schema: OntologySchema | null,
|
|
60
|
+
type?: string,
|
|
61
|
+
): Match<P> | null {
|
|
62
|
+
let best: RegistryEntry<P> | null = null;
|
|
63
|
+
let bestScore: number = NOT_APPLICABLE;
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const score = entry.tester(binding, schema, type);
|
|
66
|
+
// Strictly greater → first registered wins ties.
|
|
67
|
+
if (score > bestScore) {
|
|
68
|
+
bestScore = score;
|
|
69
|
+
best = entry;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (best === null) return null;
|
|
73
|
+
// TH-08: key from the matched entry, never from the binding/block.
|
|
74
|
+
return { key: best.key, payload: best.payload };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type RenderDecision =
|
|
78
|
+
| { render: "widget" }
|
|
79
|
+
| { render: "empty" }
|
|
80
|
+
| { render: "error" };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fail-closed per blast radius (§14.8). Given whether a widget matched AND
|
|
84
|
+
* whether its data resolved, decide how the slot renders:
|
|
85
|
+
* - matched + data ok → render the widget
|
|
86
|
+
* - failed, optional slot → soft-empty (render nothing)
|
|
87
|
+
* - failed, structural slot → visible error (MUST NOT silently vanish)
|
|
88
|
+
* The operator's raw `type`/`kind` is never interpolated in any branch.
|
|
89
|
+
*/
|
|
90
|
+
export function decideRender(
|
|
91
|
+
block: Pick<Block, "importance">,
|
|
92
|
+
matched: boolean,
|
|
93
|
+
dataOk: boolean,
|
|
94
|
+
): RenderDecision {
|
|
95
|
+
if (matched && dataOk) return { render: "widget" };
|
|
96
|
+
return block.importance === "structural"
|
|
97
|
+
? { render: "error" }
|
|
98
|
+
: { render: "empty" };
|
|
99
|
+
}
|