@object-ui/app-shell 11.3.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 +700 -0
- package/README.md +32 -0
- package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
- package/dist/console/AppContent.js +145 -26
- package/dist/console/ConsoleShell.js +25 -3
- package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
- package/dist/console/home/CloudOnboardingNext.js +14 -4
- package/dist/console/home/HomePage.js +34 -7
- package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
- package/dist/console/organizations/OrganizationsPage.js +16 -7
- package/dist/context/CommandPaletteProvider.js +2 -1
- package/dist/hooks/useConsoleActionRuntime.js +32 -3
- package/dist/hooks/useObjectActions.js +16 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- 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 +31 -1
- package/dist/preview/DraftChangesPanel.js +146 -18
- 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/deriveRelatedLists.d.ts +20 -5
- package/dist/utils/deriveRelatedLists.js +31 -13
- 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/utils/resolveViewId.d.ts +23 -0
- package/dist/utils/resolveViewId.js +37 -0
- package/dist/utils/warnSuppressedListNav.d.ts +10 -0
- package/dist/utils/warnSuppressedListNav.js +40 -0
- package/dist/views/InterfaceListPage.d.ts +1 -0
- package/dist/views/InterfaceListPage.js +7 -5
- package/dist/views/ObjectDataPage.d.ts +29 -0
- package/dist/views/ObjectDataPage.js +227 -0
- package/dist/views/ObjectView.js +65 -13
- package/dist/views/RecordDetailView.js +191 -123
- package/dist/views/RecordFormPage.js +7 -1
- package/dist/views/RelatedRecordActionsBridge.d.ts +33 -0
- package/dist/views/RelatedRecordActionsBridge.js +147 -0
- package/dist/views/metadata-admin/PackagesPage.js +18 -7
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
- package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
- package/dist/views/metadata-admin/i18n.d.ts +12 -21
- package/dist/views/metadata-admin/i18n.js +557 -6
- 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 +60 -30
- 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/permission-slice.d.ts +66 -0
- package/dist/views/metadata-admin/permission-slice.js +70 -0
- package/dist/views/metadata-admin/previews/AppNavCanvas.js +19 -7
- package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
- package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
- package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
- package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
- package/dist/views/studio-design/BuilderLanding.js +126 -0
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +33 -0
- package/dist/views/studio-design/ObjectFormDesigner.js +231 -0
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectSettingsPanel.js +46 -0
- package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
- package/dist/views/studio-design/ObjectValidationsPanel.js +80 -0
- 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 +962 -145
- package/dist/views/studio-design/metadataError.d.ts +23 -0
- package/dist/views/studio-design/metadataError.js +44 -0
- 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 +38 -0
- package/dist/views/studio-design/packages-io.js +73 -0
- package/dist/views/studio-design/skeletons.d.ts +16 -0
- package/dist/views/studio-design/skeletons.js +51 -0
- package/package.json +42 -39
|
@@ -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
|
*
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
* Resolve a URL-requested view name against the object's actual view ids.
|
|
10
|
+
*
|
|
11
|
+
* Canonical view ids are fully qualified (`<object>.<viewKind>`, see
|
|
12
|
+
* MetadataProvider), but nav items emit their `viewName` verbatim — usually
|
|
13
|
+
* the short form — and legacy embedded listViews carry bare keys (including
|
|
14
|
+
* the `'all'` fallback view). Accept both directions (#2217):
|
|
15
|
+
*
|
|
16
|
+
* 1. exact id match;
|
|
17
|
+
* 2. short name (no `.`) → retry as `<objectName>.<name>`;
|
|
18
|
+
* 3. qualified name → retry with the `<objectName>.` prefix stripped.
|
|
19
|
+
*
|
|
20
|
+
* Returns the matching view id, or `undefined` when nothing matches — the
|
|
21
|
+
* caller decides how to fall back (and should warn rather than swallow it).
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveViewId(requested: string | undefined | null, viewIds: readonly string[], objectName: string): string | undefined;
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
* Resolve a URL-requested view name against the object's actual view ids.
|
|
10
|
+
*
|
|
11
|
+
* Canonical view ids are fully qualified (`<object>.<viewKind>`, see
|
|
12
|
+
* MetadataProvider), but nav items emit their `viewName` verbatim — usually
|
|
13
|
+
* the short form — and legacy embedded listViews carry bare keys (including
|
|
14
|
+
* the `'all'` fallback view). Accept both directions (#2217):
|
|
15
|
+
*
|
|
16
|
+
* 1. exact id match;
|
|
17
|
+
* 2. short name (no `.`) → retry as `<objectName>.<name>`;
|
|
18
|
+
* 3. qualified name → retry with the `<objectName>.` prefix stripped.
|
|
19
|
+
*
|
|
20
|
+
* Returns the matching view id, or `undefined` when nothing matches — the
|
|
21
|
+
* caller decides how to fall back (and should warn rather than swallow it).
|
|
22
|
+
*/
|
|
23
|
+
export function resolveViewId(requested, viewIds, objectName) {
|
|
24
|
+
if (!requested)
|
|
25
|
+
return undefined;
|
|
26
|
+
const has = (id) => viewIds.includes(id);
|
|
27
|
+
if (has(requested))
|
|
28
|
+
return requested;
|
|
29
|
+
const prefix = `${objectName}.`;
|
|
30
|
+
if (!requested.includes('.') && has(prefix + requested)) {
|
|
31
|
+
return prefix + requested;
|
|
32
|
+
}
|
|
33
|
+
if (requested.startsWith(prefix) && has(requested.slice(prefix.length))) {
|
|
34
|
+
return requested.slice(prefix.length);
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
export declare function warnSuppressedListNav(objectName: string, viewId: string, viewDef: Record<string, unknown> | null | undefined, listSchema: Record<string, unknown> | null | undefined): boolean;
|
|
9
|
+
/** Test-only: clear the one-shot warning memory. */
|
|
10
|
+
export declare function resetSuppressedListNavWarnings(): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
* ADR-0053: on an object's default list ("views" mode) the ViewTabBar is the
|
|
10
|
+
* only navigation control — `userFilters` / `quickFilters` belong to page
|
|
11
|
+
* lists (InterfaceListPage, "filters" mode) and are suppressed by
|
|
12
|
+
* `ObjectView.renderListView`.
|
|
13
|
+
*
|
|
14
|
+
* The suppression is correct, but until the phase-4 guardrail (zod `refine`
|
|
15
|
+
* + `check` rule) lands, an author who puts `userFilters` on an object list
|
|
16
|
+
* view gets a valid schema and a page that renders nothing where they expect
|
|
17
|
+
* filter controls — zero signal at any layer (#2219). Surface the drop with
|
|
18
|
+
* a one-shot console warning per object/view.
|
|
19
|
+
*
|
|
20
|
+
* Returns whether the view carried a suppressed field (mainly for tests).
|
|
21
|
+
*/
|
|
22
|
+
const warned = new Set();
|
|
23
|
+
export function warnSuppressedListNav(objectName, viewId, viewDef, listSchema) {
|
|
24
|
+
const offending = ['userFilters', 'quickFilters'].filter((k) => (viewDef?.[k] ?? listSchema?.[k]) != null);
|
|
25
|
+
if (offending.length === 0)
|
|
26
|
+
return false;
|
|
27
|
+
const key = `${objectName}.${viewId}`;
|
|
28
|
+
if (!warned.has(key)) {
|
|
29
|
+
warned.add(key);
|
|
30
|
+
console.warn(`[ObjectView] View "${viewId}" on object "${objectName}" defines ` +
|
|
31
|
+
`${offending.join(' and ')}, which are ignored on an object list view ` +
|
|
32
|
+
`(ADR-0053 "views" mode — the view switcher is the only nav control here). ` +
|
|
33
|
+
`Move them to a page list (InterfaceListPage "filters" mode).`);
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/** Test-only: clear the one-shot warning memory. */
|
|
38
|
+
export function resetSuppressedListNavWarnings() {
|
|
39
|
+
warned.clear();
|
|
40
|
+
}
|
|
@@ -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;
|
|
@@ -53,17 +53,19 @@ function resolveSourceView(objectDef, sourceView) {
|
|
|
53
53
|
/**
|
|
54
54
|
* Default column set when the resolved view carries none — mirrors
|
|
55
55
|
* ObjectView's data-mode fallback so an interface page never renders a
|
|
56
|
-
* column-less grid. Priority:
|
|
57
|
-
* business fields (system/audit columns
|
|
56
|
+
* column-less grid. Priority: the `highlightFields` semantic role
|
|
57
|
+
* (ADR-0085), else the first business fields (system/audit columns
|
|
58
|
+
* excluded).
|
|
58
59
|
*/
|
|
59
60
|
const SYSTEM_FIELDS = new Set([
|
|
60
61
|
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
|
|
61
62
|
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
|
|
62
63
|
'updated_by', 'updatedBy', '_version', '_rev',
|
|
63
64
|
]);
|
|
64
|
-
function defaultColumnsFromObject(objectDef) {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
export function defaultColumnsFromObject(objectDef) {
|
|
66
|
+
const curated = objectDef?.highlightFields;
|
|
67
|
+
if (Array.isArray(curated) && curated.length > 0) {
|
|
68
|
+
return curated.filter((n) => objectDef.fields?.[n]);
|
|
67
69
|
}
|
|
68
70
|
const fields = objectDef?.fields;
|
|
69
71
|
if (fields && typeof fields === 'object') {
|
|
@@ -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
|
+
}
|