@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.
- package/CHANGELOG.md +1436 -0
- package/README.md +31 -12
- package/dist/actions/ActionEngine.d.ts +53 -31
- package/dist/actions/ActionEngine.js +87 -66
- package/dist/actions/ActionRunner.d.ts +215 -24
- package/dist/actions/ActionRunner.js +334 -17
- package/dist/actions/actionErrorDetail.d.ts +26 -0
- package/dist/actions/actionErrorDetail.js +37 -0
- package/dist/actions/actionKeys.d.ts +96 -20
- package/dist/actions/actionKeys.js +168 -18
- package/dist/actions/actionResponse.d.ts +42 -0
- package/dist/actions/actionResponse.js +104 -0
- package/dist/actions/index.d.ts +3 -0
- package/dist/actions/index.js +3 -0
- package/dist/actions/serverActionHandler.d.ts +143 -0
- package/dist/actions/serverActionHandler.js +137 -0
- package/dist/data-scope/ViewDataProvider.d.ts +37 -12
- package/dist/data-scope/ViewDataProvider.js +83 -5
- package/dist/data-scope/element-data-source.d.ts +113 -0
- package/dist/data-scope/element-data-source.js +216 -0
- package/dist/data-scope/index.d.ts +2 -1
- package/dist/data-scope/index.js +1 -0
- package/dist/evaluator/ExpressionEvaluator.js +4 -2
- package/dist/evaluator/declaredPredicate.d.ts +119 -0
- package/dist/evaluator/declaredPredicate.js +152 -0
- package/dist/evaluator/fieldRules.d.ts +42 -2
- package/dist/evaluator/fieldRules.js +101 -11
- package/dist/evaluator/index.d.ts +2 -0
- package/dist/evaluator/index.js +2 -0
- package/dist/evaluator/listConditional.d.ts +18 -3
- package/dist/evaluator/listConditional.js +0 -0
- package/dist/evaluator/optionRules.js +5 -1
- package/dist/evaluator/predicateInput.d.ts +132 -0
- package/dist/evaluator/predicateInput.js +129 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +26 -1
- package/dist/protocols/DndProtocol.d.ts +42 -1
- package/dist/protocols/KeyboardProtocol.d.ts +31 -1
- package/dist/protocols/SharingProtocol.d.ts +11 -1
- package/dist/protocols/index.d.ts +0 -1
- package/dist/protocols/index.js +8 -1
- package/dist/registry/Registry.d.ts +24 -0
- package/dist/theme/ThemeEngine.d.ts +0 -8
- package/dist/theme/ThemeEngine.js +22 -103
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +7 -1
- package/dist/types/index.d.ts +42 -8
- package/dist/utils/chart-series.d.ts +258 -5
- package/dist/utils/chart-series.js +423 -20
- package/dist/utils/compare-to.d.ts +40 -13
- package/dist/utils/compare-to.js +4 -32
- package/dist/utils/dashboard-filters.d.ts +54 -8
- package/dist/utils/dashboard-filters.js +261 -12
- package/dist/utils/dataset-format.d.ts +79 -15
- package/dist/utils/dataset-format.js +130 -9
- package/dist/utils/dataset-pivot.d.ts +78 -0
- package/dist/utils/dataset-pivot.js +78 -0
- package/dist/utils/dom-props.d.ts +137 -0
- package/dist/utils/dom-props.js +153 -0
- package/dist/utils/filter-converter.d.ts +39 -11
- package/dist/utils/filter-converter.js +87 -7
- package/dist/utils/normalize-list-view.d.ts +26 -6
- package/dist/utils/normalize-list-view.js +30 -8
- package/dist/utils/number-display.d.ts +151 -0
- package/dist/utils/number-display.js +71 -0
- package/dist/utils/predicate-fields.d.ts +96 -0
- package/dist/utils/predicate-fields.js +163 -0
- package/dist/utils/predicate-record.d.ts +32 -0
- package/dist/utils/predicate-record.js +120 -0
- package/dist/utils/record-title.js +0 -0
- package/dist/utils/resolve-view-id.d.ts +31 -0
- package/dist/utils/resolve-view-id.js +45 -0
- package/dist/utils/sort-query.d.ts +55 -0
- package/dist/utils/sort-query.js +35 -0
- package/package.json +5 -5
- package/dist/protocols/NotificationProtocol.d.ts +0 -71
- package/dist/protocols/NotificationProtocol.js +0 -89
- package/dist/utils/merge-views-into-objects.d.ts +0 -19
- 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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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;
|