@aiaiai-pt/design-system 0.33.0 → 0.35.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/EChart.svelte +266 -0
- package/components/EChart.svelte.d.ts +63 -0
- package/components/index.js +3 -0
- package/components/renderer/EChartWidget.svelte +78 -0
- package/components/renderer/EChartWidget.svelte.d.ts +4 -0
- 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 +101 -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 +17 -3
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voting-ballot helpers (#105) — pure read-path builders + ballot mapping + the
|
|
3
|
+
* public-submit cast, kept OUT of the `.svelte` widget so (a) the HTTP verbs and
|
|
4
|
+
* header literals aren't mangled by Wuchale's `.svelte` string extraction, and
|
|
5
|
+
* (b) the shaping/cast is unit-testable without mounting a component.
|
|
6
|
+
*
|
|
7
|
+
* The ballot COMPOSES two existing PUBLIC reads (no new backend): the edition
|
|
8
|
+
* row — its `current_phase` decides open/closed — and the votable-entity list
|
|
9
|
+
* (the finalists, as radio options). The cast reuses the citizen public-submit
|
|
10
|
+
* lane (the vote action's placement) — the SAME auth-optional `/bff` surface
|
|
11
|
+
* `submit_proposal` uses, so the signed-in citizen's Bearer rides along and the
|
|
12
|
+
* server stamps the one-vote pseudonym (anonymous is refused server-side).
|
|
13
|
+
*
|
|
14
|
+
* Vertical-agnostic: every entity / field / status / phase literal is block
|
|
15
|
+
* DATA (the civic page supplies proposal / proposal_edition / in_voting /
|
|
16
|
+
* voting / title), so the widget drops into any vertical with a vote action.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Path-only read fetch (the resolve-data read shape). */
|
|
20
|
+
export type ReadFetch = (
|
|
21
|
+
path: string,
|
|
22
|
+
) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
|
|
23
|
+
|
|
24
|
+
/** Write fetch for the cast POST (the browser `fetch` fits). */
|
|
25
|
+
export type WriteFetch = (
|
|
26
|
+
path: string,
|
|
27
|
+
init: { method: string; headers: Record<string, string>; body: string },
|
|
28
|
+
) => Promise<{ ok: boolean; status: number }>;
|
|
29
|
+
|
|
30
|
+
const JSON_HEADERS: Record<string, string> = {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function enc(value: string): string {
|
|
35
|
+
return encodeURIComponent(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface VoteOption {
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The resolved ballot a `vote` block hands the widget. */
|
|
44
|
+
export interface Ballot {
|
|
45
|
+
options: VoteOption[];
|
|
46
|
+
/** Voting is NOT open on this edition (current_phase ≠ the open phase). */
|
|
47
|
+
closed: boolean;
|
|
48
|
+
/** The edition id the cast writes back (carried so the widget needn't re-derive). */
|
|
49
|
+
edition: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How to SOURCE the ballot from the public surface — all DATA (the votable
|
|
54
|
+
* entity type comes from the binding; the rest from props), so no civic literal
|
|
55
|
+
* lives in code. Generic facet/field names default to the platform conventions.
|
|
56
|
+
*/
|
|
57
|
+
export interface VoteReadConfig {
|
|
58
|
+
/** Votable entity type (binding.entity), e.g. `proposal`. */
|
|
59
|
+
entity: string;
|
|
60
|
+
/** Edition entity type, e.g. `proposal_edition`. */
|
|
61
|
+
editionEntity: string;
|
|
62
|
+
/** Status value marking a votable option, e.g. `in_voting`. */
|
|
63
|
+
optionStatus: string;
|
|
64
|
+
/** Facet param carrying the status filter (default `status`). */
|
|
65
|
+
statusFacet: string;
|
|
66
|
+
/** Facet param carrying the edition id (default `edition.id`). */
|
|
67
|
+
editionFacet: string;
|
|
68
|
+
/** Option label field on the entity row (default `title`). */
|
|
69
|
+
labelField: string;
|
|
70
|
+
/** Edition phase field (default `current_phase`). */
|
|
71
|
+
phaseField: string;
|
|
72
|
+
/** Phase value that means voting is OPEN, e.g. `voting`. */
|
|
73
|
+
openPhase: string;
|
|
74
|
+
/** Max options to fetch (default 50). */
|
|
75
|
+
limit: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where the cast POSTs — the placement + the vote entity's field names. DATA. */
|
|
79
|
+
export interface VoteCastConfig {
|
|
80
|
+
app: string;
|
|
81
|
+
/** Citizen public-submit placement key, e.g. `civic-cast-vote`. */
|
|
82
|
+
placementKey: string;
|
|
83
|
+
/** Envelope key = the created entity type, e.g. `proposal_vote`. */
|
|
84
|
+
voteModel: string;
|
|
85
|
+
/** Field on the vote entity pointing at the chosen option, e.g. `proposal`. */
|
|
86
|
+
voteField: string;
|
|
87
|
+
/** Field on the vote entity pointing at the edition, e.g. `edition`. */
|
|
88
|
+
editionField: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function strProp(value: unknown): string | undefined {
|
|
92
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function strField(row: unknown, key: string): string | undefined {
|
|
96
|
+
if (row === null || typeof row !== "object") return undefined;
|
|
97
|
+
const v = (row as Record<string, unknown>)[key];
|
|
98
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the read config from the block (entity on the binding, the rest in
|
|
103
|
+
* props). Returns null when a REQUIRED knob is missing — resolveData then fails
|
|
104
|
+
* the block closed rather than fetch a half-configured ballot.
|
|
105
|
+
*/
|
|
106
|
+
export function voteReadConfig(
|
|
107
|
+
entity: string | null | undefined,
|
|
108
|
+
props: Record<string, unknown> | undefined,
|
|
109
|
+
): VoteReadConfig | null {
|
|
110
|
+
const p = props ?? {};
|
|
111
|
+
const ent = strProp(entity);
|
|
112
|
+
const editionEntity = strProp(p.edition_entity);
|
|
113
|
+
const optionStatus = strProp(p.option_status);
|
|
114
|
+
const openPhase = strProp(p.open_phase);
|
|
115
|
+
if (!ent || !editionEntity || !optionStatus || !openPhase) return null;
|
|
116
|
+
const limit =
|
|
117
|
+
typeof p.limit === "number" && p.limit > 0 ? Math.floor(p.limit) : 50;
|
|
118
|
+
return {
|
|
119
|
+
entity: ent,
|
|
120
|
+
editionEntity,
|
|
121
|
+
optionStatus,
|
|
122
|
+
statusFacet: strProp(p.status_facet) ?? "status",
|
|
123
|
+
editionFacet: strProp(p.edition_facet) ?? "edition.id",
|
|
124
|
+
labelField: strProp(p.label_field) ?? "title",
|
|
125
|
+
phaseField: strProp(p.phase_field) ?? "current_phase",
|
|
126
|
+
openPhase,
|
|
127
|
+
limit,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Build the cast config from props + the widget's app. Null if misconfigured. */
|
|
132
|
+
export function voteCastConfig(
|
|
133
|
+
app: string | undefined,
|
|
134
|
+
props: Record<string, unknown> | undefined,
|
|
135
|
+
): VoteCastConfig | null {
|
|
136
|
+
const p = props ?? {};
|
|
137
|
+
const a = strProp(app);
|
|
138
|
+
const placementKey = strProp(p.placement_key);
|
|
139
|
+
const voteModel = strProp(p.vote_model);
|
|
140
|
+
const voteField = strProp(p.vote_field);
|
|
141
|
+
const editionField = strProp(p.edition_field);
|
|
142
|
+
if (!a || !placementKey || !voteModel || !voteField || !editionField) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return { app: a, placementKey, voteModel, voteField, editionField };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The edition read — its `current_phase` decides open/closed. */
|
|
149
|
+
export function editionReadPath(
|
|
150
|
+
app: string,
|
|
151
|
+
cfg: VoteReadConfig,
|
|
152
|
+
editionId: string,
|
|
153
|
+
): string {
|
|
154
|
+
return `/bff/${enc(app)}/public/${enc(cfg.editionEntity)}/${enc(editionId)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The votable-options read — the finalists for this edition, by status facet. */
|
|
158
|
+
export function optionsReadPath(
|
|
159
|
+
app: string,
|
|
160
|
+
cfg: VoteReadConfig,
|
|
161
|
+
editionId: string,
|
|
162
|
+
): string {
|
|
163
|
+
const params = new URLSearchParams();
|
|
164
|
+
params.set(cfg.statusFacet, cfg.optionStatus);
|
|
165
|
+
params.set(cfg.editionFacet, editionId);
|
|
166
|
+
params.set("limit", String(cfg.limit));
|
|
167
|
+
return `/bff/${enc(app)}/public/${enc(cfg.entity)}?${params.toString()}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Rows out of a `{items: [...]}` list envelope (or a bare array). */
|
|
171
|
+
function rowsFrom(value: unknown): unknown[] {
|
|
172
|
+
if (Array.isArray(value)) return value;
|
|
173
|
+
if (value && typeof value === "object") {
|
|
174
|
+
const items = (value as { items?: unknown }).items;
|
|
175
|
+
if (Array.isArray(items)) return items;
|
|
176
|
+
}
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Map the votable-entity list into ballot options (id + a non-empty label). */
|
|
181
|
+
export function toOptions(
|
|
182
|
+
proposalsBody: unknown,
|
|
183
|
+
cfg: VoteReadConfig,
|
|
184
|
+
): VoteOption[] {
|
|
185
|
+
const out: VoteOption[] = [];
|
|
186
|
+
for (const row of rowsFrom(proposalsBody)) {
|
|
187
|
+
const id = strField(row, "id");
|
|
188
|
+
if (!id) continue;
|
|
189
|
+
const label =
|
|
190
|
+
strField(row, cfg.labelField) ?? strField(row, "public_ref") ?? id;
|
|
191
|
+
out.push({ id, label });
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Voting is closed unless the edition's phase equals the configured open phase
|
|
197
|
+
* (an absent/null phase reads as CLOSED — fail-closed, matching the gate). */
|
|
198
|
+
export function isClosed(editionRow: unknown, cfg: VoteReadConfig): boolean {
|
|
199
|
+
return strField(editionRow, cfg.phaseField) !== cfg.openPhase;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The cast body: `{ <voteModel>: { <voteField>: choice, <editionField>: edition } }`. */
|
|
203
|
+
export function castBody(
|
|
204
|
+
cfg: VoteCastConfig,
|
|
205
|
+
choiceId: string,
|
|
206
|
+
editionId: string,
|
|
207
|
+
): Record<string, unknown> {
|
|
208
|
+
return {
|
|
209
|
+
[cfg.voteModel]: {
|
|
210
|
+
[cfg.voteField]: choiceId,
|
|
211
|
+
[cfg.editionField]: editionId,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type CastOutcome =
|
|
217
|
+
| { kind: "ok" }
|
|
218
|
+
/** One-vote-per-edition already used (409) — a settled state, not an error. */
|
|
219
|
+
| { kind: "conflict" }
|
|
220
|
+
/** Identity missing/insufficient (401/403) — the cast needs a signed-in citizen. */
|
|
221
|
+
| { kind: "auth" }
|
|
222
|
+
| { kind: "error"; status: number };
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Cast the vote through the citizen public-submit lane. The signed-in citizen's
|
|
226
|
+
* Bearer is forwarded by the same-origin `/bff` proxy, so the server stamps the
|
|
227
|
+
* pseudonym and enforces the one-vote constraint. Maps the response to a settled
|
|
228
|
+
* outcome the widget renders (never throws on a non-OK — a 409 is expected).
|
|
229
|
+
*/
|
|
230
|
+
export async function castVote(
|
|
231
|
+
fetchImpl: WriteFetch,
|
|
232
|
+
cfg: VoteCastConfig,
|
|
233
|
+
choiceId: string,
|
|
234
|
+
editionId: string,
|
|
235
|
+
): Promise<CastOutcome> {
|
|
236
|
+
let resp: { ok: boolean; status: number };
|
|
237
|
+
try {
|
|
238
|
+
resp = await fetchImpl(
|
|
239
|
+
`/bff/${enc(cfg.app)}/public/submit?placement_key=${enc(cfg.placementKey)}`,
|
|
240
|
+
{
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: JSON_HEADERS,
|
|
243
|
+
body: JSON.stringify(castBody(cfg, choiceId, editionId)),
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
} catch {
|
|
247
|
+
return { kind: "error", status: 0 };
|
|
248
|
+
}
|
|
249
|
+
if (resp.ok) return { kind: "ok" };
|
|
250
|
+
if (resp.status === 409) return { kind: "conflict" };
|
|
251
|
+
if (resp.status === 401 || resp.status === 403) return { kind: "auth" };
|
|
252
|
+
return { kind: "error", status: resp.status };
|
|
253
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiaiai-pt/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Design system tokens and Svelte components for aiaiai products",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"site"
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
|
-
"build:types": "svelte-package --input . --output dist && cp dist/components/*.d.ts components/ && rm -rf dist",
|
|
18
|
+
"build:types": "svelte-package --input . --output dist && cp dist/components/*.d.ts components/ && cp dist/components/renderer/*.d.ts components/renderer/ && rm -rf dist",
|
|
19
|
+
"test": "vitest run",
|
|
19
20
|
"prepublishOnly": "npm run build:types"
|
|
20
21
|
},
|
|
21
22
|
"svelte": "./components/index.js",
|
|
@@ -40,6 +41,16 @@
|
|
|
40
41
|
"svelte": "./components/*",
|
|
41
42
|
"default": "./components/*"
|
|
42
43
|
},
|
|
44
|
+
"./renderer/*.svelte": {
|
|
45
|
+
"types": "./components/renderer/*.svelte.d.ts",
|
|
46
|
+
"svelte": "./components/renderer/*.svelte",
|
|
47
|
+
"default": "./components/renderer/*.svelte"
|
|
48
|
+
},
|
|
49
|
+
"./renderer/*": {
|
|
50
|
+
"types": "./components/renderer/*.d.ts",
|
|
51
|
+
"svelte": "./components/renderer/*",
|
|
52
|
+
"default": "./components/renderer/*"
|
|
53
|
+
},
|
|
43
54
|
"./tokens/*": "./tokens/*"
|
|
44
55
|
},
|
|
45
56
|
"files": [
|
|
@@ -56,15 +67,18 @@
|
|
|
56
67
|
"@codemirror/view": "^6.40.0",
|
|
57
68
|
"@lezer/highlight": "^1.0.0",
|
|
58
69
|
"date-fns": "^4.1.0",
|
|
70
|
+
"echarts": "^5.5.0",
|
|
59
71
|
"ol": "^10.0.0",
|
|
60
72
|
"svelte": "^5.0.0"
|
|
61
73
|
},
|
|
62
74
|
"devDependencies": {
|
|
63
75
|
"@sveltejs/package": "^2.5.7",
|
|
64
76
|
"date-fns": "^4.1.0",
|
|
77
|
+
"echarts": "^5.6.0",
|
|
65
78
|
"ol": "^10.8.0",
|
|
66
79
|
"svelte": "^5.55.3",
|
|
67
80
|
"svelte-check": "^4.4.6",
|
|
68
|
-
"typescript": "^5.9.3"
|
|
81
|
+
"typescript": "^5.9.3",
|
|
82
|
+
"vitest": "^3.0.0"
|
|
69
83
|
}
|
|
70
84
|
}
|