@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.
@@ -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
- import { useState, useCallback, useEffect } from 'react';
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
- /** Optional leading icon (already-instantiated React element). */
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
- return (_jsxs("div", { className: cn('flex justify-between items-center gap-3 py-1.5 sm:py-2 px-3 sm:px-4 border-b shrink-0 bg-background z-10', className), "data-testid": testId ?? 'page-header', children: [_jsxs("div", { className: "flex items-center gap-2 sm:gap-2.5 min-w-0 flex-1", children: [icon && (_jsx("div", { className: "bg-primary/10 p-1 sm:p-1.5 rounded-md shrink-0 text-primary", children: icon })), _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "text-sm sm:text-base font-semibold tracking-tight text-foreground truncate", children: title }), description && (_jsx("p", { className: "text-xs text-muted-foreground truncate hidden sm:block max-w-md", children: description }))] })] }), actions && (_jsx("div", { className: "flex items-center gap-1.5 sm:gap-2 shrink-0", children: actions }))] }));
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) => (_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, 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 }))] }, param.name))) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: handleCancel, children: t('actionDialog.cancel') }), _jsx(Button, { onClick: handleSubmit, children: t('actionDialog.confirm') })] })] }) }));
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
  }
@@ -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 page
1330
- // Memoize to avoid unstable object identity on every render (stale closure prevention)
1331
- const detailNavigation = useMemo(() => activeView?.navigation ?? objectDef.navigation ?? { mode: 'page' }, [activeView?.navigation, objectDef.navigation]);
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
- navOverlay.handleClick(record);
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
- navOverlay.handleClick(record);
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(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
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
- navOverlay.handleClick(record);
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, className: navOverlay.mode === 'drawer' ? 'w-[90vw] sm:max-w-2xl p-0 overflow-hidden' : undefined, children: (record) => {
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(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
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 {};