@cosmicdrift/kumiko-renderer 0.393.0 → 0.395.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 (77) hide show
  1. package/dist/app/content-preview.js +1 -9
  2. package/dist/app/entity-list-screen.js +4 -2
  3. package/dist/app/kumiko-screen.js +4 -36
  4. package/dist/app/projection-list-body.js +1 -1
  5. package/dist/app/qn.d.ts +1 -6
  6. package/dist/app/qn.js +1 -12
  7. package/dist/app/screen-visibility-gate.d.ts +19 -0
  8. package/dist/app/screen-visibility-gate.js +60 -0
  9. package/dist/app/secrets-edit-body.js +5 -4
  10. package/dist/app/use-embedded-screen.d.ts +2 -0
  11. package/dist/app/use-embedded-screen.js +1 -1
  12. package/dist/app/use-gated-toolbar-actions.d.ts +7 -0
  13. package/dist/app/use-gated-toolbar-actions.js +51 -0
  14. package/dist/app/use-projection-list-actions.d.ts +1 -0
  15. package/dist/app/use-projection-list-actions.js +11 -2
  16. package/dist/app/visible-when.d.ts +5 -1
  17. package/dist/app/visible-when.js +11 -0
  18. package/dist/components/form-screen-layout.d.ts +2 -1
  19. package/dist/components/form-screen-layout.js +2 -2
  20. package/dist/components/render-edit-banners.js +1 -1
  21. package/dist/components/render-edit-layout-flags.d.ts +2 -1
  22. package/dist/components/render-edit-layout-flags.js +3 -2
  23. package/dist/components/render-edit-logic.d.ts +2 -1
  24. package/dist/components/render-edit-logic.js +14 -0
  25. package/dist/data-table-view-types.d.ts +4 -2
  26. package/dist/hooks/list-column-layout.d.ts +3 -1
  27. package/dist/hooks/list-column-layout.js +20 -7
  28. package/dist/hooks/use-list-view-state.d.ts +1 -1
  29. package/dist/hooks/use-list-view-state.js +3 -2
  30. package/dist/i18n-defaults.js +2 -0
  31. package/dist/web/app/client-plugin.d.ts +1 -1
  32. package/dist/web/app/dashboard-body.js +1 -1
  33. package/dist/web/app/dashboard-screen-tiles.d.ts +2 -1
  34. package/dist/web/app/dashboard-screen-tiles.js +9 -7
  35. package/dist/web/app/dashboard-shared.d.ts +2 -0
  36. package/dist/web/app/use-dashboard-screen-state.js +6 -1
  37. package/dist/web/index.d.ts +1 -1
  38. package/dist/web/index.js +1 -1
  39. package/dist/web/layout/shell-breadcrumb.d.ts +4 -1
  40. package/dist/web/layout/shell-breadcrumb.js +18 -3
  41. package/dist/web/layout/shell-header.js +4 -2
  42. package/dist/web/lib/upload-file-ref.d.ts +4 -1
  43. package/dist/web/lib/upload-file-ref.js +15 -3
  44. package/dist/web/primitives/action-menu.js +2 -2
  45. package/dist/web/primitives/calendar-popover.js +1 -1
  46. package/dist/web/primitives/combobox.js +1 -1
  47. package/dist/web/primitives/data-table/table-data-cell.js +27 -1
  48. package/dist/web/primitives/data-table/table-header-row.js +1 -1
  49. package/dist/web/primitives/data-table/table-view.js +2 -2
  50. package/dist/web/primitives/data-table/use-column-sizing.d.ts +4 -2
  51. package/dist/web/primitives/data-table/use-column-sizing.js +34 -11
  52. package/dist/web/primitives/data-table/view-menu.js +1 -1
  53. package/dist/web/primitives/data-table-cell.js +1 -1
  54. package/dist/web/primitives/dialog.js +1 -1
  55. package/dist/web/primitives/dropdown-menu.d.ts +1 -1
  56. package/dist/web/primitives/dropdown-menu.js +2 -2
  57. package/dist/web/primitives/file-upload.js +5 -2
  58. package/dist/web/primitives/files-dropzone.js +8 -2
  59. package/dist/web/primitives/lightbox.js +1 -1
  60. package/dist/web/primitives/modal-shell.js +1 -1
  61. package/dist/web/primitives/modal.js +1 -1
  62. package/dist/web/primitives/narrow-pinned-footer.js +1 -1
  63. package/dist/web/primitives/toast.js +2 -2
  64. package/dist/web/sse/live-events.js +0 -7
  65. package/dist/web/widgets/floating-panel-geometry.d.ts +32 -0
  66. package/dist/web/widgets/floating-panel-geometry.js +71 -0
  67. package/dist/web/widgets/floating-panel.d.ts +2 -6
  68. package/dist/web/widgets/floating-panel.js +11 -55
  69. package/dist/web/widgets/infinity-list.js +27 -14
  70. package/dist/web/widgets/plan-card.d.ts +2 -0
  71. package/dist/web/widgets/plan-card.js +5 -1
  72. package/package.json +6 -19
  73. package/src/changes.json +54 -0
  74. package/dist/web/app/rich-content-editor.d.ts +0 -3
  75. package/dist/web/app/rich-content-editor.js +0 -15
  76. package/dist/web/app/tiptap-editor.d.ts +0 -3
  77. package/dist/web/app/tiptap-editor.js +0 -120
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId } from "react";
3
- import { escapeHtmlAttr } from "../headless/index.js";
4
3
  import { useContentEditor } from "./content-editors.js";
5
4
  const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
6
5
  const noop = () => { };
@@ -12,15 +11,8 @@ export function substituteVariables(content, variables) {
12
11
  export function ContentPreview({ content, variables, contentFormat, }) {
13
12
  const Editor = useContentEditor(contentFormat);
14
13
  const id = useId();
15
- // "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
16
- // an example value substituted in raw could break the markup (`<`) or
17
- // render as an unescaped entity (`&`). "plain"/"markdown" content is text,
18
- // no escaping wanted there.
19
- const safeVariables = contentFormat === "rich"
20
- ? Object.fromEntries(Object.entries(variables).map(([name, value]) => [name, escapeHtmlAttr(value)]))
21
- : variables;
22
14
  // "document" keeps `{{x}}` as-is: substituting it into a button/link href would make the
23
15
  // schema drop that node; variable chips stay visible.
24
- const value = contentFormat === "document" ? content : substituteVariables(content, safeVariables);
16
+ const value = contentFormat === "document" ? content : substituteVariables(content, variables);
25
17
  return _jsx(Editor, { id: id, value: value, onChange: noop, variables: [], readOnly: true });
26
18
  }
@@ -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,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useState } from "react";
3
+ import { FormScreenLayout } from "../components/form-screen-layout.js";
3
4
  import { useDispatcher } from "../context/dispatcher-context.js";
4
5
  import { useUserRoles } from "../context/user-roles-context.js";
5
6
  import { useQuery } from "../hooks/use-query.js";
@@ -20,7 +21,7 @@ function groupSectionsByTitle(sections) {
20
21
  // unlike ConfigEditBody there is no server value to pre-fill a draft with —
21
22
  // every input starts at "" and stays that way unless the user types into it.
22
23
  export function SecretsEditBody({ screen, translate }) {
23
- const { Banner, Dialog, Form, Section, Field, Input, Button, Text, Grid } = usePrimitives();
24
+ const { Banner, Dialog, Section, Field, Input, Button, Text, Grid } = usePrimitives();
24
25
  const t = useTranslation();
25
26
  const effectiveTranslate = translate ?? t;
26
27
  const dispatcher = useDispatcher();
@@ -111,10 +112,10 @@ export function SecretsEditBody({ screen, translate }) {
111
112
  }))
112
113
  .filter((group) => group.fields.length > 0);
113
114
  const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
114
- return (_jsxs(_Fragment, { children: [_jsxs(Form, { onSubmit: () => {
115
+ return (_jsxs(_Fragment, { children: [_jsxs(FormScreenLayout, { onSubmit: () => {
115
116
  void handleSubmit();
116
- }, testId: "secrets-edit-form", width: "full", fillHeight: true, stickyActions: true, screenForm: true, unsavedCount: unsavedCount, ...(screen.description !== undefined && {
117
- subtitle: effectiveTranslate(screen.description),
117
+ }, testId: "secrets-edit-form", width: "full", unsavedCount: unsavedCount, ...(screen.description !== undefined && {
118
+ description: effectiveTranslate(screen.description),
118
119
  }), actions: _jsxs(_Fragment, { children: [unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: () => setDrafts({}), testId: "secrets-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: effectiveTranslate("kumiko.form.discard") })), _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting || unsavedCount === 0, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.form.saveChanges") })] }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), sectionGroups.map((section) => (_jsx(Section, { layout: "settings-list", title: effectiveTranslate(section.title), subtitle: effectiveTranslate("config.secrets.description"), children: _jsx(Grid, { columns: 1, list: true, children: section.fields.map((fieldId) => {
119
120
  const qualified = screen.secretKeys[fieldId];
120
121
  if (qualified === undefined)
@@ -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
+ }
@@ -7,7 +7,8 @@ export type FormScreenLayoutProps = {
7
7
  readonly secondaryActions?: ReactNode;
8
8
  readonly onSubmit?: () => void;
9
9
  readonly width?: FormWidth;
10
+ readonly unsavedCount?: number;
10
11
  readonly testId?: string;
11
12
  };
12
13
  /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
13
- export declare function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit, width, testId, }: FormScreenLayoutProps): ReactNode;
14
+ export declare function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit, width, unsavedCount, testId, }: FormScreenLayoutProps): ReactNode;
@@ -7,7 +7,7 @@ const FORM_SCREEN_LAYOUT_PROPS = {
7
7
  };
8
8
  const NOOP = () => { };
9
9
  /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
10
- export function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit = NOOP, width, testId, }) {
10
+ export function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit = NOOP, width, unsavedCount, testId, }) {
11
11
  const { Form } = usePrimitives();
12
- return (_jsx(Form, { onSubmit: onSubmit, ...FORM_SCREEN_LAYOUT_PROPS, ...(description !== undefined && { subtitle: description }), ...(actions !== undefined && { actions }), ...(secondaryActions !== undefined && { secondaryActions }), ...(width !== undefined && { width }), ...(testId !== undefined && { testId }), children: children }));
12
+ return (_jsx(Form, { onSubmit: onSubmit, ...FORM_SCREEN_LAYOUT_PROPS, ...(description !== undefined && { subtitle: description }), ...(actions !== undefined && { actions }), ...(secondaryActions !== undefined && { secondaryActions }), ...(width !== undefined && { width }), ...(unsavedCount !== undefined && { unsavedCount }), ...(testId !== undefined && { testId }), children: children }));
13
13
  }
@@ -52,7 +52,7 @@ export function renderFormErrorBanner({ engine: { props, state }, layout, }) {
52
52
  onReload();
53
53
  state.setFormError(null);
54
54
  state.setExtraDeleteIssues([]);
55
- }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), layout.rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), state.extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })));
55
+ }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), layout.rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), layout.unplacedIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-unplaced", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), state.extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })));
56
56
  }
57
57
  export function renderRootIssueBanners({ engine: { state }, layout, }) {
58
58
  const { Banner, Text } = state.primitives;
@@ -3,6 +3,7 @@ import type { FieldIssue, FormValues } from "../headless/index.ts";
3
3
  import type { RenderEditEngine } from "./render-edit-render-model.ts";
4
4
  export type LayoutFlags = {
5
5
  readonly rootIssues: readonly FieldIssue[];
6
+ readonly unplacedIssues: readonly FieldIssue[];
6
7
  readonly isScreenForm: boolean;
7
8
  readonly titleInShell: boolean;
8
9
  readonly menuPageHeaderActions: boolean;
@@ -25,5 +26,5 @@ export type FooterFlags = {
25
26
  readonly hasUnsavedInput: boolean;
26
27
  };
27
28
  export type FooterSlotComponent = ReturnType<typeof useExtensionSectionComponent>;
28
- export declare function deriveLayoutFlags<TValues extends FormValues, TCtx>({ props, state, form, }: RenderEditEngine<TValues, TCtx>): LayoutFlags;
29
+ export declare function deriveLayoutFlags<TValues extends FormValues, TCtx>({ props, state, form, sections, }: RenderEditEngine<TValues, TCtx>): LayoutFlags;
29
30
  export declare function deriveFooterFlags<TValues extends FormValues, TCtx>(engine: RenderEditEngine<TValues, TCtx>, layout: LayoutFlags, footerSlotComponent: FooterSlotComponent): FooterFlags;
@@ -1,6 +1,6 @@
1
1
  import { PROJECTION_DETAIL_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { shouldRenderActionsIconOnly } from "../primitives.js";
3
- import { ROOT_ISSUE_PATH, resolveExtensionEntityId } from "./render-edit-logic.js";
3
+ import { issuesWithoutRenderedField, ROOT_ISSUE_PATH, resolveExtensionEntityId, } from "./render-edit-logic.js";
4
4
  function hasSecondaryFormActions(props, menuPageHeaderActions, cancelInHeaderMenu) {
5
5
  const { onDelete, onCopyLink, actions, menuActions, onCancel } = props;
6
6
  const hasRecordActions = !menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined);
@@ -9,7 +9,7 @@ function hasSecondaryFormActions(props, menuPageHeaderActions, cancelInHeaderMen
9
9
  const hasCancel = onCancel !== undefined && !cancelInHeaderMenu;
10
10
  return hasRecordActions || hasCustomActions || hasMenuActions || hasCancel;
11
11
  }
12
- export function deriveLayoutFlags({ props, state, form, }) {
12
+ export function deriveLayoutFlags({ props, state, form, sections, }) {
13
13
  const { fillScreenHeight, hideSectionTitles, headerRegion, actions, onCancel } = props;
14
14
  const { PageHeader, ActionOverflowMenu } = state.primitives;
15
15
  // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
@@ -24,6 +24,7 @@ export function deriveLayoutFlags({ props, state, form, }) {
24
24
  const cancelInHeaderMenu = menuPageHeaderActions && state.isWizard && onCancel !== undefined;
25
25
  return {
26
26
  rootIssues,
27
+ unplacedIssues: issuesWithoutRenderedField(form.snapshot.errors, sections.filteredSections, new Set(Object.keys(props.formDerivation?.derive ?? {}))),
27
28
  isScreenForm,
28
29
  titleInShell,
29
30
  menuPageHeaderActions,
@@ -1,9 +1,10 @@
1
- import type { DispatcherError, EditFieldViewModel, EditSectionViewModel, SubmitResult } from "../headless/index.ts";
1
+ import type { DispatcherError, EditFieldViewModel, EditSectionViewModel, FieldIssue, SubmitResult } from "../headless/index.ts";
2
2
  export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
3
3
  export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
4
4
  export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
5
5
  export declare function issueRootField(path: string): string;
6
6
  export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[], derivedTargets?: ReadonlySet<string>): boolean;
7
+ export declare function issuesWithoutRenderedField(errors: Readonly<Record<string, readonly FieldIssue[]>>, sections: readonly EditSectionViewModel[], derivedTargets?: ReadonlySet<string>): readonly FieldIssue[];
7
8
  export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
8
9
  export declare function findFirstErroringSectionIndex(sections: readonly EditSectionViewModel[], issuePaths: readonly string[]): number | undefined;
9
10
  export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
@@ -42,6 +42,20 @@ export function hasIssueWithoutRenderedField(issuePaths, sections, derivedTarget
42
42
  const renderedFields = sections.flatMap((s) => s.kind === "fields" && s.visible ? s.fields : []);
43
43
  return hasIssueWithoutVisibleField(paths, renderedFields);
44
44
  }
45
+ // The server issues the banner has to carry itself because no rendered field shows them
46
+ // (an extension section's value such as a page body, `version`, a hidden field).
47
+ export function issuesWithoutRenderedField(errors, sections, derivedTargets = new Set()) {
48
+ const visibleFields = new Set(sections
49
+ .flatMap((s) => (s.kind === "fields" && s.visible ? s.fields : []))
50
+ .filter((f) => f.visible)
51
+ .map((f) => f.field));
52
+ return Object.entries(errors)
53
+ .filter(([path]) => {
54
+ const root = issueRootField(path);
55
+ return path !== ROOT_ISSUE_PATH && !derivedTargets.has(root) && !visibleFields.has(root);
56
+ })
57
+ .flatMap(([, issues]) => issues);
58
+ }
45
59
  export function hasIssueWithoutVisibleField(issuePaths, fields) {
46
60
  if (issuePaths.length === 0)
47
61
  return true;
@@ -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 {};