@object-ui/react 17.6.0 → 18.0.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 +4263 -0
- package/README.md +383 -42
- package/dist/SchemaRenderer.d.ts +64 -7
- package/dist/SchemaRenderer.js +1326 -106
- package/dist/context/ActionContext.js +40 -1
- package/dist/context/AppShellContext.d.ts +27 -0
- package/dist/context/DiscussionContext.d.ts +27 -0
- package/dist/context/DiscussionContext.js +4 -0
- package/dist/context/DrillNavigationContext.d.ts +13 -0
- package/dist/context/InlineEditContext.d.ts +151 -1
- package/dist/context/InlineEditContext.js +36 -2
- package/dist/context/PrintPageContext.d.ts +60 -0
- package/dist/context/PrintPageContext.js +32 -0
- package/dist/context/RecordContext.d.ts +48 -5
- package/dist/context/RecordContext.js +10 -1
- package/dist/context/RelatedRecordActionsContext.d.ts +72 -2
- package/dist/context/RelatedRecordActionsContext.js +4 -0
- package/dist/context/SchemaRendererContext.d.ts +61 -6
- package/dist/context/SchemaRendererContext.js +39 -6
- package/dist/context/ThemeContext.js +1 -1
- package/dist/context/actionRunnerTranslator.d.ts +27 -0
- package/dist/context/actionRunnerTranslator.js +37 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +1 -0
- package/dist/element-data-source/ElementApiDataSourcePanel.d.ts +28 -0
- package/dist/element-data-source/ElementApiDataSourcePanel.js +10 -0
- package/dist/element-data-source/ElementDataSourceGate.d.ts +118 -3
- package/dist/element-data-source/ElementDataSourceGate.js +401 -19
- package/dist/element-data-source/useElementApiDataSource.d.ts +119 -0
- package/dist/element-data-source/useElementApiDataSource.js +198 -0
- package/dist/hooks/index.d.ts +3 -1
- package/dist/hooks/index.js +10 -1
- package/dist/hooks/useActionEngine.js +46 -3
- package/dist/hooks/useActionRunner.js +4 -0
- package/dist/hooks/useActionTextLocalizer.d.ts +35 -9
- package/dist/hooks/useActionTextLocalizer.js +70 -14
- package/dist/hooks/useConfigBagEvaluator.d.ts +31 -0
- package/dist/hooks/useConfigBagEvaluator.js +40 -0
- package/dist/hooks/useDatasetDimensionLabels.js +25 -10
- package/dist/hooks/useDensityMode.d.ts +8 -2
- package/dist/hooks/useDensityMode.js +8 -2
- package/dist/hooks/useDiscovery.d.ts +1 -10
- package/dist/hooks/useETagCache.js +25 -4
- package/dist/hooks/useElementDataSource.d.ts +15 -2
- package/dist/hooks/useElementDataSource.js +24 -7
- package/dist/hooks/useExpression.d.ts +52 -59
- package/dist/hooks/useExpression.js +58 -90
- package/dist/hooks/useFilterScope.d.ts +22 -2
- package/dist/hooks/useFilterScope.js +29 -4
- package/dist/hooks/useGlobalUndo.d.ts +10 -0
- package/dist/hooks/useGlobalUndo.js +112 -46
- package/dist/hooks/useNavigationOverlay.d.ts +76 -11
- package/dist/hooks/useNavigationOverlay.js +119 -9
- package/dist/hooks/useOffline.js +65 -11
- package/dist/hooks/usePageAssignment.d.ts +33 -7
- package/dist/hooks/usePageAssignment.js +85 -21
- package/dist/hooks/useRecordSearch.d.ts +34 -1
- package/dist/hooks/useRecordSearch.js +42 -7
- package/dist/hooks/useResolvedFilter.d.ts +46 -0
- package/dist/hooks/useResolvedFilter.js +130 -0
- package/dist/hooks/useSchemaPersistence.d.ts +7 -0
- package/dist/hooks/useSchemaPersistence.js +144 -5
- package/dist/hooks/useSettledSchema.d.ts +123 -0
- package/dist/hooks/useSettledSchema.js +186 -0
- package/dist/hooks/useViewData.d.ts +10 -3
- package/dist/hooks/useViewData.js +5 -14
- package/dist/index.d.ts +9 -1
- package/dist/index.js +48 -1
- package/dist/schema-input.d.ts +38 -8
- package/dist/schema-input.js +39 -7
- package/dist/utils/configBag.d.ts +68 -0
- package/dist/utils/configBag.js +70 -0
- package/dist/utils/error-message.d.ts +1 -1
- package/dist/utils/error-message.js +1 -1
- package/dist/utils/inlineAria.d.ts +61 -0
- package/dist/utils/inlineAria.js +70 -0
- package/dist/utils/localeMap.d.ts +27 -0
- package/dist/utils/localeMap.js +106 -0
- package/dist/utils/nonGridRowCeiling.d.ts +44 -0
- package/dist/utils/nonGridRowCeiling.js +78 -0
- package/dist/utils/paramsBag.d.ts +28 -0
- package/dist/utils/paramsBag.js +146 -0
- package/dist/utils/propsBagDiagnostic.d.ts +196 -0
- package/dist/utils/propsBagDiagnostic.js +422 -0
- package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
- package/dist/utils/refusedDataPropDiagnostic.js +94 -0
- package/dist/utils/unevaluatedExpression.d.ts +44 -1
- package/dist/utils/unevaluatedExpression.js +92 -11
- package/dist/utils/visibilityDiagnostic.d.ts +282 -21
- package/dist/utils/visibilityDiagnostic.js +321 -46
- package/package.json +10 -10
- package/dist/hooks/useClientNotifications.d.ts +0 -47
- package/dist/hooks/useClientNotifications.js +0 -162
- package/dist/spec-bridge/SpecBridge.d.ts +0 -28
- package/dist/spec-bridge/SpecBridge.js +0 -56
- package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
- package/dist/spec-bridge/bridges/form-view.js +0 -170
- package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
- package/dist/spec-bridge/bridges/list-view.js +0 -268
- package/dist/spec-bridge/index.d.ts +0 -11
- package/dist/spec-bridge/index.js +0 -10
- package/dist/spec-bridge/types.d.ts +0 -55
- package/dist/spec-bridge/types.js +0 -8
|
@@ -20,11 +20,31 @@ export declare function FilterScopeProvider({ currentUserId, currentOrgId, child
|
|
|
20
20
|
children: ReactNode;
|
|
21
21
|
}): import("react").FunctionComponentElement<import("react").ProviderProps<FilterTokenScope>>;
|
|
22
22
|
/**
|
|
23
|
-
* Read the
|
|
23
|
+
* Read the scope for filter placeholder resolution: the session scope the host
|
|
24
|
+
* mounted, plus the id of the record in view when there is one.
|
|
24
25
|
*
|
|
25
26
|
* Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
|
|
26
27
|
* `@object-ui/core` — that helper expands every placeholder vocabulary in one
|
|
27
28
|
* call, which is the point: resolving only some of them is the defect behind
|
|
28
|
-
* #3574.
|
|
29
|
+
* objectstack-ai/objectstack#3574.
|
|
30
|
+
*
|
|
31
|
+
* ## `{record_id}` reads the MOUNTED record (objectui#7297)
|
|
32
|
+
*
|
|
33
|
+
* `recordId` comes from the nearest `RecordContextProvider` — the record a
|
|
34
|
+
* `type: 'record'` page is showing, the same provider whose row a component's
|
|
35
|
+
* `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
|
|
36
|
+
* parameter, not a page variable, and not a prop of `FilterScopeProvider`,
|
|
37
|
+
* which stays session-only. Every data node that already resolves its filter
|
|
38
|
+
* through this hook therefore resolves `{record_id}` on a record page without
|
|
39
|
+
* a line of its own, and refuses it by name everywhere else (a list view, a
|
|
40
|
+
* dashboard, a page that is not a record page), because there the member is
|
|
41
|
+
* simply absent.
|
|
42
|
+
*
|
|
43
|
+
* Outside a record context the session scope is handed back as the very
|
|
44
|
+
* object the provider published, so nothing changes for a surface that shows
|
|
45
|
+
* no record. Inside one, the combined scope is rebuilt only when the session
|
|
46
|
+
* scope or the record id changes. A consumer that holds a resolution compares
|
|
47
|
+
* the members one by one — `recordId` among them — never this object's
|
|
48
|
+
* identity (AGENTS.md #10).
|
|
29
49
|
*/
|
|
30
50
|
export declare function useFilterScope(): FilterTokenScope;
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { createContext, createElement, useContext, useMemo } from 'react';
|
|
9
|
+
import { useRecordContext } from '../context/RecordContext.js';
|
|
9
10
|
/**
|
|
10
11
|
* The session values filter placeholders resolve against — `{current_user_id}`
|
|
11
|
-
* and `{current_org_id}` (
|
|
12
|
+
* and `{current_org_id}` (objectstack-ai/objectstack#3574). The record-scoped
|
|
13
|
+
* `{record_id}` is not a session value and is not provided here: see
|
|
14
|
+
* {@link useFilterScope}.
|
|
12
15
|
*
|
|
13
16
|
* ## Why a dedicated context
|
|
14
17
|
*
|
|
@@ -43,13 +46,35 @@ export function FilterScopeProvider({ currentUserId, currentOrgId, children, })
|
|
|
43
46
|
return createElement(FilterScopeContext.Provider, { value }, children);
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
|
-
* Read the
|
|
49
|
+
* Read the scope for filter placeholder resolution: the session scope the host
|
|
50
|
+
* mounted, plus the id of the record in view when there is one.
|
|
47
51
|
*
|
|
48
52
|
* Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
|
|
49
53
|
* `@object-ui/core` — that helper expands every placeholder vocabulary in one
|
|
50
54
|
* call, which is the point: resolving only some of them is the defect behind
|
|
51
|
-
* #3574.
|
|
55
|
+
* objectstack-ai/objectstack#3574.
|
|
56
|
+
*
|
|
57
|
+
* ## `{record_id}` reads the MOUNTED record (objectui#7297)
|
|
58
|
+
*
|
|
59
|
+
* `recordId` comes from the nearest `RecordContextProvider` — the record a
|
|
60
|
+
* `type: 'record'` page is showing, the same provider whose row a component's
|
|
61
|
+
* `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
|
|
62
|
+
* parameter, not a page variable, and not a prop of `FilterScopeProvider`,
|
|
63
|
+
* which stays session-only. Every data node that already resolves its filter
|
|
64
|
+
* through this hook therefore resolves `{record_id}` on a record page without
|
|
65
|
+
* a line of its own, and refuses it by name everywhere else (a list view, a
|
|
66
|
+
* dashboard, a page that is not a record page), because there the member is
|
|
67
|
+
* simply absent.
|
|
68
|
+
*
|
|
69
|
+
* Outside a record context the session scope is handed back as the very
|
|
70
|
+
* object the provider published, so nothing changes for a surface that shows
|
|
71
|
+
* no record. Inside one, the combined scope is rebuilt only when the session
|
|
72
|
+
* scope or the record id changes. A consumer that holds a resolution compares
|
|
73
|
+
* the members one by one — `recordId` among them — never this object's
|
|
74
|
+
* identity (AGENTS.md #10).
|
|
52
75
|
*/
|
|
53
76
|
export function useFilterScope() {
|
|
54
|
-
|
|
77
|
+
const session = useContext(FilterScopeContext);
|
|
78
|
+
const recordId = useRecordContext()?.recordId;
|
|
79
|
+
return useMemo(() => (recordId == null ? session : { ...session, recordId }), [session, recordId]);
|
|
55
80
|
}
|
|
@@ -23,6 +23,16 @@ export interface UseGlobalUndoOptions {
|
|
|
23
23
|
*
|
|
24
24
|
* Provides reactive undo/redo state, executes data operations through the
|
|
25
25
|
* supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
|
|
26
|
+
*
|
|
27
|
+
* Keyboard: however many instances are mounted, one `window` `keydown`
|
|
28
|
+
* listener serves them all and one keypress pops at most one operation. It is
|
|
29
|
+
* answered by the longest-mounted live instance — its `dataSource` runs the
|
|
30
|
+
* write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
|
|
31
|
+
* target is an `input`, `textarea`, `select` or contenteditable region is left
|
|
32
|
+
* to that field: nothing is popped and the default is not prevented.
|
|
33
|
+
*
|
|
34
|
+
* The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
|
|
35
|
+
* THIS instance's `dataSource` and callbacks.
|
|
26
36
|
*/
|
|
27
37
|
export declare function useGlobalUndo(options?: UseGlobalUndoOptions): {
|
|
28
38
|
undo: () => Promise<void>;
|
|
@@ -5,7 +5,7 @@
|
|
|
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
|
-
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
8
|
+
import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
|
|
9
9
|
import { globalUndoManager } from '@object-ui/core';
|
|
10
10
|
function getSnapshot() {
|
|
11
11
|
return {
|
|
@@ -28,59 +28,125 @@ function subscribe(callback) {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
function getCachedSnapshot() { return cachedSnapshot; }
|
|
31
|
+
async function executeOp(optionsRef, op, data, mode) {
|
|
32
|
+
const ds = optionsRef.current.dataSource;
|
|
33
|
+
if (!ds)
|
|
34
|
+
return;
|
|
35
|
+
const action = mode === 'undo'
|
|
36
|
+
? { create: 'delete', update: 'update', delete: 'create' }[op.type]
|
|
37
|
+
: op.type;
|
|
38
|
+
if (action === 'delete')
|
|
39
|
+
await ds.delete(op.objectName, op.recordId);
|
|
40
|
+
else if (action === 'update')
|
|
41
|
+
await ds.update(op.objectName, op.recordId, data);
|
|
42
|
+
else
|
|
43
|
+
await ds.create(op.objectName, data);
|
|
44
|
+
}
|
|
45
|
+
async function runUndo(optionsRef) {
|
|
46
|
+
const op = globalUndoManager.popUndo();
|
|
47
|
+
if (!op)
|
|
48
|
+
return;
|
|
49
|
+
await executeOp(optionsRef, op, op.undoData, 'undo');
|
|
50
|
+
optionsRef.current.onUndo?.(op);
|
|
51
|
+
}
|
|
52
|
+
async function runRedo(optionsRef) {
|
|
53
|
+
const op = globalUndoManager.popRedo();
|
|
54
|
+
if (!op)
|
|
55
|
+
return;
|
|
56
|
+
await executeOp(optionsRef, op, op.redoData, 'redo');
|
|
57
|
+
optionsRef.current.onRedo?.(op);
|
|
58
|
+
}
|
|
59
|
+
// ── Keyboard shortcuts: ONE listener for the one shared stack (objectui#11081) ──
|
|
60
|
+
//
|
|
61
|
+
// Every instance used to add its own `window` `keydown` listener, and each of
|
|
62
|
+
// them popped `globalUndoManager`, so one Ctrl+Z undid one operation PER
|
|
63
|
+
// MOUNTED INSTANCE (the console mounts several at once). Now the instances
|
|
64
|
+
// register here and a single module-level listener answers each keypress,
|
|
65
|
+
// popping at most one operation.
|
|
66
|
+
//
|
|
67
|
+
// Which instance answers is decided once: the longest-mounted live one, the
|
|
68
|
+
// first entry of this insertion-ordered set. An `UndoableOperation` carries
|
|
69
|
+
// nothing that names the instance whose runner pushed it, so "the owner of the
|
|
70
|
+
// operation" is not readable here. The oldest live instance is also exactly
|
|
71
|
+
// the one that answered a keypress before this change whenever a single
|
|
72
|
+
// operation was stacked (its listener was first on `window` and popped it; the
|
|
73
|
+
// rest found the stack empty), so the one-operation keypress behaves as it
|
|
74
|
+
// always did. When it unmounts, the next-oldest takes over.
|
|
75
|
+
const keyboardInstances = new Set();
|
|
76
|
+
/** A field whose own Ctrl+Z (the browser's text undo) must not be taken over. */
|
|
77
|
+
function isEditableTarget(target) {
|
|
78
|
+
const el = target;
|
|
79
|
+
if (!el || typeof el.tagName !== 'string')
|
|
80
|
+
return false;
|
|
81
|
+
if (el.isContentEditable)
|
|
82
|
+
return true;
|
|
83
|
+
const tag = el.tagName.toUpperCase();
|
|
84
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
|
85
|
+
}
|
|
86
|
+
function onUndoShortcut(e) {
|
|
87
|
+
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
|
|
88
|
+
return;
|
|
89
|
+
// Inside a text field Ctrl+Z is the field's own undo: no pop, no preventDefault.
|
|
90
|
+
if (isEditableTarget(e.target))
|
|
91
|
+
return;
|
|
92
|
+
const owner = keyboardInstances.values().next().value;
|
|
93
|
+
if (!owner)
|
|
94
|
+
return;
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
if (e.shiftKey) {
|
|
97
|
+
void runRedo(owner);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
void runUndo(owner);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function registerKeyboardInstance(optionsRef) {
|
|
104
|
+
keyboardInstances.add(optionsRef);
|
|
105
|
+
if (keyboardInstances.size === 1)
|
|
106
|
+
window.addEventListener('keydown', onUndoShortcut);
|
|
107
|
+
return () => {
|
|
108
|
+
keyboardInstances.delete(optionsRef);
|
|
109
|
+
if (keyboardInstances.size === 0)
|
|
110
|
+
window.removeEventListener('keydown', onUndoShortcut);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
31
113
|
/**
|
|
32
114
|
* React hook that wraps the global UndoManager for use in console components.
|
|
33
115
|
*
|
|
34
116
|
* Provides reactive undo/redo state, executes data operations through the
|
|
35
117
|
* supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
|
|
118
|
+
*
|
|
119
|
+
* Keyboard: however many instances are mounted, one `window` `keydown`
|
|
120
|
+
* listener serves them all and one keypress pops at most one operation. It is
|
|
121
|
+
* answered by the longest-mounted live instance — its `dataSource` runs the
|
|
122
|
+
* write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
|
|
123
|
+
* target is an `input`, `textarea`, `select` or contenteditable region is left
|
|
124
|
+
* to that field: nothing is popped and the default is not prevented.
|
|
125
|
+
*
|
|
126
|
+
* The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
|
|
127
|
+
* THIS instance's `dataSource` and callbacks.
|
|
36
128
|
*/
|
|
37
129
|
export function useGlobalUndo(options = {}) {
|
|
38
130
|
const optionsRef = useRef(options);
|
|
39
|
-
|
|
131
|
+
// Refreshed in the MUTATION phase, never in the render body. Every caller
|
|
132
|
+
// passes an inline object literal with inline `onUndo` / `onRedo` closures,
|
|
133
|
+
// so `options` is a new object on every render; `undo` / `redo` and the
|
|
134
|
+
// keyboard registration hold the ref (whose identity React does keep) and
|
|
135
|
+
// still reach the newest callbacks. Writing it during render published the
|
|
136
|
+
// options of renders React discards or replays.
|
|
137
|
+
// `useInsertionEffect` runs ahead of every layout effect, ref attachment and
|
|
138
|
+
// paint in the commit, so the only window this moves is the render phase
|
|
139
|
+
// itself — where `undo` / `redo`, being async data mutations, are not
|
|
140
|
+
// callable. `useEffectEvent` would be idiomatic but is React 19.2+, and this
|
|
141
|
+
// package's peer range starts at React 18.
|
|
142
|
+
useInsertionEffect(() => {
|
|
143
|
+
optionsRef.current = options;
|
|
144
|
+
});
|
|
40
145
|
const state = useSyncExternalStore(subscribe, getCachedSnapshot, getServerSnapshot);
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
? { create: 'delete', update: 'update', delete: 'create' }[op.type]
|
|
47
|
-
: op.type;
|
|
48
|
-
if (action === 'delete')
|
|
49
|
-
await ds.delete(op.objectName, op.recordId);
|
|
50
|
-
else if (action === 'update')
|
|
51
|
-
await ds.update(op.objectName, op.recordId, data);
|
|
52
|
-
else
|
|
53
|
-
await ds.create(op.objectName, data);
|
|
54
|
-
}, []);
|
|
55
|
-
const undo = useCallback(async () => {
|
|
56
|
-
const op = globalUndoManager.popUndo();
|
|
57
|
-
if (!op)
|
|
58
|
-
return;
|
|
59
|
-
await executeOp(op, op.undoData, 'undo');
|
|
60
|
-
optionsRef.current.onUndo?.(op);
|
|
61
|
-
}, [executeOp]);
|
|
62
|
-
const redo = useCallback(async () => {
|
|
63
|
-
const op = globalUndoManager.popRedo();
|
|
64
|
-
if (!op)
|
|
65
|
-
return;
|
|
66
|
-
await executeOp(op, op.redoData, 'redo');
|
|
67
|
-
optionsRef.current.onRedo?.(op);
|
|
68
|
-
}, [executeOp]);
|
|
69
|
-
// Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo)
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
const handler = (e) => {
|
|
72
|
-
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
|
|
73
|
-
return;
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
if (e.shiftKey) {
|
|
76
|
-
void redo();
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
void undo();
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
window.addEventListener('keydown', handler);
|
|
83
|
-
return () => window.removeEventListener('keydown', handler);
|
|
84
|
-
}, [undo, redo]);
|
|
146
|
+
const undo = useCallback(() => runUndo(optionsRef), []);
|
|
147
|
+
const redo = useCallback(() => runRedo(optionsRef), []);
|
|
148
|
+
// Registered once per mount, keyed on the ref and on no memoised identity, so
|
|
149
|
+
// this instance's place in the keyboard order is its mount order.
|
|
150
|
+
useEffect(() => registerKeyboardInstance(optionsRef), []);
|
|
85
151
|
return useMemo(() => ({ ...state, undo, redo }), [state, undo, redo]);
|
|
86
152
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui';
|
|
9
|
+
import type { RecordNavigateAction } from '@object-ui/types';
|
|
9
10
|
import type { SpecAuthoredInput } from '../spec-input.js';
|
|
10
11
|
/**
|
|
11
12
|
* The spec's `NavigationConfigSchema`, authoring side — by reference, with no
|
|
@@ -22,8 +23,10 @@ import type { SpecAuthoredInput } from '../spec-input.js';
|
|
|
22
23
|
* `mode` is OPTIONAL, because the spec says so and this hook agrees.
|
|
23
24
|
* `packages/spec/src/ui/view.zod.ts` declares
|
|
24
25
|
* `mode: NavigationModeSchema.default('page')`, and a `.default()` lands on the
|
|
25
|
-
* AUTHORING side as `| undefined` — so `navigation: {
|
|
26
|
-
*
|
|
26
|
+
* AUTHORING side as `| undefined` — so `navigation: { size: 'lg' }` is legal
|
|
27
|
+
* authored metadata that lets the mode default. ⛔ This sentence used to make
|
|
28
|
+
* the same point with a `view` member; see "The RETIRED `view` key" below for
|
|
29
|
+
* why no example in this file may spell that key again.
|
|
27
30
|
*
|
|
28
31
|
* Until objectui#4550 this alias `Omit`ted `mode` and re-added it as
|
|
29
32
|
* `NonNullable<…>`, on the stated reasoning that "this hook dispatches on
|
|
@@ -51,9 +54,9 @@ import type { SpecAuthoredInput } from '../spec-input.js';
|
|
|
51
54
|
*
|
|
52
55
|
* Two per-key notes the hand copy carried, kept here because a derived alias
|
|
53
56
|
* has no members to hang them on:
|
|
54
|
-
* - `size` is the coarse overlay bucket added by #2578; `resolveOverlayWidth`
|
|
57
|
+
* - `size` is the coarse overlay bucket added by objectstack-ai/objectstack#2578; `resolveOverlayWidth`
|
|
55
58
|
* below maps it through {@link OVERLAY_SIZE_WIDTHS}.
|
|
56
|
-
* - `width` is DEPRECATED by #2578 in favour of `size`. It still wins when
|
|
59
|
+
* - `width` is DEPRECATED by objectstack-ai/objectstack#2578 in favour of `size`. It still wins when
|
|
57
60
|
* present, because app-shell pre-resolves `size` into it.
|
|
58
61
|
*/
|
|
59
62
|
export type NavigationConfig = SpecAuthoredInput<typeof NavigationConfigSchema>;
|
|
@@ -95,10 +98,50 @@ export interface UseNavigationOverlayOptions {
|
|
|
95
98
|
navigation?: NavigationConfig;
|
|
96
99
|
/** Object name — used to build default URLs for page/new_window modes */
|
|
97
100
|
objectName?: string;
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Called for page-level navigation with the record id and a
|
|
103
|
+
* {@link RecordNavigateAction} — a navigation-MODE token from a closed
|
|
104
|
+
* vocabulary: `'view'` opens the record page, `'new_window'` opens it in a
|
|
105
|
+
* new browser tab.
|
|
106
|
+
*
|
|
107
|
+
* `handleClick` below is the one producer of that token, and every call it
|
|
108
|
+
* makes is typed against this declaration — so a third token is a compile
|
|
109
|
+
* error here, not a branch a host's handler silently never matches
|
|
110
|
+
* (objectui#9547). Supplied by the host that renders the list (a
|
|
111
|
+
* `schema.onNavigate`, a component prop, or the host's own router closure).
|
|
112
|
+
*
|
|
113
|
+
* When it is absent, an authored `page` click goes to the record navigator
|
|
114
|
+
* the host publishes on `RelatedRecordActionsContext` instead
|
|
115
|
+
* (objectui#11293; see the module docblock). Supplied, it wins.
|
|
116
|
+
*/
|
|
117
|
+
onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void;
|
|
118
|
+
/**
|
|
119
|
+
* External onRowClick callback — if set, takes full priority.
|
|
120
|
+
*
|
|
121
|
+
* Declares BOTH arguments `handleClick` hands it: the record, and the
|
|
122
|
+
* optional modifier payload (`HandleClickModifiers`, declared just below)
|
|
123
|
+
* that lets a host implement Cmd/Ctrl/middle-click for itself. The second
|
|
124
|
+
* argument was always delivered; until objectui#9357 this line declared only
|
|
125
|
+
* the first and `handleClick` asserted the declaration away in order to make
|
|
126
|
+
* the call — so the payload was invisible on the one line a host reads, and
|
|
127
|
+
* every consumer that passes its own handler through copied the understated
|
|
128
|
+
* spelling outward.
|
|
129
|
+
*
|
|
130
|
+
* A one-parameter handler stays assignable here, and so does one that leaves
|
|
131
|
+
* its second parameter unannotated. The exception, and the one class that has
|
|
132
|
+
* to change: a handler whose second parameter is annotated NARROWER than
|
|
133
|
+
* `HandleClickModifiers` — React's `MouseEvent` is the shape this hits in
|
|
134
|
+
* practice, because the payload used to be discoverable only from the
|
|
135
|
+
* implementation — is refused from this card on with TS2322. The parameter is
|
|
136
|
+
* checked contravariantly, so the annotation has to ADMIT
|
|
137
|
+
* `HandleClickModifiers`. The fix is one line at that call site: annotate the
|
|
138
|
+
* parameter `HandleClickModifiers` (exported from this module, and from
|
|
139
|
+
* `@object-ui/react`), or drop the annotation and let it be inferred; either
|
|
140
|
+
* way the handler keeps receiving exactly what it received before. What the
|
|
141
|
+
* widening buys everyone else is that a caller who WANTS the payload can now
|
|
142
|
+
* see, from the published type, that it exists.
|
|
143
|
+
*/
|
|
144
|
+
onRowClick?: (record: Record<string, unknown>, event?: HandleClickModifiers) => void;
|
|
102
145
|
}
|
|
103
146
|
/**
|
|
104
147
|
* Optional event-like payload accepted by `handleClick`. We don't depend on
|
|
@@ -139,18 +182,40 @@ export interface NavigationOverlayState {
|
|
|
139
182
|
width: string | number | undefined;
|
|
140
183
|
/** Whether navigation is an overlay mode (drawer/modal/split/popover) */
|
|
141
184
|
isOverlay: boolean;
|
|
142
|
-
/** The target view/form name from NavigationConfig */
|
|
143
|
-
view: string | undefined;
|
|
144
185
|
}
|
|
145
186
|
/**
|
|
146
187
|
* Hook for NavigationConfig-driven navigation overlay.
|
|
147
188
|
*
|
|
189
|
+
* ## What to hand `objectName`
|
|
190
|
+
*
|
|
191
|
+
* The block's RECORD SOURCE — the object the clicked rows actually came from —
|
|
192
|
+
* and never a bare top-level `schema.objectName` read in its place. `77cb489b4`
|
|
193
|
+
* published `objectName` as the THIRD RUNG of one record-source ladder (`data`,
|
|
194
|
+
* then `staticData`, then `objectName`), not as a parallel "page object"
|
|
195
|
+
* concept, so a block has exactly ONE record source. `handleClick` below builds
|
|
196
|
+
* the record-page URL `/{objectName}/record/{id}` out of whatever it is handed:
|
|
197
|
+
* a caller that hands it the top-level key while its rows came from
|
|
198
|
+
* `data.object` navigates to a record that the URL's own object does not
|
|
199
|
+
* contain (`2ce2612df`).
|
|
200
|
+
*
|
|
201
|
+
* A caller that resolves a data config reads that ladder through the ONE shared
|
|
202
|
+
* reader — `resolveRecordSourceObjectName` from `@object-ui/core`
|
|
203
|
+
* (`b041b9c0c`) — as the example does. A caller with NO data config has
|
|
204
|
+
* nothing above rung three, and its `schema.objectName` already IS its record
|
|
205
|
+
* source; that spelling is correct there and needs no conversion.
|
|
206
|
+
*
|
|
148
207
|
* @example
|
|
149
208
|
* ```tsx
|
|
209
|
+
* import {
|
|
210
|
+
* resolveRecordSourceConfig,
|
|
211
|
+
* resolveRecordSourceObjectName,
|
|
212
|
+
* } from '@object-ui/core';
|
|
213
|
+
*
|
|
214
|
+
* const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]);
|
|
150
215
|
* const { handleClick, isOpen, selectedRecord, mode, close, width } =
|
|
151
216
|
* useNavigationOverlay({
|
|
152
217
|
* navigation: schema.navigation,
|
|
153
|
-
* objectName: schema
|
|
218
|
+
* objectName: resolveRecordSourceObjectName(schema, dataConfig),
|
|
154
219
|
* onNavigate: schema.onNavigate,
|
|
155
220
|
* onRowClick: props.onRowClick,
|
|
156
221
|
* });
|
|
@@ -14,8 +14,80 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Used by plugin-grid, plugin-list, plugin-detail and any component that needs
|
|
16
16
|
* NavigationConfig support.
|
|
17
|
+
*
|
|
18
|
+
* ## The RETIRED `view` key (objectui#9874)
|
|
19
|
+
*
|
|
20
|
+
* `view.list.navigation.view` was removed in `@objectstack/spec` 17.5.0 under
|
|
21
|
+
* ADR-0049 (enforce-or-remove). This hook was its one shipped consumer, and the
|
|
22
|
+
* spec's own tombstone describes THIS file: the authored value "was passed
|
|
23
|
+
* straight into the navigation-MODE argument of the console's `onNavigate`,
|
|
24
|
+
* where anything other than `edit` or `view` matched no branch, so the key
|
|
25
|
+
* selected nothing and could silently deaden the row click."
|
|
26
|
+
*
|
|
27
|
+
* ⭐ The point that is one character wide in the source and total in meaning:
|
|
28
|
+
* `onNavigate`'s second argument is the navigation MODE token, not a view name.
|
|
29
|
+
* An authored `view` did not SELECT a view, it SUBSTITUTED for the mode — so
|
|
30
|
+
* `{ mode: 'page', view: 'summary_view' }` dispatched `'summary_view'` into the
|
|
31
|
+
* slot a host reads against its closed `edit`/`view` vocabulary, matching
|
|
32
|
+
* neither branch. Two reads (`view ?? 'view'`, in the no-config and the `page`
|
|
33
|
+
* branch) are now the literal `'view'`, which is what every config without the
|
|
34
|
+
* key already dispatched and what every config dispatches now.
|
|
35
|
+
*
|
|
36
|
+
* The replacement route is NOT in this hook and no fallback belongs here. Per
|
|
37
|
+
* the same tombstone: to choose what opens for a record, assign a `record` page
|
|
38
|
+
* to the object and let `isDefault` pick the one that opens — page assignment
|
|
39
|
+
* is the machinery that resolves a detail layout, and a list view's
|
|
40
|
+
* `navigation` block only decides HOW the detail is surfaced (`mode`, `size`).
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ `NavigationOverlayState` lost its `view` member with the read. It could
|
|
43
|
+
* only ever carry the retired key, so keeping it would publish a field that is
|
|
44
|
+
* permanently `undefined` — the same "declared, consumed, and wrong" state the
|
|
45
|
+
* retirement exists to end. Measured before removing it: zero readers of that
|
|
46
|
+
* member in this repository (`(navOverlay|nav|state).view` → no lines, against
|
|
47
|
+
* a lit control on its sibling members in the same command). Hosts outside this
|
|
48
|
+
* repository were NOT measured.
|
|
49
|
+
*
|
|
50
|
+
* ## `page` with no `onNavigate`: the HOST's record navigator (objectui#11293)
|
|
51
|
+
*
|
|
52
|
+
* `page` is the spec's default `mode`, so it is what `{ mode: 'page' }` and
|
|
53
|
+
* every authored block written without `mode` (`{ size: 'lg' }`) resolve to.
|
|
54
|
+
* The branch used to call `onNavigate` and nothing else, and a host that
|
|
55
|
+
* renders a block without wiring one — a board, a calendar, a gallery or a
|
|
56
|
+
* grid placed on a page as a standalone block — has no `onNavigate`: the
|
|
57
|
+
* click did nothing at all. Measured before the change: a standalone
|
|
58
|
+
* `object-kanban` / `object-calendar` with either spelling opened no overlay,
|
|
59
|
+
* called no navigation and no `window.open`, and a standalone `object-grid`
|
|
60
|
+
* did the same, because the grid's own `onNavigate` is host-supplied too.
|
|
61
|
+
*
|
|
62
|
+
* So when no `onNavigate` is supplied, a `page` click is handed to the record
|
|
63
|
+
* navigator the host PUBLISHES on `RelatedRecordActionsContext`
|
|
64
|
+
* (`openRecord(objectName, recordId)`). That is the seam the grid's link
|
|
65
|
+
* column and the lookup cells already read: the route is built by the host
|
|
66
|
+
* that owns it and never re-derived here, which is why this file still builds
|
|
67
|
+
* no record URL for `page`. Precedence is unchanged above it: an `onRowClick`
|
|
68
|
+
* takes the click first, and a supplied `onNavigate` wins over the published
|
|
69
|
+
* navigator, so every host that wires one (the grid's, the console's list
|
|
70
|
+
* views) behaves exactly as before. A host that publishes no navigator (the
|
|
71
|
+
* Studio preview, a bare React mount) has no record page to open, and the
|
|
72
|
+
* click still opens nothing: the context's documented absent state.
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ Scoped to an AUTHORED block that resolves to `page`. An absent
|
|
75
|
+
* `navigation` stays the host's call (`onNavigate`, or nothing), and that is
|
|
76
|
+
* load-bearing rather than cautious: a host WITHHOLDS navigation by passing
|
|
77
|
+
* neither callback. `RelatedList` renders its mobile rows as an
|
|
78
|
+
* `object-gallery` with no `navigation` and an `onRowClick` that is undefined
|
|
79
|
+
* when the bridge withheld `onView`; reading the published navigator on the
|
|
80
|
+
* absent-key path would open those records anyway, under the very bridge that
|
|
81
|
+
* withheld them. So `{}` and no config still agree whenever an `onNavigate`
|
|
82
|
+
* is supplied, and part only where a host published a navigator but wired no
|
|
83
|
+
* callback. The kanban, calendar and gantt default an absent key to a drawer
|
|
84
|
+
* before it reaches this hook in any case. Modifier clicks and `new_window`
|
|
85
|
+
* are untouched as well. Pinned by
|
|
86
|
+
* `__tests__/useNavigationOverlay.hostRecordNavigator-11293.test.tsx` here and
|
|
87
|
+
* at the mounts by the kanban's and calendar's `NavigationMembers-8652` pins.
|
|
17
88
|
*/
|
|
18
89
|
import { useState, useCallback, useMemo } from 'react';
|
|
90
|
+
import { useRelatedRecordActions } from '../context/RelatedRecordActionsContext.js';
|
|
19
91
|
/**
|
|
20
92
|
* Pixel cap per overlay `size` bucket, clamped to the viewport at render —
|
|
21
93
|
* mirrors `plugin-view/src/recordSurface.ts` (`OVERLAY_SIZE_PX`), which owns
|
|
@@ -53,12 +125,36 @@ export function resolveOverlayWidth(navigation) {
|
|
|
53
125
|
/**
|
|
54
126
|
* Hook for NavigationConfig-driven navigation overlay.
|
|
55
127
|
*
|
|
128
|
+
* ## What to hand `objectName`
|
|
129
|
+
*
|
|
130
|
+
* The block's RECORD SOURCE — the object the clicked rows actually came from —
|
|
131
|
+
* and never a bare top-level `schema.objectName` read in its place. `77cb489b4`
|
|
132
|
+
* published `objectName` as the THIRD RUNG of one record-source ladder (`data`,
|
|
133
|
+
* then `staticData`, then `objectName`), not as a parallel "page object"
|
|
134
|
+
* concept, so a block has exactly ONE record source. `handleClick` below builds
|
|
135
|
+
* the record-page URL `/{objectName}/record/{id}` out of whatever it is handed:
|
|
136
|
+
* a caller that hands it the top-level key while its rows came from
|
|
137
|
+
* `data.object` navigates to a record that the URL's own object does not
|
|
138
|
+
* contain (`2ce2612df`).
|
|
139
|
+
*
|
|
140
|
+
* A caller that resolves a data config reads that ladder through the ONE shared
|
|
141
|
+
* reader — `resolveRecordSourceObjectName` from `@object-ui/core`
|
|
142
|
+
* (`b041b9c0c`) — as the example does. A caller with NO data config has
|
|
143
|
+
* nothing above rung three, and its `schema.objectName` already IS its record
|
|
144
|
+
* source; that spelling is correct there and needs no conversion.
|
|
145
|
+
*
|
|
56
146
|
* @example
|
|
57
147
|
* ```tsx
|
|
148
|
+
* import {
|
|
149
|
+
* resolveRecordSourceConfig,
|
|
150
|
+
* resolveRecordSourceObjectName,
|
|
151
|
+
* } from '@object-ui/core';
|
|
152
|
+
*
|
|
153
|
+
* const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]);
|
|
58
154
|
* const { handleClick, isOpen, selectedRecord, mode, close, width } =
|
|
59
155
|
* useNavigationOverlay({
|
|
60
156
|
* navigation: schema.navigation,
|
|
61
|
-
* objectName: schema
|
|
157
|
+
* objectName: resolveRecordSourceObjectName(schema, dataConfig),
|
|
62
158
|
* onNavigate: schema.onNavigate,
|
|
63
159
|
* onRowClick: props.onRowClick,
|
|
64
160
|
* });
|
|
@@ -79,11 +175,13 @@ export function resolveOverlayWidth(navigation) {
|
|
|
79
175
|
*/
|
|
80
176
|
export function useNavigationOverlay(options) {
|
|
81
177
|
const { navigation, objectName, onNavigate, onRowClick } = options;
|
|
178
|
+
// The host's published record navigator — read for the `page` branch only,
|
|
179
|
+
// and only when no `onNavigate` was supplied (objectui#11293).
|
|
180
|
+
const openRecord = useRelatedRecordActions()?.openRecord;
|
|
82
181
|
const [isOpen, setIsOpen] = useState(false);
|
|
83
182
|
const [selectedRecord, setSelectedRecord] = useState(null);
|
|
84
183
|
const mode = navigation?.mode ?? 'page';
|
|
85
184
|
const width = resolveOverlayWidth(navigation);
|
|
86
|
-
const view = navigation?.view;
|
|
87
185
|
const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
|
|
88
186
|
const close = useCallback(() => {
|
|
89
187
|
setIsOpen(false);
|
|
@@ -97,6 +195,12 @@ export function useNavigationOverlay(options) {
|
|
|
97
195
|
// External onRowClick takes full priority. Forward the modifier event
|
|
98
196
|
// so parent handlers (e.g. ObjectView) can still implement Cmd/Ctrl/
|
|
99
197
|
// middle-click → open in new tab.
|
|
198
|
+
//
|
|
199
|
+
// Called straight through the declaration (objectui#9357). This used to
|
|
200
|
+
// carry a type assertion widening the option to two parameters at the
|
|
201
|
+
// call — the producer paying for its own understated declaration, which
|
|
202
|
+
// AGENTS.md #0.1 sends back to the producer. The producer is this file,
|
|
203
|
+
// and the option above now declares what this line passes.
|
|
100
204
|
if (onRowClick) {
|
|
101
205
|
onRowClick(record, event);
|
|
102
206
|
return;
|
|
@@ -117,7 +221,7 @@ export function useNavigationOverlay(options) {
|
|
|
117
221
|
if (!navigation) {
|
|
118
222
|
const recordId = record.id || record._id;
|
|
119
223
|
if (onNavigate && recordId != null) {
|
|
120
|
-
onNavigate(recordId,
|
|
224
|
+
onNavigate(recordId, 'view');
|
|
121
225
|
}
|
|
122
226
|
return;
|
|
123
227
|
}
|
|
@@ -143,11 +247,18 @@ export function useNavigationOverlay(options) {
|
|
|
143
247
|
window.open(url, '_blank');
|
|
144
248
|
return;
|
|
145
249
|
}
|
|
146
|
-
// page — delegate to onNavigate
|
|
250
|
+
// page — delegate to onNavigate; with none supplied, to the record
|
|
251
|
+
// navigator the host publishes (objectui#11293). No URL is built here.
|
|
147
252
|
if (mode === 'page') {
|
|
148
253
|
const recordId = record.id || record._id;
|
|
149
|
-
if (
|
|
150
|
-
|
|
254
|
+
if (recordId == null)
|
|
255
|
+
return;
|
|
256
|
+
if (onNavigate) {
|
|
257
|
+
onNavigate(recordId, 'view');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (objectName && openRecord) {
|
|
261
|
+
openRecord(objectName, recordId);
|
|
151
262
|
}
|
|
152
263
|
return;
|
|
153
264
|
}
|
|
@@ -157,7 +268,7 @@ export function useNavigationOverlay(options) {
|
|
|
157
268
|
setIsOpen(true);
|
|
158
269
|
return;
|
|
159
270
|
}
|
|
160
|
-
}, [onRowClick, navigation, mode, objectName, onNavigate,
|
|
271
|
+
}, [onRowClick, navigation, mode, objectName, onNavigate, openRecord, isOverlay]);
|
|
161
272
|
return useMemo(() => ({
|
|
162
273
|
isOpen,
|
|
163
274
|
selectedRecord,
|
|
@@ -167,7 +278,6 @@ export function useNavigationOverlay(options) {
|
|
|
167
278
|
setIsOpen,
|
|
168
279
|
handleClick,
|
|
169
280
|
width,
|
|
170
|
-
view,
|
|
171
281
|
isOverlay,
|
|
172
|
-
}), [isOpen, selectedRecord, mode, close, open, handleClick, width,
|
|
282
|
+
}), [isOpen, selectedRecord, mode, close, open, handleClick, width, isOverlay]);
|
|
173
283
|
}
|