diff --git a/.changeset/25992-objectview-identity-navigation-split.md b/.changeset/25992-objectview-identity-navigation-split.md new file mode 100644 index 00000000000..8171d45f97d --- /dev/null +++ b/.changeset/25992-objectview-identity-navigation-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s identity import and record navigation move into sibling hooks + +Clause-②: no +Metadata-protocol: none + +Two concerns move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx`. The identity-import gate, its password-policy state and data source, and the managed-environment Users marker move into `useObjectViewIdentity` in `objectViewIdentity.tsx`. The record navigation overlay (how a row opens its record, the drawer's `?recordId=` URL state, "expand to full page" and the list's record links) moves into `useObjectViewRecordNavigation` in `objectViewRecordNavigation.ts`. The component passes every input in and reads the returned values. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 6676b3568e0..e009c29e060 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -32,9 +32,8 @@ const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })), ); import { ListView } from '@object-ui/plugin-list'; -import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view'; +import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view'; import type { ViewTabItem } from '@object-ui/plugin-view'; -import { RECORD_DRAWER_PARAM } from '../urlParams.js'; // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here. @@ -56,7 +55,7 @@ import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useServedViewItems } from '../hooks/useServedViewItems.js'; import { viewTabLabel } from './viewTabLabel.js'; import { getIcon } from '../utils/getIcon.js'; -import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig, ViewNavigationConfig } from '@object-ui/types'; +import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; @@ -75,11 +74,10 @@ import { ManagedByBadge } from '../components/ManagedByBadge.js'; import { isSetupAppSegment } from '../console/setupApp.js'; import { RecordDetailView } from './RecordDetailView.js'; import type { RowCrudPredicates } from '../utils/crudAffordances.js'; -import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, IDENTITY_IMPORT_WRITE_MODES, type IdentityPasswordPolicy } from './identityImport.js'; +import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js'; -import { ManagedUsersInviteLine, MANAGED_USERS_OBJECT, UnauthorizedUserMarker, UserGrantLoader, isManagedEnvironment } from './managedEnvironmentUsers.js'; +import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; import { importTargetFields } from './importTargetFields.js'; -import { useExpressionContext } from '../providers/ExpressionProvider.js'; import { listToolbarDrawsAction, resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; import { mayReadObject } from '../utils/navObjectReadGate.js'; import { resolveViewId } from '../utils/resolveViewId.js'; @@ -89,7 +87,7 @@ import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope, useMetadata } from '@object-ui/react'; +import { ActionProvider, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; import { useOfferedNavRunAction } from '../hooks/useNavRunAction.js'; @@ -104,7 +102,6 @@ import { selectFilterParams, MOBILE_FAB_LIST_CLEARANCE, } from './objectViewHelpers.js'; -import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js'; import { defaultListColumnsFromObject, bulkWiredActionNamesKey, @@ -126,6 +123,8 @@ import { } from './objectViewOptions.js'; import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js'; import { useObjectViewLiveData } from './objectViewLiveData.js'; +import { useObjectViewIdentity } from './objectViewIdentity.js'; +import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -685,71 +684,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // Import wizard open/close state — toolbar entry triggers it. const [showImport, setShowImport] = useState(false); - // ─── Identity import (framework#2782) ──────────────────────────────── - // sys_user's generic import affordance stays FALSE (a data-layer insert - // would bypass better-auth's hashing and never create a credential), but - // platform admins get the same wizard wired to the dedicated - // /api/v1/auth/admin/import-users pipeline. Gated on the deployment's - // admin surface (features.admin from /api/v1/auth/config) plus the - // caller being a workspace admin (the server re-checks, ADR-0068). - // objectui#13330 — and never on a managed environment - // (`features.cloudManaged`): there the server answers `admin/import-users` - // with 409 `RESOURCE_CONFLICT`, because people arrive from the hosting - // organisation through its identity provider. - const { features } = useExpressionContext(); - const isIdentityImport = objectDef.name === IDENTITY_IMPORT_OBJECT; - const identityImportEnabled = - isIdentityImport && features?.admin === true && isAdmin && !isManagedEnvironment(features); - - // ─── Setup → Users on a managed environment (objectui#13330) ───────── - // The line that says where people are invited, and the marker on a user - // who holds no permission set beyond the baseline. The marker reads the - // grant rows through one loader per list (`managedEnvironmentUsers.tsx`), - // rebuilt when the list refreshes, so a grant made elsewhere shows on the - // next load. The metadata store is read through a ref: its context value is - // rebuilt whenever any cached type settles (AGENTS.md #10). - const managedUsers = objectDef.name === MANAGED_USERS_OBJECT && isManagedEnvironment(features); - const metadataStore = useMetadata(); - const metadataStoreRef = useRef(metadataStore); - metadataStoreRef.current = metadataStore; - const grantLoaderRef = useRef<{ refreshKey: number; source: unknown; loader: UserGrantLoader } | null>(null); - if ( - managedUsers && - dataSource && - (grantLoaderRef.current?.refreshKey !== refreshKey || grantLoaderRef.current?.source !== dataSource) - ) { - grantLoaderRef.current = { - refreshKey, - source: dataSource, - loader: new UserGrantLoader(dataSource as any, { - ensureType: (type: string) => metadataStoreRef.current.ensureType(type), - getTypeStatus: (type: string) => metadataStoreRef.current.getTypeStatus?.(type) ?? 'ready', - }), - }; - } - const grantLoader = managedUsers ? grantLoaderRef.current?.loader : undefined; - const managedUserMarker = useCallback( - (row: Record) => { - const id = row?.id ?? row?._id; - if (!grantLoader || id === undefined || id === null || id === '') return null; - return ; - }, - [grantLoader, t], - ); - const [identityPasswordPolicy, setIdentityPasswordPolicy] = useState('auto'); - const identityPolicyRef = useRef('auto'); - identityPolicyRef.current = identityPasswordPolicy; - const identityDataSource = useMemo( - () => (identityImportEnabled - ? createIdentityImportDataSource({ - base: dataSource, - authFetch: actionRuntime.authFetch, - baseUrl: import.meta.env.VITE_SERVER_URL || '', - getPasswordPolicy: () => identityPolicyRef.current, - }) - : undefined), - [identityImportEnabled, dataSource, actionRuntime.authFetch], - ); + // objectstack#25992 slice 5: identity import and the managed-environment Users + // marker moved to `./objectViewIdentity.tsx`. + const { + identityImportEnabled, + managedUsers, + managedUserMarker, + identityPasswordPolicy, + setIdentityPasswordPolicy, + identityDataSource, + } = useObjectViewIdentity({ objectDef, isAdmin, dataSource, refreshKey, t, actionRuntime }); // ─── User-defined views (metadata overlay) ────────────────────────── // Saved views created via the ViewConfigPanel ("Add View") live in the @@ -1453,41 +1397,24 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // footer record count moved to `./objectViewLiveData.ts`; the count state stays here. useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }); - // Navigation overlay for record detail (supports drawer/modal/split/popover via config) - // Priority: activeView.navigation > objectDef.navigation > default drawer. - // - // Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where - // record peek is the primary interaction and full-page is the upgrade. - // Direct URL access (`/record/:id`) still opens as a full page because - // RecordDetailView owns its own route — only same-page click navigation - // is drawer-by-default. Per-view config can still override (e.g. a heavy - // detail object can set `navigation.mode = 'page'`). - const detailNavigation: ViewNavigationConfig = useMemo( - () => { - const authored = activeView?.navigation ?? objectDef.navigation; - if (authored) { - // Authored config wins. For an overlay, resolve the `size` - // bucket (or 'auto') to a viewport-clamped width when no explicit - // width was given — objectstack-ai/objectstack#2578: a pixel width can't be authored blind. - if (authored.mode === 'page') return authored; - return { - ...authored, - width: authored.width ?? overlayWidthFor( - (authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size, - objectDef, - ), - }; - } - // objectstack-ai/objectstack#2578: derive surface + width from FIELD COUNT. Field-heavy → full - // page (cramped in a drawer); light → a drawer sized to the content - // and clamped to the viewport. Mobile always pages (in deriveRecordSurface). - return deriveRecordSurface(objectDef) === 'page' - ? { mode: 'page' } - : { mode: 'drawer', width: overlayWidthFor('auto', objectDef) }; - }, - [activeView?.navigation, objectDef] - ); - const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM); + // objectstack#25992 slice 6: the record navigation overlay (drawer state in the + // URL, row click, expand to page, record links) moved to `./objectViewRecordNavigation.ts`. + const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({ + activeView, + objectDef, + objects, + appName, + viewId, + searchParams, + setSearchParams, + location, + navigate, + routerRootHref, + servedViews, + viewLabel, + unlabelledViewText, + language, + }); /** * URL-derived equality filters in the form `?filter[]=`. @@ -1560,130 +1487,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co }, [setSearchParams], ); - // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick - const handleNavOverlayNavigate = useCallback( - (recordId: string | number, action?: string) => { - if (action === 'new_window') { - // Open record detail in a new browser tab with Console-correct URL. - // Same builder the list's link column publishes as its href - // (objectui#4490) — one record-route shape for this surface. - window.open(listRecordDetailUrl(window.location.pathname, recordId), '_blank'); - return; - } - // Default: navigate to record detail page. - // `action` is a navigation-MODE token — 'view' / 'page' / undefined - // — and every non-`new_window` value lands on the record detail - // route. - // - // ⛔ It is NOT a view name, and this comment used to say a custom - // one could arrive here "forwarded from `navigation.view` (e.g. - // 'detail_form')", resolved "by RecordDetailView from its own - // config". No layer ever resolved a view by that name — which is - // why `@objectstack/spec` 17.5.0 retired the key under ADR-0049, - // and why `useNavigationOverlay` stopped forwarding it - // (objectui#9874). Nothing here changes behaviour: this branch - // already treated the name and the token identically, which is - // exactly what made the substitution inaudible on this surface. - const originState = { - from: { - pathname: location.pathname + (location.search || ''), - label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), - }, - }; - if (viewId) { - navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState }); - } else { - navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); - } - }, - [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language] - ); - /** - * The list surface's half of the record-link mechanism (objectui#4490): - * publish this page's record-URL builder so the grid's `link: true` column - * can render a real anchor instead of a click-only `span role="link"`. - * See {@link listRecordActionsValue} for what this does and does NOT claim. - * - * The record drawer/overlay below mounts `RecordDetailView`, which brings - * its own `RelatedRecordActionsBridge` around the whole detail body — that - * nearer provider keeps owning the detail surface, exactly as before. - */ - const listRecordActions = useMemo( - () => - listRecordActionsValue(objectDef.name, handleNavOverlayNavigate, undefined, { - appName, - objects, - navigate, - routerRootHref, - }), - [objectDef.name, handleNavOverlayNavigate, appName, objects, navigate, routerRootHref], - ); - const navOverlay = useNavigationOverlay({ - navigation: detailNavigation, - objectName: objectDef.name, - onNavigate: handleNavOverlayNavigate, - }); - const handleDrawerClose = () => { - navOverlay.close(); - const newParams = new URLSearchParams(searchParams); - newParams.delete(RECORD_DRAWER_PARAM); - setSearchParams(newParams); - }; - /** - * Row-click handler used by all list/grid/gallery/kanban surfaces inside - * this object view. Wraps `navOverlay.handleClick` so that drawer-mode - * opens are URL-driven (writes `?recordId=…`) — making the drawer state - * shareable, refresh-safe, and respected by browser back/forward. The - * URL→state sync effect below handles actually opening the drawer. - */ - const handleRowClick = useCallback( - (record: any, event?: React.MouseEvent | { metaKey?: boolean; ctrlKey?: boolean; button?: number }) => { - // Cmd/Ctrl/middle-click — let the hook open in a new tab (full page) - // regardless of configured mode. Matches browser link convention. - const isModifier = !!( - event && ((event as any).metaKey || (event as any).ctrlKey || (event as any).button === 1) - ); - if (isModifier) { - navOverlay.handleClick(record, event as any); - return; - } - // Drawer mode → URL is the source of truth. Push `?recordId=…` - // and let the existing URL-sync effect open the overlay. - if (navOverlay.mode === 'drawer') { - const id = (record?.id ?? record?._id) as string | number | undefined; - if (id != null) { - const next = new URLSearchParams(searchParams); - next.set(RECORD_DRAWER_PARAM, String(id)); - setSearchParams(next); - return; - } - } - // All other modes (page / modal / split / popover / new_window / none) - // — delegate to the hook. - navOverlay.handleClick(record, event as any); - }, - [navOverlay, searchParams, setSearchParams] - ); - /** - * "Expand to full page" — invoked from the drawer header chevron. Closes - * the drawer (which clears `?recordId=…`) and router-pushes to the - * dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page. - */ - const handleExpandDrawer = useCallback(() => { - const rec = navOverlay.selectedRecord as Record | null; - const id = rec && ((rec as any).id ?? (rec as any)._id); - if (id == null) return; - handleDrawerClose(); - handleNavOverlayNavigate(id as string | number); - }, [navOverlay.selectedRecord, handleNavOverlayNavigate]); - // Sync URL-based recordId to overlay state - useEffect(() => { - if (drawerRecordId && !navOverlay.isOpen) { - navOverlay.open({ id: drawerRecordId }); - } else if (!drawerRecordId && navOverlay.isOpen) { - navOverlay.close(); - } - }, [drawerRecordId]); // objectui#10046 — the last `options` bag handed to `ListView`, so an // equal-content rebuild keeps its identity (see `isSameOptionsValue`). diff --git a/ui/packages/app-shell/src/views/objectViewIdentity.tsx b/ui/packages/app-shell/src/views/objectViewIdentity.tsx new file mode 100644 index 00000000000..e87d4f09dfa --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewIdentity.tsx @@ -0,0 +1,99 @@ +/** + * Identity import and the managed-environment Users marker of the Console + * `ObjectView` (`./ObjectView.tsx`): the gate that wires the import wizard to + * the auth service's import pipeline for `sys_user`, its password-policy state + * and data source, and the per-row marker on a managed environment's Users + * list, moved out of `ObjectViewInner` verbatim (objectstack#25992, slice 5). + * Every input is passed in; the component reads the returned values. + */ + +import { useState, useRef, useMemo, useCallback } from 'react'; +import type { DataSource } from '@object-ui/types'; +import { useMetadata } from '@object-ui/react'; +import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, type IdentityPasswordPolicy } from './identityImport.js'; +import { MANAGED_USERS_OBJECT, UnauthorizedUserMarker, UserGrantLoader, isManagedEnvironment } from './managedEnvironmentUsers.js'; +import { useExpressionContext } from '../providers/ExpressionProvider.js'; +import type { ConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; + +export function useObjectViewIdentity({ objectDef, isAdmin, dataSource, refreshKey, t, actionRuntime }: { + objectDef: { name: string }; + isAdmin: boolean; + dataSource: DataSource; + refreshKey: number; + t: (key: string, opts?: Record) => string; + actionRuntime: Pick; +}) { + // ─── Identity import (framework#2782) ──────────────────────────────── + // sys_user's generic import affordance stays FALSE (a data-layer insert + // would bypass better-auth's hashing and never create a credential), but + // platform admins get the same wizard wired to the dedicated + // /api/v1/auth/admin/import-users pipeline. Gated on the deployment's + // admin surface (features.admin from /api/v1/auth/config) plus the + // caller being a workspace admin (the server re-checks, ADR-0068). + // objectui#13330 — and never on a managed environment + // (`features.cloudManaged`): there the server answers `admin/import-users` + // with 409 `RESOURCE_CONFLICT`, because people arrive from the hosting + // organisation through its identity provider. + const { features } = useExpressionContext(); + const isIdentityImport = objectDef.name === IDENTITY_IMPORT_OBJECT; + const identityImportEnabled = + isIdentityImport && features?.admin === true && isAdmin && !isManagedEnvironment(features); + + // ─── Setup → Users on a managed environment (objectui#13330) ───────── + // The line that says where people are invited, and the marker on a user + // who holds no permission set beyond the baseline. The marker reads the + // grant rows through one loader per list (`managedEnvironmentUsers.tsx`), + // rebuilt when the list refreshes, so a grant made elsewhere shows on the + // next load. The metadata store is read through a ref: its context value is + // rebuilt whenever any cached type settles (AGENTS.md #10). + const managedUsers = objectDef.name === MANAGED_USERS_OBJECT && isManagedEnvironment(features); + const metadataStore = useMetadata(); + const metadataStoreRef = useRef(metadataStore); + metadataStoreRef.current = metadataStore; + const grantLoaderRef = useRef<{ refreshKey: number; source: unknown; loader: UserGrantLoader } | null>(null); + if ( + managedUsers && + dataSource && + (grantLoaderRef.current?.refreshKey !== refreshKey || grantLoaderRef.current?.source !== dataSource) + ) { + grantLoaderRef.current = { + refreshKey, + source: dataSource, + loader: new UserGrantLoader(dataSource as any, { + ensureType: (type: string) => metadataStoreRef.current.ensureType(type), + getTypeStatus: (type: string) => metadataStoreRef.current.getTypeStatus?.(type) ?? 'ready', + }), + }; + } + const grantLoader = managedUsers ? grantLoaderRef.current?.loader : undefined; + const managedUserMarker = useCallback( + (row: Record) => { + const id = row?.id ?? row?._id; + if (!grantLoader || id === undefined || id === null || id === '') return null; + return ; + }, + [grantLoader, t], + ); + const [identityPasswordPolicy, setIdentityPasswordPolicy] = useState('auto'); + const identityPolicyRef = useRef('auto'); + identityPolicyRef.current = identityPasswordPolicy; + const identityDataSource = useMemo( + () => (identityImportEnabled + ? createIdentityImportDataSource({ + base: dataSource, + authFetch: actionRuntime.authFetch, + baseUrl: import.meta.env.VITE_SERVER_URL || '', + getPasswordPolicy: () => identityPolicyRef.current, + }) + : undefined), + [identityImportEnabled, dataSource, actionRuntime.authFetch], + ); + return { + identityImportEnabled, + managedUsers, + managedUserMarker, + identityPasswordPolicy, + setIdentityPasswordPolicy, + identityDataSource, + }; +} diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts new file mode 100644 index 00000000000..3230be0b8a0 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -0,0 +1,213 @@ +/** + * Record navigation of the Console `ObjectView` (`./ObjectView.tsx`): how a + * row opens its record (drawer, modal, split, popover or page), the drawer's + * `?recordId=` URL state, "expand to full page", and the record links the + * list publishes, moved out of `ObjectViewInner` verbatim (objectstack#25992, + * slice 6). Every input is passed in; the component reads the returned values. + */ + +import { useMemo, useCallback, useEffect } from 'react'; +import type { NavigateFunction, Location, SetURLSearchParams } from 'react-router-dom'; +import { deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view'; +import { useNavigationOverlay } from '@object-ui/react'; +import type { ViewNavigationConfig } from '@object-ui/types'; +import { RECORD_DRAWER_PARAM } from '../urlParams.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import type { buildViewTabs } from './objectViewTabs.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewRecordNavigation({ + activeView, + objectDef, + objects, + appName, + viewId, + searchParams, + setSearchParams, + location, + navigate, + routerRootHref, + servedViews, + viewLabel, + unlabelledViewText, + language, +}: { + activeView: ReturnType[number]; + objectDef: ConsoleObjectViewProps['objects'][number]; + objects: ConsoleObjectViewProps['objects']; + appName: string | undefined; + viewId: string | undefined; + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; + location: Location; + navigate: NavigateFunction; + routerRootHref: string; + servedViews: ReturnType; + viewLabel: ReturnType['viewLabel']; + unlabelledViewText: string; + language: ReturnType['language']; +}) { + // Navigation overlay for record detail (supports drawer/modal/split/popover via config) + // Priority: activeView.navigation > objectDef.navigation > default drawer. + // + // Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where + // record peek is the primary interaction and full-page is the upgrade. + // Direct URL access (`/record/:id`) still opens as a full page because + // RecordDetailView owns its own route — only same-page click navigation + // is drawer-by-default. Per-view config can still override (e.g. a heavy + // detail object can set `navigation.mode = 'page'`). + const detailNavigation: ViewNavigationConfig = useMemo( + () => { + const authored = activeView?.navigation ?? objectDef.navigation; + if (authored) { + // Authored config wins. For an overlay, resolve the `size` + // bucket (or 'auto') to a viewport-clamped width when no explicit + // width was given — objectstack-ai/objectstack#2578: a pixel width can't be authored blind. + if (authored.mode === 'page') return authored; + return { + ...authored, + width: authored.width ?? overlayWidthFor( + (authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size, + objectDef, + ), + }; + } + // objectstack-ai/objectstack#2578: derive surface + width from FIELD COUNT. Field-heavy → full + // page (cramped in a drawer); light → a drawer sized to the content + // and clamped to the viewport. Mobile always pages (in deriveRecordSurface). + return deriveRecordSurface(objectDef) === 'page' + ? { mode: 'page' } + : { mode: 'drawer', width: overlayWidthFor('auto', objectDef) }; + }, + [activeView?.navigation, objectDef] + ); + const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM); + // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick + const handleNavOverlayNavigate = useCallback( + (recordId: string | number, action?: string) => { + if (action === 'new_window') { + // Open record detail in a new browser tab with Console-correct URL. + // Same builder the list's link column publishes as its href + // (objectui#4490) — one record-route shape for this surface. + window.open(listRecordDetailUrl(window.location.pathname, recordId), '_blank'); + return; + } + // Default: navigate to record detail page. + // `action` is a navigation-MODE token — 'view' / 'page' / undefined + // — and every non-`new_window` value lands on the record detail + // route. + // + // ⛔ It is NOT a view name, and this comment used to say a custom + // one could arrive here "forwarded from `navigation.view` (e.g. + // 'detail_form')", resolved "by RecordDetailView from its own + // config". No layer ever resolved a view by that name — which is + // why `@objectstack/spec` 17.5.0 retired the key under ADR-0049, + // and why `useNavigationOverlay` stopped forwarding it + // (objectui#9874). Nothing here changes behaviour: this branch + // already treated the name and the token identically, which is + // exactly what made the substitution inaudible on this surface. + const originState = { + from: { + pathname: location.pathname + (location.search || ''), + label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + }, + }; + if (viewId) { + navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState }); + } else { + navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); + } + }, + [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language] + ); + /** + * The list surface's half of the record-link mechanism (objectui#4490): + * publish this page's record-URL builder so the grid's `link: true` column + * can render a real anchor instead of a click-only `span role="link"`. + * See {@link listRecordActionsValue} for what this does and does NOT claim. + * + * The record drawer/overlay below mounts `RecordDetailView`, which brings + * its own `RelatedRecordActionsBridge` around the whole detail body — that + * nearer provider keeps owning the detail surface, exactly as before. + */ + const listRecordActions = useMemo( + () => + listRecordActionsValue(objectDef.name, handleNavOverlayNavigate, undefined, { + appName, + objects, + navigate, + routerRootHref, + }), + [objectDef.name, handleNavOverlayNavigate, appName, objects, navigate, routerRootHref], + ); + const navOverlay = useNavigationOverlay({ + navigation: detailNavigation, + objectName: objectDef.name, + onNavigate: handleNavOverlayNavigate, + }); + const handleDrawerClose = () => { + navOverlay.close(); + const newParams = new URLSearchParams(searchParams); + newParams.delete(RECORD_DRAWER_PARAM); + setSearchParams(newParams); + }; + /** + * Row-click handler used by all list/grid/gallery/kanban surfaces inside + * this object view. Wraps `navOverlay.handleClick` so that drawer-mode + * opens are URL-driven (writes `?recordId=…`) — making the drawer state + * shareable, refresh-safe, and respected by browser back/forward. The + * URL→state sync effect below handles actually opening the drawer. + */ + const handleRowClick = useCallback( + (record: any, event?: React.MouseEvent | { metaKey?: boolean; ctrlKey?: boolean; button?: number }) => { + // Cmd/Ctrl/middle-click — let the hook open in a new tab (full page) + // regardless of configured mode. Matches browser link convention. + const isModifier = !!( + event && ((event as any).metaKey || (event as any).ctrlKey || (event as any).button === 1) + ); + if (isModifier) { + navOverlay.handleClick(record, event as any); + return; + } + // Drawer mode → URL is the source of truth. Push `?recordId=…` + // and let the existing URL-sync effect open the overlay. + if (navOverlay.mode === 'drawer') { + const id = (record?.id ?? record?._id) as string | number | undefined; + if (id != null) { + const next = new URLSearchParams(searchParams); + next.set(RECORD_DRAWER_PARAM, String(id)); + setSearchParams(next); + return; + } + } + // All other modes (page / modal / split / popover / new_window / none) + // — delegate to the hook. + navOverlay.handleClick(record, event as any); + }, + [navOverlay, searchParams, setSearchParams] + ); + /** + * "Expand to full page" — invoked from the drawer header chevron. Closes + * the drawer (which clears `?recordId=…`) and router-pushes to the + * dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page. + */ + const handleExpandDrawer = useCallback(() => { + const rec = navOverlay.selectedRecord as Record | null; + const id = rec && ((rec as any).id ?? (rec as any)._id); + if (id == null) return; + handleDrawerClose(); + handleNavOverlayNavigate(id as string | number); + }, [navOverlay.selectedRecord, handleNavOverlayNavigate]); + // Sync URL-based recordId to overlay state + useEffect(() => { + if (drawerRecordId && !navOverlay.isOpen) { + navOverlay.open({ id: drawerRecordId }); + } else if (!drawerRecordId && navOverlay.isOpen) { + navOverlay.close(); + } + }, [drawerRecordId]); + return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer }; +}