@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +9 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +8 -1
  6. package/dist/context/CommandPaletteProvider.js +2 -1
  7. package/dist/hooks/useObjectActions.js +16 -4
  8. package/dist/layout/AppHeader.js +13 -5
  9. package/dist/layout/AppSidebar.js +10 -4
  10. package/dist/observability/sentry.d.ts +5 -0
  11. package/dist/observability/sentry.js +6 -1
  12. package/dist/preview/DraftChangesPanel.d.ts +29 -1
  13. package/dist/preview/DraftChangesPanel.js +141 -14
  14. package/dist/urlParams.d.ts +68 -0
  15. package/dist/urlParams.js +76 -0
  16. package/dist/utils/appRoute.d.ts +15 -0
  17. package/dist/utils/appRoute.js +22 -0
  18. package/dist/utils/index.d.ts +1 -1
  19. package/dist/utils/index.js +1 -1
  20. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  21. package/dist/utils/pageTabsUrlSync.js +43 -0
  22. package/dist/utils/recordFormNavigation.d.ts +40 -0
  23. package/dist/utils/recordFormNavigation.js +30 -0
  24. package/dist/views/InterfaceListPage.d.ts +1 -0
  25. package/dist/views/InterfaceListPage.js +1 -1
  26. package/dist/views/ObjectDataPage.d.ts +29 -0
  27. package/dist/views/ObjectDataPage.js +227 -0
  28. package/dist/views/ObjectView.js +4 -3
  29. package/dist/views/RecordDetailView.js +61 -20
  30. package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
  31. package/dist/views/RelatedRecordActionsBridge.js +49 -16
  32. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  33. package/dist/views/metadata-admin/i18n.js +214 -4
  34. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  35. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  36. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  37. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  38. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
  39. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  40. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  41. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  42. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  43. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  44. package/dist/views/metadata-admin/nav-selection.js +81 -0
  45. package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
  46. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  47. package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
  48. package/dist/views/studio-design/BuilderLanding.js +12 -19
  49. package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
  50. package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
  51. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
  52. package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
  53. package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
  54. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  55. package/dist/views/studio-design/PackageIdInput.js +40 -0
  56. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  57. package/dist/views/studio-design/StudioDesignSurface.js +227 -57
  58. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  59. package/dist/views/studio-design/packageSurfaces.js +34 -0
  60. package/dist/views/studio-design/packages-io.d.ts +11 -0
  61. package/dist/views/studio-design/packages-io.js +12 -0
  62. package/dist/views/studio-design/skeletons.d.ts +16 -0
  63. package/dist/views/studio-design/skeletons.js +51 -0
  64. package/package.json +38 -38
@@ -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? }.
@@ -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
+ }
@@ -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('recordId');
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('recordId');
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('recordId', String(id));
1008
+ next.set(RECORD_DRAWER_PARAM, String(id));
1008
1009
  setSearchParams(next);
1009
1010
  return;
1010
1011
  }