Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/25992-objectview-identity-navigation-split.md
Original file line number Diff line number Diff line change
@@ -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.
267 changes: 35 additions & 232 deletions ui/packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand All @@ -104,7 +102,6 @@ import {
selectFilterParams,
MOBILE_FAB_LIST_CLEARANCE,
} from './objectViewHelpers.js';
import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js';
import {
defaultListColumnsFromObject,
bulkWiredActionNamesKey,
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown>) => {
const id = row?.id ?? row?._id;
if (!grantLoader || id === undefined || id === null || id === '') return null;
return <UnauthorizedUserMarker loader={grantLoader} userId={String(id)} t={t} />;
},
[grantLoader, t],
);
const [identityPasswordPolicy, setIdentityPasswordPolicy] = useState<IdentityPasswordPolicy>('auto');
const identityPolicyRef = useRef<IdentityPasswordPolicy>('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
Expand Down Expand Up @@ -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[<field>]=<value>`.
Expand Down Expand Up @@ -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<string, unknown> | 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`).
Expand Down
Loading
Loading