@cosmicdrift/kumiko-framework 0.130.2 → 0.131.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 +2 -2
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +93 -0
- package/src/engine/boot-validator/screens-nav.ts +51 -0
- package/src/engine/types/index.ts +5 -0
- package/src/engine/types/screen.ts +59 -0
- package/src/i18n/required-surface-keys.ts +14 -0
- package/src/testing/e2e-generator.ts +3 -0
- package/src/ui-types/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.131.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"zod": "^4.4.3"
|
|
190
190
|
},
|
|
191
191
|
"devDependencies": {
|
|
192
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
192
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.131.0",
|
|
193
193
|
"bun-types": "^1.3.13",
|
|
194
194
|
"pino-pretty": "^13.1.3"
|
|
195
195
|
},
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { requiredKeysFromScreen } from "../../i18n/required-surface-keys";
|
|
3
|
+
import { validateBoot } from "../boot-validator";
|
|
4
|
+
import { defineFeature } from "../define-feature";
|
|
5
|
+
import type { DashboardScreenDefinition } from "../types/screen";
|
|
6
|
+
|
|
7
|
+
const STAT_PANEL = {
|
|
8
|
+
kind: "stat",
|
|
9
|
+
id: "open-incidents",
|
|
10
|
+
label: "demo:dashboard:panel:open-incidents",
|
|
11
|
+
query: "demo:query:incident:open-count",
|
|
12
|
+
valueField: "count",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
function dashboardFeature(panels: DashboardScreenDefinition["panels"]) {
|
|
16
|
+
return defineFeature("demo", (r) => {
|
|
17
|
+
r.screen({ id: "overview", type: "dashboard", panels });
|
|
18
|
+
r.translations({
|
|
19
|
+
keys: {
|
|
20
|
+
"screen:overview.title": { de: "Übersicht", en: "Overview" },
|
|
21
|
+
"demo:dashboard:panel:open-incidents": { de: "Offene Vorfälle", en: "Open incidents" },
|
|
22
|
+
"demo:dashboard:panel:latest": { de: "Neueste", en: "Latest" },
|
|
23
|
+
"demo:dashboard:col:name": { de: "Name", en: "Name" },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("validateBoot — dashboard screens", () => {
|
|
30
|
+
test("accepts a valid stat + list panel set", () => {
|
|
31
|
+
const feature = dashboardFeature([
|
|
32
|
+
STAT_PANEL,
|
|
33
|
+
{
|
|
34
|
+
kind: "list",
|
|
35
|
+
id: "latest",
|
|
36
|
+
label: "demo:dashboard:panel:latest",
|
|
37
|
+
query: "demo:query:incident:latest",
|
|
38
|
+
columns: [{ field: "name", label: "demo:dashboard:col:name" }],
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("rejects an empty panels list", () => {
|
|
45
|
+
const feature = dashboardFeature([]);
|
|
46
|
+
expect(() => validateBoot([feature])).toThrow(/empty panels list/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects duplicate panel ids", () => {
|
|
50
|
+
const feature = dashboardFeature([STAT_PANEL, STAT_PANEL]);
|
|
51
|
+
expect(() => validateBoot([feature])).toThrow(/duplicate panel id/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects a stat panel with empty valueField", () => {
|
|
55
|
+
const feature = dashboardFeature([{ ...STAT_PANEL, valueField: "" }]);
|
|
56
|
+
expect(() => validateBoot([feature])).toThrow(/empty valueField/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("rejects a list panel without columns", () => {
|
|
60
|
+
const feature = dashboardFeature([
|
|
61
|
+
{
|
|
62
|
+
kind: "list",
|
|
63
|
+
id: "latest",
|
|
64
|
+
label: "demo:dashboard:panel:latest",
|
|
65
|
+
query: "demo:query:incident:latest",
|
|
66
|
+
columns: [],
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
expect(() => validateBoot([feature])).toThrow(/empty columns list/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("requiredKeysFromScreen sammelt Panel- und Column-Labels", () => {
|
|
73
|
+
const screen: DashboardScreenDefinition = {
|
|
74
|
+
id: "overview",
|
|
75
|
+
type: "dashboard",
|
|
76
|
+
panels: [
|
|
77
|
+
STAT_PANEL,
|
|
78
|
+
{
|
|
79
|
+
kind: "list",
|
|
80
|
+
id: "latest",
|
|
81
|
+
label: "demo:dashboard:panel:latest",
|
|
82
|
+
query: "demo:query:incident:latest",
|
|
83
|
+
columns: [{ field: "name", label: "demo:dashboard:col:name" }],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const keys = requiredKeysFromScreen("demo", screen);
|
|
88
|
+
expect(keys).toContain("screen:overview.title");
|
|
89
|
+
expect(keys).toContain("demo:dashboard:panel:open-incidents");
|
|
90
|
+
expect(keys).toContain("demo:dashboard:panel:latest");
|
|
91
|
+
expect(keys).toContain("demo:dashboard:col:name");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -4,6 +4,7 @@ import { qualifyEntityName } from "../qualified-name";
|
|
|
4
4
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
5
5
|
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
6
6
|
import type {
|
|
7
|
+
DashboardScreenDefinition,
|
|
7
8
|
FieldCondition,
|
|
8
9
|
RowAction,
|
|
9
10
|
RowFieldExtractor,
|
|
@@ -162,6 +163,11 @@ export function validateScreens(
|
|
|
162
163
|
continue;
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
if (screen.type === "dashboard") {
|
|
167
|
+
validateDashboardScreen(feature.name, screenId, screen);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
165
171
|
if (screen.type === "configEdit") {
|
|
166
172
|
// configEdit: layout/fields wie actionForm validieren, plus
|
|
167
173
|
// Cross-Check dass jeder qualifizierte Config-Key registriert
|
|
@@ -634,6 +640,51 @@ export function validateScreens(
|
|
|
634
640
|
}
|
|
635
641
|
}
|
|
636
642
|
|
|
643
|
+
// Panel-getrieben, keine Entity — Struktur-Checks (eindeutige Panel-Ids,
|
|
644
|
+
// non-empty Queries/Columns/valueField); die Query-Contracts (Stat-Record,
|
|
645
|
+
// Points-Envelope, Paged-Rows) werden zur Render-Zeit aufgelöst.
|
|
646
|
+
function validateDashboardScreen(
|
|
647
|
+
featureName: string,
|
|
648
|
+
screenId: string,
|
|
649
|
+
screen: DashboardScreenDefinition,
|
|
650
|
+
): void {
|
|
651
|
+
if (screen.panels.length === 0) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) has an empty panels list — ` +
|
|
654
|
+
`declare at least one panel.`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
const panelIds = new Set<string>();
|
|
658
|
+
for (const panel of screen.panels) {
|
|
659
|
+
if (panelIds.has(panel.id)) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${panel.id}".`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
panelIds.add(panel.id);
|
|
665
|
+
if (!panel.query || typeof panel.query !== "string") {
|
|
666
|
+
throw new Error(
|
|
667
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
if (panel.kind === "stat" && panel.valueField.length === 0) {
|
|
671
|
+
throw new Error(
|
|
672
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
if (panel.kind === "list") {
|
|
676
|
+
if (panel.columns.length === 0) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
for (const col of panel.columns) {
|
|
682
|
+
validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
637
688
|
// Form-check für ListColumn-Renderer in der PlatformComponent-Form
|
|
638
689
|
// (`{ react: { __component: "Name" } }`). Der Server kennt die client-
|
|
639
690
|
// seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
|
|
@@ -219,6 +219,11 @@ export type {
|
|
|
219
219
|
ConfigEditScreenDefinition,
|
|
220
220
|
CustomScreenDefinition,
|
|
221
221
|
CustomScreenRoute,
|
|
222
|
+
DashboardChartPanel,
|
|
223
|
+
DashboardListPanel,
|
|
224
|
+
DashboardPanelDefinition,
|
|
225
|
+
DashboardScreenDefinition,
|
|
226
|
+
DashboardStatPanel,
|
|
222
227
|
EditExtensionSection,
|
|
223
228
|
EditFieldSpec,
|
|
224
229
|
EditFieldsSection,
|
|
@@ -324,6 +324,64 @@ export type ProjectionListScreenDefinition = {
|
|
|
324
324
|
readonly access?: AccessRule;
|
|
325
325
|
};
|
|
326
326
|
|
|
327
|
+
// --- dashboard ---
|
|
328
|
+
|
|
329
|
+
// Deklaratives Panel-Grid — Kennzahlen, Verläufe und Kurzlisten ohne
|
|
330
|
+
// Custom-JSX. Jedes Panel zieht seine Daten aus einer eigenen Query
|
|
331
|
+
// (fully-qualified QN, cross-feature erlaubt wie projectionList).
|
|
332
|
+
// Formatierung ist Sache des Query-Handlers: Stat-Werte kommen als
|
|
333
|
+
// anzeigefertige Strings/Zahlen aus der Read-Projection (ES-Read-Models
|
|
334
|
+
// shapen ihre Daten selbst; der Renderer formatiert nicht nach).
|
|
335
|
+
|
|
336
|
+
// Query-Result-Contract: flaches Record; `valueField` zeigt auf den
|
|
337
|
+
// anzeigefertigen Wert, `subField` optional auf eine Sub-Zeile,
|
|
338
|
+
// `toneField` optional auf "default" | "positive" | "warn".
|
|
339
|
+
export type DashboardStatPanel = {
|
|
340
|
+
readonly kind: "stat";
|
|
341
|
+
/** Stable id — kebab-case, eindeutig im Panel-Set. */
|
|
342
|
+
readonly id: string;
|
|
343
|
+
/** Anzeige-Text (i18n-Key). */
|
|
344
|
+
readonly label: string;
|
|
345
|
+
readonly query: string;
|
|
346
|
+
readonly valueField: string;
|
|
347
|
+
readonly subField?: string;
|
|
348
|
+
readonly toneField?: string;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Query-Result-Contract: `{ points: { atMs, value | null }[],
|
|
352
|
+
// windowStartMs, windowEndMs }` — value=null zeichnet einen Einbruch.
|
|
353
|
+
export type DashboardChartPanel = {
|
|
354
|
+
readonly kind: "chart";
|
|
355
|
+
readonly id: string;
|
|
356
|
+
readonly label: string;
|
|
357
|
+
/** v1: geglättete Zeitreihe. Weitere Chart-Formen additiv. */
|
|
358
|
+
readonly chart: "timeseries";
|
|
359
|
+
readonly query: string;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Kurzliste im Dashboard — Query-Contract wie projectionList
|
|
363
|
+
// (`{ rows, nextCursor, total? }`), gerendert ohne Pager/Toolbar.
|
|
364
|
+
export type DashboardListPanel = {
|
|
365
|
+
readonly kind: "list";
|
|
366
|
+
readonly id: string;
|
|
367
|
+
readonly label: string;
|
|
368
|
+
readonly query: string;
|
|
369
|
+
readonly columns: readonly ListColumnSpec[];
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export type DashboardPanelDefinition =
|
|
373
|
+
| DashboardStatPanel
|
|
374
|
+
| DashboardChartPanel
|
|
375
|
+
| DashboardListPanel;
|
|
376
|
+
|
|
377
|
+
export type DashboardScreenDefinition = {
|
|
378
|
+
readonly id: string;
|
|
379
|
+
readonly type: "dashboard";
|
|
380
|
+
readonly panels: readonly DashboardPanelDefinition[];
|
|
381
|
+
readonly slots?: ScreenSlots;
|
|
382
|
+
readonly access?: AccessRule;
|
|
383
|
+
};
|
|
384
|
+
|
|
327
385
|
// --- entityEdit ---
|
|
328
386
|
|
|
329
387
|
// camelCase `readOnly` instead of the spec's lowercase `readonly`: TS's
|
|
@@ -557,6 +615,7 @@ export type ScreenSlots = {
|
|
|
557
615
|
export type ScreenDefinition =
|
|
558
616
|
| EntityListScreenDefinition
|
|
559
617
|
| ProjectionListScreenDefinition
|
|
618
|
+
| DashboardScreenDefinition
|
|
560
619
|
| EntityEditScreenDefinition
|
|
561
620
|
| ActionFormScreenDefinition
|
|
562
621
|
| ConfigEditScreenDefinition
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ActionFormScreenDefinition,
|
|
3
3
|
ConfigEditScreenDefinition,
|
|
4
|
+
DashboardScreenDefinition,
|
|
4
5
|
EntityEditScreenDefinition,
|
|
5
6
|
EntityListScreenDefinition,
|
|
6
7
|
FeatureDefinition,
|
|
@@ -106,6 +107,19 @@ export function requiredKeysFromScreen(
|
|
|
106
107
|
for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
|
|
107
108
|
break;
|
|
108
109
|
}
|
|
110
|
+
case "dashboard": {
|
|
111
|
+
const dashboard = screen as DashboardScreenDefinition;
|
|
112
|
+
for (const panel of dashboard.panels) {
|
|
113
|
+
pushKey(out, panel.label);
|
|
114
|
+
if (panel.kind === "list") {
|
|
115
|
+
for (const col of panel.columns) {
|
|
116
|
+
const normalized = normalizeListColumn(col);
|
|
117
|
+
if (normalized.label !== undefined) pushKey(out, normalized.label);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
109
123
|
case "entityEdit": {
|
|
110
124
|
const edit = screen as EntityEditScreenDefinition;
|
|
111
125
|
pushKey(out, edit.submitLabel);
|
|
@@ -115,6 +115,9 @@ export function generateE2ESpec(
|
|
|
115
115
|
// projectionList: query-getrieben, Author-spezifische Projection —
|
|
116
116
|
// kein generischer CRUD-Spec ableitbar (wie actionForm/configEdit).
|
|
117
117
|
if (screen.type === "projectionList") continue;
|
|
118
|
+
// dashboard: panel-getrieben (Stat/Chart/List-Queries) — dito, keine
|
|
119
|
+
// generische CRUD-Annahme möglich.
|
|
120
|
+
if (screen.type === "dashboard") continue;
|
|
118
121
|
const { scope: feature, name: short } = parseQn(screenQn);
|
|
119
122
|
const urlPath = `/t/${tenant}/${feature}/${short}`;
|
|
120
123
|
const title = `${feature}/${short}`;
|
package/src/ui-types/index.ts
CHANGED
|
@@ -46,6 +46,11 @@ export type {
|
|
|
46
46
|
ConfigEditScreenDefinition,
|
|
47
47
|
CustomScreenDefinition,
|
|
48
48
|
CustomScreenRoute,
|
|
49
|
+
DashboardChartPanel,
|
|
50
|
+
DashboardListPanel,
|
|
51
|
+
DashboardPanelDefinition,
|
|
52
|
+
DashboardScreenDefinition,
|
|
53
|
+
DashboardStatPanel,
|
|
49
54
|
EditExtensionSection,
|
|
50
55
|
EditFieldSpec,
|
|
51
56
|
EditFieldsSection,
|