diff --git a/.changeset/25992-objectview-tab-bar-header-chrome-split.md b/.changeset/25992-objectview-tab-bar-header-chrome-split.md new file mode 100644 index 00000000000..b63023ce18a --- /dev/null +++ b/.changeset/25992-objectview-tab-bar-header-chrome-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s view tab strip, plugin schema and header chrome move into sibling hooks + +Clause-②: no +Metadata-protocol: none + +Three concerns move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx`. The view tab items, the `ViewTabBar` handed to the list toolbar and its own-row face move into `useObjectViewTabBar` in `objectViewTabBar.tsx`. The views with the open draft merged in and the `ObjectViewSchema` handed to the plugin `ObjectView` move into `useObjectViewPluginSchema` in `objectViewPluginSchema.ts`. The header's create button, its folded actions menu and the list area for a refused view move into `useObjectViewHeaderChrome` in `objectViewHeaderChrome.tsx`. 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 18cac350a24..657bf3eea4a 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -22,8 +22,7 @@ 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 } from '@object-ui/plugin-view'; -import type { ViewTabItem } from '@object-ui/plugin-view'; +import { ObjectView as PluginObjectView, ManageViewsDialog } from '@object-ui/plugin-view'; // 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. @@ -33,19 +32,16 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Empty, - EmptyTitle, - EmptyDescription, NavigationOverlay, } from '@object-ui/components'; -import { Plus, Upload, Lock, MoreHorizontal, RotateCcw } from 'lucide-react'; +import { Plus, Upload, MoreHorizontal, RotateCcw } from 'lucide-react'; import { FavoriteMenuItem, useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; 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 } from '@object-ui/types'; +import type { DataSource, ListViewSchema, TreeViewConfig } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; @@ -72,7 +68,7 @@ import { resolveViewId } from '../utils/resolveViewId.js'; import { defaultListViewId, viewRowId, isSavedViewId } from '../utils/viewIdentity.js'; import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js'; import { useObjectActions } from '../hooks/useObjectActions.js'; -import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; +import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { ActionProvider, SchemaRenderer, useActionTextLocalizer, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react'; @@ -83,8 +79,6 @@ import { VIEW_TYPE_ICONS, FALLBACK_USER, substituteFilterTokens, - declaredPrimaryToolbarAction, - MOBILE_FAB_LIST_CLEARANCE, } from './objectViewHelpers.js'; import { defaultListColumnsFromObject, @@ -104,13 +98,16 @@ import { isSameOptionsValue, dispatchViewPatches, } from './objectViewOptions.js'; -import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js'; +import { ObjectNotFoundState, keepInlineEditModeForTheSession } from './objectViewChrome.js'; import { useObjectViewLiveData } from './objectViewLiveData.js'; import { useObjectViewIdentity } from './objectViewIdentity.js'; import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; import { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; import { useObjectViewListUrlState, useObjectViewUrlFilters } from './objectViewListUrlState.js'; +import { useObjectViewTabBar } from './objectViewTabBar.js'; +import { useObjectViewPluginSchema } from './objectViewPluginSchema.js'; +import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -1128,82 +1125,29 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // effect, so each host re-render re-issued the identical list query. const heldListFilter = useRef(undefined); - /* - * View Tabs — Airtable-style switcher with rename / delete / duplicate / - * pin / set-default / drag-reorder. Built-in views (sourced from - * objectDef.listViews) only support switching; mutating callbacks - * short-circuit with a toast. - * - * objectui#12440 — the strip is no longer a row of its own above the list: - * it is handed to `ListView` as its `toolbarLeading` host prop, so the tabs - * and the list's Filter · Sort · Display · Search share one row. From - * `sm` up only; below it nothing is handed over and the app bar's mobile - * view switcher (registered above) stays the phone's view control. - */ - const viewTabsFit = useViewTabsFit(); - // objectui#12528 — the view's description rides on its tab (the active - // tab's tooltip and ▾ menu header) instead of a row above the toolbar. - // Resolved the way `ListView` resolves the same text (`pickLocalized` on - // the display locale), and gated on the same switch the relay below hands - // `ListView` as `appearance.showDescription`: `false` shows it nowhere. - const displayLocale = useDisplayLocale(); - const viewTabItems: ViewTabItem[] = views.map((view: any) => { - const saved = savedViews.find((sv: any) => viewRowId(sv) === view.id); - // System views (loaded from objectDef.listViews / metadata) are - // *read-only*. Only sys_view-backed records can be mutated by - // the user; admins must duplicate a system view to customize it. - const isSystem = !saved; - return { - id: view.id, - label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), - type: view.type, - hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0, - hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0, - isDefault: !!(saved?.isDefault ?? view.isDefault), - isPinned: !!(saved?.isPinned ?? view.isPinned), - visibility: saved?.visibility ?? view.visibility, - readonly: isSystem, - readonlyReason: isSystem - ? t('console.objectView.systemViewReadonly') - : undefined, - description: (view.showDescription ?? view.appearance?.showDescription) === false - ? undefined - : pickLocalized(view.description, displayLocale) || undefined, - } as ViewTabItem; + // objectstack#25992 slice 9: the view tab items, the `ViewTabBar` strip and its + // own-row face moved to `./objectViewTabBar.tsx`. + const { viewTabItems, viewTabBar, viewTabRow } = useObjectViewTabBar({ + views, + savedViews, + objectDef, + servedViews, + viewLabel, + unlabelledViewText, + language, + t, + viewAccess, + activeViewId, + handleViewChange, + isAdmin, + handleAddView, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleConfigView, + setManageViewsOpen, }); - const viewTabBar = viewTabsFit && views.length >= 1 ? ( - setManageViewsOpen(true) : undefined} - /> - ) : null; - - // objectui#13114 — the strip as a row of its own, for the faces that have - // no `ListView` toolbar to carry it: a view the server refused (drawn - // page-level below) and a `type: 'chart'` view (drawn above the chart in - // `renderListView`'s chart branch, which returns before `ListView`). Every - // other face gets the strip in the list's toolbar row instead, so no face - // ever draws two. Gated on `viewTabBar`, so below `sm` it is absent too. - const viewTabRow = viewTabBar ? ( -
- {viewTabBar} -
- ) : null; // Render multi-view content via ListView plugin (for kanban, calendar, etc.) const renderListView = useCallback(({ schema: listSchema, dataSource: ds, onEdit: editHandler, className, refreshKey: pluginRefreshKey }: any) => { @@ -1972,178 +1916,54 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co ); }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker]); - // Memoize the merged views array so PluginObjectView doesn't get a new - // reference on every render (which would trigger unnecessary data refetches). - const mergedViews = useMemo(() => - views.map((v: any) => - v.id === activeViewId && viewDraft && viewDraft.id === v.id - ? { ...v, ...viewDraft } - : v - ), - [views, activeViewId, viewDraft] - ); + // objectstack#25992 slice 9: the merged views and the plugin's `ObjectViewSchema` + // moved to `./objectViewPluginSchema.ts`. + const { mergedViews, objectViewSchema } = useObjectViewPluginSchema({ + views, + activeViewId, + viewDraft, + activeView, + objectDef, + onEdit, + isAdmin, + location, + navigate, + viewId, + servedViews, + viewLabel, + unlabelledViewText, + language, + }); - // objectui#11013 — the active view's toolbar policy, read under its declared - // spelling `userActions.search` / `.filter` / `.sort`. These used to be read - // as the bare `showSearch` / `showFilters` / `showSort` flags, so a view - // that declared `userActions: { search: false }` still got a search box - // here. A stored view that still carries a bare flag is folded onto - // `userActions` by `normalizeListViewSchema`, the one fold the relay's own - // `userActions` rung already runs; this read names no legacy key. Not - // memoised: `activeView` is a fresh object on every render while a draft is - // open, and the schema below keys on the three booleans, not on this value. - const activeViewUserActions = (normalizeListViewSchema(activeView ?? {}) as { - userActions?: { search?: boolean; filter?: boolean; sort?: boolean }; - }).userActions; - const activeViewSearch = activeViewUserActions?.search !== false; - const activeViewFilter = activeViewUserActions?.filter !== false; - const activeViewSort = activeViewUserActions?.sort !== false; - - // Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft) - const objectViewSchema = useMemo(() => ({ - type: 'object-view' as const, - objectName: objectDef.name, - layout: 'page' as const, - showSearch: activeViewSearch, - showFilters: activeViewFilter, - showSort: activeViewSort, - showCreate: false, // We render our own create button in the header - allowCreateView: isAdmin, - viewActions: isAdmin ? [ - { type: 'settings' as const }, - { type: 'share' as const }, - { type: 'delete' as const }, - ] : [], - onNavigate: (recordId: string | number, mode: 'view' | 'edit') => { - if (mode === 'edit') { - onEdit?.({ id: recordId }); - } else if (mode === 'view') { - 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 }); - } - } - }, - }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews, language]); - - // The header's primary action, drawn the same in both of the header's - // faces (below). [#5153] `objectCanCreate && createVisible` — the - // object-level verdict, then the toolbar-scope `visibleWhen` layer on top - // of it. Greyed, not gone, is the `disabledWhen` case. Same pair drives the - // phone FAB below, so the two render points cannot disagree. - const showCreate = objectCanCreate && createVisible; - // objectui#13134 — the list's end-of-scroll room for the phone FAB, on the - // list host below, exactly while the FAB is drawn (see - // `MOBILE_FAB_LIST_CLEARANCE`). - const listFabClearance = showCreate ? MOBILE_FAB_LIST_CLEARANCE : undefined; - const createButton = showCreate ? ( - - ) : null; - - // objectui#12705 — the header's actions folded, for a header too narrow - // for them and the description: the primary action stays labelled and - // every other action goes into one `⋯` menu, above the page's own entries - // (favorites, Reset view). The header decides when; this decides what. - // - // The primary is DECLARED, never read off the row's order: the page's own - // New when it draws one, else the toolbar action authored - // `variant: 'primary'` (lowest `order` first) — `sys_user`'s Invite User. - // Everything else goes through one `action:bar` with no inline slots, so - // the menu is that bar's one overflow menu: the toolbar actions keep the - // bar's capability, location and `visible` / `disabled` gates and its - // execute path, as when they spill past `maxVisible` today. Import and the - // page's own entries join it as host callbacks, the way the record page's - // menu carries Favorite and Share. - // - // A `?runAction=` deep link still runs once: the arrival's render consumes - // it before the header can fold, so no action in the folded face carries - // `autoTrigger`. Not offered on the environment list, whose toolbar is - // `EnvironmentListToolbar`, not this bar. - const toolbarRowActions: UIActionSchema[] = listActions?.some((a: { locations?: readonly string[] }) => a.locations?.includes('list_toolbar')) - ? toolbarActionsWithDeepLink - : []; - const primaryToolbarAction = showCreate ? undefined : declaredPrimaryToolbarAction(toolbarRowActions); - const importShown = identityImportEnabled || (objectCanImport && importVisible); - const foldedMenuBar: ActionBarSchema = { - type: 'action:bar', - location: 'list_toolbar', - actions: [ - ...(importShown ? [{ - name: 'object_view_import', - label: t('console.objectView.import'), - icon: 'upload', - type: 'script' as const, - locations: ['list_toolbar' as const], - disabled: importDisabled, - onClick: () => setShowImport(true), - }] : []), - ...toolbarRowActions.filter((a) => a.name !== primaryToolbarAction?.name), - ], - systemActions: [ - ...(listFavorite ? [{ - name: 'object_view_favorite', - label: listFavorite.isFavorite - ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) - : t('common.addToFavorites', { defaultValue: 'Add to favorites' }), - icon: listFavorite.isFavorite ? 'star-off' : 'star', - type: 'script' as const, - onClick: listFavorite.toggle, - }] : []), - ...(activeViewHasPreference ? [{ - name: 'object_view_reset_view', - label: t('console.objectView.resetView'), - icon: 'rotate-ccw', - type: 'script' as const, - onClick: handleResetView, - }] : []), - ], - variant: 'ghost', - size: 'icon', - maxVisible: 0, - mobileMaxVisible: 0, - }; - const foldedHeaderActions = !isEnvironmentList ? ( - <> - {createButton} - {/* One inline slot at every width: `toolbarBar` keeps none below - `md` (`mobileMaxVisible: 0`), which would send the primary into - a second `⋯` beside the menu's. */} - {primaryToolbarAction && ( - - )} - - - ) : undefined; - - // objectui#12112 — the list area for a view the server refused: said as a - // permission refusal, in the server's own sentence, which names the view. - const refusedViewFace = viewAccess.refusal ? ( -
- -
- -
- {t('list.loadErrorForbiddenTitle')} - {viewAccess.refusal.message && {viewAccess.refusal.message}} -
-
- ) : null; + // objectstack#25992 slice 9: the header's create button, folded actions menu and + // the refused-view face moved to `./objectViewHeaderChrome.tsx`. + const { + showCreate, + listFabClearance, + createButton, + importShown, + foldedHeaderActions, + refusedViewFace, + } = useObjectViewHeaderChrome({ + objectCanCreate, + createVisible, + createDisabled, + actions, + t, + listActions, + toolbarActionsWithDeepLink, + identityImportEnabled, + objectCanImport, + importVisible, + importDisabled, + setShowImport, + listFavorite, + activeViewHasPreference, + handleResetView, + isEnvironmentList, + toolbarBar, + viewAccess, + }); return ( diff --git a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx new file mode 100644 index 00000000000..2fab36a245c --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx @@ -0,0 +1,176 @@ +/** + * The header chrome of the Console `ObjectView` (`./ObjectView.tsx`): the + * create button, the header's actions folded into one menu for a narrow + * header, and the list area for a view the server refused, moved out of + * `ObjectViewInner` verbatim (objectstack#25992, slice 9). It calls no React + * hook. Every input is passed in; the component reads the returned values. + */ + +import type { Dispatch, SetStateAction } from 'react'; +import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import { Plus, Lock } from 'lucide-react'; +import type { ActionBarSchema, UIActionSchema } from '@object-ui/types'; +import { SchemaRenderer } from '@object-ui/react'; +import type { useObjectTranslation } from '@object-ui/i18n'; +import type { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; +import type { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; +import type { useObjectActions } from '../hooks/useObjectActions.js'; +import { declaredPrimaryToolbarAction, MOBILE_FAB_LIST_CLEARANCE } from './objectViewHelpers.js'; +import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; +import type { useObjectViewIdentity } from './objectViewIdentity.js'; +import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; +import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; + +export function useObjectViewHeaderChrome({ + objectCanCreate, + createVisible, + createDisabled, + actions, + t, + listActions, + toolbarActionsWithDeepLink, + identityImportEnabled, + objectCanImport, + importVisible, + importDisabled, + setShowImport, + listFavorite, + activeViewHasPreference, + handleResetView, + isEnvironmentList, + toolbarBar, + viewAccess, +}: { + objectCanCreate: ReturnType['objectCanCreate']; + createVisible: ReturnType['createVisible']; + createDisabled: ReturnType['createDisabled']; + actions: ReturnType; + t: ReturnType['t']; + listActions: ReturnType['listActions']; + toolbarActionsWithDeepLink: ReturnType['toolbarActionsWithDeepLink']; + identityImportEnabled: ReturnType['identityImportEnabled']; + objectCanImport: ReturnType['objectCanImport']; + importVisible: ReturnType['importVisible']; + importDisabled: ReturnType['importDisabled']; + setShowImport: Dispatch>; + listFavorite: ReturnType; + activeViewHasPreference: ReturnType['activeViewHasPreference']; + handleResetView: ReturnType['handleResetView']; + isEnvironmentList: ReturnType['isEnvironmentList']; + toolbarBar: ReturnType['toolbarBar']; + viewAccess: ReturnType; +}) { + // The header's primary action, drawn the same in both of the header's + // faces (below). [#5153] `objectCanCreate && createVisible` — the + // object-level verdict, then the toolbar-scope `visibleWhen` layer on top + // of it. Greyed, not gone, is the `disabledWhen` case. Same pair drives the + // phone FAB below, so the two render points cannot disagree. + const showCreate = objectCanCreate && createVisible; + // objectui#13134 — the list's end-of-scroll room for the phone FAB, on the + // list host below, exactly while the FAB is drawn (see + // `MOBILE_FAB_LIST_CLEARANCE`). + const listFabClearance = showCreate ? MOBILE_FAB_LIST_CLEARANCE : undefined; + const createButton = showCreate ? ( + + ) : null; + + // objectui#12705 — the header's actions folded, for a header too narrow + // for them and the description: the primary action stays labelled and + // every other action goes into one `⋯` menu, above the page's own entries + // (favorites, Reset view). The header decides when; this decides what. + // + // The primary is DECLARED, never read off the row's order: the page's own + // New when it draws one, else the toolbar action authored + // `variant: 'primary'` (lowest `order` first) — `sys_user`'s Invite User. + // Everything else goes through one `action:bar` with no inline slots, so + // the menu is that bar's one overflow menu: the toolbar actions keep the + // bar's capability, location and `visible` / `disabled` gates and its + // execute path, as when they spill past `maxVisible` today. Import and the + // page's own entries join it as host callbacks, the way the record page's + // menu carries Favorite and Share. + // + // A `?runAction=` deep link still runs once: the arrival's render consumes + // it before the header can fold, so no action in the folded face carries + // `autoTrigger`. Not offered on the environment list, whose toolbar is + // `EnvironmentListToolbar`, not this bar. + const toolbarRowActions: UIActionSchema[] = listActions?.some((a: { locations?: readonly string[] }) => a.locations?.includes('list_toolbar')) + ? toolbarActionsWithDeepLink + : []; + const primaryToolbarAction = showCreate ? undefined : declaredPrimaryToolbarAction(toolbarRowActions); + const importShown = identityImportEnabled || (objectCanImport && importVisible); + const foldedMenuBar: ActionBarSchema = { + type: 'action:bar', + location: 'list_toolbar', + actions: [ + ...(importShown ? [{ + name: 'object_view_import', + label: t('console.objectView.import'), + icon: 'upload', + type: 'script' as const, + locations: ['list_toolbar' as const], + disabled: importDisabled, + onClick: () => setShowImport(true), + }] : []), + ...toolbarRowActions.filter((a) => a.name !== primaryToolbarAction?.name), + ], + systemActions: [ + ...(listFavorite ? [{ + name: 'object_view_favorite', + label: listFavorite.isFavorite + ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) + : t('common.addToFavorites', { defaultValue: 'Add to favorites' }), + icon: listFavorite.isFavorite ? 'star-off' : 'star', + type: 'script' as const, + onClick: listFavorite.toggle, + }] : []), + ...(activeViewHasPreference ? [{ + name: 'object_view_reset_view', + label: t('console.objectView.resetView'), + icon: 'rotate-ccw', + type: 'script' as const, + onClick: handleResetView, + }] : []), + ], + variant: 'ghost', + size: 'icon', + maxVisible: 0, + mobileMaxVisible: 0, + }; + const foldedHeaderActions = !isEnvironmentList ? ( + <> + {createButton} + {/* One inline slot at every width: `toolbarBar` keeps none below + `md` (`mobileMaxVisible: 0`), which would send the primary into + a second `⋯` beside the menu's. */} + {primaryToolbarAction && ( + + )} + + + ) : undefined; + + // objectui#12112 — the list area for a view the server refused: said as a + // permission refusal, in the server's own sentence, which names the view. + const refusedViewFace = viewAccess.refusal ? ( +
+ +
+ +
+ {t('list.loadErrorForbiddenTitle')} + {viewAccess.refusal.message && {viewAccess.refusal.message}} +
+
+ ) : null; + + return { showCreate, listFabClearance, createButton, importShown, foldedHeaderActions, refusedViewFace }; +} diff --git a/ui/packages/app-shell/src/views/objectViewPluginSchema.ts b/ui/packages/app-shell/src/views/objectViewPluginSchema.ts new file mode 100644 index 00000000000..02266ba3db5 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewPluginSchema.ts @@ -0,0 +1,111 @@ +/** + * The plugin schema of the Console `ObjectView` (`./ObjectView.tsx`): the views + * array with the open draft merged in, and the `ObjectViewSchema` handed to the + * plugin `ObjectView`, with the active view's toolbar policy read from + * `userActions`, moved out of `ObjectViewInner` verbatim (objectstack#25992, + * slice 9). Every input is passed in; the component reads the returned values. + */ + +import { useMemo } from 'react'; +import type { NavigateFunction, Location } from 'react-router-dom'; +import { normalizeListViewSchema } from '@object-ui/core'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewPluginSchema({ + views, + activeViewId, + viewDraft, + activeView, + objectDef, + onEdit, + isAdmin, + location, + navigate, + viewId, + servedViews, + viewLabel, + unlabelledViewText, + language, +}: { + views: ReturnType; + activeViewId: string; + viewDraft: Partial[number]> | null; + activeView: ReturnType[number]; + objectDef: ConsoleObjectViewProps['objects'][number]; + onEdit: ConsoleObjectViewProps['onEdit']; + isAdmin: boolean; + location: Location; + navigate: NavigateFunction; + viewId: string | undefined; + servedViews: ReturnType; + viewLabel: ReturnType['viewLabel']; + unlabelledViewText: string; + language: ReturnType['language']; +}) { + // Memoize the merged views array so PluginObjectView doesn't get a new + // reference on every render (which would trigger unnecessary data refetches). + const mergedViews = useMemo(() => + views.map((v: any) => + v.id === activeViewId && viewDraft && viewDraft.id === v.id + ? { ...v, ...viewDraft } + : v + ), + [views, activeViewId, viewDraft] + ); + + // objectui#11013 — the active view's toolbar policy, read under its declared + // spelling `userActions.search` / `.filter` / `.sort`. These used to be read + // as the bare `showSearch` / `showFilters` / `showSort` flags, so a view + // that declared `userActions: { search: false }` still got a search box + // here. A stored view that still carries a bare flag is folded onto + // `userActions` by `normalizeListViewSchema`, the one fold the relay's own + // `userActions` rung already runs; this read names no legacy key. Not + // memoised: `activeView` is a fresh object on every render while a draft is + // open, and the schema below keys on the three booleans, not on this value. + const activeViewUserActions = (normalizeListViewSchema(activeView ?? {}) as { + userActions?: { search?: boolean; filter?: boolean; sort?: boolean }; + }).userActions; + const activeViewSearch = activeViewUserActions?.search !== false; + const activeViewFilter = activeViewUserActions?.filter !== false; + const activeViewSort = activeViewUserActions?.sort !== false; + + // Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft) + const objectViewSchema = useMemo(() => ({ + type: 'object-view' as const, + objectName: objectDef.name, + layout: 'page' as const, + showSearch: activeViewSearch, + showFilters: activeViewFilter, + showSort: activeViewSort, + showCreate: false, // We render our own create button in the header + allowCreateView: isAdmin, + viewActions: isAdmin ? [ + { type: 'settings' as const }, + { type: 'share' as const }, + { type: 'delete' as const }, + ] : [], + onNavigate: (recordId: string | number, mode: 'view' | 'edit') => { + if (mode === 'edit') { + onEdit?.({ id: recordId }); + } else if (mode === 'view') { + 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 }); + } + } + }, + }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews, language]); + + return { mergedViews, objectViewSchema }; +} diff --git a/ui/packages/app-shell/src/views/objectViewTabBar.tsx b/ui/packages/app-shell/src/views/objectViewTabBar.tsx new file mode 100644 index 00000000000..59b905e1b66 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewTabBar.tsx @@ -0,0 +1,142 @@ +/** + * The view tab strip of the Console `ObjectView` (`./ObjectView.tsx`): the tab + * items, the `ViewTabBar` handed to the list toolbar as `toolbarLeading`, and + * its own-row face for the views that have no list toolbar, moved out of + * `ObjectViewInner` verbatim (objectstack#25992, slice 9). Every input is + * passed in; the component reads the returned values. + */ + +import type { Dispatch, SetStateAction } from 'react'; +import { ViewTabBar } from '@object-ui/plugin-view'; +import type { ViewTabBarProps, ViewTabItem } from '@object-ui/plugin-view'; +import { useDisplayLocale, pickLocalized } from '@object-ui/i18n'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { viewRowId } from '../utils/viewIdentity.js'; +import type { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import { VIEW_TYPE_ICONS } from './objectViewHelpers.js'; +import { useViewTabsFit } from './objectViewChrome.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewTabBar({ + views, + savedViews, + objectDef, + servedViews, + viewLabel, + unlabelledViewText, + language, + t, + viewAccess, + activeViewId, + handleViewChange, + isAdmin, + handleAddView, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleConfigView, + setManageViewsOpen, +}: { + views: ReturnType; + savedViews: Parameters[0]['savedViews']; + objectDef: ConsoleObjectViewProps['objects'][number]; + servedViews: ReturnType; + viewLabel: ReturnType['viewLabel']; + unlabelledViewText: string; + language: ReturnType['language']; + t: ReturnType['t']; + viewAccess: ReturnType; + activeViewId: ViewTabBarProps['activeViewId']; + handleViewChange: ViewTabBarProps['onViewChange']; + isAdmin: boolean; + handleAddView: NonNullable; + handleRenameView: NonNullable; + handleDeleteView: NonNullable; + handlePinView: NonNullable; + handleSetDefaultView: NonNullable; + handleConfigView: NonNullable; + setManageViewsOpen: Dispatch>; +}) { + /* + * View Tabs — Airtable-style switcher with rename / delete / duplicate / + * pin / set-default / drag-reorder. Built-in views (sourced from + * objectDef.listViews) only support switching; mutating callbacks + * short-circuit with a toast. + * + * objectui#12440 — the strip is no longer a row of its own above the list: + * it is handed to `ListView` as its `toolbarLeading` host prop, so the tabs + * and the list's Filter · Sort · Display · Search share one row. From + * `sm` up only; below it nothing is handed over and the app bar's mobile + * view switcher (registered above) stays the phone's view control. + */ + const viewTabsFit = useViewTabsFit(); + // objectui#12528 — the view's description rides on its tab (the active + // tab's tooltip and ▾ menu header) instead of a row above the toolbar. + // Resolved the way `ListView` resolves the same text (`pickLocalized` on + // the display locale), and gated on the same switch the relay below hands + // `ListView` as `appearance.showDescription`: `false` shows it nowhere. + const displayLocale = useDisplayLocale(); + const viewTabItems: ViewTabItem[] = views.map((view: any) => { + const saved = savedViews.find((sv: any) => viewRowId(sv) === view.id); + // System views (loaded from objectDef.listViews / metadata) are + // *read-only*. Only sys_view-backed records can be mutated by + // the user; admins must duplicate a system view to customize it. + const isSystem = !saved; + return { + id: view.id, + label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + type: view.type, + hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0, + hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0, + isDefault: !!(saved?.isDefault ?? view.isDefault), + isPinned: !!(saved?.isPinned ?? view.isPinned), + visibility: saved?.visibility ?? view.visibility, + readonly: isSystem, + readonlyReason: isSystem + ? t('console.objectView.systemViewReadonly') + : undefined, + description: (view.showDescription ?? view.appearance?.showDescription) === false + ? undefined + : pickLocalized(view.description, displayLocale) || undefined, + } as ViewTabItem; + }); + const viewTabBar = viewTabsFit && views.length >= 1 ? ( + setManageViewsOpen(true) : undefined} + /> + ) : null; + + // objectui#13114 — the strip as a row of its own, for the faces that have + // no `ListView` toolbar to carry it: a view the server refused (drawn + // page-level below) and a `type: 'chart'` view (drawn above the chart in + // `renderListView`'s chart branch, which returns before `ListView`). Every + // other face gets the strip in the list's toolbar row instead, so no face + // ever draws two. Gated on `viewTabBar`, so below `sm` it is absent too. + const viewTabRow = viewTabBar ? ( +
+ {viewTabBar} +
+ ) : null; + + return { viewTabItems, viewTabBar, viewTabRow }; +}