@object-ui/app-shell 3.3.2 → 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 +32 -0
- package/dist/layout/AppHeader.js +20 -8
- package/dist/layout/UnifiedSidebar.js +4 -4
- package/dist/providers/MetadataProvider.js +42 -2
- package/dist/utils/getIcon.js +2 -37
- 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.js +408 -63
- 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 +28 -26
- package/src/styles.css +16 -0
package/dist/views/ObjectView.js
CHANGED
|
@@ -14,15 +14,16 @@ 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';
|
|
17
|
+
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
|
|
18
18
|
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
|
|
19
19
|
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
|
|
20
20
|
// Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
|
|
21
|
-
import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay
|
|
22
|
-
import { Plus, Table as TableIcon,
|
|
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';
|
|
23
23
|
import { getIcon } from '../utils/getIcon';
|
|
24
|
-
import {
|
|
24
|
+
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
|
|
25
25
|
import { ViewConfigPanel } from './ViewConfigPanel';
|
|
26
|
+
import { CreateViewDialog } from './CreateViewDialog';
|
|
26
27
|
import { useObjectActions } from '../hooks/useObjectActions';
|
|
27
28
|
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
28
29
|
import { usePermissions } from '@object-ui/permissions';
|
|
@@ -199,12 +200,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
199
200
|
const navigate = useNavigate();
|
|
200
201
|
const { objectName, viewId } = useParams();
|
|
201
202
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
202
|
-
const { showDebug
|
|
203
|
+
const { showDebug } = useMetadataInspector();
|
|
203
204
|
const { t } = useObjectTranslation();
|
|
204
205
|
const { objectLabel, objectDescription: objectDesc } = useObjectLabel();
|
|
205
206
|
// Inline view config panel state (Airtable-style right sidebar)
|
|
206
207
|
const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
|
|
207
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);
|
|
208
213
|
// Draft state for view config edits — cached locally, saved on demand
|
|
209
214
|
const [viewDraft, setViewDraft] = useState(null);
|
|
210
215
|
const handleViewConfigSave = useCallback((draft) => {
|
|
@@ -230,18 +235,56 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
230
235
|
/** Create a new view via the config panel */
|
|
231
236
|
const handleViewCreate = useCallback(async (config) => {
|
|
232
237
|
try {
|
|
238
|
+
let createdId;
|
|
233
239
|
if (dataSource?.create) {
|
|
234
|
-
|
|
235
|
-
|
|
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;
|
|
236
268
|
}
|
|
237
269
|
setShowViewConfigPanel(false);
|
|
238
270
|
setViewConfigPanelMode('edit');
|
|
239
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
|
+
}
|
|
240
283
|
}
|
|
241
284
|
catch (err) {
|
|
242
285
|
console.error('[ViewConfigPanel] Failed to create view:', err);
|
|
243
286
|
}
|
|
244
|
-
}, [dataSource, objectName]);
|
|
287
|
+
}, [dataSource, objectName, objects, navigate, viewId]);
|
|
245
288
|
// Record count tracking for footer
|
|
246
289
|
const [recordCount, setRecordCount] = useState(undefined);
|
|
247
290
|
// Admin users automatically get design tools (no toggle needed)
|
|
@@ -253,31 +296,66 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
253
296
|
if (!objectDef) {
|
|
254
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')] })] }) }));
|
|
255
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]);
|
|
256
341
|
// Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
|
|
257
342
|
const views = useMemo(() => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// marked hidden/readonly on the schema. First 5 kept for compactness.
|
|
270
|
-
const SYSTEM_FIELDS = new Set([
|
|
271
|
-
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
|
|
272
|
-
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
|
|
273
|
-
'updated_by', 'updatedBy', '_version', '_rev',
|
|
274
|
-
]);
|
|
275
|
-
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 = () => {
|
|
276
354
|
if (Array.isArray(objectDef.compactLayout) && objectDef.compactLayout.length > 0) {
|
|
277
|
-
|
|
355
|
+
return objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
|
|
278
356
|
}
|
|
279
|
-
|
|
280
|
-
|
|
357
|
+
if (objectDef.fields) {
|
|
358
|
+
return Object.entries(objectDef.fields)
|
|
281
359
|
.filter(([name, f]) => {
|
|
282
360
|
if (!f)
|
|
283
361
|
return false;
|
|
@@ -290,17 +368,111 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
290
368
|
.map(([name]) => name)
|
|
291
369
|
.slice(0, 5);
|
|
292
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) {
|
|
293
380
|
viewList.push({
|
|
294
381
|
id: 'all',
|
|
295
382
|
label: t('console.objectView.allRecords'),
|
|
296
383
|
type: 'grid',
|
|
297
|
-
columns:
|
|
384
|
+
columns: resolveDefaultColumns(),
|
|
298
385
|
});
|
|
299
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
|
+
});
|
|
300
467
|
return viewList;
|
|
301
|
-
}, [objectDef]);
|
|
302
|
-
// Active View State — merge saved draft if available for this view
|
|
303
|
-
|
|
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;
|
|
304
476
|
const baseView = views.find((v) => v.id === activeViewId) || views[0];
|
|
305
477
|
const activeView = viewDraft && viewDraft.id === baseView?.id
|
|
306
478
|
? { ...baseView, ...viewDraft }
|
|
@@ -318,8 +490,12 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
318
490
|
const matchedView = views.find((v) => v.id === newViewId);
|
|
319
491
|
if (!matchedView)
|
|
320
492
|
return;
|
|
321
|
-
// Auto-close the config panel when switching
|
|
322
|
-
|
|
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
|
+
}
|
|
323
499
|
if (viewId) {
|
|
324
500
|
navigate(`../${matchedView.id}`, { relative: "path" });
|
|
325
501
|
}
|
|
@@ -329,8 +505,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
329
505
|
};
|
|
330
506
|
// ViewSwitcher callbacks — wired to both PluginObjectView instances
|
|
331
507
|
const handleCreateView = useCallback(() => {
|
|
332
|
-
|
|
333
|
-
setShowViewConfigPanel(true);
|
|
508
|
+
setShowCreateViewDialog(true);
|
|
334
509
|
}, []);
|
|
335
510
|
const handleViewAction = useCallback((actionType, viewType) => {
|
|
336
511
|
if (actionType === 'settings') {
|
|
@@ -341,19 +516,30 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
341
516
|
setShowViewConfigPanel(true);
|
|
342
517
|
}
|
|
343
518
|
}, [views, handleViewChange]);
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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.
|
|
357
543
|
const [confirmState, setConfirmState] = useState({ open: false, message: '' });
|
|
358
544
|
const [paramState, setParamState] = useState({ open: false, params: [] });
|
|
359
545
|
const confirmHandler = useCallback((message, options) => {
|
|
@@ -366,6 +552,151 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
366
552
|
setParamState({ open: true, params, resolve });
|
|
367
553
|
});
|
|
368
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;
|
|
369
700
|
const toastHandler = useCallback((message, options) => {
|
|
370
701
|
if (options?.type === 'error')
|
|
371
702
|
toast.error(message);
|
|
@@ -520,16 +851,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
520
851
|
window.open(`${basePath}/record/${encodeURIComponent(String(recordId))}`, '_blank');
|
|
521
852
|
return;
|
|
522
853
|
}
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
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))}`);
|
|
533
864
|
}
|
|
534
865
|
}, [navigate, viewId]);
|
|
535
866
|
const navOverlay = useNavigationOverlay({
|
|
@@ -738,13 +1069,27 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
738
1069
|
actions: objectDef.actions,
|
|
739
1070
|
size: 'sm',
|
|
740
1071
|
variant: 'outline',
|
|
741
|
-
} }))
|
|
1072
|
+
} }))] })] }), views.length >= 1 && (() => {
|
|
1073
|
+
const viewTabItems = views.map((view) => {
|
|
1074
|
+
const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
|
|
1075
|
+
return {
|
|
742
1076
|
id: view.id,
|
|
743
1077
|
label: view.label,
|
|
744
1078
|
type: view.type,
|
|
745
1079
|
hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
|
|
746
1080
|
hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0,
|
|
747
|
-
|
|
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)
|
|
748
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) => {
|
|
749
1094
|
navOverlay.handleClick(record);
|
|
750
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) => {
|
|
@@ -755,7 +1100,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
|
|
|
755
1100
|
}, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
|
|
756
1101
|
{ title: 'View Configuration', data: activeView },
|
|
757
1102
|
{ title: 'Object Definition', data: objectDef },
|
|
758
|
-
] }), _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)
|
|
759
1104
|
handleDrawerClose(); }, title: objectDef.label, className: navOverlay.mode === 'drawer' ? 'w-[90vw] sm:max-w-2xl p-0 overflow-hidden' : undefined, children: (record) => {
|
|
760
1105
|
const recordId = (record.id || record._id);
|
|
761
1106
|
return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ViewConfigPanel —
|
|
2
|
+
* ViewConfigPanel — Airtable-style simplified configuration panel.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Renders a focused, opinionated set of sections (Page / Data /
|
|
5
|
+
* Appearance / Toolbar / User actions / Navigation / Advanced) — matching
|
|
6
|
+
* Airtable's Interface designer right rail. Power-user sections (records,
|
|
7
|
+
* sharing, accessibility) are intentionally excluded to keep the panel
|
|
8
|
+
* approachable; they can be re-introduced later as a dedicated "advanced"
|
|
9
|
+
* settings dialog if needed.
|
|
8
10
|
*
|
|
9
11
|
* All changes are buffered in a local draft state. Clicking Save commits
|
|
10
12
|
* the draft via onSave; Discard resets to the original activeView.
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* ViewConfigPanel —
|
|
3
|
+
* ViewConfigPanel — Airtable-style simplified configuration panel.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Renders a focused, opinionated set of sections (Page / Data /
|
|
6
|
+
* Appearance / Toolbar / User actions / Navigation / Advanced) — matching
|
|
7
|
+
* Airtable's Interface designer right rail. Power-user sections (records,
|
|
8
|
+
* sharing, accessibility) are intentionally excluded to keep the panel
|
|
9
|
+
* approachable; they can be re-introduced later as a dedicated "advanced"
|
|
10
|
+
* settings dialog if needed.
|
|
9
11
|
*
|
|
10
12
|
* All changes are buffered in a local draft state. Clicking Save commits
|
|
11
13
|
* the draft via onSave; Discard resets to the original activeView.
|
|
@@ -51,7 +53,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
|
|
|
51
53
|
// Bridge: filter/sort → builder format
|
|
52
54
|
const filterGroupValue = useMemo(() => toFilterGroup(draft.filter), [draft.filter]);
|
|
53
55
|
const sortItemsValue = useMemo(() => toSortItems(draft.sort), [draft.sort]);
|
|
54
|
-
// Build schema
|
|
56
|
+
// Build schema — always essentials-only (Airtable-style focused layout).
|
|
55
57
|
const schema = useMemo(() => buildViewConfigSchema({
|
|
56
58
|
t,
|
|
57
59
|
fieldOptions,
|
|
@@ -59,6 +61,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
|
|
|
59
61
|
updateField,
|
|
60
62
|
filterGroupValue,
|
|
61
63
|
sortItemsValue,
|
|
64
|
+
essentialOnly: true,
|
|
62
65
|
}), [t, fieldOptions, objectDef, updateField, filterGroupValue, sortItemsValue]);
|
|
63
66
|
// Override breadcrumb with dynamic view type
|
|
64
67
|
const viewType = draft.type || 'grid';
|