@object-ui/app-shell 4.0.6 → 4.0.8

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.
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Console ObjectView
4
4
  *
@@ -9,9 +9,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  * - useObjectActions for toolbar create button
10
10
  * - ListView delegation for non-grid view types (kanban, calendar, chart, etc.)
11
11
  */
12
- import { useMemo, useState, useCallback, useEffect, lazy, Suspense } from 'react';
12
+ import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react';
13
13
  import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
14
14
  const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })));
15
+ const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })));
15
16
  import { ListView } from '@object-ui/plugin-list';
16
17
  import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
17
18
  import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
@@ -19,11 +20,12 @@ import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@
19
20
  // uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
20
21
  // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
21
22
  import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay } from '@object-ui/components';
22
- import { Plus, Table as TableIcon, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
23
+ import { Plus, Upload, Table as TableIcon, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
23
24
  import { getIcon } from '../utils/getIcon';
24
25
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
25
26
  import { ViewConfigPanel } from './ViewConfigPanel';
26
27
  import { CreateViewDialog } from './CreateViewDialog';
28
+ import { PageHeader } from '../layout/PageHeader';
27
29
  import { useObjectActions } from '../hooks/useObjectActions';
28
30
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
29
31
  import { usePermissions } from '@object-ui/permissions';
@@ -196,13 +198,13 @@ function DrawerDetailContent({ objectDef, recordId, dataSource, onEdit }) {
196
198
  },
197
199
  }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) })] }));
198
200
  }
199
- export function ObjectView({ dataSource, objects, onEdit }) {
201
+ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }) {
200
202
  const navigate = useNavigate();
201
203
  const { objectName, viewId } = useParams();
202
204
  const [searchParams, setSearchParams] = useSearchParams();
203
205
  const { showDebug } = useMetadataInspector();
204
206
  const { t } = useObjectTranslation();
205
- const { objectLabel, objectDescription: objectDesc } = useObjectLabel();
207
+ const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
206
208
  // Inline view config panel state (Airtable-style right sidebar)
207
209
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
208
210
  const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
@@ -212,6 +214,35 @@ export function ObjectView({ dataSource, objects, onEdit }) {
212
214
  const [manageViewsOpen, setManageViewsOpen] = useState(false);
213
215
  // Draft state for view config edits — cached locally, saved on demand
214
216
  const [viewDraft, setViewDraft] = useState(null);
217
+ // Per-view debounce timers + latest pending patch payloads. Keyed by
218
+ // viewId so toggles on different views don't clobber each other. We
219
+ // merge incoming patches into a single payload so rapid successive
220
+ // toggles (e.g. resize-drag emitting 60 events/sec) collapse into one
221
+ // network write.
222
+ const persistTimers = useRef({});
223
+ const persistPending = useRef({});
224
+ const persistViewPatch = useCallback((viewIdLocal, baseViewDef, patch) => {
225
+ if (!dataSource?.updateViewConfig || !objectName || !viewIdLocal)
226
+ return;
227
+ // Merge into pending payload — every key present is the latest
228
+ // value the user intended.
229
+ const prev = persistPending.current[viewIdLocal] || {};
230
+ persistPending.current[viewIdLocal] = { ...prev, ...patch };
231
+ const existing = persistTimers.current[viewIdLocal];
232
+ if (existing)
233
+ clearTimeout(existing);
234
+ persistTimers.current[viewIdLocal] = setTimeout(() => {
235
+ const merged = persistPending.current[viewIdLocal] || {};
236
+ delete persistPending.current[viewIdLocal];
237
+ delete persistTimers.current[viewIdLocal];
238
+ Promise.resolve(dataSource.updateViewConfig(objectName, viewIdLocal, {
239
+ ...baseViewDef,
240
+ ...merged,
241
+ })).catch((err) => {
242
+ console.error('[ObjectView] Failed to persist view config:', err);
243
+ });
244
+ }, 300);
245
+ }, [dataSource, objectName]);
215
246
  const handleViewConfigSave = useCallback((draft) => {
216
247
  setViewDraft(draft);
217
248
  setRefreshKey(k => k + 1);
@@ -258,10 +289,43 @@ export function ObjectView({ dataSource, objects, onEdit }) {
258
289
  const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
259
290
  ? config.columns
260
291
  : defaultColumns;
292
+ // Translate NamedListView spec shape (type, label, kanban, chart,
293
+ // gantt, etc., columns, filter, sort) into the sys_view storage
294
+ // shape (view_type, label, object_name, *_json columns).
295
+ // Per spec: front-end follows the protocol, the persistence
296
+ // boundary owns the mapping to physical columns.
297
+ const VIEW_TYPE_KEYS = [
298
+ 'kanban', 'calendar', 'timeline', 'gantt',
299
+ 'gallery', 'map', 'chart', 'grid',
300
+ ];
301
+ const subConfig = {};
302
+ for (const k of VIEW_TYPE_KEYS) {
303
+ if (config[k] && typeof config[k] === 'object') {
304
+ Object.assign(subConfig, config[k]);
305
+ }
306
+ }
307
+ const viewType = config.type || 'grid';
308
+ const baseLabel = config.label || config.name || 'Untitled View';
309
+ const slug = baseLabel
310
+ .toLowerCase()
311
+ .replace(/[^a-z0-9]+/g, '_')
312
+ .replace(/^_+|_+$/g, '')
313
+ .slice(0, 60) || 'view';
261
314
  const payload = {
262
- objectName,
263
- ...config,
264
- columns: incomingColumns,
315
+ name: `${slug}_${Date.now().toString(36)}`,
316
+ label: baseLabel,
317
+ object_name: objectName,
318
+ view_type: viewType,
319
+ columns_json: JSON.stringify(incomingColumns),
320
+ filters_json: config.filter ? JSON.stringify(config.filter) : null,
321
+ sort_json: config.sort ? JSON.stringify(config.sort) : null,
322
+ config_json: Object.keys(subConfig).length > 0
323
+ ? JSON.stringify(subConfig)
324
+ : null,
325
+ page_size: config.pageSize ?? 25,
326
+ show_search: config.showSearch !== false,
327
+ show_filters: config.showFilters !== false,
328
+ managed_by: 'user',
265
329
  };
266
330
  const created = await dataSource.create('sys_view', payload);
267
331
  createdId = created?.id ?? created?._id;
@@ -298,6 +362,15 @@ export function ObjectView({ dataSource, objects, onEdit }) {
298
362
  }
299
363
  // Refresh trigger — bumped after view CRUD or external data mutations.
300
364
  const [refreshKey, setRefreshKey] = useState(0);
365
+ // Propagate externally-triggered refreshes (e.g. global ModalForm submit)
366
+ // into our internal refreshKey so list/data effects re-run.
367
+ useEffect(() => {
368
+ if (externalRefreshKey === undefined || externalRefreshKey === 0)
369
+ return;
370
+ setRefreshKey(k => k + 1);
371
+ }, [externalRefreshKey]);
372
+ // Import wizard open/close state — toolbar entry triggers it.
373
+ const [showImport, setShowImport] = useState(false);
301
374
  // ─── User-defined views (sys_view) ──────────────────────────────────
302
375
  // Saved views created via the ViewConfigPanel ("Add View") are persisted
303
376
  // to the `sys_view` object. We fetch them here and merge into `views` so
@@ -338,6 +411,70 @@ export function ObjectView({ dataSource, objects, onEdit }) {
338
411
  });
339
412
  return () => { cancelled = true; };
340
413
  }, [dataSource, objectName, refreshKey]);
414
+ // Persisted per-view config overrides (e.g. density toggle). Saved
415
+ // separately from `objectDef.listViews` (the embedded definition) via
416
+ // `dataSource.updateViewConfig` and read back here so toggle preferences
417
+ // survive a hard reload. Keyed by viewId → partial view config to merge.
418
+ //
419
+ // Use the batch listViewOverrides() when available — fires one HTTP
420
+ // GET per object instead of N (one per defined view), avoiding a flurry
421
+ // of 404s for objects whose views have never been customized. Falls
422
+ // back to per-view getView() for adapters that don't support the batch
423
+ // method.
424
+ const [viewOverrides, setViewOverrides] = useState({});
425
+ useEffect(() => {
426
+ let cancelled = false;
427
+ if (!dataSource || !objectName) {
428
+ setViewOverrides({});
429
+ return;
430
+ }
431
+ const definedViews = (objectDef.listViews || objectDef.list_views || {});
432
+ const ids = Object.keys(definedViews);
433
+ if (ids.length === 0) {
434
+ setViewOverrides({});
435
+ return;
436
+ }
437
+ const loadBatch = async () => {
438
+ if (typeof dataSource.listViewOverrides === 'function') {
439
+ try {
440
+ const all = await dataSource.listViewOverrides(objectName);
441
+ if (all && typeof all === 'object') {
442
+ const map = {};
443
+ for (const id of ids) {
444
+ if (all[id])
445
+ map[id] = all[id];
446
+ }
447
+ return map;
448
+ }
449
+ }
450
+ catch {
451
+ // fall through to per-view fetch
452
+ }
453
+ }
454
+ if (typeof dataSource.getView !== 'function')
455
+ return {};
456
+ const entries = await Promise.all(ids.map(async (id) => {
457
+ try {
458
+ const v = await dataSource.getView(objectName, id);
459
+ return [id, v];
460
+ }
461
+ catch {
462
+ return [id, null];
463
+ }
464
+ }));
465
+ const map = {};
466
+ for (const [id, v] of entries) {
467
+ if (v && typeof v === 'object')
468
+ map[id] = v;
469
+ }
470
+ return map;
471
+ };
472
+ loadBatch().then((map) => {
473
+ if (!cancelled)
474
+ setViewOverrides(map);
475
+ });
476
+ return () => { cancelled = true; };
477
+ }, [dataSource, objectName, objectDef.listViews, objectDef.list_views, refreshKey]);
341
478
  // Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
342
479
  const views = useMemo(() => {
343
480
  // Default column resolution priority:
@@ -371,11 +508,18 @@ export function ObjectView({ dataSource, objects, onEdit }) {
371
508
  return [];
372
509
  };
373
510
  const definedViews = objectDef.listViews || objectDef.list_views || {};
374
- const viewList = Object.entries(definedViews).map(([key, value]) => ({
375
- id: key,
376
- ...value,
377
- type: value.type || 'grid'
378
- }));
511
+ const viewList = Object.entries(definedViews).map(([key, value]) => {
512
+ const override = viewOverrides[key];
513
+ // Override wins per-key — saved overrides represent user
514
+ // preferences (density, column widths, etc.) that should
515
+ // shadow the embedded definition.
516
+ return {
517
+ id: key,
518
+ ...value,
519
+ ...(override || {}),
520
+ type: (override?.type) || value.type || 'grid',
521
+ };
522
+ });
379
523
  if (viewList.length === 0) {
380
524
  viewList.push({
381
525
  id: 'all',
@@ -465,7 +609,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
465
609
  return (aSaved.created_at || '').localeCompare(bSaved.created_at || '');
466
610
  });
467
611
  return viewList;
468
- }, [objectDef, savedViews, t]);
612
+ }, [objectDef, savedViews, viewOverrides, t]);
469
613
  // Active View State — merge saved draft if available for this view.
470
614
  // Resolution priority: URL viewId → ?view= → user-marked default → first.
471
615
  const defaultViewId = useMemo(() => {
@@ -534,7 +678,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
534
678
  }
535
679
  catch (err) {
536
680
  console.error('[ViewTabBar] Failed to rename view:', err);
537
- toast.error('Failed to rename view');
681
+ toast.error(t('objectViewActions.renameFailed'));
538
682
  }
539
683
  }, [dataSource, isSavedView, t]);
540
684
  // Promise-based confirm/param dialogs — declared early so destructive
@@ -581,7 +725,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
581
725
  }
582
726
  catch (err) {
583
727
  console.error('[ViewTabBar] Failed to delete view:', err);
584
- toast.error('Failed to delete view');
728
+ toast.error(t('objectViewActions.deleteFailed'));
585
729
  }
586
730
  }, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]);
587
731
  const handleDuplicateView = useCallback(async (vid) => {
@@ -696,14 +840,6 @@ export function ObjectView({ dataSource, objects, onEdit }) {
696
840
  const handleAddView = useCallback(() => {
697
841
  setShowCreateViewDialog(true);
698
842
  }, []);
699
- // Action system for toolbar operations — refreshKey moved up (declared earlier).
700
- const actions = useObjectActions({
701
- objectName: objectDef.name,
702
- objectLabel: objectDef.label,
703
- dataSource,
704
- onEdit,
705
- onRefresh: () => setRefreshKey(k => k + 1),
706
- });
707
843
  // ─── ActionProvider handlers for schema-driven toolbar actions ──────
708
844
  const currentUser = user
709
845
  ? { id: user.id, name: user.name, avatar: user.image }
@@ -714,6 +850,18 @@ export function ObjectView({ dataSource, objects, onEdit }) {
714
850
  else
715
851
  toast.success(message);
716
852
  }, []);
853
+ // Action system for toolbar operations — refreshKey moved up (declared earlier).
854
+ // Wired to confirmHandler/toastHandler so deletes use the Shadcn AlertDialog
855
+ // and Sonner toast instead of native window.confirm.
856
+ const actions = useObjectActions({
857
+ objectName: objectDef.name,
858
+ objectLabel: objectDef.label,
859
+ dataSource,
860
+ onEdit,
861
+ onRefresh: () => setRefreshKey(k => k + 1),
862
+ onConfirm: confirmHandler,
863
+ onToast: toastHandler,
864
+ });
717
865
  const navigateHandler = useCallback((url, options) => {
718
866
  if (options?.external || options?.newTab) {
719
867
  window.open(url, '_blank', 'noopener,noreferrer');
@@ -854,6 +1002,24 @@ export function ObjectView({ dataSource, objects, onEdit }) {
854
1002
  // Memoize to avoid unstable object identity on every render (stale closure prevention)
855
1003
  const detailNavigation = useMemo(() => activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' }, [activeView?.navigation, objectDef.navigation]);
856
1004
  const drawerRecordId = searchParams.get('recordId');
1005
+ /**
1006
+ * URL-derived equality filters in the form `?filter[<field>]=<value>`.
1007
+ * Used by related-list "View All" buttons to scope the destination list
1008
+ * to a single parent record. Emitted as ObjectQL triples (`[field, '=', value]`)
1009
+ * which matches the shape consumed by the list view's data fetcher when
1010
+ * merging base filters.
1011
+ */
1012
+ const urlFilters = useMemo(() => {
1013
+ const out = [];
1014
+ searchParams.forEach((value, key) => {
1015
+ const m = /^filter\[(.+)\]$/.exec(key);
1016
+ if (m && m[1] && value !== '') {
1017
+ out.push([m[1], '=', value]);
1018
+ }
1019
+ });
1020
+ return out;
1021
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1022
+ }, [searchParams.toString()]);
857
1023
  // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick
858
1024
  const handleNavOverlayNavigate = useCallback((recordId, action) => {
859
1025
  if (action === 'new_window') {
@@ -933,6 +1099,35 @@ export function ObjectView({ dataSource, objects, onEdit }) {
933
1099
  // Propagate appearance/view-config properties for live preview
934
1100
  rowHeight: viewDef.rowHeight ?? listSchema.rowHeight,
935
1101
  densityMode: viewDef.densityMode ?? listSchema.densityMode,
1102
+ // Hydrate persisted user preferences so they survive reload
1103
+ // (Airtable-style per-view personal config). All four below go
1104
+ // through the same persistViewPatch helper which debounces and
1105
+ // batches concurrent toggles.
1106
+ sort: viewDef.sort ?? listSchema.sort,
1107
+ filter: (() => {
1108
+ const base = viewDef.filter ?? listSchema.filter;
1109
+ if (!urlFilters.length)
1110
+ return base;
1111
+ const baseArr = Array.isArray(base) ? base : [];
1112
+ return [...baseArr, ...urlFilters];
1113
+ })(),
1114
+ hiddenFields: viewDef.hiddenFields ?? listSchema.hiddenFields,
1115
+ columnState: viewDef.columnState ?? listSchema.columnState,
1116
+ onDensityChange: (mode) => {
1117
+ persistViewPatch(viewDef.id, viewDef, { densityMode: mode });
1118
+ },
1119
+ onSortChange: (sort) => {
1120
+ persistViewPatch(viewDef.id, viewDef, { sort });
1121
+ },
1122
+ onFilterChange: (filter) => {
1123
+ persistViewPatch(viewDef.id, viewDef, { filter });
1124
+ },
1125
+ onHiddenFieldsChange: (hidden) => {
1126
+ persistViewPatch(viewDef.id, viewDef, { hiddenFields: hidden });
1127
+ },
1128
+ onColumnStateChange: (state) => {
1129
+ persistViewPatch(viewDef.id, viewDef, { columnState: state });
1130
+ },
936
1131
  inlineEdit: viewDef.inlineEdit ?? viewDef.editRecordsInline ?? listSchema.inlineEdit,
937
1132
  appearance: viewDef.showDescription != null
938
1133
  ? { showDescription: viewDef.showDescription }
@@ -966,7 +1161,6 @@ export function ObjectView({ dataSource, objects, onEdit }) {
966
1161
  searchableFields: viewDef.searchableFields ?? listSchema.searchableFields,
967
1162
  filterableFields: viewDef.filterableFields ?? listSchema.filterableFields,
968
1163
  resizable: viewDef.resizable ?? listSchema.resizable,
969
- hiddenFields: viewDef.hiddenFields ?? listSchema.hiddenFields,
970
1164
  rowActions: viewDef.rowActions ?? listSchema.rowActions,
971
1165
  bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
972
1166
  sharing: viewDef.sharing ?? listSchema.sharing,
@@ -981,7 +1175,13 @@ export function ObjectView({ dataSource, objects, onEdit }) {
981
1175
  aria: viewDef.aria ?? listSchema.aria,
982
1176
  tabs: listSchema.tabs,
983
1177
  // Propagate filter/sort as default filters/sort for data flow
984
- ...(viewDef.filter?.length ? { filters: viewDef.filter } : {}),
1178
+ ...((() => {
1179
+ const combined = [
1180
+ ...(Array.isArray(viewDef.filter) ? viewDef.filter : []),
1181
+ ...urlFilters,
1182
+ ];
1183
+ return combined.length ? { filters: combined } : {};
1184
+ })()),
985
1185
  ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}),
986
1186
  options: {
987
1187
  kanban: {
@@ -1040,10 +1240,40 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1040
1240
  },
1041
1241
  },
1042
1242
  };
1043
- return (_jsx(ListView, { schema: fullSchema, className: className, onEdit: editHandler, onRowClick: (record) => {
1243
+ return (_jsx(ListView, { schema: fullSchema, className: className, onEdit: editHandler, onDelete: (record) => {
1244
+ if (record?.id != null) {
1245
+ // useObjectActions.deleteRecord wraps execute() which
1246
+ // already shows a confirmation dialog + success toast
1247
+ // and triggers onRefresh on success.
1248
+ actions.deleteRecord(String(record.id));
1249
+ }
1250
+ }, onBulkDelete: (records) => {
1251
+ const valid = records.filter((r) => r?.id != null);
1252
+ if (valid.length === 0)
1253
+ return;
1254
+ // Route through actions.execute so the shared AlertDialog
1255
+ // confirms once for the whole batch and the existing
1256
+ // delete handler (now batch-aware) handles refresh + toast.
1257
+ actions.execute({
1258
+ type: 'delete',
1259
+ confirmText: t('console.objectView.bulkDeleteConfirm', {
1260
+ count: valid.length,
1261
+ defaultValue: `Delete ${valid.length} selected records? This cannot be undone.`,
1262
+ }),
1263
+ params: { records: valid },
1264
+ });
1265
+ }, onRowClick: (record) => {
1044
1266
  navOverlay.handleClick(record);
1267
+ }, onSortChange: (sort) => {
1268
+ persistViewPatch(viewDef.id, viewDef, { sort });
1269
+ }, onFilterChange: (filter) => {
1270
+ persistViewPatch(viewDef.id, viewDef, { filter });
1271
+ }, onHiddenFieldsChange: (hidden) => {
1272
+ persistViewPatch(viewDef.id, viewDef, { hiddenFields: hidden });
1273
+ }, onColumnStateChange: (state) => {
1274
+ persistViewPatch(viewDef.id, viewDef, { columnState: state });
1045
1275
  }, dataSource: ds }, key));
1046
- }, [activeView, objectDef, objectName, refreshKey, navOverlay]);
1276
+ }, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters]);
1047
1277
  // Memoize the merged views array so PluginObjectView doesn't get a new
1048
1278
  // reference on every render (which would trigger unnecessary data refetches).
1049
1279
  const mergedViews = useMemo(() => views.map((v) => v.id === activeViewId && viewDraft && viewDraft.id === v.id
@@ -1080,13 +1310,37 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1080
1310
  }
1081
1311
  },
1082
1312
  }), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId, isAdmin]);
1083
- return (_jsxs(ActionProvider, { context: { objectName: objectDef.name, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsxs("div", { className: "flex justify-between items-center py-2.5 sm:py-3 px-3 sm:px-4 border-b shrink-0 bg-background z-10", children: [_jsxs("div", { className: "flex items-center gap-2 sm:gap-3 min-w-0 flex-1", children: [_jsx("div", { className: "bg-primary/10 p-1.5 sm:p-2 rounded-md shrink-0", children: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4 text-primary" }); })() }), _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "text-base sm:text-lg font-semibold tracking-tight text-foreground truncate", children: objectLabel(objectDef) }), objectDef.description && (_jsx("p", { className: "text-xs text-muted-foreground truncate hidden sm:block max-w-md", children: objectDesc(objectDef) }))] })] }), _jsxs("div", { className: "flex items-center gap-1.5 sm:gap-2 shrink-0", children: [can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1084
- type: 'action:bar',
1085
- location: 'list_toolbar',
1086
- actions: objectDef.actions,
1087
- size: 'sm',
1088
- variant: 'outline',
1089
- } }))] })] }), views.length >= 1 && (() => {
1313
+ return (_jsxs(ActionProvider, { context: { objectName: objectDef.name, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx(PageHeader, { title: objectLabel(objectDef), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1314
+ type: 'action:bar',
1315
+ location: 'list_toolbar',
1316
+ actions: (objectDef.actions || []).map((a) => ({
1317
+ ...a,
1318
+ label: actionLabel(objectDef.name, a.name, a.label || a.name),
1319
+ ...(a.confirmText !== undefined && {
1320
+ confirmText: actionConfirm(objectDef.name, a.name, a.confirmText),
1321
+ }),
1322
+ ...(a.successMessage !== undefined && {
1323
+ successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
1324
+ }),
1325
+ })),
1326
+ size: 'sm',
1327
+ variant: 'outline',
1328
+ } }))] }) }), showImport && (_jsx(Suspense, { fallback: null, children: _jsx(ImportWizard, { open: showImport, onOpenChange: setShowImport, objectName: objectDef.name, objectLabel: objectLabel(objectDef), fields: Object.entries(objectDef.fields || {}).map(([name, def]) => ({
1329
+ name,
1330
+ label: def?.label || name,
1331
+ type: def?.type || 'text',
1332
+ required: !!def?.required,
1333
+ })), dataSource: dataSource, onComplete: (result) => {
1334
+ setRefreshKey(k => k + 1);
1335
+ const ok = result.importedRows;
1336
+ const skip = result.skippedRows;
1337
+ if (skip > 0) {
1338
+ toast.warning(t('console.objectView.importedWithSkipped', { ok, skipped: skip }));
1339
+ }
1340
+ else if (ok > 0) {
1341
+ toast.success(t('console.objectView.importedToast', { count: ok }));
1342
+ }
1343
+ } }) })), views.length >= 1 && (() => {
1090
1344
  const viewTabItems = views.map((view) => {
1091
1345
  const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
1092
1346
  // System views (loaded from objectDef.listViews / metadata) are
@@ -1095,7 +1349,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1095
1349
  const isSystem = !saved;
1096
1350
  return {
1097
1351
  id: view.id,
1098
- label: view.label,
1352
+ label: viewLabel(objectDef.name, view.name || view.id, view.label || view.name || view.id),
1099
1353
  type: view.type,
1100
1354
  hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
1101
1355
  hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0,
@@ -1116,14 +1370,14 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1116
1370
  showVisibilityGroups: true,
1117
1371
  }, onAddView: isAdmin ? handleAddView : undefined, onRenameView: isAdmin ? handleRenameView : undefined, onDeleteView: isAdmin ? handleDeleteView : undefined, onDuplicateView: isAdmin ? handleDuplicateView : undefined, onPinView: isAdmin ? handlePinView : undefined, onSetDefaultView: isAdmin ? handleSetDefaultView : undefined, onConfigView: isAdmin ? handleConfigView : undefined, onManageViews: isAdmin ? () => setManageViewsOpen(true) : undefined }), isAdmin && (_jsx(ManageViewsDialog, { open: manageViewsOpen, onOpenChange: setManageViewsOpen, views: viewTabItems, activeViewId: activeViewId, viewTypeIcons: VIEW_TYPE_ICONS, onRename: handleRenameView, onDelete: handleDeleteView, onDuplicate: handleDuplicateView, onSetDefault: handleSetDefaultView, onSetPinned: handlePinView, onReorder: handleReorderViews, onAddView: handleAddView, onConfigView: handleConfigView }))] }));
1118
1372
  })(), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
1119
- handleDrawerClose(); }, title: objectLabel(objectDef), mainContent: _jsxs("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: [_jsx("div", { className: "flex-1 relative overflow-auto p-3 sm:p-4", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record) => {
1120
- navOverlay.handleClick(record);
1121
- }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }), typeof recordCount === 'number' && (_jsx("div", { "data-testid": "record-count-footer", className: "border-t px-3 sm:px-4 py-1.5 text-xs text-muted-foreground bg-muted/5 shrink-0", children: t('console.objectView.recordCount', { count: recordCount }) }))] }), children: (record) => {
1373
+ handleDrawerClose(); }, title: objectLabel(objectDef), mainContent: _jsxs("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: [_jsx("div", { className: "flex-1 relative overflow-hidden p-3 sm:p-4", children: _jsx("div", { className: "h-full overflow-auto rounded-lg border bg-card shadow-xs", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record) => {
1374
+ navOverlay.handleClick(record);
1375
+ }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) }), typeof recordCount === 'number' && (_jsx("div", { "data-testid": "record-count-footer", className: "border-t px-3 sm:px-4 py-1.5 text-xs text-muted-foreground bg-muted/5 shrink-0", children: t('console.objectView.recordCount', { count: recordCount }) }))] }), children: (record) => {
1122
1376
  const recordId = (record.id || record._id);
1123
1377
  return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
1124
- } })) : (_jsx("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: _jsx("div", { className: "flex-1 relative overflow-auto p-3 sm:p-4", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record) => {
1125
- navOverlay.handleClick(record);
1126
- }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
1378
+ } })) : (_jsx("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: _jsx("div", { className: "flex-1 relative overflow-hidden p-3 sm:p-4", children: _jsx("div", { className: "h-full overflow-auto rounded-lg border bg-card shadow-xs", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record) => {
1379
+ navOverlay.handleClick(record);
1380
+ }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
1127
1381
  { title: 'View Configuration', data: activeView },
1128
1382
  { title: 'Object Definition', data: objectDef },
1129
1383
  ] }), _jsx("div", { "data-testid": "view-config-panel-wrapper", className: `transition-[max-width,opacity] duration-300 ease-in-out overflow-hidden ${showViewConfigPanel && isAdmin ? 'max-w-[280px] opacity-100' : 'max-w-0 opacity-0'}`, children: _jsx(ViewConfigPanel, { open: showViewConfigPanel && isAdmin, onClose: () => { setShowViewConfigPanel(false); setViewConfigPanelMode('edit'); }, mode: viewConfigPanelMode, activeView: activeView, objectDef: objectDef, recordCount: recordCount, onSave: handleViewConfigSave, onViewUpdate: handleViewUpdate, onCreate: handleViewCreate }) }), _jsx(CreateViewDialog, { open: showCreateViewDialog && isAdmin, onOpenChange: setShowCreateViewDialog, existingLabels: views.map((v) => v.label).filter(Boolean), objectDef: objectDef, onCreate: (cfg) => handleViewCreate(cfg) })] }), navOverlay.mode !== 'split' && (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
@@ -9,11 +9,13 @@ import { useParams, useSearchParams } from 'react-router-dom';
9
9
  import { SchemaRenderer } from '@object-ui/react';
10
10
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
11
11
  import { FileText, Pencil } from 'lucide-react';
12
+ import { useObjectTranslation } from '@object-ui/i18n';
12
13
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
13
14
  import { useMetadata } from '../providers/MetadataProvider';
14
15
  import { DesignDrawer } from './DesignDrawer';
15
16
  const PageCanvasEditor = lazy(() => import('@object-ui/plugin-designer').then((m) => ({ default: m.PageCanvasEditor })));
16
17
  export function PageView() {
18
+ const { t } = useObjectTranslation();
17
19
  const { pageName } = useParams();
18
20
  const [searchParams] = useSearchParams();
19
21
  const { showDebug } = useMetadataInspector();
@@ -35,7 +37,7 @@ export function PageView() {
35
37
  }
36
38
  }, []);
37
39
  if (!page) {
38
- 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(FileText, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: "Page Not Found" }), _jsxs(EmptyDescription, { children: ["The page \"", pageName, "\" could not be found. It may have been removed or renamed."] })] }) }));
40
+ 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(FileText, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.pageNotFound') }), _jsx(EmptyDescription, { children: t('empty.pageNotFoundDescription', { name: pageName }) })] }) }));
39
41
  }
40
42
  // Convert URL search params to an object for the page context
41
43
  const params = Object.fromEntries(searchParams.entries());