@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,299 @@
|
|
|
1
|
+
import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
3
|
+
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
4
|
+
import { useQuery } from "../hooks/use-query";
|
|
5
|
+
import { usePrimitives } from "../primitives";
|
|
6
|
+
|
|
7
|
+
// RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
|
|
8
|
+
// Kein raw HTML mehr; alle Darstellungsentscheidungen (Label-Position,
|
|
9
|
+
// Fehler-Layout, Input-Styling) leben in der Primitives-Implementation.
|
|
10
|
+
//
|
|
11
|
+
// Der field.type → Input-kind Mapping bleibt hier, weil es
|
|
12
|
+
// Domain-Logik ist (EntityDefinition-Feldtyp) und nicht Darstellung.
|
|
13
|
+
|
|
14
|
+
export type RenderFieldProps = {
|
|
15
|
+
readonly field: EditFieldViewModel;
|
|
16
|
+
readonly issues?: readonly FieldIssue[];
|
|
17
|
+
readonly onChange: (value: unknown) => void;
|
|
18
|
+
/** Nur bei type:"reference" relevant — Feature-Name für die Lookup-
|
|
19
|
+
* Query-QN (`<feature>:query:<refEntity>:list`). Andere Field-Types
|
|
20
|
+
* ignorieren das Prop. */
|
|
21
|
+
readonly featureName?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function RenderField({ field, issues, onChange, featureName }: RenderFieldProps): ReactNode {
|
|
25
|
+
const { Field, Input } = usePrimitives();
|
|
26
|
+
if (!field.visible) return null;
|
|
27
|
+
|
|
28
|
+
const id = inputId(field);
|
|
29
|
+
const hasError = issues !== undefined && issues.length > 0;
|
|
30
|
+
|
|
31
|
+
// Reference-Field rendert eine eigene Component — sie nutzt
|
|
32
|
+
// useQuery() für den Live-Lookup, also muss sie als React-
|
|
33
|
+
// Komponente gemountet werden (nicht als pure render-Call).
|
|
34
|
+
const control =
|
|
35
|
+
field.type === "reference" ? (
|
|
36
|
+
<ReferenceInput
|
|
37
|
+
field={field}
|
|
38
|
+
id={id}
|
|
39
|
+
hasError={hasError}
|
|
40
|
+
onChange={onChange}
|
|
41
|
+
Input={Input}
|
|
42
|
+
featureName={featureName ?? ""}
|
|
43
|
+
/>
|
|
44
|
+
) : (
|
|
45
|
+
renderInput({ field, id, hasError, onChange, Input })
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Field
|
|
50
|
+
id={id}
|
|
51
|
+
label={field.label}
|
|
52
|
+
required={field.required}
|
|
53
|
+
{...(issues !== undefined && { issues })}
|
|
54
|
+
testId={`field-${field.field}`}
|
|
55
|
+
>
|
|
56
|
+
{control}
|
|
57
|
+
</Field>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
|
|
62
|
+
// gefüllt aus einer Live-Query auf die referenced Entity. Default-
|
|
63
|
+
// Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
|
|
64
|
+
// (Tier 2.7e-Remote: server-side Search-Query mit debounce kommt später).
|
|
65
|
+
// - Display = row[refLabelField], Default labelField "id".
|
|
66
|
+
// - Loading-State: leeres Dropdown bis die rows da sind. Field
|
|
67
|
+
// ist disabled während useQuery läuft.
|
|
68
|
+
// - Multi-Mode (Tier 2.7e-Multi via field.refMultiple): value ist
|
|
69
|
+
// ein UUID-Array, Combobox rendert Selected-Tags.
|
|
70
|
+
//
|
|
71
|
+
// Storage: UI-Wert ist UUID (row.id) oder UUID-Array bei multiple.
|
|
72
|
+
// Server-Schema: z.uuid() bzw. z.array(z.uuid()).
|
|
73
|
+
// REFERENCE_COMBOBOX_LIMIT lebt zentral in hooks/reference-limits.ts
|
|
74
|
+
// (siehe dort für Begründung der Default-Werte).
|
|
75
|
+
|
|
76
|
+
function ReferenceInput({
|
|
77
|
+
field,
|
|
78
|
+
id,
|
|
79
|
+
hasError,
|
|
80
|
+
onChange,
|
|
81
|
+
Input,
|
|
82
|
+
featureName,
|
|
83
|
+
}: {
|
|
84
|
+
readonly field: EditFieldViewModel;
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly hasError: boolean;
|
|
87
|
+
readonly onChange: (value: unknown) => void;
|
|
88
|
+
readonly Input: ReturnType<typeof usePrimitives>["Input"];
|
|
89
|
+
readonly featureName: string;
|
|
90
|
+
}): ReactNode {
|
|
91
|
+
const refEntity = field.refEntity ?? "";
|
|
92
|
+
const refFeature = field.refFeature ?? featureName;
|
|
93
|
+
const labelField = field.refLabelField ?? "id";
|
|
94
|
+
const isMultiple = field.refMultiple === true;
|
|
95
|
+
// Tier 2.7e Cross-Feature: refFeature kann ≠ featureName sein
|
|
96
|
+
// (z.B. items.assignee → users:query:user:list). Default ist
|
|
97
|
+
// same-feature, kommt aus dem ViewModel (parseRefTarget).
|
|
98
|
+
const queryQn = `${refFeature}:query:${refEntity}:list`;
|
|
99
|
+
// Tier 2.7e Remote-Search: User tippt im Combobox → Server filtert
|
|
100
|
+
// via existing list-payload `search`-Param (Tier 2.6c). Combobox
|
|
101
|
+
// debounced den keystroke selbst (300ms) und ruft onSearchChange.
|
|
102
|
+
// Initial-State leer → erste 50 Items vom Server (default-sortiert).
|
|
103
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
104
|
+
const queryPayload = useMemo<Record<string, unknown>>(
|
|
105
|
+
() =>
|
|
106
|
+
searchTerm === ""
|
|
107
|
+
? { limit: REFERENCE_COMBOBOX_LIMIT }
|
|
108
|
+
: { limit: REFERENCE_COMBOBOX_LIMIT, search: searchTerm },
|
|
109
|
+
[searchTerm],
|
|
110
|
+
);
|
|
111
|
+
const queryResult = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(
|
|
112
|
+
queryQn,
|
|
113
|
+
queryPayload,
|
|
114
|
+
);
|
|
115
|
+
const handleSearchChange = useCallback((q: string) => setSearchTerm(q), []);
|
|
116
|
+
const options = useMemo(() => {
|
|
117
|
+
const rows = queryResult.data?.rows ?? [];
|
|
118
|
+
return rows.map((row) => {
|
|
119
|
+
const idVal = String(row["id"] ?? "");
|
|
120
|
+
const label = String(row[labelField] ?? idVal);
|
|
121
|
+
return { value: idVal, label };
|
|
122
|
+
});
|
|
123
|
+
}, [queryResult.data, labelField]);
|
|
124
|
+
// Single: value ist String/null; Multi: Array. Coerce auf das was
|
|
125
|
+
// der Combobox-Mode erwartet, damit Storage-Drift (Server liefert
|
|
126
|
+
// alten String wo jetzt Array erwartet wird) keine Crash auslöst.
|
|
127
|
+
const baseInputProps = {
|
|
128
|
+
id,
|
|
129
|
+
name: field.field,
|
|
130
|
+
// Initial-Load disabled — danach loading-Indicator im Popover.
|
|
131
|
+
disabled: field.readOnly || (queryResult.loading && options.length === 0),
|
|
132
|
+
required: field.required,
|
|
133
|
+
hasError,
|
|
134
|
+
options,
|
|
135
|
+
onSearchChange: handleSearchChange,
|
|
136
|
+
loading: queryResult.loading,
|
|
137
|
+
} as const;
|
|
138
|
+
if (isMultiple) {
|
|
139
|
+
const arrayValue: readonly string[] = Array.isArray(field.value)
|
|
140
|
+
? (field.value as readonly string[])
|
|
141
|
+
: [];
|
|
142
|
+
return (
|
|
143
|
+
<Input
|
|
144
|
+
kind="combobox"
|
|
145
|
+
{...baseInputProps}
|
|
146
|
+
multiple
|
|
147
|
+
value={arrayValue}
|
|
148
|
+
onChange={(v) => onChange(v)}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const stringValue = field.value === undefined || field.value === null ? "" : String(field.value);
|
|
153
|
+
return (
|
|
154
|
+
<Input
|
|
155
|
+
kind="combobox"
|
|
156
|
+
{...baseInputProps}
|
|
157
|
+
value={stringValue}
|
|
158
|
+
onChange={(v) => onChange(v === "" ? null : v)}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function inputId(field: EditFieldViewModel): string {
|
|
164
|
+
return `kumiko-edit-${field.field}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Dispatch auf field.type → Input-Kind. Select threaded options aus dem
|
|
168
|
+
// EditFieldViewModel (computeEditViewModel zieht sie aus
|
|
169
|
+
// SelectFieldDef.options). Unknown-Types fallen auf text zurück damit
|
|
170
|
+
// die Form was Sinnvolles rendert statt blank zu sein.
|
|
171
|
+
function renderInput({
|
|
172
|
+
field,
|
|
173
|
+
id,
|
|
174
|
+
hasError,
|
|
175
|
+
onChange,
|
|
176
|
+
Input,
|
|
177
|
+
}: {
|
|
178
|
+
readonly field: EditFieldViewModel;
|
|
179
|
+
readonly id: string;
|
|
180
|
+
readonly hasError: boolean;
|
|
181
|
+
readonly onChange: (value: unknown) => void;
|
|
182
|
+
readonly Input: ReturnType<typeof usePrimitives>["Input"];
|
|
183
|
+
}): ReactNode {
|
|
184
|
+
const common = {
|
|
185
|
+
id,
|
|
186
|
+
name: field.field,
|
|
187
|
+
disabled: field.readOnly,
|
|
188
|
+
required: field.required,
|
|
189
|
+
hasError,
|
|
190
|
+
} as const;
|
|
191
|
+
|
|
192
|
+
switch (field.type) {
|
|
193
|
+
case "number":
|
|
194
|
+
return (
|
|
195
|
+
<Input
|
|
196
|
+
kind="number"
|
|
197
|
+
{...common}
|
|
198
|
+
value={numberValue(field.value)}
|
|
199
|
+
onChange={(v) => onChange(v)}
|
|
200
|
+
/>
|
|
201
|
+
);
|
|
202
|
+
case "money": {
|
|
203
|
+
const moneyDef = field as unknown as { currency?: string; locale?: string };
|
|
204
|
+
return (
|
|
205
|
+
<Input
|
|
206
|
+
kind="money"
|
|
207
|
+
{...common}
|
|
208
|
+
value={numberValue(field.value)}
|
|
209
|
+
onChange={(v) => onChange(v)}
|
|
210
|
+
{...(moneyDef.currency !== undefined && { currency: moneyDef.currency })}
|
|
211
|
+
{...(moneyDef.locale !== undefined && { locale: moneyDef.locale })}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
case "boolean":
|
|
216
|
+
return (
|
|
217
|
+
<Input
|
|
218
|
+
kind="boolean"
|
|
219
|
+
{...common}
|
|
220
|
+
value={field.value === true}
|
|
221
|
+
onChange={(v) => onChange(v)}
|
|
222
|
+
/>
|
|
223
|
+
);
|
|
224
|
+
case "date":
|
|
225
|
+
return (
|
|
226
|
+
<Input
|
|
227
|
+
kind="date"
|
|
228
|
+
{...common}
|
|
229
|
+
value={stringValue(field.value)}
|
|
230
|
+
onChange={(v) => onChange(v)}
|
|
231
|
+
/>
|
|
232
|
+
);
|
|
233
|
+
case "timestamp":
|
|
234
|
+
return (
|
|
235
|
+
<Input
|
|
236
|
+
kind="timestamp"
|
|
237
|
+
{...common}
|
|
238
|
+
value={stringValue(field.value)}
|
|
239
|
+
onChange={(v) => onChange(v)}
|
|
240
|
+
/>
|
|
241
|
+
);
|
|
242
|
+
case "select": {
|
|
243
|
+
// Translated Option-Labels kommen aus dem ViewModel-Builder
|
|
244
|
+
// (computeEditViewModel, Convention-Key
|
|
245
|
+
// `<feature>:entity:<entity>:field:<field>:option:<value>`).
|
|
246
|
+
// Wenn keine Translations registriert sind, fallback auf raw
|
|
247
|
+
// value als Label — der ComboboxInput zeigt dann unverändert.
|
|
248
|
+
const rawOptions = field.options ?? [];
|
|
249
|
+
const labels = field.optionLabels;
|
|
250
|
+
const selectOptions =
|
|
251
|
+
labels !== undefined
|
|
252
|
+
? rawOptions.map((value) => ({ value, label: labels[value] ?? value }))
|
|
253
|
+
: rawOptions;
|
|
254
|
+
return (
|
|
255
|
+
<Input
|
|
256
|
+
kind="select"
|
|
257
|
+
{...common}
|
|
258
|
+
value={stringValue(field.value)}
|
|
259
|
+
onChange={(v) => onChange(v)}
|
|
260
|
+
options={selectOptions}
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
default: {
|
|
265
|
+
// text + unknown → text input. Wenn TextFieldDef.multiline gesetzt
|
|
266
|
+
// ist (das ViewModel hält's), wechselt der Renderer auf textarea.
|
|
267
|
+
if (field.type === "text" && field.multiline) {
|
|
268
|
+
const rows = typeof field.multiline === "object" ? field.multiline.rows : undefined;
|
|
269
|
+
return (
|
|
270
|
+
<Input
|
|
271
|
+
kind="textarea"
|
|
272
|
+
{...common}
|
|
273
|
+
value={stringValue(field.value)}
|
|
274
|
+
onChange={(v) => onChange(v)}
|
|
275
|
+
{...(rows !== undefined && { rows })}
|
|
276
|
+
/>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return (
|
|
280
|
+
<Input
|
|
281
|
+
kind="text"
|
|
282
|
+
{...common}
|
|
283
|
+
value={stringValue(field.value)}
|
|
284
|
+
onChange={(v) => onChange(v)}
|
|
285
|
+
/>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stringValue(v: unknown): string {
|
|
292
|
+
if (v === undefined || v === null) return "";
|
|
293
|
+
return typeof v === "string" ? v : String(v);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function numberValue(v: unknown): number | "" {
|
|
297
|
+
if (v === undefined || v === null || v === "") return "";
|
|
298
|
+
return typeof v === "number" ? v : Number(v);
|
|
299
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type { EagerloadedRow } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type {
|
|
3
|
+
EntityDefinition,
|
|
4
|
+
EntityListScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { computeListViewModel } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
|
9
|
+
import type { ListSort } from "../hooks/use-list-url-state";
|
|
10
|
+
import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
|
|
11
|
+
import { useTranslation } from "../i18n";
|
|
12
|
+
import { type DataTableRowAction, usePrimitives } from "../primitives";
|
|
13
|
+
|
|
14
|
+
// RenderList — präsentationaler View für entityList-Screens.
|
|
15
|
+
//
|
|
16
|
+
// Daten + State sind komplett controlled vom Caller (KumikoScreen):
|
|
17
|
+
// - rows kommen aus dem Server (durch useQuery + URL-State-Payload)
|
|
18
|
+
// - sort/q sind Props, RenderList ruft onSortChange/onQChange wenn
|
|
19
|
+
// der User UI-Aktionen macht
|
|
20
|
+
// - Search ist serverseitig (via SearchAdapter / Meilisearch). Lokaler
|
|
21
|
+
// State im Search-Input ist nur Debounce-Buffer, keine Filterung.
|
|
22
|
+
//
|
|
23
|
+
// Apps die RenderList ohne Server-State nutzen wollen (z.B. ein
|
|
24
|
+
// statischer Lookup) liefern stabile rows + lassen sort/q weg —
|
|
25
|
+
// DataTable fällt dann auf "kein Sort-Wiring + kein Search" zurück.
|
|
26
|
+
|
|
27
|
+
export type RenderListProps = {
|
|
28
|
+
readonly screen: EntityListScreenDefinition;
|
|
29
|
+
readonly entity: EntityDefinition;
|
|
30
|
+
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
31
|
+
readonly featureName: string;
|
|
32
|
+
readonly translate?: Translate;
|
|
33
|
+
readonly onRowClick?: (row: ListRowViewModel) => void;
|
|
34
|
+
/** Override der Default-Empty-Box. Wenn gesetzt, wird der Auto-CTA
|
|
35
|
+
* via `onCreate` ignoriert — Caller-Inhalt gewinnt. */
|
|
36
|
+
readonly emptyState?: ReactNode;
|
|
37
|
+
/** Setzt einen "+ Neu" Button in die Toolbar UND dient als CTA in
|
|
38
|
+
* der Default-Empty-Box. Caller liefert die Action selber (z. B.
|
|
39
|
+
* navigate auf den Edit-Screen). */
|
|
40
|
+
readonly onCreate?: () => void;
|
|
41
|
+
/** Label für den + Neu Button. Default kommt aus dem i18n-Bundle
|
|
42
|
+
* (`kumiko.actions.create`). Caller kann durch eigenen String
|
|
43
|
+
* überschreiben. */
|
|
44
|
+
readonly createLabel?: string;
|
|
45
|
+
/** Aktiviert ein Search-Input in der Toolbar. Server-side Filter
|
|
46
|
+
* via Caller's onSearchChange — RenderList filtert NICHT lokal. */
|
|
47
|
+
readonly searchable?: boolean;
|
|
48
|
+
/** Placeholder für das Search-Input. Default kommt aus dem i18n-
|
|
49
|
+
* Bundle (`kumiko.list.search-placeholder`). */
|
|
50
|
+
readonly searchPlaceholder?: string;
|
|
51
|
+
/** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
|
|
52
|
+
* Tipps lokal mit 300ms Debounce, bevor onSearchChange gefeuert
|
|
53
|
+
* wird — sonst macht jeder Tastendruck einen Server-Roundtrip. */
|
|
54
|
+
readonly searchValue?: string;
|
|
55
|
+
/** Wird gerufen wenn der debounced Search-Term sich ändert. Caller
|
|
56
|
+
* setzt damit URL-State (?<id>.q=…) und triggert ein refetch. */
|
|
57
|
+
readonly onSearchChange?: (next: string) => void;
|
|
58
|
+
/** Aktuelle Sortierung (oder null = Server-Default-Order). */
|
|
59
|
+
readonly sort?: ListSort | null;
|
|
60
|
+
/** Wird gerufen mit dem nächsten Sort-State nach einem Header-Klick. */
|
|
61
|
+
readonly onSortChange?: (next: ListSort | null) => void;
|
|
62
|
+
/** Pager-Props für pagination="pages". Wenn undefined, kein Pager-
|
|
63
|
+
* UI — Default oder infinite-Mode. KumikoScreen liefert das je nach
|
|
64
|
+
* screen.pagination. */
|
|
65
|
+
readonly pager?: {
|
|
66
|
+
readonly page: number;
|
|
67
|
+
readonly limit: number;
|
|
68
|
+
readonly total: number;
|
|
69
|
+
readonly onPageChange: (next: number) => void;
|
|
70
|
+
};
|
|
71
|
+
/** Infinite-Scroll-Wiring für pagination="infinite". KumikoScreen
|
|
72
|
+
* hält accumulated rows + cursor, RenderList reicht die Callbacks
|
|
73
|
+
* einfach durch an DataTable. */
|
|
74
|
+
readonly onReachEnd?: () => void;
|
|
75
|
+
readonly loadingMore?: boolean;
|
|
76
|
+
readonly hasMore?: boolean;
|
|
77
|
+
/** Pro-Row-Aktionen — Resolved-Form (KumikoScreen baut das aus
|
|
78
|
+
* EntityListScreenDefinition.rowActions: handler-QN → dispatcher-Call,
|
|
79
|
+
* i18n-Keys → translated Strings). */
|
|
80
|
+
readonly rowActions?: readonly DataTableRowAction[];
|
|
81
|
+
/** Toolbar-Aktionen im List-Header — Resolved-Form (KumikoScreen baut
|
|
82
|
+
* das aus EntityListScreenDefinition.toolbarActions: navigate-target
|
|
83
|
+
* → useNav, handler-QN → dispatcher-Call). RenderList rendert die
|
|
84
|
+
* Buttons rechts in der Toolbar, vor "+ Neu". */
|
|
85
|
+
readonly toolbarActions?: readonly ToolbarActionButton[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
|
|
89
|
+
// Schema (entweder navigate- oder writeHandler-kind), RenderList sieht
|
|
90
|
+
// nur einen onTrigger-Callback + Label/Style — keine kind-Discrimination
|
|
91
|
+
// mehr.
|
|
92
|
+
export type ToolbarActionButton = {
|
|
93
|
+
readonly id: string;
|
|
94
|
+
readonly label: string;
|
|
95
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
96
|
+
readonly confirm?: string;
|
|
97
|
+
readonly confirmLabel?: string;
|
|
98
|
+
readonly onTrigger: () => Promise<void> | void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
102
|
+
|
|
103
|
+
export function RenderList(props: RenderListProps): ReactNode {
|
|
104
|
+
const {
|
|
105
|
+
screen,
|
|
106
|
+
entity,
|
|
107
|
+
rows,
|
|
108
|
+
featureName,
|
|
109
|
+
translate: translateProp,
|
|
110
|
+
onRowClick,
|
|
111
|
+
emptyState,
|
|
112
|
+
onCreate,
|
|
113
|
+
createLabel,
|
|
114
|
+
searchable = false,
|
|
115
|
+
searchPlaceholder,
|
|
116
|
+
searchValue,
|
|
117
|
+
onSearchChange,
|
|
118
|
+
sort,
|
|
119
|
+
onSortChange,
|
|
120
|
+
pager,
|
|
121
|
+
onReachEnd,
|
|
122
|
+
loadingMore,
|
|
123
|
+
hasMore,
|
|
124
|
+
rowActions,
|
|
125
|
+
toolbarActions,
|
|
126
|
+
} = props;
|
|
127
|
+
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
128
|
+
// wären Column-Header raw i18n-Keys.
|
|
129
|
+
const t = useTranslation();
|
|
130
|
+
const translate: Translate = translateProp ?? t;
|
|
131
|
+
const { DataTable, Button, Dialog, Input, Text } = usePrimitives();
|
|
132
|
+
|
|
133
|
+
// Local Search-Buffer + Debounce. Externe Änderungen (Browser-Back,
|
|
134
|
+
// Cross-Component-Reset) spiegeln wir per Sync-Effect zurück; Tipps
|
|
135
|
+
// im Input feuern onSearchChange erst nach 300ms ohne weitere Tasten.
|
|
136
|
+
const [localQ, setLocalQ] = useState(searchValue ?? "");
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
setLocalQ(searchValue ?? "");
|
|
139
|
+
}, [searchValue]);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (onSearchChange === undefined) return;
|
|
142
|
+
if (localQ === (searchValue ?? "")) return;
|
|
143
|
+
const timer = setTimeout(() => onSearchChange(localQ), SEARCH_DEBOUNCE_MS);
|
|
144
|
+
return () => clearTimeout(timer);
|
|
145
|
+
}, [localQ, searchValue, onSearchChange]);
|
|
146
|
+
|
|
147
|
+
const vm = useMemo(
|
|
148
|
+
() => computeListViewModel({ screen, entity, rows, translate, featureName }),
|
|
149
|
+
[screen, entity, rows, translate, featureName],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Tier 2.7e-4: Reference-Field-Eagerload via Bridge-Component-Pattern.
|
|
153
|
+
// Hooks-Rule-Konflikt: pro reference-Spalte muss useReferenceLookup
|
|
154
|
+
// gerufen werden, aber die Anzahl varriiert per Schema. Lösung —
|
|
155
|
+
// ReferenceLookupBridge-Komponenten pro Spalte gemountet, die ihre
|
|
156
|
+
// Map über onMap-Callback in einen lokalen State veröffentlichen.
|
|
157
|
+
// Anzahl der Hook-Calls pro Bridge-Component ist konstant (1), und
|
|
158
|
+
// React verwaltet die mounted/unmounted Lifecycles beim Schema-
|
|
159
|
+
// Wechsel über die key-Property automatisch.
|
|
160
|
+
const referenceColumns = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
vm.columns
|
|
163
|
+
.filter((c) => c.type === "reference" && c.refEntity !== undefined)
|
|
164
|
+
.map((c) => ({
|
|
165
|
+
field: c.field,
|
|
166
|
+
refEntity: c.refEntity ?? "",
|
|
167
|
+
// Tier 2.7e Cross-Feature: refFeature kommt aus parseRefTarget
|
|
168
|
+
// im ViewModel (default = current featureName).
|
|
169
|
+
refFeature: c.refFeature ?? featureName,
|
|
170
|
+
labelField: c.refLabelField ?? "id",
|
|
171
|
+
})),
|
|
172
|
+
[vm.columns, featureName],
|
|
173
|
+
);
|
|
174
|
+
const [referenceLookups, setReferenceLookups] = useState<Record<string, ReferenceLookupMap>>({});
|
|
175
|
+
const handleLookupMap = useCallback((field: string, map: ReferenceLookupMap) => {
|
|
176
|
+
setReferenceLookups((prev) => {
|
|
177
|
+
// skip-update wenn die Map identisch ist (Render-Loop-Schutz —
|
|
178
|
+
// Bridge-Component re-rendert sonst und schickt die gleiche Map
|
|
179
|
+
// wieder rein bis React in eine Endlosschleife geht).
|
|
180
|
+
if (prev[field] === map) return prev;
|
|
181
|
+
return { ...prev, [field]: map };
|
|
182
|
+
});
|
|
183
|
+
}, []);
|
|
184
|
+
const enrichedColumns = useMemo(() => {
|
|
185
|
+
if (referenceColumns.length === 0) return vm.columns;
|
|
186
|
+
return vm.columns.map((col) => {
|
|
187
|
+
if (col.type !== "reference") return col;
|
|
188
|
+
// Author-deklarierter Renderer übersteuert immer — Default greift
|
|
189
|
+
// nur wenn keiner gesetzt ist.
|
|
190
|
+
if (col.renderer !== undefined) return col;
|
|
191
|
+
const map = referenceLookups[col.field];
|
|
192
|
+
const labelField = col.refLabelField ?? "id";
|
|
193
|
+
const renderer = (value: unknown, row?: Readonly<Record<string, unknown>>): string => {
|
|
194
|
+
// Tier 2.7e Server-Eagerload: wenn der Server _refs mit-
|
|
195
|
+
// schickt, lesen wir den Display-Wert direkt aus der
|
|
196
|
+
// resolved Row — kein Roundtrip durch die Bridge-Map nötig
|
|
197
|
+
// und keine limit:200-Constraint. EagerloadedRow-Type aus
|
|
198
|
+
// @cosmicdrift/kumiko-framework/db pinnt die Form von _refs.
|
|
199
|
+
const eagerloadedRow = row as EagerloadedRow | undefined;
|
|
200
|
+
const resolved = eagerloadedRow?._refs?.[col.field];
|
|
201
|
+
if (Array.isArray(resolved) && resolved.length > 0) {
|
|
202
|
+
return resolved.map((r) => String(r[labelField] ?? r["id"] ?? "")).join(", ");
|
|
203
|
+
}
|
|
204
|
+
if (resolved !== undefined && !Array.isArray(resolved)) {
|
|
205
|
+
const single = resolved as Record<string, unknown>; // @cast-boundary render-helper
|
|
206
|
+
return String(single[labelField] ?? single["id"] ?? "");
|
|
207
|
+
}
|
|
208
|
+
// Renderer-Side-Fallback (kein Server-Eagerload aktiv).
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
if (value.length === 0) return "—";
|
|
211
|
+
return value.map((v) => map?.get(String(v)) ?? String(v)).join(", ");
|
|
212
|
+
}
|
|
213
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
214
|
+
const idStr = String(value);
|
|
215
|
+
return map?.get(idStr) ?? idStr;
|
|
216
|
+
};
|
|
217
|
+
return { ...col, renderer };
|
|
218
|
+
});
|
|
219
|
+
}, [vm.columns, referenceColumns, referenceLookups]);
|
|
220
|
+
const enrichedVm = useMemo(() => ({ ...vm, columns: enrichedColumns }), [vm, enrichedColumns]);
|
|
221
|
+
|
|
222
|
+
// i18n-Defaults für Toolbar/Empty-State Strings — Caller kann jeden
|
|
223
|
+
// einzeln per Prop überschreiben, sonst kommen die Framework-Bundles
|
|
224
|
+
// (kumiko.actions.create, kumiko.list.search-placeholder, …).
|
|
225
|
+
const effectiveCreateLabel = createLabel ?? translate("kumiko.actions.create");
|
|
226
|
+
const effectiveSearchPlaceholder =
|
|
227
|
+
searchPlaceholder ?? translate("kumiko.list.search-placeholder");
|
|
228
|
+
|
|
229
|
+
const toolbarStart = searchable ? (
|
|
230
|
+
<Input
|
|
231
|
+
kind="text"
|
|
232
|
+
id="render-list-search"
|
|
233
|
+
name="search"
|
|
234
|
+
value={localQ}
|
|
235
|
+
onChange={setLocalQ}
|
|
236
|
+
placeholder={effectiveSearchPlaceholder}
|
|
237
|
+
/>
|
|
238
|
+
) : undefined;
|
|
239
|
+
|
|
240
|
+
// Toolbar-End-Slot: Toolbar-Actions (List-Header-Buttons aus dem
|
|
241
|
+
// Schema) + optional "+ Neu" am rechten Edge. Reihenfolge im Rendering
|
|
242
|
+
// = Reihenfolge im Array (Schema-deklariert), "+ Neu" kommt zuletzt
|
|
243
|
+
// weil das die häufigste/auffälligste CTA ist.
|
|
244
|
+
const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
|
|
245
|
+
const toolbarEnd =
|
|
246
|
+
hasToolbarActions || onCreate !== undefined ? (
|
|
247
|
+
<>
|
|
248
|
+
{hasToolbarActions &&
|
|
249
|
+
toolbarActions.map((a) => (
|
|
250
|
+
<ToolbarActionView key={a.id} action={a} Button={Button} Dialog={Dialog} />
|
|
251
|
+
))}
|
|
252
|
+
{onCreate !== undefined && (
|
|
253
|
+
<Button variant="primary" onClick={onCreate} testId="render-list-create">
|
|
254
|
+
{`+ ${effectiveCreateLabel}`}
|
|
255
|
+
</Button>
|
|
256
|
+
)}
|
|
257
|
+
</>
|
|
258
|
+
) : undefined;
|
|
259
|
+
|
|
260
|
+
// Empty-State: Default zeigt Heading + Description + optional CTA-
|
|
261
|
+
// Button. Caller kann via emptyState-Prop komplett überschreiben.
|
|
262
|
+
// Wenn weder onCreate noch ein Override gegeben ist, fällt die
|
|
263
|
+
// DataTable auf den DataTable-Default zurück (`kumiko.list.no-entries`).
|
|
264
|
+
const composedEmptyState =
|
|
265
|
+
emptyState ??
|
|
266
|
+
(onCreate !== undefined ? (
|
|
267
|
+
<>
|
|
268
|
+
<Text>{translate("kumiko.list.empty.title")}</Text>
|
|
269
|
+
<Text variant="small">{translate("kumiko.list.empty.hint")}</Text>
|
|
270
|
+
<Button variant="primary" onClick={onCreate} testId="render-list-empty-create">
|
|
271
|
+
{`+ ${effectiveCreateLabel}`}
|
|
272
|
+
</Button>
|
|
273
|
+
</>
|
|
274
|
+
) : undefined);
|
|
275
|
+
|
|
276
|
+
// Title-Resolution: ein konventioneller i18n-Key `screen:<id>.title`.
|
|
277
|
+
// Wenn das Bundle den Key kennt, schöner Titel; sonst kommt der
|
|
278
|
+
// screenId selber raus (kein "Untitled"-Fallback — der App-Dev sieht
|
|
279
|
+
// dann dass die Übersetzung fehlt).
|
|
280
|
+
const titleKey = `screen:${screen.id}.title`;
|
|
281
|
+
const resolvedTitle = translate(titleKey);
|
|
282
|
+
const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
|
|
283
|
+
|
|
284
|
+
// ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
|
|
285
|
+
return (
|
|
286
|
+
<>
|
|
287
|
+
{referenceColumns.map((rc) => (
|
|
288
|
+
<ReferenceLookupBridge
|
|
289
|
+
key={rc.field}
|
|
290
|
+
field={rc.field}
|
|
291
|
+
refEntity={rc.refEntity}
|
|
292
|
+
labelField={rc.labelField}
|
|
293
|
+
featureName={rc.refFeature}
|
|
294
|
+
onMap={handleLookupMap}
|
|
295
|
+
/>
|
|
296
|
+
))}
|
|
297
|
+
<DataTable
|
|
298
|
+
columns={enrichedVm.columns}
|
|
299
|
+
rows={enrichedVm.rows}
|
|
300
|
+
toolbarTitle={toolbarTitle}
|
|
301
|
+
{...(onRowClick !== undefined && { onRowClick })}
|
|
302
|
+
{...(composedEmptyState !== undefined && { emptyState: composedEmptyState })}
|
|
303
|
+
{...(toolbarStart !== undefined && { toolbarStart })}
|
|
304
|
+
{...(toolbarEnd !== undefined && { toolbarEnd })}
|
|
305
|
+
{...(sort !== undefined && { sort })}
|
|
306
|
+
{...(onSortChange !== undefined && { onSortChange })}
|
|
307
|
+
{...(pager !== undefined && { pager })}
|
|
308
|
+
{...(onReachEnd !== undefined && { onReachEnd })}
|
|
309
|
+
{...(loadingMore !== undefined && { loadingMore })}
|
|
310
|
+
{...(hasMore !== undefined && { hasMore })}
|
|
311
|
+
{...(rowActions !== undefined && { rowActions })}
|
|
312
|
+
testId="render-list-table"
|
|
313
|
+
/>
|
|
314
|
+
</>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Tier 2.7e-4: Bridge-Component pro reference-Spalte. Mounted für jede
|
|
319
|
+
// Spalte einmal, ruft useReferenceLookup unconditional (Hook-Rules
|
|
320
|
+
// happy), und published die Map über onMap an den Parent. React
|
|
321
|
+
// verwaltet das Mounting/Unmounting beim Schema-Wechsel über die
|
|
322
|
+
// key-Prop im map-Loop des Parents.
|
|
323
|
+
function ReferenceLookupBridge({
|
|
324
|
+
field,
|
|
325
|
+
refEntity,
|
|
326
|
+
labelField,
|
|
327
|
+
featureName,
|
|
328
|
+
onMap,
|
|
329
|
+
}: {
|
|
330
|
+
readonly field: string;
|
|
331
|
+
readonly refEntity: string;
|
|
332
|
+
readonly labelField: string;
|
|
333
|
+
readonly featureName: string;
|
|
334
|
+
readonly onMap: (field: string, map: ReferenceLookupMap) => void;
|
|
335
|
+
}): null {
|
|
336
|
+
const lookup = useReferenceLookup(featureName, refEntity, labelField);
|
|
337
|
+
// useEffect statt direktem call damit setState außerhalb des Render-
|
|
338
|
+
// Pfads passiert (sonst React-Warning "Cannot update a component
|
|
339
|
+
// while rendering a different component").
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
onMap(field, lookup.map);
|
|
342
|
+
}, [field, lookup.map, onMap]);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ToolbarActionView — pro Toolbar-Action ein Button (+ Confirm-Dialog
|
|
347
|
+
// wenn confirm gesetzt). Same Pattern wie RowAction (Inline-Variante)
|
|
348
|
+
// aber ohne row-Context. busy-State während async onTrigger; Dialog
|
|
349
|
+
// öffnet sich vor dem Trigger wenn confirm/danger gesetzt.
|
|
350
|
+
function ToolbarActionView({
|
|
351
|
+
action,
|
|
352
|
+
Button,
|
|
353
|
+
Dialog,
|
|
354
|
+
}: {
|
|
355
|
+
readonly action: ToolbarActionButton;
|
|
356
|
+
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
357
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
358
|
+
}): ReactNode {
|
|
359
|
+
const [busy, setBusy] = useState(false);
|
|
360
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
361
|
+
|
|
362
|
+
const trigger = async (): Promise<void> => {
|
|
363
|
+
setBusy(true);
|
|
364
|
+
try {
|
|
365
|
+
await action.onTrigger();
|
|
366
|
+
} finally {
|
|
367
|
+
setBusy(false);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const variant: "primary" | "secondary" | "danger" = action.style ?? "secondary";
|
|
372
|
+
const needsConfirm = action.confirm !== undefined || action.style === "danger";
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
<Button
|
|
377
|
+
variant={variant}
|
|
378
|
+
loading={busy}
|
|
379
|
+
onClick={() => {
|
|
380
|
+
if (needsConfirm) {
|
|
381
|
+
setConfirmOpen(true);
|
|
382
|
+
} else {
|
|
383
|
+
void trigger();
|
|
384
|
+
}
|
|
385
|
+
}}
|
|
386
|
+
testId={`render-list-toolbar-action-${action.id}`}
|
|
387
|
+
>
|
|
388
|
+
{action.label}
|
|
389
|
+
</Button>
|
|
390
|
+
<Dialog
|
|
391
|
+
open={confirmOpen}
|
|
392
|
+
onOpenChange={setConfirmOpen}
|
|
393
|
+
title={action.label}
|
|
394
|
+
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
395
|
+
confirmLabel={action.confirmLabel ?? action.label}
|
|
396
|
+
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
397
|
+
onConfirm={trigger}
|
|
398
|
+
testId={`render-list-toolbar-action-${action.id}-dialog`}
|
|
399
|
+
/>
|
|
400
|
+
</>
|
|
401
|
+
);
|
|
402
|
+
}
|