@object-ui/app-shell 4.0.7 → 4.0.9

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,7 +9,7 @@ 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
15
  const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })));
@@ -25,6 +25,7 @@ import { getIcon } from '../utils/getIcon';
25
25
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
26
26
  import { ViewConfigPanel } from './ViewConfigPanel';
27
27
  import { CreateViewDialog } from './CreateViewDialog';
28
+ import { PageHeader } from '../layout/PageHeader';
28
29
  import { useObjectActions } from '../hooks/useObjectActions';
29
30
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
30
31
  import { usePermissions } from '@object-ui/permissions';
@@ -197,13 +198,13 @@ function DrawerDetailContent({ objectDef, recordId, dataSource, onEdit }) {
197
198
  },
198
199
  }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) })] }));
199
200
  }
200
- export function ObjectView({ dataSource, objects, onEdit }) {
201
+ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }) {
201
202
  const navigate = useNavigate();
202
203
  const { objectName, viewId } = useParams();
203
204
  const [searchParams, setSearchParams] = useSearchParams();
204
205
  const { showDebug } = useMetadataInspector();
205
206
  const { t } = useObjectTranslation();
206
- const { objectLabel, objectDescription: objectDesc } = useObjectLabel();
207
+ const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
207
208
  // Inline view config panel state (Airtable-style right sidebar)
208
209
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
209
210
  const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
@@ -213,6 +214,35 @@ export function ObjectView({ dataSource, objects, onEdit }) {
213
214
  const [manageViewsOpen, setManageViewsOpen] = useState(false);
214
215
  // Draft state for view config edits — cached locally, saved on demand
215
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]);
216
246
  const handleViewConfigSave = useCallback((draft) => {
217
247
  setViewDraft(draft);
218
248
  setRefreshKey(k => k + 1);
@@ -259,10 +289,43 @@ export function ObjectView({ dataSource, objects, onEdit }) {
259
289
  const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
260
290
  ? config.columns
261
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';
262
314
  const payload = {
263
- objectName,
264
- ...config,
265
- 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',
266
329
  };
267
330
  const created = await dataSource.create('sys_view', payload);
268
331
  createdId = created?.id ?? created?._id;
@@ -299,6 +362,13 @@ export function ObjectView({ dataSource, objects, onEdit }) {
299
362
  }
300
363
  // Refresh trigger — bumped after view CRUD or external data mutations.
301
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]);
302
372
  // Import wizard open/close state — toolbar entry triggers it.
303
373
  const [showImport, setShowImport] = useState(false);
304
374
  // ─── User-defined views (sys_view) ──────────────────────────────────
@@ -341,6 +411,70 @@ export function ObjectView({ dataSource, objects, onEdit }) {
341
411
  });
342
412
  return () => { cancelled = true; };
343
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]);
344
478
  // Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
345
479
  const views = useMemo(() => {
346
480
  // Default column resolution priority:
@@ -374,11 +508,18 @@ export function ObjectView({ dataSource, objects, onEdit }) {
374
508
  return [];
375
509
  };
376
510
  const definedViews = objectDef.listViews || objectDef.list_views || {};
377
- const viewList = Object.entries(definedViews).map(([key, value]) => ({
378
- id: key,
379
- ...value,
380
- type: value.type || 'grid'
381
- }));
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
+ });
382
523
  if (viewList.length === 0) {
383
524
  viewList.push({
384
525
  id: 'all',
@@ -468,7 +609,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
468
609
  return (aSaved.created_at || '').localeCompare(bSaved.created_at || '');
469
610
  });
470
611
  return viewList;
471
- }, [objectDef, savedViews, t]);
612
+ }, [objectDef, savedViews, viewOverrides, t]);
472
613
  // Active View State — merge saved draft if available for this view.
473
614
  // Resolution priority: URL viewId → ?view= → user-marked default → first.
474
615
  const defaultViewId = useMemo(() => {
@@ -861,6 +1002,24 @@ export function ObjectView({ dataSource, objects, onEdit }) {
861
1002
  // Memoize to avoid unstable object identity on every render (stale closure prevention)
862
1003
  const detailNavigation = useMemo(() => activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' }, [activeView?.navigation, objectDef.navigation]);
863
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()]);
864
1023
  // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick
865
1024
  const handleNavOverlayNavigate = useCallback((recordId, action) => {
866
1025
  if (action === 'new_window') {
@@ -940,6 +1099,35 @@ export function ObjectView({ dataSource, objects, onEdit }) {
940
1099
  // Propagate appearance/view-config properties for live preview
941
1100
  rowHeight: viewDef.rowHeight ?? listSchema.rowHeight,
942
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
+ },
943
1131
  inlineEdit: viewDef.inlineEdit ?? viewDef.editRecordsInline ?? listSchema.inlineEdit,
944
1132
  appearance: viewDef.showDescription != null
945
1133
  ? { showDescription: viewDef.showDescription }
@@ -973,7 +1161,6 @@ export function ObjectView({ dataSource, objects, onEdit }) {
973
1161
  searchableFields: viewDef.searchableFields ?? listSchema.searchableFields,
974
1162
  filterableFields: viewDef.filterableFields ?? listSchema.filterableFields,
975
1163
  resizable: viewDef.resizable ?? listSchema.resizable,
976
- hiddenFields: viewDef.hiddenFields ?? listSchema.hiddenFields,
977
1164
  rowActions: viewDef.rowActions ?? listSchema.rowActions,
978
1165
  bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
979
1166
  sharing: viewDef.sharing ?? listSchema.sharing,
@@ -988,7 +1175,13 @@ export function ObjectView({ dataSource, objects, onEdit }) {
988
1175
  aria: viewDef.aria ?? listSchema.aria,
989
1176
  tabs: listSchema.tabs,
990
1177
  // Propagate filter/sort as default filters/sort for data flow
991
- ...(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
+ })()),
992
1185
  ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}),
993
1186
  options: {
994
1187
  kanban: {
@@ -1054,10 +1247,33 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1054
1247
  // and triggers onRefresh on success.
1055
1248
  actions.deleteRecord(String(record.id));
1056
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
+ });
1057
1265
  }, onRowClick: (record) => {
1058
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 });
1059
1275
  }, dataSource: ds }, key));
1060
- }, [activeView, objectDef, objectName, refreshKey, navOverlay, actions]);
1276
+ }, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters]);
1061
1277
  // Memoize the merged views array so PluginObjectView doesn't get a new
1062
1278
  // reference on every render (which would trigger unnecessary data refetches).
1063
1279
  const mergedViews = useMemo(() => views.map((v) => v.id === activeViewId && viewDraft && viewDraft.id === v.id
@@ -1094,13 +1310,22 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1094
1310
  }
1095
1311
  },
1096
1312
  }), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId, isAdmin]);
1097
- 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') })] })), 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: {
1098
- type: 'action:bar',
1099
- location: 'list_toolbar',
1100
- actions: objectDef.actions,
1101
- size: 'sm',
1102
- variant: 'outline',
1103
- } }))] })] }), 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]) => ({
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]) => ({
1104
1329
  name,
1105
1330
  label: def?.label || name,
1106
1331
  type: def?.type || 'text',
@@ -1124,7 +1349,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1124
1349
  const isSystem = !saved;
1125
1350
  return {
1126
1351
  id: view.id,
1127
- label: view.label,
1352
+ label: viewLabel(objectDef.name, view.name || view.id, view.label || view.name || view.id),
1128
1353
  type: view.type,
1129
1354
  hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
1130
1355
  hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0,
@@ -1145,14 +1370,14 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1145
1370
  showVisibilityGroups: true,
1146
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 }))] }));
1147
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)
1148
- 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) => {
1149
- navOverlay.handleClick(record);
1150
- }, 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", children: _jsx("div", { className: "h-full overflow-auto", 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) => {
1151
1376
  const recordId = (record.id || record._id);
1152
1377
  return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
1153
- } })) : (_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) => {
1154
- navOverlay.handleClick(record);
1155
- }, 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", children: _jsx("div", { className: "h-full overflow-auto", 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: [
1156
1381
  { title: 'View Configuration', data: activeView },
1157
1382
  { title: 'Object Definition', data: objectDef },
1158
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)
@@ -12,7 +12,7 @@ import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
12
12
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13
13
  import { PresenceAvatars } from '@object-ui/collaboration';
14
14
  import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
15
- import { ActionProvider, useObjectTranslation } from '@object-ui/react';
15
+ import { ActionProvider, useObjectTranslation, useObjectLabel } from '@object-ui/react';
16
16
  import { toast } from 'sonner';
17
17
  import { Database, Users } from 'lucide-react';
18
18
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
@@ -23,11 +23,12 @@ import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
23
23
  import { getRecordDisplayName } from '../utils';
24
24
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
25
25
  export function RecordDetailView({ dataSource, objects, onEdit }) {
26
- const { objectName, recordId } = useParams();
26
+ const { appName, objectName, recordId } = useParams();
27
27
  const { showDebug } = useMetadataInspector();
28
28
  const { user } = useAuth();
29
29
  const navigate = useNavigate();
30
30
  const { t } = useObjectTranslation();
31
+ const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
31
32
  const [isLoading, setIsLoading] = useState(true);
32
33
  const [feedItems, setFeedItems] = useState([]);
33
34
  const [recordViewers, setRecordViewers] = useState([]);
@@ -368,7 +369,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
368
369
  const formSections = objectDef.views?.form?.sections;
369
370
  const sections = formSections && formSections.length > 0
370
371
  ? formSections.map((sec) => ({
371
- title: sec.title,
372
+ title: sec.name ? sectionLabel(objectDef.name, sec.name, sec.title || sec.name) : sec.title,
372
373
  collapsible: sec.collapsible,
373
374
  defaultCollapsed: sec.defaultCollapsed,
374
375
  fields: (sec.fields || []).map((f) => {
@@ -423,7 +424,16 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
423
424
  return false;
424
425
  seen.add(a.name);
425
426
  return true;
426
- });
427
+ }).map((a) => ({
428
+ ...a,
429
+ label: actionLabel(objectDef.name, a.name, a.label || a.name),
430
+ ...(a.confirmText !== undefined && {
431
+ confirmText: actionConfirm(objectDef.name, a.name, a.confirmText),
432
+ }),
433
+ ...(a.successMessage !== undefined && {
434
+ successMessage: actionSuccess(objectDef.name, a.name, a.successMessage),
435
+ }),
436
+ }));
427
437
  })();
428
438
  // Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback)
429
439
  const highlightFields = objectDef.views?.detail?.highlightFields ?? [];
@@ -432,16 +442,88 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
432
442
  // Build related entries from reverse-reference child objects.
433
443
  // `referenceField` is the FK field on the child pointing back to this
434
444
  // record — passed so the related-list renderer can hide the redundant
435
- // parent-ID column.
445
+ // parent-ID column. Each entry carries action handlers that the renderer
446
+ // surfaces as header `+ New` / `View All` buttons and per-row Edit /
447
+ // Delete controls.
448
+ const baseAppUrl = appName ? `/apps/${appName}` : '';
436
449
  const related = childRelations.map(({ childObject, childLabel, referenceField }) => {
437
450
  const childObjectDef = objects.find((o) => o.name === childObject);
451
+ const parentId = pureRecordId || '';
452
+ const localizedTitle = childObjectDef
453
+ ? objectLabel({ name: childObjectDef.name, label: childObjectDef.label || childLabel })
454
+ : childLabel;
455
+ const buildNewUrl = () => {
456
+ const qs = new URLSearchParams({ [referenceField]: parentId }).toString();
457
+ return `${baseAppUrl}/${childObject}/new${qs ? `?${qs}` : ''}`;
458
+ };
459
+ const buildListUrl = () => {
460
+ const qs = new URLSearchParams({
461
+ [`filter[${referenceField}]`]: parentId,
462
+ }).toString();
463
+ return `${baseAppUrl}/${childObject}${qs ? `?${qs}` : ''}`;
464
+ };
465
+ const buildEditUrl = (row) => {
466
+ const rid = row?.id || row?._id;
467
+ if (!rid)
468
+ return null;
469
+ return `${baseAppUrl}/${childObject}/record/${encodeURIComponent(String(rid))}/edit`;
470
+ };
471
+ const buildRecordUrl = (row) => {
472
+ const rid = row?.id || row?._id;
473
+ if (!rid)
474
+ return null;
475
+ return `${baseAppUrl}/${childObject}/record/${encodeURIComponent(String(rid))}`;
476
+ };
477
+ const onNew = baseAppUrl
478
+ ? () => navigate(buildNewUrl())
479
+ : undefined;
480
+ const onViewAll = baseAppUrl
481
+ ? () => navigate(buildListUrl())
482
+ : undefined;
483
+ const onRowClick = baseAppUrl
484
+ ? (row) => {
485
+ const url = buildRecordUrl(row);
486
+ if (url)
487
+ navigate(url);
488
+ }
489
+ : undefined;
490
+ const onRowEdit = baseAppUrl
491
+ ? (row) => {
492
+ const url = buildEditUrl(row);
493
+ if (url)
494
+ navigate(url);
495
+ }
496
+ : undefined;
497
+ const onRowDelete = dataSource && parentId
498
+ ? async (row) => {
499
+ const rid = row?.id || row?._id;
500
+ if (!rid)
501
+ return;
502
+ try {
503
+ await dataSource.delete(childObject, rid);
504
+ toast.success(t('detail.deleteSuccess', { defaultValue: 'Deleted' }));
505
+ setChildRelatedData((prev) => ({
506
+ ...prev,
507
+ [childObject]: (prev[childObject] || []).filter((r) => (r.id || r._id) !== rid),
508
+ }));
509
+ }
510
+ catch (err) {
511
+ toast.error(err?.message || t('detail.deleteError', { defaultValue: 'Delete failed' }));
512
+ }
513
+ }
514
+ : undefined;
438
515
  return {
439
- title: childLabel,
516
+ title: localizedTitle,
440
517
  type: 'table',
441
518
  api: childObject,
442
519
  data: childRelatedData[childObject] || [],
443
520
  referenceField,
444
521
  icon: childObjectDef?.icon,
522
+ onNew,
523
+ onViewAll,
524
+ onRowClick,
525
+ onRowEdit,
526
+ onRowDelete,
445
527
  };
446
528
  });
447
529
  return {
@@ -468,14 +550,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
468
550
  }),
469
551
  };
470
552
  // eslint-disable-next-line react-hooks/exhaustive-deps
471
- }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey]);
553
+ }, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects]);
472
554
  if (isLoading) {
473
555
  return _jsx(SkeletonDetail, {});
474
556
  }
475
557
  if (!objectDef) {
476
558
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", 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('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) })] }) }));
477
559
  }
478
- return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectDef.label, onDataLoaded: (record) => {
560
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
479
561
  if (!record || typeof record !== 'object')
480
562
  return;
481
563
  // Resolve the same way DetailView's header does, so the
@@ -29,7 +29,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
29
29
  * @module views/RecordFormPage
30
30
  */
31
31
  import { useCallback, useMemo } from 'react';
32
- import { useParams, useNavigate, Link } from 'react-router-dom';
32
+ import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
33
33
  import { ObjectForm } from '@object-ui/plugin-form';
34
34
  import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
35
35
  import { ArrowLeft, Database } from 'lucide-react';
@@ -52,11 +52,29 @@ import { ExpressionEvaluator } from '@object-ui/core';
52
52
  export function RecordFormPage({ mode }) {
53
53
  const { appName, objectName, recordId } = useParams();
54
54
  const navigate = useNavigate();
55
+ const [searchParams] = useSearchParams();
55
56
  const dataSource = useAdapter();
56
57
  const { objects, loading: metadataLoading } = useMetadata();
57
58
  const { t } = useObjectTranslation();
58
59
  const { objectLabel } = useObjectLabel();
59
60
  const { user } = useAuth();
61
+ /**
62
+ * Query-string prefills for create mode. Used by related-list "+ New"
63
+ * buttons that pass the parent record id as a `<referenceField>=<id>`
64
+ * pair so the new child record is auto-linked back to the parent.
65
+ * Stable identity: only changes when the actual search string changes.
66
+ */
67
+ const prefillValues = useMemo(() => {
68
+ if (mode !== 'create')
69
+ return undefined;
70
+ const entries = [];
71
+ for (const [k, v] of searchParams.entries()) {
72
+ if (k && v)
73
+ entries.push([k, v]);
74
+ }
75
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [mode, searchParams.toString()]);
60
78
  const objectDef = useMemo(() => objects.find((o) => o.name === objectName), [objects, objectName]);
61
79
  const baseUrl = `/apps/${appName}`;
62
80
  const objectListUrl = `${baseUrl}/${objectName}`;
@@ -149,6 +167,7 @@ export function RecordFormPage({ mode }) {
149
167
  objectName: objectDef.name,
150
168
  mode,
151
169
  recordId: mode === 'edit' ? recordId : undefined,
170
+ ...(prefillValues && { initialValues: prefillValues }),
152
171
  title: pageTitle,
153
172
  description: mode === 'create'
154
173
  ? t('form.createDescription', {