@cosmicdrift/kumiko-renderer 0.392.2 → 0.394.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.
Files changed (57) hide show
  1. package/dist/app/entity-list-screen.js +4 -2
  2. package/dist/app/kumiko-screen.js +4 -36
  3. package/dist/app/projection-list-body.js +1 -1
  4. package/dist/app/qn.d.ts +1 -6
  5. package/dist/app/qn.js +1 -12
  6. package/dist/app/screen-visibility-gate.d.ts +19 -0
  7. package/dist/app/screen-visibility-gate.js +60 -0
  8. package/dist/app/use-embedded-screen.d.ts +2 -0
  9. package/dist/app/use-embedded-screen.js +1 -1
  10. package/dist/app/use-gated-toolbar-actions.d.ts +7 -0
  11. package/dist/app/use-gated-toolbar-actions.js +51 -0
  12. package/dist/app/use-projection-list-actions.d.ts +1 -0
  13. package/dist/app/use-projection-list-actions.js +11 -2
  14. package/dist/app/visible-when.d.ts +5 -1
  15. package/dist/app/visible-when.js +11 -0
  16. package/dist/data-table-view-types.d.ts +4 -2
  17. package/dist/hooks/list-column-layout.d.ts +3 -1
  18. package/dist/hooks/list-column-layout.js +20 -7
  19. package/dist/hooks/use-list-view-state.d.ts +1 -1
  20. package/dist/hooks/use-list-view-state.js +3 -2
  21. package/dist/i18n-defaults.js +2 -0
  22. package/dist/web/app/dashboard-body.js +1 -1
  23. package/dist/web/app/dashboard-screen-tiles.d.ts +2 -1
  24. package/dist/web/app/dashboard-screen-tiles.js +9 -7
  25. package/dist/web/app/dashboard-shared.d.ts +2 -0
  26. package/dist/web/app/use-dashboard-screen-state.js +6 -1
  27. package/dist/web/layout/shell-breadcrumb.d.ts +4 -1
  28. package/dist/web/layout/shell-breadcrumb.js +18 -3
  29. package/dist/web/layout/shell-header.js +4 -2
  30. package/dist/web/primitives/action-menu.js +2 -2
  31. package/dist/web/primitives/calendar-popover.js +1 -1
  32. package/dist/web/primitives/combobox.js +1 -1
  33. package/dist/web/primitives/data-table/table-data-cell.js +27 -1
  34. package/dist/web/primitives/data-table/table-header-row.js +1 -1
  35. package/dist/web/primitives/data-table/table-view.js +2 -2
  36. package/dist/web/primitives/data-table/use-column-sizing.d.ts +4 -2
  37. package/dist/web/primitives/data-table/use-column-sizing.js +34 -11
  38. package/dist/web/primitives/data-table/view-menu.js +1 -1
  39. package/dist/web/primitives/data-table-cell.js +1 -1
  40. package/dist/web/primitives/dialog.js +1 -1
  41. package/dist/web/primitives/dropdown-menu.d.ts +1 -1
  42. package/dist/web/primitives/dropdown-menu.js +2 -2
  43. package/dist/web/primitives/lightbox.js +1 -1
  44. package/dist/web/primitives/modal-shell.js +1 -1
  45. package/dist/web/primitives/modal.js +1 -1
  46. package/dist/web/primitives/narrow-pinned-footer.js +1 -1
  47. package/dist/web/primitives/toast.js +2 -2
  48. package/dist/web/sse/live-events.js +0 -7
  49. package/dist/web/widgets/floating-panel-geometry.d.ts +32 -0
  50. package/dist/web/widgets/floating-panel-geometry.js +71 -0
  51. package/dist/web/widgets/floating-panel.d.ts +2 -6
  52. package/dist/web/widgets/floating-panel.js +11 -55
  53. package/dist/web/widgets/infinity-list.js +16 -7
  54. package/dist/web/widgets/plan-card.d.ts +2 -0
  55. package/dist/web/widgets/plan-card.js +5 -1
  56. package/package.json +6 -15
  57. package/src/changes.json +42 -0
@@ -31,6 +31,7 @@ import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAc
31
31
  import { findEditScreenFor } from "./screen-access.js";
32
32
  import { buildListQueryPayload, entityQueryCommand, isEntityEditScreenOfEntity, useNavigateToCreateFor, } from "./screen-shared.js";
33
33
  import { useEntityRangeFacets } from "./use-entity-range-facets.js";
34
+ import { useGatedToolbarActions } from "./use-gated-toolbar-actions.js";
34
35
  import { listPager, useScreenListRows } from "./use-screen-list-rows.js";
35
36
  import { useScreenListTable } from "./use-screen-list-table.js";
36
37
  export function EntityListScreen({ schema, screen, translate, onRowClick, }) {
@@ -299,7 +300,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
299
300
  });
300
301
  // ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
301
302
  // projectionList and relatedList sections via buildProjectionToolbarActions.
302
- const toolbarActions = useMemo(() => buildProjectionToolbarActions({
303
+ const resolvedToolbarActions = useMemo(() => buildProjectionToolbarActions({
303
304
  toolbarActions: screen.toolbarActions,
304
305
  translate: effectiveTranslate,
305
306
  dispatcher,
@@ -319,6 +320,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
319
320
  appFeatures,
320
321
  userRoles,
321
322
  ]);
323
+ const { actions: toolbarActions, gateQueries: toolbarGateQueries } = useGatedToolbarActions(screen.toolbarActions, resolvedToolbarActions);
322
324
  const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
323
325
  nav,
324
326
  host,
@@ -407,7 +409,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
407
409
  expandedRowIds,
408
410
  onToggleRowExpanded: toggleRowExpanded,
409
411
  renderExpandedRow,
410
- }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
412
+ }) }), toolbarGateQueries, _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
411
413
  closeDrawer();
412
414
  invalidateExpansions();
413
415
  void refreshRowsAfterWrite();
@@ -1,8 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef } from "react";
2
+ import { useMemo } from "react";
3
3
  import { useUserRoles } from "../context/user-roles-context.js";
4
- import { useQuery } from "../hooks/use-query.js";
5
- import { useTranslation } from "../i18n.js";
6
4
  import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
7
5
  import { usePrimitives } from "../primitives.js";
8
6
  import { ActionFormBody } from "./action-form-body.js";
@@ -16,14 +14,13 @@ import { ProjectionDetailBody } from "./projection-detail-body.js";
16
14
  import { ProjectionListBody } from "./projection-list-body.js";
17
15
  import { lastSegment } from "./qn.js";
18
16
  import { qualifyScreenId } from "./qualify-screen-id.js";
19
- import { QueryLoadingBanner } from "./query-state-banners.js";
20
17
  import { ReturnHostProvider, useReturnHost } from "./return-to.js";
21
18
  import { screenAccessAllows } from "./screen-access.js";
22
19
  import { ScreenAccessDeniedBanner, ScreenMissingState } from "./screen-shared.js";
20
+ import { ScreenNotice, ScreenVisibilityGate } from "./screen-visibility-gate.js";
23
21
  import { SecretMintBody } from "./secret-mint-body.js";
24
22
  import { SecretsEditBody } from "./secrets-edit-body.js";
25
23
  import { useEmbeddedScreen } from "./use-embedded-screen.js";
26
- import { evalVisibleWhenAfterShown } from "./visible-when.js";
27
24
  export { qualifyScreenId };
28
25
  /** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
29
26
  * Callers building QNs (e.g. the WorkspaceShell resolver) should use this instead of string-concat, so a QN-schema change only touches one place. */
@@ -55,7 +52,7 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
55
52
  onCopyLink,
56
53
  });
57
54
  // Gate every entry (incl. direct URL): the body element is only mounted once the condition holds.
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 }));
55
+ const body = screen.visibleWhen !== undefined ? (_jsx(ScreenVisibilityGate, { entityId: entityId, visibleWhen: screen.visibleWhen, fallback: screen.fallback, translate: translate, renderFallbackScreen: (target, fallbackEntityId) => (_jsx(FallbackScreen, { featureName: schema.featureName, fallback: target, translate: translate, ...(fallbackEntityId !== undefined && { entityId: fallbackEntityId }) })), children: _jsx(StandInChainBoundary, { children: screenBody }) }, entityId)) : (_jsx(StandInChainBoundary, { children: screenBody }));
59
56
  // An embedded screen (e.g. a dashboard panel) keeps its parent's host and
60
57
  // must not portal its page header into the shell that hosts the outer screen.
61
58
  if (outerHost !== undefined) {
@@ -63,41 +60,12 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
63
60
  }
64
61
  return _jsx(ReturnHostProvider, { value: ownHost, children: body });
65
62
  }
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 });
72
- const wasShown = useRef(false);
73
- const verdict = missingRouteId
74
- ? "hidden"
75
- : evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
76
- useEffect(() => {
77
- if (verdict === "visible")
78
- wasShown.current = true;
79
- }, [verdict]);
80
- if (verdict === "visible")
81
- return children;
82
- if (verdict === "loading") {
83
- return _jsx(QueryLoadingBanner, {});
84
- }
85
- if (fallback !== undefined) {
86
- return (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate, ...(visibleWhen.idParam !== undefined && entityId !== undefined && { entityId }) }));
87
- }
88
- return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
89
- }
90
- function ScreenUnavailableBanner() {
91
- const { Banner } = usePrimitives();
92
- const t = useTranslation();
93
- return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
94
- }
95
63
  function FallbackScreen({ featureName, fallback, translate, entityId, }) {
96
64
  const target = useEmbeddedScreen(featureName, fallback);
97
65
  // Also undefined when the user lacks access to the fallback screen, which is
98
66
  // not a framework error.
99
67
  if (target === undefined) {
100
- return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
68
+ return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenNotice, {})] }));
101
69
  }
102
70
  return (_jsx(LockedHeaderScope, { standInScreenId: lastSegment(target.qn), children: _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate, ...(entityId !== undefined && { entityId }) }) }));
103
71
  }
@@ -108,7 +108,7 @@ export function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
108
108
  limit: query.limit,
109
109
  urlState: query.urlState,
110
110
  });
111
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs, onOptions: query.handleProjectionFacetOptions }), renderProjectionList({
111
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, actions.toolbarGateQueries, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs, onOptions: query.handleProjectionFacetOptions }), renderProjectionList({
112
112
  schema,
113
113
  screen,
114
114
  translate,
package/dist/app/qn.d.ts CHANGED
@@ -1,7 +1,2 @@
1
1
  export declare function lastSegment(qn: string): string;
2
- /**
3
- * Client-safe copy of framework `toKebab` (engine/qualified-name.ts).
4
- * Must stay in sync — do NOT import from `@cosmicdrift/kumiko-framework/engine`
5
- * here: that barrel pulls server deps into the browser bundle.
6
- */
7
- export declare function toKebab(input: string): string;
2
+ export { toKebab } from "@cosmicdrift/kumiko-types/kebab-case";
package/dist/app/qn.js CHANGED
@@ -18,15 +18,4 @@ export function lastSegment(qn) {
18
18
  const idx = qn.lastIndexOf(":");
19
19
  return idx < 0 ? qn : qn.slice(idx + 1);
20
20
  }
21
- /**
22
- * Client-safe copy of framework `toKebab` (engine/qualified-name.ts).
23
- * Must stay in sync — do NOT import from `@cosmicdrift/kumiko-framework/engine`
24
- * here: that barrel pulls server deps into the browser bundle.
25
- */
26
- export function toKebab(input) {
27
- return input
28
- .replace(/\./g, "-")
29
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
30
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
31
- .toLowerCase();
32
- }
21
+ export { toKebab } from "@cosmicdrift/kumiko-types/kebab-case";
@@ -0,0 +1,19 @@
1
+ import type { ScreenFallback, ScreenVisibility } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ import type { Translate } from "../headless/index.ts";
4
+ type NoticeKey = "kumiko.screen.unavailable" | "kumiko.screen.forbidden" | "kumiko.screen.notFound";
5
+ export declare function ScreenNotice({ messageKey, }: {
6
+ readonly messageKey?: NoticeKey;
7
+ }): ReactNode;
8
+ export declare function LockedNotice({ messageKey }: {
9
+ readonly messageKey?: NoticeKey;
10
+ }): ReactNode;
11
+ export declare function ScreenVisibilityGate({ entityId, visibleWhen, fallback, translate, renderFallbackScreen, children, }: {
12
+ readonly entityId: string | undefined;
13
+ readonly visibleWhen: ScreenVisibility;
14
+ readonly fallback: ScreenFallback | undefined;
15
+ readonly translate?: Translate;
16
+ readonly renderFallbackScreen: (target: string, entityId: string | undefined) => ReactNode;
17
+ readonly children: ReactNode;
18
+ }): ReactNode;
19
+ export {};
@@ -0,0 +1,60 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { fallbackTargetFor } from "@cosmicdrift/kumiko-types/screen";
3
+ import { useEffect, useRef } from "react";
4
+ import { useQuery } from "../hooks/use-query.js";
5
+ import { useTranslation } from "../i18n.js";
6
+ import { usePrimitives } from "../primitives.js";
7
+ import { LockedHeaderScope } from "./locked-screen-header.js";
8
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
9
+ import { evalVisibleWhenAfterShown, screenFallbackReason, } from "./visible-when.js";
10
+ export function ScreenNotice({ messageKey = "kumiko.screen.unavailable", }) {
11
+ const { Banner } = usePrimitives();
12
+ const t = useTranslation();
13
+ return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t(messageKey) }));
14
+ }
15
+ export function LockedNotice({ messageKey }) {
16
+ return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenNotice, { ...(messageKey !== undefined && { messageKey }) })] }));
17
+ }
18
+ // Only the map form gets reason-specific defaults; a plain string or no fallback keeps the
19
+ // single "unavailable" notice. `error` renders a retry banner instead, so its entry is a
20
+ // safety net for an error verdict that carries no error object.
21
+ const DEFAULT_NOTICE_KEY = {
22
+ hidden: "kumiko.screen.unavailable",
23
+ forbidden: "kumiko.screen.forbidden",
24
+ notFound: "kumiko.screen.notFound",
25
+ error: "kumiko.screen.unavailable",
26
+ };
27
+ export function ScreenVisibilityGate({ entityId, visibleWhen, fallback, translate, renderFallbackScreen, children, }) {
28
+ // An idParam gate without a route id has nothing to ask: treated as hidden, never queried.
29
+ const missingRouteId = visibleWhen.idParam !== undefined && entityId === undefined;
30
+ const result = useQuery(visibleWhen.query, visibleWhen.idParam !== undefined && entityId !== undefined
31
+ ? { [visibleWhen.idParam]: entityId }
32
+ : {}, { live: true, enabled: !missingRouteId });
33
+ const wasShown = useRef(false);
34
+ const verdict = missingRouteId
35
+ ? "hidden"
36
+ : evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
37
+ useEffect(() => {
38
+ if (verdict === "visible")
39
+ wasShown.current = true;
40
+ }, [verdict]);
41
+ if (verdict === "visible")
42
+ return children;
43
+ if (verdict === "loading") {
44
+ return _jsx(QueryLoadingBanner, {});
45
+ }
46
+ return (_jsx(ClosedGate, { verdict: verdict, result: result, fallback: fallback, translate: translate, renderFallbackScreen: (target) => renderFallbackScreen(target, visibleWhen.idParam !== undefined && entityId !== undefined ? entityId : undefined) }));
47
+ }
48
+ function ClosedGate({ verdict, result, fallback, translate, renderFallbackScreen, }) {
49
+ const t = useTranslation();
50
+ const reason = screenFallbackReason(verdict, result.error);
51
+ const target = fallback === undefined ? undefined : fallbackTargetFor(fallback, reason);
52
+ if (target !== undefined)
53
+ return renderFallbackScreen(target);
54
+ if (typeof fallback !== "object")
55
+ return _jsx(LockedNotice, {});
56
+ if (reason === "error" && result.error) {
57
+ return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(QueryErrorBanner, { error: result.error, translate: translate ?? t, onRetry: result.refetch })] }));
58
+ }
59
+ return _jsx(LockedNotice, { messageKey: DEFAULT_NOTICE_KEY[reason] });
60
+ }
@@ -1,6 +1,8 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
1
2
  import type { FeatureSchema } from "./feature-schema.ts";
2
3
  export type EmbeddedScreenTarget = {
3
4
  readonly schema: FeatureSchema;
4
5
  readonly qn: string;
6
+ readonly screenType: ScreenDefinition["type"];
5
7
  };
6
8
  export declare function useEmbeddedScreen(hostFeatureName: string, screen: string): EmbeddedScreenTarget | undefined;
@@ -15,5 +15,5 @@ export function useEmbeddedScreen(hostFeatureName, screen) {
15
15
  return undefined;
16
16
  if (!screenAccessAllows(target.access, userRoles))
17
17
  return undefined;
18
- return { schema, qn };
18
+ return { schema, qn, screenType: target.type };
19
19
  }
@@ -0,0 +1,7 @@
1
+ import type { ToolbarAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ import type { ToolbarActionButton } from "../components/render-list-toolbar-action.tsx";
4
+ export declare function useGatedToolbarActions(toolbarActions: readonly ToolbarAction[] | undefined, resolved: readonly ToolbarActionButton[] | undefined): {
5
+ readonly actions: readonly ToolbarActionButton[] | undefined;
6
+ readonly gateQueries: ReactNode;
7
+ };
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useQuery } from "../hooks/use-query.js";
4
+ import { evalVisibleWhen } from "./visible-when.js";
5
+ // Same query name, same answer: buttons gating on one query share a single live request.
6
+ function GateQuery({ query, onResult, onForget, }) {
7
+ const { data, error, loading, refetch } = useQuery(query, {}, { live: true });
8
+ useEffect(() => {
9
+ onResult(query, { data, error, loading, refetch });
10
+ }, [query, data, error, loading, refetch, onResult]);
11
+ useEffect(() => () => onForget(query), [query, onForget]);
12
+ return null;
13
+ }
14
+ function gatesById(toolbarActions) {
15
+ const gates = new Map();
16
+ for (const action of toolbarActions ?? []) {
17
+ if (action.visibleWhen !== undefined)
18
+ gates.set(action.id, action.visibleWhen);
19
+ }
20
+ return gates;
21
+ }
22
+ // Fail-closed: a gated button shows only after its query answered with a match; while the
23
+ // query loads or after it failed it stays hidden.
24
+ export function useGatedToolbarActions(toolbarActions, resolved) {
25
+ const [results, setResults] = useState(new Map());
26
+ const report = useCallback((query, result) => {
27
+ setResults((prev) => new Map(prev).set(query, result));
28
+ }, []);
29
+ const forget = useCallback((query) => {
30
+ setResults((prev) => {
31
+ if (!prev.has(query))
32
+ return prev;
33
+ const next = new Map(prev);
34
+ next.delete(query);
35
+ return next;
36
+ });
37
+ }, []);
38
+ const gates = useMemo(() => gatesById(toolbarActions), [toolbarActions]);
39
+ const distinctQueries = useMemo(() => [...new Set([...gates.values()].map((gate) => gate.query))], [gates]);
40
+ const actions = useMemo(() => {
41
+ if (gates.size === 0)
42
+ return resolved;
43
+ const visible = resolved?.filter((action) => {
44
+ const gate = gates.get(action.id);
45
+ return gate === undefined || evalVisibleWhen(gate, results.get(gate.query)) === "visible";
46
+ });
47
+ return visible !== undefined && visible.length > 0 ? visible : undefined;
48
+ }, [gates, resolved, results]);
49
+ const gateQueries = distinctQueries.map((query) => (_jsx(GateQuery, { query: query, onResult: report, onForget: forget }, query)));
50
+ return { actions, gateQueries };
51
+ }
@@ -25,6 +25,7 @@ export declare function useProjectionListActions(input: ProjectionListActionsInp
25
25
  runNavigate: (action: RowActionNavigate, row: ListRowViewModel) => void;
26
26
  rowActions: readonly import("../primitives.tsx").DataTableRowAction[] | undefined;
27
27
  toolbarActions: readonly import("../components/render-list-toolbar-action.tsx").ToolbarActionButton[] | undefined;
28
+ toolbarGateQueries: import("react").ReactNode;
28
29
  bulk: import("../data-table-bulk-types.ts").DataTableBulk | undefined;
29
30
  emptyContent: import("../components/list-empty-state.tsx").ListEmptyStateContent | undefined;
30
31
  };
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
2
2
  import { useListBulk } from "./list-bulk-actions.js";
3
3
  import { useListEmptyContent } from "./list-empty-content.js";
4
4
  import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, runProjectionRowNavigate, } from "./row-actions.js";
5
+ import { useGatedToolbarActions } from "./use-gated-toolbar-actions.js";
5
6
  export function useProjectionListActions(input) {
6
7
  const { screen, effectiveTranslate, dispatcher, clientActions, nav, host, refreshRows, openDrawer, appFeatures, userRoles, defaultEditScreen, } = input;
7
8
  // Entity targets: see EntityListBody.runNavigate for why the resolution happens
@@ -35,7 +36,7 @@ export function useProjectionListActions(input) {
35
36
  defaultEditRowAction,
36
37
  host,
37
38
  ]);
38
- const toolbarActions = useMemo(() => buildProjectionToolbarActions({
39
+ const resolvedToolbarActions = useMemo(() => buildProjectionToolbarActions({
39
40
  toolbarActions: screen.toolbarActions,
40
41
  translate: effectiveTranslate,
41
42
  dispatcher,
@@ -55,6 +56,7 @@ export function useProjectionListActions(input) {
55
56
  appFeatures,
56
57
  userRoles,
57
58
  ]);
59
+ const { actions: toolbarActions, gateQueries: toolbarGateQueries } = useGatedToolbarActions(screen.toolbarActions, resolvedToolbarActions);
58
60
  const bulk = useListBulk({
59
61
  bulkActions: screen.bulkActions,
60
62
  rowActions: screen.rowActions,
@@ -69,5 +71,12 @@ export function useProjectionListActions(input) {
69
71
  openDrawer,
70
72
  onWriteSuccess: refreshRows,
71
73
  });
72
- return { runNavigate, rowActions, toolbarActions, bulk, emptyContent };
74
+ return {
75
+ runNavigate,
76
+ rowActions,
77
+ toolbarActions,
78
+ toolbarGateQueries,
79
+ bulk,
80
+ emptyContent,
81
+ };
73
82
  }
@@ -1,5 +1,9 @@
1
- import type { DashboardPanelVisibility } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { DashboardPanelVisibility, ScreenFallbackReason } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { UseQueryResult } from "../hooks/use-query.ts";
3
3
  export type VisibleWhenVerdict = "loading" | "visible" | "hidden" | "error";
4
4
  export declare function evalVisibleWhen(visibleWhen: DashboardPanelVisibility, result: UseQueryResult<Readonly<Record<string, unknown>>> | undefined): VisibleWhenVerdict;
5
5
  export declare function evalVisibleWhenAfterShown(visibleWhen: DashboardPanelVisibility, result: UseQueryResult<Readonly<Record<string, unknown>>> | undefined, wasShown: boolean): VisibleWhenVerdict;
6
+ export type FailedVerdict = Exclude<VisibleWhenVerdict, "visible" | "loading">;
7
+ export declare function screenFallbackReason(verdict: FailedVerdict, error: {
8
+ readonly httpStatus: number;
9
+ } | null): ScreenFallbackReason;
@@ -16,3 +16,14 @@ export function evalVisibleWhenAfterShown(visibleWhen, result, wasShown) {
16
16
  const lastGoodDataMatches = result?.data?.[visibleWhen.field] === visibleWhen.eq;
17
17
  return verdict === "error" && wasShown && lastGoodDataMatches ? "visible" : verdict;
18
18
  }
19
+ // Splits a closed gate by cause so a screen's `fallback` map can answer "not allowed" and
20
+ // "gone" differently from "condition unmet" and "something broke".
21
+ export function screenFallbackReason(verdict, error) {
22
+ if (verdict === "hidden")
23
+ return "hidden";
24
+ if (error?.httpStatus === 403)
25
+ return "forbidden";
26
+ if (error?.httpStatus === 404)
27
+ return "notFound";
28
+ return "error";
29
+ }
@@ -21,11 +21,13 @@ export type DataTableViewOptions = {
21
21
  /** Present: pinning is on and the view menu has a pin section. Unpinned columns have no entry. */
22
22
  readonly pins?: DataTableColumnPins;
23
23
  readonly onPinsChange?: (next: DataTableColumnPins) => void;
24
- /** Present: resizing is on; the table switches to table-layout fixed. Every column has an entry (px). */
24
+ /** Present: resizing is on. Only columns with a start width or a user-chosen width have an entry (px);
25
+ * while none of the visible columns has one, the table keeps the auto layout and sizes by content. */
25
26
  readonly widths?: DataTableColumnWidths;
26
27
  /** Present together with `widths`; every column has an entry (px). */
27
28
  readonly minWidths?: DataTableColumnWidths;
28
- readonly onWidthChange?: (field: string, width: number) => void;
29
+ /** `others` holds the displayed widths of the columns that had no entry yet, so they keep their size once the layout turns fixed. */
30
+ readonly onWidthChange?: (field: string, width: number, others?: DataTableColumnWidths) => void;
29
31
  readonly keyboardNavigation?: boolean;
30
32
  /** Present: the view menu has a "group by" section. */
31
33
  readonly groupBy?: DataTableGroupByChoice;
@@ -12,8 +12,10 @@ type LayoutSchemaColumn = {
12
12
  export declare function defaultPins(schema: readonly LayoutSchemaColumn[]): DataTableColumnPins;
13
13
  export declare function minWidthsOf(schema: readonly LayoutSchemaColumn[]): DataTableColumnWidths;
14
14
  export declare function clampWidth(width: number, min: number): number;
15
+ /** Only columns with a schema `width`: the others are sized by their content until the user resizes. */
15
16
  export declare function defaultWidths(schema: readonly LayoutSchemaColumn[], minWidths: DataTableColumnWidths): DataTableColumnWidths;
16
- /** A saved width wins per field; a column the saved view has never seen keeps its default. */
17
+ /** A saved width wins per field; fields the schema no longer has are dropped. */
17
18
  export declare function mergeSavedWidths(defaults: DataTableColumnWidths, saved: Readonly<Record<string, number>>, minWidths: DataTableColumnWidths): DataTableColumnWidths;
19
+ export declare function clampWidths(widths: DataTableColumnWidths, minWidths: DataTableColumnWidths): DataTableColumnWidths;
18
20
  export declare function sameRecord<T>(a: Readonly<Record<string, T>>, b: Readonly<Record<string, T>>): boolean;
19
21
  export {};
@@ -16,20 +16,33 @@ export function minWidthsOf(schema) {
16
16
  export function clampWidth(width, min) {
17
17
  return Math.min(MAX_COLUMN_WIDTH_PX, Math.max(min, Math.round(width)));
18
18
  }
19
+ /** Only columns with a schema `width`: the others are sized by their content until the user resizes. */
19
20
  export function defaultWidths(schema, minWidths) {
20
- return Object.fromEntries(schema.map((column) => [
21
- column.field,
22
- clampWidth(column.width ?? DEFAULT_COLUMN_WIDTH_PX, minWidths[column.field] ?? DEFAULT_MIN_COLUMN_WIDTH_PX),
23
- ]));
21
+ return Object.fromEntries(schema.flatMap((column) => column.width === undefined
22
+ ? []
23
+ : [
24
+ [
25
+ column.field,
26
+ clampWidth(column.width, minWidths[column.field] ?? DEFAULT_MIN_COLUMN_WIDTH_PX),
27
+ ],
28
+ ]));
24
29
  }
25
- /** A saved width wins per field; a column the saved view has never seen keeps its default. */
30
+ /** A saved width wins per field; fields the schema no longer has are dropped. */
26
31
  export function mergeSavedWidths(defaults, saved, minWidths) {
27
- return Object.fromEntries(Object.entries(defaults).map(([field, fallback]) => {
32
+ return Object.fromEntries(Object.keys(minWidths).flatMap((field) => {
28
33
  const savedWidth = saved[field];
29
34
  const min = minWidths[field] ?? DEFAULT_MIN_COLUMN_WIDTH_PX;
30
- return [field, savedWidth === undefined ? fallback : clampWidth(savedWidth, min)];
35
+ if (savedWidth !== undefined)
36
+ return [[field, clampWidth(savedWidth, min)]];
37
+ const fallback = defaults[field];
38
+ return fallback === undefined ? [] : [[field, fallback]];
31
39
  }));
32
40
  }
41
+ export function clampWidths(widths, minWidths) {
42
+ return Object.fromEntries(Object.entries(widths).flatMap(([field, width]) => field in minWidths
43
+ ? [[field, clampWidth(width, minWidths[field] ?? DEFAULT_MIN_COLUMN_WIDTH_PX)]]
44
+ : []));
45
+ }
33
46
  export function sameRecord(a, b) {
34
47
  const keys = Object.keys(a);
35
48
  return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
@@ -21,7 +21,7 @@ export type ListViewState = {
21
21
  /** Undefined unless `table.resizing` is on. */
22
22
  readonly widths: DataTableColumnWidths | undefined;
23
23
  readonly minWidths: DataTableColumnWidths | undefined;
24
- readonly setWidth: (field: string, width: number) => void;
24
+ readonly setWidth: (field: string, width: number, others?: DataTableColumnWidths) => void;
25
25
  /** Active group field, "" = not grouped. */
26
26
  readonly groupBy: string;
27
27
  /** Undefined unless `table.grouping.userChoice` is on. */
@@ -1,6 +1,6 @@
1
1
  import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { useCallback, useMemo, useState } from "react";
3
- import { clampWidth, DEFAULT_MIN_COLUMN_WIDTH_PX, defaultPins, defaultWidths, mergeSavedWidths, minWidthsOf, sameRecord, } from "./list-column-layout.js";
3
+ import { clampWidth, clampWidths, DEFAULT_MIN_COLUMN_WIDTH_PX, defaultPins, defaultWidths, mergeSavedWidths, minWidthsOf, sameRecord, } from "./list-column-layout.js";
4
4
  import { sanitizeStoredListState } from "./list-state-schema.js";
5
5
  export function listViewSchemaColumns(columns) {
6
6
  return columns.map((column) => {
@@ -124,9 +124,10 @@ export function useListViewState(args) {
124
124
  }, [commit, view]);
125
125
  const setDensity = useCallback((next) => commit({ ...view, density: next }), [commit, view]);
126
126
  const setPins = useCallback((next) => commit({ ...view, pins: next }), [commit, view]);
127
- const setWidth = useCallback((field, width) => commit({
127
+ const setWidth = useCallback((field, width, others = {}) => commit({
128
128
  ...view,
129
129
  widths: {
130
+ ...clampWidths(others, minWidths),
130
131
  ...view.widths,
131
132
  [field]: clampWidth(width, minWidths[field] ?? DEFAULT_MIN_COLUMN_WIDTH_PX),
132
133
  },
@@ -191,6 +191,8 @@ export const kumikoDefaultTranslations = {
191
191
  "kumiko.dashboard.progress.unlimited": "unlimited",
192
192
  "kumiko.dashboard.record.missing": "Open this page from a record to see its dashboard.",
193
193
  "kumiko.dashboard.time-range": "Time range",
194
+ "kumiko.screen.forbidden": "You do not have access to this page.",
195
+ "kumiko.screen.notFound": "This page could not be found.",
194
196
  "kumiko.screen.unavailable": "This page is not available right now.",
195
197
  "kumiko.dashboard.brush-end": "Window end",
196
198
  "kumiko.dashboard.brush-start": "Window start",
@@ -48,7 +48,7 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
48
48
  }
49
49
  function renderDashboardPanel(panel, featureName, screenId, screenParams, translate) {
50
50
  if (panel.kind === "screen") {
51
- return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: translate }, panel.id));
51
+ return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, recordId: screenParams.recordId, translate: translate }, panel.id));
52
52
  }
53
53
  const label = panel.kind === "custom" || panel.label === undefined ? undefined : translate(panel.label);
54
54
  return (_jsx(GatedGridCell, { panel: panel, screenParams: screenParams, label: label ?? panel.id, className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screenId, screenParams: screenParams, translate: translate }) }, panel.id));
@@ -1,8 +1,9 @@
1
1
  import type { DashboardScreenPanel } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { type ReactNode } from "react";
3
3
  import type { Translate } from "../../headless/index.ts";
4
- export declare function ScreenPanelTile({ panel, featureName, translate, }: {
4
+ export declare function ScreenPanelTile({ panel, featureName, recordId, translate, }: {
5
5
  readonly panel: DashboardScreenPanel;
6
6
  readonly featureName: string;
7
+ readonly recordId: string | undefined;
7
8
  readonly translate: Translate;
8
9
  }): ReactNode;
@@ -6,19 +6,21 @@ import { EmbeddedFormProvider } from "../primitives/index.js";
6
6
  import { SectionCard } from "../widgets/section-card.js";
7
7
  import { PanelError, WIDE_PANEL } from "./dashboard-shared.js";
8
8
  import { NO_GATE_PAYLOAD, VisibilityQueryContext, visibilityGate, } from "./dashboard-visibility.js";
9
- function EmbeddedScreenPanelTile({ panel, target, translate, }) {
10
- const screenBody = _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate });
9
+ function EmbeddedScreenPanelTile({ recordId, panel, target, translate, }) {
10
+ // Only a record screen takes the dashboard's bound record; other screens have no id to read.
11
+ const entityId = target.screenType === "projectionDetail" ? recordId : undefined;
12
+ const screenBody = (_jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate, ...(entityId !== undefined && { entityId }) }));
11
13
  const embedded = panel.chromeless === true ? (_jsx(EmbeddedFormProvider, { children: _jsx(EmbeddedScreenProvider, { children: screenBody }) })) : (screenBody);
12
14
  return (_jsx("div", { className: cn("min-w-0", WIDE_PANEL), "data-testid": `dashboard-panel-${panel.id}`, children: panel.label !== undefined && panel.chromeless !== true ? (_jsx(SectionCard, { title: translate(panel.label), children: embedded })) : (_jsxs(_Fragment, { children: [panel.label !== undefined && (_jsx("h3", { className: "mb-3 text-sm font-semibold text-foreground", children: translate(panel.label) })), embedded] })) }));
13
15
  }
14
16
  // Own component so the target lookup (a hook) only runs for panels that actually fall back.
15
- function FallbackScreenPanelTile({ panel, fallback, featureName, translate, }) {
17
+ function FallbackScreenPanelTile({ panel, fallback, featureName, recordId, translate, }) {
16
18
  const target = useEmbeddedScreen(featureName, fallback);
17
19
  if (target === undefined)
18
20
  return null;
19
- return _jsx(EmbeddedScreenPanelTile, { panel: panel, target: target, translate: translate });
21
+ return (_jsx(EmbeddedScreenPanelTile, { panel: panel, target: target, recordId: recordId, translate: translate }));
20
22
  }
21
- export function ScreenPanelTile({ panel, featureName, translate, }) {
23
+ export function ScreenPanelTile({ panel, featureName, recordId, translate, }) {
22
24
  const visibleWhen = panel.visibleWhen;
23
25
  const visibility = useContext(VisibilityQueryContext).get(visibleWhen !== undefined ? visibilityGate(visibleWhen.query, NO_GATE_PAYLOAD).key : "");
24
26
  const target = useEmbeddedScreen(featureName, panel.screen);
@@ -27,7 +29,7 @@ export function ScreenPanelTile({ panel, featureName, translate, }) {
27
29
  if (verdict === "loading")
28
30
  return null;
29
31
  if (verdict !== "visible" && panel.fallback !== undefined) {
30
- return (_jsx(FallbackScreenPanelTile, { panel: panel, fallback: panel.fallback, featureName: featureName, translate: translate }));
32
+ return (_jsx(FallbackScreenPanelTile, { panel: panel, fallback: panel.fallback, featureName: featureName, recordId: recordId, translate: translate }));
31
33
  }
32
34
  if (target === undefined)
33
35
  return null;
@@ -38,5 +40,5 @@ export function ScreenPanelTile({ panel, featureName, translate, }) {
38
40
  }
39
41
  if (verdict !== "visible")
40
42
  return null;
41
- return _jsx(EmbeddedScreenPanelTile, { panel: panel, target: target, translate: translate });
43
+ return (_jsx(EmbeddedScreenPanelTile, { panel: panel, target: target, recordId: recordId, translate: translate }));
42
44
  }
@@ -11,6 +11,8 @@ export type ScreenParams = {
11
11
  readonly rangeParams: Readonly<Record<string, unknown>>;
12
12
  /** `{ [payloadKey]: recordId }` of a record-bound dashboard, else empty. */
13
13
  readonly recordParams: Readonly<Record<string, unknown>>;
14
+ /** Id read from `record.param`; set whenever the dashboard renders its panels. */
15
+ readonly recordId?: string;
14
16
  };
15
17
  export declare function panelPayload(panel: DashboardPanelQueryOptions, screenParams: ScreenParams): Readonly<Record<string, unknown>>;
16
18
  type RefetchQuery = () => Promise<void>;
@@ -12,7 +12,12 @@ export function useDashboardScreenParams(screen, translate, nav) {
12
12
  const recordQueryName = recordBinding?.query;
13
13
  const { params: filterParams, picker } = useFilterParams(screen, !recordMissing);
14
14
  const { params: rangeParams, control: rangeControl } = useTimeRange(screen, translate);
15
- const screenParams = { filterParams, rangeParams, recordParams };
15
+ const screenParams = {
16
+ filterParams,
17
+ rangeParams,
18
+ recordParams,
19
+ ...(recordId !== "" && { recordId }),
20
+ };
16
21
  const { loadedAtMs, reportLoaded } = useLoadedAt(screenParams);
17
22
  return {
18
23
  recordBinding,
@@ -5,9 +5,12 @@ export type BreadcrumbCrumb = {
5
5
  readonly screenId?: string;
6
6
  };
7
7
  export declare function screenTitleKey(screenShortId: string): string;
8
+ export declare function screenCreateTitleKey(screenShortId: string): string;
8
9
  /** The short id of {screenId}'s parent list screen, resolved via the same
9
10
  * logic as `resolveDetailBreadcrumb` — `undefined` when nothing resolves
10
11
  * (unknown screen, or no explicit/heuristic parent). */
11
12
  export declare function resolveParentScreenId(screens: readonly ScreenDefinition[], screenId: string): string | undefined;
12
- export declare function resolveDetailBreadcrumb(screens: readonly ScreenDefinition[], detailScreenId: string, t: (key: string) => string): readonly BreadcrumbCrumb[] | undefined;
13
+ export declare function resolveDetailBreadcrumb(screens: readonly ScreenDefinition[], detailScreenId: string, t: (key: string) => string, options?: {
14
+ readonly creating?: boolean;
15
+ }): readonly BreadcrumbCrumb[] | undefined;
13
16
  export declare function activeNavLabel(nodes: readonly NavNode[], screenId: string, t: (key: string) => string): string | undefined;