@cosmicdrift/kumiko-renderer 0.346.0 → 0.348.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/dist/app/kumiko-screen.js +69 -10
- package/dist/components/render-edit-types.d.ts +2 -1
- package/dist/components/render-edit.js +3 -2
- package/dist/context/dispatcher-context.d.ts +2 -0
- package/dist/context/dispatcher-context.js +9 -2
- package/dist/context/query-pool.d.ts +11 -0
- package/dist/context/query-pool.js +64 -0
- package/dist/hooks/use-query.d.ts +1 -0
- package/dist/hooks/use-query.js +18 -3
- package/dist/primitives.d.ts +3 -0
- package/package.json +5 -5
- package/src/changes.json +36 -0
|
@@ -13,8 +13,10 @@ import { RenderList } from "../components/render-list.js";
|
|
|
13
13
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
14
14
|
import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
|
|
15
15
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
16
|
+
import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
|
|
16
17
|
import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
|
|
17
18
|
import { useQuery } from "../hooks/use-query.js";
|
|
19
|
+
import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
|
|
18
20
|
import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
|
|
19
21
|
import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
|
|
20
22
|
import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
|
|
@@ -86,7 +88,8 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
|
|
|
86
88
|
return _jsx(ReturnHostProvider, { value: ownHost, children: body });
|
|
87
89
|
}
|
|
88
90
|
function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
|
|
89
|
-
const { Banner } = usePrimitives();
|
|
91
|
+
const { Banner, PageHeader } = usePrimitives();
|
|
92
|
+
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
90
93
|
const t = useTranslation();
|
|
91
94
|
const result = useQuery(visibleWhen.query, {}, { live: true });
|
|
92
95
|
const verdict = evalVisibleWhen(visibleWhen, result);
|
|
@@ -95,10 +98,9 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
|
|
|
95
98
|
if (verdict === "loading") {
|
|
96
99
|
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
97
100
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
|
|
101
|
+
// The route still names the locked screen; its breadcrumb must not label what stands in for it.
|
|
102
|
+
const hideLockedBreadcrumb = PageHeader !== undefined && pageHeaderSlotAvailable ? _jsx(PageHeader, { hideBreadcrumb: true }) : null;
|
|
103
|
+
return (_jsxs(_Fragment, { children: [hideLockedBreadcrumb, fallback !== undefined ? (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate })) : (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }))] }));
|
|
102
104
|
}
|
|
103
105
|
function FallbackScreen({ featureName, fallback, translate, }) {
|
|
104
106
|
const target = useEmbeddedScreen(featureName, fallback);
|
|
@@ -125,9 +127,12 @@ function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onC
|
|
|
125
127
|
case "secretMint":
|
|
126
128
|
return _jsx(SecretMintBody, { schema: schema, screen: screen, translate: translate });
|
|
127
129
|
case "configEdit":
|
|
128
|
-
|
|
130
|
+
// Keyed by screen id: the form state is seeded once at mount, so switching
|
|
131
|
+
// between two settings screens must remount instead of carrying the
|
|
132
|
+
// previous screen's (empty) field state over.
|
|
133
|
+
return (_jsx(ConfigEditBody, { schema: schema, screen: screen, translate: translate }, screen.id));
|
|
129
134
|
case "secretsEdit":
|
|
130
|
-
return _jsx(SecretsEditBody, { screen: screen, translate: translate });
|
|
135
|
+
return _jsx(SecretsEditBody, { screen: screen, translate: translate }, screen.id);
|
|
131
136
|
case "custom":
|
|
132
137
|
return _jsx(CustomScreenBody, { screenId: screen.id, featureName: schema.featureName });
|
|
133
138
|
}
|
|
@@ -360,6 +365,27 @@ function formatSummaryValue(value, locale) {
|
|
|
360
365
|
function formatSummaryParams(prefill, locale) {
|
|
361
366
|
return Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
|
|
362
367
|
}
|
|
368
|
+
// Placeholders of `successMessage`: formatted submitted values, with a
|
|
369
|
+
// reference field resolved to the chosen record's label. A lookup that fails or
|
|
370
|
+
// misses the row (the lookup list is capped) falls back to the raw id.
|
|
371
|
+
async function resolveSuccessMessageParams(screen, featureName, values, dispatcher, locale) {
|
|
372
|
+
const params = formatSummaryParams(values, locale);
|
|
373
|
+
await Promise.all(Object.entries(screen.fields).map(async ([name, def]) => {
|
|
374
|
+
const raw = values[name];
|
|
375
|
+
if (def.type !== "reference" || typeof raw !== "string" || raw === "")
|
|
376
|
+
return;
|
|
377
|
+
const target = parseRefTarget(def.entity, featureName);
|
|
378
|
+
const { queryQn, labelKey } = referenceLookupSource(target.featureName, target.entityName, def.labelField ?? "id");
|
|
379
|
+
const lookup = await dispatcher.query(queryQn, { limit: REFERENCE_LIST_LOOKUP_LIMIT });
|
|
380
|
+
if (!lookup.isSuccess)
|
|
381
|
+
return;
|
|
382
|
+
const row = lookup.data.rows.find((candidate) => candidate["id"] === raw);
|
|
383
|
+
const label = row?.[labelKey];
|
|
384
|
+
if (label !== undefined && label !== null)
|
|
385
|
+
params[name] = String(label);
|
|
386
|
+
}));
|
|
387
|
+
return params;
|
|
388
|
+
}
|
|
363
389
|
function resolveActionFormSummary(summary, prefill, translate, locale) {
|
|
364
390
|
if (summary === undefined)
|
|
365
391
|
return undefined;
|
|
@@ -2323,13 +2349,34 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
|
|
|
2323
2349
|
const t = useTranslation();
|
|
2324
2350
|
const effectiveTranslate = translate ?? t;
|
|
2325
2351
|
const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
|
|
2326
|
-
const
|
|
2352
|
+
const dispatcher = useDispatcher();
|
|
2353
|
+
const [successText, setSuccessText] = useState(undefined);
|
|
2354
|
+
// A slow label lookup of an earlier submit must not overwrite the banner of a later one.
|
|
2355
|
+
const successSequence = useRef(0);
|
|
2356
|
+
useEffect(() => () => {
|
|
2357
|
+
successSequence.current += 1;
|
|
2358
|
+
}, []);
|
|
2359
|
+
const handleSubmitted = useCallback((result, values) => {
|
|
2360
|
+
successSequence.current += 1;
|
|
2361
|
+
const sequence = successSequence.current;
|
|
2362
|
+
setSuccessText(undefined);
|
|
2327
2363
|
if (!result.isSuccess)
|
|
2328
2364
|
return;
|
|
2329
2365
|
if (onSuccess !== undefined) {
|
|
2330
2366
|
onSuccess();
|
|
2331
2367
|
return;
|
|
2332
2368
|
}
|
|
2369
|
+
if (screen.successMessage !== undefined && screen.redirect === undefined) {
|
|
2370
|
+
const messageKey = screen.successMessage;
|
|
2371
|
+
const show = (params) => {
|
|
2372
|
+
if (successSequence.current === sequence) {
|
|
2373
|
+
setSuccessText(effectiveTranslate(messageKey, params));
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
void resolveSuccessMessageParams(screen, schema.featureName, values, dispatcher, locale)
|
|
2377
|
+
.then(show)
|
|
2378
|
+
.catch(() => show(formatSummaryParams(values, locale)));
|
|
2379
|
+
}
|
|
2333
2380
|
// Without a redirect the form stays put; returnTo only replaces an existing
|
|
2334
2381
|
// navigation, and never one to a record screen (see redirectTargetsRecord).
|
|
2335
2382
|
if (screen.redirect !== undefined) {
|
|
@@ -2341,7 +2388,17 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
|
|
|
2341
2388
|
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
2342
2389
|
}
|
|
2343
2390
|
}
|
|
2344
|
-
}, [
|
|
2391
|
+
}, [
|
|
2392
|
+
nav,
|
|
2393
|
+
screen,
|
|
2394
|
+
onSuccess,
|
|
2395
|
+
schema,
|
|
2396
|
+
appFeatures,
|
|
2397
|
+
returnTarget,
|
|
2398
|
+
dispatcher,
|
|
2399
|
+
locale,
|
|
2400
|
+
effectiveTranslate,
|
|
2401
|
+
]);
|
|
2345
2402
|
// Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
|
|
2346
2403
|
// sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
|
|
2347
2404
|
// gewinnt über redirect; `false` schaltet den Button explizit ab
|
|
@@ -2366,7 +2423,9 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
|
|
|
2366
2423
|
// (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
|
|
2367
2424
|
// — anders als im entityEdit gibt es hier keinen record, aus dem
|
|
2368
2425
|
// sie Kontext ziehen könnten.
|
|
2369
|
-
extensionInitialValues: initial,
|
|
2426
|
+
extensionInitialValues: initial, ...(successText !== undefined && {
|
|
2427
|
+
leadContent: (_jsx(Banner, { variant: "info", testId: "action-form-success", children: successText })),
|
|
2428
|
+
}), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
|
|
2370
2429
|
submitLabel: screen.submitLabel ?? submitLabelFallback,
|
|
2371
2430
|
}), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
|
|
2372
2431
|
}
|
|
@@ -35,7 +35,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
35
35
|
readonly translate?: Translate;
|
|
36
36
|
readonly ctx?: TCtx;
|
|
37
37
|
readonly schema?: z.ZodType;
|
|
38
|
-
|
|
38
|
+
/** `values` are the form values at the moment the submit succeeded. */
|
|
39
|
+
readonly onSubmit?: (result: SubmitResult<unknown>, values: TValues) => void;
|
|
39
40
|
readonly payloadMode?: "values" | "changes";
|
|
40
41
|
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
41
42
|
/** Prefix to strip from server validation issue paths before mapping them
|
|
@@ -1001,8 +1001,9 @@ export function RenderEdit(props) {
|
|
|
1001
1001
|
// skip: on entity-success-but-extension-failure the extension-error
|
|
1002
1002
|
// banner is showing — don't notify (the caller navigates away on success
|
|
1003
1003
|
// and would unmount it before the user sees the failure).
|
|
1004
|
-
if (shouldNotifyCaller(result, extensionsPersisted))
|
|
1005
|
-
onSubmit?.(result);
|
|
1004
|
+
if (shouldNotifyCaller(result, extensionsPersisted)) {
|
|
1005
|
+
onSubmit?.(result, controller.getSnapshot().values);
|
|
1006
|
+
}
|
|
1006
1007
|
}
|
|
1007
1008
|
finally {
|
|
1008
1009
|
isSubmittingRef.current = false;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Dispatcher, DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
+
import { type QueryPools } from "./query-pool.js";
|
|
3
4
|
export type DispatcherProviderProps = {
|
|
4
5
|
readonly dispatcher: Dispatcher;
|
|
5
6
|
readonly children: ReactNode;
|
|
6
7
|
};
|
|
7
8
|
export declare function DispatcherProvider({ dispatcher, children }: DispatcherProviderProps): ReactNode;
|
|
9
|
+
export declare function useOptionalQueryPools(): QueryPools | undefined;
|
|
8
10
|
export declare function useDispatcher(): Dispatcher;
|
|
9
11
|
export declare function useOptionalDispatcher(): Dispatcher | undefined;
|
|
10
12
|
export declare function useDispatcherStatus(): DispatcherStatus;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext } from "react";
|
|
2
|
+
import { createContext, useContext, useState } from "react";
|
|
3
3
|
import { useStore } from "../hooks/use-store.js";
|
|
4
|
+
import { createQueryPools } from "./query-pool.js";
|
|
4
5
|
// React Context threading the Dispatcher through the tree. An app
|
|
5
6
|
// wraps its root in <DispatcherProvider dispatcher={createLiveDispatcher()}>
|
|
6
7
|
// and every hook below reaches into that context instead of taking the
|
|
@@ -11,8 +12,14 @@ import { useStore } from "../hooks/use-store.js";
|
|
|
11
12
|
// dispatcher per app (rebuilding would wipe its in-flight tracking and
|
|
12
13
|
// status listeners). Tests wire a fake dispatcher in directly.
|
|
13
14
|
const DispatcherContext = createContext(null);
|
|
15
|
+
const QueryPoolsContext = createContext(null);
|
|
14
16
|
export function DispatcherProvider({ dispatcher, children }) {
|
|
15
|
-
|
|
17
|
+
// Pools live with the provider so every hook of one query name shares them.
|
|
18
|
+
const [queryPools] = useState(createQueryPools);
|
|
19
|
+
return (_jsx(DispatcherContext, { value: dispatcher, children: _jsx(QueryPoolsContext, { value: queryPools, children: children }) }));
|
|
20
|
+
}
|
|
21
|
+
export function useOptionalQueryPools() {
|
|
22
|
+
return useContext(QueryPoolsContext) ?? undefined;
|
|
16
23
|
}
|
|
17
24
|
// Reads the ambient Dispatcher. Throws instead of returning null when
|
|
18
25
|
// no provider is mounted — a dispatcher-less hook is always a
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type QuerySlot = {
|
|
2
|
+
readonly release: () => void;
|
|
3
|
+
};
|
|
4
|
+
export type QueryPools = {
|
|
5
|
+
/** Resolves with a slot once fewer than `limit` requests of this query name
|
|
6
|
+
* run, or with null when `signal` aborts first (a waiter that gives up
|
|
7
|
+
* never holds a slot). The caller must release a granted slot exactly once. Hooks of one
|
|
8
|
+
* query name are expected to pass the same limit. */
|
|
9
|
+
readonly acquire: (name: string, limit: number, signal: AbortSignal) => Promise<QuerySlot | null>;
|
|
10
|
+
};
|
|
11
|
+
export declare function createQueryPools(): QueryPools;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function createQueryPools() {
|
|
2
|
+
const pools = new Map();
|
|
3
|
+
const poolFor = (name) => {
|
|
4
|
+
const existing = pools.get(name);
|
|
5
|
+
if (existing)
|
|
6
|
+
return existing;
|
|
7
|
+
const created = { running: 0, waiting: [] };
|
|
8
|
+
pools.set(name, created);
|
|
9
|
+
return created;
|
|
10
|
+
};
|
|
11
|
+
const makeSlot = (name, pool) => {
|
|
12
|
+
let released = false;
|
|
13
|
+
return {
|
|
14
|
+
release: () => {
|
|
15
|
+
if (!released) {
|
|
16
|
+
released = true;
|
|
17
|
+
pool.running -= 1;
|
|
18
|
+
drain(name, pool);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const drain = (name, pool) => {
|
|
24
|
+
let next = pool.waiting[0];
|
|
25
|
+
while (next !== undefined && pool.running < next.limit) {
|
|
26
|
+
pool.waiting.shift();
|
|
27
|
+
pool.running += 1;
|
|
28
|
+
next.grant(makeSlot(name, pool));
|
|
29
|
+
next = pool.waiting[0];
|
|
30
|
+
}
|
|
31
|
+
if (pool.running === 0 && pool.waiting.length === 0)
|
|
32
|
+
pools.delete(name);
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
acquire: (name, limit, signal) => {
|
|
36
|
+
if (signal.aborted)
|
|
37
|
+
return Promise.resolve(null);
|
|
38
|
+
const pool = poolFor(name);
|
|
39
|
+
if (pool.waiting.length === 0 && pool.running < limit) {
|
|
40
|
+
pool.running += 1;
|
|
41
|
+
return Promise.resolve(makeSlot(name, pool));
|
|
42
|
+
}
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const waiter = {
|
|
45
|
+
limit,
|
|
46
|
+
grant: (slot) => {
|
|
47
|
+
signal.removeEventListener("abort", onAbort);
|
|
48
|
+
resolve(slot);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const onAbort = () => {
|
|
52
|
+
const index = pool.waiting.indexOf(waiter);
|
|
53
|
+
if (index >= 0)
|
|
54
|
+
pool.waiting.splice(index, 1);
|
|
55
|
+
signal.removeEventListener("abort", onAbort);
|
|
56
|
+
resolve(null);
|
|
57
|
+
drain(name, pool);
|
|
58
|
+
};
|
|
59
|
+
signal.addEventListener("abort", onAbort);
|
|
60
|
+
pool.waiting.push(waiter);
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -9,6 +9,7 @@ export type UseQueryOptions = {
|
|
|
9
9
|
readonly enabled?: boolean;
|
|
10
10
|
readonly live?: boolean;
|
|
11
11
|
readonly refetchIntervalMs?: number;
|
|
12
|
+
readonly concurrency?: number;
|
|
12
13
|
};
|
|
13
14
|
export declare function entityFromQueryType(type: string): string | undefined;
|
|
14
15
|
export declare function useQuery<TData = unknown>(type: string, payload: unknown, options?: UseQueryOptions): UseQueryResult<TData>;
|
package/dist/hooks/use-query.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { useDispatcher } from "../context/dispatcher-context.js";
|
|
2
|
+
import { useDispatcher, useOptionalQueryPools } from "../context/dispatcher-context.js";
|
|
3
3
|
import { useLiveEvents } from "../sse/live-events.js";
|
|
4
4
|
// Extract the entity-name from a standard Kumiko query type. Returns
|
|
5
5
|
// undefined for non-conforming types so the live-mode silently skips
|
|
@@ -13,9 +13,13 @@ export function entityFromQueryType(type) {
|
|
|
13
13
|
return undefined;
|
|
14
14
|
return parts[2];
|
|
15
15
|
}
|
|
16
|
+
function isValidConcurrency(value) {
|
|
17
|
+
return value !== undefined && Number.isFinite(value) && value >= 1;
|
|
18
|
+
}
|
|
16
19
|
export function useQuery(type, payload, options = {}) {
|
|
17
20
|
const dispatcher = useDispatcher();
|
|
18
|
-
const { enabled = true, live = false, refetchIntervalMs } = options;
|
|
21
|
+
const { enabled = true, live = false, refetchIntervalMs, concurrency } = options;
|
|
22
|
+
const queryPools = useOptionalQueryPools();
|
|
19
23
|
const [data, setData] = useState(null);
|
|
20
24
|
const [error, setError] = useState(null);
|
|
21
25
|
const [loading, setLoading] = useState(enabled);
|
|
@@ -45,10 +49,21 @@ export function useQuery(type, payload, options = {}) {
|
|
|
45
49
|
if (!background)
|
|
46
50
|
setLoading(true);
|
|
47
51
|
let result;
|
|
52
|
+
let slot = null;
|
|
48
53
|
try {
|
|
54
|
+
if (queryPools !== undefined && isValidConcurrency(concurrency)) {
|
|
55
|
+
slot = await queryPools.acquire(type, Math.floor(concurrency), ctrl.signal);
|
|
56
|
+
// skip: aborted while queued, the superseding run owns the state
|
|
57
|
+
if (slot === null)
|
|
58
|
+
return;
|
|
59
|
+
// skip: slot granted just as the run was superseded, finally frees it
|
|
60
|
+
if (ctrl.signal.aborted)
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
49
63
|
result = await dispatcher.query(type, payload, { signal: ctrl.signal });
|
|
50
64
|
}
|
|
51
65
|
finally {
|
|
66
|
+
slot?.release();
|
|
52
67
|
// A rejected fetch must not leave polling stuck; a superseded one must
|
|
53
68
|
// not clear the newer fetch's flag.
|
|
54
69
|
if (activeCtrl.current === ctrl)
|
|
@@ -69,7 +84,7 @@ export function useQuery(type, payload, options = {}) {
|
|
|
69
84
|
setError(result.error);
|
|
70
85
|
}
|
|
71
86
|
setLoading(false);
|
|
72
|
-
}, [dispatcher, type, payloadKey]);
|
|
87
|
+
}, [dispatcher, type, payloadKey, queryPools, concurrency]);
|
|
73
88
|
const run = useCallback(() => runFetch(false), [runFetch]);
|
|
74
89
|
useEffect(() => {
|
|
75
90
|
if (!enabled) {
|
package/dist/primitives.d.ts
CHANGED
|
@@ -1305,6 +1305,9 @@ export type PageHeaderProps = {
|
|
|
1305
1305
|
readonly actions?: ReactNode;
|
|
1306
1306
|
/** Land in the shell header's overflow menu at phone width. */
|
|
1307
1307
|
readonly overflowItems?: readonly ActionMenuItemSpec[];
|
|
1308
|
+
/** Hide the shell header's breadcrumb while mounted, for a screen that stands in
|
|
1309
|
+
* for the one the route names (a locked screen's fallback). */
|
|
1310
|
+
readonly hideBreadcrumb?: boolean;
|
|
1308
1311
|
};
|
|
1309
1312
|
/** One item in an `ActionOverflowMenu` (A7: header/row actions beyond the
|
|
1310
1313
|
* primary one collapse into this menu instead of a wall of buttons). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.348.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,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.348.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.348.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.348.0",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.348.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.348.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "ModeSwitch gains a pill variant and className, LanguageSwitcher gains a compact chip variant",
|
|
6
|
+
"detail": "ModeSwitch pill variant and LanguageSwitcher chip variant\n`ModeSwitch` gets `variant=\"pill\"` (grey track, raised active segment, dark mode aware) and a `className` prop; `outline` stays the default. `LanguageSwitcher` gets `variant=\"chip\"`, a compact monospace chip with the uppercase locale code in a border; `default` stays as it was."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.348.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "TimeseriesChart takes a height, an optional y-axis with gridlines and a date axis with n labels",
|
|
12
|
+
"detail": "TimeseriesChart: height, y-axis gridlines and a date axis\n`height` (px) replaces the fixed `h-16`; without it the chart looks as before. `yAxis: { ticks, format? }` draws that many gridlines with rounded value labels (0/200/400/600) in a left gutter, and the y-scale reaches the top tick. `xAxis: { ticks, format }` renders n evenly spaced date labels instead of the fixed start/mid/end of `axisLabels`."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.348.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "useQuery accepts concurrency to cap parallel requests per query name through a pool in the DispatcherProvider",
|
|
18
|
+
"detail": "useQuery option `concurrency` caps parallel requests per query name\n`useQuery(type, payload, { concurrency: n })` lets at most n `dispatcher.query` calls of that query name run at once. The pool lives in the DispatcherProvider and is shared by every hook with the same name; the rest wait in line with `loading` still true. A hook that unmounts, refetches or changes its payload while waiting leaves the line without holding a slot, and a finished or failed request hands its slot to the next waiter. Without the option nothing changes."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.347.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "actionForm successMessage names the chosen record's label after a successful submit",
|
|
24
|
+
"detail": "actionForm screens can confirm a submit with the chosen record\nA new `successMessage` i18n key on an `actionForm` screen shows a confirmation above the form after a successful submit, for screens that stay put (no `redirect`, not in a drawer). `{field}` placeholders take the submitted values, and a `reference` field shows the chosen record's label instead of its id. The tier-engine admin form uses it: \"Tier assigned: Acme → Pro\", so choosing the wrong tenant is visible right away. New i18n key: `tier-admin.success` (en/de/es). `RenderEdit`'s `onSubmit` receives the submitted values as a second argument."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.347.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Switching between generated config screens no longer shows empty fields for stored values",
|
|
30
|
+
"detail": "Settings screens show their stored values after switching screens\nMoving from one generated settings screen to another kept the previous screen's form state, so the fields of the new screen rendered empty and a select showed no active option even though a tenant value was set. Each config and secrets screen now mounts its own form."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.347.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "The shell header drops the locked screen's breadcrumb while a visibleWhen fallback or notice is shown",
|
|
36
|
+
"detail": "A locked screen's fallback shows no breadcrumb\nWhen `visibleWhen` is unmet, the route still names the locked screen, so the shell header kept that screen's breadcrumb above the fallback or the unavailable notice. The gate now mounts `<PageHeader hideBreadcrumb />`, and the shell header drops the breadcrumb while it is mounted. Embedded screens (dashboard panels, drawers) are not affected."
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "0.343.0",
|
|
4
40
|
"type": "improvement",
|