@cosmicdrift/kumiko-headless 0.105.2 → 0.109.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.0",
|
|
4
4
|
"description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
35
|
+
"@cosmicdrift/kumiko-framework": "0.109.0",
|
|
36
36
|
"zod": "^4.4.3"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
package/src/apex/index.ts
CHANGED
|
@@ -24,16 +24,27 @@ export type ApexBrand = {
|
|
|
24
24
|
export type ApexCtaVariant = "primary" | "secondary" | "link";
|
|
25
25
|
export type ApexCta = {
|
|
26
26
|
readonly label: string;
|
|
27
|
+
/** App-authored only, no user input — escapeHtml encodes entities but does
|
|
28
|
+
* NOT sanitize the URL scheme (a `javascript:`/`data:` URI survives it
|
|
29
|
+
* unchanged). Trust boundary is the deploy-time apex config, not runtime. */
|
|
27
30
|
readonly href: string;
|
|
28
31
|
/** "link" renders a plain anchor (no .btn). Default "primary". */
|
|
29
32
|
readonly variant?: ApexCtaVariant;
|
|
30
33
|
};
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
// `kind` is optional here (footer links / ApexNavMenu.footer don't need it) —
|
|
36
|
+
// it exists so ApexNavEntry (ApexLink | ApexNavMenu) is a real discriminated
|
|
37
|
+
// union: TS narrows on `entry.kind === "menu"` without a structural
|
|
38
|
+
// `"items" in entry` check or an `as` cast.
|
|
39
|
+
export type ApexLink = { readonly kind?: "link"; readonly label: string; readonly href: string };
|
|
33
40
|
|
|
34
41
|
/** One entry inside a dropdown nav menu: icon + title + optional description. */
|
|
35
42
|
export type ApexNavMenuItem = {
|
|
36
|
-
/** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
|
|
43
|
+
/** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
|
|
44
|
+
* ponytail: rendered verbatim, no sanitizer — trusted because it's
|
|
45
|
+
* app-authored at deploy time, same trust boundary as ApexCta.href. If an
|
|
46
|
+
* app ever sources this from tenant/user content, sanitize at that
|
|
47
|
+
* construction boundary before it reaches here. */
|
|
37
48
|
readonly icon?: string;
|
|
38
49
|
readonly title: string;
|
|
39
50
|
readonly desc?: string;
|
|
@@ -88,7 +99,11 @@ export type ApexHeroSection = {
|
|
|
88
99
|
};
|
|
89
100
|
|
|
90
101
|
export type ApexFeature = {
|
|
91
|
-
/** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
|
|
102
|
+
/** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
|
|
103
|
+
* ponytail: rendered verbatim, no sanitizer — trusted because it's
|
|
104
|
+
* app-authored at deploy time, same trust boundary as ApexCta.href. If an
|
|
105
|
+
* app ever sources this from tenant/user content, sanitize at that
|
|
106
|
+
* construction boundary before it reaches here. */
|
|
92
107
|
readonly icon?: string;
|
|
93
108
|
readonly title: string;
|
|
94
109
|
readonly desc: string;
|
|
@@ -373,7 +388,7 @@ function renderNavMenu(m: ApexNavMenu): string {
|
|
|
373
388
|
}
|
|
374
389
|
|
|
375
390
|
function renderNavEntry(entry: ApexNavEntry): string {
|
|
376
|
-
return "
|
|
391
|
+
return entry.kind === "menu"
|
|
377
392
|
? renderNavMenu(entry)
|
|
378
393
|
: `<a href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</a>`;
|
|
379
394
|
}
|
|
@@ -140,6 +140,21 @@ describe("computeListViewModel", () => {
|
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
test("labeled column with no matching field AND no renderer → throws (label alone isn't enough)", () => {
|
|
144
|
+
// Regression (697/1): renderer is what actually draws a virtual column —
|
|
145
|
+
// a label with no renderer would otherwise push an empty, unrendered
|
|
146
|
+
// column into the view model instead of catching the author typo.
|
|
147
|
+
expect(() =>
|
|
148
|
+
computeListViewModel({
|
|
149
|
+
screen: listScreen(["title", { field: "tags", label: "Tags" }]),
|
|
150
|
+
entity: taskEntity,
|
|
151
|
+
rows: [],
|
|
152
|
+
translate,
|
|
153
|
+
featureName: "tasks",
|
|
154
|
+
}),
|
|
155
|
+
).toThrow(/unknown field "tags"/);
|
|
156
|
+
});
|
|
157
|
+
|
|
143
158
|
test("label overrides the field-convention header on a real field", () => {
|
|
144
159
|
const vm = computeListViewModel({
|
|
145
160
|
screen: listScreen([{ field: "title", label: "custom.header" }]),
|
package/src/view-model/list.ts
CHANGED
|
@@ -42,19 +42,20 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
|
|
|
42
42
|
// columns carry no reference/select metadata and never server-sort.
|
|
43
43
|
const derivedDef = entity.derivedFields?.[normalized.field];
|
|
44
44
|
if (!derivedDef) {
|
|
45
|
-
// A
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
45
|
+
// A virtual presentational column — drawn entirely by a columnRenderer
|
|
46
|
+
// component from the row (e.g. tag chips); value is undefined and it
|
|
47
|
+
// never server-sorts. `field` is just the column key. It needs BOTH a
|
|
48
|
+
// label AND a renderer — renderer is what actually draws it; a label
|
|
49
|
+
// alone with no renderer would push an empty, unrendered column into
|
|
50
|
+
// the view model. Missing either is an author typo (or a stale
|
|
51
|
+
// field-rename) → fail loud instead of silently drawing nothing.
|
|
52
|
+
if (normalized.label !== undefined && normalized.renderer !== undefined) {
|
|
52
53
|
columns.push({
|
|
53
54
|
field: normalized.field,
|
|
54
55
|
label: translate(normalized.label),
|
|
55
56
|
type: "text",
|
|
56
57
|
sortable: false,
|
|
57
|
-
|
|
58
|
+
renderer: normalized.renderer,
|
|
58
59
|
});
|
|
59
60
|
continue;
|
|
60
61
|
}
|