@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.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 +5 -5
- package/src/app/__tests__/entity-edit-fill-height.test.tsx +118 -0
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +64 -47
- package/src/app/action-form-shim.ts +2 -0
- package/src/app/kumiko-screen.tsx +274 -87
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/app/projection-list-shim.ts +3 -0
- package/src/app/row-actions.ts +1 -0
- package/src/changes.json +52 -0
- package/src/components/__tests__/related-list-section.test.tsx +65 -1
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +2 -2
- package/src/components/__tests__/write-form-section.test.tsx +1 -1
- package/src/components/grid-cell-for-field.tsx +30 -2
- package/src/components/related-list-section.tsx +53 -29
- package/src/components/render-edit-action-button.tsx +4 -1
- package/src/components/render-edit-types.ts +10 -0
- package/src/components/render-edit.tsx +564 -393
- package/src/components/render-field.tsx +11 -2
- package/src/components/render-list.tsx +45 -13
- package/src/components/write-form-section.tsx +0 -1
- package/src/hooks/__tests__/use-query.test.tsx +149 -0
- package/src/hooks/use-list-url-state.ts +37 -1
- package/src/hooks/use-query.ts +57 -23
- package/src/i18n-defaults.ts +21 -1
- package/src/index.ts +7 -0
- package/src/inside-drawer.ts +12 -0
- package/src/page-header-slot.ts +12 -0
- package/src/primitives.tsx +137 -16
- package/src/screen-fills-height.ts +3 -0
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type EditFieldViewModel,
|
|
12
12
|
type FieldIssue,
|
|
13
13
|
} from "@cosmicdrift/kumiko-headless";
|
|
14
|
-
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
14
|
+
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
|
|
15
15
|
import { useAppFeatures } from "../app/app-features-context";
|
|
16
16
|
import { useColumnRenderer } from "../app/column-renderers";
|
|
17
17
|
import { extensionSectionName } from "../app/extension-sections";
|
|
@@ -71,8 +71,14 @@ export type RenderFieldProps = {
|
|
|
71
71
|
* field's sibling-field `unit`. Omitted → falls back to a single-key
|
|
72
72
|
* `{ [field.field]: field.value }` row. */
|
|
73
73
|
readonly row?: Readonly<Record<string, unknown>>;
|
|
74
|
+
/** Marks the field as modified against the loaded record (edit mode only). */
|
|
75
|
+
readonly changed?: boolean;
|
|
74
76
|
};
|
|
75
77
|
|
|
78
|
+
const AllFieldsRequiredContext = createContext(false);
|
|
79
|
+
|
|
80
|
+
export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
|
|
81
|
+
|
|
76
82
|
export function RenderField({
|
|
77
83
|
field,
|
|
78
84
|
issues,
|
|
@@ -83,6 +89,7 @@ export function RenderField({
|
|
|
83
89
|
allIssues,
|
|
84
90
|
valueDisplay = "form",
|
|
85
91
|
row,
|
|
92
|
+
changed,
|
|
86
93
|
}: RenderFieldProps): ReactNode {
|
|
87
94
|
const { Field, Input, Banner, Text, JsonView } = usePrimitives();
|
|
88
95
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
@@ -92,6 +99,7 @@ export function RenderField({
|
|
|
92
99
|
// Standalone-Consumer/Tests müssen wrappen (createKumikoApp tut es).
|
|
93
100
|
const appLocale = useLocale().locale();
|
|
94
101
|
const t = useTranslation();
|
|
102
|
+
const allFieldsRequired = useContext(AllFieldsRequiredContext);
|
|
95
103
|
if (!field.visible) return null;
|
|
96
104
|
|
|
97
105
|
const id = inputId(field);
|
|
@@ -157,10 +165,11 @@ export function RenderField({
|
|
|
157
165
|
<Field
|
|
158
166
|
id={id}
|
|
159
167
|
label={field.label}
|
|
160
|
-
required={field.required}
|
|
168
|
+
required={field.required && !allFieldsRequired}
|
|
161
169
|
{...(issues !== undefined && { issues })}
|
|
162
170
|
{...(labelAppendix !== undefined && { labelAppendix })}
|
|
163
171
|
{...(fieldAppendix !== undefined && { fieldAppendix })}
|
|
172
|
+
{...(changed === true && { changed })}
|
|
164
173
|
testId={`field-${field.field}`}
|
|
165
174
|
>
|
|
166
175
|
{control}
|
|
@@ -17,6 +17,7 @@ import { extensionSectionName, useExtensionSectionComponent } from "../app/exten
|
|
|
17
17
|
import type { ListSort } from "../hooks/use-list-url-state";
|
|
18
18
|
import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
|
|
19
19
|
import { useTranslation } from "../i18n";
|
|
20
|
+
import { usePageHeaderSlotAvailable } from "../page-header-slot";
|
|
20
21
|
import {
|
|
21
22
|
type DataTableDateRangeFacet,
|
|
22
23
|
type DataTableFacet,
|
|
@@ -63,6 +64,10 @@ export type RenderListProps = {
|
|
|
63
64
|
/** Placeholder für das Search-Input. Default kommt aus dem i18n-
|
|
64
65
|
* Bundle (`kumiko.list.search-placeholder`). */
|
|
65
66
|
readonly searchPlaceholder?: string;
|
|
67
|
+
/** Hint at the left of the toolbar when there is no search box (already translated). */
|
|
68
|
+
readonly description?: string;
|
|
69
|
+
/** Plural-forms key for the footer count noun; default is the entity's `:noun` convention key. */
|
|
70
|
+
readonly itemNounKey?: string;
|
|
66
71
|
/** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
|
|
67
72
|
* Tipps lokal mit 300ms Debounce, bevor onSearchChange gefeuert
|
|
68
73
|
* wird — sonst macht jeder Tastendruck einen Server-Roundtrip. */
|
|
@@ -167,6 +172,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
167
172
|
createLabel,
|
|
168
173
|
searchable = false,
|
|
169
174
|
searchPlaceholder,
|
|
175
|
+
description,
|
|
176
|
+
itemNounKey,
|
|
170
177
|
searchValue,
|
|
171
178
|
onSearchChange,
|
|
172
179
|
sort,
|
|
@@ -192,7 +199,10 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
192
199
|
// wären Column-Header raw i18n-Keys.
|
|
193
200
|
const t = useTranslation();
|
|
194
201
|
const translate: Translate = translateProp ?? t;
|
|
195
|
-
const { DataTable, Button, Dialog, Input, Text, Banner } = usePrimitives();
|
|
202
|
+
const { DataTable, Button, Dialog, Input, Text, Banner, PageHeader } = usePrimitives();
|
|
203
|
+
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
204
|
+
const createInPageHeader =
|
|
205
|
+
onCreate !== undefined && PageHeader !== undefined && pageHeaderSlotAvailable;
|
|
196
206
|
|
|
197
207
|
// Local search buffer + debounce. External changes (browser back,
|
|
198
208
|
// cross-component reset) are mirrored back; typing only fires
|
|
@@ -300,9 +310,19 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
300
310
|
// i18n-Defaults für Toolbar/Empty-State Strings — Caller kann jeden
|
|
301
311
|
// einzeln per Prop überschreiben, sonst kommen die Framework-Bundles
|
|
302
312
|
// (kumiko.actions.create, kumiko.list.search-placeholder, …).
|
|
303
|
-
const effectiveCreateLabel =
|
|
304
|
-
|
|
305
|
-
|
|
313
|
+
const effectiveCreateLabel = translate(
|
|
314
|
+
createLabel ?? screen.createLabel ?? "kumiko.actions.create",
|
|
315
|
+
);
|
|
316
|
+
const effectiveSearchPlaceholder = translate(
|
|
317
|
+
searchPlaceholder ?? screen.searchPlaceholder ?? "kumiko.list.search-placeholder",
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const nounKey = itemNounKey ?? `${featureName}:entity:${screen.entity}:noun`;
|
|
321
|
+
const itemNoun =
|
|
322
|
+
(itemNounKey !== undefined || !screen.entity.startsWith("__")) &&
|
|
323
|
+
translate(nounKey, { count: 2 }) !== nounKey
|
|
324
|
+
? (count: number): string => translate(nounKey, { count })
|
|
325
|
+
: undefined;
|
|
306
326
|
|
|
307
327
|
const toolbarStart = searchable ? (
|
|
308
328
|
<Input
|
|
@@ -324,8 +344,20 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
324
344
|
const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
|
|
325
345
|
const hasHeaderSlot = screen.slots?.header !== undefined;
|
|
326
346
|
const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
|
|
347
|
+
const createButton =
|
|
348
|
+
onCreate !== undefined ? (
|
|
349
|
+
<Button
|
|
350
|
+
variant="primary"
|
|
351
|
+
icon="plus"
|
|
352
|
+
onClick={onCreate}
|
|
353
|
+
testId="render-list-create"
|
|
354
|
+
className="px-3.5"
|
|
355
|
+
>
|
|
356
|
+
{effectiveCreateLabel}
|
|
357
|
+
</Button>
|
|
358
|
+
) : undefined;
|
|
327
359
|
const toolbarEnd =
|
|
328
|
-
hasHeaderSlot || hasToolbarActions || onCreate !== undefined ? (
|
|
360
|
+
hasHeaderSlot || hasToolbarActions || (onCreate !== undefined && !createInPageHeader) ? (
|
|
329
361
|
<>
|
|
330
362
|
{hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
|
|
331
363
|
{hasToolbarActions &&
|
|
@@ -339,11 +371,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
339
371
|
Banner={Banner}
|
|
340
372
|
/>
|
|
341
373
|
))}
|
|
342
|
-
{
|
|
343
|
-
<Button variant="primary" onClick={onCreate} testId="render-list-create">
|
|
344
|
-
{`+ ${effectiveCreateLabel}`}
|
|
345
|
-
</Button>
|
|
346
|
-
)}
|
|
374
|
+
{!createInPageHeader && createButton}
|
|
347
375
|
</>
|
|
348
376
|
) : undefined;
|
|
349
377
|
|
|
@@ -357,8 +385,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
357
385
|
<>
|
|
358
386
|
<Text>{translate("kumiko.list.empty.title")}</Text>
|
|
359
387
|
<Text variant="small">{translate("kumiko.list.empty.hint")}</Text>
|
|
360
|
-
<Button variant="primary" onClick={onCreate} testId="render-list-empty-create">
|
|
361
|
-
{
|
|
388
|
+
<Button variant="primary" icon="plus" onClick={onCreate} testId="render-list-empty-create">
|
|
389
|
+
{effectiveCreateLabel}
|
|
362
390
|
</Button>
|
|
363
391
|
</>
|
|
364
392
|
) : undefined);
|
|
@@ -374,6 +402,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
374
402
|
// ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
|
|
375
403
|
return (
|
|
376
404
|
<>
|
|
405
|
+
{createInPageHeader && PageHeader !== undefined && <PageHeader actions={createButton} />}
|
|
377
406
|
{referenceColumns.map(
|
|
378
407
|
(rc: { field: string; refEntity: string; refFeature: string; labelField: string }) => (
|
|
379
408
|
<ReferenceLookupBridge
|
|
@@ -393,6 +422,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
393
422
|
{...(onRowClick !== undefined && { onRowClick })}
|
|
394
423
|
{...(composedEmptyState !== undefined && { emptyState: composedEmptyState })}
|
|
395
424
|
{...(toolbarStart !== undefined && { toolbarStart })}
|
|
425
|
+
{...(description !== undefined && !searchable && { toolbarDescription: description })}
|
|
396
426
|
{...(toolbarEnd !== undefined && { toolbarEnd })}
|
|
397
427
|
{...(sort !== undefined && { sort })}
|
|
398
428
|
{...(onSortChange !== undefined && { onSortChange })}
|
|
@@ -400,6 +430,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
400
430
|
{...(onReachEnd !== undefined && { onReachEnd })}
|
|
401
431
|
{...(loadingMore !== undefined && { loadingMore })}
|
|
402
432
|
{...(hasMore !== undefined && { hasMore })}
|
|
433
|
+
{...(itemNoun !== undefined && { itemNoun })}
|
|
403
434
|
{...(rowActions !== undefined && { rowActions })}
|
|
404
435
|
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
405
436
|
{...(filterFacets !== undefined && { filterFacets })}
|
|
@@ -529,7 +560,8 @@ function ToolbarActionView({
|
|
|
529
560
|
variant={variant}
|
|
530
561
|
loading={busy}
|
|
531
562
|
{...(action.icon !== undefined && { icon: action.icon })}
|
|
532
|
-
{
|
|
563
|
+
size={showIconOnly ? "icon" : "sm"}
|
|
564
|
+
{...(showIconOnly && { ariaLabel: action.label })}
|
|
533
565
|
onClick={() => {
|
|
534
566
|
if (needsConfirm) {
|
|
535
567
|
setConfirmOpen(true);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
+
import { useQuery } from "../use-query";
|
|
7
|
+
|
|
8
|
+
type QueryFn = Dispatcher["query"];
|
|
9
|
+
|
|
10
|
+
function makeDispatcher(query: QueryFn): Dispatcher {
|
|
11
|
+
return {
|
|
12
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
13
|
+
query,
|
|
14
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
15
|
+
statusStore: {
|
|
16
|
+
getState: () => "online",
|
|
17
|
+
subscribe: () => () => {},
|
|
18
|
+
} as unknown as Dispatcher["statusStore"],
|
|
19
|
+
async *stream() {},
|
|
20
|
+
pendingWrites: () => [],
|
|
21
|
+
pendingFiles: () => [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function wrapperFor(dispatcher: Dispatcher) {
|
|
26
|
+
return ({ children }: { readonly children: ReactNode }) => (
|
|
27
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function countingDispatcher(): { dispatcher: Dispatcher; calls: () => number } {
|
|
32
|
+
let calls = 0;
|
|
33
|
+
const query = (async () => {
|
|
34
|
+
calls += 1;
|
|
35
|
+
return { isSuccess: true, data: { n: calls } };
|
|
36
|
+
}) as unknown as QueryFn;
|
|
37
|
+
return { dispatcher: makeDispatcher(query), calls: () => calls };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function advance(ms: number): Promise<void> {
|
|
41
|
+
await act(async () => {
|
|
42
|
+
jest.advanceTimersByTime(ms);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const QUERY_TYPE = "f:query:x:list";
|
|
47
|
+
|
|
48
|
+
describe("useQuery refetchIntervalMs", () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.useFakeTimers();
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
jest.useRealTimers();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("re-runs the query on every interval tick", async () => {
|
|
57
|
+
const { dispatcher, calls } = countingDispatcher();
|
|
58
|
+
const { result } = renderHook(
|
|
59
|
+
() => useQuery<{ n: number }>(QUERY_TYPE, {}, { refetchIntervalMs: 1000 }),
|
|
60
|
+
{
|
|
61
|
+
wrapper: wrapperFor(dispatcher),
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
await advance(0);
|
|
65
|
+
expect(calls()).toBe(1);
|
|
66
|
+
|
|
67
|
+
await advance(1000);
|
|
68
|
+
expect(calls()).toBe(2);
|
|
69
|
+
await advance(1000);
|
|
70
|
+
expect(calls()).toBe(3);
|
|
71
|
+
expect(result.current.data).toEqual({ n: 3 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("unmount stops the ticks", async () => {
|
|
75
|
+
const { dispatcher, calls } = countingDispatcher();
|
|
76
|
+
const { unmount } = renderHook(() => useQuery(QUERY_TYPE, {}, { refetchIntervalMs: 1000 }), {
|
|
77
|
+
wrapper: wrapperFor(dispatcher),
|
|
78
|
+
});
|
|
79
|
+
await advance(1000);
|
|
80
|
+
const before = calls();
|
|
81
|
+
unmount();
|
|
82
|
+
await advance(5000);
|
|
83
|
+
expect(calls()).toBe(before);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("enabled: false never ticks", async () => {
|
|
87
|
+
const { dispatcher, calls } = countingDispatcher();
|
|
88
|
+
renderHook(() => useQuery(QUERY_TYPE, {}, { enabled: false, refetchIntervalMs: 1000 }), {
|
|
89
|
+
wrapper: wrapperFor(dispatcher),
|
|
90
|
+
});
|
|
91
|
+
await advance(5000);
|
|
92
|
+
expect(calls()).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a tick while a fetch is in flight is skipped instead of aborting it", async () => {
|
|
96
|
+
let calls = 0;
|
|
97
|
+
let release: (() => void) | undefined;
|
|
98
|
+
const query = (async (_type: string, _payload: unknown, opts?: { signal?: AbortSignal }) => {
|
|
99
|
+
calls += 1;
|
|
100
|
+
await new Promise<void>((resolve) => {
|
|
101
|
+
release = resolve;
|
|
102
|
+
});
|
|
103
|
+
return opts?.signal?.aborted
|
|
104
|
+
? { isSuccess: false, error: { code: "aborted", message: "aborted", i18nKey: "x" } }
|
|
105
|
+
: { isSuccess: true, data: { done: true } };
|
|
106
|
+
}) as unknown as QueryFn;
|
|
107
|
+
const { result } = renderHook(
|
|
108
|
+
() => useQuery<{ done: boolean }>(QUERY_TYPE, {}, { refetchIntervalMs: 1000 }),
|
|
109
|
+
{
|
|
110
|
+
wrapper: wrapperFor(makeDispatcher(query)),
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
await advance(0);
|
|
114
|
+
expect(calls).toBe(1);
|
|
115
|
+
|
|
116
|
+
await advance(3000);
|
|
117
|
+
expect(calls).toBe(1);
|
|
118
|
+
|
|
119
|
+
await act(async () => {
|
|
120
|
+
release?.();
|
|
121
|
+
});
|
|
122
|
+
expect(result.current.data).toEqual({ done: true });
|
|
123
|
+
|
|
124
|
+
await advance(1000);
|
|
125
|
+
expect(calls).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("background ticks do not flip loading to true", async () => {
|
|
129
|
+
const { dispatcher, calls } = countingDispatcher();
|
|
130
|
+
const seenLoading: boolean[] = [];
|
|
131
|
+
const { result } = renderHook(
|
|
132
|
+
() => {
|
|
133
|
+
const r = useQuery<{ n: number }>(QUERY_TYPE, {}, { refetchIntervalMs: 1000 });
|
|
134
|
+
seenLoading.push(r.loading);
|
|
135
|
+
return r;
|
|
136
|
+
},
|
|
137
|
+
{ wrapper: wrapperFor(dispatcher) },
|
|
138
|
+
);
|
|
139
|
+
await advance(0);
|
|
140
|
+
expect(result.current.loading).toBe(false);
|
|
141
|
+
seenLoading.length = 0;
|
|
142
|
+
|
|
143
|
+
await advance(1000);
|
|
144
|
+
await advance(1000);
|
|
145
|
+
expect(calls()).toBe(3);
|
|
146
|
+
expect(result.current.data).toEqual({ n: 3 });
|
|
147
|
+
expect(seenLoading.every((l) => l === false)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -30,6 +30,8 @@ export type ListUrlState = {
|
|
|
30
30
|
/** 1-basierte Page-Nummer. Bei pagination="infinite" oder false ist
|
|
31
31
|
* der Wert ignoriert; Caller liest ihn nur wenn relevant. */
|
|
32
32
|
readonly page: number;
|
|
33
|
+
/** User-chosen page size from `PAGE_SIZE_OPTIONS`; undefined = screen default. */
|
|
34
|
+
readonly pageSize: number | undefined;
|
|
33
35
|
/** Aktive Faceted-Filter: field → ausgewählte Werte (Strings; Caller
|
|
34
36
|
* coerced pro Field-Type). Leer wenn kein Filter gesetzt. */
|
|
35
37
|
readonly filters: Readonly<Record<string, readonly string[]>>;
|
|
@@ -43,6 +45,8 @@ export type ListUrlStateApi = ListUrlState & {
|
|
|
43
45
|
readonly setQ: (next: string) => void;
|
|
44
46
|
/** Setzt die Page. 1 oder kleiner löscht den Key (Default-Page). */
|
|
45
47
|
readonly setPage: (next: number) => void;
|
|
48
|
+
/** Sets the page size and resets the page; undefined clears the key. */
|
|
49
|
+
readonly setPageSize: (next: number | undefined) => void;
|
|
46
50
|
/** Setzt die ausgewählten Werte eines Facet-Felds. Leeres Array löscht
|
|
47
51
|
* den Key. Resettet die Page (wie sort/search). */
|
|
48
52
|
readonly setFilter: (field: string, values: readonly string[]) => void;
|
|
@@ -61,6 +65,14 @@ export type ListUrlStateApi = ListUrlState & {
|
|
|
61
65
|
// `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
|
|
62
66
|
// üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
|
|
63
67
|
// pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
|
|
68
|
+
export const PAGE_SIZE_OPTIONS: readonly number[] = [25, 50, 100];
|
|
69
|
+
|
|
70
|
+
function parsePageSize(value: string | undefined): number | undefined {
|
|
71
|
+
if (value === undefined) return undefined;
|
|
72
|
+
const n = Number.parseInt(value, 10);
|
|
73
|
+
return PAGE_SIZE_OPTIONS.includes(n) ? n : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
function key(screenId: string, suffix: string): string {
|
|
65
77
|
return `${screenId}.${suffix}`;
|
|
66
78
|
}
|
|
@@ -88,6 +100,7 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
|
88
100
|
|
|
89
101
|
const q = params[key(screenId, "q")] ?? "";
|
|
90
102
|
const page = parsePage(params[key(screenId, "page")]);
|
|
103
|
+
const pageSize = parsePageSize(params[key(screenId, "size")]);
|
|
91
104
|
|
|
92
105
|
const filterPrefix = `${screenId}.f.`;
|
|
93
106
|
const filters = useMemo<Readonly<Record<string, readonly string[]>>>(() => {
|
|
@@ -140,6 +153,16 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
|
140
153
|
[nav, screenId],
|
|
141
154
|
);
|
|
142
155
|
|
|
156
|
+
const setPageSize = useCallback(
|
|
157
|
+
(next: number | undefined) => {
|
|
158
|
+
nav.setSearchParams({
|
|
159
|
+
[key(screenId, "size")]: next === undefined ? null : String(next),
|
|
160
|
+
[key(screenId, "page")]: null,
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
[nav, screenId],
|
|
164
|
+
);
|
|
165
|
+
|
|
143
166
|
const setFilter = useCallback(
|
|
144
167
|
(field: string, values: readonly string[]) => {
|
|
145
168
|
nav.setSearchParams({
|
|
@@ -167,5 +190,18 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
|
167
190
|
nav.setSearchParams(updates);
|
|
168
191
|
}, [nav, screenId, filters]);
|
|
169
192
|
|
|
170
|
-
return {
|
|
193
|
+
return {
|
|
194
|
+
sort,
|
|
195
|
+
q,
|
|
196
|
+
page,
|
|
197
|
+
pageSize,
|
|
198
|
+
filters,
|
|
199
|
+
setSort,
|
|
200
|
+
setQ,
|
|
201
|
+
setPage,
|
|
202
|
+
setPageSize,
|
|
203
|
+
setFilter,
|
|
204
|
+
clearFilters,
|
|
205
|
+
setDateRange,
|
|
206
|
+
};
|
|
171
207
|
}
|
package/src/hooks/use-query.ts
CHANGED
|
@@ -38,6 +38,10 @@ export type UseQueryOptions = {
|
|
|
38
38
|
// `<feature>:query:<entity>:<verb>` convention — if the type
|
|
39
39
|
// doesn't follow that shape, live-mode is a no-op.
|
|
40
40
|
readonly live?: boolean;
|
|
41
|
+
// Re-runs the query every N ms while enabled. Ticks are silent (no
|
|
42
|
+
// `loading` flip) and skipped while a fetch is still in flight. Only
|
|
43
|
+
// timers are used, so it behaves the same on web and React Native.
|
|
44
|
+
readonly refetchIntervalMs?: number;
|
|
41
45
|
};
|
|
42
46
|
|
|
43
47
|
// Extract the entity-name from a standard Kumiko query type. Returns
|
|
@@ -57,7 +61,7 @@ export function useQuery<TData = unknown>(
|
|
|
57
61
|
options: UseQueryOptions = {},
|
|
58
62
|
): UseQueryResult<TData> {
|
|
59
63
|
const dispatcher = useDispatcher();
|
|
60
|
-
const { enabled = true, live = false } = options;
|
|
64
|
+
const { enabled = true, live = false, refetchIntervalMs } = options;
|
|
61
65
|
|
|
62
66
|
const [data, setData] = useState<TData | null>(null);
|
|
63
67
|
const [error, setError] = useState<DispatcherError | null>(null);
|
|
@@ -67,6 +71,9 @@ export function useQuery<TData = unknown>(
|
|
|
67
71
|
// call can cancel the older one. Ref, not state, because the
|
|
68
72
|
// controller identity shouldn't trigger a re-render.
|
|
69
73
|
const activeCtrl = useRef<AbortController | null>(null);
|
|
74
|
+
// A tick that aborted a running fetch would starve slow queries, so
|
|
75
|
+
// ticks consult this flag instead.
|
|
76
|
+
const fetchInFlight = useRef(false);
|
|
70
77
|
|
|
71
78
|
// Serialize payload so object-identity-changes across renders (the
|
|
72
79
|
// caller building a fresh `{}` every render) don't loop. Strings are
|
|
@@ -77,29 +84,42 @@ export function useQuery<TData = unknown>(
|
|
|
77
84
|
// are the meaningful re-run triggers. `payload` itself is intentionally
|
|
78
85
|
// not in deps — it's serialized into payloadKey above.
|
|
79
86
|
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
const runFetch = useCallback(
|
|
88
|
+
async (background: boolean): Promise<void> => {
|
|
89
|
+
// Abort whatever's in flight. observer on Safari 17+ handles this
|
|
90
|
+
// without raising a fetch-throw in the previous caller — they
|
|
91
|
+
// already set the error path.
|
|
92
|
+
activeCtrl.current?.abort();
|
|
93
|
+
const ctrl = new AbortController();
|
|
94
|
+
activeCtrl.current = ctrl;
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
fetchInFlight.current = true;
|
|
97
|
+
if (!background) setLoading(true);
|
|
98
|
+
let result: Awaited<ReturnType<typeof dispatcher.query<TData>>>;
|
|
99
|
+
try {
|
|
100
|
+
result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
|
|
101
|
+
} finally {
|
|
102
|
+
// A rejected fetch must not leave polling stuck; a superseded one must
|
|
103
|
+
// not clear the newer fetch's flag.
|
|
104
|
+
if (activeCtrl.current === ctrl) fetchInFlight.current = false;
|
|
105
|
+
}
|
|
106
|
+
// skip: a newer fetch already superseded this one, don't clobber its state
|
|
107
|
+
if (ctrl.signal.aborted) return;
|
|
108
|
+
if (result.isSuccess) {
|
|
109
|
+
setData(result.data);
|
|
110
|
+
setError(null);
|
|
111
|
+
} else {
|
|
112
|
+
// A cancelled request comes back with code "aborted" from the
|
|
113
|
+
// skip: aborted request, a newer run already replaces this result
|
|
114
|
+
if (result.error.code === "aborted") return;
|
|
115
|
+
setError(result.error);
|
|
116
|
+
}
|
|
117
|
+
setLoading(false);
|
|
118
|
+
},
|
|
119
|
+
[dispatcher, type, payloadKey],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const run = useCallback((): Promise<void> => runFetch(false), [runFetch]);
|
|
103
123
|
|
|
104
124
|
useEffect(() => {
|
|
105
125
|
if (!enabled) {
|
|
@@ -110,9 +130,23 @@ export function useQuery<TData = unknown>(
|
|
|
110
130
|
void run();
|
|
111
131
|
return () => {
|
|
112
132
|
activeCtrl.current?.abort();
|
|
133
|
+
fetchInFlight.current = false;
|
|
113
134
|
};
|
|
114
135
|
}, [enabled, run]);
|
|
115
136
|
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
// skip: polling off or disabled query
|
|
139
|
+
if (!enabled || refetchIntervalMs === undefined) return;
|
|
140
|
+
// skip: non-positive or non-finite interval disables polling
|
|
141
|
+
if (!Number.isFinite(refetchIntervalMs) || refetchIntervalMs <= 0) return;
|
|
142
|
+
const timer = setInterval(() => {
|
|
143
|
+
// skip: a fetch is still running, the next tick retries
|
|
144
|
+
if (fetchInFlight.current) return;
|
|
145
|
+
void runFetch(true);
|
|
146
|
+
}, refetchIntervalMs);
|
|
147
|
+
return () => clearInterval(timer);
|
|
148
|
+
}, [enabled, refetchIntervalMs, runFetch]);
|
|
149
|
+
|
|
116
150
|
// Live-mode: auf SSE-Events für die Query-Entity hören und refetchen.
|
|
117
151
|
// Separater Effect, damit eine Änderung an `live` oder `type` das
|
|
118
152
|
// Subscription-Lifecycle genau einmal durchwalzt.
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -65,15 +65,31 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
65
65
|
"kumiko.list.sort.label": "Sort",
|
|
66
66
|
"kumiko.list.sort.unsorted": "Unsorted",
|
|
67
67
|
"kumiko.list.filter.reset": "Reset",
|
|
68
|
+
"kumiko.list.filter.toggle": "Filters",
|
|
68
69
|
"kumiko.list.related-list-truncated":
|
|
69
70
|
"Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
|
|
70
71
|
"kumiko.reference.system-tenant": "System",
|
|
71
72
|
"kumiko.reference.system-user": "System",
|
|
72
73
|
|
|
73
74
|
"kumiko.pager.status": "{from}–{to} of {total}",
|
|
75
|
+
"kumiko.pager.pageOf": "Page {page} of {pages}",
|
|
76
|
+
"kumiko.form.all-required": "All fields are required.",
|
|
77
|
+
"kumiko.pager.status.noun": "{from}–{to} of {total} {noun}",
|
|
78
|
+
"kumiko.pager.pageSize": "{size} per page",
|
|
79
|
+
"kumiko.pager.pageSizeLabel": "Entries per page",
|
|
80
|
+
"kumiko.list.count.noun": "{count} {noun}",
|
|
81
|
+
"kumiko.list.count.one": "1 entry",
|
|
82
|
+
"kumiko.list.count.other": "{count} entries",
|
|
83
|
+
"kumiko.list.row-actions.more": "More actions",
|
|
84
|
+
"kumiko.list.sort.by": "Sorted by {column}",
|
|
85
|
+
"kumiko.form.changed": "changed",
|
|
86
|
+
"kumiko.form.on-this-page": "On this page",
|
|
87
|
+
"kumiko.form.unsaved.one": "1 unsaved change",
|
|
88
|
+
"kumiko.form.unsaved.other": "{count} unsaved changes",
|
|
89
|
+
"kumiko.wizard.next-with-title": "Next: {title}",
|
|
90
|
+
"kumiko.wizard.save-and-close": "Save and close",
|
|
74
91
|
"kumiko.pager.previousPage": "Previous page",
|
|
75
92
|
"kumiko.pager.nextPage": "Next page",
|
|
76
|
-
"kumiko.pager.page": "Page {entry}",
|
|
77
93
|
|
|
78
94
|
"kumiko.combobox.search-placeholder": "Search…",
|
|
79
95
|
"kumiko.combobox.empty": "No matches.",
|
|
@@ -123,6 +139,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
123
139
|
"kumiko.dialog.confirm": "Confirm",
|
|
124
140
|
"kumiko.dialog.cancel": "Cancel",
|
|
125
141
|
"kumiko.dialog.close": "Close",
|
|
142
|
+
"kumiko.drawer.discard.title": "Discard changes?",
|
|
143
|
+
"kumiko.drawer.discard.body": "Your input in this form will be lost.",
|
|
144
|
+
"kumiko.drawer.discard.confirm": "Discard",
|
|
145
|
+
"kumiko.drawer.discard.cancel": "Keep editing",
|
|
126
146
|
|
|
127
147
|
"kumiko.aiText.acceptHint": "Tab = accept, Esc = discard",
|
|
128
148
|
"kumiko.aiText.correct": "Correct",
|
package/src/index.ts
CHANGED
|
@@ -188,6 +188,8 @@ export {
|
|
|
188
188
|
useTranslation,
|
|
189
189
|
} from "./i18n";
|
|
190
190
|
export { kumikoDefaultTranslations } from "./i18n-defaults";
|
|
191
|
+
export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer";
|
|
192
|
+
export { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable } from "./page-header-slot";
|
|
191
193
|
export type {
|
|
192
194
|
ActionMenuItemSpec,
|
|
193
195
|
ActionOverflowMenuProps,
|
|
@@ -212,9 +214,11 @@ export type {
|
|
|
212
214
|
EmbeddedListColumn,
|
|
213
215
|
EmbeddedListInputProps,
|
|
214
216
|
EmbeddedListTotal,
|
|
217
|
+
FieldCellWidth,
|
|
215
218
|
FieldProps,
|
|
216
219
|
FillContainerProps,
|
|
217
220
|
FormProps,
|
|
221
|
+
FormSectionNavItem,
|
|
218
222
|
FormWidth,
|
|
219
223
|
GridCellProps,
|
|
220
224
|
GridProps,
|
|
@@ -223,8 +227,10 @@ export type {
|
|
|
223
227
|
JsonViewProps,
|
|
224
228
|
LightboxProps,
|
|
225
229
|
LinkProps,
|
|
230
|
+
MetricBandProps,
|
|
226
231
|
MetricProps,
|
|
227
232
|
ModalProps,
|
|
233
|
+
PageHeaderProps,
|
|
228
234
|
PrimitivesProviderProps,
|
|
229
235
|
PrimitivesRegistry,
|
|
230
236
|
ProgressProps,
|
|
@@ -249,6 +255,7 @@ export {
|
|
|
249
255
|
PrimitivesProvider,
|
|
250
256
|
STICKY_PRIMARY_ACTION_PROP,
|
|
251
257
|
shouldRenderActionsIconOnly,
|
|
258
|
+
statusToneForOptionTone,
|
|
252
259
|
statusToneForValue,
|
|
253
260
|
usePrimitives,
|
|
254
261
|
} from "./primitives";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
// True while a form renders inside the Drawer primitive (DrawerHost). The form
|
|
4
|
+
// then drops its own title and card chrome (the drawer header carries the
|
|
5
|
+
// title) and lays out as scrolling body plus pinned footer.
|
|
6
|
+
const InsideDrawerContext = createContext(false);
|
|
7
|
+
|
|
8
|
+
export const InsideDrawerProvider = InsideDrawerContext.Provider;
|
|
9
|
+
|
|
10
|
+
export function useInsideDrawer(): boolean {
|
|
11
|
+
return useContext(InsideDrawerContext);
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
// True while the screen renders below an app shell that offers page-header
|
|
4
|
+
// slots (see the PageHeader primitive). Embedded hosts (drawer, dashboard
|
|
5
|
+
// panel) reset it to false so their inner screens keep their own placement.
|
|
6
|
+
const PageHeaderSlotAvailableContext = createContext(false);
|
|
7
|
+
|
|
8
|
+
export const PageHeaderSlotAvailableProvider = PageHeaderSlotAvailableContext.Provider;
|
|
9
|
+
|
|
10
|
+
export function usePageHeaderSlotAvailable(): boolean {
|
|
11
|
+
return useContext(PageHeaderSlotAvailableContext);
|
|
12
|
+
}
|