@cosmicdrift/kumiko-renderer-web 0.334.0 → 0.336.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/app-schema-boot.d.ts +2 -0
- package/dist/app/app-schema-boot.js +13 -4
- package/dist/app/create-app.js +28 -24
- package/dist/app/dashboard-body.js +51 -16
- package/dist/layout/nav-tree.js +1 -1
- package/dist/layout/shell-header.js +9 -3
- package/dist/layout/theme-label.d.ts +9 -0
- package/dist/layout/theme-label.js +11 -0
- package/dist/layout/theme-menu-item.js +2 -7
- package/dist/layout/theme-toggle.js +2 -8
- package/dist/primitives/combobox.js +1 -1
- package/dist/primitives/embedded-list-input.js +1 -1
- package/dist/primitives/index.js +104 -44
- package/dist/primitives/layout.d.ts +1 -0
- package/dist/primitives/layout.js +7 -0
- package/dist/primitives/metric.d.ts +1 -0
- package/dist/primitives/metric.js +6 -1
- package/dist/primitives/narrow-pinned-footer.d.ts +10 -0
- package/dist/primitives/narrow-pinned-footer.js +90 -0
- package/dist/primitives/number-input.d.ts +2 -1
- package/dist/primitives/number-input.js +2 -2
- package/dist/primitives/use-narrow-viewport.d.ts +1 -0
- package/dist/primitives/use-narrow-viewport.js +20 -8
- package/dist/widgets/charts.js +2 -1
- package/dist/widgets/dashboard-list.d.ts +3 -2
- package/dist/widgets/dashboard-list.js +9 -3
- package/dist/widgets/drawer.js +6 -19
- package/dist/widgets/step-bar.d.ts +10 -4
- package/dist/widgets/step-bar.js +40 -15
- package/package.json +7 -7
- package/src/changes.json +79 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { toAppSchema, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { toAppSchema, usePrimitives, useSessionEndedSignal, useTranslation, } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
// Mirrors framework's api-constants Routes.schema, same pattern as
|
|
5
5
|
// dispatcher-live's PATH_* constants — the one place that spells out the
|
|
@@ -40,7 +40,9 @@ export async function fetchAppSchema(signal) {
|
|
|
40
40
|
throw err;
|
|
41
41
|
return { kind: "failed" };
|
|
42
42
|
}
|
|
43
|
-
if (res.status === 401
|
|
43
|
+
if (res.status === 401)
|
|
44
|
+
return { kind: "session-ended" };
|
|
45
|
+
if (res.status === 403)
|
|
44
46
|
return { kind: "unauthorized" };
|
|
45
47
|
if (!res.ok)
|
|
46
48
|
return { kind: "failed" };
|
|
@@ -63,6 +65,7 @@ export function AppSchemaFetchBoot({ onLoaded, }) {
|
|
|
63
65
|
const t = useTranslation();
|
|
64
66
|
const { Banner, Button } = usePrimitives();
|
|
65
67
|
const [status, setStatus] = useState("loading");
|
|
68
|
+
const sessionEndedSignal = useSessionEndedSignal();
|
|
66
69
|
const [attempt, setAttempt] = useState(0);
|
|
67
70
|
// Ref instead of an effect dependency: onLoaded is a fresh closure on
|
|
68
71
|
// every KumikoAppRoot render (it captures setApp) — depending on it
|
|
@@ -89,13 +92,19 @@ export function AppSchemaFetchBoot({ onLoaded, }) {
|
|
|
89
92
|
});
|
|
90
93
|
return () => controller.abort();
|
|
91
94
|
}, [attempt]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (status === "session-ended")
|
|
97
|
+
sessionEndedSignal?.notify();
|
|
98
|
+
}, [status, sessionEndedSignal]);
|
|
92
99
|
if (status === "loading") {
|
|
93
100
|
return (_jsx("div", { role: "status", "aria-busy": "true", children: t("kumiko.app-boot.loading") }));
|
|
94
101
|
}
|
|
95
|
-
if (status === "unauthorized") {
|
|
102
|
+
if (status === "unauthorized" || status === "session-ended") {
|
|
96
103
|
// No auto-retry/reload here on purpose — a signed-out user retrying
|
|
97
104
|
// the same request just gets the same 401 again; the login gate that
|
|
98
|
-
// wraps this component is the actual way out.
|
|
105
|
+
// wraps this component is the actual way out. For session-ended the
|
|
106
|
+
// notify above normally swaps this banner for the login screen; the
|
|
107
|
+
// banner stays as fallback when no session provider is mounted.
|
|
99
108
|
return (_jsx(Banner, { variant: "error", padded: true, children: t("kumiko.app-boot.unauthorized") }));
|
|
100
109
|
}
|
|
101
110
|
return (_jsx(Banner, { variant: "error", padded: true, actions: _jsx(Button, { variant: "secondary", onClick: () => setAttempt((n) => n + 1), children: t("kumiko.actions.reload") }), children: t("kumiko.app-boot.failed") }));
|
package/dist/app/create-app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
3
3
|
import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
4
|
-
import { AppFeaturesProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { AppFeaturesProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, createSessionEndedSignal, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, SessionEndedSignalProvider, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, withSessionEndedDetection, } from "@cosmicdrift/kumiko-renderer";
|
|
5
5
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { createRoot } from "react-dom/client";
|
|
7
7
|
import { lastSegment } from "../layout/nav-tree.js";
|
|
@@ -139,7 +139,7 @@ export function resolveRootScreenQn(features, explicitScreenQn, schemaIsRoleProj
|
|
|
139
139
|
}
|
|
140
140
|
if (!schemaIsRoleProjected)
|
|
141
141
|
return explicitScreenQn;
|
|
142
|
-
if (
|
|
142
|
+
if (findOwnerFeature(features, explicitScreenQn) !== undefined)
|
|
143
143
|
return explicitScreenQn;
|
|
144
144
|
return firstLandingScreenQnForProjectedSchema(features);
|
|
145
145
|
}
|
|
@@ -233,7 +233,7 @@ function KumikoAppRoot(props) {
|
|
|
233
233
|
warnMissingCustomScreens(loaded, props.customScreens);
|
|
234
234
|
setApp(loaded);
|
|
235
235
|
}, schemaIsRoleProjected: isFetchMode, ...(isFetchMode && { onUnmountResetSchema: () => setApp(undefined) }), ...(props.translate !== undefined && { translate: props.translate }), ...(props.onRowClick !== undefined && { onRowClick: props.onRowClick }), ...(props.shell !== undefined && { shell: props.shell }) }));
|
|
236
|
-
return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth ?? "4xl", children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsxs(ToastProvider, { children: [_jsx(UpdateChecker, {}), stackWrappers(props.providers, stackWrappers(props.gates, screenNode))] }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
|
|
236
|
+
return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth ?? "4xl", children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsxs(ToastProvider, { children: [_jsx(UpdateChecker, {}), _jsx(SessionEndedSignalProvider, { signal: props.sessionEndedSignal, children: stackWrappers(props.providers, stackWrappers(props.gates, screenNode)) })] }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
|
|
237
237
|
}
|
|
238
238
|
// Sits INSIDE the clientFeature gates (e.g. an auth gate) — a gate that
|
|
239
239
|
// renders a login placeholder instead of children keeps this component
|
|
@@ -284,7 +284,8 @@ export function createKumikoApp(options = {}) {
|
|
|
284
284
|
throw new Error("createKumikoApp: schema contains no screens accessible without a role restriction. Add at least one entry to `schema.screens` without `access.roles`, or pass `screenQn` explicitly.");
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
|
-
const
|
|
287
|
+
const sessionEndedSignal = createSessionEndedSignal();
|
|
288
|
+
const dispatcher = withSessionEndedDetection(options.dispatcher ?? createLiveDispatcher(), sessionEndedSignal);
|
|
288
289
|
const draftStorage = options.draftStorage ?? createBrowserDraftStorage();
|
|
289
290
|
const primitives = { ...defaultPrimitives, ...(options.primitives ?? {}) };
|
|
290
291
|
const liveEvents = createEventSourceLiveEvents();
|
|
@@ -331,7 +332,7 @@ export function createKumikoApp(options = {}) {
|
|
|
331
332
|
const localeResolver = options.locale ?? createBrowserLocaleResolver();
|
|
332
333
|
const navAdapter = options.navAdapter ?? useBrowserNavApi;
|
|
333
334
|
const root = createRoot(container);
|
|
334
|
-
root.render(_jsx(KumikoAppRoot, { initialApp: initialApp, screenQn: options.screenQn, clientFeatures: clientFeatures, providers: providers, gates: gates, customScreens: customScreens, columnRenderers: columnRenderers, extensionSectionComponents: extensionSectionComponents, contentEditors: contentEditors, resolvers: resolvers, dispatcher: dispatcher, draftStorage: draftStorage, primitives: primitives, liveEvents: liveEvents, localeResolver: localeResolver, navAdapter: navAdapter, screenWidth: options.screenWidth, ...(options.translate !== undefined && { translate: options.translate }), ...(options.onRowClick !== undefined && { onRowClick: options.onRowClick }), ...(options.shell !== undefined && { shell: options.shell }) }));
|
|
335
|
+
root.render(_jsx(KumikoAppRoot, { initialApp: initialApp, screenQn: options.screenQn, clientFeatures: clientFeatures, providers: providers, gates: gates, customScreens: customScreens, columnRenderers: columnRenderers, extensionSectionComponents: extensionSectionComponents, contentEditors: contentEditors, resolvers: resolvers, dispatcher: dispatcher, sessionEndedSignal: sessionEndedSignal, draftStorage: draftStorage, primitives: primitives, liveEvents: liveEvents, localeResolver: localeResolver, navAdapter: navAdapter, screenWidth: options.screenWidth, ...(options.translate !== undefined && { translate: options.translate }), ...(options.onRowClick !== undefined && { onRowClick: options.onRowClick }), ...(options.shell !== undefined && { shell: options.shell }) }));
|
|
335
336
|
return { root };
|
|
336
337
|
}
|
|
337
338
|
// TokensBoot uses the browser-backed TokensApi hook (class-based
|
|
@@ -348,9 +349,10 @@ function BrowserNavBoot({ app, fallbackQn, useNavApi, hasWorkspaces, translate,
|
|
|
348
349
|
const screen = (_jsx(RoutedScreen, { app: app, fallbackQn: fallbackQn, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
|
|
349
350
|
return (_jsx(NavProvider, { value: navApi, children: Shell !== undefined ? _jsx(Shell, { schema: app, children: screen }) : screen }));
|
|
350
351
|
}
|
|
351
|
-
// Finds the feature that owns a fully qualified ScreenQn
|
|
352
|
-
// Returns undefined if the screen isn't declared in any
|
|
353
|
-
|
|
352
|
+
// Finds the feature that owns a fully qualified ScreenQn.
|
|
353
|
+
// Returns undefined if the screen isn't declared in any feature schema —
|
|
354
|
+
// KumikoScreen then renders the "Screen not found" banner.
|
|
355
|
+
function findOwnerFeature(features, qn) {
|
|
354
356
|
for (const feature of features) {
|
|
355
357
|
for (const s of feature.screens) {
|
|
356
358
|
if (qualifyScreenId(feature.featureName, s.id) === qn)
|
|
@@ -359,11 +361,16 @@ function findOwnerFeatureInFeatures(features, qn) {
|
|
|
359
361
|
}
|
|
360
362
|
return undefined;
|
|
361
363
|
}
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
function
|
|
366
|
-
|
|
364
|
+
// Single source for "which entityEdit screen does a row click land on", shared
|
|
365
|
+
// by the click handler and hasRowClickTarget so a row never looks clickable
|
|
366
|
+
// without a target (or the reverse).
|
|
367
|
+
function findEditScreenForEntity(features, entityName) {
|
|
368
|
+
for (const feature of features) {
|
|
369
|
+
const editScreen = feature.screens.find((s) => s.type === "entityEdit" && s.entity === entityName);
|
|
370
|
+
if (editScreen)
|
|
371
|
+
return editScreen;
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
367
374
|
}
|
|
368
375
|
// fw#2640: a list screen with no reachable click target (no `detailFor`
|
|
369
376
|
// screen for its entity, no app-wide onRowClick, no entityEdit screen)
|
|
@@ -382,7 +389,7 @@ function hasRowClickTarget(app, screen, onRowClick) {
|
|
|
382
389
|
return false;
|
|
383
390
|
if (hasDetailScreen(app.features, screen.entity))
|
|
384
391
|
return true;
|
|
385
|
-
return app.features
|
|
392
|
+
return findEditScreenForEntity(app.features, screen.entity) !== undefined;
|
|
386
393
|
}
|
|
387
394
|
function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
|
|
388
395
|
const nav = useNav();
|
|
@@ -393,7 +400,7 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
|
|
|
393
400
|
// fallback feature (the one from fallbackQn).
|
|
394
401
|
const { feature, qn, entityId, activeScreen } = useMemo(() => {
|
|
395
402
|
if (nav.route === undefined) {
|
|
396
|
-
const ownerFeature = findOwnerFeature(app, fallbackQn);
|
|
403
|
+
const ownerFeature = findOwnerFeature(app.features, fallbackQn);
|
|
397
404
|
return {
|
|
398
405
|
feature: ownerFeature,
|
|
399
406
|
qn: fallbackQn,
|
|
@@ -410,7 +417,7 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
|
|
|
410
417
|
break;
|
|
411
418
|
}
|
|
412
419
|
}
|
|
413
|
-
const ownerFeature = matchedFeature ?? findOwnerFeature(app, fallbackQn);
|
|
420
|
+
const ownerFeature = matchedFeature ?? findOwnerFeature(app.features, fallbackQn);
|
|
414
421
|
const qualifiedQn = ownerFeature ? qualifyScreenId(ownerFeature.featureName, shortId) : shortId;
|
|
415
422
|
return {
|
|
416
423
|
feature: ownerFeature,
|
|
@@ -445,14 +452,11 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
|
|
|
445
452
|
// single-feature setup that's the same feature as the active one, in
|
|
446
453
|
// multi-feature the edit could theoretically live in a different
|
|
447
454
|
// feature (one that shares the entity).
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
navigateWithReturnTo(nav, { screenId: lastSegment(editScreen.id), entityId: row.id }, host);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
455
|
+
const editScreen = findEditScreenForEntity(app.features, entityName);
|
|
456
|
+
if (editScreen) {
|
|
457
|
+
// editScreen.id is already short form; lastSegment is a no-op
|
|
458
|
+
// safety net here, kept for symmetry with the other call sites.
|
|
459
|
+
navigateWithReturnTo(nav, { screenId: lastSegment(editScreen.id), entityId: row.id }, host);
|
|
456
460
|
}
|
|
457
461
|
};
|
|
458
462
|
}, [app, activeScreen, onRowClick, nav, qn, entityId]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import { dispatcherErrorText, EmbeddedScreenProvider, extensionSectionName, KumikoScreen, useEmbeddedScreen, useExtensionSectionComponent, useLocale, useNav, usePageHeaderSlotAvailable, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
4
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
5
5
|
import { EmbeddedFormProvider } from "../primitives/index.js";
|
|
6
6
|
import { PageSection } from "../primitives/layout.js";
|
|
7
7
|
import { Skeleton } from "../ui/skeleton.js";
|
|
@@ -221,7 +221,7 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
221
221
|
} }));
|
|
222
222
|
}
|
|
223
223
|
function ListPanelBody({ panel, label, screenParams, translate, }) {
|
|
224
|
-
const { formatPercent, formatNumber } = useDashboardFormats();
|
|
224
|
+
const { formatPercent, formatNumber, formatDateTime } = useDashboardFormats();
|
|
225
225
|
const columns = panel.columns.map((c) => {
|
|
226
226
|
const normalized = normalizeListColumn(c);
|
|
227
227
|
return {
|
|
@@ -233,7 +233,7 @@ function ListPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
233
233
|
}),
|
|
234
234
|
};
|
|
235
235
|
});
|
|
236
|
-
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, translate: translate })) }));
|
|
236
|
+
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, formatDateTime: formatDateTime, translate: translate })) }));
|
|
237
237
|
}
|
|
238
238
|
function FeedPanelBody({ panel, label, screenParams, translate, }) {
|
|
239
239
|
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
|
|
@@ -302,6 +302,7 @@ function useFilterParams(screen) {
|
|
|
302
302
|
function useTimeRange(screen, translate) {
|
|
303
303
|
const t = useTranslation();
|
|
304
304
|
const nav = useNav();
|
|
305
|
+
const resolver = useLocale();
|
|
305
306
|
const timeRange = screen.timeRange;
|
|
306
307
|
if (timeRange === undefined)
|
|
307
308
|
return { params: {}, control: null };
|
|
@@ -310,7 +311,9 @@ function useTimeRange(screen, translate) {
|
|
|
310
311
|
? fromUrl
|
|
311
312
|
: timeRange.default;
|
|
312
313
|
return {
|
|
313
|
-
|
|
314
|
+
// Metric buckets are cut server-side; without the user's zone they default
|
|
315
|
+
// to UTC while the axis labels are formatted in the user's zone.
|
|
316
|
+
params: { [timeRange.id]: value, timeZone: resolver.timeZone() },
|
|
314
317
|
control: (_jsx(ModeSwitch, { value: value, options: timeRange.options.map((o) => ({ value: o.value, label: translate(o.label) })), onChange: (next) => nav.setSearchParams({ [timeRange.id]: next }), ariaLabel: t("kumiko.dashboard.time-range"), testId: `dashboard-time-range-${timeRange.id}` })),
|
|
315
318
|
};
|
|
316
319
|
}
|
|
@@ -345,14 +348,45 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
|
345
348
|
}
|
|
346
349
|
return (_jsx(ProgressListPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
|
|
347
350
|
}
|
|
351
|
+
const VisibilityQueryContext = createContext(new Map());
|
|
352
|
+
// One live query per distinct `visibleWhen.query`, shared by every panel that
|
|
353
|
+
// gates on it — N panels on the same status query would otherwise each open
|
|
354
|
+
// their own request, SSE subscription and refetch per event.
|
|
355
|
+
function VisibilityQueryScope({ query, children, }) {
|
|
356
|
+
const parent = useContext(VisibilityQueryContext);
|
|
357
|
+
const result = useQuery(query, {}, { live: true });
|
|
358
|
+
const value = useMemo(() => new Map(parent).set(query, result), [parent, query, result]);
|
|
359
|
+
return (_jsx(VisibilityQueryContext.Provider, { value: value, children: children }));
|
|
360
|
+
}
|
|
361
|
+
function VisibilityQueryScopes({ queries, children, }) {
|
|
362
|
+
const [first, ...rest] = queries;
|
|
363
|
+
if (first === undefined)
|
|
364
|
+
return children;
|
|
365
|
+
return (_jsx(VisibilityQueryScope, { query: first, children: _jsx(VisibilityQueryScopes, { queries: rest, children: children }) }));
|
|
366
|
+
}
|
|
367
|
+
function distinctVisibilityQueries(panels) {
|
|
368
|
+
const queries = new Set();
|
|
369
|
+
for (const panel of panels) {
|
|
370
|
+
if (panel.kind === "screen" && panel.visibleWhen !== undefined) {
|
|
371
|
+
queries.add(panel.visibleWhen.query);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return [...queries];
|
|
375
|
+
}
|
|
348
376
|
function ScreenPanelTile({ panel, featureName, translate, }) {
|
|
349
377
|
const visibleWhen = panel.visibleWhen;
|
|
350
|
-
const visibility =
|
|
378
|
+
const visibility = useContext(VisibilityQueryContext).get(visibleWhen?.query ?? "");
|
|
351
379
|
const target = useEmbeddedScreen(featureName, panel.screen);
|
|
352
380
|
if (target === undefined)
|
|
353
381
|
return null;
|
|
354
|
-
if (visibleWhen !== undefined
|
|
355
|
-
|
|
382
|
+
if (visibleWhen !== undefined) {
|
|
383
|
+
// A failed gate query must not silently drop the panel: on the
|
|
384
|
+
// account-security page that would hide every MFA enable/disable path.
|
|
385
|
+
if (visibility?.error) {
|
|
386
|
+
return (_jsx("div", { className: WIDE_PANEL, "data-testid": `dashboard-panel-${panel.id}`, children: _jsx(PanelError, { label: panel.label !== undefined ? translate(panel.label) : panel.id, error: visibility.error, onRetry: () => void visibility.refetch() }) }));
|
|
387
|
+
}
|
|
388
|
+
if (visibility?.data?.[visibleWhen.field] !== visibleWhen.eq)
|
|
389
|
+
return null;
|
|
356
390
|
}
|
|
357
391
|
const screenBody = _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate });
|
|
358
392
|
const embedded = panel.chromeless === true ? (_jsx(EmbeddedFormProvider, { children: _jsx(EmbeddedScreenProvider, { children: screenBody }) })) : (screenBody);
|
|
@@ -377,6 +411,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
377
411
|
const { params: rangeParams, control: rangeControl } = useTimeRange(screen, effectiveTranslate);
|
|
378
412
|
const screenParams = { filterParams, rangeParams };
|
|
379
413
|
const loadedAtMs = useLoadedAt(screenParams);
|
|
414
|
+
const visibilityQueries = distinctVisibilityQueries(screen.panels);
|
|
380
415
|
const scope = screen.scope;
|
|
381
416
|
// A raw description is agent-facing prose; only an i18n key is user-facing copy.
|
|
382
417
|
const translatedDescription = screen.description !== undefined ? effectiveTranslate(screen.description) : undefined;
|
|
@@ -384,13 +419,13 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
384
419
|
const scopeBadge = scope !== undefined ? (_jsx(StatusBadge, { tone: "accent", testId: `dashboard-${screen.id}-scope`, children: effectiveTranslate(scope.badge) })) : null;
|
|
385
420
|
// The shell header owns the title; the badge sits right after it there.
|
|
386
421
|
const badgeInHeader = scopeBadge !== null && headerSlotAvailable && PageHeader !== undefined;
|
|
387
|
-
return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
422
|
+
return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx(VisibilityQueryScopes, { queries: visibilityQueries, children: _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
|
|
423
|
+
if (panel.kind === "screen") {
|
|
424
|
+
return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
|
|
425
|
+
}
|
|
426
|
+
const label = panel.kind === "custom" || panel.label === undefined
|
|
427
|
+
? undefined
|
|
428
|
+
: effectiveTranslate(panel.label);
|
|
429
|
+
return (_jsx("div", { className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screen.id, screenParams: screenParams, translate: effectiveTranslate }) }, panel.id));
|
|
430
|
+
}) }) })] }));
|
|
396
431
|
}
|
package/dist/layout/nav-tree.js
CHANGED
|
@@ -298,7 +298,7 @@ function ActionGlyph({ icon }) {
|
|
|
298
298
|
}
|
|
299
299
|
// Boot validates screen XOR target for NavDefinition actions, but a
|
|
300
300
|
// provider-emitted TreeNode reaches here without going through boot — warn
|
|
301
|
-
// once per label instead of rendering a dead button
|
|
301
|
+
// once per label instead of rendering a dead button.
|
|
302
302
|
const warnedTreeActionLabels = new Set();
|
|
303
303
|
function warnTreeActionDropped(label) {
|
|
304
304
|
// skip: already warned for this label — suppresses the repeat, not the warning itself.
|
|
@@ -55,8 +55,7 @@ export function ShellHeader({ schema, user, headerActions, }) {
|
|
|
55
55
|
e.preventDefault();
|
|
56
56
|
nav.navigate({ screenId });
|
|
57
57
|
}, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
|
|
58
|
-
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "
|
|
59
|
-
"min-w-0 [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
58
|
+
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn("flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
60
59
|
}
|
|
61
60
|
// A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
|
|
62
61
|
// nodes with their own popovers, and the panel stays mounted while closed so their
|
|
@@ -94,8 +93,15 @@ function HeaderOverflow({ items, headerActions, }) {
|
|
|
94
93
|
};
|
|
95
94
|
}, [open]);
|
|
96
95
|
const onPanelKeyDown = (event) => {
|
|
96
|
+
// Opaque app headerActions (inputs, plain buttons) keep native Tab/arrow behaviour.
|
|
97
|
+
if (!(event.target instanceof HTMLElement) ||
|
|
98
|
+
event.target.getAttribute("role") !== "menuitem") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
97
101
|
if (event.key === "Tab") {
|
|
102
|
+
event.preventDefault();
|
|
98
103
|
setOpen(false);
|
|
104
|
+
triggerRef.current?.focus();
|
|
99
105
|
return;
|
|
100
106
|
}
|
|
101
107
|
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key))
|
|
@@ -103,7 +109,7 @@ function HeaderOverflow({ items, headerActions, }) {
|
|
|
103
109
|
const menuItems = Array.from(panelRef.current?.querySelectorAll(MENU_ITEM_SELECTOR) ?? []);
|
|
104
110
|
if (menuItems.length === 0)
|
|
105
111
|
return;
|
|
106
|
-
const current = menuItems.
|
|
112
|
+
const current = menuItems.indexOf(document.activeElement);
|
|
107
113
|
const last = menuItems.length - 1;
|
|
108
114
|
const next = event.key === "Home"
|
|
109
115
|
? 0
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ThemeToggleLabelOptions = {
|
|
2
|
+
readonly titleInDark?: string | undefined;
|
|
3
|
+
readonly titleInLight?: string | undefined;
|
|
4
|
+
};
|
|
5
|
+
export declare function useThemeToggleLabel({ titleInDark, titleInLight }: ThemeToggleLabelOptions): {
|
|
6
|
+
readonly isDark: boolean;
|
|
7
|
+
readonly title: string;
|
|
8
|
+
readonly toggleMode: () => void;
|
|
9
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useOptionalTranslation, useTokenController } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
export function useThemeToggleLabel({ titleInDark, titleInLight }) {
|
|
3
|
+
const { mode, toggleMode } = useTokenController();
|
|
4
|
+
// Optional: the toggle also renders outside a LocaleProvider (samples, tests).
|
|
5
|
+
const translate = useOptionalTranslation();
|
|
6
|
+
const isDark = mode === "dark";
|
|
7
|
+
const title = isDark
|
|
8
|
+
? (titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme")
|
|
9
|
+
: (titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme");
|
|
10
|
+
return { isDark, title, toggleMode };
|
|
11
|
+
}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useOptionalTranslation, useTokenController } from "@cosmicdrift/kumiko-renderer";
|
|
3
2
|
import { DropdownMenuItem } from "../primitives/dropdown-menu.js";
|
|
3
|
+
import { useThemeToggleLabel } from "./theme-label.js";
|
|
4
4
|
/** Theme toggle as a dropdown entry, for a user menu that carries the shell controls. */
|
|
5
5
|
export function ThemeMenuItem({ lightIcon = "☀", darkIcon = "☾", titleInDark, titleInLight, testId, }) {
|
|
6
|
-
const {
|
|
7
|
-
const isDark = mode === "dark";
|
|
8
|
-
const translate = useOptionalTranslation();
|
|
9
|
-
const title = isDark
|
|
10
|
-
? (titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme")
|
|
11
|
-
: (titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme");
|
|
6
|
+
const { isDark, title, toggleMode } = useThemeToggleLabel({ titleInDark, titleInLight });
|
|
12
7
|
return (_jsxs(DropdownMenuItem, { "data-testid": testId, onSelect: (event) => {
|
|
13
8
|
event.preventDefault();
|
|
14
9
|
toggleMode();
|
|
@@ -4,18 +4,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// Dependency zieht. Default: Unicode-Glyphs (☀ / ☾) — funktionieren in
|
|
5
5
|
// jedem Browser, jede App kann lucide/heroicons/eigene SVG via Props
|
|
6
6
|
// reinreichen.
|
|
7
|
-
import { useOptionalTranslation, useTokenController } from "@cosmicdrift/kumiko-renderer";
|
|
8
7
|
import { useContext } from "react";
|
|
9
8
|
import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
|
|
9
|
+
import { useThemeToggleLabel } from "./theme-label.js";
|
|
10
10
|
export function ThemeToggle({ lightIcon = "☀", darkIcon = "☾", titleInDark, titleInLight, testId, }) {
|
|
11
|
-
const { mode, toggleMode } = useTokenController();
|
|
12
11
|
const inOverflowMenu = useContext(HeaderOverflowMenuContext);
|
|
13
|
-
|
|
14
|
-
const translate = useOptionalTranslation();
|
|
15
|
-
const isDark = mode === "dark";
|
|
16
|
-
const title = isDark
|
|
17
|
-
? (titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme")
|
|
18
|
-
: (titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme");
|
|
12
|
+
const { isDark, title, toggleMode } = useThemeToggleLabel({ titleInDark, titleInLight });
|
|
19
13
|
const icon = isDark ? lightIcon : darkIcon;
|
|
20
14
|
if (inOverflowMenu) {
|
|
21
15
|
return (_jsxs("button", { type: "button", role: "menuitem", onClick: toggleMode, className: headerOverflowMenuItemClass, "data-testid": testId, children: [_jsx("span", { "aria-hidden": "true", children: icon }), _jsx("span", { children: title })] }));
|
|
@@ -105,7 +105,7 @@ export function ComboboxInput(props) {
|
|
|
105
105
|
const isSelected = multiple
|
|
106
106
|
? selectedValues.has(opt.value)
|
|
107
107
|
: opt.value === singleValue;
|
|
108
|
-
return (_jsxs(Command.Item, { value: opt.label, keywords: opt.description !== undefined ? [opt.description] : undefined, onSelect: () => {
|
|
108
|
+
return (_jsxs(Command.Item, { "data-testid": `combobox-${id}-option-${opt.value}`, value: opt.label, keywords: opt.description !== undefined ? [opt.description] : undefined, onSelect: () => {
|
|
109
109
|
if (props.multiple === true) {
|
|
110
110
|
toggleMulti(opt.value);
|
|
111
111
|
}
|
|
@@ -276,7 +276,7 @@ export function EmbeddedListInput({ id, columns, rows, totals, currency, disable
|
|
|
276
276
|
return (_jsxs("div", { ref: containerRef, "data-testid": testId !== undefined ? `${testId}-empty` : "embedded-list-empty", className: "flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground", children: [_jsx("span", { children: emptyLabel }), showControls && (_jsxs(UiButton, { type: "button", variant: "outline", size: "sm", onClick: onAddRow, "data-testid": testId !== undefined ? `${testId}-empty-add` : "embedded-list-empty-add", children: [_jsx(Plus, { className: "size-4", "aria-hidden": "true" }), emptyCtaLabel] }))] }));
|
|
277
277
|
}
|
|
278
278
|
const hasTotals = totals !== undefined && totals.length > 0;
|
|
279
|
-
return (_jsxs("div", { ref: containerRef, "data-testid": testId, className: "min-w-0", children: [!isMobile && (_jsxs("div", { "data-testid": testIdFor("desktop"), className: "hidden min-w-0 md:block", children: [_jsxs("div", { className: "min-w-0 overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "relative min-w-0", children: [_jsx("div", { ref: setScrollContainerEl, "data-testid": testIdFor("desktop-scroll"), className: "overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible", children: _jsxs(Table, { className: "w-full table-fixed", style: { minWidth: `${tableMinWidthRem(columns, showControls)}rem` }, children: [_jsxs("colgroup", { children: [columns.map((column) => (_jsx("col", { style: columnWidthStyle(column.type) }, column.field))), showControls && _jsx("col", { style: { width: `${ACTIONS_COLUMN_REM}rem` } })] }), _jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [columns.map((column) => (_jsx(TableHead, { className: columnAlignClass(column.type), children: column.label }, column.field))), showControls && (_jsx(TableHead, { className: cn("sticky right-0 z-10 whitespace-nowrap bg-muted px-1 text-right", isHorizontallyScrollable &&
|
|
279
|
+
return (_jsxs("div", { ref: containerRef, "data-testid": testId, className: "min-w-0", children: [!isMobile && (_jsxs("div", { "data-testid": testIdFor("desktop"), className: "hidden min-w-0 md:block", children: [_jsxs("div", { className: "min-w-0 overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "relative min-w-0", children: [_jsx("div", { ref: setScrollContainerEl, "data-testid": testIdFor("desktop-scroll"), className: "overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible", children: _jsxs(Table, { className: "w-full table-fixed", style: { minWidth: `${tableMinWidthRem(columns, showControls)}rem` }, children: [_jsxs("colgroup", { children: [columns.map((column) => (_jsx("col", { style: columnWidthStyle(column.type) }, column.field))), showControls && _jsx("col", { style: { width: `${ACTIONS_COLUMN_REM}rem` } })] }), _jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [columns.map((column) => (_jsx(TableHead, { title: column.label, className: cn("truncate", columnAlignClass(column.type)), children: column.label }, column.field))), showControls && (_jsx(TableHead, { className: cn("sticky right-0 z-10 whitespace-nowrap bg-muted px-1 text-right", isHorizontallyScrollable &&
|
|
280
280
|
"shadow-[inset_1px_0_0_var(--color-border)]"), "aria-label": "Actions" }))] }) }), _jsx(TableBody, { children: rows.map((row, rowIndex) => {
|
|
281
281
|
const isLastRow = rowIndex === rows.length - 1;
|
|
282
282
|
const rowIssuesForRow = rowIssues?.[rowIndex];
|