@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/README.md +32 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +25 -3
  6. package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
  7. package/dist/console/home/CloudOnboardingNext.js +14 -4
  8. package/dist/console/home/HomePage.js +34 -7
  9. package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
  10. package/dist/console/organizations/OrganizationsPage.js +16 -7
  11. package/dist/context/CommandPaletteProvider.js +2 -1
  12. package/dist/hooks/useConsoleActionRuntime.js +32 -3
  13. package/dist/hooks/useObjectActions.js +16 -4
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +3 -0
  16. package/dist/layout/AppHeader.js +13 -5
  17. package/dist/layout/AppSidebar.js +10 -4
  18. package/dist/observability/sentry.d.ts +5 -0
  19. package/dist/observability/sentry.js +6 -1
  20. package/dist/preview/DraftChangesPanel.d.ts +31 -1
  21. package/dist/preview/DraftChangesPanel.js +146 -18
  22. package/dist/urlParams.d.ts +68 -0
  23. package/dist/urlParams.js +76 -0
  24. package/dist/utils/appRoute.d.ts +15 -0
  25. package/dist/utils/appRoute.js +22 -0
  26. package/dist/utils/deriveRelatedLists.d.ts +20 -5
  27. package/dist/utils/deriveRelatedLists.js +31 -13
  28. package/dist/utils/index.d.ts +1 -1
  29. package/dist/utils/index.js +1 -1
  30. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  31. package/dist/utils/pageTabsUrlSync.js +43 -0
  32. package/dist/utils/recordFormNavigation.d.ts +40 -0
  33. package/dist/utils/recordFormNavigation.js +30 -0
  34. package/dist/utils/resolveViewId.d.ts +23 -0
  35. package/dist/utils/resolveViewId.js +37 -0
  36. package/dist/utils/warnSuppressedListNav.d.ts +10 -0
  37. package/dist/utils/warnSuppressedListNav.js +40 -0
  38. package/dist/views/InterfaceListPage.d.ts +1 -0
  39. package/dist/views/InterfaceListPage.js +7 -5
  40. package/dist/views/ObjectDataPage.d.ts +29 -0
  41. package/dist/views/ObjectDataPage.js +227 -0
  42. package/dist/views/ObjectView.js +65 -13
  43. package/dist/views/RecordDetailView.js +191 -123
  44. package/dist/views/RecordFormPage.js +7 -1
  45. package/dist/views/RelatedRecordActionsBridge.d.ts +33 -0
  46. package/dist/views/RelatedRecordActionsBridge.js +147 -0
  47. package/dist/views/metadata-admin/PackagesPage.js +18 -7
  48. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
  49. package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
  50. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  51. package/dist/views/metadata-admin/i18n.d.ts +12 -21
  52. package/dist/views/metadata-admin/i18n.js +557 -6
  53. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  54. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  55. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  56. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  57. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +60 -30
  58. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  59. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  60. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  61. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  62. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  63. package/dist/views/metadata-admin/nav-selection.js +81 -0
  64. package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
  65. package/dist/views/metadata-admin/permission-slice.js +70 -0
  66. package/dist/views/metadata-admin/previews/AppNavCanvas.js +19 -7
  67. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  68. package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
  69. package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
  70. package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
  71. package/dist/views/studio-design/BuilderLanding.js +126 -0
  72. package/dist/views/studio-design/ObjectFormDesigner.d.ts +33 -0
  73. package/dist/views/studio-design/ObjectFormDesigner.js +231 -0
  74. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
  75. package/dist/views/studio-design/ObjectSettingsPanel.js +46 -0
  76. package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
  77. package/dist/views/studio-design/ObjectValidationsPanel.js +80 -0
  78. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  79. package/dist/views/studio-design/PackageIdInput.js +40 -0
  80. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  81. package/dist/views/studio-design/StudioDesignSurface.js +962 -145
  82. package/dist/views/studio-design/metadataError.d.ts +23 -0
  83. package/dist/views/studio-design/metadataError.js +44 -0
  84. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  85. package/dist/views/studio-design/packageSurfaces.js +34 -0
  86. package/dist/views/studio-design/packages-io.d.ts +38 -0
  87. package/dist/views/studio-design/packages-io.js +73 -0
  88. package/dist/views/studio-design/skeletons.d.ts +16 -0
  89. package/dist/views/studio-design/skeletons.js +51 -0
  90. 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: curated `compactLayout`, else the first
57
- * business fields (system/audit columns excluded).
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
- if (Array.isArray(objectDef?.compactLayout) && objectDef.compactLayout.length > 0) {
66
- return objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
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
+ }