@object-ui/app-shell 4.5.0 → 4.6.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 +17 -0
- package/README.md +21 -0
- package/dist/console/AppContent.js +6 -39
- package/dist/console/ConsoleShell.js +45 -4
- package/dist/console/home/AppCard.d.ts +2 -1
- package/dist/console/home/AppCard.js +24 -5
- package/dist/console/home/HomePage.js +29 -7
- package/dist/console/home/QuickActions.js +13 -7
- package/dist/console/home/RecentApps.js +12 -5
- package/dist/console/home/StarredApps.js +12 -5
- package/dist/context/FavoritesProvider.d.ts +9 -12
- package/dist/context/FavoritesProvider.js +83 -37
- package/dist/context/RecentItemsProvider.d.ts +45 -0
- package/dist/context/RecentItemsProvider.js +139 -0
- package/dist/context/UserStateAdapters.d.ts +61 -0
- package/dist/context/UserStateAdapters.js +141 -0
- package/dist/context/index.d.ts +4 -0
- package/dist/context/index.js +2 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useRecentItems.d.ts +9 -17
- package/dist/hooks/useRecentItems.js +9 -46
- package/dist/hooks/useTrackRouteAsRecent.d.ts +18 -0
- package/dist/hooks/useTrackRouteAsRecent.js +91 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/layout/PageHeader.d.ts +29 -2
- package/dist/layout/PageHeader.js +20 -2
- package/dist/views/ActionParamDialog.js +16 -2
- package/dist/views/ObjectView.js +63 -164
- package/dist/views/RecordDetailView.d.ts +20 -1
- package/dist/views/RecordDetailView.js +23 -9
- package/package.json +24 -24
|
@@ -1,50 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useRecentItems
|
|
2
|
+
* useRecentItems — re-export shim
|
|
3
|
+
*
|
|
4
|
+
* The recent-items state has been migrated to a React Context so all consumers
|
|
5
|
+
* share a single state instance and can be backed by an optional backend
|
|
6
|
+
* persistence adapter. See `../context/RecentItemsProvider`.
|
|
7
|
+
*
|
|
8
|
+
* All existing imports of `useRecentItems` and `RecentItem` from this path
|
|
9
|
+
* continue to work without any changes at the call sites.
|
|
3
10
|
*
|
|
4
|
-
* Tracks recently visited items (objects, dashboards, pages) with
|
|
5
|
-
* localStorage persistence. Exposes helpers to add and retrieve items.
|
|
6
11
|
* @module
|
|
7
12
|
*/
|
|
8
|
-
|
|
9
|
-
const STORAGE_KEY = 'objectui-recent-items';
|
|
10
|
-
const MAX_RECENT = 8;
|
|
11
|
-
function loadRecent() {
|
|
12
|
-
try {
|
|
13
|
-
const raw = localStorage.getItem(STORAGE_KEY);
|
|
14
|
-
return raw ? JSON.parse(raw) : [];
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return [];
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
function saveRecent(items) {
|
|
21
|
-
try {
|
|
22
|
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
// Storage full — silently ignore
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
export function useRecentItems() {
|
|
29
|
-
const [recentItems, setRecentItems] = useState(loadRecent);
|
|
30
|
-
// Sync from storage on mount
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
setRecentItems(loadRecent());
|
|
33
|
-
}, []);
|
|
34
|
-
const addRecentItem = useCallback((item) => {
|
|
35
|
-
setRecentItems(prev => {
|
|
36
|
-
const filtered = prev.filter(r => r.id !== item.id);
|
|
37
|
-
const updated = [
|
|
38
|
-
{ ...item, visitedAt: new Date().toISOString() },
|
|
39
|
-
...filtered,
|
|
40
|
-
].slice(0, MAX_RECENT);
|
|
41
|
-
saveRecent(updated);
|
|
42
|
-
return updated;
|
|
43
|
-
});
|
|
44
|
-
}, []);
|
|
45
|
-
const clearRecentItems = useCallback(() => {
|
|
46
|
-
setRecentItems([]);
|
|
47
|
-
saveRecent([]);
|
|
48
|
-
}, []);
|
|
49
|
-
return { recentItems, addRecentItem, clearRecentItems };
|
|
50
|
-
}
|
|
13
|
+
export { useRecentItems } from '../context/RecentItemsProvider';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface ObjectLike {
|
|
2
|
+
name: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface UseTrackRouteAsRecentOptions {
|
|
6
|
+
/** Active route path. Usually `useLocation().pathname`. */
|
|
7
|
+
pathname: string;
|
|
8
|
+
/** Currently selected app name. Used to build the `href` and namespace. */
|
|
9
|
+
appName: string | undefined;
|
|
10
|
+
/** Objects available in the current app — used to resolve labels. */
|
|
11
|
+
objects?: ObjectLike[];
|
|
12
|
+
/** Optional override; defaults to `/apps`. */
|
|
13
|
+
basePathSegment?: string;
|
|
14
|
+
/** When `true`, the effect is suspended (e.g. when shell is hydrating). */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function useTrackRouteAsRecent({ pathname, appName, objects, basePathSegment, disabled, }: UseTrackRouteAsRecentOptions): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTrackRouteAsRecent
|
|
3
|
+
*
|
|
4
|
+
* Watches `pathname` and records the current entity (object / dashboard /
|
|
5
|
+
* page / report) into `RecentItemsProvider`. Encapsulates the URL parsing
|
|
6
|
+
* logic that previously lived inline in `AppContent.tsx` so it can be reused
|
|
7
|
+
* by other shells and tested in isolation.
|
|
8
|
+
*
|
|
9
|
+
* The hook understands the standard console URL layout:
|
|
10
|
+
*
|
|
11
|
+
* /apps/:appName/:objectName
|
|
12
|
+
* /apps/:appName/dashboard/:id
|
|
13
|
+
* /apps/:appName/page/:id
|
|
14
|
+
* /apps/:appName/report/:id
|
|
15
|
+
*
|
|
16
|
+
* Pass `objects` (the list available in the current app) so we can resolve
|
|
17
|
+
* a human-readable label for object routes.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import { useEffect, useRef } from 'react';
|
|
22
|
+
import { useRecentItems } from '../context/RecentItemsProvider';
|
|
23
|
+
/** Segments after `appName` that are NOT object names but route prefixes. */
|
|
24
|
+
const ROUTE_PREFIXES = new Set(['view', 'record', 'page', 'dashboard', 'design', 'report']);
|
|
25
|
+
function titleize(slug) {
|
|
26
|
+
return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
27
|
+
}
|
|
28
|
+
export function useTrackRouteAsRecent({ pathname, appName, objects = [], basePathSegment = 'apps', disabled = false, }) {
|
|
29
|
+
const { addRecentItem } = useRecentItems();
|
|
30
|
+
// Hold `objects` in a ref so we don't re-fire the tracking effect every
|
|
31
|
+
// time the parent passes a new array reference (which would happen on
|
|
32
|
+
// every render in idiomatic React). Only the route should drive the effect.
|
|
33
|
+
const objectsRef = useRef(objects);
|
|
34
|
+
objectsRef.current = objects;
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (disabled || !appName)
|
|
37
|
+
return;
|
|
38
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
39
|
+
// Expect: [basePathSegment, appName, ...rest]
|
|
40
|
+
if (parts[0] !== basePathSegment || parts[1] !== appName)
|
|
41
|
+
return;
|
|
42
|
+
const seg2 = parts[2];
|
|
43
|
+
const seg3 = parts[3];
|
|
44
|
+
const basePath = `/${basePathSegment}/${appName}`;
|
|
45
|
+
if (seg2 && !ROUTE_PREFIXES.has(seg2)) {
|
|
46
|
+
const obj = objectsRef.current.find(o => o.name === seg2);
|
|
47
|
+
if (obj) {
|
|
48
|
+
addRecentItem({
|
|
49
|
+
id: `object:${obj.name}`,
|
|
50
|
+
label: obj.label || obj.name,
|
|
51
|
+
href: `${basePath}/${obj.name}`,
|
|
52
|
+
type: 'object',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!seg3)
|
|
58
|
+
return;
|
|
59
|
+
switch (seg2) {
|
|
60
|
+
case 'dashboard':
|
|
61
|
+
addRecentItem({
|
|
62
|
+
id: `dashboard:${seg3}`,
|
|
63
|
+
label: titleize(seg3),
|
|
64
|
+
href: `${basePath}/dashboard/${seg3}`,
|
|
65
|
+
type: 'dashboard',
|
|
66
|
+
});
|
|
67
|
+
break;
|
|
68
|
+
case 'page':
|
|
69
|
+
addRecentItem({
|
|
70
|
+
id: `page:${seg3}`,
|
|
71
|
+
label: titleize(seg3),
|
|
72
|
+
href: `${basePath}/page/${seg3}`,
|
|
73
|
+
type: 'page',
|
|
74
|
+
});
|
|
75
|
+
break;
|
|
76
|
+
case 'report':
|
|
77
|
+
addRecentItem({
|
|
78
|
+
id: `report:${seg3}`,
|
|
79
|
+
label: titleize(seg3),
|
|
80
|
+
href: `${basePath}/report/${seg3}`,
|
|
81
|
+
type: 'report',
|
|
82
|
+
});
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
// Intentionally drives off route changes only. `addRecentItem` is stable
|
|
88
|
+
// per provider, and `objects` is read through a ref to avoid loops.
|
|
89
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
+
}, [pathname, appName, basePathSegment, disabled]);
|
|
91
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -26,7 +26,8 @@ export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView,
|
|
|
26
26
|
export type { RecordFormPageProps } from './views';
|
|
27
27
|
export { useFavorites, useMetadataService, useNavPins, useNavigationSync, NavigationSyncEffect, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, generateNavId, useResponsiveSidebar, } from './hooks';
|
|
28
28
|
export type { FavoriteItem } from './hooks';
|
|
29
|
-
export { NavigationProvider, useNavigationContext, FavoritesProvider } from './context';
|
|
29
|
+
export { NavigationProvider, useNavigationContext, FavoritesProvider, RecentItemsProvider, UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './context';
|
|
30
|
+
export type { UserDataAdapter, UserStateKind } from './context';
|
|
30
31
|
export { AppContent as DefaultAppContent } from './console/AppContent';
|
|
31
32
|
export { LoginPage as DefaultLoginPage } from './console/auth/LoginPage';
|
|
32
33
|
export { RegisterPage as DefaultRegisterPage } from './console/auth/RegisterPage';
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView,
|
|
|
29
29
|
// Hooks
|
|
30
30
|
export { useFavorites, useMetadataService, useNavPins, useNavigationSync, NavigationSyncEffect, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, generateNavId, useResponsiveSidebar, } from './hooks';
|
|
31
31
|
// Context providers
|
|
32
|
-
export { NavigationProvider, useNavigationContext, FavoritesProvider } from './context';
|
|
32
|
+
export { NavigationProvider, useNavigationContext, FavoritesProvider, RecentItemsProvider, UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './context';
|
|
33
33
|
// Default page implementations (consumers can partial-override slots)
|
|
34
34
|
export { AppContent as DefaultAppContent } from './console/AppContent';
|
|
35
35
|
export { LoginPage as DefaultLoginPage } from './console/auth/LoginPage';
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* individual screens don't reinvent the title row.
|
|
14
14
|
*
|
|
15
15
|
* Layout:
|
|
16
|
+
* [accent hairline]
|
|
16
17
|
* [icon] Title [actions...]
|
|
17
18
|
* description
|
|
18
19
|
*/
|
|
@@ -22,10 +23,32 @@ export interface PageHeaderProps {
|
|
|
22
23
|
title: React.ReactNode;
|
|
23
24
|
/** Optional secondary description shown beneath the title. */
|
|
24
25
|
description?: React.ReactNode;
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Optional leading icon (already-instantiated React element).
|
|
28
|
+
* The icon is auto-sized via CSS — pass it with whatever default sizing,
|
|
29
|
+
* the chip will scale child `<svg>` elements to fit.
|
|
30
|
+
*/
|
|
26
31
|
icon?: React.ReactNode;
|
|
27
32
|
/** Right-aligned actions (buttons, dropdowns, etc.). */
|
|
28
33
|
actions?: React.ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* CSS color used to tint the top hairline, icon chip background, and
|
|
36
|
+
* icon stroke. Defaults to the Shadcn `--primary` token so the header
|
|
37
|
+
* automatically follows the active app's branding.
|
|
38
|
+
*
|
|
39
|
+
* Accepts any CSS color value (e.g. `'#8b5cf6'`, `'hsl(280 70% 55%)'`,
|
|
40
|
+
* `'var(--my-token)'`).
|
|
41
|
+
*/
|
|
42
|
+
accentColor?: string;
|
|
43
|
+
/**
|
|
44
|
+
* When true, the header is `position: sticky; top: 0` and gains a
|
|
45
|
+
* translucent backdrop blur so content scrolling underneath remains
|
|
46
|
+
* partly visible. Opt-in — the default layout used by ObjectView already
|
|
47
|
+
* pins the header by virtue of being the first child of a non-scrolling
|
|
48
|
+
* flex column, so flipping this on there would be a no-op (or worse,
|
|
49
|
+
* conflict with z-index of overlays).
|
|
50
|
+
*/
|
|
51
|
+
sticky?: boolean;
|
|
29
52
|
/** Additional className applied to the outer container. */
|
|
30
53
|
className?: string;
|
|
31
54
|
/** Optional data-testid for E2E selectors. */
|
|
@@ -37,6 +60,10 @@ export interface PageHeaderProps {
|
|
|
37
60
|
* Mirrors Shadcn / Linear / Notion conventions: subtle border, comfortable
|
|
38
61
|
* vertical padding, primary-tinted icon chip, monolithic action cluster on
|
|
39
62
|
* the right with consistent gap.
|
|
63
|
+
*
|
|
64
|
+
* The accent color cascades through a CSS custom property
|
|
65
|
+
* (`--page-header-accent`) so consumers can override it without rebuilding
|
|
66
|
+
* Tailwind classes.
|
|
40
67
|
*/
|
|
41
|
-
export declare function PageHeader({ title, description, icon, actions, className, 'data-testid': testId, }: PageHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
68
|
+
export declare function PageHeader({ title, description, icon, actions, accentColor, sticky, className, 'data-testid': testId, }: PageHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
42
69
|
export default PageHeader;
|
|
@@ -6,8 +6,26 @@ import { cn } from '@object-ui/components';
|
|
|
6
6
|
* Mirrors Shadcn / Linear / Notion conventions: subtle border, comfortable
|
|
7
7
|
* vertical padding, primary-tinted icon chip, monolithic action cluster on
|
|
8
8
|
* the right with consistent gap.
|
|
9
|
+
*
|
|
10
|
+
* The accent color cascades through a CSS custom property
|
|
11
|
+
* (`--page-header-accent`) so consumers can override it without rebuilding
|
|
12
|
+
* Tailwind classes.
|
|
9
13
|
*/
|
|
10
|
-
export function PageHeader({ title, description, icon, actions, className, 'data-testid': testId, }) {
|
|
11
|
-
|
|
14
|
+
export function PageHeader({ title, description, icon, actions, accentColor, sticky = false, className, 'data-testid': testId, }) {
|
|
15
|
+
// Resolve accent → CSS var. Falls back to the Shadcn primary token so the
|
|
16
|
+
// header follows whatever brand the active app has injected.
|
|
17
|
+
const accent = accentColor || 'hsl(var(--primary))';
|
|
18
|
+
const style = { '--page-header-accent': accent };
|
|
19
|
+
return (_jsxs("div", { style: style, className: cn('relative shrink-0 border-b bg-background z-10', sticky &&
|
|
20
|
+
'sticky top-0 bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/75', className), "data-testid": testId ?? 'page-header', children: [_jsx("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-0 top-0 h-[2px] opacity-80", style: {
|
|
21
|
+
background: 'linear-gradient(90deg, transparent, var(--page-header-accent), transparent)',
|
|
22
|
+
} }), _jsxs("div", { className: "flex justify-between items-center gap-3 py-2.5 sm:py-3 px-4 sm:px-6", children: [_jsxs("div", { className: "flex items-center gap-3 min-w-0 flex-1", children: [icon && (_jsx("div", { className: cn('shrink-0 inline-flex items-center justify-center rounded-lg', 'h-9 w-9 sm:h-10 sm:w-10',
|
|
23
|
+
// Auto-size any nested SVG so callers don't have to think
|
|
24
|
+
// about sizing inside the chip.
|
|
25
|
+
'[&_svg]:h-[18px] [&_svg]:w-[18px] sm:[&_svg]:h-5 sm:[&_svg]:w-5'), style: {
|
|
26
|
+
color: 'var(--page-header-accent)',
|
|
27
|
+
background: 'linear-gradient(135deg, color-mix(in srgb, var(--page-header-accent) 18%, transparent), color-mix(in srgb, var(--page-header-accent) 6%, transparent))',
|
|
28
|
+
boxShadow: 'inset 0 0 0 1px color-mix(in srgb, var(--page-header-accent) 22%, transparent)',
|
|
29
|
+
}, children: icon })), _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "text-base sm:text-lg lg:text-xl font-semibold tracking-tight text-foreground truncate leading-tight", children: title }), description && (_jsx("p", { className: "text-xs sm:text-sm text-muted-foreground truncate hidden sm:block max-w-xl mt-0.5", children: description }))] })] }), actions && (_jsx("div", { className: "flex items-center gap-1.5 sm:gap-2 shrink-0", children: actions }))] })] }));
|
|
12
30
|
}
|
|
13
31
|
export default PageHeader;
|
|
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
* Returns collected param values or null on cancel.
|
|
12
12
|
*/
|
|
13
13
|
import { useState, useEffect } from 'react';
|
|
14
|
-
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Button, Input, Label, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
|
|
14
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Button, Input, Label, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Checkbox, } from '@object-ui/components';
|
|
15
15
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
16
16
|
export function ActionParamDialog({ state, onOpenChange }) {
|
|
17
17
|
const { t } = useObjectTranslation();
|
|
@@ -37,6 +37,9 @@ export function ActionParamDialog({ state, onOpenChange }) {
|
|
|
37
37
|
return value.trim() === '';
|
|
38
38
|
if (Array.isArray(value))
|
|
39
39
|
return value.length === 0;
|
|
40
|
+
// Boolean false is a VALID value — only treat undefined/null as missing.
|
|
41
|
+
if (typeof value === 'boolean')
|
|
42
|
+
return false;
|
|
40
43
|
return false;
|
|
41
44
|
};
|
|
42
45
|
const handleSubmit = () => {
|
|
@@ -65,5 +68,16 @@ export function ActionParamDialog({ state, onOpenChange }) {
|
|
|
65
68
|
return (_jsx(Dialog, { open: state.open, onOpenChange: (open) => {
|
|
66
69
|
if (!open)
|
|
67
70
|
handleCancel();
|
|
68
|
-
}, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('actionDialog.title') }), _jsx(DialogDescription, { children: t('actionDialog.description') })] }), _jsx("div", { className: "grid gap-4 py-4", children: state.params.map((param) =>
|
|
71
|
+
}, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('actionDialog.title') }), _jsx(DialogDescription, { children: t('actionDialog.description') })] }), _jsx("div", { className: "grid gap-4 py-4", children: state.params.map((param) => {
|
|
72
|
+
const isBooleanParam = param.type === 'boolean' || param.type === 'checkbox';
|
|
73
|
+
// Boolean → render as inline checkbox row (label sits beside the
|
|
74
|
+
// control instead of above it; help text appears underneath).
|
|
75
|
+
if (isBooleanParam) {
|
|
76
|
+
const checked = values[param.name] === true;
|
|
77
|
+
return (_jsxs("div", { className: "grid gap-1", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(Checkbox, { id: param.name, checked: checked, onCheckedChange: (c) => updateValue(param.name, c === true), className: "mt-0.5" }), _jsxs(Label, { htmlFor: param.name, className: "font-normal cursor-pointer", children: [param.label, param.required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] })] }), errors[param.name] && (_jsx("p", { className: "text-xs text-destructive ml-6", children: t('actionDialog.requiredError', { label: param.label }) })), param.helpText && (_jsx("p", { className: "text-xs text-muted-foreground ml-6", children: param.helpText }))] }, param.name));
|
|
78
|
+
}
|
|
79
|
+
const isLookupParam = param.type === 'lookup' || param.type === 'reference';
|
|
80
|
+
return (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: param.name, children: [param.label, param.required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] }), param.type === 'select' && param.options ? (_jsxs(Select, { value: values[param.name] ?? '', onValueChange: (val) => updateValue(param.name, val), children: [_jsx(SelectTrigger, { id: param.name, className: errors[param.name] ? 'border-destructive' : '', children: _jsx(SelectValue, { placeholder: param.placeholder || t('actionDialog.selectPlaceholder', { label: param.label }) }) }), _jsx(SelectContent, { children: param.options.map((opt) => (_jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value))) })] })) : param.type === 'textarea' ? (_jsx(Textarea, { id: param.name, value: values[param.name] ?? '', onChange: (e) => updateValue(param.name, e.target.value), placeholder: param.placeholder, className: errors[param.name] ? 'border-destructive' : '' })) : param.type === 'number' ? (_jsx(Input, { id: param.name, type: "number", value: values[param.name] ?? '', onChange: (e) => updateValue(param.name, e.target.value === '' ? undefined : e.target.valueAsNumber), placeholder: param.placeholder, className: errors[param.name] ? 'border-destructive' : '' })) : (_jsx(Input, { id: param.name, type: ['email', 'url', 'date', 'datetime-local', 'time', 'password'].includes(param.type) ? param.type : 'text', value: values[param.name] ?? '', onChange: (e) => updateValue(param.name, e.target.value), placeholder: param.placeholder ||
|
|
81
|
+
(isLookupParam ? t('actionDialog.lookupPlaceholder', { label: param.label }) : undefined), className: errors[param.name] ? 'border-destructive' : '' })), errors[param.name] && (_jsx("p", { className: "text-xs text-destructive", children: t('actionDialog.requiredError', { label: param.label }) })), param.helpText && (_jsx("p", { className: "text-xs text-muted-foreground", children: param.helpText })), isLookupParam && !param.helpText && (_jsx("p", { className: "text-xs text-muted-foreground", children: t('actionDialog.lookupHelpText') }))] }, param.name));
|
|
82
|
+
}) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: handleCancel, children: t('actionDialog.cancel') }), _jsx(Button, { onClick: handleSubmit, children: t('actionDialog.confirm') })] })] }) }));
|
|
69
83
|
}
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -14,7 +14,6 @@ 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 })));
|
|
16
16
|
import { ListView } from '@object-ui/plugin-list';
|
|
17
|
-
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
|
|
18
17
|
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
|
|
19
18
|
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
|
|
20
19
|
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
|
|
@@ -27,6 +26,7 @@ import { ViewConfigPanel } from './ViewConfigPanel';
|
|
|
27
26
|
import { CreateViewDialog } from './CreateViewDialog';
|
|
28
27
|
import { PageHeader } from '../layout/PageHeader';
|
|
29
28
|
import { ManagedByBadge } from '../components/ManagedByBadge';
|
|
29
|
+
import { RecordDetailView } from './RecordDetailView';
|
|
30
30
|
import { resolveCrudAffordances } from '../utils/crudAffordances';
|
|
31
31
|
import { resolveManagedByEmptyState } from '../utils/managedByEmptyState';
|
|
32
32
|
import { useObjectActions } from '../hooks/useObjectActions';
|
|
@@ -191,157 +191,6 @@ function fromSysViewRecord(sv) {
|
|
|
191
191
|
pageSize: sv.page_size ?? sv.pageSize,
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
|
-
/**
|
|
195
|
-
* DrawerDetailContent — extracted component for NavigationOverlay content.
|
|
196
|
-
* Needs to be a proper component (not a render prop) so it can use hooks
|
|
197
|
-
* for data fetching, comment handling, etc.
|
|
198
|
-
*/
|
|
199
|
-
function DrawerDetailContent({ objectDef, recordId, dataSource, onEdit }) {
|
|
200
|
-
const { user } = useAuth();
|
|
201
|
-
const currentUser = user
|
|
202
|
-
? { id: user.id, name: user.name, avatar: user.image }
|
|
203
|
-
: FALLBACK_USER;
|
|
204
|
-
const [feedItems, setFeedItems] = useState([]);
|
|
205
|
-
// Fetch persisted comments from API
|
|
206
|
-
useEffect(() => {
|
|
207
|
-
if (!dataSource || !objectDef?.name || !recordId)
|
|
208
|
-
return;
|
|
209
|
-
const threadId = `${objectDef.name}:${recordId}`;
|
|
210
|
-
dataSource.find('sys_comment', { $filter: { threadId }, $orderby: { createdAt: 'asc' } })
|
|
211
|
-
.then((res) => {
|
|
212
|
-
if (res.data?.length) {
|
|
213
|
-
setFeedItems(res.data.map((c) => ({
|
|
214
|
-
id: c.id,
|
|
215
|
-
type: 'comment',
|
|
216
|
-
actor: c.author?.name ?? 'Unknown',
|
|
217
|
-
actorAvatarUrl: c.author?.avatar,
|
|
218
|
-
body: c.content,
|
|
219
|
-
createdAt: c.createdAt,
|
|
220
|
-
updatedAt: c.updatedAt,
|
|
221
|
-
parentId: c.parentId,
|
|
222
|
-
reactions: c.reactions
|
|
223
|
-
? Object.entries(c.reactions).map(([emoji, userIds]) => ({
|
|
224
|
-
emoji,
|
|
225
|
-
count: userIds.length,
|
|
226
|
-
reacted: userIds.includes(currentUser.id),
|
|
227
|
-
}))
|
|
228
|
-
: undefined,
|
|
229
|
-
})));
|
|
230
|
-
}
|
|
231
|
-
})
|
|
232
|
-
.catch(() => { });
|
|
233
|
-
}, [dataSource, objectDef?.name, recordId, currentUser.id]);
|
|
234
|
-
const handleAddComment = useCallback(async (text) => {
|
|
235
|
-
const newItem = {
|
|
236
|
-
id: crypto.randomUUID(),
|
|
237
|
-
type: 'comment',
|
|
238
|
-
actor: currentUser.name,
|
|
239
|
-
actorAvatarUrl: 'avatar' in currentUser ? currentUser.avatar : undefined,
|
|
240
|
-
body: text,
|
|
241
|
-
createdAt: new Date().toISOString(),
|
|
242
|
-
};
|
|
243
|
-
setFeedItems(prev => [...prev, newItem]);
|
|
244
|
-
if (dataSource) {
|
|
245
|
-
const threadId = `${objectDef.name}:${recordId}`;
|
|
246
|
-
dataSource.create('sys_comment', {
|
|
247
|
-
id: newItem.id,
|
|
248
|
-
threadId,
|
|
249
|
-
author: currentUser,
|
|
250
|
-
content: text,
|
|
251
|
-
mentions: [],
|
|
252
|
-
createdAt: newItem.createdAt,
|
|
253
|
-
}).catch(() => { });
|
|
254
|
-
}
|
|
255
|
-
}, [currentUser, dataSource, objectDef?.name, recordId]);
|
|
256
|
-
const handleAddReply = useCallback(async (parentId, text) => {
|
|
257
|
-
const newItem = {
|
|
258
|
-
id: crypto.randomUUID(),
|
|
259
|
-
type: 'comment',
|
|
260
|
-
actor: currentUser.name,
|
|
261
|
-
actorAvatarUrl: 'avatar' in currentUser ? currentUser.avatar : undefined,
|
|
262
|
-
body: text,
|
|
263
|
-
createdAt: new Date().toISOString(),
|
|
264
|
-
parentId,
|
|
265
|
-
};
|
|
266
|
-
setFeedItems(prev => {
|
|
267
|
-
const updated = [...prev, newItem];
|
|
268
|
-
return updated.map(item => item.id === parentId
|
|
269
|
-
? { ...item, replyCount: (item.replyCount ?? 0) + 1 }
|
|
270
|
-
: item);
|
|
271
|
-
});
|
|
272
|
-
if (dataSource) {
|
|
273
|
-
const threadId = `${objectDef.name}:${recordId}`;
|
|
274
|
-
dataSource.create('sys_comment', {
|
|
275
|
-
id: newItem.id,
|
|
276
|
-
threadId,
|
|
277
|
-
author: currentUser,
|
|
278
|
-
content: text,
|
|
279
|
-
mentions: [],
|
|
280
|
-
createdAt: newItem.createdAt,
|
|
281
|
-
parentId,
|
|
282
|
-
}).catch(() => { });
|
|
283
|
-
}
|
|
284
|
-
}, [currentUser, dataSource, objectDef?.name, recordId]);
|
|
285
|
-
const handleToggleReaction = useCallback((itemId, emoji) => {
|
|
286
|
-
setFeedItems(prev => prev.map(item => {
|
|
287
|
-
if (item.id !== itemId)
|
|
288
|
-
return item;
|
|
289
|
-
const reactions = [...(item.reactions ?? [])];
|
|
290
|
-
const idx = reactions.findIndex(r => r.emoji === emoji);
|
|
291
|
-
if (idx >= 0) {
|
|
292
|
-
const r = reactions[idx];
|
|
293
|
-
if (r.reacted) {
|
|
294
|
-
if (r.count <= 1) {
|
|
295
|
-
reactions.splice(idx, 1);
|
|
296
|
-
}
|
|
297
|
-
else {
|
|
298
|
-
reactions[idx] = { ...r, count: r.count - 1, reacted: false };
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
reactions[idx] = { ...r, count: r.count + 1, reacted: true };
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
reactions.push({ emoji, count: 1, reacted: true });
|
|
307
|
-
}
|
|
308
|
-
const updated = { ...item, reactions };
|
|
309
|
-
if (dataSource) {
|
|
310
|
-
dataSource.update('sys_comment', String(itemId), {
|
|
311
|
-
$toggleReaction: { emoji, userId: currentUser.id },
|
|
312
|
-
}).catch(() => { });
|
|
313
|
-
}
|
|
314
|
-
return updated;
|
|
315
|
-
}));
|
|
316
|
-
}, [currentUser.id, dataSource]);
|
|
317
|
-
return (_jsxs("div", { className: "h-full bg-background overflow-auto p-3 sm:p-4 lg:p-6", children: [_jsx(DetailView, { schema: {
|
|
318
|
-
type: 'detail-view',
|
|
319
|
-
objectName: objectDef.name,
|
|
320
|
-
resourceId: recordId,
|
|
321
|
-
showBack: false,
|
|
322
|
-
showEdit: true,
|
|
323
|
-
title: objectDef.label,
|
|
324
|
-
sections: [
|
|
325
|
-
{
|
|
326
|
-
title: 'Details',
|
|
327
|
-
fields: Object.keys(objectDef.fields || {}).map((key) => ({
|
|
328
|
-
name: key,
|
|
329
|
-
label: objectDef.fields[key].label || key,
|
|
330
|
-
type: objectDef.fields[key].type || 'text'
|
|
331
|
-
})),
|
|
332
|
-
}
|
|
333
|
-
]
|
|
334
|
-
}, dataSource: dataSource, onEdit: () => onEdit({ id: recordId }) }), _jsx("div", { className: "mt-6 border-t pt-6", children: _jsx(RecordChatterPanel, { config: {
|
|
335
|
-
position: 'bottom',
|
|
336
|
-
collapsible: true,
|
|
337
|
-
defaultCollapsed: true,
|
|
338
|
-
feed: {
|
|
339
|
-
enableReactions: true,
|
|
340
|
-
enableThreading: true,
|
|
341
|
-
showCommentInput: true,
|
|
342
|
-
},
|
|
343
|
-
}, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) })] }));
|
|
344
|
-
}
|
|
345
194
|
export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }) {
|
|
346
195
|
const navigate = useNavigate();
|
|
347
196
|
const { objectName, viewId } = useParams();
|
|
@@ -1326,9 +1175,16 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1326
1175
|
}
|
|
1327
1176
|
}, [dataSource, objectDef.name, refreshKey]);
|
|
1328
1177
|
// Navigation overlay for record detail (supports drawer/modal/split/popover via config)
|
|
1329
|
-
// Priority: activeView.navigation > objectDef.navigation > default
|
|
1330
|
-
//
|
|
1331
|
-
|
|
1178
|
+
// Priority: activeView.navigation > objectDef.navigation > default drawer.
|
|
1179
|
+
//
|
|
1180
|
+
// Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where
|
|
1181
|
+
// record peek is the primary interaction and full-page is the upgrade.
|
|
1182
|
+
// Direct URL access (`/record/:id`) still opens as a full page because
|
|
1183
|
+
// RecordDetailView owns its own route — only same-page click navigation
|
|
1184
|
+
// is drawer-by-default. Per-view config can still override (e.g. a heavy
|
|
1185
|
+
// detail object can set `navigation.mode = 'page'`).
|
|
1186
|
+
const detailNavigation = useMemo(() => activeView?.navigation ??
|
|
1187
|
+
objectDef.navigation ?? { mode: 'drawer', width: 'min(92vw, 1280px)' }, [activeView?.navigation, objectDef.navigation]);
|
|
1332
1188
|
const drawerRecordId = searchParams.get('recordId');
|
|
1333
1189
|
/**
|
|
1334
1190
|
* URL-derived equality filters in the form `?filter[<field>]=<value>`.
|
|
@@ -1379,6 +1235,49 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1379
1235
|
newParams.delete('recordId');
|
|
1380
1236
|
setSearchParams(newParams);
|
|
1381
1237
|
};
|
|
1238
|
+
/**
|
|
1239
|
+
* Row-click handler used by all list/grid/gallery/kanban surfaces inside
|
|
1240
|
+
* this object view. Wraps `navOverlay.handleClick` so that drawer-mode
|
|
1241
|
+
* opens are URL-driven (writes `?recordId=…`) — making the drawer state
|
|
1242
|
+
* shareable, refresh-safe, and respected by browser back/forward. The
|
|
1243
|
+
* URL→state sync effect below handles actually opening the drawer.
|
|
1244
|
+
*/
|
|
1245
|
+
const handleRowClick = useCallback((record, event) => {
|
|
1246
|
+
// Cmd/Ctrl/middle-click — let the hook open in a new tab (full page)
|
|
1247
|
+
// regardless of configured mode. Matches browser link convention.
|
|
1248
|
+
const isModifier = !!(event && (event.metaKey || event.ctrlKey || event.button === 1));
|
|
1249
|
+
if (isModifier) {
|
|
1250
|
+
navOverlay.handleClick(record, event);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
// Drawer mode → URL is the source of truth. Push `?recordId=…`
|
|
1254
|
+
// and let the existing URL-sync effect open the overlay.
|
|
1255
|
+
if (navOverlay.mode === 'drawer') {
|
|
1256
|
+
const id = (record?.id ?? record?._id);
|
|
1257
|
+
if (id != null) {
|
|
1258
|
+
const next = new URLSearchParams(searchParams);
|
|
1259
|
+
next.set('recordId', String(id));
|
|
1260
|
+
setSearchParams(next);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
// All other modes (page / modal / split / popover / new_window / none)
|
|
1265
|
+
// — delegate to the hook.
|
|
1266
|
+
navOverlay.handleClick(record, event);
|
|
1267
|
+
}, [navOverlay, searchParams, setSearchParams]);
|
|
1268
|
+
/**
|
|
1269
|
+
* "Expand to full page" — invoked from the drawer header chevron. Closes
|
|
1270
|
+
* the drawer (which clears `?recordId=…`) and router-pushes to the
|
|
1271
|
+
* dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page.
|
|
1272
|
+
*/
|
|
1273
|
+
const handleExpandDrawer = useCallback(() => {
|
|
1274
|
+
const rec = navOverlay.selectedRecord;
|
|
1275
|
+
const id = rec && (rec.id ?? rec._id);
|
|
1276
|
+
if (id == null)
|
|
1277
|
+
return;
|
|
1278
|
+
handleDrawerClose();
|
|
1279
|
+
handleNavOverlayNavigate(id);
|
|
1280
|
+
}, [navOverlay.selectedRecord, handleNavOverlayNavigate]);
|
|
1382
1281
|
// Sync URL-based recordId to overlay state
|
|
1383
1282
|
useEffect(() => {
|
|
1384
1283
|
if (drawerRecordId && !navOverlay.isOpen) {
|
|
@@ -1606,8 +1505,8 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1606
1505
|
}),
|
|
1607
1506
|
params: { records: valid },
|
|
1608
1507
|
});
|
|
1609
|
-
}, onRowClick: (record) => {
|
|
1610
|
-
|
|
1508
|
+
}, onRowClick: (record, event) => {
|
|
1509
|
+
handleRowClick(record, event);
|
|
1611
1510
|
}, onSortChange: (sort) => {
|
|
1612
1511
|
persistViewPatch(viewDef.id, viewDef, { sort });
|
|
1613
1512
|
}, onFilterChange: (filter) => {
|
|
@@ -1727,20 +1626,20 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
|
|
|
1727
1626
|
showVisibilityGroups: true,
|
|
1728
1627
|
}, 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 }))] }));
|
|
1729
1628
|
})(), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
|
|
1730
|
-
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) => {
|
|
1731
|
-
|
|
1629
|
+
handleDrawerClose(); }, title: objectLabel(objectDef), onExpand: handleExpandDrawer, expandLabel: t('console.objectView.expandToPage', { defaultValue: 'Open as full page' }), storageKey: `drawer-width:${objectDef.name}`, 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, event) => {
|
|
1630
|
+
handleRowClick(record, event);
|
|
1732
1631
|
}, 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) => {
|
|
1733
1632
|
const recordId = (record.id || record._id);
|
|
1734
|
-
return (_jsx(
|
|
1735
|
-
} })) : (_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) => {
|
|
1736
|
-
|
|
1633
|
+
return (_jsx(RecordDetailView, { dataSource: dataSource, objects: objects, onEdit: onEdit, objectNameOverride: objectDef.name, recordIdOverride: recordId, embedded: true }));
|
|
1634
|
+
} })) : (_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, event) => {
|
|
1635
|
+
handleRowClick(record, event);
|
|
1737
1636
|
}, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
|
|
1738
1637
|
{ title: 'View Configuration', data: activeView },
|
|
1739
1638
|
{ title: 'Object Definition', data: objectDef },
|
|
1740
1639
|
] }), _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)
|
|
1741
|
-
handleDrawerClose(); }, title: objectDef.label,
|
|
1640
|
+
handleDrawerClose(); }, title: objectDef.label, onExpand: handleExpandDrawer, expandLabel: t('console.objectView.expandToPage', { defaultValue: 'Open as full page' }), storageKey: `drawer-width:${objectDef.name}`, children: (record) => {
|
|
1742
1641
|
const recordId = (record.id || record._id);
|
|
1743
|
-
return (_jsx(
|
|
1642
|
+
return (_jsx(RecordDetailView, { dataSource: dataSource, objects: objects, onEdit: onEdit, objectNameOverride: objectDef.name, recordIdOverride: recordId, embedded: true }));
|
|
1744
1643
|
} }))] }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
|
|
1745
1644
|
if (!open)
|
|
1746
1645
|
setConfirmState({ open: false, message: '' });
|
|
@@ -9,6 +9,25 @@ interface RecordDetailViewProps {
|
|
|
9
9
|
dataSource: any;
|
|
10
10
|
objects: any[];
|
|
11
11
|
onEdit: (record: any) => void;
|
|
12
|
+
/**
|
|
13
|
+
* When provided, this object name overrides the value derived from the
|
|
14
|
+
* current URL (`useParams()`). Used when rendering the detail inside a
|
|
15
|
+
* navigation drawer or split-pane — the parent route owns the URL while
|
|
16
|
+
* the embedded detail is keyed by an in-memory record selection.
|
|
17
|
+
*/
|
|
18
|
+
objectNameOverride?: string;
|
|
19
|
+
/**
|
|
20
|
+
* When provided, this record id overrides the value derived from the
|
|
21
|
+
* current URL. See {@link objectNameOverride}.
|
|
22
|
+
*/
|
|
23
|
+
recordIdOverride?: string;
|
|
24
|
+
/**
|
|
25
|
+
* `true` when this view is embedded (drawer / split-pane). Suppresses
|
|
26
|
+
* side effects that would conflict with the host route — namely the
|
|
27
|
+
* breadcrumb title publish (the parent route already owns the
|
|
28
|
+
* breadcrumb).
|
|
29
|
+
*/
|
|
30
|
+
embedded?: boolean;
|
|
12
31
|
}
|
|
13
|
-
export declare function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
32
|
+
export declare function RecordDetailView({ dataSource, objects, onEdit, objectNameOverride, recordIdOverride, embedded }: RecordDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
14
33
|
export {};
|