@object-ui/core 17.2.0 → 17.5.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 (79) hide show
  1. package/CHANGELOG.md +1436 -0
  2. package/README.md +31 -12
  3. package/dist/actions/ActionEngine.d.ts +53 -31
  4. package/dist/actions/ActionEngine.js +87 -66
  5. package/dist/actions/ActionRunner.d.ts +215 -24
  6. package/dist/actions/ActionRunner.js +334 -17
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +96 -20
  10. package/dist/actions/actionKeys.js +168 -18
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  18. package/dist/data-scope/ViewDataProvider.js +83 -5
  19. package/dist/data-scope/element-data-source.d.ts +113 -0
  20. package/dist/data-scope/element-data-source.js +216 -0
  21. package/dist/data-scope/index.d.ts +2 -1
  22. package/dist/data-scope/index.js +1 -0
  23. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  24. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  25. package/dist/evaluator/declaredPredicate.js +152 -0
  26. package/dist/evaluator/fieldRules.d.ts +42 -2
  27. package/dist/evaluator/fieldRules.js +101 -11
  28. package/dist/evaluator/index.d.ts +2 -0
  29. package/dist/evaluator/index.js +2 -0
  30. package/dist/evaluator/listConditional.d.ts +18 -3
  31. package/dist/evaluator/listConditional.js +0 -0
  32. package/dist/evaluator/optionRules.js +5 -1
  33. package/dist/evaluator/predicateInput.d.ts +132 -0
  34. package/dist/evaluator/predicateInput.js +129 -0
  35. package/dist/index.d.ts +7 -1
  36. package/dist/index.js +26 -1
  37. package/dist/protocols/DndProtocol.d.ts +42 -1
  38. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  39. package/dist/protocols/SharingProtocol.d.ts +11 -1
  40. package/dist/protocols/index.d.ts +0 -1
  41. package/dist/protocols/index.js +8 -1
  42. package/dist/registry/Registry.d.ts +24 -0
  43. package/dist/theme/ThemeEngine.d.ts +0 -8
  44. package/dist/theme/ThemeEngine.js +22 -103
  45. package/dist/theme/index.d.ts +1 -1
  46. package/dist/theme/index.js +7 -1
  47. package/dist/types/index.d.ts +42 -8
  48. package/dist/utils/chart-series.d.ts +258 -5
  49. package/dist/utils/chart-series.js +423 -20
  50. package/dist/utils/compare-to.d.ts +40 -13
  51. package/dist/utils/compare-to.js +4 -32
  52. package/dist/utils/dashboard-filters.d.ts +54 -8
  53. package/dist/utils/dashboard-filters.js +261 -12
  54. package/dist/utils/dataset-format.d.ts +79 -15
  55. package/dist/utils/dataset-format.js +130 -9
  56. package/dist/utils/dataset-pivot.d.ts +78 -0
  57. package/dist/utils/dataset-pivot.js +78 -0
  58. package/dist/utils/dom-props.d.ts +137 -0
  59. package/dist/utils/dom-props.js +153 -0
  60. package/dist/utils/filter-converter.d.ts +39 -11
  61. package/dist/utils/filter-converter.js +87 -7
  62. package/dist/utils/normalize-list-view.d.ts +26 -6
  63. package/dist/utils/normalize-list-view.js +30 -8
  64. package/dist/utils/number-display.d.ts +151 -0
  65. package/dist/utils/number-display.js +71 -0
  66. package/dist/utils/predicate-fields.d.ts +96 -0
  67. package/dist/utils/predicate-fields.js +163 -0
  68. package/dist/utils/predicate-record.d.ts +32 -0
  69. package/dist/utils/predicate-record.js +120 -0
  70. package/dist/utils/record-title.js +0 -0
  71. package/dist/utils/resolve-view-id.d.ts +31 -0
  72. package/dist/utils/resolve-view-id.js +45 -0
  73. package/dist/utils/sort-query.d.ts +55 -0
  74. package/dist/utils/sort-query.js +35 -0
  75. package/package.json +5 -5
  76. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  77. package/dist/protocols/NotificationProtocol.js +0 -89
  78. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  79. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * `createServerActionHandler` — the core server-action dispatcher factory
10
+ * (objectui#2904, the follow-up #2896 deferred).
11
+ *
12
+ * `ActionSchema.body` (HookBodySchema — L1 expression / L2 sandboxed JS) is the
13
+ * spec's preferred binding for script actions, and bodies execute SERVER-side:
14
+ * `POST /api/v1/actions/{object}/{action}` → the runtime sandbox. This client
15
+ * never interprets a body (L2 needs an isolated VM enforcing capabilities /
16
+ * `timeoutMs` / `memoryMb`, which a browser cannot provide; L1 is formula-engine
17
+ * CEL, a different dialect from this package's `${…}` evaluator). The client
18
+ * DISPATCHES; it does not interpret — see the `ActionDef.body` doc and #2896.
19
+ *
20
+ * Until this factory existed, only the console could dispatch: app-shell's
21
+ * `useConsoleActionRuntime` and `RecordDetailView` each hand-rolled the same
22
+ * POST (and drifted — objectstack#3913, framework#3935), while every other
23
+ * `@object-ui/core` consumer fell through to the built-in `executeScript` and
24
+ * hit a dead end. The factory is deliberately opinion-free about the three
25
+ * things core has no business deciding (#2904):
26
+ *
27
+ * - **auth** — inject `fetch` (an authenticated wrapper: Bearer, tenant,
28
+ * cookies — whatever the host's transport needs);
29
+ * - **base URL** — inject `baseUrl` (a string or a thunk; core inherits no
30
+ * bundler env convention like `VITE_SERVER_URL`);
31
+ * - **object scope** — inject `resolveObject` for the fallback when the action
32
+ * declares no `objectName` of its own.
33
+ *
34
+ * Everything protocol-shaped lives here, once: name-only action identity
35
+ * (ADR-0110 D1), the record-id resolution dance (`_rowRecord`, `recordIdField`,
36
+ * aggregate `_selectedIds`, toolbar-selection fallback with its guards), the
37
+ * re-entrancy guard, the POST body shape `{ recordId, params }`, and the
38
+ * `/actions` envelope rule (`interpretActionResponse`).
39
+ *
40
+ * Standalone usage:
41
+ *
42
+ * ```ts
43
+ * import { createServerActionHandler } from '@object-ui/core';
44
+ *
45
+ * const script = createServerActionHandler({
46
+ * fetch: myAuthenticatedFetch,
47
+ * baseUrl: 'https://api.example.com',
48
+ * resolveObject: () => currentObjectName,
49
+ * onRefresh: () => notifyDataChanged(),
50
+ * });
51
+ * // Register it — registered handlers beat the built-in executeScript:
52
+ * // <ActionProvider handlers={{ script }} … /> (or runner.registerHandler('script', script))
53
+ * ```
54
+ */
55
+ import type { ActionContext, ActionDef, ActionResult } from './ActionRunner';
56
+ /** The response surface the dispatcher needs — satisfied by a WHATWG `Response`. */
57
+ export interface ServerActionFetchResponse {
58
+ ok: boolean;
59
+ status: number;
60
+ json(): Promise<any>;
61
+ }
62
+ /**
63
+ * The injected transport. Core has no auth opinion: pass an authenticated
64
+ * wrapper (e.g. app-shell's `createAuthenticatedFetch()`), not the bare global
65
+ * `fetch`, unless your deployment really needs no credentials.
66
+ */
67
+ export type ServerActionFetch = (url: string, init: {
68
+ method: 'POST';
69
+ headers: Record<string, string>;
70
+ body: string;
71
+ }) => Promise<ServerActionFetchResponse>;
72
+ /**
73
+ * What a record-id resolver reports: a record id (absent for object-scoped
74
+ * dispatches), or a blocking error the handler returns without POSTing.
75
+ */
76
+ export interface ServerActionRecordIdResolution {
77
+ recordId?: unknown;
78
+ error?: string;
79
+ }
80
+ export type ServerActionRecordIdResolver = (action: ActionDef, context: ActionContext | undefined) => ServerActionRecordIdResolution;
81
+ /**
82
+ * A dispatch handler produced by {@link createServerActionHandler}. Assignable
83
+ * to `ActionRunnerHandler` — register it under `'script'` (and, where the host
84
+ * routes them server-side, other action types).
85
+ */
86
+ export type ServerActionHandler = (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
87
+ export interface ServerActionHandlerConfig {
88
+ /** Authenticated transport — see {@link ServerActionFetch}. */
89
+ fetch: ServerActionFetch;
90
+ /**
91
+ * Server origin prefix (`''`/omitted = same-origin). A thunk is read per
92
+ * dispatch, so hosts with late-bound configuration can inject one.
93
+ */
94
+ baseUrl?: string | (() => string);
95
+ /**
96
+ * Fallback object scope when the action declares no `objectName` — e.g. the
97
+ * current view's object. When neither yields a name the dispatch is `global`.
98
+ */
99
+ resolveObject?: (action: ActionDef, context: ActionContext | undefined) => string | undefined;
100
+ /**
101
+ * Replace the standard record-id dance ({@link resolveServerActionRecordId})
102
+ * wholesale — e.g. a record page resolves against ITS record, not a grid
103
+ * selection.
104
+ */
105
+ resolveRecordId?: ServerActionRecordIdResolver;
106
+ /**
107
+ * Invoked after a successful dispatch that requests a refresh
108
+ * (`refreshAfter !== false`) — wire your data invalidation here.
109
+ */
110
+ onRefresh?: () => void;
111
+ }
112
+ /**
113
+ * An action that also mounts on list rows (`list_item`) or record pages is
114
+ * designed to run against a single record. When such an action is launched
115
+ * from the list toolbar with nothing selected, there is no record context to
116
+ * resolve — block up front instead of triggering a run that fails at its
117
+ * first record-bound step (#2210: "Update requires an ID"). Actions declaring
118
+ * only object-level locations (e.g. `['list_toolbar']`) are left alone: they
119
+ * legitimately run without a record.
120
+ */
121
+ export declare function isRecordScopedAction(action: ActionDef): boolean;
122
+ /**
123
+ * The standard record-id resolution dance, shared by the dispatcher and by
124
+ * consumers that need the id BEFORE dispatching (e.g. app-shell's zero-roundtrip
125
+ * `newTabUrl` fast path). Pure — reads the action/context, mutates nothing.
126
+ *
127
+ * Order: explicit `params.recordId` → the stashed `_rowRecord`'s
128
+ * `recordIdField` (list_item invocations) → the grid's checkbox selection from
129
+ * the runner context (`selectedRecords`, list_toolbar invocations). An
130
+ * AGGREGATE bulk dispatch (objectui#3139) carries the whole selection as
131
+ * `params._selectedIds` — the server reads that array, not `recordId`, so the
132
+ * selection fallback must not touch it (resolving a recordId would mislabel
133
+ * the run as record-scoped, and the multi-select guard would block exactly the
134
+ * selection the dispatch exists to carry). Multi-select is ambiguous for a
135
+ * single-record action — block with a message; so is zero selection when the
136
+ * action is record-scoped (see {@link isRecordScopedAction}).
137
+ */
138
+ export declare function resolveServerActionRecordId(action: ActionDef, context: ActionContext | undefined): ServerActionRecordIdResolution;
139
+ /**
140
+ * Build a server-action dispatch handler. See the module doc for what the
141
+ * factory owns and what the config injects.
142
+ */
143
+ export declare function createServerActionHandler(config: ServerActionHandlerConfig): ServerActionHandler;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import { interpretActionResponse } from './actionResponse';
9
+ /**
10
+ * An action that also mounts on list rows (`list_item`) or record pages is
11
+ * designed to run against a single record. When such an action is launched
12
+ * from the list toolbar with nothing selected, there is no record context to
13
+ * resolve — block up front instead of triggering a run that fails at its
14
+ * first record-bound step (#2210: "Update requires an ID"). Actions declaring
15
+ * only object-level locations (e.g. `['list_toolbar']`) are left alone: they
16
+ * legitimately run without a record.
17
+ */
18
+ export function isRecordScopedAction(action) {
19
+ const locations = action.locations;
20
+ if (!Array.isArray(locations))
21
+ return false;
22
+ return locations.some((l) => l === 'list_item' || l === 'record_header' || l === 'record_more' || l === 'record_section');
23
+ }
24
+ /**
25
+ * The standard record-id resolution dance, shared by the dispatcher and by
26
+ * consumers that need the id BEFORE dispatching (e.g. app-shell's zero-roundtrip
27
+ * `newTabUrl` fast path). Pure — reads the action/context, mutates nothing.
28
+ *
29
+ * Order: explicit `params.recordId` → the stashed `_rowRecord`'s
30
+ * `recordIdField` (list_item invocations) → the grid's checkbox selection from
31
+ * the runner context (`selectedRecords`, list_toolbar invocations). An
32
+ * AGGREGATE bulk dispatch (objectui#3139) carries the whole selection as
33
+ * `params._selectedIds` — the server reads that array, not `recordId`, so the
34
+ * selection fallback must not touch it (resolving a recordId would mislabel
35
+ * the run as record-scoped, and the multi-select guard would block exactly the
36
+ * selection the dispatch exists to carry). Multi-select is ambiguous for a
37
+ * single-record action — block with a message; so is zero selection when the
38
+ * action is record-scoped (see {@link isRecordScopedAction}).
39
+ */
40
+ export function resolveServerActionRecordId(action, context) {
41
+ const params = (action.params && !Array.isArray(action.params))
42
+ ? action.params
43
+ : {};
44
+ const rowRecord = params._rowRecord;
45
+ const recordIdField = action.recordIdField || 'id';
46
+ let recordId = params.recordId ?? rowRecord?.[recordIdField];
47
+ const isAggregateDispatch = Array.isArray(params._selectedIds);
48
+ if (recordId == null && !isAggregateDispatch) {
49
+ const selected = Array.isArray(context?.selectedRecords) ? context.selectedRecords : [];
50
+ if (selected.length === 1) {
51
+ recordId = selected[0]?.[recordIdField];
52
+ }
53
+ else if (selected.length > 1) {
54
+ // The runner's post-execution hook surfaces `error` as a toast.
55
+ return { error: 'This action runs on a single record — select exactly one row.' };
56
+ }
57
+ else if (isRecordScopedAction(action)) {
58
+ return { error: 'This action runs on a single record — select a row first.' };
59
+ }
60
+ }
61
+ return { recordId: recordId ?? undefined };
62
+ }
63
+ /**
64
+ * Build a server-action dispatch handler. See the module doc for what the
65
+ * factory owns and what the config injects.
66
+ */
67
+ export function createServerActionHandler(config) {
68
+ const { fetch: dispatchFetch, resolveObject, onRefresh } = config;
69
+ const resolveRecordId = config.resolveRecordId ?? resolveServerActionRecordId;
70
+ // Guards against double-firing a server action (slow SSO handoff, etc.).
71
+ // Handler-instance state: keep the produced handler stable for its guard to
72
+ // span invocations.
73
+ const inFlight = new Set();
74
+ return async function serverActionDispatch(action, context) {
75
+ // [ADR-0110 D1] The URL identifies the action by its declarative `name`,
76
+ // never by `target`. `target` is a BINDING EXPRESSION — the handler's
77
+ // registration key here, but a URL / flow id / FormView name for other
78
+ // types, `${param.X}`-interpolatable, and legitimately non-unique — so it
79
+ // cannot identify a declaration. Posting it (the old `target || name`)
80
+ // meant the server resolved NO declaration for a target-bound action and
81
+ // silently skipped both the ADR-0066 D4 capability gate and the ADR-0104
82
+ // param contract (framework#3935). The server derives the handler key from
83
+ // the declaration it resolves by name.
84
+ const actionName = action.name;
85
+ if (!actionName) {
86
+ return { success: false, error: 'Action has no name — a server action must declare one' };
87
+ }
88
+ // The row record is a CLIENT-side stash (list_item invocations park it
89
+ // under `params._rowRecord` for param defaults and id resolution) — never
90
+ // part of the server contract, so it is stripped from the POSTed params.
91
+ const params = (action.params && !Array.isArray(action.params))
92
+ ? { ...action.params }
93
+ : {};
94
+ delete params._rowRecord;
95
+ const resolution = resolveRecordId(action, context);
96
+ if (resolution.error) {
97
+ return { success: false, error: resolution.error };
98
+ }
99
+ const recordId = resolution.recordId;
100
+ // Re-entrancy guard.
101
+ const inflightKey = `${actionName}:${recordId ?? ''}`;
102
+ if (inFlight.has(inflightKey)) {
103
+ return { success: false, error: 'Action already in progress' };
104
+ }
105
+ inFlight.add(inflightKey);
106
+ try {
107
+ const baseUrl = typeof config.baseUrl === 'function' ? config.baseUrl() : (config.baseUrl ?? '');
108
+ const objectName = action.objectName || resolveObject?.(action, context) || 'global';
109
+ const res = await dispatchFetch(`${baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ recordId, params }),
113
+ });
114
+ const json = await res.json().catch(() => null);
115
+ // The `/actions` envelope rule, once — see ./actionResponse.
116
+ const outcome = interpretActionResponse(res, json, `Action "${actionName}"`);
117
+ if (!outcome.ok) {
118
+ // Don't toast here — the ActionRunner's post-execution hook surfaces
119
+ // `error` as a toast; a handler-side toast double-fires it.
120
+ return { success: false, error: outcome.error };
121
+ }
122
+ const shouldRefresh = action.refreshAfter !== false;
123
+ if (shouldRefresh)
124
+ onRefresh?.();
125
+ // `data` carries the ACTION ENVELOPE (`{ success, data }`), the level
126
+ // `ActionResult.data` has always carried; consumers reading the
127
+ // handler's own return value go through `readActionPayload`.
128
+ return { success: true, data: outcome.envelope, reload: shouldRefresh };
129
+ }
130
+ catch (error) {
131
+ return { success: false, error: error.message };
132
+ }
133
+ finally {
134
+ inFlight.delete(inflightKey);
135
+ }
136
+ };
137
+ }
@@ -18,6 +18,7 @@
18
18
  * @module data-scope
19
19
  * @packageDocumentation
20
20
  */
21
+ import { type ElementDataSourceConfig, type ElementSavedView } from './element-data-source.js';
21
22
  /** ViewData union — matches @objectstack/spec ViewDataSchema */
22
23
  export type ViewDataConfig = {
23
24
  provider: 'object';
@@ -38,17 +39,12 @@ export type ViewDataConfig = {
38
39
  provider: 'value';
39
40
  items: any[];
40
41
  };
41
- /** Element-level data source — matches @objectstack/spec ElementDataSourceSchema */
42
- export interface ElementDataSourceConfig {
43
- object: string;
44
- view?: string;
45
- filter?: Record<string, any>;
46
- sort?: Array<{
47
- field: string;
48
- order: 'asc' | 'desc';
49
- }>;
50
- limit?: number;
51
- }
42
+ /**
43
+ * Re-exported from `./element-data-source.js`, where the binding's type now
44
+ * lives alongside the logic that consumes it. Kept on this module path because
45
+ * that is where it was declared before objectstack#5576.
46
+ */
47
+ export type { ElementDataSourceConfig };
52
48
  /** Fetcher interface — consumers provide the actual fetch implementation */
53
49
  export interface DataFetcher {
54
50
  /** Fetch records for an object */
@@ -82,6 +78,19 @@ export interface DataFetcher {
82
78
  headers?: Record<string, string>;
83
79
  body?: any;
84
80
  }): Promise<any>;
81
+ /**
82
+ * Fetch the object's saved views, so an `ElementDataSource.view` name can be
83
+ * resolved to real columns / filter / sort (objectstack#5576).
84
+ *
85
+ * Optional because most fetchers only read records. A fetcher WITHOUT it
86
+ * cannot honour a named view, and {@link ViewDataProvider.resolveElementDataSource}
87
+ * says so in `error` rather than dropping the key — the whole point of #5576
88
+ * is that a `view` the runtime cannot apply must never look applied.
89
+ *
90
+ * Either shape stored views come in is accepted: a map keyed by view id, or
91
+ * the array of overlay rows an adapter's `listViews()` returns.
92
+ */
93
+ fetchViews?(object: string): Promise<Record<string, ElementSavedView> | ElementSavedView[]>;
85
94
  }
86
95
  /** Resolved data result */
87
96
  export interface ResolvedData {
@@ -138,6 +147,22 @@ export declare class ViewDataProvider {
138
147
  private resolveApi;
139
148
  /** Resolve object-based data (metadata-driven) */
140
149
  private resolveObject;
141
- /** Resolve element-level data source */
150
+ /**
151
+ * Resolve an element-level data source (`PageComponentSchema.dataSource`).
152
+ *
153
+ * `view` is HONOURED here, not dropped. Until objectstack#5576 this method
154
+ * forwarded `filter`/`sort`/`limit` and silently discarded `view`, so a page
155
+ * component that named a saved view got every record the object had — the
156
+ * declared binding was inert while looking applied.
157
+ *
158
+ * Three outcomes, all explicit:
159
+ * - no `view` → unchanged behaviour, the binding's own keys are forwarded;
160
+ * - `view` resolved → its columns become the `fields` projection and its
161
+ * filter/sort/limit the baseline the binding's keys compose with (see
162
+ * {@link composeElementDataSource} for the per-key rule);
163
+ * - `view` unresolvable, or the fetcher cannot list views at all → an
164
+ * `error` naming the view and what the object does have. NOT an empty
165
+ * result, and NOT a silent fall back to the unfiltered object.
166
+ */
142
167
  resolveElementDataSource(config: ElementDataSourceConfig): Promise<ResolvedData>;
143
168
  }
@@ -5,6 +5,20 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ /**
9
+ * @object-ui/core - View Data Provider
10
+ *
11
+ * Resolves @objectstack/spec ViewData discriminated union into
12
+ * a unified data access interface. Supports three data modes:
13
+ *
14
+ * 1. 'object' — Metadata-driven via ObjectStack API (object name → fields/records)
15
+ * 2. 'api' — Custom REST API endpoints (read/write URLs)
16
+ * 3. 'value' — Static data (pre-loaded items array)
17
+ *
18
+ * @module data-scope
19
+ * @packageDocumentation
20
+ */
21
+ import { collectSavedViews, composeElementDataSource, elementDataSourceViewNotFoundMessage, resolveSavedView, } from './element-data-source.js';
8
22
  /** Extract records array from various common API response shapes */
9
23
  function extractRecords(data) {
10
24
  if (Array.isArray(data))
@@ -142,12 +156,76 @@ export class ViewDataProvider {
142
156
  };
143
157
  }
144
158
  }
145
- /** Resolve element-level data source */
159
+ /**
160
+ * Resolve an element-level data source (`PageComponentSchema.dataSource`).
161
+ *
162
+ * `view` is HONOURED here, not dropped. Until objectstack#5576 this method
163
+ * forwarded `filter`/`sort`/`limit` and silently discarded `view`, so a page
164
+ * component that named a saved view got every record the object had — the
165
+ * declared binding was inert while looking applied.
166
+ *
167
+ * Three outcomes, all explicit:
168
+ * - no `view` → unchanged behaviour, the binding's own keys are forwarded;
169
+ * - `view` resolved → its columns become the `fields` projection and its
170
+ * filter/sort/limit the baseline the binding's keys compose with (see
171
+ * {@link composeElementDataSource} for the per-key rule);
172
+ * - `view` unresolvable, or the fetcher cannot list views at all → an
173
+ * `error` naming the view and what the object does have. NOT an empty
174
+ * result, and NOT a silent fall back to the unfiltered object.
175
+ */
146
176
  async resolveElementDataSource(config) {
147
- return this.resolve({ provider: 'object', object: config.object }, {
148
- filter: config.filter,
149
- sort: config.sort,
150
- limit: config.limit,
177
+ let view;
178
+ if (config.view) {
179
+ if (!this.fetcher?.fetchViews) {
180
+ return {
181
+ records: [],
182
+ total: 0,
183
+ provider: 'object',
184
+ loading: false,
185
+ error: `dataSource.view "${config.view}" cannot be resolved: this data ` +
186
+ `fetcher does not implement fetchViews().`,
187
+ };
188
+ }
189
+ let views;
190
+ try {
191
+ views = collectSavedViews(await this.fetcher.fetchViews(config.object));
192
+ }
193
+ catch (error) {
194
+ return {
195
+ records: [],
196
+ total: 0,
197
+ provider: 'object',
198
+ loading: false,
199
+ error: error.message,
200
+ };
201
+ }
202
+ view = resolveSavedView(views, config.view, config.object);
203
+ if (!view) {
204
+ return {
205
+ records: [],
206
+ total: 0,
207
+ provider: 'object',
208
+ loading: false,
209
+ error: elementDataSourceViewNotFoundMessage(config.object, config.view, Object.keys(views)),
210
+ };
211
+ }
212
+ }
213
+ const composed = composeElementDataSource(config, view);
214
+ const fields = Array.isArray(composed.columns)
215
+ ? composed.columns.filter((c) => typeof c === 'string' && !!c)
216
+ : undefined;
217
+ return this.resolve({ provider: 'object', object: composed.object }, {
218
+ // `DataFetcher.fetchRecords`'s `filter` is declared
219
+ // `Record<string, any>`, which predates the three filter shapes that
220
+ // legitimately circulate here: a MongoDB-style condition object, an
221
+ // ObjectQL AST node array, and a spec `ViewFilterRule[]`. The last two
222
+ // are arrays, so a saved view's filter does not fit the declared option
223
+ // — the cast is that gap, not a coercion. Widening the declared option
224
+ // is a separate change with its own consumers.
225
+ filter: composed.filter,
226
+ sort: composed.sort,
227
+ limit: composed.limit,
228
+ ...(fields && fields.length > 0 ? { fields } : {}),
151
229
  });
152
230
  }
153
231
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /** Sort entry, as both the spec's `SortItem` and objectui's view configs spell it. */
9
+ export interface ElementDataSourceSort {
10
+ field: string;
11
+ order: 'asc' | 'desc';
12
+ }
13
+ /**
14
+ * Element-level data source — matches `@objectstack/spec` `ElementDataSourceSchema`.
15
+ *
16
+ * `filter` is typed `unknown` rather than the spec's `FilterCondition` because
17
+ * three shapes legitimately reach a renderer here (a MongoDB-style condition
18
+ * object, an ObjectQL AST node array, a spec `ViewFilterRule[]`) and
19
+ * {@link mergeFilterNodes} is the single sink that lowers all three. Narrowing
20
+ * the type here would only move the cast, not remove it.
21
+ */
22
+ export interface ElementDataSourceConfig {
23
+ object: string;
24
+ view?: string;
25
+ filter?: unknown;
26
+ sort?: ElementDataSourceSort[];
27
+ limit?: number;
28
+ }
29
+ /**
30
+ * The subset of a saved view's config that an `ElementDataSource` `view` name
31
+ * contributes. Deliberately loose: stored view metadata is user data and carries
32
+ * both the spec vocabulary and the legacy objectui spellings that
33
+ * `normalizeListViewSchema` folds at the component boundary.
34
+ */
35
+ export type ElementSavedView = Record<string, unknown>;
36
+ /** What {@link composeElementDataSource} produces for a renderer to consume. */
37
+ export interface ComposedElementDataSource {
38
+ /** Object to query. */
39
+ object: string;
40
+ /** Columns the named view shows; absent when no view was named. */
41
+ columns?: unknown;
42
+ /** View filter AND binding filter, combined; `undefined` when neither. */
43
+ filter?: unknown;
44
+ /** Binding sort if given, else the view's. */
45
+ sort?: unknown;
46
+ /** Binding limit if given, else the view's page size. */
47
+ limit?: number;
48
+ /** The view's render kind (grid / kanban / …), when the view declares one. */
49
+ viewType?: string;
50
+ }
51
+ /**
52
+ * Is this value the spec's `dataSource` METADATA, rather than a runtime
53
+ * data-source adapter?
54
+ *
55
+ * This guard exists because the two collide by name and the collision was live:
56
+ * `SchemaRenderer` spreads every non-metadata schema key onto the component as a
57
+ * React prop, so a spec-compliant `dataSource: { object, view }` on a page
58
+ * component landed on `ListView`'s `dataSource` PROP — which is the injected
59
+ * adapter — and shadowed it. The first `dataSource.find(…)` then threw
60
+ * `dataSource.find is not a function`, which the list reported as
61
+ * "Couldn't load records": writing the binding the spec documents BROKE the
62
+ * component (objectstack#5576).
63
+ *
64
+ * The test is positive on the metadata rather than negative on the adapter: a
65
+ * binding is a plain object with a non-empty string `object` and no `find`
66
+ * method. An adapter never has a string `object`; metadata never has `find`.
67
+ */
68
+ export declare function isElementDataSourceConfig(value: unknown): value is ElementDataSourceConfig;
69
+ /**
70
+ * Flatten every place saved views for one object can live into `id → config`.
71
+ *
72
+ * Two sources are real and both are read, in the order app-shell's `ObjectView`
73
+ * reads them: the object definition's embedded `listViews` / `list_views` map
74
+ * (metadata-defined) and the metadata overlay rows an adapter returns from
75
+ * `listViews()` (user-defined). Later sources win, so a saved view shadows a
76
+ * metadata view of the same id — the same precedence `ObjectView` applies when
77
+ * it dedups its tab list.
78
+ *
79
+ * Accepts a record keyed by view id or an array of view configs (overlay rows
80
+ * are an array and carry their identity in `name`, falling back to `id`).
81
+ */
82
+ export declare function collectSavedViews(...sources: unknown[]): Record<string, ElementSavedView>;
83
+ /**
84
+ * Find the saved view a `dataSource.view` name refers to.
85
+ *
86
+ * Name matching is {@link resolveViewId}'s — the same short/qualified tolerance
87
+ * the object page applies to a URL-requested view — so a name that works in one
88
+ * surface works in the other. Returns `undefined` when nothing matches; the
89
+ * caller must REPORT that rather than fall back to the object's default view.
90
+ * Silently widening a named view to "all records" is the failure mode this whole
91
+ * binding exists to remove.
92
+ */
93
+ export declare function resolveSavedView(views: Record<string, ElementSavedView>, requested: string | undefined | null, object: string): ElementSavedView | undefined;
94
+ /**
95
+ * Compose an `ElementDataSource` binding with the saved view it names.
96
+ *
97
+ * Pure — the caller resolves the view (see {@link resolveSavedView}) and owns
98
+ * the "named a view that does not exist" decision. Passing `undefined` /`null`
99
+ * for `view` composes a binding with no view, which is exactly the pre-#5576
100
+ * behaviour for the other four keys.
101
+ *
102
+ * See the module doc for the per-key rule table; `filter` is the only key that
103
+ * combines rather than overrides, because the spec describes the binding's
104
+ * filter as *additional*.
105
+ */
106
+ export declare function composeElementDataSource(config: ElementDataSourceConfig, view?: ElementSavedView | null): ComposedElementDataSource;
107
+ /**
108
+ * The message shown when `dataSource.view` names a view the object does not
109
+ * have. Built here so the render path and `ViewDataProvider` report the same
110
+ * defect the same way, and so the known-view list (the thing that actually gets
111
+ * an author unstuck) is never omitted by one caller.
112
+ */
113
+ export declare function elementDataSourceViewNotFoundMessage(object: string, view: string, knownViews: readonly string[]): string;