@cosmicdrift/kumiko-renderer-web 0.239.0 → 0.241.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-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.241.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.241.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.241.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.241.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.241.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -12,6 +12,7 @@ import { type ColumnRendererProps, ColumnRenderersProvider } from "@cosmicdrift/
|
|
|
12
12
|
import userEvent from "@testing-library/user-event";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
14
|
import { defaultPrimitives, END_LABEL_MIN_ROWS, FormScreenShell } from "../primitives";
|
|
15
|
+
import { DefaultJsonView } from "../primitives/json-view";
|
|
15
16
|
import { PageSection, Stack } from "../primitives/layout";
|
|
16
17
|
import { fireEvent, render, screen, waitFor } from "./test-utils";
|
|
17
18
|
|
|
@@ -1766,3 +1767,50 @@ describe("PageSection", () => {
|
|
|
1766
1767
|
expect(screen.getByTestId("p-default").className).toContain("max-w-full");
|
|
1767
1768
|
});
|
|
1768
1769
|
});
|
|
1770
|
+
|
|
1771
|
+
describe("JsonView", () => {
|
|
1772
|
+
test("tokenisiert statt einen flachen String zu rendern — Keys/Strings/Zahlen/Booleans/null bekommen je eigene Klasse", () => {
|
|
1773
|
+
render(
|
|
1774
|
+
<DefaultJsonView testId="jv" value={{ name: "job-1", attempts: 3, ok: true, error: null }} />,
|
|
1775
|
+
);
|
|
1776
|
+
const spans = Array.from(screen.getByTestId("jv").querySelectorAll("span"));
|
|
1777
|
+
// 1 Key-Token pro Feld + 1 Value-Token (string/number/boolean/null) = 8.
|
|
1778
|
+
expect(spans.length).toBe(8);
|
|
1779
|
+
const classNames = new Set(spans.map((s) => s.className));
|
|
1780
|
+
expect(classNames.size).toBe(5);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
test("Whitespace bleibt erhalten (pre-wrap) — mehrzeiliger, eingerückter Output statt einer kollabierten Zeile", () => {
|
|
1784
|
+
const value = { a: { b: 1 } };
|
|
1785
|
+
render(<DefaultJsonView testId="jv" value={value} />);
|
|
1786
|
+
const el = screen.getByTestId("jv");
|
|
1787
|
+
expect(el.className).toContain("whitespace-pre-wrap");
|
|
1788
|
+
expect(el.className).toContain("break-words");
|
|
1789
|
+
expect(el.textContent).toBe(JSON.stringify(value, null, 2));
|
|
1790
|
+
expect(el.textContent).toContain("\n");
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
test("indent-Prop steuert die Einrückung wie bei JSON.stringify", () => {
|
|
1794
|
+
const value = { a: 1 };
|
|
1795
|
+
render(<DefaultJsonView testId="jv" value={value} indent={4} />);
|
|
1796
|
+
expect(screen.getByTestId("jv").textContent).toBe(JSON.stringify(value, null, 4));
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
test("zirkulärer Wert wirft nicht — rendert einen [Circular]-Marker statt die Seite zu killen", () => {
|
|
1800
|
+
const circular: Record<string, unknown> = { name: "job-1" };
|
|
1801
|
+
circular["self"] = circular;
|
|
1802
|
+
expect(() => render(<DefaultJsonView testId="jv" value={circular} />)).not.toThrow();
|
|
1803
|
+
expect(screen.getByTestId("jv").textContent).toContain("[Circular]");
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
test("BigInt wirft nicht — wird als String serialisiert", () => {
|
|
1807
|
+
expect(() =>
|
|
1808
|
+
render(<DefaultJsonView testId="jv" value={{ amount: 9007199254740993n }} />),
|
|
1809
|
+
).not.toThrow();
|
|
1810
|
+
expect(screen.getByTestId("jv").textContent).toContain("9007199254740993n");
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
test("undefined wirft nicht", () => {
|
|
1814
|
+
expect(() => render(<DefaultJsonView testId="jv" value={undefined} />)).not.toThrow();
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
@@ -91,9 +91,13 @@ function StatPanelBody({
|
|
|
91
91
|
}
|
|
92
92
|
}, [panel.icon, panel.id, iconName, Icon, screenId]);
|
|
93
93
|
|
|
94
|
+
// filterParams first, panel.params second: a static author-set param is a
|
|
95
|
+
// deliberate pin (e.g. status: "failed") and must win over whatever the
|
|
96
|
+
// screen-wide filter happens to contribute under the same key.
|
|
97
|
+
const queryParams = { ...filterParams, ...panel.params };
|
|
94
98
|
const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
|
|
95
99
|
panel.query,
|
|
96
|
-
|
|
100
|
+
queryParams,
|
|
97
101
|
{ live: true },
|
|
98
102
|
);
|
|
99
103
|
if (loading && data === null) return <LoadingState rows={2} />;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Siehe visual-tree.md V.1.2 + V.1.1-B + V.1.4b.
|
|
14
14
|
|
|
15
15
|
import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
-
import { useNav } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { useNav, usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
17
17
|
import { X } from "lucide-react";
|
|
18
18
|
import type { ComponentType, ReactNode } from "react";
|
|
19
19
|
import { useCallback, useMemo } from "react";
|
|
@@ -37,6 +37,7 @@ function EditorPanelInner({
|
|
|
37
37
|
readonly resolvers: ReadonlyMap<string, ResolverComponent>;
|
|
38
38
|
readonly onClose: () => void;
|
|
39
39
|
}): ReactNode {
|
|
40
|
+
const { JsonView } = usePrimitives();
|
|
40
41
|
const resolverKey = `${target.featureId}:${target.action}`;
|
|
41
42
|
const Resolver = resolvers.get(resolverKey);
|
|
42
43
|
|
|
@@ -65,9 +66,13 @@ function EditorPanelInner({
|
|
|
65
66
|
</code>{" "}
|
|
66
67
|
registriert.
|
|
67
68
|
</p>
|
|
68
|
-
|
|
69
|
-
{
|
|
70
|
-
|
|
69
|
+
{JsonView !== undefined ? (
|
|
70
|
+
<JsonView value={target.args} />
|
|
71
|
+
) : (
|
|
72
|
+
<pre className="bg-muted p-2 rounded text-xs overflow-auto">
|
|
73
|
+
{JSON.stringify(target.args, null, 2)}
|
|
74
|
+
</pre>
|
|
75
|
+
)}
|
|
71
76
|
</div>
|
|
72
77
|
</div>
|
|
73
78
|
);
|
package/src/primitives/index.tsx
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
} from "./dropdown-menu";
|
|
110
110
|
import { EmbeddedListInput } from "./embedded-list-input";
|
|
111
111
|
import { FileUploadInput } from "./file-upload";
|
|
112
|
+
import { DefaultJsonView } from "./json-view";
|
|
112
113
|
import { screenWidthClassName } from "./layout";
|
|
113
114
|
import { DefaultLightbox } from "./lightbox";
|
|
114
115
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
@@ -2560,4 +2561,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2560
2561
|
Tabs: DefaultTabs,
|
|
2561
2562
|
StatusBadge: DefaultStatusBadge,
|
|
2562
2563
|
Metric: DefaultMetric,
|
|
2564
|
+
JsonView: DefaultJsonView,
|
|
2563
2565
|
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { JsonViewProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
// Recursive pre-pass (not JSX) that turns `value` into a JSON.stringify-safe
|
|
5
|
+
// plain structure: BigInt → string, and a real ancestor-path cycle check
|
|
6
|
+
// (not a global "seen" set — a value referenced twice via two different,
|
|
7
|
+
// non-circular paths must still render twice, not collapse to "[Circular]").
|
|
8
|
+
function toSafeJson(value: unknown, ancestors: readonly unknown[] = []): unknown {
|
|
9
|
+
if (typeof value === "bigint") return `${value.toString()}n`;
|
|
10
|
+
if (typeof value !== "object" || value === null) return value;
|
|
11
|
+
if (ancestors.includes(value)) return "[Circular]";
|
|
12
|
+
const nextAncestors = [...ancestors, value];
|
|
13
|
+
if (Array.isArray(value)) return value.map((item) => toSafeJson(item, nextAncestors));
|
|
14
|
+
const out: Record<string, unknown> = {};
|
|
15
|
+
for (const [k, v] of Object.entries(value)) out[k] = toSafeJson(v, nextAncestors);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A broken audit/log payload must not take the whole screen down with it —
|
|
20
|
+
// double try/catch so even a throwing getter on the input falls back to a
|
|
21
|
+
// plain string instead of propagating.
|
|
22
|
+
function safeStringify(value: unknown, indent: number): string {
|
|
23
|
+
try {
|
|
24
|
+
const json = JSON.stringify(toSafeJson(value), null, indent);
|
|
25
|
+
return json ?? String(value);
|
|
26
|
+
} catch {
|
|
27
|
+
try {
|
|
28
|
+
return String(value);
|
|
29
|
+
} catch {
|
|
30
|
+
return "[unserializable value]";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type TokenKind = "key" | "string" | "number" | "boolean" | "null";
|
|
36
|
+
|
|
37
|
+
const TOKEN_CLASS_NAME: Record<TokenKind, string> = {
|
|
38
|
+
key: "text-syntax-key font-medium",
|
|
39
|
+
string: "text-syntax-string",
|
|
40
|
+
number: "text-syntax-number",
|
|
41
|
+
boolean: "text-syntax-literal",
|
|
42
|
+
null: "text-syntax-literal italic",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function classifyToken(token: string): TokenKind {
|
|
46
|
+
if (token.startsWith('"')) return token.endsWith(":") ? "key" : "string";
|
|
47
|
+
if (token === "true" || token === "false") return "boolean";
|
|
48
|
+
if (token === "null") return "null";
|
|
49
|
+
return "number";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The stringify output is canonical JSON — a regex tokenizer over that
|
|
53
|
+
// string is a fraction of the code of a recursive value-tree renderer, and
|
|
54
|
+
// tokens become plain React elements (never dangerouslySetInnerHTML): audit
|
|
55
|
+
// payloads and job logs carry untrusted third-party data, so this must not
|
|
56
|
+
// become an HTML-injection path.
|
|
57
|
+
function tokenizeJson(json: string): ReactNode[] {
|
|
58
|
+
const tokenRe =
|
|
59
|
+
/("(?:\\u[a-fA-F0-9]{4}|\\.|[^\\"])*"(?:\s*:)?|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\btrue\b|\bfalse\b|\bnull\b)/g;
|
|
60
|
+
const nodes: ReactNode[] = [];
|
|
61
|
+
let lastIndex = 0;
|
|
62
|
+
let match: RegExpExecArray | null = tokenRe.exec(json);
|
|
63
|
+
let key = 0;
|
|
64
|
+
while (match !== null) {
|
|
65
|
+
if (match.index > lastIndex) nodes.push(json.slice(lastIndex, match.index));
|
|
66
|
+
const token = match[0];
|
|
67
|
+
nodes.push(
|
|
68
|
+
<span key={key++} className={TOKEN_CLASS_NAME[classifyToken(token)]}>
|
|
69
|
+
{token}
|
|
70
|
+
</span>,
|
|
71
|
+
);
|
|
72
|
+
lastIndex = tokenRe.lastIndex;
|
|
73
|
+
match = tokenRe.exec(json);
|
|
74
|
+
}
|
|
75
|
+
if (lastIndex < json.length) nodes.push(json.slice(lastIndex));
|
|
76
|
+
return nodes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Syntax-highlighted, whitespace-preserving JSON display. Bounded height
|
|
80
|
+
* with its own vertical scroll (job logs get long) instead of growing the
|
|
81
|
+
* page; wraps rather than overflowing horizontally. Colors come from the
|
|
82
|
+
* dedicated syntax-* theme tokens (styles.css), readable in light + dark. */
|
|
83
|
+
export function DefaultJsonView({ value, indent = 2, testId }: JsonViewProps): ReactNode {
|
|
84
|
+
const json = safeStringify(value, indent);
|
|
85
|
+
return (
|
|
86
|
+
<pre
|
|
87
|
+
data-testid={testId}
|
|
88
|
+
className="max-h-80 overflow-y-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono text-xs"
|
|
89
|
+
>
|
|
90
|
+
{tokenizeJson(json)}
|
|
91
|
+
</pre>
|
|
92
|
+
);
|
|
93
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -89,6 +89,15 @@
|
|
|
89
89
|
--color-status-warn: #f59e0b;
|
|
90
90
|
--color-status-bad: #ef4444;
|
|
91
91
|
--color-status-critical: #f87171;
|
|
92
|
+
|
|
93
|
+
/* JSON-Syntax-Highlighting (JsonView). Eigene, gedämpfte Palette statt der
|
|
94
|
+
Status-Ampel — ein `false` oder eine Zahl ist kein Fehler-/Warn-Signal.
|
|
95
|
+
Dark-Werte; Light-Overrides unten. Kontrast gegen bg-muted geprüft (>=6:1
|
|
96
|
+
in beiden Themes). */
|
|
97
|
+
--color-syntax-key: hsl(210 15% 75%);
|
|
98
|
+
--color-syntax-string: hsl(150 25% 60%);
|
|
99
|
+
--color-syntax-number: hsl(35 40% 65%);
|
|
100
|
+
--color-syntax-literal: hsl(270 25% 70%);
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/* Card-Chrome-Maße — Framework-Defaults. Eine App überschreibt selektiv in
|
|
@@ -151,6 +160,13 @@
|
|
|
151
160
|
--color-status-warn: #d97706;
|
|
152
161
|
--color-status-bad: #dc2626;
|
|
153
162
|
--color-status-critical: #991b1b;
|
|
163
|
+
|
|
164
|
+
/* JSON-Syntax-Highlighting Light — dunkler/gesättigter, Kontrast gegen
|
|
165
|
+
bg-muted geprüft (>=6:1). */
|
|
166
|
+
--color-syntax-key: hsl(210 20% 35%);
|
|
167
|
+
--color-syntax-string: hsl(150 30% 30%);
|
|
168
|
+
--color-syntax-number: hsl(30 45% 32%);
|
|
169
|
+
--color-syntax-literal: hsl(270 30% 40%);
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
* {
|