@cosmicdrift/kumiko-renderer 0.64.0 → 0.66.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 +3 -3
- package/src/__tests__/use-list-url-state.test.tsx +53 -0
- package/src/app/kumiko-screen.tsx +73 -1
- package/src/components/render-edit.tsx +27 -8
- package/src/components/render-field.tsx +18 -0
- package/src/components/render-list.tsx +18 -1
- package/src/hooks/use-list-url-state.ts +44 -5
- package/src/i18n-defaults.ts +12 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
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
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.66.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.66.0",
|
|
20
20
|
"react": "^19.2.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
@@ -145,4 +145,57 @@ describe("useListUrlState", () => {
|
|
|
145
145
|
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
146
146
|
expect(result.current.page).toBe(1);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
test("liest Faceted-Filter aus .f.<field>-Keys (comma-split), namespaced", () => {
|
|
150
|
+
const nav = makeNav({
|
|
151
|
+
"orders.f.status": "draft,published",
|
|
152
|
+
"orders.f.active": "true",
|
|
153
|
+
"incidents.f.status": "open", // andere Liste → ignoriert
|
|
154
|
+
});
|
|
155
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
156
|
+
expect(result.current.filters).toEqual({
|
|
157
|
+
status: ["draft", "published"],
|
|
158
|
+
active: ["true"],
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("setFilter schreibt comma-joined Werte + resettet page", () => {
|
|
163
|
+
const nav = makeNav({ "orders.page": "4" });
|
|
164
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
165
|
+
act(() => {
|
|
166
|
+
result.current.setFilter("status", ["draft", "review"]);
|
|
167
|
+
});
|
|
168
|
+
expect(nav.captures[0]).toEqual({
|
|
169
|
+
"orders.f.status": "draft,review",
|
|
170
|
+
"orders.page": null,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("setFilter mit leerem Array löscht den Facet-Key", () => {
|
|
175
|
+
const nav = makeNav({ "orders.f.status": "draft" });
|
|
176
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
177
|
+
act(() => {
|
|
178
|
+
result.current.setFilter("status", []);
|
|
179
|
+
});
|
|
180
|
+
expect(nav.captures[0]).toEqual({
|
|
181
|
+
"orders.f.status": null,
|
|
182
|
+
"orders.page": null,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("clearFilters löscht alle aktiven .f.<field>-Keys", () => {
|
|
187
|
+
const nav = makeNav({
|
|
188
|
+
"orders.f.status": "draft",
|
|
189
|
+
"orders.f.active": "true",
|
|
190
|
+
});
|
|
191
|
+
const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
|
|
192
|
+
act(() => {
|
|
193
|
+
result.current.clearFilters();
|
|
194
|
+
});
|
|
195
|
+
expect(nav.captures[0]).toEqual({
|
|
196
|
+
"orders.page": null,
|
|
197
|
+
"orders.f.status": null,
|
|
198
|
+
"orders.f.active": null,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
148
201
|
});
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
SubmitResult,
|
|
21
21
|
Translate,
|
|
22
22
|
} from "@cosmicdrift/kumiko-headless";
|
|
23
|
+
import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
|
|
23
24
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
24
25
|
import { RenderEdit } from "../components/render-edit";
|
|
25
26
|
import { RenderList, type ToolbarActionButton } from "../components/render-list";
|
|
@@ -27,7 +28,7 @@ import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-cont
|
|
|
27
28
|
import { useListUrlState } from "../hooks/use-list-url-state";
|
|
28
29
|
import { useQuery } from "../hooks/use-query";
|
|
29
30
|
import { useTranslation } from "../i18n";
|
|
30
|
-
import { type DataTableRowAction, usePrimitives } from "../primitives";
|
|
31
|
+
import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
|
|
31
32
|
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
|
|
32
33
|
import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
|
|
33
34
|
import { useCustomScreenComponent } from "./custom-screens";
|
|
@@ -327,6 +328,7 @@ function EntityEditCreateBody({
|
|
|
327
328
|
writeCommand={writeCommand}
|
|
328
329
|
onSubmit={handleSubmitted}
|
|
329
330
|
onCancel={navigateToList}
|
|
331
|
+
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
330
332
|
{...(translate !== undefined && { translate })}
|
|
331
333
|
/>
|
|
332
334
|
);
|
|
@@ -485,6 +487,7 @@ function EntityEditUpdateForm({
|
|
|
485
487
|
{...(screen.allowDelete !== false && { onDelete: handleDelete })}
|
|
486
488
|
onCancel={navigateToList}
|
|
487
489
|
onReload={() => void onReload()}
|
|
490
|
+
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
488
491
|
{...(translate !== undefined && { translate })}
|
|
489
492
|
/>
|
|
490
493
|
);
|
|
@@ -594,6 +597,23 @@ function EntityListBody({
|
|
|
594
597
|
setHasMore(true);
|
|
595
598
|
}, [useInfinite, sortQKey]);
|
|
596
599
|
|
|
600
|
+
// User-gewählte Faceted-Filter aus dem URL-State → payload.filters.
|
|
601
|
+
// Boolean-Felder: "true"/"false"-Strings zu echten Booleans coercen
|
|
602
|
+
// (DB-Spalte ist boolean). Alles als op:"in" (Multi-Select-Semantik).
|
|
603
|
+
const filterPayload = useMemo(() => {
|
|
604
|
+
const out: { field: string; op: "in"; value: unknown }[] = [];
|
|
605
|
+
for (const [field, values] of Object.entries(urlState.filters)) {
|
|
606
|
+
if (values.length === 0) continue;
|
|
607
|
+
// entity.fields ist am Renderer-Layer schwach getypt (vom Schema
|
|
608
|
+
// deserialisiert) — Boundary-Cast wie buildInitialValues.
|
|
609
|
+
const def = entity.fields[field] as { type?: string } | undefined;
|
|
610
|
+
if (def === undefined) continue;
|
|
611
|
+
const value = def.type === "boolean" ? values.map((v) => v === "true") : values;
|
|
612
|
+
out.push({ field, op: "in", value });
|
|
613
|
+
}
|
|
614
|
+
return out;
|
|
615
|
+
}, [urlState.filters, entity.fields]);
|
|
616
|
+
|
|
597
617
|
// Payload für den Server-Query-Handler (LIST_PAYLOAD_SCHEMA):
|
|
598
618
|
// search/sort/sortDirection/limit + offset/totalCount für Pager-Mode
|
|
599
619
|
// ODER cursor für Infinite-Scroll.
|
|
@@ -611,6 +631,9 @@ function EntityListBody({
|
|
|
611
631
|
if (screen.filter !== undefined) {
|
|
612
632
|
payload["filter"] = screen.filter;
|
|
613
633
|
}
|
|
634
|
+
if (filterPayload.length > 0) {
|
|
635
|
+
payload["filters"] = filterPayload;
|
|
636
|
+
}
|
|
614
637
|
if (usePager) {
|
|
615
638
|
// page=1 → offset=0, page=2 → offset=limit, etc. Server
|
|
616
639
|
// clampt selbst wenn offset >= total.
|
|
@@ -629,6 +652,7 @@ function EntityListBody({
|
|
|
629
652
|
urlState.q,
|
|
630
653
|
effectiveSort,
|
|
631
654
|
screen.filter,
|
|
655
|
+
filterPayload,
|
|
632
656
|
usePager,
|
|
633
657
|
urlState.page,
|
|
634
658
|
useInfinite,
|
|
@@ -678,6 +702,48 @@ function EntityListBody({
|
|
|
678
702
|
// sonst kollidieren die Hook-Slots zwischen Renders.
|
|
679
703
|
const t = useTranslation();
|
|
680
704
|
const effectiveTranslate = translate ?? t;
|
|
705
|
+
|
|
706
|
+
// Faceted-Filter: ein Dropdown pro filterable select/boolean-Feld.
|
|
707
|
+
// Labels + select-Option-Labels über dieselbe i18n-Konvention wie die
|
|
708
|
+
// Spalten-Header (fieldLabelKey / :option:<value>).
|
|
709
|
+
const filterFacets = useMemo<DataTableFacet[]>(() => {
|
|
710
|
+
const out: DataTableFacet[] = [];
|
|
711
|
+
for (const [field, rawDef] of Object.entries(entity.fields)) {
|
|
712
|
+
// entity.fields ist am Renderer-Layer schwach getypt (Record<string,
|
|
713
|
+
// unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
|
|
714
|
+
const def = rawDef as {
|
|
715
|
+
type?: string;
|
|
716
|
+
filterable?: boolean;
|
|
717
|
+
options?: readonly string[];
|
|
718
|
+
};
|
|
719
|
+
if (def.filterable !== true) continue;
|
|
720
|
+
const label = effectiveTranslate(fieldLabelKey(featureName, screen.entity, field));
|
|
721
|
+
if (def.type === "select" && Array.isArray(def.options)) {
|
|
722
|
+
out.push({
|
|
723
|
+
field,
|
|
724
|
+
label,
|
|
725
|
+
options: def.options.map((value) => ({
|
|
726
|
+
value,
|
|
727
|
+
label: effectiveTranslate(
|
|
728
|
+
`${featureName}:entity:${screen.entity}:field:${field}:option:${value}`,
|
|
729
|
+
),
|
|
730
|
+
})),
|
|
731
|
+
});
|
|
732
|
+
} else if (def.type === "boolean") {
|
|
733
|
+
out.push({
|
|
734
|
+
field,
|
|
735
|
+
label,
|
|
736
|
+
// ponytail: Yes/No literal — boolean-Facet-Labels i18n'en wenn eine App es braucht
|
|
737
|
+
options: [
|
|
738
|
+
{ value: "true", label: "Yes" },
|
|
739
|
+
{ value: "false", label: "No" },
|
|
740
|
+
],
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return out;
|
|
745
|
+
}, [entity.fields, featureName, screen.entity, effectiveTranslate]);
|
|
746
|
+
|
|
681
747
|
// Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
|
|
682
748
|
// bleibt rowActions undefined statt zu crashen. Echte Apps haben
|
|
683
749
|
// den Provider via createKumikoApp — wenn nicht, ist es vermutlich
|
|
@@ -909,6 +975,12 @@ function EntityListBody({
|
|
|
909
975
|
{...(onCreate !== undefined && { onCreate })}
|
|
910
976
|
{...(translate !== undefined && { translate })}
|
|
911
977
|
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
978
|
+
{...(filterFacets.length > 0 && {
|
|
979
|
+
filterFacets,
|
|
980
|
+
filterValues: urlState.filters,
|
|
981
|
+
onFilterChange: urlState.setFilter,
|
|
982
|
+
onFilterReset: urlState.clearFilters,
|
|
983
|
+
})}
|
|
912
984
|
/>
|
|
913
985
|
);
|
|
914
986
|
}
|
|
@@ -361,21 +361,38 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
361
361
|
</>
|
|
362
362
|
);
|
|
363
363
|
|
|
364
|
-
// Title
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
364
|
+
// Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
|
|
365
|
+
// screen:<id>.<mode>.title / .<mode>.subtitle
|
|
366
|
+
// Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
|
|
367
|
+
// title fällt zuletzt auf screenId, subtitle auf undefined (kein Untertitel).
|
|
368
|
+
const isCreate = (() => {
|
|
369
|
+
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
370
|
+
return id == null || id === "";
|
|
371
|
+
})();
|
|
372
|
+
const formMode = isCreate ? "create" : "edit";
|
|
373
|
+
const resolveScreenText = (suffix: string): string | undefined => {
|
|
374
|
+
for (const key of [
|
|
375
|
+
`screen:${screen.id}.${formMode}.${suffix}`,
|
|
376
|
+
`screen:${screen.id}.${suffix}`,
|
|
377
|
+
]) {
|
|
378
|
+
const value = translate(key);
|
|
379
|
+
if (value !== key) return value;
|
|
380
|
+
}
|
|
381
|
+
return undefined;
|
|
382
|
+
};
|
|
383
|
+
const formTitle = resolveScreenText("title") ?? screen.id;
|
|
384
|
+
const formSubtitle = resolveScreenText("subtitle");
|
|
369
385
|
|
|
370
386
|
return (
|
|
371
387
|
<ExtensionFormRegistryProvider value={extensionFormRegistry}>
|
|
372
388
|
<Form
|
|
373
389
|
onSubmit={() => void handleSubmit()}
|
|
374
390
|
title={formTitle}
|
|
391
|
+
{...(formSubtitle !== undefined && { subtitle: formSubtitle })}
|
|
375
392
|
actions={formActions}
|
|
376
393
|
testId="render-edit-form"
|
|
377
394
|
>
|
|
378
|
-
{vm.sections.map((section: EditSectionViewModel) => {
|
|
395
|
+
{vm.sections.map((section: EditSectionViewModel, sectionIndex: number) => {
|
|
379
396
|
if (section.kind === "extension") {
|
|
380
397
|
return (
|
|
381
398
|
<ExtensionSectionMount
|
|
@@ -391,11 +408,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
391
408
|
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
392
409
|
// ActionForms, deren Section-Label = Screen-Titel ist).
|
|
393
410
|
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
411
|
+
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
412
|
+
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
394
413
|
return (
|
|
395
414
|
<Section
|
|
396
|
-
key={
|
|
415
|
+
key={sectionKey}
|
|
397
416
|
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
398
|
-
testId={`section-${
|
|
417
|
+
testId={`section-${sectionKey}`}
|
|
399
418
|
>
|
|
400
419
|
<Grid columns={section.columns}>
|
|
401
420
|
{section.fields.map((field: EditFieldViewModel) => (
|
|
@@ -73,6 +73,7 @@ export function RenderField({
|
|
|
73
73
|
{...(issues !== undefined && { issues })}
|
|
74
74
|
{...(labelAppendix !== undefined && { labelAppendix })}
|
|
75
75
|
{...(fieldAppendix !== undefined && { fieldAppendix })}
|
|
76
|
+
{...(field.type === "boolean" && { layout: "inline" as const })}
|
|
76
77
|
testId={`field-${field.field}`}
|
|
77
78
|
>
|
|
78
79
|
{control}
|
|
@@ -292,6 +293,23 @@ function renderInput({
|
|
|
292
293
|
/>
|
|
293
294
|
);
|
|
294
295
|
}
|
|
296
|
+
case "file":
|
|
297
|
+
case "image": {
|
|
298
|
+
const kind = field.type === "image" ? ("image" as const) : ("file" as const);
|
|
299
|
+
const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
|
|
300
|
+
return (
|
|
301
|
+
<Input
|
|
302
|
+
kind={kind}
|
|
303
|
+
{...common}
|
|
304
|
+
value={fileId}
|
|
305
|
+
onChange={(v) => onChange(v)}
|
|
306
|
+
{...(field.accept !== undefined && { accept: field.accept })}
|
|
307
|
+
{...(field.maxSize !== undefined && { maxSize: field.maxSize })}
|
|
308
|
+
{...(field.entityType !== undefined && { entityType: field.entityType })}
|
|
309
|
+
{...(field.fieldName !== undefined && { fieldName: field.fieldName })}
|
|
310
|
+
/>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
295
313
|
default: {
|
|
296
314
|
// text + unknown → text input. Wenn TextFieldDef.multiline gesetzt
|
|
297
315
|
// ist (das ViewModel hält's), wechselt der Renderer auf textarea.
|
|
@@ -16,7 +16,7 @@ import { extensionSectionName, useExtensionSectionComponent } from "../app/exten
|
|
|
16
16
|
import type { ListSort } from "../hooks/use-list-url-state";
|
|
17
17
|
import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
|
|
18
18
|
import { useTranslation } from "../i18n";
|
|
19
|
-
import { type DataTableRowAction, usePrimitives } from "../primitives";
|
|
19
|
+
import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
|
|
20
20
|
|
|
21
21
|
// RenderList — präsentationaler View für entityList-Screens.
|
|
22
22
|
//
|
|
@@ -90,6 +90,15 @@ export type RenderListProps = {
|
|
|
90
90
|
* → useNav, handler-QN → dispatcher-Call). RenderList rendert die
|
|
91
91
|
* Buttons rechts in der Toolbar, vor "+ Neu". */
|
|
92
92
|
readonly toolbarActions?: readonly ToolbarActionButton[];
|
|
93
|
+
/** Faceted-Filter-Dropdowns — KumikoScreen baut sie aus den filterable
|
|
94
|
+
* select/boolean-Feldern; RenderList reicht sie an die DataTable durch. */
|
|
95
|
+
readonly filterFacets?: readonly DataTableFacet[];
|
|
96
|
+
/** Aktuell gewählte Facet-Werte je Feld (vom URL-State). */
|
|
97
|
+
readonly filterValues?: Readonly<Record<string, readonly string[]>>;
|
|
98
|
+
/** Toggle eines Facet-Werts (URL-State setzen + refetch). */
|
|
99
|
+
readonly onFilterChange?: (field: string, values: readonly string[]) => void;
|
|
100
|
+
/** Reset aller aktiven Facets. */
|
|
101
|
+
readonly onFilterReset?: () => void;
|
|
93
102
|
};
|
|
94
103
|
|
|
95
104
|
// Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
|
|
@@ -130,6 +139,10 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
130
139
|
hasMore,
|
|
131
140
|
rowActions,
|
|
132
141
|
toolbarActions,
|
|
142
|
+
filterFacets,
|
|
143
|
+
filterValues,
|
|
144
|
+
onFilterChange,
|
|
145
|
+
onFilterReset,
|
|
133
146
|
} = props;
|
|
134
147
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
135
148
|
// wären Column-Header raw i18n-Keys.
|
|
@@ -331,6 +344,10 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
331
344
|
{...(loadingMore !== undefined && { loadingMore })}
|
|
332
345
|
{...(hasMore !== undefined && { hasMore })}
|
|
333
346
|
{...(rowActions !== undefined && { rowActions })}
|
|
347
|
+
{...(filterFacets !== undefined && { filterFacets })}
|
|
348
|
+
{...(filterValues !== undefined && { filterValues })}
|
|
349
|
+
{...(onFilterChange !== undefined && { onFilterChange })}
|
|
350
|
+
{...(onFilterReset !== undefined && { onFilterReset })}
|
|
334
351
|
testId="render-list-table"
|
|
335
352
|
/>
|
|
336
353
|
</>
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// nicht über dieselben Query-Keys streiten.
|
|
5
5
|
//
|
|
6
6
|
// Param-Schema pro Liste:
|
|
7
|
-
// <screenId>.sort
|
|
8
|
-
// <screenId>.dir
|
|
9
|
-
// <screenId>.q
|
|
10
|
-
// <screenId>.page
|
|
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
|
+
// <screenId>.f.<field> — Faceted-Filter: comma-joined selected values
|
|
11
12
|
//
|
|
12
13
|
// Schreibt mit setSearchParams (replaceState — kein push), damit
|
|
13
14
|
// Sort/Filter-Toggles nicht die Browser-History fluten.
|
|
@@ -29,6 +30,9 @@ export type ListUrlState = {
|
|
|
29
30
|
/** 1-basierte Page-Nummer. Bei pagination="infinite" oder false ist
|
|
30
31
|
* der Wert ignoriert; Caller liest ihn nur wenn relevant. */
|
|
31
32
|
readonly page: number;
|
|
33
|
+
/** Aktive Faceted-Filter: field → ausgewählte Werte (Strings; Caller
|
|
34
|
+
* coerced pro Field-Type). Leer wenn kein Filter gesetzt. */
|
|
35
|
+
readonly filters: Readonly<Record<string, readonly string[]>>;
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
export type ListUrlStateApi = ListUrlState & {
|
|
@@ -39,6 +43,11 @@ export type ListUrlStateApi = ListUrlState & {
|
|
|
39
43
|
readonly setQ: (next: string) => void;
|
|
40
44
|
/** Setzt die Page. 1 oder kleiner löscht den Key (Default-Page). */
|
|
41
45
|
readonly setPage: (next: number) => void;
|
|
46
|
+
/** Setzt die ausgewählten Werte eines Facet-Felds. Leeres Array löscht
|
|
47
|
+
* den Key. Resettet die Page (wie sort/search). */
|
|
48
|
+
readonly setFilter: (field: string, values: readonly string[]) => void;
|
|
49
|
+
/** Löscht alle aktiven Faceted-Filter (Reset-Button). */
|
|
50
|
+
readonly clearFilters: () => void;
|
|
42
51
|
};
|
|
43
52
|
|
|
44
53
|
// `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
|
|
@@ -72,6 +81,20 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
|
72
81
|
const q = params[key(screenId, "q")] ?? "";
|
|
73
82
|
const page = parsePage(params[key(screenId, "page")]);
|
|
74
83
|
|
|
84
|
+
const filterPrefix = `${screenId}.f.`;
|
|
85
|
+
const filters = useMemo<Readonly<Record<string, readonly string[]>>>(() => {
|
|
86
|
+
const out: Record<string, readonly string[]> = {};
|
|
87
|
+
for (const [k, v] of Object.entries(params)) {
|
|
88
|
+
if (!k.startsWith(filterPrefix) || v === "") continue;
|
|
89
|
+
const field = k.slice(filterPrefix.length);
|
|
90
|
+
if (field === "") continue;
|
|
91
|
+
// ponytail: comma-join — Facet-Werte sind select-Options/booleans
|
|
92
|
+
// ohne Komma; upgrade auf repeated-keys falls je Komma-Werte nötig.
|
|
93
|
+
out[field] = v.split(",").filter((s) => s !== "");
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}, [params, filterPrefix]);
|
|
97
|
+
|
|
75
98
|
const setSort = useCallback(
|
|
76
99
|
(next: ListSort | null) => {
|
|
77
100
|
// Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
|
|
@@ -109,5 +132,21 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
|
109
132
|
[nav, screenId],
|
|
110
133
|
);
|
|
111
134
|
|
|
112
|
-
|
|
135
|
+
const setFilter = useCallback(
|
|
136
|
+
(field: string, values: readonly string[]) => {
|
|
137
|
+
nav.setSearchParams({
|
|
138
|
+
[key(screenId, `f.${field}`)]: values.length === 0 ? null : values.join(","),
|
|
139
|
+
[key(screenId, "page")]: null,
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
[nav, screenId],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const clearFilters = useCallback(() => {
|
|
146
|
+
const updates: Record<string, string | null> = { [key(screenId, "page")]: null };
|
|
147
|
+
for (const field of Object.keys(filters)) updates[key(screenId, `f.${field}`)] = null;
|
|
148
|
+
nav.setSearchParams(updates);
|
|
149
|
+
}, [nav, screenId, filters]);
|
|
150
|
+
|
|
151
|
+
return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters };
|
|
113
152
|
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -23,6 +23,13 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
23
23
|
// Version — Update-Awareness-Banner (UpdateChecker).
|
|
24
24
|
"kumiko.version.update-available": "Eine neue Version ist verfügbar.",
|
|
25
25
|
|
|
26
|
+
// Toast — Self-service Docs-Link-Default (useToast docsLinkLabel).
|
|
27
|
+
"kumiko.toast.learn-more": "Mehr erfahren",
|
|
28
|
+
|
|
29
|
+
// Field — aria-Labels der Date/Timestamp-Primitives.
|
|
30
|
+
"kumiko.field.open-calendar": "Kalender öffnen",
|
|
31
|
+
"kumiko.field.time": "Uhrzeit",
|
|
32
|
+
|
|
26
33
|
// List — DataTable Toolbar, Empty-State, Search.
|
|
27
34
|
"kumiko.list.search-placeholder": "Suchen…",
|
|
28
35
|
"kumiko.list.empty.title": "Noch keine Einträge.",
|
|
@@ -104,6 +111,11 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
104
111
|
|
|
105
112
|
"kumiko.version.update-available": "A new version is available.",
|
|
106
113
|
|
|
114
|
+
"kumiko.toast.learn-more": "Learn more",
|
|
115
|
+
|
|
116
|
+
"kumiko.field.open-calendar": "Open calendar",
|
|
117
|
+
"kumiko.field.time": "Time",
|
|
118
|
+
|
|
107
119
|
"kumiko.list.search-placeholder": "Search…",
|
|
108
120
|
"kumiko.list.empty.title": "No entries yet.",
|
|
109
121
|
"kumiko.list.empty.hint": "Create the first one to get started.",
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -99,6 +99,10 @@ export type FieldProps = {
|
|
|
99
99
|
readonly labelAppendix?: ReactNode;
|
|
100
100
|
readonly fieldAppendix?: ReactNode;
|
|
101
101
|
readonly children: ReactNode;
|
|
102
|
+
/** Label-Layout. `stacked` (Default): Label über dem Control. `inline`:
|
|
103
|
+
* Control + Label nebeneinander (Box links, Label rechts) — für
|
|
104
|
+
* Checkbox/Switch-Felder (boolean), wie das shadcn-Muster. */
|
|
105
|
+
readonly layout?: "stacked" | "inline";
|
|
102
106
|
readonly testId?: string;
|
|
103
107
|
};
|
|
104
108
|
|
|
@@ -170,6 +174,24 @@ export type InputProps =
|
|
|
170
174
|
readonly required?: boolean;
|
|
171
175
|
readonly hasError?: boolean;
|
|
172
176
|
}
|
|
177
|
+
| {
|
|
178
|
+
readonly kind: "file" | "image";
|
|
179
|
+
readonly id: string;
|
|
180
|
+
readonly name: string;
|
|
181
|
+
/** FileRef-UUID (gespeicherte Datei) oder null (kein Upload). */
|
|
182
|
+
readonly value: string | null;
|
|
183
|
+
readonly onChange: (fileId: string | null) => void;
|
|
184
|
+
readonly disabled?: boolean;
|
|
185
|
+
readonly required?: boolean;
|
|
186
|
+
readonly hasError?: boolean;
|
|
187
|
+
/** Erlaubte Extensions/MIME (z.B. ["jpg","png"]) → `accept`-Attribut. */
|
|
188
|
+
readonly accept?: readonly string[];
|
|
189
|
+
readonly maxSize?: string;
|
|
190
|
+
/** Für den Upload-POST `/api/files` — der Endpoint prüft maxSize/accept
|
|
191
|
+
* gegen die Field-Def von (entityType, fieldName). */
|
|
192
|
+
readonly entityType?: string;
|
|
193
|
+
readonly fieldName?: string;
|
|
194
|
+
}
|
|
173
195
|
| {
|
|
174
196
|
readonly kind: "date";
|
|
175
197
|
readonly id: string;
|
|
@@ -344,6 +366,19 @@ export type DataTableRowAction = {
|
|
|
344
366
|
readonly isVisible?: (row: ListRowViewModel) => boolean;
|
|
345
367
|
};
|
|
346
368
|
|
|
369
|
+
// Ein Faceted-Filter-Slot in der Toolbar: ein Outline-Dropdown-Button
|
|
370
|
+
// (wie shadcns "Columns"-Toggle) mit Multi-Select-Checkboxen. KumikoScreen
|
|
371
|
+
// baut das aus den filterable select/boolean-Feldern des Entity.
|
|
372
|
+
export type DataTableFacet = {
|
|
373
|
+
/** Entity-Feldname (= URL-State-Key + payload.filters[].field). */
|
|
374
|
+
readonly field: string;
|
|
375
|
+
/** Translated Button-Label ("Status", "Active"). */
|
|
376
|
+
readonly label: string;
|
|
377
|
+
/** Auswählbare Werte mit Anzeige-Label. value = roher Filterwert
|
|
378
|
+
* (select-option / "true"|"false"), als String transportiert. */
|
|
379
|
+
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
380
|
+
};
|
|
381
|
+
|
|
347
382
|
export type DataTableProps = {
|
|
348
383
|
readonly columns: readonly ListColumnViewModel[];
|
|
349
384
|
readonly rows: readonly ListRowViewModel[];
|
|
@@ -384,6 +419,16 @@ export type DataTableProps = {
|
|
|
384
419
|
/** Toolbar-Slot rechts (typisch + Neu Button, Filter, View-Switch).
|
|
385
420
|
* Web zieht den Cluster mit ml-auto an die rechte Kante. */
|
|
386
421
|
readonly toolbarEnd?: ReactNode;
|
|
422
|
+
/** Faceted-Filter-Dropdowns (select/boolean-Felder) — der Renderer
|
|
423
|
+
* setzt sie neben das Search-Input. Leer/undefined → keine Filter. */
|
|
424
|
+
readonly filterFacets?: readonly DataTableFacet[];
|
|
425
|
+
/** Aktuell ausgewählte Werte je Facet-Feld (vom URL-State). */
|
|
426
|
+
readonly filterValues?: Readonly<Record<string, readonly string[]>>;
|
|
427
|
+
/** Toggle eines Facet-Werts → Caller schreibt URL-State + refetch. */
|
|
428
|
+
readonly onFilterChange?: (field: string, values: readonly string[]) => void;
|
|
429
|
+
/** Reset-Button: löscht alle aktiven Facets. Renderer zeigt ihn nur
|
|
430
|
+
* wenn mindestens ein Facet aktiv ist. */
|
|
431
|
+
readonly onFilterReset?: () => void;
|
|
387
432
|
/** Pagination-State + Callback. Wenn gesetzt, rendert der Renderer
|
|
388
433
|
* einen Pager unter der Tabelle (Web: Footer-Bar mit ← 1 ... N →).
|
|
389
434
|
* total/limit/page sind 1-basiert für die UI; Server-Translation
|
|
@@ -425,6 +470,9 @@ export type FormProps = {
|
|
|
425
470
|
readonly onSubmit: (e?: FormEvent) => void;
|
|
426
471
|
readonly children: ReactNode;
|
|
427
472
|
readonly title?: ReactNode;
|
|
473
|
+
/** Optionaler muted Untertitel unter dem Titel (z.B. "Add a new item to
|
|
474
|
+
* your catalog") — gibt dem Form-Header Kontext statt nur ein Label. */
|
|
475
|
+
readonly subtitle?: ReactNode;
|
|
428
476
|
readonly actions?: ReactNode;
|
|
429
477
|
readonly testId?: string;
|
|
430
478
|
};
|