@cosmicdrift/kumiko-renderer 0.1.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 +42 -0
- package/src/__tests__/i18n.test.tsx +127 -0
- package/src/__tests__/qn.test.ts +40 -0
- package/src/__tests__/use-list-url-state.test.tsx +161 -0
- package/src/app/action-form-shim.ts +50 -0
- package/src/app/column-renderers.tsx +64 -0
- package/src/app/config-edit-shim.ts +48 -0
- package/src/app/custom-screens.tsx +29 -0
- package/src/app/feature-schema.ts +59 -0
- package/src/app/kumiko-screen.tsx +1050 -0
- package/src/app/nav.tsx +124 -0
- package/src/app/qn.ts +23 -0
- package/src/components/render-edit.tsx +346 -0
- package/src/components/render-field.tsx +299 -0
- package/src/components/render-list.tsx +402 -0
- package/src/context/dispatcher-context.tsx +59 -0
- package/src/hooks/reference-limits.ts +18 -0
- package/src/hooks/use-form.ts +88 -0
- package/src/hooks/use-list-url-state.ts +113 -0
- package/src/hooks/use-query.ts +129 -0
- package/src/hooks/use-reference-lookup.ts +54 -0
- package/src/hooks/use-store.ts +47 -0
- package/src/i18n-defaults.ts +94 -0
- package/src/i18n.tsx +158 -0
- package/src/index.ts +104 -0
- package/src/primitives.tsx +528 -0
- package/src/sse/live-events.tsx +56 -0
- package/src/tokens.tsx +142 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
|
+
"license": "BUSL-1.1",
|
|
6
|
+
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"kumiko": {
|
|
9
|
+
"runtime": "client"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
16
|
+
"@cosmicdrift/kumiko-headless": "workspace:*",
|
|
17
|
+
"react": "^19.2.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@testing-library/react": "^16.3.0",
|
|
21
|
+
"@types/react": "^19.2.0",
|
|
22
|
+
"jsdom": "^29.1.0"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
|
|
27
|
+
"directory": "packages/renderer"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://kumiko.so",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"registry": "https://registry.npmjs.org",
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { act, render, renderHook } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
createStaticLocaleResolver,
|
|
8
|
+
LocaleProvider,
|
|
9
|
+
type TranslationsByLocale,
|
|
10
|
+
useLocale,
|
|
11
|
+
useTranslation,
|
|
12
|
+
} from "../i18n";
|
|
13
|
+
|
|
14
|
+
// Stateful resolver fixture: we drive locale changes with setState
|
|
15
|
+
// and the test asserts re-render via subscribe.
|
|
16
|
+
function makeStatefulResolver(initial: string): LocaleResolver {
|
|
17
|
+
let current = initial;
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
return {
|
|
20
|
+
translate: (key) => key,
|
|
21
|
+
locale: () => current,
|
|
22
|
+
timeZone: () => "UTC",
|
|
23
|
+
subscribe: (l) => {
|
|
24
|
+
listeners.add(l);
|
|
25
|
+
return () => {
|
|
26
|
+
listeners.delete(l);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
setLocale: (next) => {
|
|
30
|
+
current = next;
|
|
31
|
+
for (const l of listeners) l();
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const wrap =
|
|
37
|
+
(resolver: LocaleResolver, fallbackBundles?: TranslationsByLocale[]) =>
|
|
38
|
+
({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
39
|
+
<LocaleProvider resolver={resolver} {...(fallbackBundles !== undefined && { fallbackBundles })}>
|
|
40
|
+
{children}
|
|
41
|
+
</LocaleProvider>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
describe("useTranslation — lookup order", () => {
|
|
45
|
+
test("App-Resolver wins when it returns a non-key value", () => {
|
|
46
|
+
const resolver: LocaleResolver = {
|
|
47
|
+
...createStaticLocaleResolver({ locale: "de" }),
|
|
48
|
+
translate: (key) => (key === "hello" ? "Resolved by app" : key),
|
|
49
|
+
};
|
|
50
|
+
const { result } = renderHook(() => useTranslation(), { wrapper: wrap(resolver) });
|
|
51
|
+
expect(result.current("hello")).toBe("Resolved by app");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("falls back to plugin-bundle for current locale", () => {
|
|
55
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
56
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
|
|
57
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
58
|
+
wrapper: wrap(resolver, bundles),
|
|
59
|
+
});
|
|
60
|
+
expect(result.current("greet")).toBe("Hallo");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("strips region for bundle lookup (de-AT → de)", () => {
|
|
64
|
+
const resolver = createStaticLocaleResolver({ locale: "de-AT" });
|
|
65
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" } }];
|
|
66
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
67
|
+
wrapper: wrap(resolver, bundles),
|
|
68
|
+
});
|
|
69
|
+
expect(result.current("greet")).toBe("Hallo");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("falls back to fallbackLocale (en) when current locale missing", () => {
|
|
73
|
+
const resolver = createStaticLocaleResolver({ locale: "fr" });
|
|
74
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
|
|
75
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
76
|
+
wrapper: wrap(resolver, bundles),
|
|
77
|
+
});
|
|
78
|
+
expect(result.current("greet")).toBe("Hello");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns key as-is when nothing resolves", () => {
|
|
82
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
83
|
+
const { result } = renderHook(() => useTranslation(), { wrapper: wrap(resolver) });
|
|
84
|
+
expect(result.current("missing.key")).toBe("missing.key");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("interpolates {param}-placeholders from params arg", () => {
|
|
88
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
89
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo {name}!" } }];
|
|
90
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
91
|
+
wrapper: wrap(resolver, bundles),
|
|
92
|
+
});
|
|
93
|
+
expect(result.current("greet", { name: "Marc" })).toBe("Hallo Marc!");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("useTranslation — re-render on locale change", () => {
|
|
98
|
+
test("subscribe fires when setLocale runs, hook returns new value", () => {
|
|
99
|
+
const resolver = makeStatefulResolver("de");
|
|
100
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
|
|
101
|
+
|
|
102
|
+
function Probe(): ReactNode {
|
|
103
|
+
const t = useTranslation();
|
|
104
|
+
return <span data-testid="msg">{t("greet")}</span>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { getByTestId } = render(
|
|
108
|
+
<LocaleProvider resolver={resolver} fallbackBundles={bundles}>
|
|
109
|
+
<Probe />
|
|
110
|
+
</LocaleProvider>,
|
|
111
|
+
);
|
|
112
|
+
expect(getByTestId("msg").textContent).toBe("Hallo");
|
|
113
|
+
|
|
114
|
+
act(() => {
|
|
115
|
+
resolver.setLocale?.("en");
|
|
116
|
+
});
|
|
117
|
+
expect(getByTestId("msg").textContent).toBe("Hello");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("useLocale", () => {
|
|
122
|
+
test("returns the resolver", () => {
|
|
123
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
124
|
+
const { result } = renderHook(() => useLocale(), { wrapper: wrap(resolver) });
|
|
125
|
+
expect(result.current.locale()).toBe("de");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { lastSegment } from "../app/qn";
|
|
3
|
+
|
|
4
|
+
describe("lastSegment", () => {
|
|
5
|
+
test("strips feature-prefix from screen-QN", () => {
|
|
6
|
+
expect(lastSegment("publicstatus:screen:component-edit")).toBe("component-edit");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("strips feature-prefix from nav-QN", () => {
|
|
10
|
+
expect(lastSegment("shop:nav:catalog")).toBe("catalog");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("strips feature-prefix from workspace-QN", () => {
|
|
14
|
+
expect(lastSegment("admin:workspace:disposition")).toBe("disposition");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("returns short-form input unchanged", () => {
|
|
18
|
+
// Defensive default — an Author who already passes a short id
|
|
19
|
+
// shouldn't get a misformatted result.
|
|
20
|
+
expect(lastSegment("component-edit")).toBe("component-edit");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("strips only the LAST segment, not the first", () => {
|
|
24
|
+
// The QN convention is `<feature>:<kind>:<short-id>`. lastIndexOf
|
|
25
|
+
// ensures kebab-ids that themselves contain colons (defensive —
|
|
26
|
+
// kebab spec rejects them, but the helper shouldn't break).
|
|
27
|
+
expect(lastSegment("a:b:c:d")).toBe("d");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("handles empty string", () => {
|
|
31
|
+
expect(lastSegment("")).toBe("");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("handles trailing colon as empty segment", () => {
|
|
35
|
+
// E.g. when registry stamping is buggy and writes `feature:screen:`
|
|
36
|
+
// — the helper returns "" rather than throwing, the caller's
|
|
37
|
+
// navigate-then-not-found banner makes the bug visible.
|
|
38
|
+
expect(lastSegment("publicstatus:screen:")).toBe("");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// useListUrlState pinnt den URL-State-Vertrag pro Screen-ID-Namespace:
|
|
4
|
+
// `?<screenId>.sort=…&<screenId>.dir=…&<screenId>.q=…&<screenId>.page=…`.
|
|
5
|
+
// Zwei Listen auf der Route teilen sich die URL ohne Param-Konflikt.
|
|
6
|
+
// Page wird bei Sort/Filter-Wechsel reseted; Sort bleibt bei Search.
|
|
7
|
+
|
|
8
|
+
import { act, renderHook } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { describe, expect, test, vi } from "vitest";
|
|
11
|
+
import type { NavApi } from "../app/nav";
|
|
12
|
+
import { NavProvider } from "../app/nav";
|
|
13
|
+
import { useListUrlState } from "../hooks/use-list-url-state";
|
|
14
|
+
|
|
15
|
+
function makeNav(initial: Record<string, string> = {}): NavApi & {
|
|
16
|
+
readonly current: { params: Record<string, string> };
|
|
17
|
+
readonly captures: Array<Record<string, string | null>>;
|
|
18
|
+
} {
|
|
19
|
+
const params: Record<string, string> = { ...initial };
|
|
20
|
+
const captures: Array<Record<string, string | null>> = [];
|
|
21
|
+
const api: NavApi & {
|
|
22
|
+
current: { params: Record<string, string> };
|
|
23
|
+
captures: Array<Record<string, string | null>>;
|
|
24
|
+
} = {
|
|
25
|
+
route: undefined,
|
|
26
|
+
navigate: vi.fn(),
|
|
27
|
+
replace: vi.fn(),
|
|
28
|
+
hrefFor: () => "",
|
|
29
|
+
get searchParams() {
|
|
30
|
+
return params;
|
|
31
|
+
},
|
|
32
|
+
setSearchParams: (updates) => {
|
|
33
|
+
captures.push(updates);
|
|
34
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
35
|
+
if (v === null) delete params[k];
|
|
36
|
+
else params[k] = v;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
current: { params },
|
|
40
|
+
captures,
|
|
41
|
+
};
|
|
42
|
+
return api;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function wrapper(nav: NavApi): (props: { children: ReactNode }) => ReactNode {
|
|
46
|
+
return ({ children }) => <NavProvider value={nav}>{children}</NavProvider>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("useListUrlState", () => {
|
|
50
|
+
test("Default-State: kein URL-Param → sort=null, q='', page=1", () => {
|
|
51
|
+
const nav = makeNav();
|
|
52
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
53
|
+
expect(result.current.sort).toBeNull();
|
|
54
|
+
expect(result.current.q).toBe("");
|
|
55
|
+
expect(result.current.page).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("liest sort+dir aus URL-Params (mit screenId-Prefix)", () => {
|
|
59
|
+
const nav = makeNav({ "orders.sort": "createdAt", "orders.dir": "desc" });
|
|
60
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
61
|
+
expect(result.current.sort).toEqual({ field: "createdAt", dir: "desc" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("ignoriert sort einer ANDEREN Liste (Namespacing)", () => {
|
|
65
|
+
const nav = makeNav({ "incidents.sort": "severity", "incidents.dir": "asc" });
|
|
66
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
67
|
+
expect(result.current.sort).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("invalid dir (z.B. 'foo') → sort=null (defensive parse)", () => {
|
|
71
|
+
const nav = makeNav({ "orders.sort": "name", "orders.dir": "foo" });
|
|
72
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
73
|
+
expect(result.current.sort).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("setSort schreibt sort+dir, resettet page (atomic update)", () => {
|
|
77
|
+
const nav = makeNav({ "orders.page": "5" });
|
|
78
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
79
|
+
act(() => {
|
|
80
|
+
result.current.setSort({ field: "name", dir: "asc" });
|
|
81
|
+
});
|
|
82
|
+
expect(nav.captures).toHaveLength(1);
|
|
83
|
+
expect(nav.captures[0]).toEqual({
|
|
84
|
+
"orders.sort": "name",
|
|
85
|
+
"orders.dir": "asc",
|
|
86
|
+
"orders.page": null, // page-reset bei sort-change
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("setSort(null) löscht sort+dir+page", () => {
|
|
91
|
+
const nav = makeNav({
|
|
92
|
+
"orders.sort": "name",
|
|
93
|
+
"orders.dir": "asc",
|
|
94
|
+
"orders.page": "3",
|
|
95
|
+
});
|
|
96
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
97
|
+
act(() => {
|
|
98
|
+
result.current.setSort(null);
|
|
99
|
+
});
|
|
100
|
+
expect(nav.captures[0]).toEqual({
|
|
101
|
+
"orders.sort": null,
|
|
102
|
+
"orders.dir": null,
|
|
103
|
+
"orders.page": null,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("setQ schreibt q + resettet page (Sort bleibt unangetastet)", () => {
|
|
108
|
+
const nav = makeNav({
|
|
109
|
+
"orders.sort": "name",
|
|
110
|
+
"orders.dir": "asc",
|
|
111
|
+
"orders.page": "3",
|
|
112
|
+
});
|
|
113
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
114
|
+
act(() => {
|
|
115
|
+
result.current.setQ("acme");
|
|
116
|
+
});
|
|
117
|
+
expect(nav.captures[0]).toEqual({
|
|
118
|
+
"orders.q": "acme",
|
|
119
|
+
"orders.page": null,
|
|
120
|
+
});
|
|
121
|
+
// Sort darf NICHT in den captures sein — search-change zerlegt
|
|
122
|
+
// die Sortierung nicht.
|
|
123
|
+
expect(nav.captures[0]).not.toHaveProperty("orders.sort");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("setQ('') löscht den q-Key", () => {
|
|
127
|
+
const nav = makeNav({ "orders.q": "acme" });
|
|
128
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.setQ("");
|
|
131
|
+
});
|
|
132
|
+
expect(nav.captures[0]).toEqual({
|
|
133
|
+
"orders.q": null,
|
|
134
|
+
"orders.page": null,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("setPage(1) löscht den Key (Default-Page = unprefixed URL)", () => {
|
|
139
|
+
const nav = makeNav({ "orders.page": "5" });
|
|
140
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
141
|
+
act(() => {
|
|
142
|
+
result.current.setPage(1);
|
|
143
|
+
});
|
|
144
|
+
expect(nav.captures[0]).toEqual({ "orders.page": null });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("setPage(N>1) speichert die Page als String", () => {
|
|
148
|
+
const nav = makeNav();
|
|
149
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
150
|
+
act(() => {
|
|
151
|
+
result.current.setPage(7);
|
|
152
|
+
});
|
|
153
|
+
expect(nav.captures[0]).toEqual({ "orders.page": "7" });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("invalid page (negativ, NaN, 0) → fallback auf 1", () => {
|
|
157
|
+
const nav = makeNav({ "orders.page": "-3" });
|
|
158
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
159
|
+
expect(result.current.page).toBe(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Shim für ActionForm-Renderer (Tier 2.7d).
|
|
2
|
+
//
|
|
3
|
+
// RenderEdit verlangt heute eine `entity: EntityDefinition` + ein
|
|
4
|
+
// `screen: EntityEditScreenDefinition` Pair, nutzt aber intern nur
|
|
5
|
+
// `entity.fields` und `screen.layout`. ActionForm-Screens haben weder
|
|
6
|
+
// eine Entity-Reference noch sind sie type: "entityEdit" — die zwei
|
|
7
|
+
// Helper hier shapen den Daten-Input ad-hoc um, damit RenderEdit
|
|
8
|
+
// reused werden kann ohne den Stack zu duplizieren.
|
|
9
|
+
//
|
|
10
|
+
// Schulden-Flag: Sobald RenderEdit auf entity.transitions / idType /
|
|
11
|
+
// defaultCurrency / softDelete zugreift, oder ein zukünftiger Boot-
|
|
12
|
+
// Validator die schema.entities-Map cross-referenziert, brechen die
|
|
13
|
+
// Type-Lies hier silent. Dann ist es Zeit für eine echte
|
|
14
|
+
// RenderActionForm-Komponente, die `fields` direkt als Input nimmt.
|
|
15
|
+
// Der Boot-Validator kennt actionForm bereits eigenständig (kein
|
|
16
|
+
// entity-Lookup), also ist der Server-Pfad davon nicht betroffen.
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ActionFormScreenDefinition,
|
|
20
|
+
EntityDefinition,
|
|
21
|
+
EntityEditScreenDefinition,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
23
|
+
|
|
24
|
+
const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
|
|
25
|
+
|
|
26
|
+
/** Baut eine minimale EntityDefinition aus den Inline-Fields des
|
|
27
|
+
* ActionForm-Screens. RenderEdit + computeEditViewModel iterieren
|
|
28
|
+
* über entity.fields zur Render-Zeit; alle weiteren EntityDefinition-
|
|
29
|
+
* Felder bleiben undefined. */
|
|
30
|
+
export function synthesizeActionFormEntity(
|
|
31
|
+
fields: ActionFormScreenDefinition["fields"],
|
|
32
|
+
): EntityDefinition {
|
|
33
|
+
return { fields } as EntityDefinition;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Wandelt ein ActionFormScreenDefinition in die EntityEditScreen-
|
|
37
|
+
* Shape die RenderEdit erwartet. type wird auf "entityEdit" gesetzt
|
|
38
|
+
* damit der Type-Constraint hält; entity wird auf den Pseudo-Namen
|
|
39
|
+
* gepinnt — RenderEdit liest das Feld nicht. */
|
|
40
|
+
export function synthesizeActionFormScreen(
|
|
41
|
+
screen: ActionFormScreenDefinition,
|
|
42
|
+
): EntityEditScreenDefinition {
|
|
43
|
+
return {
|
|
44
|
+
id: screen.id,
|
|
45
|
+
type: "entityEdit",
|
|
46
|
+
entity: ACTION_FORM_PSEUDO_ENTITY,
|
|
47
|
+
layout: screen.layout,
|
|
48
|
+
...(screen.access !== undefined && { access: screen.access }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Column-Renderer-Map: client-side Lookup für ListColumn-Spalten die im
|
|
2
|
+
// Schema einen PlatformComponent-Renderer (`{ react: { __component: "Name" } }`)
|
|
3
|
+
// statt einer String-Funktion angeben. createKumikoApp im renderer-web sammelt
|
|
4
|
+
// alle clientFeatures.columnRenderers und mountet den Provider; der DataTable-
|
|
5
|
+
// Cell-Renderer schlägt den Component über `useColumnRenderer(name)` nach.
|
|
6
|
+
//
|
|
7
|
+
// Analog zu CustomScreensProvider — der Renderer-Web-Bootstrap mounted beide
|
|
8
|
+
// Provider an derselben Stelle. Schemas selbst bleiben serializable: der
|
|
9
|
+
// Component wird nur per String-Key referenziert, die Map liegt allein
|
|
10
|
+
// client-seitig.
|
|
11
|
+
|
|
12
|
+
import { type ComponentType, createContext, type ReactNode, useContext } from "react";
|
|
13
|
+
|
|
14
|
+
// Props die ein Column-Renderer-Component bekommt. `value` ist der
|
|
15
|
+
// Cell-Value für die Spalte (rohes Field-Value aus der Row), `row` ist
|
|
16
|
+
// die ganze Row als plain object — nice-to-have für Renderer die auf
|
|
17
|
+
// andere Spalten zugreifen wollen (z.B. Status-Badge der den
|
|
18
|
+
// Erstellungs-Zeitpunkt aus der Row mitnimmt). `column.field` ist der
|
|
19
|
+
// Field-Name; hilfreich für generische Renderer (JSON-Pretty-Printer
|
|
20
|
+
// oder Debug-Renderer die den Feldnamen anzeigen).
|
|
21
|
+
export type ColumnRendererProps = {
|
|
22
|
+
readonly value: unknown;
|
|
23
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
24
|
+
readonly column: {
|
|
25
|
+
readonly field: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ColumnRendererComponent = ComponentType<ColumnRendererProps>;
|
|
30
|
+
|
|
31
|
+
export type ColumnRenderersMap = Readonly<Record<string, ColumnRendererComponent>>;
|
|
32
|
+
|
|
33
|
+
const ColumnRenderersContext = createContext<ColumnRenderersMap | undefined>(undefined);
|
|
34
|
+
|
|
35
|
+
export type ColumnRenderersProviderProps = {
|
|
36
|
+
readonly children: ReactNode;
|
|
37
|
+
readonly value: ColumnRenderersMap;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function ColumnRenderersProvider({
|
|
41
|
+
children,
|
|
42
|
+
value,
|
|
43
|
+
}: ColumnRenderersProviderProps): ReactNode {
|
|
44
|
+
return (
|
|
45
|
+
<ColumnRenderersContext.Provider value={value}>{children}</ColumnRenderersContext.Provider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Schaut die Component für einen Renderer-Key nach. Returnt undefined
|
|
50
|
+
* wenn `name` leer/undefined ist (Caller hat keinen __component-Renderer)
|
|
51
|
+
* oder weder Provider gemounted noch der Key in der Map ist. Der Caller
|
|
52
|
+
* (DataTable.renderCell) loggt dann eine Warnung und fällt auf den
|
|
53
|
+
* Default-Type-Renderer zurück.
|
|
54
|
+
*
|
|
55
|
+
* `name` ist optional damit Caller den Hook unkonditional aufrufen
|
|
56
|
+
* können — Rules-of-Hooks verbieten einen Function-Branch der den Hook
|
|
57
|
+
* überspringt. So bleibt der Aufruf eine einzige Zeile, ohne dass der
|
|
58
|
+
* Caller einen Stub-Key wie `""` reichen muss. */
|
|
59
|
+
export function useColumnRenderer(name?: string): ColumnRendererComponent | undefined {
|
|
60
|
+
const map = useContext(ColumnRenderersContext);
|
|
61
|
+
if (name === undefined || name === "") return undefined;
|
|
62
|
+
if (map === undefined) return undefined;
|
|
63
|
+
return map[name];
|
|
64
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Shim für ConfigEdit-Renderer (analog zu action-form-shim.ts).
|
|
2
|
+
//
|
|
3
|
+
// RenderEdit verlangt eine `entity: EntityDefinition` + ein
|
|
4
|
+
// `screen: EntityEditScreenDefinition` Pair, nutzt aber intern nur
|
|
5
|
+
// `entity.fields` und `screen.layout` für das Rendern. ConfigEdit-
|
|
6
|
+
// Screens haben weder eine Entity-Reference noch sind sie
|
|
7
|
+
// type: "entityEdit" — die zwei Helper hier shapen den Daten-Input
|
|
8
|
+
// ad-hoc um, damit RenderEdit reused werden kann.
|
|
9
|
+
//
|
|
10
|
+
// Selbe Schulden-Reservation wie bei action-form-shim: sobald RenderEdit
|
|
11
|
+
// auf entity.transitions / idType / softDelete zugreift, oder ein
|
|
12
|
+
// zukünftiger Boot-Validator die schema.entities-Map cross-referenziert,
|
|
13
|
+
// brechen die Type-Lies hier silent. Dann ist Zeit für eine echte
|
|
14
|
+
// RenderConfigEdit-Komponente die direkt fields nimmt.
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
ConfigEditScreenDefinition,
|
|
18
|
+
EntityDefinition,
|
|
19
|
+
EntityEditScreenDefinition,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
21
|
+
|
|
22
|
+
const CONFIG_EDIT_PSEUDO_ENTITY = "__config-edit__";
|
|
23
|
+
|
|
24
|
+
/** Baut eine minimale EntityDefinition aus den Inline-Fields des
|
|
25
|
+
* ConfigEdit-Screens. RenderEdit + computeEditViewModel iterieren
|
|
26
|
+
* über entity.fields zur Render-Zeit; alle weiteren EntityDefinition-
|
|
27
|
+
* Felder bleiben undefined. */
|
|
28
|
+
export function synthesizeConfigEditEntity(
|
|
29
|
+
fields: ConfigEditScreenDefinition["fields"],
|
|
30
|
+
): EntityDefinition {
|
|
31
|
+
return { fields } as EntityDefinition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wandelt ein ConfigEditScreenDefinition in die EntityEditScreen-
|
|
35
|
+
* Shape die RenderEdit erwartet. type wird auf "entityEdit" gesetzt
|
|
36
|
+
* damit der Type-Constraint hält; entity wird auf den Pseudo-Namen
|
|
37
|
+
* gepinnt — RenderEdit liest das Feld nicht. */
|
|
38
|
+
export function synthesizeConfigEditScreen(
|
|
39
|
+
screen: ConfigEditScreenDefinition,
|
|
40
|
+
): EntityEditScreenDefinition {
|
|
41
|
+
return {
|
|
42
|
+
id: screen.id,
|
|
43
|
+
type: "entityEdit",
|
|
44
|
+
entity: CONFIG_EDIT_PSEUDO_ENTITY,
|
|
45
|
+
layout: screen.layout,
|
|
46
|
+
...(screen.access !== undefined && { access: screen.access }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Custom-Screen-Components-Map: client-side Lookup für Screens vom Typ
|
|
2
|
+
// `custom`. KumikoScreen schaut hier nach `screen.id` (oder qn) und
|
|
3
|
+
// rendert die passende Component. createKumikoApp im renderer-web sammelt
|
|
4
|
+
// alle clientFeatures.components und mountet den Provider; Tests die
|
|
5
|
+
// einzelne Custom-Screens prüfen wollen, mounten den Provider direkt.
|
|
6
|
+
|
|
7
|
+
import { type ComponentType, createContext, type ReactNode, useContext } from "react";
|
|
8
|
+
|
|
9
|
+
export type CustomScreensMap = Readonly<Record<string, ComponentType>>;
|
|
10
|
+
|
|
11
|
+
const CustomScreensContext = createContext<CustomScreensMap | undefined>(undefined);
|
|
12
|
+
|
|
13
|
+
export type CustomScreensProviderProps = {
|
|
14
|
+
readonly children: ReactNode;
|
|
15
|
+
readonly value: CustomScreensMap;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function CustomScreensProvider({ children, value }: CustomScreensProviderProps): ReactNode {
|
|
19
|
+
return <CustomScreensContext.Provider value={value}>{children}</CustomScreensContext.Provider>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Schaut die Component für ein Custom-Screen nach. Returnt undefined
|
|
23
|
+
* wenn weder Provider gemounted noch screenId in der Map ist — der
|
|
24
|
+
* Caller (KumikoScreen) zeigt dann seinen Placeholder-Banner. */
|
|
25
|
+
export function useCustomScreenComponent(screenId: string): ComponentType | undefined {
|
|
26
|
+
const map = useContext(CustomScreensContext);
|
|
27
|
+
if (map === undefined) return undefined;
|
|
28
|
+
return map[screenId];
|
|
29
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Client-safe view of a feature: the subset the renderer needs to
|
|
2
|
+
// mount screens. Intentionally narrower than the server-side
|
|
3
|
+
// FeatureDefinition (no handlers, no hooks, no projections) so the
|
|
4
|
+
// file that exports a schema can be imported from the browser bundle
|
|
5
|
+
// without dragging in Node-only framework internals.
|
|
6
|
+
//
|
|
7
|
+
// Types wohnen in `framework/ui-types/app-schema.ts` damit der Server
|
|
8
|
+
// (buildAppSchema, dev-server) sie produzieren kann ohne renderer als
|
|
9
|
+
// Dependency zu ziehen. Hier nur Re-Export + Runtime-Helpers
|
|
10
|
+
// (toAppSchema, isAppSchema) die Client-Code zur Laufzeit braucht.
|
|
11
|
+
//
|
|
12
|
+
// Typical layout on the feature author's side:
|
|
13
|
+
//
|
|
14
|
+
// // feature-schema.ts (imported by client AND server)
|
|
15
|
+
// export const taskEntity = createEntity({ ... });
|
|
16
|
+
// export const editScreen: EntityEditScreenDefinition = { ... };
|
|
17
|
+
// export const clientSchema: FeatureSchema = {
|
|
18
|
+
// featureName: "tasks",
|
|
19
|
+
// entities: { task: taskEntity },
|
|
20
|
+
// screens: [editScreen, ...],
|
|
21
|
+
// };
|
|
22
|
+
//
|
|
23
|
+
// // feature.ts (server-only — imports defineFeature)
|
|
24
|
+
// import { taskEntity, editScreen, ... } from "./feature-schema";
|
|
25
|
+
// export const taskFeature = defineFeature("tasks", (r) => {
|
|
26
|
+
// r.entity("task", taskEntity);
|
|
27
|
+
// r.writeHandler(...);
|
|
28
|
+
// r.screen(editScreen);
|
|
29
|
+
// ...
|
|
30
|
+
// });
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
AppSchema,
|
|
34
|
+
FeatureSchema,
|
|
35
|
+
WorkspaceSchema,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
37
|
+
|
|
38
|
+
export type { AppSchema, FeatureSchema, WorkspaceSchema };
|
|
39
|
+
|
|
40
|
+
// Normalisiert FeatureSchema → AppSchema. Idempotent für AppSchema.
|
|
41
|
+
// Hebt eine Feature-lokal deklarierte `workspaces`-Liste (Legacy) auf
|
|
42
|
+
// App-Ebene hoch, damit alle Layouts mit der neuen Form arbeiten können
|
|
43
|
+
// ohne dass alte clientSchemas migriert werden müssen.
|
|
44
|
+
export function toAppSchema(input: FeatureSchema | AppSchema): AppSchema {
|
|
45
|
+
if ("features" in input) return input;
|
|
46
|
+
// Old single-feature shape — wrap.
|
|
47
|
+
const { workspaces, ...feature } = input;
|
|
48
|
+
return {
|
|
49
|
+
features: [feature],
|
|
50
|
+
...(workspaces !== undefined && { workspaces }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// TypeGuard — Caller die schon zur Laufzeit unterscheiden müssen
|
|
55
|
+
// (selten; meist reicht toAppSchema). Nicht via `"features" in x` inline
|
|
56
|
+
// machen — narrow'd TS dann auf den join-Typ statt die echte Differenz.
|
|
57
|
+
export function isAppSchema(input: FeatureSchema | AppSchema): input is AppSchema {
|
|
58
|
+
return "features" in input;
|
|
59
|
+
}
|