@object-ui/app-shell 3.3.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/chrome/CommandPalette.js +2 -16
- package/dist/layout/AppHeader.js +21 -9
- package/dist/layout/AppSidebar.js +7 -22
- package/dist/layout/AppSwitcher.js +1 -16
- package/dist/layout/ConsoleFloatingChatbot.d.ts +10 -0
- package/dist/layout/ConsoleFloatingChatbot.js +27 -0
- package/dist/layout/ConsoleLayout.d.ts +1 -0
- package/dist/layout/ConsoleLayout.js +6 -28
- package/dist/layout/UnifiedSidebar.d.ts +5 -0
- package/dist/layout/UnifiedSidebar.js +5 -29
- package/dist/providers/MetadataProvider.js +42 -2
- package/dist/utils/getIcon.d.ts +16 -2
- package/dist/utils/getIcon.js +40 -12
- package/dist/views/CreateViewDialog.d.ts +44 -0
- package/dist/views/CreateViewDialog.js +140 -0
- package/dist/views/DashboardView.js +31 -9
- package/dist/views/ObjectView.d.ts +0 -3
- package/dist/views/ObjectView.js +424 -80
- package/dist/views/ReportView.js +4 -3
- package/dist/views/ViewConfigPanel.d.ts +7 -5
- package/dist/views/ViewConfigPanel.js +9 -6
- package/dist/views/index.d.ts +1 -0
- package/dist/views/index.js +1 -0
- package/package.json +29 -27
- package/src/styles.css +16 -0
package/dist/views/ObjectView.js
CHANGED
|
@@ -9,21 +9,21 @@ 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 } from 'react';
|
|
12
|
+
import { useMemo, useState, useCallback, useEffect, 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
|
import { ListView } from '@object-ui/plugin-list';
|
|
16
16
|
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
|
|
17
|
-
import { ObjectView as PluginObjectView, ViewTabBar } from '@object-ui/plugin-view';
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
import '@object-ui/plugin-
|
|
21
|
-
import '@object-ui/
|
|
22
|
-
import {
|
|
23
|
-
import { Plus, Table as TableIcon, Settings2, Wrench, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
|
|
17
|
+
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
|
|
18
|
+
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
|
|
19
|
+
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
|
|
20
|
+
// Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
|
|
21
|
+
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';
|
|
24
23
|
import { getIcon } from '../utils/getIcon';
|
|
25
|
-
import {
|
|
24
|
+
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
|
|
26
25
|
import { ViewConfigPanel } from './ViewConfigPanel';
|
|
26
|
+
import { CreateViewDialog } from './CreateViewDialog';
|
|
27
27
|
import { useObjectActions } from '../hooks/useObjectActions';
|
|
28
28
|
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
29
29
|
import { usePermissions } from '@object-ui/permissions';
|
|
@@ -200,12 +200,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
200
200
|
const navigate = useNavigate();
|
|
201
201
|
const { objectName, viewId } = useParams();
|
|
202
202
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
203
|
-
const { showDebug
|
|
203
|
+
const { showDebug } = useMetadataInspector();
|
|
204
204
|
const { t } = useObjectTranslation();
|
|
205
205
|
const { objectLabel, objectDescription: objectDesc } = useObjectLabel();
|
|
206
206
|
// Inline view config panel state (Airtable-style right sidebar)
|
|
207
207
|
const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
|
|
208
208
|
const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
|
|
209
|
+
// Airtable-style "Create view" dialog (type picker + name input)
|
|
210
|
+
const [showCreateViewDialog, setShowCreateViewDialog] = useState(false);
|
|
211
|
+
// Manage Views dialog (vertical sortable list of all views)
|
|
212
|
+
const [manageViewsOpen, setManageViewsOpen] = useState(false);
|
|
209
213
|
// Draft state for view config edits — cached locally, saved on demand
|
|
210
214
|
const [viewDraft, setViewDraft] = useState(null);
|
|
211
215
|
const handleViewConfigSave = useCallback((draft) => {
|
|
@@ -231,18 +235,56 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
231
235
|
/** Create a new view via the config panel */
|
|
232
236
|
const handleViewCreate = useCallback(async (config) => {
|
|
233
237
|
try {
|
|
238
|
+
let createdId;
|
|
234
239
|
if (dataSource?.create) {
|
|
235
|
-
|
|
236
|
-
|
|
240
|
+
// Prefill sensible defaults so the saved view renders rows
|
|
241
|
+
// immediately even if the user didn't pick columns yet.
|
|
242
|
+
const objectDef = objects?.find?.((o) => o.name === objectName);
|
|
243
|
+
const SYSTEM_FIELDS = new Set([
|
|
244
|
+
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
|
|
245
|
+
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
|
|
246
|
+
'updated_by', 'updatedBy', '_version', '_rev',
|
|
247
|
+
]);
|
|
248
|
+
let defaultColumns = [];
|
|
249
|
+
if (Array.isArray(objectDef?.compactLayout) && objectDef.compactLayout.length > 0) {
|
|
250
|
+
defaultColumns = objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
|
|
251
|
+
}
|
|
252
|
+
else if (objectDef?.fields) {
|
|
253
|
+
defaultColumns = Object.entries(objectDef.fields)
|
|
254
|
+
.filter(([name, f]) => f && !f.hidden && !SYSTEM_FIELDS.has(name))
|
|
255
|
+
.map(([name]) => name)
|
|
256
|
+
.slice(0, 5);
|
|
257
|
+
}
|
|
258
|
+
const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
|
|
259
|
+
? config.columns
|
|
260
|
+
: defaultColumns;
|
|
261
|
+
const payload = {
|
|
262
|
+
objectName,
|
|
263
|
+
...config,
|
|
264
|
+
columns: incomingColumns,
|
|
265
|
+
};
|
|
266
|
+
const created = await dataSource.create('sys_view', payload);
|
|
267
|
+
createdId = created?.id ?? created?._id;
|
|
237
268
|
}
|
|
238
269
|
setShowViewConfigPanel(false);
|
|
239
270
|
setViewConfigPanelMode('edit');
|
|
240
271
|
setRefreshKey(k => k + 1);
|
|
272
|
+
// Auto-activate the newly created view (Airtable parity).
|
|
273
|
+
// Routing falls back to the default view if `createdId` doesn't
|
|
274
|
+
// resolve yet — re-render after refresh will pick it up.
|
|
275
|
+
if (createdId) {
|
|
276
|
+
if (viewId) {
|
|
277
|
+
navigate(`../${createdId}`, { relative: 'path' });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
navigate(`view/${createdId}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
241
283
|
}
|
|
242
284
|
catch (err) {
|
|
243
285
|
console.error('[ViewConfigPanel] Failed to create view:', err);
|
|
244
286
|
}
|
|
245
|
-
}, [dataSource, objectName]);
|
|
287
|
+
}, [dataSource, objectName, objects, navigate, viewId]);
|
|
246
288
|
// Record count tracking for footer
|
|
247
289
|
const [recordCount, setRecordCount] = useState(undefined);
|
|
248
290
|
// Admin users automatically get design tools (no toggle needed)
|
|
@@ -254,31 +296,66 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
254
296
|
if (!objectDef) {
|
|
255
297
|
return (_jsx("div", { className: "h-full p-4 flex items-center justify-center", 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(TableIcon, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('console.objectView.objectNotFound') }), _jsxs(EmptyDescription, { children: [t('console.objectView.objectNotFoundDescription', { objectName }), ' ', t('console.objectView.objectNotFoundHint')] })] }) }));
|
|
256
298
|
}
|
|
299
|
+
// Refresh trigger — bumped after view CRUD or external data mutations.
|
|
300
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
301
|
+
// ─── User-defined views (sys_view) ──────────────────────────────────
|
|
302
|
+
// Saved views created via the ViewConfigPanel ("Add View") are persisted
|
|
303
|
+
// to the `sys_view` object. We fetch them here and merge into `views` so
|
|
304
|
+
// the ViewTabBar can render them alongside metadata-defined listViews.
|
|
305
|
+
const [savedViews, setSavedViews] = useState([]);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
let cancelled = false;
|
|
308
|
+
if (!dataSource?.find || !objectName) {
|
|
309
|
+
setSavedViews([]);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
dataSource
|
|
313
|
+
.find('sys_view', {
|
|
314
|
+
$filter: ['objectName', '=', objectName],
|
|
315
|
+
$orderby: [{ field: 'created_at', order: 'asc' }],
|
|
316
|
+
$top: 200,
|
|
317
|
+
})
|
|
318
|
+
.then((res) => {
|
|
319
|
+
if (cancelled)
|
|
320
|
+
return;
|
|
321
|
+
const rows = Array.isArray(res)
|
|
322
|
+
? res
|
|
323
|
+
: Array.isArray(res?.data) ? res.data
|
|
324
|
+
: Array.isArray(res?.records) ? res.records
|
|
325
|
+
: Array.isArray(res?.value) ? res.value
|
|
326
|
+
: [];
|
|
327
|
+
// Defensive client-side filter: only keep rows that look like
|
|
328
|
+
// sys_view records for *this* object. Adapters that don't
|
|
329
|
+
// honour $filter (or test mocks that ignore it) won't pollute
|
|
330
|
+
// the view list with arbitrary records.
|
|
331
|
+
const filtered = rows.filter(r => r && r.objectName === objectName);
|
|
332
|
+
setSavedViews(filtered);
|
|
333
|
+
})
|
|
334
|
+
.catch((err) => {
|
|
335
|
+
console.error('[ObjectView] Failed to load sys_view records:', err);
|
|
336
|
+
if (!cancelled)
|
|
337
|
+
setSavedViews([]);
|
|
338
|
+
});
|
|
339
|
+
return () => { cancelled = true; };
|
|
340
|
+
}, [dataSource, objectName, refreshKey]);
|
|
257
341
|
// Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
|
|
258
342
|
const views = useMemo(() => {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// marked hidden/readonly on the schema. First 5 kept for compactness.
|
|
271
|
-
const SYSTEM_FIELDS = new Set([
|
|
272
|
-
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
|
|
273
|
-
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
|
|
274
|
-
'updated_by', 'updatedBy', '_version', '_rev',
|
|
275
|
-
]);
|
|
276
|
-
let defaultColumns = [];
|
|
343
|
+
// Default column resolution priority:
|
|
344
|
+
// 1. `compactLayout` (curated primary business fields).
|
|
345
|
+
// 2. Business fields only — exclude system-managed identifiers/audit
|
|
346
|
+
// columns (id, created_at, updated_at, …) and fields explicitly
|
|
347
|
+
// marked hidden/readonly on the schema. First 5 kept for compactness.
|
|
348
|
+
const SYSTEM_FIELDS = new Set([
|
|
349
|
+
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
|
|
350
|
+
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
|
|
351
|
+
'updated_by', 'updatedBy', '_version', '_rev',
|
|
352
|
+
]);
|
|
353
|
+
const resolveDefaultColumns = () => {
|
|
277
354
|
if (Array.isArray(objectDef.compactLayout) && objectDef.compactLayout.length > 0) {
|
|
278
|
-
|
|
355
|
+
return objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
|
|
279
356
|
}
|
|
280
|
-
|
|
281
|
-
|
|
357
|
+
if (objectDef.fields) {
|
|
358
|
+
return Object.entries(objectDef.fields)
|
|
282
359
|
.filter(([name, f]) => {
|
|
283
360
|
if (!f)
|
|
284
361
|
return false;
|
|
@@ -291,17 +368,111 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
291
368
|
.map(([name]) => name)
|
|
292
369
|
.slice(0, 5);
|
|
293
370
|
}
|
|
371
|
+
return [];
|
|
372
|
+
};
|
|
373
|
+
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
|
+
}));
|
|
379
|
+
if (viewList.length === 0) {
|
|
294
380
|
viewList.push({
|
|
295
381
|
id: 'all',
|
|
296
382
|
label: t('console.objectView.allRecords'),
|
|
297
383
|
type: 'grid',
|
|
298
|
-
columns:
|
|
384
|
+
columns: resolveDefaultColumns(),
|
|
299
385
|
});
|
|
300
386
|
}
|
|
387
|
+
// Merge user-defined views (sys_view) after metadata-defined views.
|
|
388
|
+
// Dedup by id so a saved view that shadows a metadata view wins.
|
|
389
|
+
const metaIds = new Set(viewList.map(v => v.id));
|
|
390
|
+
for (const sv of savedViews) {
|
|
391
|
+
const id = sv.id || sv._id;
|
|
392
|
+
if (!id)
|
|
393
|
+
continue;
|
|
394
|
+
const normalized = {
|
|
395
|
+
label: sv.label || sv.name || id,
|
|
396
|
+
type: sv.type || 'grid',
|
|
397
|
+
columns: sv.columns,
|
|
398
|
+
filter: sv.filter,
|
|
399
|
+
sort: sv.sort,
|
|
400
|
+
showSearch: sv.showSearch,
|
|
401
|
+
showFilters: sv.showFilters,
|
|
402
|
+
showSort: sv.showSort,
|
|
403
|
+
isPinned: sv.isPinned,
|
|
404
|
+
isDefault: sv.isDefault,
|
|
405
|
+
visibility: sv.visibility,
|
|
406
|
+
sortOrder: sv.sortOrder,
|
|
407
|
+
...sv,
|
|
408
|
+
id,
|
|
409
|
+
};
|
|
410
|
+
if (metaIds.has(id)) {
|
|
411
|
+
const idx = viewList.findIndex(v => v.id === id);
|
|
412
|
+
viewList[idx] = { ...viewList[idx], ...normalized };
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
viewList.push(normalized);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Apply default columns to any grid-like view that has no explicit
|
|
419
|
+
// columns (e.g. saved views created via "Add View" before the user
|
|
420
|
+
// configured fields). Without this, the grid renders an empty header
|
|
421
|
+
// row and the data fetch omits a `select` clause.
|
|
422
|
+
const GRID_LIKE = new Set(['grid', 'list', 'table']);
|
|
423
|
+
for (const v of viewList) {
|
|
424
|
+
if (!GRID_LIKE.has(v.type))
|
|
425
|
+
continue;
|
|
426
|
+
if (!Array.isArray(v.columns) || v.columns.length === 0) {
|
|
427
|
+
v.columns = resolveDefaultColumns();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Stable sort: respect a per-user view-order preference (for both
|
|
431
|
+
// metadata and saved views). Falls back to: metadata views first
|
|
432
|
+
// in declared order, then saved views by `sortOrder` / created_at.
|
|
433
|
+
const indexOf = new Map(viewList.map((v, i) => [v.id, i]));
|
|
434
|
+
let userOrder = [];
|
|
435
|
+
try {
|
|
436
|
+
const raw = typeof window !== 'undefined'
|
|
437
|
+
? window.localStorage?.getItem(`viewOrder:${objectDef.name}`)
|
|
438
|
+
: null;
|
|
439
|
+
if (raw)
|
|
440
|
+
userOrder = JSON.parse(raw);
|
|
441
|
+
}
|
|
442
|
+
catch { /* ignore */ }
|
|
443
|
+
const userOrderIndex = new Map(userOrder.map((id, i) => [id, i]));
|
|
444
|
+
viewList.sort((a, b) => {
|
|
445
|
+
const aUser = userOrderIndex.get(a.id);
|
|
446
|
+
const bUser = userOrderIndex.get(b.id);
|
|
447
|
+
if (aUser !== undefined && bUser !== undefined)
|
|
448
|
+
return aUser - bUser;
|
|
449
|
+
if (aUser !== undefined)
|
|
450
|
+
return -1;
|
|
451
|
+
if (bUser !== undefined)
|
|
452
|
+
return 1;
|
|
453
|
+
const aSaved = savedViews.find((sv) => (sv.id || sv._id) === a.id);
|
|
454
|
+
const bSaved = savedViews.find((sv) => (sv.id || sv._id) === b.id);
|
|
455
|
+
if (!aSaved && !bSaved)
|
|
456
|
+
return (indexOf.get(a.id) ?? 0) - (indexOf.get(b.id) ?? 0);
|
|
457
|
+
if (!aSaved)
|
|
458
|
+
return -1;
|
|
459
|
+
if (!bSaved)
|
|
460
|
+
return 1;
|
|
461
|
+
const ao = typeof aSaved.sortOrder === 'number' ? aSaved.sortOrder : Number.MAX_SAFE_INTEGER;
|
|
462
|
+
const bo = typeof bSaved.sortOrder === 'number' ? bSaved.sortOrder : Number.MAX_SAFE_INTEGER;
|
|
463
|
+
if (ao !== bo)
|
|
464
|
+
return ao - bo;
|
|
465
|
+
return (aSaved.created_at || '').localeCompare(bSaved.created_at || '');
|
|
466
|
+
});
|
|
301
467
|
return viewList;
|
|
302
|
-
}, [objectDef]);
|
|
303
|
-
// Active View State — merge saved draft if available for this view
|
|
304
|
-
|
|
468
|
+
}, [objectDef, savedViews, t]);
|
|
469
|
+
// Active View State — merge saved draft if available for this view.
|
|
470
|
+
// Resolution priority: URL viewId → ?view= → user-marked default → first.
|
|
471
|
+
const defaultViewId = useMemo(() => {
|
|
472
|
+
const def = views.find((v) => v.isDefault);
|
|
473
|
+
return def?.id;
|
|
474
|
+
}, [views]);
|
|
475
|
+
const activeViewId = viewId || searchParams.get('view') || defaultViewId || views[0]?.id;
|
|
305
476
|
const baseView = views.find((v) => v.id === activeViewId) || views[0];
|
|
306
477
|
const activeView = viewDraft && viewDraft.id === baseView?.id
|
|
307
478
|
? { ...baseView, ...viewDraft }
|
|
@@ -319,8 +490,12 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
319
490
|
const matchedView = views.find((v) => v.id === newViewId);
|
|
320
491
|
if (!matchedView)
|
|
321
492
|
return;
|
|
322
|
-
// Auto-close the config panel when switching
|
|
323
|
-
|
|
493
|
+
// Auto-close the config panel only when actually switching to a
|
|
494
|
+
// different view. Same-view clicks (e.g., bubbling from the actions
|
|
495
|
+
// dropdown menu item) must not stomp on a freshly-opened panel.
|
|
496
|
+
if (matchedView.id !== activeViewId) {
|
|
497
|
+
setShowViewConfigPanel(false);
|
|
498
|
+
}
|
|
324
499
|
if (viewId) {
|
|
325
500
|
navigate(`../${matchedView.id}`, { relative: "path" });
|
|
326
501
|
}
|
|
@@ -330,8 +505,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
330
505
|
};
|
|
331
506
|
// ViewSwitcher callbacks — wired to both PluginObjectView instances
|
|
332
507
|
const handleCreateView = useCallback(() => {
|
|
333
|
-
|
|
334
|
-
setShowViewConfigPanel(true);
|
|
508
|
+
setShowCreateViewDialog(true);
|
|
335
509
|
}, []);
|
|
336
510
|
const handleViewAction = useCallback((actionType, viewType) => {
|
|
337
511
|
if (actionType === 'settings') {
|
|
@@ -342,19 +516,30 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
342
516
|
setShowViewConfigPanel(true);
|
|
343
517
|
}
|
|
344
518
|
}, [views, handleViewChange]);
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
519
|
+
// ─── ViewTabBar CRUD callbacks (Phase 2) ────────────────────────────
|
|
520
|
+
/** Returns true if the view is backed by a sys_view record (mutable). */
|
|
521
|
+
const isSavedView = useCallback((vid) => {
|
|
522
|
+
return savedViews.some((sv) => (sv.id || sv._id) === vid);
|
|
523
|
+
}, [savedViews]);
|
|
524
|
+
const handleRenameView = useCallback(async (vid, newName) => {
|
|
525
|
+
if (!dataSource?.update)
|
|
526
|
+
return;
|
|
527
|
+
if (!isSavedView(vid)) {
|
|
528
|
+
toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be renamed.');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await dataSource.update('sys_view', vid, { label: newName });
|
|
533
|
+
setRefreshKey(k => k + 1);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
console.error('[ViewTabBar] Failed to rename view:', err);
|
|
537
|
+
toast.error('Failed to rename view');
|
|
538
|
+
}
|
|
539
|
+
}, [dataSource, isSavedView, t]);
|
|
540
|
+
// Promise-based confirm/param dialogs — declared early so destructive
|
|
541
|
+
// handlers (delete, etc.) can `await confirmHandler(...)` for a proper
|
|
542
|
+
// Airtable-style confirmation flow.
|
|
358
543
|
const [confirmState, setConfirmState] = useState({ open: false, message: '' });
|
|
359
544
|
const [paramState, setParamState] = useState({ open: false, params: [] });
|
|
360
545
|
const confirmHandler = useCallback((message, options) => {
|
|
@@ -367,6 +552,151 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
367
552
|
setParamState({ open: true, params, resolve });
|
|
368
553
|
});
|
|
369
554
|
}, []);
|
|
555
|
+
const handleDeleteView = useCallback(async (vid) => {
|
|
556
|
+
if (!dataSource?.delete)
|
|
557
|
+
return;
|
|
558
|
+
if (!isSavedView(vid)) {
|
|
559
|
+
toast.error(t('console.objectView.cannotDeleteMetaView') || 'Built-in views cannot be deleted.');
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const targetView = views.find((v) => v.id === vid);
|
|
563
|
+
const viewLabel = targetView?.label || vid;
|
|
564
|
+
const confirmed = await confirmHandler(t('console.objectView.deleteViewConfirm', { name: viewLabel }) ||
|
|
565
|
+
`Are you sure you want to delete the view "${viewLabel}"? This cannot be undone.`, {
|
|
566
|
+
title: t('console.objectView.deleteViewTitle') || 'Delete view',
|
|
567
|
+
confirmText: t('console.objectView.delete') || 'Delete',
|
|
568
|
+
cancelText: t('console.objectView.cancel') || 'Cancel',
|
|
569
|
+
});
|
|
570
|
+
if (!confirmed)
|
|
571
|
+
return;
|
|
572
|
+
try {
|
|
573
|
+
await dataSource.delete('sys_view', vid);
|
|
574
|
+
// If we deleted the active view, fall back to the first remaining view.
|
|
575
|
+
if (vid === activeViewId) {
|
|
576
|
+
const fallback = views.find((v) => v.id !== vid);
|
|
577
|
+
if (fallback)
|
|
578
|
+
navigate(viewId ? `../${fallback.id}` : `view/${fallback.id}`, viewId ? { relative: 'path' } : undefined);
|
|
579
|
+
}
|
|
580
|
+
setRefreshKey(k => k + 1);
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
console.error('[ViewTabBar] Failed to delete view:', err);
|
|
584
|
+
toast.error('Failed to delete view');
|
|
585
|
+
}
|
|
586
|
+
}, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]);
|
|
587
|
+
const handleDuplicateView = useCallback(async (vid) => {
|
|
588
|
+
if (!dataSource?.create)
|
|
589
|
+
return;
|
|
590
|
+
const source = views.find((v) => v.id === vid);
|
|
591
|
+
if (!source)
|
|
592
|
+
return;
|
|
593
|
+
try {
|
|
594
|
+
const { id: _omit, created_at, updated_at, ...rest } = source;
|
|
595
|
+
const newId = `view_${Date.now()}`;
|
|
596
|
+
const payload = {
|
|
597
|
+
...rest,
|
|
598
|
+
objectName,
|
|
599
|
+
id: newId,
|
|
600
|
+
label: `${source.label || vid} (Copy)`,
|
|
601
|
+
isDefault: false,
|
|
602
|
+
isPinned: false,
|
|
603
|
+
};
|
|
604
|
+
await dataSource.create('sys_view', payload);
|
|
605
|
+
setRefreshKey(k => k + 1);
|
|
606
|
+
// Auto-activate the duplicate (Airtable parity).
|
|
607
|
+
if (viewId) {
|
|
608
|
+
navigate(`../${newId}`, { relative: 'path' });
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
navigate(`view/${newId}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
console.error('[ViewTabBar] Failed to duplicate view:', err);
|
|
616
|
+
toast.error('Failed to duplicate view');
|
|
617
|
+
}
|
|
618
|
+
}, [dataSource, views, objectName, navigate, viewId]);
|
|
619
|
+
const handlePinView = useCallback(async (vid, pinned) => {
|
|
620
|
+
if (!dataSource?.update)
|
|
621
|
+
return;
|
|
622
|
+
if (!isSavedView(vid)) {
|
|
623
|
+
toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be pinned.');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
await dataSource.update('sys_view', vid, { isPinned: pinned });
|
|
628
|
+
setRefreshKey(k => k + 1);
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
console.error('[ViewTabBar] Failed to pin view:', err);
|
|
632
|
+
}
|
|
633
|
+
}, [dataSource, isSavedView, t]);
|
|
634
|
+
const handleSetDefaultView = useCallback(async (vid) => {
|
|
635
|
+
if (!dataSource?.update)
|
|
636
|
+
return;
|
|
637
|
+
try {
|
|
638
|
+
// Clear `isDefault` on all other saved views, then set this one.
|
|
639
|
+
const updates = savedViews
|
|
640
|
+
.filter((sv) => (sv.id || sv._id) !== vid && sv.isDefault)
|
|
641
|
+
.map((sv) => dataSource.update('sys_view', sv.id || sv._id, { isDefault: false }));
|
|
642
|
+
if (isSavedView(vid)) {
|
|
643
|
+
updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
|
|
644
|
+
}
|
|
645
|
+
await Promise.all(updates);
|
|
646
|
+
setRefreshKey(k => k + 1);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
console.error('[ViewTabBar] Failed to set default view:', err);
|
|
650
|
+
toast.error('Failed to set default view');
|
|
651
|
+
}
|
|
652
|
+
}, [dataSource, savedViews, isSavedView]);
|
|
653
|
+
const handleReorderViews = useCallback(async (orderedIds) => {
|
|
654
|
+
// Persist order for ALL views (incl. metadata) in localStorage so the
|
|
655
|
+
// UI immediately reflects the new ordering, including reorderings
|
|
656
|
+
// that involve metadata-only views.
|
|
657
|
+
try {
|
|
658
|
+
if (typeof window !== 'undefined') {
|
|
659
|
+
window.localStorage?.setItem(`viewOrder:${objectName}`, JSON.stringify(orderedIds));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch { /* ignore */ }
|
|
663
|
+
// Best-effort: also persist `sortOrder` on each saved view so other
|
|
664
|
+
// sessions / users can pick up the order from the backend.
|
|
665
|
+
if (dataSource?.update) {
|
|
666
|
+
const savedIdSet = new Set(savedViews.map((sv) => sv.id || sv._id));
|
|
667
|
+
const updates = orderedIds
|
|
668
|
+
.filter(id => savedIdSet.has(id))
|
|
669
|
+
.map((id, idx) => dataSource.update('sys_view', id, { sortOrder: idx }));
|
|
670
|
+
try {
|
|
671
|
+
await Promise.all(updates);
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
console.error('[ViewTabBar] Failed to reorder views:', err);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
setRefreshKey(k => k + 1);
|
|
678
|
+
}, [dataSource, savedViews, objectName]);
|
|
679
|
+
const handleConfigView = useCallback((vid) => {
|
|
680
|
+
if (vid !== activeViewId)
|
|
681
|
+
handleViewChange(vid);
|
|
682
|
+
setViewConfigPanelMode('edit');
|
|
683
|
+
setShowViewConfigPanel(true);
|
|
684
|
+
}, [activeViewId, handleViewChange]);
|
|
685
|
+
const handleAddView = useCallback(() => {
|
|
686
|
+
setShowCreateViewDialog(true);
|
|
687
|
+
}, []);
|
|
688
|
+
// Action system for toolbar operations — refreshKey moved up (declared earlier).
|
|
689
|
+
const actions = useObjectActions({
|
|
690
|
+
objectName: objectDef.name,
|
|
691
|
+
objectLabel: objectDef.label,
|
|
692
|
+
dataSource,
|
|
693
|
+
onEdit,
|
|
694
|
+
onRefresh: () => setRefreshKey(k => k + 1),
|
|
695
|
+
});
|
|
696
|
+
// ─── ActionProvider handlers for schema-driven toolbar actions ──────
|
|
697
|
+
const currentUser = user
|
|
698
|
+
? { id: user.id, name: user.name, avatar: user.image }
|
|
699
|
+
: FALLBACK_USER;
|
|
370
700
|
const toastHandler = useCallback((message, options) => {
|
|
371
701
|
if (options?.type === 'error')
|
|
372
702
|
toast.error(message);
|
|
@@ -521,16 +851,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
521
851
|
window.open(`${basePath}/record/${encodeURIComponent(String(recordId))}`, '_blank');
|
|
522
852
|
return;
|
|
523
853
|
}
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
854
|
+
// Default: navigate to record detail page.
|
|
855
|
+
// `action` may be 'view' / 'page' / undefined, OR a custom view name
|
|
856
|
+
// forwarded from `navigation.view` (e.g. 'detail_form'). The view
|
|
857
|
+
// variant is resolved by RecordDetailView from its own config, so
|
|
858
|
+
// any non-`new_window` action lands on the record detail route.
|
|
859
|
+
if (viewId) {
|
|
860
|
+
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path' });
|
|
861
|
+
}
|
|
862
|
+
else {
|
|
863
|
+
navigate(`record/${encodeURIComponent(String(recordId))}`);
|
|
534
864
|
}
|
|
535
865
|
}, [navigate, viewId]);
|
|
536
866
|
const navOverlay = useNavigationOverlay({
|
|
@@ -575,17 +905,17 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
575
905
|
}
|
|
576
906
|
if (viewDef.type === 'chart') {
|
|
577
907
|
const chartConfig = viewDef.chart || {};
|
|
578
|
-
return (_jsx(ObjectChart, { dataSource: ds, schema: {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
908
|
+
return (_jsx(Suspense, { fallback: _jsx("div", { className: "p-4 text-sm text-muted-foreground", children: "Loading chart\u2026" }), children: _jsx(ObjectChart, { dataSource: ds, schema: {
|
|
909
|
+
type: 'object-chart',
|
|
910
|
+
objectName: objectDef.name,
|
|
911
|
+
chartType: chartConfig.chartType,
|
|
912
|
+
xAxisField: chartConfig.xAxisField,
|
|
913
|
+
yAxisFields: chartConfig.yAxisFields,
|
|
914
|
+
aggregation: chartConfig.aggregation,
|
|
915
|
+
series: chartConfig.series,
|
|
916
|
+
config: chartConfig.config,
|
|
917
|
+
filter: chartConfig.filter,
|
|
918
|
+
} }) }, key));
|
|
589
919
|
}
|
|
590
920
|
const fullSchema = {
|
|
591
921
|
...listSchema,
|
|
@@ -739,13 +1069,27 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
739
1069
|
actions: objectDef.actions,
|
|
740
1070
|
size: 'sm',
|
|
741
1071
|
variant: 'outline',
|
|
742
|
-
} }))
|
|
1072
|
+
} }))] })] }), views.length >= 1 && (() => {
|
|
1073
|
+
const viewTabItems = views.map((view) => {
|
|
1074
|
+
const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
|
|
1075
|
+
return {
|
|
743
1076
|
id: view.id,
|
|
744
1077
|
label: view.label,
|
|
745
1078
|
type: view.type,
|
|
746
1079
|
hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
|
|
747
1080
|
hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0,
|
|
748
|
-
|
|
1081
|
+
isDefault: !!(saved?.isDefault ?? view.isDefault),
|
|
1082
|
+
isPinned: !!(saved?.isPinned ?? view.isPinned),
|
|
1083
|
+
visibility: saved?.visibility ?? view.visibility,
|
|
1084
|
+
};
|
|
1085
|
+
});
|
|
1086
|
+
return (_jsxs("div", { className: "border-b px-3 sm:px-4 bg-background overflow-x-auto shrink-0", children: [_jsx(ViewTabBar, { views: viewTabItems, activeViewId: activeViewId, onViewChange: handleViewChange, viewTypeIcons: VIEW_TYPE_ICONS, config: {
|
|
1087
|
+
reorderable: false,
|
|
1088
|
+
showAddButton: isAdmin,
|
|
1089
|
+
showPinnedSection: true,
|
|
1090
|
+
showVisibilityGroups: true,
|
|
1091
|
+
}, 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 }))] }));
|
|
1092
|
+
})(), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
|
|
749
1093
|
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) => {
|
|
750
1094
|
navOverlay.handleClick(record);
|
|
751
1095
|
}, 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) => {
|
|
@@ -756,7 +1100,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
756
1100
|
}, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
|
|
757
1101
|
{ title: 'View Configuration', data: activeView },
|
|
758
1102
|
{ title: 'Object Definition', data: objectDef },
|
|
759
|
-
] }), _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 }) })] }), navOverlay.mode !== 'split' && (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
|
|
1103
|
+
] }), _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)
|
|
760
1104
|
handleDrawerClose(); }, title: objectDef.label, className: navOverlay.mode === 'drawer' ? 'w-[90vw] sm:max-w-2xl p-0 overflow-hidden' : undefined, children: (record) => {
|
|
761
1105
|
const recordId = (record.id || record._id);
|
|
762
1106
|
return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
|