@cosmicdrift/kumiko-framework 0.130.2 → 0.132.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-framework",
3
- "version": "0.130.2",
3
+ "version": "0.132.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.130.2",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.132.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}`;
@@ -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,