@cosmicdrift/kumiko-renderer 0.391.1 → 0.392.1
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/action-form-body.d.ts +3 -5
- package/dist/app/action-form-body.js +8 -1
- package/dist/app/header-actions-bar.d.ts +3 -1
- package/dist/app/header-actions-bar.js +4 -5
- package/dist/app/header-actions.d.ts +5 -0
- package/dist/app/header-actions.js +11 -0
- package/dist/app/kumiko-screen.js +13 -7
- package/dist/app/projection-detail-header-model.d.ts +4 -3
- package/dist/app/projection-detail-header-model.js +33 -2
- package/dist/app/projection-detail-header-region.js +2 -0
- package/dist/app/projection-detail-header.js +5 -4
- package/dist/app/row-actions.d.ts +1 -1
- package/dist/app/row-actions.js +21 -10
- package/dist/app/screen-shared.d.ts +7 -0
- package/dist/app/screen-shared.js +12 -0
- package/dist/components/related-list-section.js +8 -2
- package/dist/headless/view-model/edit.js +9 -0
- package/dist/headless/view-model/types.d.ts +2 -1
- package/dist/hooks/live-query-entities.d.ts +6 -0
- package/dist/hooks/live-query-entities.js +44 -0
- package/dist/hooks/use-query.d.ts +1 -1
- package/dist/hooks/use-query.js +7 -17
- package/dist/primitives.d.ts +4 -2
- package/dist/primitives.js +2 -0
- package/dist/web/app/dashboard-body.js +1 -0
- package/dist/web/layout/shell-breadcrumb.d.ts +2 -0
- package/dist/web/layout/shell-breadcrumb.js +11 -0
- package/dist/web/layout/shell-header.d.ts +2 -1
- package/dist/web/layout/shell-header.js +12 -16
- package/dist/web/layout/workspace-shell.js +1 -1
- package/dist/web/primitives/data-table/data-cell-props.d.ts +1 -1
- package/dist/web/primitives/data-table/toolbar.d.ts +2 -1
- package/dist/web/primitives/data-table/toolbar.js +16 -3
- package/dist/web/primitives/data-table/use-data-table-state.d.ts +1 -0
- package/dist/web/primitives/data-table/use-data-table-state.js +3 -0
- package/dist/web/primitives/data-table-cell.js +2 -0
- package/dist/web/primitives/data-table.js +3 -2
- package/dist/web/primitives/layout-elements.js +2 -2
- package/dist/web/widgets/infinity-list.js +7 -6
- package/package.json +5 -5
- package/src/changes.json +48 -0
|
@@ -32,11 +32,9 @@ export declare function ActionFormBody({ schema, screen, translate, entityId, in
|
|
|
32
32
|
* from the clicked row, applied on top of searchParams through the same
|
|
33
33
|
* renderableFields/sensitive gates (see mergeSearchParamsIntoInitial). */
|
|
34
34
|
readonly initialOverrides?: Readonly<Record<string, unknown>>;
|
|
35
|
-
/** Drawer-hosted usage
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* `screen.redirect` is set — a full-page redirect would navigate away
|
|
39
|
-
* from the list the drawer sits on top of. */
|
|
35
|
+
/** Drawer-hosted usage: called on successful submit so the host closes the
|
|
36
|
+
* drawer + refetches its list; a declared `screen.redirect` then navigates
|
|
37
|
+
* (without returnTo, a drawer is no navigation target). */
|
|
40
38
|
readonly onSuccess?: () => void;
|
|
41
39
|
/** Drawer-hosted usage: replaces the cancelTarget/redirect-based Cancel
|
|
42
40
|
* handler so Cancel closes the drawer instead of navigating. */
|
|
@@ -20,7 +20,7 @@ import { qualifyScreenId } from "./qualify-screen-id.js";
|
|
|
20
20
|
import { QueryLoadingBanner } from "./query-state-banners.js";
|
|
21
21
|
import { navigateToReturn, navigateToReturnOr, useReturnTarget } from "./return-to.js";
|
|
22
22
|
import { screenAccessAllows } from "./screen-access.js";
|
|
23
|
-
import { formatSummaryParams, navigateToRedirectTarget, redirectScreenTarget, resolveActionFormSummary, resolveRedirectTarget, resolveSuccessLinks, resolveSuccessMessageParams, returnToWinsOverRedirect, ScreenAccessDeniedBanner, ScreenMissingState, } from "./screen-shared.js";
|
|
23
|
+
import { formatSummaryParams, navigateAfterDrawerSubmit, navigateToRedirectTarget, redirectScreenTarget, resolveActionFormSummary, resolveRedirectTarget, resolveSuccessLinks, resolveSuccessMessageParams, returnToWinsOverRedirect, ScreenAccessDeniedBanner, ScreenMissingState, } from "./screen-shared.js";
|
|
24
24
|
export function useDrawerAction(schema) {
|
|
25
25
|
const [drawerState, setDrawerState] = useState(null);
|
|
26
26
|
const drawerAction = drawerState?.action ?? null;
|
|
@@ -182,6 +182,13 @@ export function ActionFormBody({ schema, screen, translate, entityId, initialOve
|
|
|
182
182
|
return;
|
|
183
183
|
if (onSuccess !== undefined) {
|
|
184
184
|
onSuccess();
|
|
185
|
+
navigateAfterDrawerSubmit({
|
|
186
|
+
nav,
|
|
187
|
+
redirect: screen.redirect,
|
|
188
|
+
resultData: result.data,
|
|
189
|
+
schema,
|
|
190
|
+
appFeatures,
|
|
191
|
+
});
|
|
185
192
|
return;
|
|
186
193
|
}
|
|
187
194
|
if (screen.redirect === undefined) {
|
|
@@ -2,9 +2,11 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { RenderEditAction } from "../components/render-edit-types.ts";
|
|
3
3
|
import type { usePrimitives } from "../primitives.tsx";
|
|
4
4
|
import type { ErrorNotice } from "./error-reference.tsx";
|
|
5
|
-
export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary, compact, preferredPrimaryId, PageHeader, overflowTestId, }: {
|
|
5
|
+
export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary, compact, preferredPrimaryId, inlineActionIds, PageHeader, overflowTestId, }: {
|
|
6
6
|
/** `primaryAction` of the screen: wins over the edit/primary-style precedence. */
|
|
7
7
|
readonly preferredPrimaryId?: string;
|
|
8
|
+
/** `inlineActions` of the screen: stay buttons next to the primary instead of the menu. */
|
|
9
|
+
readonly inlineActionIds?: readonly string[];
|
|
8
10
|
/** Header slot layout: only the primary action stays a button, the rest goes into the menu. */
|
|
9
11
|
readonly collapseAfterPrimary?: boolean;
|
|
10
12
|
/** Phone-width shell header: an icon-only primary button, the rest in the shell's overflow menu. */
|
|
@@ -2,14 +2,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { RenderEditActionButton } from "../components/render-edit-action-button.js";
|
|
3
3
|
import { useActionMenuItems } from "../components/use-action-menu-items.js";
|
|
4
4
|
import { useTranslation } from "../i18n.js";
|
|
5
|
-
import { compactHeaderPrimary, orderPlainHeaderActions,
|
|
5
|
+
import { compactHeaderPrimary, orderPlainHeaderActions, splitHeaderActions, } from "./header-actions.js";
|
|
6
6
|
// Header actions bar: <=2 actions render as plain buttons; >2 with an
|
|
7
7
|
// `ActionOverflowMenu` primitive collapse to the primary button plus a menu
|
|
8
8
|
// for the rest — same rule as RowActionsCell for table rows. Owns confirm
|
|
9
9
|
// state for menu items: RenderEditActionButton already owns a per-button
|
|
10
10
|
// confirm dialog for the always-visible primary action, but a menu item has
|
|
11
11
|
// no button of its own to carry one.
|
|
12
|
-
export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, PageHeader, overflowTestId = "kumiko-screen-projection-detail-actions-overflow", }) {
|
|
12
|
+
export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, inlineActionIds, PageHeader, overflowTestId = "kumiko-screen-projection-detail-actions-overflow", }) {
|
|
13
13
|
const t = useTranslation();
|
|
14
14
|
const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
|
|
15
15
|
Dialog,
|
|
@@ -24,7 +24,6 @@ export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu,
|
|
|
24
24
|
if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
|
|
25
25
|
return (_jsx(_Fragment, { children: orderPlainHeaderActions(actions, preferredPrimaryId).map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
|
|
26
26
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: overflowTestId, items: rest.map(toMenuItem) }), pendingActionDialog] }));
|
|
27
|
+
const { buttons, menu } = splitHeaderActions(actions, preferredPrimaryId, inlineActionIds);
|
|
28
|
+
return (_jsxs(_Fragment, { children: [buttons.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))), menu.length > 0 && (_jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: overflowTestId, items: menu.map(toMenuItem) })), pendingActionDialog] }));
|
|
30
29
|
}
|
|
@@ -5,4 +5,9 @@ export type CompactHeaderPrimary = {
|
|
|
5
5
|
readonly iconOnly: boolean;
|
|
6
6
|
};
|
|
7
7
|
export declare function compactHeaderPrimary(actions: readonly RenderEditAction[], preferredId?: string): CompactHeaderPrimary | undefined;
|
|
8
|
+
export type SplitHeaderActions = {
|
|
9
|
+
readonly buttons: readonly RenderEditAction[];
|
|
10
|
+
readonly menu: readonly RenderEditAction[];
|
|
11
|
+
};
|
|
12
|
+
export declare function splitHeaderActions(actions: readonly RenderEditAction[], preferredId?: string, inlineIds?: readonly string[]): SplitHeaderActions;
|
|
8
13
|
export declare function orderPlainHeaderActions(actions: readonly RenderEditAction[], preferredId?: string): readonly RenderEditAction[];
|
|
@@ -25,6 +25,17 @@ export function compactHeaderPrimary(actions, preferredId) {
|
|
|
25
25
|
const iconAction = primaryHeaderAction(actions);
|
|
26
26
|
return canStandAsIconButton(iconAction) ? { action: iconAction, iconOnly: true } : undefined;
|
|
27
27
|
}
|
|
28
|
+
// The primary action leads the visible buttons, the declared inline actions follow it,
|
|
29
|
+
// everything else goes into the menu.
|
|
30
|
+
export function splitHeaderActions(actions, preferredId, inlineIds) {
|
|
31
|
+
const primary = primaryHeaderAction(actions, preferredId);
|
|
32
|
+
const others = actions.filter((action) => action.id !== primary?.id);
|
|
33
|
+
const inline = others.filter((action) => inlineIds?.includes(action.id) === true);
|
|
34
|
+
return {
|
|
35
|
+
buttons: primary === undefined ? inline : [primary, ...inline],
|
|
36
|
+
menu: others.filter((action) => !inline.includes(action)),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
28
39
|
export function orderPlainHeaderActions(actions, preferredId) {
|
|
29
40
|
const preferred = preferredId !== undefined ? actions.find((a) => a.id === preferredId) : undefined;
|
|
30
41
|
return preferred === undefined ? actions : [preferred, ...actions.filter((a) => a !== preferred)];
|
|
@@ -55,7 +55,7 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
|
|
|
55
55
|
onCopyLink,
|
|
56
56
|
});
|
|
57
57
|
// Gate every entry (incl. direct URL): the body element is only mounted once the condition holds.
|
|
58
|
-
const body = screen.visibleWhen !== undefined ? (_jsx(ScreenVisibilityGate, { featureName: schema.featureName, visibleWhen: screen.visibleWhen, fallback: screen.fallback, translate: translate, children: _jsx(StandInChainBoundary, { children: screenBody }) })) : (_jsx(StandInChainBoundary, { children: screenBody }));
|
|
58
|
+
const body = screen.visibleWhen !== undefined ? (_jsx(ScreenVisibilityGate, { entityId: entityId, featureName: schema.featureName, visibleWhen: screen.visibleWhen, fallback: screen.fallback, translate: translate, children: _jsx(StandInChainBoundary, { children: screenBody }) }, entityId)) : (_jsx(StandInChainBoundary, { children: screenBody }));
|
|
59
59
|
// An embedded screen (e.g. a dashboard panel) keeps its parent's host and
|
|
60
60
|
// must not portal its page header into the shell that hosts the outer screen.
|
|
61
61
|
if (outerHost !== undefined) {
|
|
@@ -63,10 +63,16 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
|
|
|
63
63
|
}
|
|
64
64
|
return _jsx(ReturnHostProvider, { value: ownHost, children: body });
|
|
65
65
|
}
|
|
66
|
-
function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
|
|
67
|
-
|
|
66
|
+
function ScreenVisibilityGate({ entityId, featureName, visibleWhen, fallback, translate, children, }) {
|
|
67
|
+
// An idParam gate without a route id has nothing to ask: treated as hidden, never queried.
|
|
68
|
+
const missingRouteId = visibleWhen.idParam !== undefined && entityId === undefined;
|
|
69
|
+
const result = useQuery(visibleWhen.query, visibleWhen.idParam !== undefined && entityId !== undefined
|
|
70
|
+
? { [visibleWhen.idParam]: entityId }
|
|
71
|
+
: {}, { live: true, enabled: !missingRouteId });
|
|
68
72
|
const wasShown = useRef(false);
|
|
69
|
-
const verdict =
|
|
73
|
+
const verdict = missingRouteId
|
|
74
|
+
? "hidden"
|
|
75
|
+
: evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
|
|
70
76
|
useEffect(() => {
|
|
71
77
|
if (verdict === "visible")
|
|
72
78
|
wasShown.current = true;
|
|
@@ -77,7 +83,7 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
|
|
|
77
83
|
return _jsx(QueryLoadingBanner, {});
|
|
78
84
|
}
|
|
79
85
|
if (fallback !== undefined) {
|
|
80
|
-
return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
|
|
86
|
+
return (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate, ...(visibleWhen.idParam !== undefined && entityId !== undefined && { entityId }) }));
|
|
81
87
|
}
|
|
82
88
|
return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
|
|
83
89
|
}
|
|
@@ -86,14 +92,14 @@ function ScreenUnavailableBanner() {
|
|
|
86
92
|
const t = useTranslation();
|
|
87
93
|
return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
|
|
88
94
|
}
|
|
89
|
-
function FallbackScreen({ featureName, fallback, translate, }) {
|
|
95
|
+
function FallbackScreen({ featureName, fallback, translate, entityId, }) {
|
|
90
96
|
const target = useEmbeddedScreen(featureName, fallback);
|
|
91
97
|
// Also undefined when the user lacks access to the fallback screen, which is
|
|
92
98
|
// not a framework error.
|
|
93
99
|
if (target === undefined) {
|
|
94
100
|
return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
|
|
95
101
|
}
|
|
96
|
-
return (_jsx(LockedHeaderScope, { standInScreenId: lastSegment(target.qn), children: _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate }) }));
|
|
102
|
+
return (_jsx(LockedHeaderScope, { standInScreenId: lastSegment(target.qn), children: _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate, ...(entityId !== undefined && { entityId }) }) }));
|
|
97
103
|
}
|
|
98
104
|
function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
|
|
99
105
|
switch (screen.type) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
-
import type
|
|
3
|
-
import { type
|
|
4
|
-
export declare function headerStatusTone(header: NonNullable<ProjectionDetailScreenDefinition["header"]>, record: Readonly<Record<string, unknown>>):
|
|
2
|
+
import { type Translate } from "../headless/index.ts";
|
|
3
|
+
import { type StatusBadgeOptionTone } from "../primitives.tsx";
|
|
4
|
+
export declare function headerStatusTone(header: NonNullable<ProjectionDetailScreenDefinition["header"]>, record: Readonly<Record<string, unknown>>): StatusBadgeOptionTone | undefined;
|
|
5
|
+
export declare function resolveHeaderStatusText(screen: ProjectionDetailScreenDefinition, record: Readonly<Record<string, unknown>>, translate: Translate): string;
|
|
5
6
|
export declare function resolveSubtitleHref(header: ProjectionDetailScreenDefinition["header"], record: Readonly<Record<string, unknown>>): string | undefined;
|
|
6
7
|
export type DetailTabItem = {
|
|
7
8
|
readonly id: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { isWriteFormEditSection } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
-
import {
|
|
1
|
+
import { isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { applyFormatSpec } from "../headless/index.js";
|
|
3
|
+
import { statusToneForOptionTone, statusToneForValue, } from "../primitives.js";
|
|
3
4
|
import { isAbsoluteHttpUrl } from "./projection-detail-navigation.js";
|
|
4
5
|
export function headerStatusTone(header, record) {
|
|
5
6
|
// skip: a header without status has no tone
|
|
@@ -12,6 +13,36 @@ export function headerStatusTone(header, record) {
|
|
|
12
13
|
const declaredTone = declared !== undefined ? statusToneForOptionTone(declared) : undefined;
|
|
13
14
|
return declaredTone ?? statusToneForValue(value);
|
|
14
15
|
}
|
|
16
|
+
function layoutStatusFormat(screen, statusField) {
|
|
17
|
+
for (const section of screen.layout.sections) {
|
|
18
|
+
if (!isFieldsEditSection(section))
|
|
19
|
+
continue;
|
|
20
|
+
for (const spec of sectionFieldSpecs(section)) {
|
|
21
|
+
const field = normalizeEditField(spec);
|
|
22
|
+
if (field.field !== statusField)
|
|
23
|
+
continue;
|
|
24
|
+
const renderer = field.renderer;
|
|
25
|
+
if (isFormatSpec(renderer) &&
|
|
26
|
+
(renderer.format === "enumOption" || renderer.format === "i18nText")) {
|
|
27
|
+
return renderer;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
export function resolveHeaderStatusText(screen, record, translate) {
|
|
34
|
+
const statusField = screen.header?.status;
|
|
35
|
+
// skip: a header without status has no badge text
|
|
36
|
+
if (statusField === undefined)
|
|
37
|
+
return "";
|
|
38
|
+
const raw = record[statusField];
|
|
39
|
+
const rawText = String(raw ?? "");
|
|
40
|
+
const format = screen.header?.statusFormat ?? layoutStatusFormat(screen, statusField);
|
|
41
|
+
if (format === undefined)
|
|
42
|
+
return rawText;
|
|
43
|
+
const formatted = applyFormatSpec(format, raw, translate);
|
|
44
|
+
return formatted === "" ? rawText : formatted;
|
|
45
|
+
}
|
|
15
46
|
export function resolveSubtitleHref(header, record) {
|
|
16
47
|
// skip: no subtitleHref declared
|
|
17
48
|
if (header?.subtitleHref === undefined)
|
|
@@ -47,6 +47,8 @@ function renderPageHeaderBranch(model, pieces, headerSlot, PageHeader) {
|
|
|
47
47
|
return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(!pageHeaderCompact && statusBadge !== undefined && { status: statusBadge }), ...(hasHeaderActions && {
|
|
48
48
|
actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: model.headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader, ...(screen.primaryAction !== undefined && {
|
|
49
49
|
preferredPrimaryId: screen.primaryAction,
|
|
50
|
+
}), ...(screen.inlineActions !== undefined && {
|
|
51
|
+
inlineActionIds: screen.inlineActions,
|
|
50
52
|
}) }) })),
|
|
51
53
|
}) }), MetricBand === undefined && bandSubtitle, MetricBand === undefined && headerSlot, (hasMetrics || bandSubtitle !== undefined || headerSlot !== undefined) &&
|
|
52
54
|
(MetricBand !== undefined || hasMetrics) &&
|
|
@@ -2,13 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { Fragment } from "react";
|
|
3
3
|
import { ErrorReference } from "./error-reference.js";
|
|
4
4
|
import { HeaderActionsBar } from "./header-actions-bar.js";
|
|
5
|
-
import { headerStatusTone, resolveSubtitleHref } from "./projection-detail-header-model.js";
|
|
5
|
+
import { headerStatusTone, resolveHeaderStatusText, resolveSubtitleHref, } from "./projection-detail-header-model.js";
|
|
6
6
|
import { buildSubtitleParts, runMetricNavigate } from "./projection-detail-navigation.js";
|
|
7
7
|
import { navigateTargetAllows } from "./screen-access.js";
|
|
8
8
|
export function renderHeaderActionsContent(model) {
|
|
9
9
|
const { screen, primitives, headerActionsList, setActionError } = model;
|
|
10
10
|
const { Grid, Button, Dialog, ActionOverflowMenu } = primitives;
|
|
11
|
-
return (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, ...(screen.primaryAction !== undefined && { preferredPrimaryId: screen.primaryAction }) }) }));
|
|
11
|
+
return (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, ...(screen.primaryAction !== undefined && { preferredPrimaryId: screen.primaryAction }), ...(screen.inlineActions !== undefined && { inlineActionIds: screen.inlineActions }) }) }));
|
|
12
12
|
}
|
|
13
13
|
// Plain Text, not Heading — the Card title slot already renders as its own
|
|
14
14
|
// heading element (h3); nesting another heading (h1) inside it is invalid HTML.
|
|
@@ -16,7 +16,8 @@ export function renderHeaderTitleSlot(model) {
|
|
|
16
16
|
const { screen, record, primitives } = model;
|
|
17
17
|
const { Grid, Text, StatusBadge } = primitives;
|
|
18
18
|
const header = screen.header;
|
|
19
|
-
|
|
19
|
+
const statusText = resolveHeaderStatusText(screen, record, model.effectiveTranslate);
|
|
20
|
+
return header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: statusText, tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: statusText }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
|
|
20
21
|
}
|
|
21
22
|
function renderSubtitleParts(model, subtitleParts) {
|
|
22
23
|
const { Text, Link } = model.primitives;
|
|
@@ -75,7 +76,7 @@ export function renderStatusBadge(model) {
|
|
|
75
76
|
const { screen, record, primitives } = model;
|
|
76
77
|
const { StatusBadge } = primitives;
|
|
77
78
|
const header = screen.header;
|
|
78
|
-
return header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value:
|
|
79
|
+
return header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: resolveHeaderStatusText(screen, record, model.effectiveTranslate), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
|
|
79
80
|
}
|
|
80
81
|
// Lives in the band's subtitle row so it shares the metrics' horizontal inset.
|
|
81
82
|
export function renderBandSubtitle(model, statusBadge, headerSubtitleSlot, pageHeaderCompact) {
|
|
@@ -43,7 +43,7 @@ export declare function buildProjectionRowActions(options: {
|
|
|
43
43
|
}): readonly DataTableRowAction[] | undefined;
|
|
44
44
|
type OpenToolbarDrawer = (action: ToolbarAction & {
|
|
45
45
|
readonly kind: "drawer";
|
|
46
|
-
}) => void;
|
|
46
|
+
}, initialValues?: Readonly<Record<string, unknown>>) => void;
|
|
47
47
|
export declare function buildProjectionToolbarActions(options: {
|
|
48
48
|
readonly toolbarActions: readonly RelatedListToolbarAction[] | undefined;
|
|
49
49
|
readonly translate: Translate;
|
package/dist/app/row-actions.js
CHANGED
|
@@ -277,7 +277,7 @@ function buildNavigateToolbarAction(action, translate, nav, host, prefill, recor
|
|
|
277
277
|
},
|
|
278
278
|
};
|
|
279
279
|
}
|
|
280
|
-
function buildDrawerToolbarAction(action, translate, openDrawer) {
|
|
280
|
+
function buildDrawerToolbarAction(action, translate, openDrawer, record) {
|
|
281
281
|
const actionIcon = resolveActionIcon(action.id);
|
|
282
282
|
return {
|
|
283
283
|
id: action.id,
|
|
@@ -285,10 +285,12 @@ function buildDrawerToolbarAction(action, translate, openDrawer) {
|
|
|
285
285
|
...(action.style !== undefined && { style: action.style }),
|
|
286
286
|
confirmRequired: false,
|
|
287
287
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
288
|
-
onTrigger: () =>
|
|
288
|
+
onTrigger: () => action.params !== undefined && record !== undefined
|
|
289
|
+
? openDrawer(action, evalRowExtractor(action.params, record))
|
|
290
|
+
: openDrawer(action),
|
|
289
291
|
};
|
|
290
292
|
}
|
|
291
|
-
function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher) {
|
|
293
|
+
function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher, record) {
|
|
292
294
|
const actionIcon = resolveActionIcon(action.id);
|
|
293
295
|
return {
|
|
294
296
|
id: action.id,
|
|
@@ -300,7 +302,10 @@ function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher)
|
|
|
300
302
|
confirmLabel: translate(action.confirmLabel),
|
|
301
303
|
}),
|
|
302
304
|
onTrigger: async () => {
|
|
303
|
-
const
|
|
305
|
+
const extracted = action.params !== undefined && record !== undefined
|
|
306
|
+
? evalRowExtractor(action.params, record)
|
|
307
|
+
: undefined;
|
|
308
|
+
const result = await dispatcher.write(action.handler, { ...action.payload, ...extracted });
|
|
304
309
|
if (!result.isSuccess) {
|
|
305
310
|
throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
|
|
306
311
|
}
|
|
@@ -308,6 +313,15 @@ function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher)
|
|
|
308
313
|
},
|
|
309
314
|
};
|
|
310
315
|
}
|
|
316
|
+
// Fail closed: `visible` and `params` are evaluated against the parent record, so without one
|
|
317
|
+
// the action stays hidden. (navigate falls back to the implicit parent prefill without params.)
|
|
318
|
+
function isToolbarActionHiddenForRecord(action, record) {
|
|
319
|
+
if (action.visible !== undefined &&
|
|
320
|
+
(record === undefined || !evalFieldCondition(action.visible, record))) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
return action.kind !== "navigate" && action.params !== undefined && record === undefined;
|
|
324
|
+
}
|
|
311
325
|
// Shared by entityList, projectionList, and relatedList toolbars so the
|
|
312
326
|
// three call sites can't drift apart.
|
|
313
327
|
export function buildProjectionToolbarActions(options) {
|
|
@@ -316,11 +330,8 @@ export function buildProjectionToolbarActions(options) {
|
|
|
316
330
|
return undefined;
|
|
317
331
|
const out = [];
|
|
318
332
|
for (const action of toolbarActions) {
|
|
319
|
-
|
|
320
|
-
if (action.visible !== undefined &&
|
|
321
|
-
(record === undefined || !evalFieldCondition(action.visible, record))) {
|
|
333
|
+
if (isToolbarActionHiddenForRecord(action, record))
|
|
322
334
|
continue;
|
|
323
|
-
}
|
|
324
335
|
if (action.kind === "navigate") {
|
|
325
336
|
if (access !== undefined &&
|
|
326
337
|
!navigateTargetAllows({ screen: action.screen }, access.appFeatures, access.userRoles)) {
|
|
@@ -334,13 +345,13 @@ export function buildProjectionToolbarActions(options) {
|
|
|
334
345
|
warnDrawerActionDropped("toolbarAction", action.id);
|
|
335
346
|
continue;
|
|
336
347
|
}
|
|
337
|
-
out.push(buildDrawerToolbarAction(action, translate, openDrawer));
|
|
348
|
+
out.push(buildDrawerToolbarAction(action, translate, openDrawer, record));
|
|
338
349
|
continue;
|
|
339
350
|
}
|
|
340
351
|
// writeHandler — skip without a dispatcher instead of crashing (same as rowActions).
|
|
341
352
|
if (dispatcher === undefined)
|
|
342
353
|
continue;
|
|
343
|
-
out.push(buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher));
|
|
354
|
+
out.push(buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher, record));
|
|
344
355
|
}
|
|
345
356
|
return out.length > 0 ? out : undefined;
|
|
346
357
|
}
|
|
@@ -68,5 +68,12 @@ export declare function navigateAfterRedirect(options: {
|
|
|
68
68
|
readonly resultData: unknown;
|
|
69
69
|
readonly record: Readonly<Record<string, unknown>>;
|
|
70
70
|
}): void;
|
|
71
|
+
export declare function navigateAfterDrawerSubmit(options: {
|
|
72
|
+
readonly nav: NavApi;
|
|
73
|
+
readonly schema: FeatureSchema;
|
|
74
|
+
readonly appFeatures: readonly FeatureSchema[];
|
|
75
|
+
readonly redirect: string | ActionFormRedirect | undefined;
|
|
76
|
+
readonly resultData: unknown;
|
|
77
|
+
}): void;
|
|
71
78
|
export declare function overlayUpdatedProjection(record: Readonly<Record<string, unknown>>, resultData: unknown): Readonly<Record<string, unknown>>;
|
|
72
79
|
export {};
|
|
@@ -320,6 +320,18 @@ export function navigateAfterRedirect(options) {
|
|
|
320
320
|
}
|
|
321
321
|
navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, resultData, schema, appFeatures, record));
|
|
322
322
|
}
|
|
323
|
+
// A drawer is no navigation target, so returnTo does not apply. Redirecting onto the host
|
|
324
|
+
// screen would only reset its filters/scroll/tab; the drawer's close + refetch already refreshed it.
|
|
325
|
+
export function navigateAfterDrawerSubmit(options) {
|
|
326
|
+
const { nav, schema, appFeatures, redirect, resultData } = options;
|
|
327
|
+
if (redirect === undefined)
|
|
328
|
+
return;
|
|
329
|
+
const target = resolveRedirectTarget(redirect, resultData, schema, appFeatures);
|
|
330
|
+
const hostScreenId = nav.route === undefined ? undefined : lastSegment(nav.route.screenId);
|
|
331
|
+
const isHostScreen = target.screenId === hostScreenId && target.entityId === undefined && target.tab === undefined;
|
|
332
|
+
if (!isHostScreen)
|
|
333
|
+
navigateToRedirectTarget(nav, target);
|
|
334
|
+
}
|
|
323
335
|
// The update payload nests the saved projection under `data`; a changed
|
|
324
336
|
// parent FK lives there, not in the pre-edit `record`.
|
|
325
337
|
export function overlayUpdatedProjection(record, resultData) {
|
|
@@ -92,6 +92,11 @@ function buildRowTone(rule) {
|
|
|
92
92
|
return undefined;
|
|
93
93
|
return (row) => (evalFieldCondition(rule, { ...row.values }) ? rule.tone : undefined);
|
|
94
94
|
}
|
|
95
|
+
function pickEmptyStateText(emptyState, record) {
|
|
96
|
+
if (emptyState === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
return (emptyState.variants?.find((variant) => evalFieldCondition(variant.when, record)) ?? emptyState);
|
|
99
|
+
}
|
|
95
100
|
export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, refreshToken, }) {
|
|
96
101
|
const { Banner, Section, Card, FillContainer, Text, Button, Dialog } = usePrimitives();
|
|
97
102
|
const [emptyStateActionError, setEmptyStateActionError] = useState(null);
|
|
@@ -248,7 +253,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
248
253
|
host,
|
|
249
254
|
access: { appFeatures, userRoles },
|
|
250
255
|
...(onOpenDrawer !== undefined && {
|
|
251
|
-
openDrawer:
|
|
256
|
+
openDrawer: onOpenDrawer,
|
|
252
257
|
}),
|
|
253
258
|
}), [
|
|
254
259
|
appFeatures,
|
|
@@ -302,7 +307,8 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
302
307
|
onOpenDrawer,
|
|
303
308
|
refetchSelfAndHost,
|
|
304
309
|
]);
|
|
305
|
-
const
|
|
310
|
+
const emptyStateText = pickEmptyStateText(section.emptyState, record);
|
|
311
|
+
const emptyStateContent = emptyStateText !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: emptyStateText.title }), emptyStateText.description !== undefined && (_jsx(Text, { variant: "small", children: emptyStateText.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
|
|
306
312
|
// Without a cursor-driven pager the client-side sort covers only the fetched
|
|
307
313
|
// rows; the banner keeps it from claiming "these are the top N".
|
|
308
314
|
const content = listRows.firstLoad ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : listRows.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(listRows.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [listRows.truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
|
|
@@ -91,6 +91,15 @@ export function computeRelatedListSectionViewModel(sectionSpec, translate) {
|
|
|
91
91
|
...(sectionSpec.emptyState.action !== undefined && {
|
|
92
92
|
action: sectionSpec.emptyState.action,
|
|
93
93
|
}),
|
|
94
|
+
...(sectionSpec.emptyState.variants !== undefined && {
|
|
95
|
+
variants: sectionSpec.emptyState.variants.map((variant) => ({
|
|
96
|
+
when: variant.when,
|
|
97
|
+
title: translate(variant.title),
|
|
98
|
+
...(variant.description !== undefined && {
|
|
99
|
+
description: translate(variant.description),
|
|
100
|
+
}),
|
|
101
|
+
})),
|
|
102
|
+
}),
|
|
94
103
|
},
|
|
95
104
|
}),
|
|
96
105
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EditFieldSpec, EditSectionSpec, FieldAffix, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import type { EditFieldSpec, EditSectionSpec, FieldAffix, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
export type RuntimeRenderer = (value: unknown, row?: Readonly<Record<string, unknown>>) => string;
|
|
3
3
|
/** Where a cell's text links to. `href` comes from NavApi.hrefFor (new-tab and
|
|
4
4
|
* copy-link work); `onPress` is the in-app navigation for a plain click. */
|
|
@@ -369,6 +369,7 @@ export type EditRelatedListSectionViewModel = {
|
|
|
369
369
|
readonly title: string;
|
|
370
370
|
readonly description?: string;
|
|
371
371
|
readonly action?: RowAction;
|
|
372
|
+
readonly variants?: readonly RelatedListEmptyStateVariant[];
|
|
372
373
|
};
|
|
373
374
|
};
|
|
374
375
|
export type EditWriteFormSectionViewModel = {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FeatureSchema } from "../app/feature-schema.ts";
|
|
2
|
+
import type { LiveEvent, LiveEventSubscriber } from "../sse/live-events.tsx";
|
|
3
|
+
export declare function entityFromQueryType(type: string): string | undefined;
|
|
4
|
+
export declare function liveEntitiesForQuery(type: string, features: readonly FeatureSchema[]): readonly string[];
|
|
5
|
+
export declare function useLiveEntitiesForQuery(type: string): readonly string[];
|
|
6
|
+
export declare function subscribeToEntities(subscribe: LiveEventSubscriber, entities: readonly string[], listener: (event: LiveEvent) => void): () => void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useAppFeatures } from "../app/app-features-context.js";
|
|
3
|
+
import { toKebab } from "../app/qn.js";
|
|
4
|
+
// Entity of a standard `<feature>:query:<entity>:<verb>` type; undefined for
|
|
5
|
+
// non-conforming types so live mode skips them instead of subscribing to a
|
|
6
|
+
// channel no event will ever match.
|
|
7
|
+
export function entityFromQueryType(type) {
|
|
8
|
+
const parts = type.split(":");
|
|
9
|
+
if (parts.length !== 4)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (parts[1] !== "query")
|
|
12
|
+
return undefined;
|
|
13
|
+
return parts[2];
|
|
14
|
+
}
|
|
15
|
+
// The server signals both the r.entity() name and its kebab-case form (executors
|
|
16
|
+
// may write under either), so the client listens on both.
|
|
17
|
+
export function liveEntitiesForQuery(type, features) {
|
|
18
|
+
const names = new Set();
|
|
19
|
+
const nameSegment = entityFromQueryType(type);
|
|
20
|
+
if (nameSegment !== undefined)
|
|
21
|
+
names.add(nameSegment);
|
|
22
|
+
for (const feature of features) {
|
|
23
|
+
for (const entity of feature.queryLiveEntities?.[type] ?? [])
|
|
24
|
+
names.add(entity);
|
|
25
|
+
}
|
|
26
|
+
const subscribed = new Set();
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
subscribed.add(name);
|
|
29
|
+
subscribed.add(toKebab(name));
|
|
30
|
+
}
|
|
31
|
+
return [...subscribed].sort();
|
|
32
|
+
}
|
|
33
|
+
export function useLiveEntitiesForQuery(type) {
|
|
34
|
+
const features = useAppFeatures();
|
|
35
|
+
const featuresKey = useMemo(() => liveEntitiesForQuery(type, features).join("\n"), [type, features]);
|
|
36
|
+
return useMemo(() => (featuresKey === "" ? [] : featuresKey.split("\n")), [featuresKey]);
|
|
37
|
+
}
|
|
38
|
+
export function subscribeToEntities(subscribe, entities, listener) {
|
|
39
|
+
const unsubscribers = entities.map((entity) => subscribe(entity, listener));
|
|
40
|
+
return () => {
|
|
41
|
+
for (const unsubscribe of unsubscribers)
|
|
42
|
+
unsubscribe();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DispatcherError } from "../headless/index.ts";
|
|
2
|
+
export { entityFromQueryType } from "./live-query-entities.ts";
|
|
2
3
|
export type UseQueryResult<TData> = {
|
|
3
4
|
readonly data: TData | null;
|
|
4
5
|
readonly error: DispatcherError | null;
|
|
@@ -11,5 +12,4 @@ export type UseQueryOptions = {
|
|
|
11
12
|
readonly refetchIntervalMs?: number;
|
|
12
13
|
readonly concurrency?: number;
|
|
13
14
|
};
|
|
14
|
-
export declare function entityFromQueryType(type: string): string | undefined;
|
|
15
15
|
export declare function useQuery<TData = unknown>(type: string, payload: unknown, options?: UseQueryOptions): UseQueryResult<TData>;
|
package/dist/hooks/use-query.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useDispatcher, useOptionalQueryPools } from "../context/dispatcher-context.js";
|
|
3
3
|
import { useLiveEvents } from "../sse/live-events.js";
|
|
4
|
+
import { subscribeToEntities, useLiveEntitiesForQuery } from "./live-query-entities.js";
|
|
4
5
|
import { createLiveRefetchScheduler } from "./live-refetch-scheduler.js";
|
|
5
|
-
|
|
6
|
-
// undefined for non-conforming types so the live-mode silently skips
|
|
7
|
-
// them instead of subscribing to a channel no event will ever match.
|
|
8
|
-
export function entityFromQueryType(type) {
|
|
9
|
-
// Expected shape: "<feature>:query:<entity>:<verb>"
|
|
10
|
-
const parts = type.split(":");
|
|
11
|
-
if (parts.length !== 4)
|
|
12
|
-
return undefined;
|
|
13
|
-
if (parts[1] !== "query")
|
|
14
|
-
return undefined;
|
|
15
|
-
return parts[2];
|
|
16
|
-
}
|
|
6
|
+
export { entityFromQueryType } from "./live-query-entities.js";
|
|
17
7
|
function isValidConcurrency(value) {
|
|
18
8
|
return value !== undefined && Number.isFinite(value) && value >= 1;
|
|
19
9
|
}
|
|
@@ -118,20 +108,20 @@ export function useQuery(type, payload, options = {}) {
|
|
|
118
108
|
// A separate effect ensures changes to `live` or `type` update the
|
|
119
109
|
// subscription lifecycle exactly once.
|
|
120
110
|
const subscribeLive = useLiveEvents();
|
|
111
|
+
const liveEntities = useLiveEntitiesForQuery(type);
|
|
121
112
|
useEffect(() => {
|
|
122
113
|
// skip: live mode or query disabled, no SSE subscription needed
|
|
123
114
|
if (!live || !enabled)
|
|
124
115
|
return;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (entity === undefined)
|
|
116
|
+
// skip: no entity to listen on, nothing to subscribe to
|
|
117
|
+
if (liveEntities.length === 0)
|
|
128
118
|
return;
|
|
129
119
|
const scheduler = createLiveRefetchScheduler(() => runFetch(true));
|
|
130
|
-
const unsubscribe = subscribeLive
|
|
120
|
+
const unsubscribe = subscribeToEntities(subscribeLive, liveEntities, scheduler.trigger);
|
|
131
121
|
return () => {
|
|
132
122
|
unsubscribe();
|
|
133
123
|
scheduler.dispose();
|
|
134
124
|
};
|
|
135
|
-
}, [live, enabled,
|
|
125
|
+
}, [live, enabled, liveEntities, runFetch, subscribeLive]);
|
|
136
126
|
return { data, error, loading, refetch: run };
|
|
137
127
|
}
|
package/dist/primitives.d.ts
CHANGED
|
@@ -1334,13 +1334,15 @@ export type TabsProps = {
|
|
|
1334
1334
|
readonly testId?: string;
|
|
1335
1335
|
};
|
|
1336
1336
|
export type StatusTone = "ok" | "warn" | "bad" | "critical" | "muted";
|
|
1337
|
+
/** Tones a select option can request: the status tones plus the brand `accent`. */
|
|
1338
|
+
export type StatusBadgeOptionTone = StatusTone | "accent";
|
|
1337
1339
|
/** Status pill. `value` is the raw column value — tone-mapping is the app's job. */
|
|
1338
1340
|
export type StatusBadgeProps = {
|
|
1339
1341
|
readonly value: string;
|
|
1340
|
-
readonly tone?:
|
|
1342
|
+
readonly tone?: StatusBadgeOptionTone;
|
|
1341
1343
|
readonly testId?: string;
|
|
1342
1344
|
};
|
|
1343
|
-
export declare function statusToneForOptionTone(tone: SelectOptionTone):
|
|
1345
|
+
export declare function statusToneForOptionTone(tone: SelectOptionTone): StatusBadgeOptionTone | undefined;
|
|
1344
1346
|
export declare function statusToneForValue(value: string): StatusTone | undefined;
|
|
1345
1347
|
/** Structured JSON display (audit payload/metadata, job logs, unsupported
|
|
1346
1348
|
* jsonb/embedded field fallback). Takes the raw value, not a pre-stringified
|
package/dist/primitives.js
CHANGED
|
@@ -50,6 +50,8 @@ const STATUS_TONE_BY_OPTION_TONE = {
|
|
|
50
50
|
ok: "ok",
|
|
51
51
|
warn: "warn",
|
|
52
52
|
bad: "bad",
|
|
53
|
+
critical: "critical",
|
|
54
|
+
accent: "accent",
|
|
53
55
|
neutral: "muted",
|
|
54
56
|
};
|
|
55
57
|
// Own-key check: an unknown tone from an untyped definition must yield undefined so callers fall back to the value heuristic.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { NavNode } from "../../headless/index.ts";
|
|
2
3
|
export type BreadcrumbCrumb = {
|
|
3
4
|
readonly label: string;
|
|
4
5
|
readonly screenId?: string;
|
|
@@ -9,3 +10,4 @@ export declare function screenTitleKey(screenShortId: string): string;
|
|
|
9
10
|
* (unknown screen, or no explicit/heuristic parent). */
|
|
10
11
|
export declare function resolveParentScreenId(screens: readonly ScreenDefinition[], screenId: string): string | undefined;
|
|
11
12
|
export declare function resolveDetailBreadcrumb(screens: readonly ScreenDefinition[], detailScreenId: string, t: (key: string) => string): readonly BreadcrumbCrumb[] | undefined;
|
|
13
|
+
export declare function activeNavLabel(nodes: readonly NavNode[], screenId: string, t: (key: string) => string): string | undefined;
|
|
@@ -32,3 +32,14 @@ export function resolveDetailBreadcrumb(screens, detailScreenId, t) {
|
|
|
32
32
|
{ label: t(screenTitleKey(detailScreenId)) },
|
|
33
33
|
];
|
|
34
34
|
}
|
|
35
|
+
export function activeNavLabel(nodes, screenId, t) {
|
|
36
|
+
for (const node of nodes) {
|
|
37
|
+
if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
|
|
38
|
+
return node.label.includes(".") || node.label.includes(":") ? t(node.label) : node.label;
|
|
39
|
+
}
|
|
40
|
+
const child = activeNavLabel(node.children, screenId, t);
|
|
41
|
+
if (child !== undefined)
|
|
42
|
+
return child;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
@@ -4,9 +4,10 @@ type ShellHeaderUser = {
|
|
|
4
4
|
readonly id: string;
|
|
5
5
|
readonly roles: readonly string[];
|
|
6
6
|
};
|
|
7
|
-
export declare function ShellHeader({ schema, user, headerActions, }: {
|
|
7
|
+
export declare function ShellHeader({ schema, user, headerActions, allowedNavQns, }: {
|
|
8
8
|
readonly schema: AppSchema | FeatureSchema;
|
|
9
9
|
readonly user?: ShellHeaderUser;
|
|
10
10
|
readonly headerActions?: ReactNode;
|
|
11
|
+
readonly allowedNavQns?: ReadonlySet<string>;
|
|
11
12
|
}): ReactNode;
|
|
12
13
|
export {};
|
|
@@ -16,11 +16,11 @@ import { cn } from "../lib/cn.js";
|
|
|
16
16
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "../ui/breadcrumb.js";
|
|
17
17
|
import { SidebarTrigger } from "../ui/sidebar.js";
|
|
18
18
|
import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
|
|
19
|
-
import { buildNavRegistrySliceForApp
|
|
19
|
+
import { buildNavRegistrySliceForApp } from "./nav-tree.js";
|
|
20
20
|
import { usePageHeaderSlot } from "./page-header-slot.js";
|
|
21
|
-
import { resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
|
|
21
|
+
import { activeNavLabel, resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
|
|
22
22
|
import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
|
|
23
|
-
export function ShellHeader({ schema, user, headerActions, }) {
|
|
23
|
+
export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
|
|
24
24
|
const nav = useNav();
|
|
25
25
|
const t = useTranslation();
|
|
26
26
|
const appSchema = toAppSchema(schema);
|
|
@@ -28,6 +28,12 @@ export function ShellHeader({ schema, user, headerActions, }) {
|
|
|
28
28
|
const source = buildNavRegistrySliceForApp(appSchema);
|
|
29
29
|
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
30
30
|
}, [appSchema, user]);
|
|
31
|
+
const allowedTree = useMemo(() => {
|
|
32
|
+
if (allowedNavQns === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
const source = buildNavRegistrySliceForApp(appSchema, allowedNavQns);
|
|
35
|
+
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
36
|
+
}, [appSchema, user, allowedNavQns]);
|
|
31
37
|
const allScreens = useMemo(() => appSchema.features.flatMap((f) => f.screens), [appSchema.features]);
|
|
32
38
|
const slot = usePageHeaderSlot();
|
|
33
39
|
const standInScreenId = slot?.breadcrumbScreenId;
|
|
@@ -43,11 +49,12 @@ export function ShellHeader({ schema, user, headerActions, }) {
|
|
|
43
49
|
if (detailCrumbs !== undefined && detailCrumbs.length > 1)
|
|
44
50
|
return detailCrumbs;
|
|
45
51
|
}
|
|
46
|
-
const navLabel = activeNavLabel(
|
|
52
|
+
const navLabel = (allowedTree !== undefined ? activeNavLabel(allowedTree, screenId, t) : undefined) ??
|
|
53
|
+
activeNavLabel(tree, screenId, t);
|
|
47
54
|
if (navLabel !== undefined)
|
|
48
55
|
return [{ label: navLabel }];
|
|
49
56
|
return resolveDetailBreadcrumb(allScreens, screenId, t);
|
|
50
|
-
}, [allScreens, screenId, entityId, t, tree]);
|
|
57
|
+
}, [allScreens, screenId, entityId, t, tree, allowedTree]);
|
|
51
58
|
const setStatusElement = slot?.setStatusElement;
|
|
52
59
|
const setActionsElement = slot?.setActionsElement;
|
|
53
60
|
const titleOverride = slot?.title;
|
|
@@ -147,14 +154,3 @@ function HeaderOverflow({ items, headerActions, }) {
|
|
|
147
154
|
// being restyled through a child selector that reaches into their markup.
|
|
148
155
|
className: "flex flex-col items-stretch gap-1", onClick: () => setOpen(false), children: _jsx(HeaderOverflowMenuContext.Provider, { value: true, children: headerActions }) }))] })] }));
|
|
149
156
|
}
|
|
150
|
-
function activeNavLabel(nodes, screenId, t) {
|
|
151
|
-
for (const node of nodes) {
|
|
152
|
-
if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
|
|
153
|
-
return node.label.includes(".") || node.label.includes(":") ? t(node.label) : node.label;
|
|
154
|
-
}
|
|
155
|
-
const child = activeNavLabel(node.children, screenId, t);
|
|
156
|
-
if (child !== undefined)
|
|
157
|
-
return child;
|
|
158
|
-
}
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
@@ -150,7 +150,7 @@ export function WorkspaceShell({ brand, schema, topbarActions, user, initialWork
|
|
|
150
150
|
// The second column appears only when a screen fills it (sidebar-09 pattern);
|
|
151
151
|
// otherwise every other screen would have an empty strip beside the navigation.
|
|
152
152
|
const panel = useSidebarPanelHost();
|
|
153
|
-
return (_jsx(SidebarProvider, { ...fillCls.provider, children: _jsxs(SidebarPanelProvider, { value: panel.value, children: [_jsxs(Sidebar, { collapsible: "icon", mobileTitle: t("kumiko.sidebar.title"), mobileDescription: t("kumiko.sidebar.description"), children: [_jsxs(SidebarHeader, { "data-kumiko-layout": "sidebar-header", children: [brand, switcher] }), _jsx(SidebarContent, { "data-kumiko-layout": "sidebar-nav", children: sidebarContent }), sidebarFooter !== undefined && (_jsx(SidebarFooter, { "data-kumiko-layout": "sidebar-footer", className: "border-t border-sidebar-border", children: sidebarFooter })), _jsx(SidebarRail, { "aria-label": t("kumiko.sidebar.toggle"), title: t("kumiko.sidebar.toggle") })] }), _jsx("div", { ref: panel.ref, "data-kumiko-layout": "sidebar-panel", className: panel.occupied ? "flex h-svh shrink-0 flex-col" : "hidden" }), _jsx(SidebarInset, { className: fillCls.inset, children: _jsxs(PageHeaderSlotProvider, { children: [_jsx(ShellHeader, { schema: app, ...(user !== undefined && { user }), ...(topbarActions !== undefined && { headerActions: topbarActions }) }), _jsx("main", { className: fillCls.main, children: activeTarget !== undefined ? (_jsx(EditorPanel, { resolvers: resolvers })) : (_jsx(UserRolesProvider, { roles: user?.roles, children: children })) })] }) })] }) }));
|
|
153
|
+
return (_jsx(SidebarProvider, { ...fillCls.provider, children: _jsxs(SidebarPanelProvider, { value: panel.value, children: [_jsxs(Sidebar, { collapsible: "icon", mobileTitle: t("kumiko.sidebar.title"), mobileDescription: t("kumiko.sidebar.description"), children: [_jsxs(SidebarHeader, { "data-kumiko-layout": "sidebar-header", children: [brand, switcher] }), _jsx(SidebarContent, { "data-kumiko-layout": "sidebar-nav", children: sidebarContent }), sidebarFooter !== undefined && (_jsx(SidebarFooter, { "data-kumiko-layout": "sidebar-footer", className: "border-t border-sidebar-border", children: sidebarFooter })), _jsx(SidebarRail, { "aria-label": t("kumiko.sidebar.toggle"), title: t("kumiko.sidebar.toggle") })] }), _jsx("div", { ref: panel.ref, "data-kumiko-layout": "sidebar-panel", className: panel.occupied ? "flex h-svh shrink-0 flex-col" : "hidden" }), _jsx(SidebarInset, { className: fillCls.inset, children: _jsxs(PageHeaderSlotProvider, { children: [_jsx(ShellHeader, { schema: app, ...(user !== undefined && { user }), ...(allowedNavQns !== undefined && { allowedNavQns }), ...(topbarActions !== undefined && { headerActions: topbarActions }) }), _jsx("main", { className: fillCls.main, children: activeTarget !== undefined ? (_jsx(EditorPanel, { resolvers: resolvers })) : (_jsx(UserRolesProvider, { roles: user?.roles, children: children })) })] }) })] }) }));
|
|
154
154
|
}
|
|
155
155
|
// --- helpers (exported for tests) ---
|
|
156
156
|
export function filterByAccess(workspaces, userRoles) {
|
|
@@ -2,6 +2,6 @@ import type { ListColumnViewModel } from "../../../headless/index.ts";
|
|
|
2
2
|
/** Optional presentation props every DataTableCell gets from its column. */
|
|
3
3
|
export declare function columnCellPresentationProps(col: ListColumnViewModel): {
|
|
4
4
|
optionLabels?: Readonly<Record<string, string>> | undefined;
|
|
5
|
-
optionTones?: Readonly<Partial<Record<string, "bad" | "neutral" | "ok" | "warn">>> | undefined;
|
|
5
|
+
optionTones?: Readonly<Partial<Record<string, "accent" | "bad" | "critical" | "neutral" | "ok" | "warn">>> | undefined;
|
|
6
6
|
grouping?: boolean | undefined;
|
|
7
7
|
};
|
|
@@ -2,5 +2,6 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { DataTableModel } from "./use-data-table-state.ts";
|
|
3
3
|
export declare function buildModelFacetCluster(m: DataTableModel): ReactNode;
|
|
4
4
|
export declare function hasToolbarContent(m: DataTableModel, facetCluster: ReactNode): boolean;
|
|
5
|
+
export declare function toolbarMergesDescription(m: DataTableModel, facetCluster: ReactNode): boolean;
|
|
5
6
|
export declare function renderToolbar(m: DataTableModel, facetCluster: ReactNode): ReactNode;
|
|
6
|
-
export declare function renderToolbarDescription(m: DataTableModel, hasToolbar: boolean): ReactNode;
|
|
7
|
+
export declare function renderToolbarDescription(m: DataTableModel, hasToolbar: boolean, inToolbarRow?: boolean): ReactNode;
|
|
@@ -40,14 +40,27 @@ function renderFacetToggle(m, facetCluster) {
|
|
|
40
40
|
const { testId, facetsOpenNarrow, setFacetsOpenNarrow } = m;
|
|
41
41
|
return (_jsxs(_Fragment, { children: [_jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: "size-11", "aria-label": m.tableTranslate?.("kumiko.list.filter.toggle") ?? "Filter", "aria-expanded": facetsOpenNarrow, "data-testid": testId !== undefined ? `${testId}-filter-toggle` : "filter-toggle", onClick: () => setFacetsOpenNarrow((open) => !open), children: _jsx(SlidersHorizontal, { className: "size-4", "aria-hidden": "true" }) }), facetsOpenNarrow && _jsx("div", { className: "w-full", children: facetCluster })] }));
|
|
42
42
|
}
|
|
43
|
+
// With only end content (no search, no facets) the row has room for the description,
|
|
44
|
+
// so both share one line instead of stacking the description under the actions.
|
|
45
|
+
export function toolbarMergesDescription(m, facetCluster) {
|
|
46
|
+
return (m.toolbarDescription !== undefined &&
|
|
47
|
+
m.toolbarStart === undefined &&
|
|
48
|
+
facetCluster === undefined &&
|
|
49
|
+
toolbarEndHasContent(m.view, m.exportList, m.toolbarEnd));
|
|
50
|
+
}
|
|
43
51
|
export function renderToolbar(m, facetCluster) {
|
|
44
52
|
const { testId, toolbarStart } = m;
|
|
45
53
|
const collapsesFacetsBehindButton = m.isNarrow && m.fillsHeight && facetCluster !== undefined;
|
|
46
|
-
|
|
54
|
+
const mergesDescription = toolbarMergesDescription(m, facetCluster);
|
|
55
|
+
return (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar", className: cn(toolbarClassName(m), mergesDescription && "justify-between", mergesDescription && m.fillsHeight && !m.isNarrow && "md:py-2",
|
|
56
|
+
// A dashboard panel title above has no inset; the description must share its left edge.
|
|
57
|
+
mergesDescription && m.fillsHeight && m.insideEmbeddedScreen && "px-0"), children: [mergesDescription && renderToolbarDescription(m, true, true), toolbarStart !== undefined && renderToolbarStart(m, toolbarStart), collapsesFacetsBehindButton ? renderFacetToggle(m, facetCluster) : facetCluster, _jsx(ToolbarEndCluster, { view: m.view, columnLabels: new Map(m.schemaColumns.map((col) => [col.field, col.label])), exportList: m.exportList, toolbarEnd: m.toolbarEnd })] }));
|
|
47
58
|
}
|
|
48
|
-
export function renderToolbarDescription(m, hasToolbar) {
|
|
59
|
+
export function renderToolbarDescription(m, hasToolbar, inToolbarRow = false) {
|
|
49
60
|
const { testId, toolbarDescription, fillsHeight, isNarrow } = m;
|
|
50
61
|
if (toolbarDescription === undefined)
|
|
51
62
|
return null;
|
|
52
|
-
return (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-description` : "render-list-description", className: cn("m-0
|
|
63
|
+
return (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-description` : "render-list-description", className: cn("m-0 text-sm text-muted-foreground",
|
|
64
|
+
// min-w-48 lets the actions wrap below the text instead of squeezing it.
|
|
65
|
+
inToolbarRow ? "min-w-48 flex-1" : "pb-2", !inToolbarRow && fillsHeight && (isNarrow ? "px-3" : "px-6"), !inToolbarRow && fillsHeight && !hasToolbar && "pt-4"), children: toolbarDescription }));
|
|
53
66
|
}
|
|
@@ -30,6 +30,7 @@ export type DataTableModel = Omit<DataTableProps, "columns"> & {
|
|
|
30
30
|
readonly rowTestId: (row: ListRowViewModel) => string;
|
|
31
31
|
readonly expansion: RowExpansion | undefined;
|
|
32
32
|
readonly fillsHeight: boolean;
|
|
33
|
+
readonly insideEmbeddedScreen: boolean;
|
|
33
34
|
readonly selection: ReturnType<typeof useBulkSelection>;
|
|
34
35
|
readonly headerRowRef: ReturnType<typeof useMeasuredColumnWidths>["headerRowRef"];
|
|
35
36
|
readonly pins: ColumnPins<ListColumnViewModel>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useId, useState } from "react";
|
|
2
|
+
import { useIsEmbeddedScreen } from "../../../context/embedded-screen-context.js";
|
|
2
3
|
import { useOptionalLocale, useOptionalTranslation } from "../../../index.js";
|
|
3
4
|
import { useIsNarrowViewport } from "../use-narrow-viewport.js";
|
|
4
5
|
import { isPinningActive, isResizingActive } from "./column-layout-mode.js";
|
|
@@ -55,6 +56,7 @@ export function useDataTableState(props) {
|
|
|
55
56
|
const expansion = resolveRowExpansion(props);
|
|
56
57
|
const isRowExpandable = expansion !== undefined;
|
|
57
58
|
const fillsHeight = scrollBody === true;
|
|
59
|
+
const insideEmbeddedScreen = useIsEmbeddedScreen();
|
|
58
60
|
const selection = useBulkSelection({
|
|
59
61
|
bulk,
|
|
60
62
|
rows,
|
|
@@ -110,6 +112,7 @@ export function useDataTableState(props) {
|
|
|
110
112
|
rowTestId,
|
|
111
113
|
expansion,
|
|
112
114
|
fillsHeight,
|
|
115
|
+
insideEmbeddedScreen,
|
|
113
116
|
selection,
|
|
114
117
|
headerRowRef,
|
|
115
118
|
pins,
|
|
@@ -259,6 +259,8 @@ const ROW_TONE_CLASS = {
|
|
|
259
259
|
bad: "bg-status-bad/10 hover:bg-status-bad/15",
|
|
260
260
|
warn: "bg-status-warn/10 hover:bg-status-warn/15",
|
|
261
261
|
ok: "bg-status-ok/10 hover:bg-status-ok/15",
|
|
262
|
+
critical: "bg-status-critical/10 hover:bg-status-critical/15",
|
|
263
|
+
accent: "bg-primary/10 hover:bg-primary/15",
|
|
262
264
|
neutral: "",
|
|
263
265
|
};
|
|
264
266
|
export function rowToneClass(tone) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
3
|
import { renderTableContent } from "./data-table/table-content.js";
|
|
4
|
-
import { buildModelFacetCluster, hasToolbarContent, renderToolbar, renderToolbarDescription, } from "./data-table/toolbar.js";
|
|
4
|
+
import { buildModelFacetCluster, hasToolbarContent, renderToolbar, renderToolbarDescription, toolbarMergesDescription, } from "./data-table/toolbar.js";
|
|
5
5
|
import { useDataTableState } from "./data-table/use-data-table-state.js";
|
|
6
6
|
import { screenPaddingClassName } from "./layout.js";
|
|
7
7
|
// `screenPadding`: on a list screen this wrapper IS the screen container,
|
|
@@ -28,10 +28,11 @@ export function DefaultDataTable(props) {
|
|
|
28
28
|
const model = useDataTableState(props);
|
|
29
29
|
const facetCluster = buildModelFacetCluster(model);
|
|
30
30
|
const hasToolbar = hasToolbarContent(model, facetCluster);
|
|
31
|
+
const mergesDescription = toolbarMergesDescription(model, facetCluster);
|
|
31
32
|
// dashboard-01 pattern: the toolbar (search + facets + "+ New") sits ABOVE
|
|
32
33
|
// the table in the same padding block; no separate bg bar, no screen title
|
|
33
34
|
// (that lives in the shell's breadcrumb). The toolbar is NOT sticky: lists
|
|
34
35
|
// typically scroll with the page container, not internally. Sticky would
|
|
35
36
|
// compete with the topbar.
|
|
36
|
-
return (_jsxs("div", { className: wrapperClassName(model), children: [!model.fillsHeight && renderToolbarDescription(model, hasToolbar), hasToolbar && renderToolbar(model, facetCluster), model.fillsHeight && renderToolbarDescription(model, hasToolbar), renderTableContent(model)] }));
|
|
37
|
+
return (_jsxs("div", { className: wrapperClassName(model), children: [!mergesDescription && !model.fillsHeight && renderToolbarDescription(model, hasToolbar), hasToolbar && renderToolbar(model, facetCluster), !mergesDescription && model.fillsHeight && renderToolbarDescription(model, hasToolbar), renderTableContent(model)] }));
|
|
37
38
|
}
|
|
@@ -21,8 +21,8 @@ export function DefaultSection({ id, title, subtitle, children, actions, variant
|
|
|
21
21
|
// footer — a title-less section still draws the row when actions are
|
|
22
22
|
// present, so a hideTitle tabs-Section with `actions` isn't stranded.
|
|
23
23
|
const TitleTag = insideScreenForm ? "h2" : "h3";
|
|
24
|
-
const titleBlock = title !== undefined || subtitle !== undefined ? (_jsxs("div", { className: "flex flex-col gap-1", children: [title !== undefined && (_jsxs(TitleTag, { "data-testid": testId !== undefined ? `${testId}-title` : undefined, className: "flex items-center gap-2 text-base font-semibold leading-none tracking-tight", children: [icon !== undefined && _jsx(Icon, { name: icon, className: "size-4 text-muted-foreground" }), title] })), subtitle !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: cn("text-sm text-muted-foreground", insideScreenForm && "text-[13px] text-foreground-secondary"), children: subtitle }))] })) : null;
|
|
25
|
-
const header = titleBlock !== null || actions !== undefined ? (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [titleBlock, actions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "ml-auto flex shrink-0 items-center gap-2", children: actions }))] })) : null;
|
|
24
|
+
const titleBlock = title !== undefined || subtitle !== undefined ? (_jsxs("div", { className: "flex min-w-48 flex-1 flex-col gap-1", children: [title !== undefined && (_jsxs(TitleTag, { "data-testid": testId !== undefined ? `${testId}-title` : undefined, className: "flex items-center gap-2 text-base font-semibold leading-none tracking-tight", children: [icon !== undefined && _jsx(Icon, { name: icon, className: "size-4 text-muted-foreground" }), title] })), subtitle !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: cn("text-sm text-muted-foreground", insideScreenForm && "text-[13px] text-foreground-secondary"), children: subtitle }))] })) : null;
|
|
25
|
+
const header = titleBlock !== null || actions !== undefined ? (_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-4 gap-y-3", children: [titleBlock, actions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "ml-auto flex shrink-0 items-center gap-2", children: actions }))] })) : null;
|
|
26
26
|
// Inside a form: divider-less section WITHOUT a card surface of its own.
|
|
27
27
|
// The separator lines BETWEEN sections come from the divide-y wrapper in
|
|
28
28
|
// the form.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { subscribeToEntities, useLiveEntitiesForQuery } from "../../hooks/live-query-entities.js";
|
|
4
|
+
import { useDispatcher, useLiveEvents, useTranslation } from "../../index.js";
|
|
4
5
|
import { EmptyState, ErrorState, LoadingState } from "./states.js";
|
|
5
6
|
/** Cursor-paginated scroll list (mail inbox, activity feeds) — loads the
|
|
6
7
|
* next page when the end sentinel becomes visible (IntersectionObserver),
|
|
@@ -11,6 +12,7 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
11
12
|
const dispatcher = useDispatcher();
|
|
12
13
|
const t = useTranslation();
|
|
13
14
|
const subscribeLive = useLiveEvents();
|
|
15
|
+
const liveEntities = useLiveEntitiesForQuery(query);
|
|
14
16
|
const [state, setState] = useState({ kind: "loading" });
|
|
15
17
|
const sentinelRef = useRef(null);
|
|
16
18
|
const payloadKey = JSON.stringify(payload ?? {});
|
|
@@ -136,9 +138,8 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
136
138
|
// skip: live mode off, no SSE subscription needed
|
|
137
139
|
if (!live)
|
|
138
140
|
return;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (entity === undefined)
|
|
141
|
+
// skip: no entity to listen on, nothing to subscribe to
|
|
142
|
+
if (liveEntities.length === 0)
|
|
142
143
|
return;
|
|
143
144
|
const runRefresh = () => {
|
|
144
145
|
liveInFlightRef.current = true;
|
|
@@ -162,7 +163,7 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
162
163
|
runRefresh();
|
|
163
164
|
}, 250);
|
|
164
165
|
};
|
|
165
|
-
const unsubscribe = subscribeLive
|
|
166
|
+
const unsubscribe = subscribeToEntities(subscribeLive, liveEntities, scheduleRefresh);
|
|
166
167
|
return () => {
|
|
167
168
|
unsubscribe();
|
|
168
169
|
if (liveTimerRef.current !== null) {
|
|
@@ -170,7 +171,7 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
170
171
|
liveTimerRef.current = null;
|
|
171
172
|
}
|
|
172
173
|
};
|
|
173
|
-
}, [live,
|
|
174
|
+
}, [live, liveEntities, refreshFirstPage, subscribeLive]);
|
|
174
175
|
useEffect(() => {
|
|
175
176
|
const sentinel = sentinelRef.current;
|
|
176
177
|
// skip: no further page or not ready yet — observer not needed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.392.1",
|
|
4
4
|
"description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
51
|
+
"@cosmicdrift/kumiko-framework": "0.392.1",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.392.1",
|
|
53
53
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
54
54
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
55
55
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -92,8 +92,8 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
95
|
+
"@cosmicdrift/kumiko-locale-de": "0.392.1",
|
|
96
|
+
"@cosmicdrift/kumiko-testing": "0.392.1",
|
|
97
97
|
"@tailwindcss/cli": "^4.3.0",
|
|
98
98
|
"@testing-library/react": "^16.3.2",
|
|
99
99
|
"@testing-library/user-event": "^14.6.1",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,52 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "List descriptions sit beside the toolbar actions",
|
|
6
|
+
"detail": "A description plus a toolbar with only end content (no search, no facets) renders as one row with the actions on the right, in embedded and full-height lists. Before, a full-height list put the description under the create button. With search or facets the layout is unchanged. Section header actions wrap below the title on narrow widths."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.392.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "The projectionDetail header badge shows the translated status",
|
|
12
|
+
"detail": "The header badge formats its text with header.statusFormat. Without it, the badge reuses the layout renderer of the status field when that is enumOption or i18nText, so header and body show the same text. Without either, the raw value is shown as before. The tone keeps resolving on the raw value."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.392.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "The shell header title follows the active workspace",
|
|
18
|
+
"detail": "When two navs point at the same screen, the breadcrumb now takes the label of the nav the sidebar shows for the active workspace. Before, the first nav in the app won, so the title could differ from the sidebar entry. Without a matching nav in the workspace, the old lookup applies."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.392.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "The detail header keeps inlineActions as buttons",
|
|
24
|
+
"detail": "HeaderActionsBar takes inlineActionIds and renders those actions as buttons after the primary action; only the remaining actions go into the overflow menu, and no menu is shown when none remain."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.392.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "The relatedList empty state follows the parent record",
|
|
30
|
+
"detail": "RelatedListSection evaluates emptyState.variants against the parent record and shows the first matching text. The headless view model translates the variant texts."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.392.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "English header comments in the renderer e2e config",
|
|
36
|
+
"detail": "Comment-only change in the renderer Playwright config and the demo dev bootstrap; no runtime behavior changes."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.392.0",
|
|
40
|
+
"type": "improvement",
|
|
41
|
+
"title": "A drawer-hosted actionForm follows its redirect",
|
|
42
|
+
"detail": "After a successful submit the drawer closes, the host refetches, and a declared redirect then navigates like on the full page, without returnTo. A redirect that points at the screen the drawer sits on (no record id, no tab) does not navigate, so filters and scroll position stay. Behavior change: before, a drawer ignored redirect."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.392.0",
|
|
46
|
+
"type": "improvement",
|
|
47
|
+
"title": "Live queries follow the handler's liveEntities",
|
|
48
|
+
"detail": "useQuery({ live: true }) and InfinityList refetch on signals for the entity in the query name and for every entity the query handler declares in liveEntities, in both the declared and the kebab-case form. The app schema carries them per feature as queryLiveEntities, and a tenant feature toggle strips them with the feature. A query that reads a second entity no longer needs a name under that entity to stay live."
|
|
49
|
+
},
|
|
2
50
|
{
|
|
3
51
|
"version": "0.391.0",
|
|
4
52
|
"type": "improvement",
|