@object-ui/app-shell 11.4.0 → 11.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 +178 -0
- package/README.md +9 -0
- package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
- package/dist/console/AppContent.js +145 -26
- package/dist/console/ConsoleShell.js +8 -1
- package/dist/context/CommandPaletteProvider.js +2 -1
- package/dist/hooks/useObjectActions.js +16 -4
- package/dist/layout/AppHeader.js +13 -5
- package/dist/layout/AppSidebar.js +10 -4
- package/dist/observability/sentry.d.ts +5 -0
- package/dist/observability/sentry.js +6 -1
- package/dist/preview/DraftChangesPanel.d.ts +29 -1
- package/dist/preview/DraftChangesPanel.js +141 -14
- package/dist/urlParams.d.ts +68 -0
- package/dist/urlParams.js +76 -0
- package/dist/utils/appRoute.d.ts +15 -0
- package/dist/utils/appRoute.js +22 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/pageTabsUrlSync.d.ts +32 -0
- package/dist/utils/pageTabsUrlSync.js +43 -0
- package/dist/utils/recordFormNavigation.d.ts +40 -0
- package/dist/utils/recordFormNavigation.js +30 -0
- package/dist/views/InterfaceListPage.d.ts +1 -0
- package/dist/views/InterfaceListPage.js +1 -1
- package/dist/views/ObjectDataPage.d.ts +29 -0
- package/dist/views/ObjectDataPage.js +227 -0
- package/dist/views/ObjectView.js +4 -3
- package/dist/views/RecordDetailView.js +61 -20
- package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
- package/dist/views/RelatedRecordActionsBridge.js +49 -16
- package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
- package/dist/views/metadata-admin/i18n.js +214 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
- package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
- package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
- package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
- package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
- package/dist/views/metadata-admin/nav-selection.js +81 -0
- package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
- package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
- package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
- package/dist/views/studio-design/BuilderLanding.js +12 -19
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
- package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
- package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
- package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
- package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
- package/dist/views/studio-design/PackageIdInput.js +40 -0
- package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
- package/dist/views/studio-design/StudioDesignSurface.js +227 -57
- package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
- package/dist/views/studio-design/packageSurfaces.js +34 -0
- package/dist/views/studio-design/packages-io.d.ts +11 -0
- package/dist/views/studio-design/packages-io.js +12 -0
- package/dist/views/studio-design/skeletons.d.ts +16 -0
- package/dist/views/studio-design/skeletons.js +51 -0
- package/package.json +38 -38
package/dist/utils/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type { ObjectDefinitionForNavigation, RecordFormTarget, ObjectDefinitionF
|
|
|
8
8
|
export { deriveRelatedLists } from './deriveRelatedLists';
|
|
9
9
|
export type { DerivedRelatedList } from './deriveRelatedLists';
|
|
10
10
|
export { preferLocal } from './preferLocal';
|
|
11
|
-
export { appRouteSegment, matchAppBySegment } from './appRoute';
|
|
11
|
+
export { appRouteSegment, matchAppBySegment, appStudioDesignPath } from './appRoute';
|
|
12
12
|
/**
|
|
13
13
|
* Resolves an I18nLabel to a plain string.
|
|
14
14
|
* I18nLabel can be either a string or an object { key, defaultValue?, params? }.
|
package/dist/utils/index.js
CHANGED
|
@@ -12,7 +12,7 @@ formatTitleTemplate as formatRecordTitle, } from '@object-ui/core';
|
|
|
12
12
|
export { resolveRecordFormTarget, resolveFormViewLayout, } from './recordFormNavigation';
|
|
13
13
|
export { deriveRelatedLists } from './deriveRelatedLists';
|
|
14
14
|
export { preferLocal } from './preferLocal';
|
|
15
|
-
export { appRouteSegment, matchAppBySegment } from './appRoute';
|
|
15
|
+
export { appRouteSegment, matchAppBySegment, appStudioDesignPath } from './appRoute';
|
|
16
16
|
/**
|
|
17
17
|
* Resolves an I18nLabel to a plain string.
|
|
18
18
|
* I18nLabel can be either a string or an object { key, defaultValue?, params? }.
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
* pageTabsUrlSync — make a record page's `page:tabs` node URL-aware
|
|
10
|
+
* (objectui#2257, ADR-0054 C3 "URL-addressable state").
|
|
11
|
+
*
|
|
12
|
+
* The active detail tab is user state that must SURVIVE the page subtree
|
|
13
|
+
* remounting — which happens for real on every `refreshKey`-style save
|
|
14
|
+
* refresh (and, in dev StrictMode, on any URL search change). An
|
|
15
|
+
* uncontrolled Radix `defaultValue` loses it; the URL keeps it.
|
|
16
|
+
*
|
|
17
|
+
* This helper walks a page schema tree and returns a NEW tree in which every
|
|
18
|
+
* `page:tabs` node carries the host-provided `defaultTab` (the value read
|
|
19
|
+
* from `?tab=`) and `onTabChange` (writes `?tab=` back, with `replace`). It
|
|
20
|
+
* never mutates the input — authored/assigned page schemas may be shared or
|
|
21
|
+
* memoized objects.
|
|
22
|
+
*
|
|
23
|
+
* Only container keys are traversed (`regions` / `components` / `children`);
|
|
24
|
+
* `page:tabs`' own `items` are its content, not a container to recurse into.
|
|
25
|
+
*/
|
|
26
|
+
export interface PageTabsUrlSyncInject {
|
|
27
|
+
/** Initial active tab (from `?tab=`); ignored by the renderer if it names no tab. */
|
|
28
|
+
defaultTab?: string;
|
|
29
|
+
/** Called by the renderer on every tab switch (host writes `?tab=`). */
|
|
30
|
+
onTabChange?: (value: string) => void;
|
|
31
|
+
}
|
|
32
|
+
export declare function withPageTabsUrlSync<T>(node: T, inject: PageTabsUrlSyncInject): T;
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
const CONTAINER_KEYS = ['regions', 'components', 'children'];
|
|
9
|
+
export function withPageTabsUrlSync(node, inject) {
|
|
10
|
+
if (Array.isArray(node)) {
|
|
11
|
+
let outArr = null;
|
|
12
|
+
node.forEach((n, i) => {
|
|
13
|
+
const next = withPageTabsUrlSync(n, inject);
|
|
14
|
+
if (next !== n) {
|
|
15
|
+
outArr = outArr ?? node.slice();
|
|
16
|
+
outArr[i] = next;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
return (outArr ?? node);
|
|
20
|
+
}
|
|
21
|
+
if (!node || typeof node !== 'object')
|
|
22
|
+
return node;
|
|
23
|
+
const rec = node;
|
|
24
|
+
let out = null;
|
|
25
|
+
if (rec.type === 'page:tabs') {
|
|
26
|
+
out = { ...rec };
|
|
27
|
+
if (inject.defaultTab !== undefined)
|
|
28
|
+
out.defaultTab = inject.defaultTab;
|
|
29
|
+
if (inject.onTabChange)
|
|
30
|
+
out.onTabChange = inject.onTabChange;
|
|
31
|
+
}
|
|
32
|
+
for (const key of CONTAINER_KEYS) {
|
|
33
|
+
const child = (out ?? rec)[key];
|
|
34
|
+
if (child === undefined || child === null)
|
|
35
|
+
continue;
|
|
36
|
+
const next = withPageTabsUrlSync(child, inject);
|
|
37
|
+
if (next !== child) {
|
|
38
|
+
out = out ?? { ...rec };
|
|
39
|
+
out[key] = next;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return (out ?? node);
|
|
43
|
+
}
|
|
@@ -115,6 +115,46 @@ export interface FormViewModalLayout {
|
|
|
115
115
|
* out the form.
|
|
116
116
|
*/
|
|
117
117
|
export declare function resolveFormViewLayout(objectDef: ObjectDefinitionForFormView | null | undefined): FormViewModalLayout;
|
|
118
|
+
/**
|
|
119
|
+
* Result of the post-create-save navigation decision (#2604 save invariant:
|
|
120
|
+
* *create takes you to the record you made*). `kind: 'none'` means stay put
|
|
121
|
+
* (no usable record id came back from the save).
|
|
122
|
+
*/
|
|
123
|
+
export type PostCreateTarget = {
|
|
124
|
+
kind: 'none';
|
|
125
|
+
} | {
|
|
126
|
+
kind: 'detail-page' | 'detail-drawer';
|
|
127
|
+
url: string;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Decide where a CREATE save lands (#2604): the new record's detail, on the
|
|
131
|
+
* record's own derived surface.
|
|
132
|
+
*
|
|
133
|
+
* - `surface: 'page'` (field-heavy) → the detail ROUTE
|
|
134
|
+
* `{baseUrl}/{objectName}/record/{id}` — deep-linkable, and the detail
|
|
135
|
+
* page's own "← all records" affordance covers the way back.
|
|
136
|
+
* - `surface: 'drawer'` (light) → the CURRENT list route with
|
|
137
|
+
* `?recordId={id}` — the detail drawer opens OVER the still-intact list
|
|
138
|
+
* (ObjectView treats that param as the drawer's source of truth), so the
|
|
139
|
+
* list context is preserved for free.
|
|
140
|
+
*
|
|
141
|
+
* The `form` overlay param is stripped from the drawer URL so the create
|
|
142
|
+
* overlay does not reopen underneath the drawer. Pure and router-free: the
|
|
143
|
+
* caller derives `surface` (deriveRecordSurface) and performs the navigation
|
|
144
|
+
* (with `replace: true`, so Back skips the transient form state).
|
|
145
|
+
*/
|
|
146
|
+
export declare function resolvePostCreateTarget(opts: {
|
|
147
|
+
objectName: string;
|
|
148
|
+
baseUrl: string;
|
|
149
|
+
/** Current location pathname (the list route the create started from). */
|
|
150
|
+
pathname: string;
|
|
151
|
+
/** Current location search (query string, with or without leading `?`). */
|
|
152
|
+
search?: string;
|
|
153
|
+
/** The record's derived VIEW surface (`deriveRecordSurface(objectDef)`). */
|
|
154
|
+
surface: 'page' | 'drawer';
|
|
155
|
+
/** Saved record id (`result.id ?? result._id`). */
|
|
156
|
+
recordId: unknown;
|
|
157
|
+
}): PostCreateTarget;
|
|
118
158
|
/**
|
|
119
159
|
* Action descriptor accepted by the navigate-create / navigate-edit
|
|
120
160
|
* handlers. Loose-typed because the same shape is constructed dynamically
|
|
@@ -76,6 +76,36 @@ export function resolveFormViewLayout(objectDef) {
|
|
|
76
76
|
}
|
|
77
77
|
return layout;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Decide where a CREATE save lands (#2604): the new record's detail, on the
|
|
81
|
+
* record's own derived surface.
|
|
82
|
+
*
|
|
83
|
+
* - `surface: 'page'` (field-heavy) → the detail ROUTE
|
|
84
|
+
* `{baseUrl}/{objectName}/record/{id}` — deep-linkable, and the detail
|
|
85
|
+
* page's own "← all records" affordance covers the way back.
|
|
86
|
+
* - `surface: 'drawer'` (light) → the CURRENT list route with
|
|
87
|
+
* `?recordId={id}` — the detail drawer opens OVER the still-intact list
|
|
88
|
+
* (ObjectView treats that param as the drawer's source of truth), so the
|
|
89
|
+
* list context is preserved for free.
|
|
90
|
+
*
|
|
91
|
+
* The `form` overlay param is stripped from the drawer URL so the create
|
|
92
|
+
* overlay does not reopen underneath the drawer. Pure and router-free: the
|
|
93
|
+
* caller derives `surface` (deriveRecordSurface) and performs the navigation
|
|
94
|
+
* (with `replace: true`, so Back skips the transient form state).
|
|
95
|
+
*/
|
|
96
|
+
export function resolvePostCreateTarget(opts) {
|
|
97
|
+
const { objectName, baseUrl, pathname, search, surface, recordId } = opts;
|
|
98
|
+
if (recordId == null || recordId === '' || !objectName)
|
|
99
|
+
return { kind: 'none' };
|
|
100
|
+
const encoded = encodeURIComponent(String(recordId));
|
|
101
|
+
if (surface === 'page') {
|
|
102
|
+
return { kind: 'detail-page', url: `${baseUrl}/${objectName}/record/${encoded}` };
|
|
103
|
+
}
|
|
104
|
+
const sp = new URLSearchParams(search ?? '');
|
|
105
|
+
sp.delete('form');
|
|
106
|
+
sp.set('recordId', String(recordId));
|
|
107
|
+
return { kind: 'detail-drawer', url: `${pathname}?${sp.toString()}` };
|
|
108
|
+
}
|
|
79
109
|
/**
|
|
80
110
|
* Resolve the URL for a `navigate_create` action.
|
|
81
111
|
*
|
|
@@ -27,6 +27,7 @@ interface InterfaceListPageProps {
|
|
|
27
27
|
* toolbar buttons don't sit under it. */
|
|
28
28
|
reserveEditAffordance?: boolean;
|
|
29
29
|
}
|
|
30
|
+
export declare function defaultColumnsFromObject(objectDef: any): string[];
|
|
30
31
|
export declare function defaultKanbanFromObject(objectDef: any): {
|
|
31
32
|
groupField: string;
|
|
32
33
|
groupByField: string;
|
|
@@ -62,7 +62,7 @@ const SYSTEM_FIELDS = new Set([
|
|
|
62
62
|
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
|
|
63
63
|
'updated_by', 'updatedBy', '_version', '_rev',
|
|
64
64
|
]);
|
|
65
|
-
function defaultColumnsFromObject(objectDef) {
|
|
65
|
+
export function defaultColumnsFromObject(objectDef) {
|
|
66
66
|
const curated = objectDef?.highlightFields;
|
|
67
67
|
if (Array.isArray(curated) && curated.length > 0) {
|
|
68
68
|
return curated.filter((n) => objectDef.fields?.[n]);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Object Data Page — the parameterized bare data surface (ADR-0055, #2251).
|
|
3
|
+
*
|
|
4
|
+
* Route: `/apps/:appName/:objectName/data` (± `filter[<field>]=<value>` and
|
|
5
|
+
* `uf_<field>` search params).
|
|
6
|
+
*
|
|
7
|
+
* Where ObjectView anchors to a saved list view (default or `/view/:viewId`),
|
|
8
|
+
* this surface is deliberately UNANCHORED — "the URL is the view":
|
|
9
|
+
*
|
|
10
|
+
* • no saved-view filter is baked in: URL `filter[...]` conditions apply on
|
|
11
|
+
* top of everything the user is allowed to see (row-level security is the
|
|
12
|
+
* server-enforced baseline, never a view);
|
|
13
|
+
* • URL conditions render as visible, removable chips (unlike Odoo's
|
|
14
|
+
* invisible action domain);
|
|
15
|
+
* • no saved-view tab bar — switching to a saved view is an explicit
|
|
16
|
+
* navigation to `/view/:viewId` ("Save as view" is the exit);
|
|
17
|
+
* • nothing here writes back to any saved view;
|
|
18
|
+
* • the visualization switcher (grid/kanban/...) is ListView-internal, so
|
|
19
|
+
* switching presentation never touches the URL — filter state survives;
|
|
20
|
+
* • the common filter bar (ADR-0047 `userFilters` + `uf_*` persistence) is
|
|
21
|
+
* auto-derived from the object's enum-ish fields, since there is no view
|
|
22
|
+
* to author it on (ADR-0053 puts userFilters on views/pages).
|
|
23
|
+
*
|
|
24
|
+
* Field-level security: auto-derived columns, the filter bar, and URL filter
|
|
25
|
+
* predicates are all trimmed to readable fields client-side; the server is
|
|
26
|
+
* the enforcement point (it must drop predicates on unreadable fields).
|
|
27
|
+
*/
|
|
28
|
+
import * as React from 'react';
|
|
29
|
+
export declare function ObjectDataPage({ dataSource, objects }: any): React.JSX.Element;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Object Data Page — the parameterized bare data surface (ADR-0055, #2251).
|
|
4
|
+
*
|
|
5
|
+
* Route: `/apps/:appName/:objectName/data` (± `filter[<field>]=<value>` and
|
|
6
|
+
* `uf_<field>` search params).
|
|
7
|
+
*
|
|
8
|
+
* Where ObjectView anchors to a saved list view (default or `/view/:viewId`),
|
|
9
|
+
* this surface is deliberately UNANCHORED — "the URL is the view":
|
|
10
|
+
*
|
|
11
|
+
* • no saved-view filter is baked in: URL `filter[...]` conditions apply on
|
|
12
|
+
* top of everything the user is allowed to see (row-level security is the
|
|
13
|
+
* server-enforced baseline, never a view);
|
|
14
|
+
* • URL conditions render as visible, removable chips (unlike Odoo's
|
|
15
|
+
* invisible action domain);
|
|
16
|
+
* • no saved-view tab bar — switching to a saved view is an explicit
|
|
17
|
+
* navigation to `/view/:viewId` ("Save as view" is the exit);
|
|
18
|
+
* • nothing here writes back to any saved view;
|
|
19
|
+
* • the visualization switcher (grid/kanban/...) is ListView-internal, so
|
|
20
|
+
* switching presentation never touches the URL — filter state survives;
|
|
21
|
+
* • the common filter bar (ADR-0047 `userFilters` + `uf_*` persistence) is
|
|
22
|
+
* auto-derived from the object's enum-ish fields, since there is no view
|
|
23
|
+
* to author it on (ADR-0053 puts userFilters on views/pages).
|
|
24
|
+
*
|
|
25
|
+
* Field-level security: auto-derived columns, the filter bar, and URL filter
|
|
26
|
+
* predicates are all trimmed to readable fields client-side; the server is
|
|
27
|
+
* the enforcement point (it must drop predicates on unreadable fields).
|
|
28
|
+
*/
|
|
29
|
+
import * as React from 'react';
|
|
30
|
+
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
|
31
|
+
import { ListView } from '@object-ui/plugin-list';
|
|
32
|
+
import { useNavigationOverlay } from '@object-ui/react';
|
|
33
|
+
import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay, } from '@object-ui/components';
|
|
34
|
+
import { Database, Lock, Plus, Save, X } from 'lucide-react';
|
|
35
|
+
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
36
|
+
import { usePermissions, useFieldPermissions } from '@object-ui/permissions';
|
|
37
|
+
import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth';
|
|
38
|
+
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
|
|
39
|
+
import { defaultColumnsFromObject, defaultKanbanFromObject, defaultCalendarFromObject, defaultGalleryFromObject, } from './InterfaceListPage';
|
|
40
|
+
import { RecordDetailView } from './RecordDetailView';
|
|
41
|
+
import { PageHeader } from '../layout/PageHeader';
|
|
42
|
+
import { getIcon } from '../utils/getIcon';
|
|
43
|
+
import { useMetadataClient } from './metadata-admin/useMetadata';
|
|
44
|
+
import { createRuntimeMetadata } from './runtime-metadata-persistence';
|
|
45
|
+
import { CreateViewDialog } from './CreateViewDialog';
|
|
46
|
+
/** Parse `filter[<field>]=<value>` search params into equality triples. */
|
|
47
|
+
function parseUrlFilterTriples(searchParams) {
|
|
48
|
+
const out = [];
|
|
49
|
+
searchParams.forEach((value, key) => {
|
|
50
|
+
const m = /^filter\[(.+)\]$/.exec(key);
|
|
51
|
+
if (m && m[1] && value !== '')
|
|
52
|
+
out.push([m[1], '=', value]);
|
|
53
|
+
});
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** Field types the auto-derived user-filter bar offers as dropdowns. */
|
|
57
|
+
const USER_FILTER_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']);
|
|
58
|
+
const MAX_USER_FILTERS = 4;
|
|
59
|
+
export function ObjectDataPage({ dataSource, objects }) {
|
|
60
|
+
const { appName, objectName } = useParams();
|
|
61
|
+
const { t } = useObjectTranslation();
|
|
62
|
+
const { objectLabel, fieldLabel } = useObjectLabel();
|
|
63
|
+
const navigate = useNavigate();
|
|
64
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
65
|
+
const { can } = usePermissions();
|
|
66
|
+
const { canRead } = useFieldPermissions(objectName ?? '');
|
|
67
|
+
const { user } = useAuth();
|
|
68
|
+
const isAdmin = useIsWorkspaceAdmin();
|
|
69
|
+
const metadataClient = useMetadataClient();
|
|
70
|
+
const [showCreateViewDialog, setShowCreateViewDialog] = React.useState(false);
|
|
71
|
+
const objectDef = React.useMemo(() => (objects || []).find((o) => o.name === objectName), [objects, objectName]);
|
|
72
|
+
// ADR-0047 filter persistence — same wiring as InterfaceListPage: restore
|
|
73
|
+
// `uf_*` once at mount, mirror selection changes back (replace, no history
|
|
74
|
+
// spam).
|
|
75
|
+
const [initialUfSelections] = React.useState(() => parseUserFilterParams(new URLSearchParams(window.location.search)));
|
|
76
|
+
const handleUserFilterSelectionsChange = React.useCallback((selections) => {
|
|
77
|
+
setSearchParams((prev) => applyUserFilterParams(prev, selections), { replace: true });
|
|
78
|
+
}, [setSearchParams]);
|
|
79
|
+
// URL filter triples, trimmed to readable fields. Predicates on unreadable
|
|
80
|
+
// fields are dropped here for UX honesty; the SERVER is the actual
|
|
81
|
+
// enforcement point against filter-oracle probing.
|
|
82
|
+
const filterParamsKey = Array.from(searchParams.entries())
|
|
83
|
+
.filter(([k]) => k.startsWith('filter['))
|
|
84
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
85
|
+
.join('&');
|
|
86
|
+
const urlFilters = React.useMemo(() => {
|
|
87
|
+
const all = parseUrlFilterTriples(new URLSearchParams(filterParamsKey));
|
|
88
|
+
const readable = all.filter(([field]) => canRead(field));
|
|
89
|
+
if (readable.length < all.length) {
|
|
90
|
+
const dropped = all.filter(([field]) => !canRead(field)).map(([field]) => field);
|
|
91
|
+
console.warn(`[ObjectDataPage] Dropped URL filter(s) on unreadable field(s): ${dropped.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
// Template variables mirror nav `recordId` substitution so shared links
|
|
94
|
+
// can carry `{current_user_id}`.
|
|
95
|
+
return readable.map(([field, op, value]) => value === '{current_user_id}' ? [field, op, user?.id ?? value] : [field, op, value]);
|
|
96
|
+
}, [filterParamsKey, canRead, user?.id]);
|
|
97
|
+
const removeUrlFilter = React.useCallback((field) => {
|
|
98
|
+
setSearchParams((prev) => {
|
|
99
|
+
const next = new URLSearchParams(prev);
|
|
100
|
+
next.delete(`filter[${field}]`);
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
}, [setSearchParams]);
|
|
104
|
+
// Auto-derived columns + filter bar, both trimmed by field-level security.
|
|
105
|
+
const columns = React.useMemo(() => defaultColumnsFromObject(objectDef).filter((f) => canRead(f)), [objectDef, canRead]);
|
|
106
|
+
const userFilters = React.useMemo(() => {
|
|
107
|
+
const fields = objectDef?.fields;
|
|
108
|
+
if (!fields || typeof fields !== 'object')
|
|
109
|
+
return undefined;
|
|
110
|
+
const picks = Object.entries(fields)
|
|
111
|
+
.filter(([name, f]) => f && !f.hidden && USER_FILTER_TYPES.has(f.type) && canRead(name))
|
|
112
|
+
.slice(0, MAX_USER_FILTERS)
|
|
113
|
+
.map(([name]) => ({ field: name }));
|
|
114
|
+
return picks.length > 0 ? { element: 'dropdown', fields: picks } : undefined;
|
|
115
|
+
}, [objectDef, canRead]);
|
|
116
|
+
// Record open behavior — URL-driven drawer, same convention as ObjectView
|
|
117
|
+
// and InterfaceListPage (`?recordId=…` is shareable and refresh-safe).
|
|
118
|
+
const recordUrl = React.useCallback((id) => `/apps/${appName}/${objectName}/record/${encodeURIComponent(String(id))}`, [appName, objectName]);
|
|
119
|
+
const navOverlay = useNavigationOverlay({
|
|
120
|
+
navigation: { mode: 'drawer' },
|
|
121
|
+
objectName: objectName ?? '',
|
|
122
|
+
onNavigate: (id) => navigate(recordUrl(id)),
|
|
123
|
+
});
|
|
124
|
+
const drawerRecordId = searchParams.get('recordId');
|
|
125
|
+
const handleRecordClick = React.useCallback((record, event) => {
|
|
126
|
+
const id = record?.id ?? record?._id;
|
|
127
|
+
const isMod = !!(event && (event.metaKey || event.ctrlKey || event.button === 1));
|
|
128
|
+
if (isMod && id != null) {
|
|
129
|
+
window.open(recordUrl(id), '_blank');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (id != null) {
|
|
133
|
+
setSearchParams((prev) => { const n = new URLSearchParams(prev); n.set('recordId', String(id)); return n; });
|
|
134
|
+
}
|
|
135
|
+
}, [recordUrl, setSearchParams]);
|
|
136
|
+
const closeRecordDrawer = React.useCallback(() => {
|
|
137
|
+
setSearchParams((prev) => { const n = new URLSearchParams(prev); n.delete('recordId'); return n; });
|
|
138
|
+
}, [setSearchParams]);
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
if (drawerRecordId && !navOverlay.isOpen)
|
|
141
|
+
navOverlay.open({ id: drawerRecordId });
|
|
142
|
+
else if (!drawerRecordId && navOverlay.isOpen)
|
|
143
|
+
navOverlay.close();
|
|
144
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
|
+
}, [drawerRecordId]);
|
|
146
|
+
// Visualization whitelist: grid always; others only when a field binding
|
|
147
|
+
// resolves from the object. The switcher is ListView-internal, so switching
|
|
148
|
+
// presentation never rewrites the URL — filter params survive by
|
|
149
|
+
// construction (#2251 acceptance).
|
|
150
|
+
const kanban = React.useMemo(() => defaultKanbanFromObject(objectDef), [objectDef]);
|
|
151
|
+
const calendar = React.useMemo(() => defaultCalendarFromObject(objectDef), [objectDef]);
|
|
152
|
+
const gallery = React.useMemo(() => defaultGalleryFromObject(objectDef), [objectDef]);
|
|
153
|
+
const allowedVisualizations = React.useMemo(() => {
|
|
154
|
+
const allowed = ['grid'];
|
|
155
|
+
if (kanban)
|
|
156
|
+
allowed.push('kanban');
|
|
157
|
+
if (calendar)
|
|
158
|
+
allowed.push('calendar');
|
|
159
|
+
if (gallery)
|
|
160
|
+
allowed.push('gallery');
|
|
161
|
+
return allowed;
|
|
162
|
+
}, [kanban, calendar, gallery]);
|
|
163
|
+
const schema = React.useMemo(() => {
|
|
164
|
+
if (!objectDef)
|
|
165
|
+
return undefined;
|
|
166
|
+
return {
|
|
167
|
+
type: 'list-view',
|
|
168
|
+
objectName: objectDef.name,
|
|
169
|
+
viewType: 'grid',
|
|
170
|
+
fields: columns,
|
|
171
|
+
...(urlFilters.length ? { filters: urlFilters } : {}),
|
|
172
|
+
kanban,
|
|
173
|
+
calendar,
|
|
174
|
+
gallery,
|
|
175
|
+
userFilters,
|
|
176
|
+
appearance: { allowedVisualizations },
|
|
177
|
+
showViewSwitcher: allowedVisualizations.length > 1,
|
|
178
|
+
// Full list capability — this surface trades the saved-view anchor for
|
|
179
|
+
// the complete toolbar, NOT for a reduced one.
|
|
180
|
+
showSearch: true,
|
|
181
|
+
showSort: true,
|
|
182
|
+
showFilters: true,
|
|
183
|
+
showGroup: true,
|
|
184
|
+
showHideFields: true,
|
|
185
|
+
showDensity: true,
|
|
186
|
+
showRecordCount: true,
|
|
187
|
+
// Deliberately NO onSortChange/onFilterChange persistence hooks: this
|
|
188
|
+
// surface never writes back to any saved view (#2251).
|
|
189
|
+
};
|
|
190
|
+
}, [objectDef, columns, urlFilters, kanban, calendar, gallery, userFilters, allowedVisualizations]);
|
|
191
|
+
// "Save as view" — the one exit into the workspace: materialize the current
|
|
192
|
+
// URL conditions as a new saved view, then navigate to it.
|
|
193
|
+
const handleSaveAsView = React.useCallback(async (config) => {
|
|
194
|
+
try {
|
|
195
|
+
const spec = {
|
|
196
|
+
...config,
|
|
197
|
+
columns: Array.isArray(config.columns) && config.columns.length > 0 ? config.columns : columns,
|
|
198
|
+
...(urlFilters.length ? { filter: urlFilters } : {}),
|
|
199
|
+
};
|
|
200
|
+
const draftName = String(config?.name ?? config?.id ?? '');
|
|
201
|
+
const createdId = await createRuntimeMetadata('view', draftName, spec, { metadataClient });
|
|
202
|
+
if (createdId)
|
|
203
|
+
navigate(`../view/${createdId}`, { relative: 'path' });
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error('[ObjectDataPage] Failed to save view:', err);
|
|
207
|
+
}
|
|
208
|
+
}, [columns, urlFilters, metadataClient, navigate]);
|
|
209
|
+
if (!objectDef) {
|
|
210
|
+
return (_jsx("div", { className: "h-full flex items-center justify-center p-8", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('console.objectView.objectNotFound') }), _jsx(EmptyDescription, { children: t('console.objectView.objectNotFoundDescription', { objectName }) })] }) }));
|
|
211
|
+
}
|
|
212
|
+
// Route gate — no read permission renders an explicit denial, never an
|
|
213
|
+
// empty list (#2251 security model). The server-enforced row filter is the
|
|
214
|
+
// real boundary; this is the honest UI for "you can't be here".
|
|
215
|
+
if (!can(objectDef.name, 'read')) {
|
|
216
|
+
return (_jsx("div", { className: "h-full flex items-center justify-center p-8", "data-testid": "object-data-403", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Lock, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('console.objectData.noAccessTitle', { defaultValue: 'Access denied' }) }), _jsx(EmptyDescription, { children: t('console.objectData.noAccess', {
|
|
217
|
+
defaultValue: 'You do not have permission to view this data.',
|
|
218
|
+
}) })] }) }));
|
|
219
|
+
}
|
|
220
|
+
return (_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", "data-testid": "object-data-page", children: [_jsx("div", { className: "hidden sm:block", children: _jsx(PageHeader, { title: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx("span", { className: "rounded border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: t('console.objectData.badge', { defaultValue: 'Data' }) })] }), description: t('console.objectData.description', {
|
|
221
|
+
defaultValue: 'URL-defined data slice — not bound to any saved view.',
|
|
222
|
+
}), icon: React.createElement(getIcon(objectDef?.icon), { className: 'h-4 w-4' }), actions: _jsxs(_Fragment, { children: [can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: () => navigate('../new', { relative: 'path' }), className: "shadow-none gap-1.5 h-8 sm:h-9", "data-testid": "object-data-new-button", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), isAdmin && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowCreateViewDialog(true), className: "shadow-none gap-1.5 h-8 sm:h-9", "data-testid": "object-data-save-as-view", children: [_jsx(Save, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectData.saveAsView', { defaultValue: 'Save as view' }) })] }))] }) }) }), urlFilters.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5 border-b px-3 sm:px-4 py-2 shrink-0", "data-testid": "object-data-filter-chips", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: t('console.objectData.filteredBy', { defaultValue: 'Filtered by' }) }), urlFilters.map(([field, , value]) => (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full border bg-muted/40 px-2 py-0.5 text-xs", children: [_jsx("span", { className: "font-medium", children: fieldLabel(objectDef.name, field, field) }), _jsxs("span", { className: "text-muted-foreground", children: ["= ", String(value)] }), _jsx("button", { type: "button", onClick: () => removeUrlFilter(field), className: "ml-0.5 rounded-full hover:bg-muted p-0.5", "aria-label": t('console.objectData.removeFilter', {
|
|
223
|
+
defaultValue: 'Remove filter {{field}}',
|
|
224
|
+
field,
|
|
225
|
+
}), "data-testid": `object-data-remove-filter-${field}`, children: _jsx(X, { className: "h-3 w-3" }) })] }, field)))] })), _jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: schema && (_jsx(ListView, { schema: schema, dataSource: dataSource, userFilterSelections: initialUfSelections, onUserFilterSelectionsChange: handleUserFilterSelectionsChange, onRowClick: handleRecordClick })) }), navOverlay.isOverlay && (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
|
|
226
|
+
closeRecordDrawer(); }, title: objectLabel(objectDef), children: (record) => (_jsx(RecordDetailView, { objectNameOverride: objectDef.name, recordIdOverride: String(record?.id ?? record?._id ?? drawerRecordId ?? ''), embedded: true, dataSource: dataSource, objects: objects, onEdit: () => { } })) })), isAdmin && (_jsx(CreateViewDialog, { open: showCreateViewDialog, onOpenChange: setShowCreateViewDialog, onCreate: handleSaveAsView, objectDef: objectDef }))] }));
|
|
227
|
+
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -16,6 +16,7 @@ const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({
|
|
|
16
16
|
const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })));
|
|
17
17
|
import { ListView } from '@object-ui/plugin-list';
|
|
18
18
|
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view';
|
|
19
|
+
import { RECORD_DRAWER_PARAM } from '../urlParams';
|
|
19
20
|
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
|
|
20
21
|
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
|
|
21
22
|
// Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
|
|
@@ -911,7 +912,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
|
|
|
911
912
|
? { mode: 'page' }
|
|
912
913
|
: { mode: 'drawer', width: overlayWidthFor('auto', objectDef) };
|
|
913
914
|
}, [activeView?.navigation, objectDef]);
|
|
914
|
-
const drawerRecordId = searchParams.get(
|
|
915
|
+
const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM);
|
|
915
916
|
/**
|
|
916
917
|
* URL-derived equality filters in the form `?filter[<field>]=<value>`.
|
|
917
918
|
* Used by related-list "View All" buttons to scope the destination list
|
|
@@ -980,7 +981,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
|
|
|
980
981
|
const handleDrawerClose = () => {
|
|
981
982
|
navOverlay.close();
|
|
982
983
|
const newParams = new URLSearchParams(searchParams);
|
|
983
|
-
newParams.delete(
|
|
984
|
+
newParams.delete(RECORD_DRAWER_PARAM);
|
|
984
985
|
setSearchParams(newParams);
|
|
985
986
|
};
|
|
986
987
|
/**
|
|
@@ -1004,7 +1005,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }) {
|
|
|
1004
1005
|
const id = (record?.id ?? record?._id);
|
|
1005
1006
|
if (id != null) {
|
|
1006
1007
|
const next = new URLSearchParams(searchParams);
|
|
1007
|
-
next.set(
|
|
1008
|
+
next.set(RECORD_DRAWER_PARAM, String(id));
|
|
1008
1009
|
setSearchParams(next);
|
|
1009
1010
|
return;
|
|
1010
1011
|
}
|