@cosmicdrift/kumiko-renderer 0.388.0 → 0.389.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 (161) hide show
  1. package/dist/app/base-path-context.d.ts +9 -0
  2. package/dist/app/base-path-context.js +13 -0
  3. package/dist/app/base-path.d.ts +3 -0
  4. package/dist/app/base-path.js +27 -0
  5. package/dist/app/config-edit-body.d.ts +1 -1
  6. package/dist/app/config-edit-body.js +15 -6
  7. package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
  8. package/dist/app/config-edit-without-secret-fields.js +28 -0
  9. package/dist/app/entity-list-facet-specs.d.ts +3 -0
  10. package/dist/app/entity-list-facet-specs.js +65 -0
  11. package/dist/app/entity-list-screen.js +74 -206
  12. package/dist/app/kumiko-screen.js +6 -14
  13. package/dist/app/list-bulk-actions.d.ts +18 -0
  14. package/dist/app/list-bulk-actions.js +54 -0
  15. package/dist/app/locked-screen-header.d.ts +9 -0
  16. package/dist/app/locked-screen-header.js +33 -0
  17. package/dist/app/projection-list-body.js +51 -46
  18. package/dist/app/row-actions.d.ts +3 -0
  19. package/dist/app/row-actions.js +10 -5
  20. package/dist/app/screen-shared.js +9 -2
  21. package/dist/app/use-entity-range-facets.d.ts +24 -0
  22. package/dist/app/use-entity-range-facets.js +64 -0
  23. package/dist/app/use-projection-date-range.d.ts +12 -0
  24. package/dist/app/use-projection-date-range.js +21 -0
  25. package/dist/app/use-screen-list-rows.d.ts +37 -0
  26. package/dist/app/use-screen-list-rows.js +104 -0
  27. package/dist/app/use-screen-list-table.d.ts +24 -0
  28. package/dist/app/use-screen-list-table.js +46 -0
  29. package/dist/components/list-group-page-bridges.d.ts +3 -0
  30. package/dist/components/list-group-page-bridges.js +31 -0
  31. package/dist/components/list-state-store.d.ts +16 -0
  32. package/dist/components/list-state-store.js +31 -0
  33. package/dist/components/related-list-section.js +37 -89
  34. package/dist/components/render-field.js +6 -5
  35. package/dist/components/render-list-toolbar-action.d.ts +27 -0
  36. package/dist/components/render-list-toolbar-action.js +40 -0
  37. package/dist/components/render-list.d.ts +13 -29
  38. package/dist/components/render-list.js +42 -50
  39. package/dist/current-user.d.ts +2 -0
  40. package/dist/data-table-aggregate-types.d.ts +47 -0
  41. package/dist/data-table-aggregate-types.js +1 -0
  42. package/dist/data-table-bulk-types.d.ts +27 -0
  43. package/dist/data-table-bulk-types.js +1 -0
  44. package/dist/data-table-facet-types.d.ts +36 -0
  45. package/dist/data-table-facet-types.js +1 -0
  46. package/dist/data-table-view-types.d.ts +73 -0
  47. package/dist/data-table-view-types.js +1 -0
  48. package/dist/headless/view-model/edit.js +3 -1
  49. package/dist/headless/view-model/types.d.ts +4 -2
  50. package/dist/hooks/list-column-layout.d.ts +19 -0
  51. package/dist/hooks/list-column-layout.js +36 -0
  52. package/dist/hooks/list-group-pages.d.ts +26 -0
  53. package/dist/hooks/list-group-pages.js +37 -0
  54. package/dist/hooks/list-reset.d.ts +8 -0
  55. package/dist/hooks/list-reset.js +15 -0
  56. package/dist/hooks/list-server-groups.d.ts +39 -0
  57. package/dist/hooks/list-server-groups.js +84 -0
  58. package/dist/hooks/list-state-schema.d.ts +28 -0
  59. package/dist/hooks/list-state-schema.js +76 -0
  60. package/dist/hooks/use-bulk-run.d.ts +41 -0
  61. package/dist/hooks/use-bulk-run.js +65 -0
  62. package/dist/hooks/use-group-load-state.d.ts +11 -0
  63. package/dist/hooks/use-group-load-state.js +31 -0
  64. package/dist/hooks/use-list-aggregates.d.ts +38 -0
  65. package/dist/hooks/use-list-aggregates.js +107 -0
  66. package/dist/hooks/use-list-table.d.ts +38 -0
  67. package/dist/hooks/use-list-table.js +91 -0
  68. package/dist/hooks/use-list-view-state.d.ts +43 -0
  69. package/dist/hooks/use-list-view-state.js +161 -0
  70. package/dist/hooks/use-related-list-rows.d.ts +27 -0
  71. package/dist/hooks/use-related-list-rows.js +81 -0
  72. package/dist/hooks/use-related-list-state.d.ts +15 -0
  73. package/dist/hooks/use-related-list-state.js +31 -0
  74. package/dist/hooks/use-remembered-list-params.d.ts +13 -0
  75. package/dist/hooks/use-remembered-list-params.js +53 -0
  76. package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
  77. package/dist/hooks/use-remembered-local-list-state.js +63 -0
  78. package/dist/i18n-defaults.js +53 -0
  79. package/dist/index.d.ts +3 -1
  80. package/dist/index.js +2 -0
  81. package/dist/primitives.d.ts +11 -47
  82. package/dist/web/app/create-app.d.ts +5 -0
  83. package/dist/web/app/create-app.js +7 -4
  84. package/dist/web/app/history-leave-guard.d.ts +6 -0
  85. package/dist/web/app/history-leave-guard.js +115 -0
  86. package/dist/web/app/list-state-store-boot.d.ts +6 -0
  87. package/dist/web/app/list-state-store-boot.js +15 -0
  88. package/dist/web/app/nav.js +7 -54
  89. package/dist/web/app/session-list-state-store.d.ts +15 -0
  90. package/dist/web/app/session-list-state-store.js +117 -0
  91. package/dist/web/index.d.ts +1 -0
  92. package/dist/web/index.js +1 -0
  93. package/dist/web/lib/export-xlsx-request.d.ts +11 -0
  94. package/dist/web/lib/export-xlsx-request.js +46 -0
  95. package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
  96. package/dist/web/primitives/data-table/actions-column.js +34 -0
  97. package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
  98. package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
  99. package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
  100. package/dist/web/primitives/data-table/card-sort-select.js +40 -0
  101. package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
  102. package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
  103. package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
  104. package/dist/web/primitives/data-table/column-picker-section.js +35 -0
  105. package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
  106. package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
  107. package/dist/web/primitives/data-table/density.d.ts +14 -0
  108. package/dist/web/primitives/data-table/density.js +20 -0
  109. package/dist/web/primitives/data-table/export-button.d.ts +5 -0
  110. package/dist/web/primitives/data-table/export-button.js +37 -0
  111. package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
  112. package/dist/web/primitives/data-table/facet-cluster.js +37 -0
  113. package/dist/web/primitives/data-table/group-band.d.ts +28 -0
  114. package/dist/web/primitives/data-table/group-band.js +53 -0
  115. package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
  116. package/dist/web/primitives/data-table/measure-value.js +28 -0
  117. package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
  118. package/dist/web/primitives/data-table/pin-section.js +20 -0
  119. package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
  120. package/dist/web/primitives/data-table/reset-all-button.js +8 -0
  121. package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
  122. package/dist/web/primitives/data-table/server-group-header.js +51 -0
  123. package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
  124. package/dist/web/primitives/data-table/sortable-header.js +41 -0
  125. package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
  126. package/dist/web/primitives/data-table/table-chrome.js +30 -0
  127. package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
  128. package/dist/web/primitives/data-table/table-footer.js +20 -0
  129. package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
  130. package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
  131. package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
  132. package/dist/web/primitives/data-table/totals-footer.js +48 -0
  133. package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
  134. package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
  135. package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
  136. package/dist/web/primitives/data-table/use-column-pins.js +117 -0
  137. package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
  138. package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
  139. package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
  140. package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
  141. package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
  142. package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
  143. package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
  144. package/dist/web/primitives/data-table/use-row-selection.js +48 -0
  145. package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
  146. package/dist/web/primitives/data-table/use-view-columns.js +17 -0
  147. package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
  148. package/dist/web/primitives/data-table/view-menu.js +45 -0
  149. package/dist/web/primitives/data-table-cell.d.ts +0 -22
  150. package/dist/web/primitives/data-table-cell.js +1 -58
  151. package/dist/web/primitives/data-table.d.ts +1 -1
  152. package/dist/web/primitives/data-table.js +122 -144
  153. package/dist/web/primitives/file-value-list.d.ts +1 -1
  154. package/dist/web/primitives/file-value-list.js +5 -5
  155. package/dist/web/primitives/number-range-facet.d.ts +7 -0
  156. package/dist/web/primitives/number-range-facet.js +33 -0
  157. package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
  158. package/dist/web/widgets/bounded-load-cache.js +48 -26
  159. package/dist/web/widgets/pdf-preview.js +9 -4
  160. package/package.json +5 -5
  161. package/src/changes.json +109 -0
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ /** Provides the URL prefix the app is mounted under to components outside the NavProvider
3
+ * (auth gates and pages). `createKumikoApp({ basePath })` mounts it. */
4
+ export declare function BasePathProvider({ basePath, children, }: {
5
+ readonly basePath: string | undefined;
6
+ readonly children: ReactNode;
7
+ }): ReactNode;
8
+ /** Normalized base path ("" when the app is served from the root). */
9
+ export declare function useBasePath(): string;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ import { normalizeBasePath } from "./base-path.js";
4
+ const BasePathContext = createContext("");
5
+ /** Provides the URL prefix the app is mounted under to components outside the NavProvider
6
+ * (auth gates and pages). `createKumikoApp({ basePath })` mounts it. */
7
+ export function BasePathProvider({ basePath, children, }) {
8
+ return (_jsx(BasePathContext.Provider, { value: normalizeBasePath(basePath), children: children }));
9
+ }
10
+ /** Normalized base path ("" when the app is served from the root). */
11
+ export function useBasePath() {
12
+ return useContext(BasePathContext);
13
+ }
@@ -0,0 +1,3 @@
1
+ export declare function normalizeBasePath(raw: string | undefined): string;
2
+ export declare function stripBasePath(path: string, basePath: string): string | undefined;
3
+ export declare function prependBasePath(path: string, basePath: string): string;
@@ -0,0 +1,27 @@
1
+ // basePath mounts an app under a URL prefix (for example, `/admin`). Pure helpers shared by the
2
+ // browser nav adapter and the auth pages, which sit outside the NavProvider.
3
+ export function normalizeBasePath(raw) {
4
+ if (raw === undefined || raw === "" || raw === "/")
5
+ return "";
6
+ const withLeading = raw.startsWith("/") ? raw : `/${raw}`;
7
+ return withLeading.endsWith("/") ? withLeading.slice(0, -1) : withLeading;
8
+ }
9
+ // Returns the in-app (basePath-relative) path when `path` is under basePath; otherwise
10
+ // `undefined`. The segment boundary is strict: "/administrator" does not match "/admin".
11
+ export function stripBasePath(path, basePath) {
12
+ if (basePath === "")
13
+ return path;
14
+ if (path === basePath)
15
+ return "/";
16
+ if (path.startsWith(`${basePath}/`))
17
+ return path.slice(basePath.length);
18
+ return undefined;
19
+ }
20
+ export function prependBasePath(path, basePath) {
21
+ if (basePath === "")
22
+ return path;
23
+ // An in-app path is absolute: "/" or "/screen-id" or "/screen-id/entity-id". "/" becomes basePath.
24
+ if (path === "/" || path === "")
25
+ return basePath;
26
+ return `${basePath}${path}`;
27
+ }
@@ -2,7 +2,7 @@ import type { ConfigEditScreenDefinition } from "@cosmicdrift/kumiko-framework/u
2
2
  import { type ReactNode } from "react";
3
3
  import type { Translate } from "../headless/index.ts";
4
4
  import type { FeatureSchema } from "./feature-schema.ts";
5
- export declare function ConfigEditBody({ schema, screen, translate, }: {
5
+ export declare function ConfigEditBody({ schema, screen: declaredScreen, translate, }: {
6
6
  readonly schema: FeatureSchema;
7
7
  readonly screen: ConfigEditScreenDefinition;
8
8
  readonly translate?: Translate;
@@ -11,6 +11,7 @@ import { useTranslation } from "../i18n.js";
11
11
  import { usePrimitives } from "../primitives.js";
12
12
  import { screenFillsHeight } from "../screen-fills-height.js";
13
13
  import { buildConfigEditSchema, synthesizeConfigEditEntity, synthesizeConfigEditScreen, } from "./config-edit-shim.js";
14
+ import { configEditWithoutSecretFields } from "./config-edit-without-secret-fields.js";
14
15
  import { buildInitialValues } from "./entity-edit-prefill.js";
15
16
  import { layoutEditFields } from "./layout-fields.js";
16
17
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
@@ -89,7 +90,7 @@ function isEmptyNumericFormValue(value) {
89
90
  return true;
90
91
  return typeof value === "number" && !Number.isFinite(value);
91
92
  }
92
- export function ConfigEditBody({ schema, screen, translate, }) {
93
+ export function ConfigEditBody({ schema, screen: declaredScreen, translate, }) {
93
94
  const { ConfigCascadeView } = usePrimitives();
94
95
  const t = useTranslation();
95
96
  const effectiveTranslate = translate ?? t;
@@ -98,13 +99,17 @@ export function ConfigEditBody({ schema, screen, translate, }) {
98
99
  // We map them via screen.configKeys from short to qualified name onto our
99
100
  // form field values.
100
101
  const valuesQuery = useQuery("config:query:values", {
101
- scope: screen.scope,
102
+ scope: declaredScreen.scope,
102
103
  });
103
104
  const cascadeQuery = useQuery("config:query:cascade", {
104
- scope: screen.scope,
105
+ scope: declaredScreen.scope,
105
106
  });
106
- const secretKeys = screen.secretKeys;
107
- const secretsQuery = useQuery("secrets:query:list", {}, { enabled: secretKeys !== undefined });
107
+ const secretsQuery = useQuery("secrets:query:list", {}, { enabled: declaredScreen.secretKeys !== undefined });
108
+ // The form screen's access is the union of config and secrets access, so a viewer may write
109
+ // the config keys without being allowed to list secrets: that must not take the form down.
110
+ const secretsDenied = secretsQuery.error?.code === "access_denied";
111
+ const screen = useMemo(() => (secretsDenied ? configEditWithoutSecretFields(declaredScreen) : declaredScreen), [secretsDenied, declaredScreen]);
112
+ const secretKeys = secretsDenied ? undefined : declaredScreen.secretKeys;
108
113
  const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
109
114
  const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
110
115
  const boundsSchema = useMemo(() => buildConfigEditSchema(screen.fields), [screen.fields]);
@@ -304,7 +309,11 @@ export function ConfigEditBody({ schema, screen, translate, }) {
304
309
  ]);
305
310
  if (valuesQuery.loading && valuesQuery.data === null)
306
311
  return _jsx(QueryLoadingBanner, {});
307
- const failedQuery = valuesQuery.error ? valuesQuery : secretsQuery.error ? secretsQuery : null;
312
+ const failedQuery = valuesQuery.error
313
+ ? valuesQuery
314
+ : secretsQuery.error && !secretsDenied
315
+ ? secretsQuery
316
+ : null;
308
317
  if (failedQuery?.error) {
309
318
  return (_jsx(QueryErrorBanner, { error: failedQuery.error, translate: effectiveTranslate, onRetry: failedQuery.refetch }));
310
319
  }
@@ -0,0 +1,2 @@
1
+ import type { ConfigEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ export declare function configEditWithoutSecretFields(screen: ConfigEditScreenDefinition): ConfigEditScreenDefinition;
@@ -0,0 +1,28 @@
1
+ import { isFieldsEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ // A merged provider form carries the secret-backed fields of a secrets feature the viewer may
3
+ // not read. Without the secrets list those fields cannot show or save a state, so the form
4
+ // drops them and stays usable for the config keys.
5
+ export function configEditWithoutSecretFields(screen) {
6
+ const secretFieldNames = new Set(Object.keys(screen.secretKeys ?? {}));
7
+ if (secretFieldNames.size === 0)
8
+ return screen;
9
+ const keepSpec = (spec) => !secretFieldNames.has(normalizeEditField(spec).field);
10
+ const fields = Object.fromEntries(Object.entries(screen.fields).filter(([name]) => !secretFieldNames.has(name)));
11
+ const sections = screen.layout.sections.flatMap((section) => {
12
+ if (!isFieldsEditSection(section))
13
+ return [section];
14
+ if ("groups" in section) {
15
+ const groups = section.groups
16
+ .map((group) => ({ ...group, fields: group.fields.filter(keepSpec) }))
17
+ .filter((group) => group.fields.length > 0);
18
+ return groups.length === 0 ? [] : [{ ...section, groups }];
19
+ }
20
+ const kept = section.fields.filter(keepSpec);
21
+ return kept.length === 0 ? [] : [{ ...section, fields: kept }];
22
+ });
23
+ return {
24
+ ...screen,
25
+ fields,
26
+ layout: { ...screen.layout, sections },
27
+ };
28
+ }
@@ -0,0 +1,3 @@
1
+ import type { Translate } from "../headless/index.ts";
2
+ import type { ResolvedFacetSpec } from "./list-facets.ts";
3
+ export declare function resolveEntityFacetSpecs(fields: Readonly<Record<string, unknown>>, featureName: string, entityName: string, translate: Translate): ResolvedFacetSpec[];
@@ -0,0 +1,65 @@
1
+ import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
3
+ import { fieldLabelKey, fieldOptionLabelKey } from "../headless/index.js";
4
+ // entityList adapter — one DataTableFacet per filterable select/boolean
5
+ // entity field, labels via the standard field/option i18n convention.
6
+ export function resolveEntityFacetSpecs(fields, featureName, entityName, translate) {
7
+ const out = [];
8
+ for (const [field, rawDef] of Object.entries(fields)) {
9
+ // entity.fields is weakly typed at the renderer layer (deserialized from the schema).
10
+ const def = {
11
+ type: propertyOf(rawDef, "type"),
12
+ filterable: propertyOf(rawDef, "filterable"),
13
+ options: propertyOf(rawDef, "options"),
14
+ entity: propertyOf(rawDef, "entity"),
15
+ labelField: propertyOf(rawDef, "labelField"),
16
+ };
17
+ if (def.filterable !== true)
18
+ continue;
19
+ const label = translate(fieldLabelKey(featureName, entityName, field));
20
+ if (def.type === "select" && Array.isArray(def.options)) {
21
+ const optionValues = def.options.filter((value) => typeof value === "string");
22
+ out.push({
23
+ field,
24
+ type: "select",
25
+ label,
26
+ options: optionValues.map((value) => ({
27
+ value,
28
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
29
+ })),
30
+ });
31
+ }
32
+ else if (def.type === "boolean") {
33
+ out.push({
34
+ field,
35
+ type: "boolean",
36
+ label,
37
+ options: [
38
+ {
39
+ value: "true",
40
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
41
+ },
42
+ {
43
+ value: "false",
44
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
45
+ },
46
+ ],
47
+ });
48
+ }
49
+ else if (def.type === "reference" && typeof def.entity === "string") {
50
+ const target = parseRefTarget(def.entity, featureName);
51
+ out.push({
52
+ field,
53
+ type: "reference",
54
+ label,
55
+ options: [],
56
+ reference: {
57
+ refEntity: target.entityName,
58
+ refFeature: target.featureName,
59
+ labelField: typeof def.labelField === "string" ? def.labelField : "id",
60
+ },
61
+ });
62
+ }
63
+ }
64
+ return out;
65
+ }
@@ -1,16 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
4
4
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ import { ListGroupPageBridges } from "../components/list-group-page-bridges.js";
6
7
  import { RenderList } from "../components/render-list.js";
7
8
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
8
9
  import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
9
10
  import { useUserRoles } from "../context/user-roles-context.js";
10
11
  import { rendererWarn } from "../dev-console.js";
11
- import { computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, } from "../headless/index.js";
12
+ import { computeRelatedListSectionViewModel } from "../headless/index.js";
12
13
  import { useListUrlState } from "../hooks/use-list-url-state.js";
13
- import { useQuery } from "../hooks/use-query.js";
14
14
  import { useTranslation } from "../i18n.js";
15
15
  import { usePrimitives } from "../primitives.js";
16
16
  import { screenFillsHeight } from "../screen-fills-height.js";
@@ -18,73 +18,21 @@ import { DrawerHost, useDrawerAction } from "./action-form-body.js";
18
18
  import { useAppFeatures } from "./app-features-context.js";
19
19
  import { useClientActionRuntime } from "./client-actions.js";
20
20
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
21
+ import { resolveEntityFacetSpecs } from "./entity-list-facet-specs.js";
21
22
  import { FacetCountBridges } from "./facet-count-bridge.js";
23
+ import { useListBulk } from "./list-bulk-actions.js";
22
24
  import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
23
25
  import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
24
26
  import { useNav } from "./nav.js";
25
27
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
26
28
  import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
27
29
  import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
28
- import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, evalRowExtractor, findRowClickAction, navigateActionSearchParams, refetchAfterWrite, runDrawerRowAction, } from "./row-actions.js";
30
+ import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, buildWriteHandlerRowAction, findRowClickAction, navigateActionSearchParams, runDrawerRowAction, } from "./row-actions.js";
29
31
  import { findEditScreenFor } from "./screen-access.js";
30
- import { buildListQueryPayload, entityQueryCommand, isEntityEditScreenOfEntity, pageSizeOptionsFor, useNavigateToCreateFor, } from "./screen-shared.js";
31
- import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
32
- // entityList adapter — one DataTableFacet per filterable select/boolean
33
- // entity field, labels via the standard field/option i18n convention.
34
- function resolveEntityFacetSpecs(fields, featureName, entityName, translate) {
35
- const out = [];
36
- for (const [field, rawDef] of Object.entries(fields)) {
37
- // entity.fields is weakly typed at the renderer layer (Record<string,
38
- // unknown>, deserialized from the schema); boundary cast like buildInitialValues.
39
- const def = rawDef;
40
- if (def.filterable !== true)
41
- continue;
42
- const label = translate(fieldLabelKey(featureName, entityName, field));
43
- if (def.type === "select" && Array.isArray(def.options)) {
44
- out.push({
45
- field,
46
- type: "select",
47
- label,
48
- options: def.options.map((value) => ({
49
- value,
50
- label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
51
- })),
52
- });
53
- }
54
- else if (def.type === "boolean") {
55
- out.push({
56
- field,
57
- type: "boolean",
58
- label,
59
- options: [
60
- {
61
- value: "true",
62
- label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
63
- },
64
- {
65
- value: "false",
66
- label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
67
- },
68
- ],
69
- });
70
- }
71
- else if (def.type === "reference" && def.entity !== undefined) {
72
- const target = parseRefTarget(def.entity, featureName);
73
- out.push({
74
- field,
75
- type: "reference",
76
- label,
77
- options: [],
78
- reference: {
79
- refEntity: target.entityName,
80
- refFeature: target.featureName,
81
- labelField: def.labelField ?? "id",
82
- },
83
- });
84
- }
85
- }
86
- return out;
87
- }
32
+ import { buildListQueryPayload, entityQueryCommand, isEntityEditScreenOfEntity, useNavigateToCreateFor, } from "./screen-shared.js";
33
+ import { useEntityRangeFacets } from "./use-entity-range-facets.js";
34
+ import { listPager, useScreenListRows } from "./use-screen-list-rows.js";
35
+ import { useScreenListTable } from "./use-screen-list-table.js";
88
36
  export function EntityListScreen({ schema, screen, translate, onRowClick, }) {
89
37
  const { Banner, Text } = usePrimitives();
90
38
  const entity = schema.entities[screen.entity];
@@ -120,29 +68,17 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
120
68
  const paginationMode = screen.pagination ?? "pages";
121
69
  const usePager = paginationMode === "pages";
122
70
  const useInfinite = paginationMode === "infinite";
123
- // Infinite scroll: local state accumulates rows across several useQuery
124
- // calls (instead of replacing them like the pager). The cursor changes when
125
- // the user reaches the bottom sentinel; on a sort/q change everything is
126
- // reset, since the insert order of the accumulated rows would otherwise be
127
- // inconsistent with the new sorting.
128
- const [accumulated, setAccumulated] = useState([]);
129
- const [cursor, setCursor] = useState(undefined);
130
- const [hasMore, setHasMore] = useState(true);
131
- // Ref so the useEffect cleanup does not see stale state.
132
- const sortQRef = useRef("");
133
- const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
134
- // Reset rows + cursor on a sort/q change, before the next useQuery so the
135
- // reload starts with cursor=undefined.
136
- useEffect(() => {
137
- if (!useInfinite)
138
- return;
139
- if (sortQRef.current === sortQKey)
140
- return;
141
- sortQRef.current = sortQKey;
142
- setAccumulated([]);
143
- setCursor(undefined);
144
- setHasMore(true);
145
- }, [useInfinite, sortQKey]);
71
+ // Hook order: ALL hooks before the early return for loading/error,
72
+ // otherwise the hook slots collide between renders.
73
+ const t = useTranslation();
74
+ const effectiveTranslate = translate ?? t;
75
+ const range = useEntityRangeFacets({
76
+ fields: entity.fields,
77
+ screen,
78
+ featureName,
79
+ translate: effectiveTranslate,
80
+ urlState,
81
+ });
146
82
  // User-selected faceted filters from the URL state -> payload.filters.
147
83
  // typeOf reads the field type straight from entity.fields (not gated on
148
84
  // filterable, deliberately permissive; see the buildFilterPayload doc).
@@ -162,7 +98,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
162
98
  usePager,
163
99
  page: urlState.page,
164
100
  useInfinite,
165
- cursor,
101
+ cursor: undefined,
166
102
  });
167
103
  // Screen filter (tier 2.7c), declared by the author on the schema and
168
104
  // independent of the user's q search. Several buckets of the same entity
@@ -171,9 +107,9 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
171
107
  if (screen.filter !== undefined) {
172
108
  payload["filter"] = screen.filter;
173
109
  }
174
- if (filterPayload.length > 0) {
175
- payload["filters"] = filterPayload;
176
- }
110
+ const filters = [...filterPayload, ...range.rangeFilters];
111
+ if (filters.length > 0)
112
+ payload["filters"] = filters;
177
113
  return payload;
178
114
  }, [
179
115
  limit,
@@ -181,82 +117,51 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
181
117
  effectiveSort,
182
118
  screen.filter,
183
119
  filterPayload,
120
+ range.rangeFilters,
184
121
  usePager,
185
122
  urlState.page,
186
123
  useInfinite,
187
- cursor,
188
124
  ]);
189
- const rowsQuery = useQuery(queryType, queryPayload, { live: true });
190
125
  // Facet counts come from their own queries, which a write leaves untouched.
191
126
  const [facetCountRefreshNonce, setFacetCountRefreshNonce] = useState(0);
192
127
  const refreshFacetCounts = useCallback(() => setFacetCountRefreshNonce((n) => n + 1), []);
193
- const refreshRowsAfterWrite = useCallback(async () => {
194
- refreshFacetCounts();
195
- if (useInfinite) {
196
- setAccumulated([]);
197
- setCursor(undefined);
198
- setHasMore(true);
199
- return;
200
- }
201
- await refetchAfterWrite(rowsQuery.refetch);
202
- }, [useInfinite, rowsQuery.refetch, refreshFacetCounts]);
203
- // Infinite scroll: on every successful result append the rows and derive
204
- // hasMore from nextCursor. Live updates (postgres NOTIFY) and initial loads
205
- // both pass through here; the useEffect dependency array pins the dedup to
206
- // the last processed data pointer.
207
- const lastDataRef = useRef(null);
208
- useEffect(() => {
209
- if (!useInfinite)
210
- return;
211
- const data = rowsQuery.data;
212
- if (data === null)
213
- return;
214
- if (data === lastDataRef.current)
215
- return;
216
- lastDataRef.current = data;
217
- setAccumulated((prev) => {
218
- // When cursor is undefined this is the first page after a sort/q change:
219
- // replace everything instead of appending. Otherwise dedupe on id in case
220
- // live updates bring in an entry on the next page that was already appended.
221
- if (cursor === undefined)
222
- return data.rows;
223
- const seen = new Set(prev.map((r) => r["id"]));
224
- const fresh = data.rows.filter((r) => !seen.has(r["id"]));
225
- return [...prev, ...fresh];
226
- });
227
- setHasMore(data.nextCursor !== null);
228
- }, [rowsQuery.data, useInfinite, cursor]);
229
- const loadMore = useCallback(() => {
230
- if (!useInfinite)
231
- return;
232
- if (rowsQuery.loading)
233
- return;
234
- const data = rowsQuery.data;
235
- if (data?.nextCursor === undefined || data.nextCursor === null)
236
- return;
237
- setCursor(data.nextCursor);
238
- }, [useInfinite, rowsQuery.loading, rowsQuery.data]);
239
- // RowActions: schema form (handler QN + i18n key) -> resolved form
240
- // (dispatcher call + translated strings). dispatcher.write knows the user
241
- // internally (JWT cookie). The schema may contain raw strings as well as
242
- // i18n keys; translate() returns the key when the bundle does not know it
243
- // (convention everywhere in the renderer).
244
- // Hook order: ALL hooks before the early return for loading/error,
245
- // otherwise the hook slots collide between renders.
246
- const t = useTranslation();
247
- const effectiveTranslate = translate ?? t;
248
128
  // Faceted filter: one dropdown per filterable select/boolean/reference field.
249
129
  // Labels + select option labels use the same i18n convention as the column
250
130
  // headers (fieldLabelKey / :option:<value>); reference fields load their
251
131
  // options asynchronously through ReferenceFacetBridges.
252
132
  const entityFacetSpecs = useMemo(() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate), [entity.fields, featureName, screen.entity, effectiveTranslate]);
133
+ const facetFilterFields = useMemo(() => [...entityFacetSpecs.map((spec) => spec.field), ...range.rangeFields], [entityFacetSpecs, range.rangeFields]);
134
+ const listTable = useScreenListTable({
135
+ screen,
136
+ featureName,
137
+ payload: queryPayload,
138
+ filterFields: facetFilterFields,
139
+ queryType,
140
+ pageSize: limit,
141
+ paging: useInfinite ? "cursor" : "offset",
142
+ translate: effectiveTranslate,
143
+ });
144
+ const listRows = useScreenListRows({
145
+ queryType,
146
+ payload: queryPayload,
147
+ infinite: useInfinite,
148
+ aggregates: listTable.aggregates,
149
+ });
150
+ const { refreshAfterWrite: refreshListAfterWrite } = listRows;
151
+ const refreshRowsAfterWrite = useCallback(async () => {
152
+ refreshFacetCounts();
153
+ await refreshListAfterWrite();
154
+ }, [refreshFacetCounts, refreshListAfterWrite]);
253
155
  const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
254
156
  const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
255
157
  const [facetCounts, setFacetCounts] = useState({});
256
158
  const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
257
159
  const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
258
160
  const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
259
- const filters = buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type);
161
+ const filters = [
162
+ ...buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type),
163
+ ...range.rangeFilters,
164
+ ];
260
165
  return {
261
166
  key: request.key,
262
167
  payload: {
@@ -275,6 +180,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
275
180
  urlState.filters,
276
181
  urlState.q,
277
182
  entity.fields,
183
+ range.rangeFilters,
278
184
  ]);
279
185
  // Soft dispatcher: in tests that mount without a DispatcherProvider,
280
186
  // rowActions stays undefined instead of crashing. Real apps have the
@@ -371,35 +277,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
371
277
  }
372
278
  if (dispatcher === undefined)
373
279
  return null;
374
- const writeAction = action;
375
- const writeActionVisible = writeAction.visible;
376
- return {
377
- id: writeAction.id,
378
- label: effectiveTranslate(writeAction.label),
379
- style: writeAction.style,
380
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
381
- confirm: writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
382
- confirmLabel: writeAction.confirmLabel !== undefined
383
- ? effectiveTranslate(writeAction.confirmLabel)
384
- : undefined,
385
- onTrigger: async (row) => {
386
- const buildPayload = writeAction.payload;
387
- const payload = buildPayload !== undefined
388
- ? evalRowExtractor(buildPayload, row.values)
389
- : { id: row.values["id"] };
390
- const result = await dispatcher.write(writeAction.handler, payload);
391
- // write() does not throw: a failure result MUST become an error here,
392
- // otherwise the confirm dialog closes silently and the user sees "nothing
393
- // happened" (prod bug 2026-06-07).
394
- if (!result.isSuccess) {
395
- throw new WriteFailedError(result.error, dispatcherErrorText(result.error, effectiveTranslate));
396
- }
397
- await refreshRowsAfterWrite();
398
- },
399
- isVisible: writeActionVisible !== undefined
400
- ? (row) => evalFieldCondition(writeActionVisible, row.values)
401
- : undefined,
402
- };
280
+ return buildWriteHandlerRowAction(action, effectiveTranslate, refreshRowsAfterWrite, dispatcher);
403
281
  })
404
282
  .filter((a) => a !== null);
405
283
  }, [
@@ -412,6 +290,13 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
412
290
  refreshRowsAfterWrite,
413
291
  openDrawer,
414
292
  ]);
293
+ const bulk = useListBulk({
294
+ bulkActions: screen.bulkActions,
295
+ rowActions: screen.rowActions,
296
+ translate: effectiveTranslate,
297
+ dispatcher,
298
+ reload: refreshRowsAfterWrite,
299
+ });
415
300
  // ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
416
301
  // projectionList and relatedList sections via buildProjectionToolbarActions.
417
302
  const toolbarActions = useMemo(() => buildProjectionToolbarActions({
@@ -471,14 +356,14 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
471
356
  refreshRowsAfterWrite,
472
357
  invalidateExpansions,
473
358
  ]);
474
- if (rowsQuery.loading && rowsQuery.data === null) {
359
+ if (listRows.firstLoad) {
475
360
  return _jsx(QueryLoadingBanner, {});
476
361
  }
477
- if (rowsQuery.error && rowsQuery.data === null) {
478
- return (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch }));
362
+ if (listRows.error && !listRows.hasData) {
363
+ return (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry }));
479
364
  }
480
365
  // A failed refetch keeps the last good data on screen.
481
- const refetchErrorBanner = rowsQuery.error ? (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch })) : null;
366
+ const refetchErrorBanner = listRows.error ? (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry })) : null;
482
367
  // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
483
368
  // 2-arg shape (row, entityName) is the public surface — thread the
484
369
  // screen's entity through here so callers don't have to re-derive
@@ -507,41 +392,24 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
507
392
  // Assemble pager props only for pagination="pages". The server total
508
393
  // arrives async; until it is there RenderList renders the table without a
509
394
  // pager (the pager has its own total>0 guard).
510
- const total = rowsQuery.data?.total;
511
- const pager = usePager && total !== undefined
512
- ? {
513
- page: urlState.page,
514
- limit,
515
- total,
516
- onPageChange: urlState.setPage,
517
- pageSizeOptions: pageSizeOptionsFor(limit),
518
- onPageSizeChange: urlState.setPageSize,
519
- }
520
- : undefined;
521
- // In infinite mode rows come from accumulated (collected across several
522
- // useQuery calls), with pages/false from the current result.
523
- const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
524
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
395
+ const pager = listPager({ usePager, page: listRows.page, limit, urlState });
396
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
525
397
  description: effectiveTranslate(screen.description),
526
398
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
527
399
  ? { screenPadding: false, chromeless: true }
528
- : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
529
- onReachEnd: loadMore,
530
- loadingMore: rowsQuery.loading,
531
- hasMore,
532
- }), ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
533
- filterFacets,
400
+ : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...listRows.infinite, ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && { filterFacets, onFilterChange: urlState.setFilter }), ...(range.rangeFacets.length > 0 && {
401
+ rangeFacets: range.rangeFacets,
402
+ onRangeChange: range.onRangeChange,
403
+ }), ...((filterFacets.length > 0 || range.rangeFacets.length > 0) && {
534
404
  filterValues: urlState.filters,
535
- onFilterChange: urlState.setFilter,
536
405
  onFilterReset: urlState.clearFilters,
537
- }), ...(expandableSection !== undefined && {
406
+ }), ...listTable.props, ...(expandableSection !== undefined && {
538
407
  expandedRowIds,
539
408
  onToggleRowExpanded: toggleRowExpanded,
540
409
  renderExpandedRow,
541
410
  }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
542
411
  closeDrawer();
543
412
  invalidateExpansions();
544
- refreshFacetCounts();
545
- void rowsQuery.refetch();
413
+ void refreshRowsAfterWrite();
546
414
  } })] }));
547
415
  }