Skip to content

Commit 3db7bbf

Browse files
refactor(app-shell): move ObjectView's identity import and record navigation into sibling hooks (slices 5+6) (#26353)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9VtKf3F9dzJPJM4RRtABaEc)_ Part of #25992 (slices 5 and 6 of the move-only split, paired in one PR as the seat allowed; slices 1 to 4 landed as #26152, #26233, #26310 and #26336, and the card stays open for the slices listed below). Clause-②: no Metadata-protocol: none ## What moved Base `b9cd9809` (slice 4 merged). `ui/packages/app-shell/src/views/ObjectView.tsx` is 3123 lines there. Two concerns of `ObjectViewInner` move verbatim into custom hooks in new sibling modules. In both, every input is passed as an argument, the component destructures the returned values under their original names, no identifier is renamed and no context is added. ### Slice 5: identity import and the managed-environment Users marker → `objectViewIdentity.tsx` Lines 688–752 move into `useObjectViewIdentity({ objectDef, isAdmin, dataSource, refreshKey, t, actionRuntime })`: - the identity-import gate (`features` from `useExpressionContext`, `isIdentityImport`, `identityImportEnabled`), - the managed-environment Users marker (`managedUsers`, the metadata-store ref, the grant-loader ref and `managedUserMarker`), - the import password-policy state, its ref, and `identityDataSource`. It returns the six names the rest of the component reads: `identityImportEnabled`, `managedUsers`, `managedUserMarker`, `identityPasswordPolicy`, `setIdentityPasswordPolicy`, `identityDataSource`. The module is `.tsx` because the marker callback returns `<UnauthorizedUserMarker>`. The import-wizard open state (`showImport`, line 686) stays in the component, because the header and the wizard read it. ### Slice 6: the record navigation overlay → `objectViewRecordNavigation.ts` Two blocks move into `useObjectViewRecordNavigation({ activeView, objectDef, objects, appName, viewId, searchParams, setSearchParams, location, navigate, routerRootHref, servedViews, viewLabel, unlabelledViewText, language })`: - lines 1456–1490: `detailNavigation` (the authored or derived overlay mode and width) and `drawerRecordId`, - lines 1563–1686: `handleNavOverlayNavigate`, `listRecordActions`, `navOverlay` (`useNavigationOverlay`), `handleDrawerClose`, `handleRowClick`, `handleExpandDrawer` and the effect that syncs `?recordId=` to the overlay. It returns the five names the rest of the component reads: `listRecordActions`, `navOverlay`, `handleDrawerClose`, `handleRowClick`, `handleExpandDrawer`. The block between the two (lines 1491–1562: the `filter[...]` URL equality filters and the `uf_*` user-filter selections) stays in the component. It is a different concern (list filter URL state, row H's neighbour in the seam table), and `ObjectView.urlFilterSuffix-9196.test.ts` pins `const urlFilters`, `parseUrlEqualityFilterTriples` and the `./drillUrlFilters.js` import in `ObjectView.tsx`. Because the hook is called where the first block stood, the three hooks of that filter block (`useMemo`, `useRef`, `useCallback`) now run after the navigation hooks instead of between them. The two groups share no value, the order is the same on every render, and the only effect in either group, the `?recordId=` sync, keeps its place among the component's effects. ### Imports Each import moves with its only readers: `deriveRecordSurface`, `overlayWidthFor`, `RECORD_DRAWER_PARAM`, `ViewNavigationConfig`, `useNavigationOverlay`, `listRecordDetailUrl`, `listRecordActionsValue` (still re-exported from `ObjectView.tsx`, line unchanged) go to the navigation module. `useMetadata`, `useExpressionContext`, `createIdentityImportDataSource`, `IDENTITY_IMPORT_OBJECT`, `IdentityPasswordPolicy`, `MANAGED_USERS_OBJECT`, `UnauthorizedUserMarker`, `UserGrantLoader` and `isManagedEnvironment` go to the identity module. `viewTabLabel` is imported by both, since the component still reads it. Nothing new is exported from `ObjectView.tsx`, and the package entry is untouched. `ObjectView.tsx` goes from 3123 to 2926 lines. Move-only proof: the multiset of non-blank lines of the base `ObjectView.tsx` is contained in new `ObjectView.tsx` plus the two modules. The only base lines missing are the five import lines that lost a moved name, each replaced by its trimmed form. The added lines are the trimmed imports and the two new imports, the module headers and imports, the hook signatures and parameter types, the `return` objects, and in `ObjectView.tsx` the two calls with their destructuring and two-line comments. ## Test edits None. `identityImportWriteModes-12697` reads `ObjectView.tsx` for the wizard's `IDENTITY_IMPORT_WRITE_MODES` spread and its import from `./identityImport.js`; both stay in the component, and the import keeps that name. `urlFilterSuffix-9196` is covered by keeping the filter block in place (above). No other source reader names a moved line. ## Seam table of `ObjectViewInner` The full table (regions A to Q) is in #26336. Measured again on `b9cd9809`, with the rows this PR changes: | # | Concern | Lines on `b9cd9809` | Reads | Read by | Slice | |---|---|---|---|---|---| | E | Identity import + managed-environment Users marker | 688–752 | A (`t`), C (`objectDef`, `isAdmin`, `refreshKey`, `actionRuntime`), prop `dataSource` | O, P, Q | **5, this PR** | | L-nav | Record navigation overlay, drawer URL state, row click, expand, record links | 1456–1490, 1563–1686 | A, C, F (`activeView`), props `objects` | O, Q | **6, this PR** | | L-filter | `filter[...]` URL equality filters + `uf_*` user-filter selections | 1491–1562 | A, H (`listIdentityKey`, `ownListParamsRef`) | O | 8, with H | | K | Realtime refresh + record count | (moved) | | | 4, #26336 | ## Remaining slices (the last closes the card) - **Slice 7 — D:** environment entitlements, toolbar actions and the create and import verdicts into a sibling hook. - **Slice 8 — G + H + L-filter:** header toolbar actions, the list URL state and the URL/user filter block into sibling hooks. Moving L-filter re-points `ObjectView.urlFilterSuffix-9196.test.ts` to the new file (path change only, under the seat's re-pointing allowance); otherwise it stays. - **Slice 9 — N + P:** the view tab bar and the plugin schema plus header chrome. - **Slice 10 — Q:** the JSX return's `PageHeader`, import wizard and `NavigationOverlay` blocks into sub-components with explicit props. - **Slice 11 — B + I + O (closes the card):** the view-config and view-tab CRUD handlers and `renderListView`, with the `writeRefusalCensus-11583` rows and the `relayRungCensus-7559` read re-pointed to the new file (path and file-key changes only), as the seat allowed. The seat allows pairing two of these per PR. A, C, F, J and M (the held list refs, read only by `renderListView`, which go with O) stay where the table says. ## Verification See the os-dev-report comment on the card for the full readings: app-shell `type-check`, every `ObjectView.*` test in `views/`, the `AppContent*` tests, every other test that reads `ObjectView.tsx` as source, the app-shell tests that touch identity import, managed users or the record drawer, eslint on the 3 touched files, the derived dispatch gates, and a Console build. ## Acceptance notes - eslint: 0 errors, and no new warning. The warnings in the moved code are the ones it carried in the component: refs read during render in the grant-loader block, `any` casts, and the two `exhaustive-deps` warnings on `handleExpandDrawer` (`handleDrawerClose`) and the drawer-sync effect (`navOverlay`). Dependency arrays and casts are kept verbatim because the slices are move-only. The three files report 152 warnings in total, against 158 for the base `ObjectView.tsx` alone. - The navigation hook's parameters are typed from where the component gets each value, not as `any` (seat review, lane `any` removal #25918): `activeView` is `ReturnType<typeof buildViewTabs>[number]`, `objects` is `ConsoleObjectViewProps['objects']` and `objectDef` its element. The `ConsoleObjectViewProps` import from `./ObjectView.js` is `import type`, so it is erased and adds no runtime cycle. Changeset: `.changeset/25992-objectview-identity-navigation-split.md` (patch, `@object-ui/app-shell`). --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 222755e commit 3db7bbf

4 files changed

Lines changed: 359 additions & 232 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
refactor(app-shell): the Console `ObjectView`'s identity import and record navigation move into sibling hooks
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
What to do: nothing.

‎ui/packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 35 additions & 232 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,8 @@ const ImportWizard = lazy(() =>
3232
import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })),
3333
);
3434
import { ListView } from '@object-ui/plugin-list';
35-
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view';
35+
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
3636
import type { ViewTabItem } from '@object-ui/plugin-view';
37-
import { RECORD_DRAWER_PARAM } from '../urlParams.js';
3837
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
3938
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
4039
// Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
@@ -56,7 +55,7 @@ import { useTenancyPosture } from '../hooks/useTenancyPosture.js';
5655
import { useServedViewItems } from '../hooks/useServedViewItems.js';
5756
import { viewTabLabel } from './viewTabLabel.js';
5857
import { getIcon } from '../utils/getIcon.js';
59-
import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig, ViewNavigationConfig } from '@object-ui/types';
58+
import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig } from '@object-ui/types';
6059
import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js';
6160
import { ViewConfigPanel } from './ViewConfigPanel.js';
6261
import { useMetadataClient } from './metadata-admin/useMetadata.js';
@@ -75,11 +74,10 @@ import { ManagedByBadge } from '../components/ManagedByBadge.js';
7574
import { isSetupAppSegment } from '../console/setupApp.js';
7675
import { RecordDetailView } from './RecordDetailView.js';
7776
import type { RowCrudPredicates } from '../utils/crudAffordances.js';
78-
import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, IDENTITY_IMPORT_WRITE_MODES, type IdentityPasswordPolicy } from './identityImport.js';
77+
import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js';
7978
import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js';
80-
import { ManagedUsersInviteLine, MANAGED_USERS_OBJECT, UnauthorizedUserMarker, UserGrantLoader, isManagedEnvironment } from './managedEnvironmentUsers.js';
79+
import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js';
8180
import { importTargetFields } from './importTargetFields.js';
82-
import { useExpressionContext } from '../providers/ExpressionProvider.js';
8381
import { listToolbarDrawsAction, resolveManagedByEmptyState } from '../utils/managedByEmptyState.js';
8482
import { mayReadObject } from '../utils/navObjectReadGate.js';
8583
import { resolveViewId } from '../utils/resolveViewId.js';
@@ -89,7 +87,7 @@ import { useObjectActions } from '../hooks/useObjectActions.js';
8987
import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n';
9088
import { usePermissions } from '@object-ui/permissions';
9189
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
92-
import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope, useMetadata } from '@object-ui/react';
90+
import { ActionProvider, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope } from '@object-ui/react';
9391
import { toast } from 'sonner';
9492
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js';
9593
import { useOfferedNavRunAction } from '../hooks/useNavRunAction.js';
@@ -104,7 +102,6 @@ import {
104102
selectFilterParams,
105103
MOBILE_FAB_LIST_CLEARANCE,
106104
} from './objectViewHelpers.js';
107-
import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js';
108105
import {
109106
defaultListColumnsFromObject,
110107
bulkWiredActionNamesKey,
@@ -126,6 +123,8 @@ import {
126123
} from './objectViewOptions.js';
127124
import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js';
128125
import { useObjectViewLiveData } from './objectViewLiveData.js';
126+
import { useObjectViewIdentity } from './objectViewIdentity.js';
127+
import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js';
129128

130129
// objectstack#25992 slice 1: these module-level helpers moved to the sibling
131130
// `objectView*.ts` modules above. Every name this file exported stays
@@ -685,71 +684,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
685684
// Import wizard open/close state — toolbar entry triggers it.
686685
const [showImport, setShowImport] = useState(false);
687686

688-
// ─── Identity import (framework#2782) ────────────────────────────────
689-
// sys_user's generic import affordance stays FALSE (a data-layer insert
690-
// would bypass better-auth's hashing and never create a credential), but
691-
// platform admins get the same wizard wired to the dedicated
692-
// /api/v1/auth/admin/import-users pipeline. Gated on the deployment's
693-
// admin surface (features.admin from /api/v1/auth/config) plus the
694-
// caller being a workspace admin (the server re-checks, ADR-0068).
695-
// objectui#13330 — and never on a managed environment
696-
// (`features.cloudManaged`): there the server answers `admin/import-users`
697-
// with 409 `RESOURCE_CONFLICT`, because people arrive from the hosting
698-
// organisation through its identity provider.
699-
const { features } = useExpressionContext();
700-
const isIdentityImport = objectDef.name === IDENTITY_IMPORT_OBJECT;
701-
const identityImportEnabled =
702-
isIdentityImport && features?.admin === true && isAdmin && !isManagedEnvironment(features);
703-
704-
// ─── Setup → Users on a managed environment (objectui#13330) ─────────
705-
// The line that says where people are invited, and the marker on a user
706-
// who holds no permission set beyond the baseline. The marker reads the
707-
// grant rows through one loader per list (`managedEnvironmentUsers.tsx`),
708-
// rebuilt when the list refreshes, so a grant made elsewhere shows on the
709-
// next load. The metadata store is read through a ref: its context value is
710-
// rebuilt whenever any cached type settles (AGENTS.md #10).
711-
const managedUsers = objectDef.name === MANAGED_USERS_OBJECT && isManagedEnvironment(features);
712-
const metadataStore = useMetadata();
713-
const metadataStoreRef = useRef(metadataStore);
714-
metadataStoreRef.current = metadataStore;
715-
const grantLoaderRef = useRef<{ refreshKey: number; source: unknown; loader: UserGrantLoader } | null>(null);
716-
if (
717-
managedUsers &&
718-
dataSource &&
719-
(grantLoaderRef.current?.refreshKey !== refreshKey || grantLoaderRef.current?.source !== dataSource)
720-
) {
721-
grantLoaderRef.current = {
722-
refreshKey,
723-
source: dataSource,
724-
loader: new UserGrantLoader(dataSource as any, {
725-
ensureType: (type: string) => metadataStoreRef.current.ensureType(type),
726-
getTypeStatus: (type: string) => metadataStoreRef.current.getTypeStatus?.(type) ?? 'ready',
727-
}),
728-
};
729-
}
730-
const grantLoader = managedUsers ? grantLoaderRef.current?.loader : undefined;
731-
const managedUserMarker = useCallback(
732-
(row: Record<string, unknown>) => {
733-
const id = row?.id ?? row?._id;
734-
if (!grantLoader || id === undefined || id === null || id === '') return null;
735-
return <UnauthorizedUserMarker loader={grantLoader} userId={String(id)} t={t} />;
736-
},
737-
[grantLoader, t],
738-
);
739-
const [identityPasswordPolicy, setIdentityPasswordPolicy] = useState<IdentityPasswordPolicy>('auto');
740-
const identityPolicyRef = useRef<IdentityPasswordPolicy>('auto');
741-
identityPolicyRef.current = identityPasswordPolicy;
742-
const identityDataSource = useMemo(
743-
() => (identityImportEnabled
744-
? createIdentityImportDataSource({
745-
base: dataSource,
746-
authFetch: actionRuntime.authFetch,
747-
baseUrl: import.meta.env.VITE_SERVER_URL || '',
748-
getPasswordPolicy: () => identityPolicyRef.current,
749-
})
750-
: undefined),
751-
[identityImportEnabled, dataSource, actionRuntime.authFetch],
752-
);
687+
// objectstack#25992 slice 5: identity import and the managed-environment Users
688+
// marker moved to `./objectViewIdentity.tsx`.
689+
const {
690+
identityImportEnabled,
691+
managedUsers,
692+
managedUserMarker,
693+
identityPasswordPolicy,
694+
setIdentityPasswordPolicy,
695+
identityDataSource,
696+
} = useObjectViewIdentity({ objectDef, isAdmin, dataSource, refreshKey, t, actionRuntime });
753697

754698
// ─── User-defined views (metadata overlay) ──────────────────────────
755699
// Saved views created via the ViewConfigPanel ("Add View") live in the
@@ -1453,41 +1397,24 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
14531397
// footer record count moved to `./objectViewLiveData.ts`; the count state stays here.
14541398
useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount });
14551399

1456-
// Navigation overlay for record detail (supports drawer/modal/split/popover via config)
1457-
// Priority: activeView.navigation > objectDef.navigation > default drawer.
1458-
//
1459-
// Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where
1460-
// record peek is the primary interaction and full-page is the upgrade.
1461-
// Direct URL access (`/record/:id`) still opens as a full page because
1462-
// RecordDetailView owns its own route — only same-page click navigation
1463-
// is drawer-by-default. Per-view config can still override (e.g. a heavy
1464-
// detail object can set `navigation.mode = 'page'`).
1465-
const detailNavigation: ViewNavigationConfig = useMemo(
1466-
() => {
1467-
const authored = activeView?.navigation ?? objectDef.navigation;
1468-
if (authored) {
1469-
// Authored config wins. For an overlay, resolve the `size`
1470-
// bucket (or 'auto') to a viewport-clamped width when no explicit
1471-
// width was given — objectstack-ai/objectstack#2578: a pixel width can't be authored blind.
1472-
if (authored.mode === 'page') return authored;
1473-
return {
1474-
...authored,
1475-
width: authored.width ?? overlayWidthFor(
1476-
(authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size,
1477-
objectDef,
1478-
),
1479-
};
1480-
}
1481-
// objectstack-ai/objectstack#2578: derive surface + width from FIELD COUNT. Field-heavy → full
1482-
// page (cramped in a drawer); light → a drawer sized to the content
1483-
// and clamped to the viewport. Mobile always pages (in deriveRecordSurface).
1484-
return deriveRecordSurface(objectDef) === 'page'
1485-
? { mode: 'page' }
1486-
: { mode: 'drawer', width: overlayWidthFor('auto', objectDef) };
1487-
},
1488-
[activeView?.navigation, objectDef]
1489-
);
1490-
const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM);
1400+
// objectstack#25992 slice 6: the record navigation overlay (drawer state in the
1401+
// URL, row click, expand to page, record links) moved to `./objectViewRecordNavigation.ts`.
1402+
const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({
1403+
activeView,
1404+
objectDef,
1405+
objects,
1406+
appName,
1407+
viewId,
1408+
searchParams,
1409+
setSearchParams,
1410+
location,
1411+
navigate,
1412+
routerRootHref,
1413+
servedViews,
1414+
viewLabel,
1415+
unlabelledViewText,
1416+
language,
1417+
});
14911418

14921419
/**
14931420
* URL-derived equality filters in the form `?filter[<field>]=<value>`.
@@ -1560,130 +1487,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
15601487
},
15611488
[setSearchParams],
15621489
);
1563-
// Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick
1564-
const handleNavOverlayNavigate = useCallback(
1565-
(recordId: string | number, action?: string) => {
1566-
if (action === 'new_window') {
1567-
// Open record detail in a new browser tab with Console-correct URL.
1568-
// Same builder the list's link column publishes as its href
1569-
// (objectui#4490) — one record-route shape for this surface.
1570-
window.open(listRecordDetailUrl(window.location.pathname, recordId), '_blank');
1571-
return;
1572-
}
1573-
// Default: navigate to record detail page.
1574-
// `action` is a navigation-MODE token — 'view' / 'page' / undefined
1575-
// — and every non-`new_window` value lands on the record detail
1576-
// route.
1577-
//
1578-
// ⛔ It is NOT a view name, and this comment used to say a custom
1579-
// one could arrive here "forwarded from `navigation.view` (e.g.
1580-
// 'detail_form')", resolved "by RecordDetailView from its own
1581-
// config". No layer ever resolved a view by that name — which is
1582-
// why `@objectstack/spec` 17.5.0 retired the key under ADR-0049,
1583-
// and why `useNavigationOverlay` stopped forwarding it
1584-
// (objectui#9874). Nothing here changes behaviour: this branch
1585-
// already treated the name and the token identically, which is
1586-
// exactly what made the substitution inaudible on this surface.
1587-
const originState = {
1588-
from: {
1589-
pathname: location.pathname + (location.search || ''),
1590-
label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
1591-
},
1592-
};
1593-
if (viewId) {
1594-
navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState });
1595-
} else {
1596-
navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState });
1597-
}
1598-
},
1599-
[navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language]
1600-
);
1601-
/**
1602-
* The list surface's half of the record-link mechanism (objectui#4490):
1603-
* publish this page's record-URL builder so the grid's `link: true` column
1604-
* can render a real anchor instead of a click-only `span role="link"`.
1605-
* See {@link listRecordActionsValue} for what this does and does NOT claim.
1606-
*
1607-
* The record drawer/overlay below mounts `RecordDetailView`, which brings
1608-
* its own `RelatedRecordActionsBridge` around the whole detail body — that
1609-
* nearer provider keeps owning the detail surface, exactly as before.
1610-
*/
1611-
const listRecordActions = useMemo(
1612-
() =>
1613-
listRecordActionsValue(objectDef.name, handleNavOverlayNavigate, undefined, {
1614-
appName,
1615-
objects,
1616-
navigate,
1617-
routerRootHref,
1618-
}),
1619-
[objectDef.name, handleNavOverlayNavigate, appName, objects, navigate, routerRootHref],
1620-
);
1621-
const navOverlay = useNavigationOverlay({
1622-
navigation: detailNavigation,
1623-
objectName: objectDef.name,
1624-
onNavigate: handleNavOverlayNavigate,
1625-
});
1626-
const handleDrawerClose = () => {
1627-
navOverlay.close();
1628-
const newParams = new URLSearchParams(searchParams);
1629-
newParams.delete(RECORD_DRAWER_PARAM);
1630-
setSearchParams(newParams);
1631-
};
1632-
/**
1633-
* Row-click handler used by all list/grid/gallery/kanban surfaces inside
1634-
* this object view. Wraps `navOverlay.handleClick` so that drawer-mode
1635-
* opens are URL-driven (writes `?recordId=…`) — making the drawer state
1636-
* shareable, refresh-safe, and respected by browser back/forward. The
1637-
* URL→state sync effect below handles actually opening the drawer.
1638-
*/
1639-
const handleRowClick = useCallback(
1640-
(record: any, event?: React.MouseEvent | { metaKey?: boolean; ctrlKey?: boolean; button?: number }) => {
1641-
// Cmd/Ctrl/middle-click — let the hook open in a new tab (full page)
1642-
// regardless of configured mode. Matches browser link convention.
1643-
const isModifier = !!(
1644-
event && ((event as any).metaKey || (event as any).ctrlKey || (event as any).button === 1)
1645-
);
1646-
if (isModifier) {
1647-
navOverlay.handleClick(record, event as any);
1648-
return;
1649-
}
1650-
// Drawer mode → URL is the source of truth. Push `?recordId=…`
1651-
// and let the existing URL-sync effect open the overlay.
1652-
if (navOverlay.mode === 'drawer') {
1653-
const id = (record?.id ?? record?._id) as string | number | undefined;
1654-
if (id != null) {
1655-
const next = new URLSearchParams(searchParams);
1656-
next.set(RECORD_DRAWER_PARAM, String(id));
1657-
setSearchParams(next);
1658-
return;
1659-
}
1660-
}
1661-
// All other modes (page / modal / split / popover / new_window / none)
1662-
// — delegate to the hook.
1663-
navOverlay.handleClick(record, event as any);
1664-
},
1665-
[navOverlay, searchParams, setSearchParams]
1666-
);
1667-
/**
1668-
* "Expand to full page" — invoked from the drawer header chevron. Closes
1669-
* the drawer (which clears `?recordId=…`) and router-pushes to the
1670-
* dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page.
1671-
*/
1672-
const handleExpandDrawer = useCallback(() => {
1673-
const rec = navOverlay.selectedRecord as Record<string, unknown> | null;
1674-
const id = rec && ((rec as any).id ?? (rec as any)._id);
1675-
if (id == null) return;
1676-
handleDrawerClose();
1677-
handleNavOverlayNavigate(id as string | number);
1678-
}, [navOverlay.selectedRecord, handleNavOverlayNavigate]);
1679-
// Sync URL-based recordId to overlay state
1680-
useEffect(() => {
1681-
if (drawerRecordId && !navOverlay.isOpen) {
1682-
navOverlay.open({ id: drawerRecordId });
1683-
} else if (!drawerRecordId && navOverlay.isOpen) {
1684-
navOverlay.close();
1685-
}
1686-
}, [drawerRecordId]);
16871490

16881491
// objectui#10046 — the last `options` bag handed to `ListView`, so an
16891492
// equal-content rebuild keeps its identity (see `isSameOptionsValue`).

0 commit comments

Comments
 (0)