@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Dispatcher, DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
3
|
+
import { useStore } from "../hooks/use-store";
|
|
4
|
+
|
|
5
|
+
// React Context threading the Dispatcher through the tree. An app
|
|
6
|
+
// wraps its root in <DispatcherProvider dispatcher={createLiveDispatcher()}>
|
|
7
|
+
// and every hook below reaches into that context instead of taking the
|
|
8
|
+
// dispatcher as a prop on every call site. Same pattern most routers
|
|
9
|
+
// and query-libraries use; nothing novel, but worth spelling out once.
|
|
10
|
+
//
|
|
11
|
+
// The context holds the Dispatcher INSTANCE, not a factory — a single
|
|
12
|
+
// dispatcher per app (rebuilding would wipe its in-flight tracking and
|
|
13
|
+
// status listeners). Tests wire a fake dispatcher in directly.
|
|
14
|
+
|
|
15
|
+
const DispatcherContext = createContext<Dispatcher | null>(null);
|
|
16
|
+
|
|
17
|
+
export type DispatcherProviderProps = {
|
|
18
|
+
readonly dispatcher: Dispatcher;
|
|
19
|
+
readonly children: ReactNode;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function DispatcherProvider({ dispatcher, children }: DispatcherProviderProps): ReactNode {
|
|
23
|
+
return <DispatcherContext value={dispatcher}>{children}</DispatcherContext>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Reads the ambient Dispatcher. Throws instead of returning null when
|
|
27
|
+
// no provider is mounted — a dispatcher-less hook is always a
|
|
28
|
+
// developer error (the app forgot to wrap its root) and surfacing it
|
|
29
|
+
// early beats debugging "why did my write silently do nothing" later.
|
|
30
|
+
export function useDispatcher(): Dispatcher {
|
|
31
|
+
const dispatcher = useContext(DispatcherContext);
|
|
32
|
+
if (!dispatcher) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"useDispatcher: no <DispatcherProvider> mounted above this component. Wrap your app root with <DispatcherProvider dispatcher={createLiveDispatcher()}>.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return dispatcher;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Soft-Variante: null wenn kein Provider mounted statt throw. Für
|
|
41
|
+
// Components die einen Dispatcher OPTIONAL brauchen (z.B. KumikoScreen
|
|
42
|
+
// rendert mit/ohne rowActions; die Test-Suite mountet kein Dispatcher
|
|
43
|
+
// wenn der Screen keine Mutation triggert). Caller ist zuständig dass
|
|
44
|
+
// er bei null einen sinnvollen Fallback hat (typisch: kein Action-Wiring).
|
|
45
|
+
export function useOptionalDispatcher(): Dispatcher | undefined {
|
|
46
|
+
return useContext(DispatcherContext) ?? undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Subscribes to online/offline/syncing transitions. The dispatcher
|
|
50
|
+
// exposes `statusStore: Store<DispatcherStatus>` directly; useStore is
|
|
51
|
+
// the canonical React-binding for that contract.
|
|
52
|
+
//
|
|
53
|
+
// Server rendering: useStore reads the store's getServerSnapshot which
|
|
54
|
+
// returns the same snapshot the client sees on hydration — for the
|
|
55
|
+
// live-dispatcher that's "online" (the optimistic boot default), so
|
|
56
|
+
// no flash on hydration.
|
|
57
|
+
export function useDispatcherStatus(): DispatcherStatus {
|
|
58
|
+
return useStore(useDispatcher().statusStore);
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Tier 2.7e Audit-Fix #7: Reference-Lookup-Limits zentral.
|
|
2
|
+
//
|
|
3
|
+
// Zwei verschiedene Use-Cases mit unterschiedlichen Default-Limits:
|
|
4
|
+
// - Combobox-Edit (single User picks one): 50 reicht weil
|
|
5
|
+
// typed-search-Fallback aktiv ist (REMOTE_SEARCH_DEBOUNCE_MS).
|
|
6
|
+
// - List-Bulk-Display (Cell-Render für viele Rows): 200 weil wir
|
|
7
|
+
// pro Reference-Spalte einmal pro Page laden, nicht pro Cell.
|
|
8
|
+
//
|
|
9
|
+
// Apps können die Defaults überschreiben indem sie die Konstanten
|
|
10
|
+
// hier neu setzen — pro feature/app gibt's heute keine Override-API
|
|
11
|
+
// (das wäre eine Erweiterung in createKumikoApp, separater Sprint).
|
|
12
|
+
// Die Konstanten leben in einem eigenen Modul damit Future-Author
|
|
13
|
+
// sie an einer Stelle findet statt verstreut zwischen render-field
|
|
14
|
+
// und use-reference-lookup.
|
|
15
|
+
|
|
16
|
+
export const REFERENCE_COMBOBOX_LIMIT = 50;
|
|
17
|
+
export const REFERENCE_LIST_LOOKUP_LIMIT = 200;
|
|
18
|
+
export const REFERENCE_SEARCH_DEBOUNCE_MS = 300;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FormController,
|
|
3
|
+
FormControllerOptions,
|
|
4
|
+
FormSnapshot,
|
|
5
|
+
FormValues,
|
|
6
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { createFormController } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
9
|
+
import { useDispatcher } from "../context/dispatcher-context";
|
|
10
|
+
|
|
11
|
+
// Thin React wrapper around createFormController. Returns both the
|
|
12
|
+
// controller (imperative — setField, submit, reset) and the current
|
|
13
|
+
// snapshot (reactive — values, errors, isDirty). React subscribes via
|
|
14
|
+
// useSyncExternalStore so re-renders happen exactly when the snapshot
|
|
15
|
+
// reference changes, which the controller guarantees.
|
|
16
|
+
//
|
|
17
|
+
// The `submit.dispatcher` config is filled from context if omitted,
|
|
18
|
+
// so the typical call site is just:
|
|
19
|
+
// const { snapshot, controller } = useForm({
|
|
20
|
+
// initial: { ... },
|
|
21
|
+
// schema,
|
|
22
|
+
// submit: { type: "foo:create" },
|
|
23
|
+
// });
|
|
24
|
+
// and the hook wires the ambient DispatcherProvider's dispatcher in.
|
|
25
|
+
|
|
26
|
+
export type UseFormOptions<TValues extends FormValues, TCtx = unknown> = Omit<
|
|
27
|
+
FormControllerOptions<TValues, TCtx>,
|
|
28
|
+
"submit"
|
|
29
|
+
> & {
|
|
30
|
+
// `submit` here takes everything the ui-core SubmitConfig takes
|
|
31
|
+
// EXCEPT dispatcher — that comes from context. Passing an explicit
|
|
32
|
+
// dispatcher here (e.g. from a test) overrides the context one.
|
|
33
|
+
readonly submit?: Omit<
|
|
34
|
+
NonNullable<FormControllerOptions<TValues, TCtx>["submit"]>,
|
|
35
|
+
"dispatcher"
|
|
36
|
+
> & {
|
|
37
|
+
readonly dispatcher?: NonNullable<FormControllerOptions<TValues, TCtx>["submit"]>["dispatcher"];
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type UseFormResult<TValues extends FormValues> = {
|
|
42
|
+
readonly controller: FormController<TValues>;
|
|
43
|
+
readonly snapshot: FormSnapshot<TValues>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function useForm<TValues extends FormValues, TCtx = unknown>(
|
|
47
|
+
options: UseFormOptions<TValues, TCtx>,
|
|
48
|
+
): UseFormResult<TValues> {
|
|
49
|
+
const contextDispatcher = useDispatcher();
|
|
50
|
+
|
|
51
|
+
// The controller is created once per mount. `options` mutates across
|
|
52
|
+
// renders in normal React usage (closures get new references), but
|
|
53
|
+
// the controller's behaviour depends only on the initial shape — we
|
|
54
|
+
// deliberately don't re-create on option-identity change, which
|
|
55
|
+
// would wipe in-flight state and "forget" dirty edits on every
|
|
56
|
+
// parent re-render. If the app needs a reset, call controller.reset()
|
|
57
|
+
// or re-mount the form (key prop).
|
|
58
|
+
// Controller lifetime = hook lifetime. Options deliberately NOT in
|
|
59
|
+
// the deps array; re-creating on option-identity change would wipe
|
|
60
|
+
// in-flight state and dirty edits on every parent re-render.
|
|
61
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — controller is lifetime-scoped, not render-scoped
|
|
62
|
+
const controller = useMemo<FormController<TValues>>(() => {
|
|
63
|
+
// Default the dispatcher to the ambient one, then hand ui-core
|
|
64
|
+
// a fully-populated SubmitConfig. The input signature lets the
|
|
65
|
+
// caller omit `dispatcher`; the output shape createFormController
|
|
66
|
+
// expects requires it.
|
|
67
|
+
type FullOptions = FormControllerOptions<TValues, TCtx>;
|
|
68
|
+
const rawSubmit = options.submit;
|
|
69
|
+
const submitConfig: FullOptions["submit"] = rawSubmit
|
|
70
|
+
? { ...rawSubmit, dispatcher: rawSubmit.dispatcher ?? contextDispatcher }
|
|
71
|
+
: undefined;
|
|
72
|
+
const controllerOptions: FullOptions = {
|
|
73
|
+
...(options as FullOptions),
|
|
74
|
+
...(submitConfig !== undefined && { submit: submitConfig }),
|
|
75
|
+
};
|
|
76
|
+
return createFormController<TValues, TCtx>(controllerOptions);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const snapshot = useSyncExternalStore(
|
|
80
|
+
(notify) => controller.subscribe(notify),
|
|
81
|
+
() => controller.getSnapshot(),
|
|
82
|
+
// SSR snapshot: same as getSnapshot(). The controller's initial
|
|
83
|
+
// snapshot is deterministic from options.initial, safe for server.
|
|
84
|
+
() => controller.getSnapshot(),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return { controller, snapshot };
|
|
88
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// useListUrlState — bündelt sort/dir/page/q einer entityList in einen
|
|
2
|
+
// per-screen-id namespaced URL-State. Mit Screen-ID-Prefix damit zwei
|
|
3
|
+
// Listen auf derselben Route (z.B. Dashboard mit Orders + Incidents)
|
|
4
|
+
// nicht über dieselben Query-Keys streiten.
|
|
5
|
+
//
|
|
6
|
+
// Param-Schema pro Liste:
|
|
7
|
+
// <screenId>.sort — field name (string)
|
|
8
|
+
// <screenId>.dir — "asc" | "desc"
|
|
9
|
+
// <screenId>.q — search term (URL-encoded)
|
|
10
|
+
// <screenId>.page — 1-based page number (nur bei pagination="pages")
|
|
11
|
+
//
|
|
12
|
+
// Schreibt mit setSearchParams (replaceState — kein push), damit
|
|
13
|
+
// Sort/Filter-Toggles nicht die Browser-History fluten.
|
|
14
|
+
|
|
15
|
+
import { useCallback, useMemo } from "react";
|
|
16
|
+
import { useNav } from "../app/nav";
|
|
17
|
+
import type { DataTableSort, DataTableSortDir } from "../primitives";
|
|
18
|
+
|
|
19
|
+
// ListSort + DataTableSort hatten dieselbe Shape und drohten zu driften
|
|
20
|
+
// — aliased auf den primitives-Type (eine Quelle, kein Cast in RenderList).
|
|
21
|
+
export type ListSortDir = DataTableSortDir;
|
|
22
|
+
export type ListSort = DataTableSort;
|
|
23
|
+
|
|
24
|
+
export type ListUrlState = {
|
|
25
|
+
/** Aktive Sortierung (oder null = unsorted, Server liefert Default-Order). */
|
|
26
|
+
readonly sort: ListSort | null;
|
|
27
|
+
/** Search-Term (leer wenn nicht gesetzt). */
|
|
28
|
+
readonly q: string;
|
|
29
|
+
/** 1-basierte Page-Nummer. Bei pagination="infinite" oder false ist
|
|
30
|
+
* der Wert ignoriert; Caller liest ihn nur wenn relevant. */
|
|
31
|
+
readonly page: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ListUrlStateApi = ListUrlState & {
|
|
35
|
+
/** Setzt sort + dir atomar. null = unsorted (löscht beide Keys). */
|
|
36
|
+
readonly setSort: (next: ListSort | null) => void;
|
|
37
|
+
/** Setzt den Search-Term. Empty-String löscht den Key. Caller debounced
|
|
38
|
+
* selber (z.B. useDebouncedCallback in der Search-Input-Komponente). */
|
|
39
|
+
readonly setQ: (next: string) => void;
|
|
40
|
+
/** Setzt die Page. 1 oder kleiner löscht den Key (Default-Page). */
|
|
41
|
+
readonly setPage: (next: number) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
|
|
45
|
+
// üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
|
|
46
|
+
// pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
|
|
47
|
+
function key(screenId: string, suffix: string): string {
|
|
48
|
+
return `${screenId}.${suffix}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseDir(value: string | undefined): ListSortDir | undefined {
|
|
52
|
+
return value === "asc" || value === "desc" ? value : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parsePage(value: string | undefined): number {
|
|
56
|
+
if (value === undefined) return 1;
|
|
57
|
+
const n = Number.parseInt(value, 10);
|
|
58
|
+
return Number.isFinite(n) && n > 0 ? n : 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
62
|
+
const nav = useNav();
|
|
63
|
+
const params = nav.searchParams;
|
|
64
|
+
|
|
65
|
+
const sort = useMemo<ListSort | null>(() => {
|
|
66
|
+
const field = params[key(screenId, "sort")];
|
|
67
|
+
const dir = parseDir(params[key(screenId, "dir")]);
|
|
68
|
+
if (field === undefined || field === "" || dir === undefined) return null;
|
|
69
|
+
return { field, dir };
|
|
70
|
+
}, [params, screenId]);
|
|
71
|
+
|
|
72
|
+
const q = params[key(screenId, "q")] ?? "";
|
|
73
|
+
const page = parsePage(params[key(screenId, "page")]);
|
|
74
|
+
|
|
75
|
+
const setSort = useCallback(
|
|
76
|
+
(next: ListSort | null) => {
|
|
77
|
+
// Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
|
|
78
|
+
// Page (sonst wäre der User auf "Seite 5 von alter Sortierung"
|
|
79
|
+
// hängen, was visuell verwirrend ist). Page-Reset gilt auch bei
|
|
80
|
+
// null — gleiche Logik.
|
|
81
|
+
nav.setSearchParams({
|
|
82
|
+
[key(screenId, "sort")]: next === null ? null : next.field,
|
|
83
|
+
[key(screenId, "dir")]: next === null ? null : next.dir,
|
|
84
|
+
[key(screenId, "page")]: null,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
[nav, screenId],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const setQ = useCallback(
|
|
91
|
+
(next: string) => {
|
|
92
|
+
// Search-Change resettet Page (gleicher Grund wie Sort) UND
|
|
93
|
+
// Sort? Nein — User kann mit aktivem Sort suchen wollen, das
|
|
94
|
+
// soll die Sortierung nicht zerlegen. Nur Page wird gereset.
|
|
95
|
+
nav.setSearchParams({
|
|
96
|
+
[key(screenId, "q")]: next === "" ? null : next,
|
|
97
|
+
[key(screenId, "page")]: null,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
[nav, screenId],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const setPage = useCallback(
|
|
104
|
+
(next: number) => {
|
|
105
|
+
nav.setSearchParams({
|
|
106
|
+
[key(screenId, "page")]: next <= 1 ? null : String(next),
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
[nav, screenId],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return { sort, q, page, setSort, setQ, setPage };
|
|
113
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useDispatcher } from "../context/dispatcher-context";
|
|
4
|
+
import { useLiveEvents } from "../sse/live-events";
|
|
5
|
+
|
|
6
|
+
// React wrapper around dispatcher.query. Fires on mount, re-fires
|
|
7
|
+
// whenever `type` or the serialized payload change, and exposes a
|
|
8
|
+
// `refetch` imperative hook for after-mutation reloads.
|
|
9
|
+
//
|
|
10
|
+
// Cancellation: each fetch owns an AbortController; a newer fetch
|
|
11
|
+
// (change in deps or manual refetch) aborts the older one's network
|
|
12
|
+
// call before firing, so a slow request can't overwrite a fresh one.
|
|
13
|
+
// Unmount aborts any in-flight call too — React will throw the
|
|
14
|
+
// "state on unmounted" warning if we set state after unmount, and
|
|
15
|
+
// aborting pre-emptively makes that path a no-op.
|
|
16
|
+
//
|
|
17
|
+
// Result shape modeled on the dispatcher's own QueryResult: a
|
|
18
|
+
// discriminated state so callers can branch on `loading | data | error`
|
|
19
|
+
// without undefined-juggling. The first render is `loading: true,
|
|
20
|
+
// data: null, error: null` (no optimistic "online" cache — add that
|
|
21
|
+
// later if needed).
|
|
22
|
+
|
|
23
|
+
export type UseQueryResult<TData> = {
|
|
24
|
+
readonly data: TData | null;
|
|
25
|
+
readonly error: DispatcherError | null;
|
|
26
|
+
readonly loading: boolean;
|
|
27
|
+
readonly refetch: () => Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UseQueryOptions = {
|
|
31
|
+
// When false, skips the automatic fetch-on-mount and re-fetch on
|
|
32
|
+
// dep-change. Useful for lazy queries that only run after a user
|
|
33
|
+
// action. `refetch()` still works as an imperative trigger.
|
|
34
|
+
readonly enabled?: boolean;
|
|
35
|
+
// When true, subscribe to SSE events for the entity this query
|
|
36
|
+
// targets and refetch on any create/update/delete/restore event.
|
|
37
|
+
// The entity-name is parsed from the query type using Kumiko's
|
|
38
|
+
// `<feature>:query:<entity>:<verb>` convention — if the type
|
|
39
|
+
// doesn't follow that shape, live-mode is a no-op.
|
|
40
|
+
readonly live?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Extract the entity-name from a standard Kumiko query type. Returns
|
|
44
|
+
// undefined for non-conforming types so the live-mode silently skips
|
|
45
|
+
// them instead of subscribing to a channel no event will ever match.
|
|
46
|
+
function entityFromQueryType(type: string): string | undefined {
|
|
47
|
+
// Expected shape: "<feature>:query:<entity>:<verb>"
|
|
48
|
+
const parts = type.split(":");
|
|
49
|
+
if (parts.length !== 4) return undefined;
|
|
50
|
+
if (parts[1] !== "query") return undefined;
|
|
51
|
+
return parts[2];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useQuery<TData = unknown>(
|
|
55
|
+
type: string,
|
|
56
|
+
payload: unknown,
|
|
57
|
+
options: UseQueryOptions = {},
|
|
58
|
+
): UseQueryResult<TData> {
|
|
59
|
+
const dispatcher = useDispatcher();
|
|
60
|
+
const { enabled = true, live = false } = options;
|
|
61
|
+
|
|
62
|
+
const [data, setData] = useState<TData | null>(null);
|
|
63
|
+
const [error, setError] = useState<DispatcherError | null>(null);
|
|
64
|
+
const [loading, setLoading] = useState<boolean>(enabled);
|
|
65
|
+
|
|
66
|
+
// Track the AbortController for the most recent fetch so a newer
|
|
67
|
+
// call can cancel the older one. Ref, not state, because the
|
|
68
|
+
// controller identity shouldn't trigger a re-render.
|
|
69
|
+
const activeCtrl = useRef<AbortController | null>(null);
|
|
70
|
+
|
|
71
|
+
// Serialize payload so object-identity-changes across renders (the
|
|
72
|
+
// caller building a fresh `{}` every render) don't loop. Strings are
|
|
73
|
+
// stable by reference in React's dep-array check.
|
|
74
|
+
const payloadKey = JSON.stringify(payload);
|
|
75
|
+
|
|
76
|
+
// `dispatcher` is stable (context identity); `type` + `payloadKey`
|
|
77
|
+
// are the meaningful re-run triggers. `payload` itself is intentionally
|
|
78
|
+
// not in deps — it's serialized into payloadKey above.
|
|
79
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
80
|
+
const run = useCallback(async (): Promise<void> => {
|
|
81
|
+
// Abort whatever's in flight. observer on Safari 17+ handles this
|
|
82
|
+
// without raising a fetch-throw in the previous caller — they
|
|
83
|
+
// already set the error path.
|
|
84
|
+
activeCtrl.current?.abort();
|
|
85
|
+
const ctrl = new AbortController();
|
|
86
|
+
activeCtrl.current = ctrl;
|
|
87
|
+
|
|
88
|
+
setLoading(true);
|
|
89
|
+
const result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
|
|
90
|
+
// Don't update state if a newer fetch has already taken over.
|
|
91
|
+
if (ctrl.signal.aborted) return;
|
|
92
|
+
if (result.isSuccess) {
|
|
93
|
+
setData(result.data);
|
|
94
|
+
setError(null);
|
|
95
|
+
} else {
|
|
96
|
+
// A cancelled request comes back with code "aborted" from the
|
|
97
|
+
// dispatcher — skip the state update, another run replaces it.
|
|
98
|
+
if (result.error.code === "aborted") return;
|
|
99
|
+
setError(result.error);
|
|
100
|
+
}
|
|
101
|
+
setLoading(false);
|
|
102
|
+
}, [dispatcher, type, payloadKey]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!enabled) {
|
|
106
|
+
setLoading(false);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
void run();
|
|
110
|
+
return () => {
|
|
111
|
+
activeCtrl.current?.abort();
|
|
112
|
+
};
|
|
113
|
+
}, [enabled, run]);
|
|
114
|
+
|
|
115
|
+
// Live-mode: auf SSE-Events für die Query-Entity hören und refetchen.
|
|
116
|
+
// Separater Effect, damit eine Änderung an `live` oder `type` das
|
|
117
|
+
// Subscription-Lifecycle genau einmal durchwalzt.
|
|
118
|
+
const subscribeLive = useLiveEvents();
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!live || !enabled) return;
|
|
121
|
+
const entity = entityFromQueryType(type);
|
|
122
|
+
if (entity === undefined) return;
|
|
123
|
+
return subscribeLive(entity, () => {
|
|
124
|
+
void run();
|
|
125
|
+
});
|
|
126
|
+
}, [live, enabled, type, run, subscribeLive]);
|
|
127
|
+
|
|
128
|
+
return { data, error, loading, refetch: run };
|
|
129
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Tier 2.7e-4: Renderer-Side Eagerload für Reference-Felder.
|
|
2
|
+
//
|
|
3
|
+
// Pro Reference-Spalte einer Liste wird einmal `<feature>:query:
|
|
4
|
+
// <refEntity>:list` (limit:200) gerufen, eine Map<uuid, displayValue>
|
|
5
|
+
// gebaut, und der Renderer nutzt sie als Cell-Display-Renderer.
|
|
6
|
+
//
|
|
7
|
+
// Strategy-Trade-offs:
|
|
8
|
+
// - Server-side Drizzle-Joins wären effizienter (eine Query statt
|
|
9
|
+
// N+1), würden aber die executor-API um lookupTables erweitern
|
|
10
|
+
// müssen. Für das MVP geht der Renderer-Side-Pfad vor.
|
|
11
|
+
// - Limit:200 ist eine harte UX-Grenze: bei mehr Entries in der
|
|
12
|
+
// referenced Entity zeigen die letzten Rows nur noch UUIDs.
|
|
13
|
+
// Searchable-Combobox (Tier 2.1c) + Server-Eagerload mit
|
|
14
|
+
// ID-Whitelist heben das später auf.
|
|
15
|
+
//
|
|
16
|
+
// Dieser Hook ist call-stable: Lookup-Map wird durch useQuery's
|
|
17
|
+
// internen Cache shared zwischen List + Edit-Form für die gleiche
|
|
18
|
+
// Entity, Live-Updates kommen via SSE (use-query-live).
|
|
19
|
+
|
|
20
|
+
import { useMemo } from "react";
|
|
21
|
+
import { REFERENCE_LIST_LOOKUP_LIMIT } from "./reference-limits";
|
|
22
|
+
import { useQuery } from "./use-query";
|
|
23
|
+
|
|
24
|
+
export type ReferenceLookupMap = ReadonlyMap<string, string>;
|
|
25
|
+
|
|
26
|
+
/** Bulk-Lookup für eine einzelne Reference-Spalte. Liefert eine Map
|
|
27
|
+
* von UUID → Display-Value (aus labelField). Während die Query lädt,
|
|
28
|
+
* ist die Map leer; der Caller fällt dann auf den UUID-Fallback.
|
|
29
|
+
*
|
|
30
|
+
* `featureName` ist hier das **target**-Feature (refFeature aus
|
|
31
|
+
* ViewModel), nicht das current Feature — Cross-Feature-Refs lookup
|
|
32
|
+
* laufen damit gegen `<refFeature>:query:<refEntity>:list`. */
|
|
33
|
+
export function useReferenceLookup(
|
|
34
|
+
featureName: string,
|
|
35
|
+
refEntity: string,
|
|
36
|
+
labelField: string,
|
|
37
|
+
): { readonly map: ReferenceLookupMap; readonly loading: boolean } {
|
|
38
|
+
const queryQn = `${featureName}:query:${refEntity}:list`;
|
|
39
|
+
const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
|
|
40
|
+
limit: REFERENCE_LIST_LOOKUP_LIMIT,
|
|
41
|
+
});
|
|
42
|
+
const map = useMemo(() => {
|
|
43
|
+
const out = new Map<string, string>();
|
|
44
|
+
for (const row of result.data?.rows ?? []) {
|
|
45
|
+
const id = row["id"];
|
|
46
|
+
if (id === undefined || id === null) continue;
|
|
47
|
+
const idStr = String(id);
|
|
48
|
+
const label = row[labelField] ?? id;
|
|
49
|
+
out.set(idStr, String(label));
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}, [result.data, labelField]);
|
|
53
|
+
return { map, loading: result.loading };
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Store } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { useRef, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
// React-Bindings für Kumikos Store-Primitive (`createStore` aus
|
|
5
|
+
// @cosmicdrift/kumiko-headless). Beide Hooks sind dünne Wrapper um React's
|
|
6
|
+
// useSyncExternalStore — die ganze Subscribe/Notify-Mechanik lebt im
|
|
7
|
+
// Store selbst, hier wird nur React verdrahtet.
|
|
8
|
+
//
|
|
9
|
+
// useStore: gibt den ganzen Snapshot zurück. Re-rendert wenn der
|
|
10
|
+
// Store seine Identität wechselt. Object.is-Gate im Store selbst
|
|
11
|
+
// verhindert no-op-Updates.
|
|
12
|
+
//
|
|
13
|
+
// useStoreSelector: gibt eine abgeleitete Sicht zurück. Selectors die
|
|
14
|
+
// Object-/Array-Literale zurückgeben (z.B. `s => ({ a, b })`) erzeugen
|
|
15
|
+
// pro Render eine neue Identität — useSyncExternalStore würde das als
|
|
16
|
+
// "Change" lesen und in eine Re-Render-Schleife laufen. Der dritte
|
|
17
|
+
// Arg `equals` dient genau dafür: gleiche Auswahl → cached
|
|
18
|
+
// Identität zurück, kein Re-Render.
|
|
19
|
+
|
|
20
|
+
export function useStore<T>(store: Store<T>): T {
|
|
21
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useStoreSelector<T, S>(
|
|
25
|
+
store: Store<T>,
|
|
26
|
+
select: (snapshot: T) => S,
|
|
27
|
+
equals: (a: S, b: S) => boolean = Object.is,
|
|
28
|
+
): S {
|
|
29
|
+
// Cache der letzten Auswahl. Beim nächsten getSnapshot-Call vergleicht
|
|
30
|
+
// der Wrapper die neu berechnete Auswahl mit dem Cache und gibt — falls
|
|
31
|
+
// gleich — die cached Identität zurück. Das ist der Trick, der
|
|
32
|
+
// Selector-Returns wie `s => ({ a, b })` stabil hält.
|
|
33
|
+
const lastSelected = useRef<S>(undefined as S);
|
|
34
|
+
const initialized = useRef(false);
|
|
35
|
+
|
|
36
|
+
const getSelectedSnapshot = (): S => {
|
|
37
|
+
const next = select(store.getSnapshot());
|
|
38
|
+
if (initialized.current && equals(lastSelected.current, next)) {
|
|
39
|
+
return lastSelected.current;
|
|
40
|
+
}
|
|
41
|
+
lastSelected.current = next;
|
|
42
|
+
initialized.current = true;
|
|
43
|
+
return next;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return useSyncExternalStore(store.subscribe, getSelectedSnapshot, getSelectedSnapshot);
|
|
47
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Framework-Default-Bundle. Strings die die Renderer-Components hart
|
|
2
|
+
// brauchen (Save/Cancel/Delete-Buttons, Empty-States, Search-Placeholder,
|
|
3
|
+
// Nav-Toggle-aria-Labels, Validation-Reasons). createKumikoApp hängt das
|
|
4
|
+
// als ALLERLETZTEN Fallback in den LocaleProvider — Apps können
|
|
5
|
+
// einzelne Keys via clientFeatures.translations überschreiben.
|
|
6
|
+
//
|
|
7
|
+
// Convention: alle Keys mit `kumiko.`-Prefix damit sie nicht mit
|
|
8
|
+
// App-Keys kollidieren. Sub-Pfade gruppieren nach Bereich (actions /
|
|
9
|
+
// list / nav / form / validation).
|
|
10
|
+
|
|
11
|
+
import type { TranslationsByLocale } from "./i18n";
|
|
12
|
+
|
|
13
|
+
export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
14
|
+
de: {
|
|
15
|
+
// Actions — Buttons in RenderEdit, RenderList, Confirm-Dialogen.
|
|
16
|
+
"kumiko.actions.save": "Speichern",
|
|
17
|
+
"kumiko.actions.cancel": "Abbrechen",
|
|
18
|
+
"kumiko.actions.delete": "Löschen",
|
|
19
|
+
"kumiko.actions.delete-confirm": "Wirklich löschen?",
|
|
20
|
+
"kumiko.actions.reload": "Neu laden",
|
|
21
|
+
"kumiko.actions.create": "Neu",
|
|
22
|
+
|
|
23
|
+
// List — DataTable Toolbar, Empty-State, Search.
|
|
24
|
+
"kumiko.list.search-placeholder": "Suchen…",
|
|
25
|
+
"kumiko.list.empty.title": "Noch keine Einträge.",
|
|
26
|
+
"kumiko.list.empty.hint": "Lege den ersten an, um loszulegen.",
|
|
27
|
+
"kumiko.list.no-entries": "Keine Einträge.",
|
|
28
|
+
|
|
29
|
+
// Combobox — Tier 2.1c Searchable-Select.
|
|
30
|
+
"kumiko.combobox.search-placeholder": "Suchen…",
|
|
31
|
+
"kumiko.combobox.empty": "Keine Treffer.",
|
|
32
|
+
"kumiko.combobox.loading": "Lade…",
|
|
33
|
+
"kumiko.combobox.placeholder": "—",
|
|
34
|
+
|
|
35
|
+
// Nav — Sidebar Tree (Toggle-aria-Labels).
|
|
36
|
+
"kumiko.nav.expand": "Aufklappen",
|
|
37
|
+
"kumiko.nav.collapse": "Zuklappen",
|
|
38
|
+
|
|
39
|
+
// Dialog — Confirm-Buttons + Close-aria-Label.
|
|
40
|
+
"kumiko.dialog.confirm": "Bestätigen",
|
|
41
|
+
"kumiko.dialog.cancel": "Abbrechen",
|
|
42
|
+
"kumiko.dialog.close": "Schließen",
|
|
43
|
+
|
|
44
|
+
// Form — Standard-Errors (App-Code kann eigene zod-Reasons nutzen,
|
|
45
|
+
// diese sind die letzte Sicherheitsschicht).
|
|
46
|
+
"kumiko.form.error.generic": "Etwas ist schiefgegangen.",
|
|
47
|
+
"kumiko.form.error.version-conflict":
|
|
48
|
+
"Datensatz wurde zwischenzeitlich geändert. Lade neu und versuche es erneut.",
|
|
49
|
+
|
|
50
|
+
// Validation — Default-Reason-Codes aus dem Framework. App-Code
|
|
51
|
+
// kann eigene Codes via Validation-Hooks reinwerfen; die hier sind
|
|
52
|
+
// die generischen.
|
|
53
|
+
"kumiko.validation.required": "Pflichtfeld.",
|
|
54
|
+
"kumiko.validation.invalid": "Ungültiger Wert.",
|
|
55
|
+
"kumiko.validation.too-short": "Zu kurz (mindestens {min} Zeichen).",
|
|
56
|
+
"kumiko.validation.too-long": "Zu lang (höchstens {max} Zeichen).",
|
|
57
|
+
"kumiko.validation.out-of-range": "Wert außerhalb des erlaubten Bereichs.",
|
|
58
|
+
},
|
|
59
|
+
en: {
|
|
60
|
+
"kumiko.actions.save": "Save",
|
|
61
|
+
"kumiko.actions.cancel": "Cancel",
|
|
62
|
+
"kumiko.actions.delete": "Delete",
|
|
63
|
+
"kumiko.actions.delete-confirm": "Confirm delete?",
|
|
64
|
+
"kumiko.actions.reload": "Reload",
|
|
65
|
+
"kumiko.actions.create": "New",
|
|
66
|
+
|
|
67
|
+
"kumiko.list.search-placeholder": "Search…",
|
|
68
|
+
"kumiko.list.empty.title": "No entries yet.",
|
|
69
|
+
"kumiko.list.empty.hint": "Create the first one to get started.",
|
|
70
|
+
"kumiko.list.no-entries": "No entries.",
|
|
71
|
+
|
|
72
|
+
"kumiko.combobox.search-placeholder": "Search…",
|
|
73
|
+
"kumiko.combobox.empty": "No matches.",
|
|
74
|
+
"kumiko.combobox.loading": "Loading…",
|
|
75
|
+
"kumiko.combobox.placeholder": "—",
|
|
76
|
+
|
|
77
|
+
"kumiko.nav.expand": "Expand",
|
|
78
|
+
"kumiko.nav.collapse": "Collapse",
|
|
79
|
+
|
|
80
|
+
"kumiko.dialog.confirm": "Confirm",
|
|
81
|
+
"kumiko.dialog.cancel": "Cancel",
|
|
82
|
+
"kumiko.dialog.close": "Close",
|
|
83
|
+
|
|
84
|
+
"kumiko.form.error.generic": "Something went wrong.",
|
|
85
|
+
"kumiko.form.error.version-conflict":
|
|
86
|
+
"Record was modified in the meantime. Reload and try again.",
|
|
87
|
+
|
|
88
|
+
"kumiko.validation.required": "Required.",
|
|
89
|
+
"kumiko.validation.invalid": "Invalid value.",
|
|
90
|
+
"kumiko.validation.too-short": "Too short (at least {min} characters).",
|
|
91
|
+
"kumiko.validation.too-long": "Too long (at most {max} characters).",
|
|
92
|
+
"kumiko.validation.out-of-range": "Value out of allowed range.",
|
|
93
|
+
},
|
|
94
|
+
};
|