diff --git a/.changeset/25992-objectview-view-crud-split.md b/.changeset/25992-objectview-view-crud-split.md new file mode 100644 index 00000000000..8674f0cdc15 --- /dev/null +++ b/.changeset/25992-objectview-view-crud-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s view management handlers move into a sibling hook + +Clause-②: no +Metadata-protocol: none + +The view-config panel's save and create handlers, the draft field update, view switching, the mobile view switcher registration and the view-tab CRUD callbacks (rename, delete, pin, set default, reorder, configure, add) move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx` into `useObjectViewViewCrud` in `objectViewViewCrud.tsx`. The panel and dialog state stays in the component, which passes every input in and reads the returned handlers. 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.overlayPatchOnly.test.ts b/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts index ea2fd17d39e..3d7799724ce 100644 --- a/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts @@ -308,7 +308,7 @@ describe('objectui#5233 — what must NOT be narrowed', () => { */ describe('objectui#12504 census — the toolbar-to-view write family is retired', () => { const here = path.dirname(fileURLToPath(import.meta.url)); - const objectViewCode = stripComments(['ObjectView.tsx', 'objectViewListRenderer.tsx'].map((file) => readFileSync(path.join(here, file), 'utf8')).join('\n')); + const objectViewCode = stripComments(['ObjectView.tsx', 'objectViewListRenderer.tsx', 'objectViewViewCrud.tsx'].map((file) => readFileSync(path.join(here, file), 'utf8')).join('\n')); const dataSourceCode = stripComments( readFileSync(path.join(here, '..', '..', '..', 'types', 'src', 'data.ts'), 'utf8'), ); diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 667ec7e9932..baf1b3de8d3 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -13,7 +13,6 @@ import { useMemo, useState, useCallback, useEffect, lazy, Suspense } from 'react import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom'; import { resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; import { useObjectListPreferences } from '../context/ListPreferencesProvider.js'; -import { formatMetadataError } from '@object-ui/data-objectstack'; const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })), ); @@ -29,21 +28,15 @@ import { 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 type { DataSource } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; import { dropPublishedViewList } from './metadata-admin/viewListDrop.js'; -import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope } from './runtime-metadata-persistence.js'; import { CreateViewDialog } from './CreateViewDialog.js'; import { usePreviewDrafts, - PREVIEW_QUERY_FLAG, - PREVIEW_QUERY_VALUE, } from '../preview/PreviewModeContext.js'; -import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext.js'; -import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext.js'; import { isSetupAppSegment } from '../console/setupApp.js'; import { RecordDetailView } from './RecordDetailView.js'; import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; @@ -51,7 +44,7 @@ import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; import { importTargetFields } from './importTargetFields.js'; import { resolveViewId } from '../utils/resolveViewId.js'; -import { defaultListViewId, viewRowId, isSavedViewId } from '../utils/viewIdentity.js'; +import { defaultListViewId, viewRowId } from '../utils/viewIdentity.js'; import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; @@ -65,15 +58,9 @@ import { } from './objectViewHelpers.js'; import { defaultListColumnsFromObject, - buildAddViewSpec, loadViewOverrides, buildViewTabs, - setDefaultViewPatches, - buildViewConfigSaveBody, } from './objectViewTabs.js'; -import { - dispatchViewPatches, -} from './objectViewOptions.js'; import { ObjectNotFoundState } from './objectViewChrome.js'; import { useObjectViewLiveData } from './objectViewLiveData.js'; import { useObjectViewIdentity } from './objectViewIdentity.js'; @@ -87,6 +74,7 @@ import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import { ObjectViewPageHeader } from './objectViewPageHeader.js'; import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js'; import { useObjectViewListRenderer } from './objectViewListRenderer.js'; +import { useObjectViewViewCrud } from './objectViewViewCrud.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -224,151 +212,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // Draft state for view config edits — cached locally, saved on demand const [viewDraft, setViewDraft] = useState | null>(null); - - // objectui#12504 — no toolbar control writes the shared view. The sort and - // the column layout (objectui#12418), the density and the Hide Fields - // popover (objectui#12474) are the user's own list state (`listPrefs`), and - // the shared view is written only by an explicit save - // (`handleViewConfigSave`, below). The toolbar-to-view writer that lived - // here, `persistViewPatch`, retired with the adapter method it was the last - // caller of. ⛔ Do not bring one back: `ObjectView.overlayPatchOnly.test.ts` - // pins its absence. - - /* - * There is deliberately NO `persistViewFilter` here (objectui#4155). - * - * The list toolbar's filter panel is the USER's state — it belongs to the - * session and to that user's own list state (`listPrefs`, objectui#12418), - * not to the view's stored body. It used to persist: every `onFilterChange` the panel - * emitted was folded and written to the view overlay, so merely opening the - * panel and clicking `Add filter` wrote an incomplete `field equals ''` - * condition into `sys_metadata` — which then REPLACED the source-declared - * `filter` for every user of that view and emptied the list, unrecoverably - * from the panel itself. - * - * The threshold for writing a view overlay is an EXPLICIT save: - * `handleViewConfigSave` (the view config panel) and `handleSaveAsView` - * (`ObjectDataPage`). `ObjectDataPage` already made this choice for the - * sibling surface — "Deliberately NO onSortChange/onFilterChange - * persistence hooks: this surface never writes back to any saved view" - * (#2251) — and this surface was the outlier. - * - * `foldFilterGroupToSpecRules` (objectstack#5159) is still the one dialect - * for those explicit paths — the Studio inspector's `FilterBuilderField` - * folds through it. What is gone is the automatic write, not the fold. - */ - - /** - * The view-config panel's edit Save. Resolves whether the draft was staged, - * so the panel stays dirty, and announces no draft, on anything but `true` - * (objectui#11583). - */ - const handleViewConfigSave = useCallback(async (draft: Record): Promise => { - setViewDraft(draft); - setRefreshKey(k => k + 1); - - // ADR-0034: stage a per-item draft via the metadata seam; an explicit - // Publish (RuntimeDraftBar) promotes it + records a version. - const vid = draft.id; - if (!metadataClient || !vid) { - console.warn('[ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId.'); - return false; - } - try { - // `dataSource` + `objectName` let the seam drop this object's view - // cache keys (#4373) — the adapter owns which keys those are. - await persistRuntimeMetadata('view', vid, buildViewConfigSaveBody(objectName, draft), { - metadataClient, - dataSource, - objectName, - }); - return true; - } catch (err) { - console.error('[ViewConfigPanel] Failed to persist view config:', err); - // objectui#11583: a refused save is SAID, with the door's own - // message, as the Create View doors do (objectui#11578). - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - return false; - } - }, [metadataClient, dataSource, objectName, t]); - - /** - * Create a new view: the Create View dialog's door and the view-config - * panel's create mode. Resolves whether the view was saved, so the dialog - * closes only on `true` (objectui#11578). - */ - const handleViewCreate = useCallback(async (config: Record): Promise => { - try { - let createdId: string | undefined; - if (metadataClient) { - // ADR-0005 overlay path — write the full spec under a unique - // `name` via the metadata customization API instead of into - // the physical `sys_view` table (whose columns no longer - // accommodate the spec shape: arrays, nested objects, etc.). - // The spec comes from the builder "Save as view" also uses - // (`buildAddViewSpec` → `buildNewViewSpec`, objectui#11581): - // this door supplies only its default business columns. - const objectDef = objects?.find?.((o: any) => o.name === objectName); - const spec = buildAddViewSpec(config, objectDef); - // ADR-0034: a new view is created as an invisible per-item - // draft via the metadata seam; an explicit Publish promotes it. - // The auto-activation below stays here; the canonical ViewItem - // envelope + qualified-name identity live in the seam. - // - // #2767 P1: the qualified name `.` is used as BOTH - // the URL segment and `body.name`, so the sys_metadata row key, - // the ViewTabBar tab id, and the body identity all agree and the - // draft → read → publish loop resolves. - const env = viewEnvelope(objectName, spec, { - name: (config as any)?.name, - label: config.label, - }); - createdId = await createRuntimeMetadata('view', env.name, env, { - metadataClient, - // #4373: this flow is the second door into the same rows the - // adapter caches. Hand it the adapter (the `dataSource` prop - // IS the `ObjectStackAdapter`, via `AppContent`'s - // `useAdapter()`) so the seam can drop the object's view keys. - dataSource, - objectName, - }); - } - setShowViewConfigPanel(false); - setViewConfigPanelMode('edit'); - setRefreshKey(k => k + 1); - // Auto-activate the newly created view (Airtable parity), and close - // the main-path loop (#2767 P4): a fresh view is a DRAFT, invisible - // to a normal (published-only) read, so navigating there plainly - // would land on "view not found". Enter draft-preview mode - // (`?preview=draft`) instead — the DraftPreviewBar then lets the - // user verify and Publish in one click. If already in preview, the - // sticky provider keeps the flag, so no suffix is needed. - if (createdId) { - const previewSuffix = previewDrafts - ? '' - : `?${PREVIEW_QUERY_FLAG}=${PREVIEW_QUERY_VALUE}`; - if (viewId) { - navigate(`../${createdId}${previewSuffix}`, { relative: 'path' }); - } else { - navigate(`view/${createdId}${previewSuffix}`); - } - } - return true; - } catch (err) { - console.error('[ViewConfigPanel] Failed to create view:', err); - // objectui#11578: a refused save is SAID, with the door's own - // message (the field-anchored issues of a 422, or the refusal's - // text), and reported to the dialog as unsaved so it stays open. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - return false; - } - }, [dataSource, objectName, objects, navigate, viewId, metadataClient, previewDrafts, t]); // Record count tracking for footer const [recordCount, setRecordCount] = useState(undefined); @@ -786,221 +629,49 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co setSearchParams, }); - /** Real-time draft field update — propagates each toggle/input change immediately */ - const handleViewUpdate = useCallback((field: string, value: any) => { - setViewDraft(prev => ({ - ...(prev || {}), - id: baseView?.id, - [field]: value, - })); - }, [baseView?.id]); - - const handleViewChange = (newViewId: string) => { - // The plugin ObjectView returns the view ID directly via onViewChange - const matchedView = views.find((v: any) => v.id === newViewId); - if (!matchedView) return; - // Auto-close the config panel only when actually switching to a - // different view. Same-view clicks (e.g., bubbling from the actions - // dropdown menu item) must not stomp on a freshly-opened panel. - if (matchedView.id !== activeViewId) { - setShowViewConfigPanel(false); - } - if (viewId) { - navigate(`../${matchedView.id}`, { relative: "path" }); - } else { - navigate(`view/${matchedView.id}`); - } - }; - - // Mobile view switcher — registers our view list with the AppHeader so - // the topbar can render a ` ▾` dropdown instead of the static - // page label. Desktop ignores this (ViewTabBar handles switching there). - const mobileViewSwitcherItems = useMemo(() => { - return (views || []).map((view: any) => { - const Icon = VIEW_TYPE_ICONS[view.type as keyof typeof VIEW_TYPE_ICONS]; - return { - id: view.id, - label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), - icon: Icon ? : undefined, - }; - }); - }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]); - useMobileViewSwitcherRegistration({ - views: mobileViewSwitcherItems, - activeViewId: activeViewId ?? '', - onChange: handleViewChange, - enabled: mobileViewSwitcherItems.length > 0 && !!activeViewId, + // objectstack#25992 slice 11b: the view-config save and create handlers, view switching, + // the mobile view switcher and the view-tab CRUD callbacks moved to `./objectViewViewCrud.tsx`. + const { + handleViewConfigSave, + handleViewCreate, + handleViewUpdate, + handleViewChange, + handleCreateView, + handleViewAction, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleReorderViews, + handleConfigView, + handleAddView, + } = useObjectViewViewCrud({ + dataSource, + objects, + navigate, + objectName, + viewId, + t, + language, + viewLabel, + servedViews, + metadataClient, + previewDrafts, + setShowViewConfigPanel, + setViewConfigPanelMode, + setShowCreateViewDialog, + setViewDraft, + objectDef, + unlabelledViewText, + setRefreshKey, + confirmHandler, + savedViews, + listPrefs, + views, + activeViewId, + baseView, }); - // ViewSwitcher callbacks — wired to both PluginObjectView instances - const handleCreateView = useCallback(() => { - setShowCreateViewDialog(true); - }, []); - - const handleViewAction = useCallback((actionType: string, viewType: string) => { - if (actionType === 'settings') { - // `type` is optional on a view entry until the grid-like default - // pass fills it in, so the predicate must tolerate its absence. - const matchedView = views.find((v: { id: string; type?: string }) => v.type === viewType); - if (matchedView) handleViewChange(matchedView.id); - setViewConfigPanelMode('edit'); - setShowViewConfigPanel(true); - } - }, [views, handleViewChange]); - - // ─── ViewTabBar CRUD callbacks (Phase 2) ──────────────────────────── - /** Returns true if the view is backed by a sys_view record (mutable). */ - const isSavedView = useCallback((vid: string) => { - return isSavedViewId(savedViews, vid); - }, [savedViews]); - - const handleRenameView = useCallback(async (vid: string, newName: string) => { - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - try { - // Metadata overlay path — `vid` is the view's `name` field. - if (typeof (dataSource as any)?.updateView === 'function') { - await (dataSource as any).updateView(objectName, vid, { label: newName }); - } - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to rename view:', err); - // objectui#12417: a refused rename is said with the door's own - // message, through the channel objectui#11583 gave the view saves - // and objectui#12398 gave Delete view. - toast.error(t('objectViewActions.renameFailed'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, objectName, isSavedView, t]); - - const handleDeleteView = useCallback(async (vid: string) => { - if (!dataSource) return; - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotDeleteMetaView')); - return; - } - const targetView = views.find((v: any) => v.id === vid); - const viewLabel = targetView?.label || vid; - const confirmed = await confirmHandler( - t('console.objectView.deleteViewConfirm', { name: viewLabel }), - { - title: t('console.objectView.deleteViewTitle'), - confirmText: t('console.objectView.delete'), - cancelText: t('console.objectView.cancel'), - }, - ); - if (!confirmed) return; - try { - if (typeof (dataSource as any)?.deleteView === 'function') { - await (dataSource as any).deleteView(objectName, vid); - } - // If we deleted the active view, fall back to the first remaining view. - if (vid === activeViewId) { - const fallback = views.find((v: any) => v.id !== vid); - if (fallback) navigate(viewId ? `../${fallback.id}` : `view/${fallback.id}`, viewId ? { relative: 'path' } : undefined); - } - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to delete view:', err); - // objectui#12398: a refused delete is said with the door's own - // message, through the channel objectui#11583 gave the view saves - // (a 409 for a view two packages hold names its remedy there). - toast.error(t('objectViewActions.deleteFailed'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]); - - const handlePinView = useCallback(async (vid: string, pinned: boolean) => { - if (!dataSource) return; - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - try { - if (typeof (dataSource as any)?.updateView === 'function') { - await (dataSource as any).updateView(objectName, vid, { isPinned: pinned }); - } - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to pin view:', err); - // objectui#11583: a refused pin is said, with the door's message. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, objectName, isSavedView, t]); - - const handleSetDefaultView = useCallback(async (vid: string) => { - // `objectName` is narrowed here for the same reason the override effect - // above narrows it: the adapter keys its cache invalidation on a real - // object name, so passing `undefined` through would write to the server - // and then fail to invalidate what it just changed. - if (!dataSource || !objectName) return; - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - try { - // Clear `isDefault` on all other saved views, then set this one. - if (typeof (dataSource as any)?.updateView !== 'function') return; - await Promise.all( - dispatchViewPatches( - dataSource, - objectName, - setDefaultViewPatches(savedViews, vid), - ), - ); - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to set default view:', err); - // objectui#11583: said like its siblings, with the door's message. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, objectName, savedViews, isSavedView, t]); - - /** - * A drag in Manage Views reorders the tabs FOR THIS USER (objectui#12418). - * - * It used to write `viewOrder:` to this browser and then PATCH a - * `sortOrder` onto every saved view's shared row, "so other sessions / - * users can pick up the order": one user's drag reordered the tabs for - * everyone, and under a walled posture the metadata write was refused. The - * order is now the user's preference (the `ui.list.` row, through - * `listPrefs`), and nothing here writes `/meta/view`. The authored order — - * the declared sequence, then a stored `sortOrder` — stays every user's - * default, and the view editor is where an author changes it. - */ - const handleReorderViews = useCallback((orderedIds: string[]) => { - listPrefs.setViewOrder(orderedIds); - }, [listPrefs.setViewOrder]); - - const handleConfigView = useCallback((vid: string) => { - // System (metadata-defined) views are read-only — opening the - // ViewConfigPanel against one would let the user save changes that - // never persist. - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - if (vid !== activeViewId) handleViewChange(vid); - setViewConfigPanelMode('edit'); - setShowViewConfigPanel(true); - }, [activeViewId, handleViewChange, isSavedView, t]); - - const handleAddView = useCallback(() => { - setShowCreateViewDialog(true); - }, []); - // Current user — also used below for `{current_user_id}` filter-token // substitution. The schema-action handlers (toast/navigate/api/flow/server) // now live in the shared `useConsoleActionRuntime` hook (declared earlier). diff --git a/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts b/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts index e506feb6079..4af7e40e277 100644 --- a/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts @@ -188,10 +188,10 @@ describe('set as default (#4463)', () => { describe('the detach-then-call pattern stays gone (ratchet)', () => { it('ObjectView.tsx never assigns an adapter method to a local', () => { - const source = readFileSync( - fileURLToPath(new URL('./ObjectView.tsx', import.meta.url)), + const source = ['./ObjectView.tsx', './objectViewViewCrud.tsx'].map((file) => readFileSync( + fileURLToPath(new URL(file, import.meta.url)), 'utf8', - ); + )).join('\n'); // Comments are stripped FIRST, and that is not a detail: the fix's own // docblock quotes the broken line verbatim to explain it, so a scan over the diff --git a/ui/packages/app-shell/src/views/objectViewViewCrud.tsx b/ui/packages/app-shell/src/views/objectViewViewCrud.tsx new file mode 100644 index 00000000000..43b07abccb5 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewViewCrud.tsx @@ -0,0 +1,459 @@ +/** + * The view management handlers of the Console `ObjectView` (`./ObjectView.tsx`): + * the view-config panel's save and create doors, the draft field update, view + * switching, the mobile view switcher registration, and the view-tab CRUD + * callbacks (rename, delete, pin, set default, reorder, configure, add), moved + * out of `ObjectViewInner` verbatim (objectstack#25992, slice 11b). The panel and + * dialog state stays in the component; every input is passed in, and the + * component reads the returned handlers. + */ + +import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'; +import type { useNavigate } from 'react-router-dom'; +import { formatMetadataError } from '@object-ui/data-objectstack'; +import { toast } from 'sonner'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import type { useObjectListPreferences } from '../context/ListPreferencesProvider.js'; +import { PREVIEW_QUERY_FLAG, PREVIEW_QUERY_VALUE } from '../preview/PreviewModeContext.js'; +import type { usePreviewDrafts } from '../preview/PreviewModeContext.js'; +import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext.js'; +import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext.js'; +import { isSavedViewId } from '../utils/viewIdentity.js'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import type { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; +import type { useMetadataClient } from './metadata-admin/useMetadata.js'; +import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope } from './runtime-metadata-persistence.js'; +import { VIEW_TYPE_ICONS } from './objectViewHelpers.js'; +import { buildAddViewSpec, setDefaultViewPatches, buildViewConfigSaveBody } from './objectViewTabs.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import { dispatchViewPatches } from './objectViewOptions.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewViewCrud({ + dataSource, + objects, + navigate, + objectName, + viewId, + t, + language, + viewLabel, + servedViews, + metadataClient, + previewDrafts, + setShowViewConfigPanel, + setViewConfigPanelMode, + setShowCreateViewDialog, + setViewDraft, + objectDef, + unlabelledViewText, + setRefreshKey, + confirmHandler, + savedViews, + listPrefs, + views, + activeViewId, + baseView, +}: { + dataSource: ConsoleObjectViewProps['dataSource']; + objects: ConsoleObjectViewProps['objects']; + navigate: ReturnType; + objectName: string | undefined; + viewId: string | undefined; + t: ReturnType['t']; + language: ReturnType['language']; + viewLabel: ReturnType['viewLabel']; + servedViews: ReturnType; + metadataClient: ReturnType; + previewDrafts: ReturnType; + setShowViewConfigPanel: Dispatch>; + setViewConfigPanelMode: Dispatch>; + setShowCreateViewDialog: Dispatch>; + setViewDraft: Dispatch[number]> | null>>; + objectDef: ConsoleObjectViewProps['objects'][number]; + unlabelledViewText: string; + setRefreshKey: Dispatch>; + confirmHandler: ReturnType['confirmHandler']; + savedViews: Parameters[0]['savedViews']; + listPrefs: ReturnType; + views: ReturnType; + activeViewId: string | undefined; + baseView: ReturnType[number]; +}) { + // objectui#12504 — no toolbar control writes the shared view. The sort and + // the column layout (objectui#12418), the density and the Hide Fields + // popover (objectui#12474) are the user's own list state (`listPrefs`), and + // the shared view is written only by an explicit save + // (`handleViewConfigSave`, below). The toolbar-to-view writer that lived + // here, `persistViewPatch`, retired with the adapter method it was the last + // caller of. ⛔ Do not bring one back: `ObjectView.overlayPatchOnly.test.ts` + // pins its absence. + + /* + * There is deliberately NO `persistViewFilter` here (objectui#4155). + * + * The list toolbar's filter panel is the USER's state — it belongs to the + * session and to that user's own list state (`listPrefs`, objectui#12418), + * not to the view's stored body. It used to persist: every `onFilterChange` the panel + * emitted was folded and written to the view overlay, so merely opening the + * panel and clicking `Add filter` wrote an incomplete `field equals ''` + * condition into `sys_metadata` — which then REPLACED the source-declared + * `filter` for every user of that view and emptied the list, unrecoverably + * from the panel itself. + * + * The threshold for writing a view overlay is an EXPLICIT save: + * `handleViewConfigSave` (the view config panel) and `handleSaveAsView` + * (`ObjectDataPage`). `ObjectDataPage` already made this choice for the + * sibling surface — "Deliberately NO onSortChange/onFilterChange + * persistence hooks: this surface never writes back to any saved view" + * (#2251) — and this surface was the outlier. + * + * `foldFilterGroupToSpecRules` (objectstack#5159) is still the one dialect + * for those explicit paths — the Studio inspector's `FilterBuilderField` + * folds through it. What is gone is the automatic write, not the fold. + */ + + /** + * The view-config panel's edit Save. Resolves whether the draft was staged, + * so the panel stays dirty, and announces no draft, on anything but `true` + * (objectui#11583). + */ + const handleViewConfigSave = useCallback(async (draft: Record): Promise => { + setViewDraft(draft); + setRefreshKey(k => k + 1); + + // ADR-0034: stage a per-item draft via the metadata seam; an explicit + // Publish (RuntimeDraftBar) promotes it + records a version. + const vid = draft.id; + if (!metadataClient || !vid) { + console.warn('[ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId.'); + return false; + } + try { + // `dataSource` + `objectName` let the seam drop this object's view + // cache keys (#4373) — the adapter owns which keys those are. + await persistRuntimeMetadata('view', vid, buildViewConfigSaveBody(objectName, draft), { + metadataClient, + dataSource, + objectName, + }); + return true; + } catch (err) { + console.error('[ViewConfigPanel] Failed to persist view config:', err); + // objectui#11583: a refused save is SAID, with the door's own + // message, as the Create View doors do (objectui#11578). + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + return false; + } + }, [metadataClient, dataSource, objectName, t]); + + /** + * Create a new view: the Create View dialog's door and the view-config + * panel's create mode. Resolves whether the view was saved, so the dialog + * closes only on `true` (objectui#11578). + */ + const handleViewCreate = useCallback(async (config: Record): Promise => { + try { + let createdId: string | undefined; + if (metadataClient) { + // ADR-0005 overlay path — write the full spec under a unique + // `name` via the metadata customization API instead of into + // the physical `sys_view` table (whose columns no longer + // accommodate the spec shape: arrays, nested objects, etc.). + // The spec comes from the builder "Save as view" also uses + // (`buildAddViewSpec` → `buildNewViewSpec`, objectui#11581): + // this door supplies only its default business columns. + const objectDef = objects?.find?.((o: any) => o.name === objectName); + const spec = buildAddViewSpec(config, objectDef); + // ADR-0034: a new view is created as an invisible per-item + // draft via the metadata seam; an explicit Publish promotes it. + // The auto-activation below stays here; the canonical ViewItem + // envelope + qualified-name identity live in the seam. + // + // #2767 P1: the qualified name `.` is used as BOTH + // the URL segment and `body.name`, so the sys_metadata row key, + // the ViewTabBar tab id, and the body identity all agree and the + // draft → read → publish loop resolves. + const env = viewEnvelope(objectName, spec, { + name: (config as any)?.name, + label: config.label, + }); + createdId = await createRuntimeMetadata('view', env.name, env, { + metadataClient, + // #4373: this flow is the second door into the same rows the + // adapter caches. Hand it the adapter (the `dataSource` prop + // IS the `ObjectStackAdapter`, via `AppContent`'s + // `useAdapter()`) so the seam can drop the object's view keys. + dataSource, + objectName, + }); + } + setShowViewConfigPanel(false); + setViewConfigPanelMode('edit'); + setRefreshKey(k => k + 1); + // Auto-activate the newly created view (Airtable parity), and close + // the main-path loop (#2767 P4): a fresh view is a DRAFT, invisible + // to a normal (published-only) read, so navigating there plainly + // would land on "view not found". Enter draft-preview mode + // (`?preview=draft`) instead — the DraftPreviewBar then lets the + // user verify and Publish in one click. If already in preview, the + // sticky provider keeps the flag, so no suffix is needed. + if (createdId) { + const previewSuffix = previewDrafts + ? '' + : `?${PREVIEW_QUERY_FLAG}=${PREVIEW_QUERY_VALUE}`; + if (viewId) { + navigate(`../${createdId}${previewSuffix}`, { relative: 'path' }); + } else { + navigate(`view/${createdId}${previewSuffix}`); + } + } + return true; + } catch (err) { + console.error('[ViewConfigPanel] Failed to create view:', err); + // objectui#11578: a refused save is SAID, with the door's own + // message (the field-anchored issues of a 422, or the refusal's + // text), and reported to the dialog as unsaved so it stays open. + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + return false; + } + }, [dataSource, objectName, objects, navigate, viewId, metadataClient, previewDrafts, t]); + + /** Real-time draft field update — propagates each toggle/input change immediately */ + const handleViewUpdate = useCallback((field: string, value: any) => { + setViewDraft(prev => ({ + ...(prev || {}), + id: baseView?.id, + [field]: value, + })); + }, [baseView?.id]); + + const handleViewChange = (newViewId: string) => { + // The plugin ObjectView returns the view ID directly via onViewChange + const matchedView = views.find((v: any) => v.id === newViewId); + if (!matchedView) return; + // Auto-close the config panel only when actually switching to a + // different view. Same-view clicks (e.g., bubbling from the actions + // dropdown menu item) must not stomp on a freshly-opened panel. + if (matchedView.id !== activeViewId) { + setShowViewConfigPanel(false); + } + if (viewId) { + navigate(`../${matchedView.id}`, { relative: "path" }); + } else { + navigate(`view/${matchedView.id}`); + } + }; + + // Mobile view switcher — registers our view list with the AppHeader so + // the topbar can render a ` ▾` dropdown instead of the static + // page label. Desktop ignores this (ViewTabBar handles switching there). + const mobileViewSwitcherItems = useMemo(() => { + return (views || []).map((view: any) => { + const Icon = VIEW_TYPE_ICONS[view.type as keyof typeof VIEW_TYPE_ICONS]; + return { + id: view.id, + label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + icon: Icon ? : undefined, + }; + }); + }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]); + useMobileViewSwitcherRegistration({ + views: mobileViewSwitcherItems, + activeViewId: activeViewId ?? '', + onChange: handleViewChange, + enabled: mobileViewSwitcherItems.length > 0 && !!activeViewId, + }); + + // ViewSwitcher callbacks — wired to both PluginObjectView instances + const handleCreateView = useCallback(() => { + setShowCreateViewDialog(true); + }, []); + + const handleViewAction = useCallback((actionType: string, viewType: string) => { + if (actionType === 'settings') { + // `type` is optional on a view entry until the grid-like default + // pass fills it in, so the predicate must tolerate its absence. + const matchedView = views.find((v: { id: string; type?: string }) => v.type === viewType); + if (matchedView) handleViewChange(matchedView.id); + setViewConfigPanelMode('edit'); + setShowViewConfigPanel(true); + } + }, [views, handleViewChange]); + + // ─── ViewTabBar CRUD callbacks (Phase 2) ──────────────────────────── + /** Returns true if the view is backed by a sys_view record (mutable). */ + const isSavedView = useCallback((vid: string) => { + return isSavedViewId(savedViews, vid); + }, [savedViews]); + + const handleRenameView = useCallback(async (vid: string, newName: string) => { + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + try { + // Metadata overlay path — `vid` is the view's `name` field. + if (typeof (dataSource as any)?.updateView === 'function') { + await (dataSource as any).updateView(objectName, vid, { label: newName }); + } + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to rename view:', err); + // objectui#12417: a refused rename is said with the door's own + // message, through the channel objectui#11583 gave the view saves + // and objectui#12398 gave Delete view. + toast.error(t('objectViewActions.renameFailed'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, objectName, isSavedView, t]); + + const handleDeleteView = useCallback(async (vid: string) => { + if (!dataSource) return; + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotDeleteMetaView')); + return; + } + const targetView = views.find((v: any) => v.id === vid); + const viewLabel = targetView?.label || vid; + const confirmed = await confirmHandler( + t('console.objectView.deleteViewConfirm', { name: viewLabel }), + { + title: t('console.objectView.deleteViewTitle'), + confirmText: t('console.objectView.delete'), + cancelText: t('console.objectView.cancel'), + }, + ); + if (!confirmed) return; + try { + if (typeof (dataSource as any)?.deleteView === 'function') { + await (dataSource as any).deleteView(objectName, vid); + } + // If we deleted the active view, fall back to the first remaining view. + if (vid === activeViewId) { + const fallback = views.find((v: any) => v.id !== vid); + if (fallback) navigate(viewId ? `../${fallback.id}` : `view/${fallback.id}`, viewId ? { relative: 'path' } : undefined); + } + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to delete view:', err); + // objectui#12398: a refused delete is said with the door's own + // message, through the channel objectui#11583 gave the view saves + // (a 409 for a view two packages hold names its remedy there). + toast.error(t('objectViewActions.deleteFailed'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]); + + const handlePinView = useCallback(async (vid: string, pinned: boolean) => { + if (!dataSource) return; + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + try { + if (typeof (dataSource as any)?.updateView === 'function') { + await (dataSource as any).updateView(objectName, vid, { isPinned: pinned }); + } + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to pin view:', err); + // objectui#11583: a refused pin is said, with the door's message. + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, objectName, isSavedView, t]); + + const handleSetDefaultView = useCallback(async (vid: string) => { + // `objectName` is narrowed here for the same reason the override effect + // above narrows it: the adapter keys its cache invalidation on a real + // object name, so passing `undefined` through would write to the server + // and then fail to invalidate what it just changed. + if (!dataSource || !objectName) return; + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + try { + // Clear `isDefault` on all other saved views, then set this one. + if (typeof (dataSource as any)?.updateView !== 'function') return; + await Promise.all( + dispatchViewPatches( + dataSource, + objectName, + setDefaultViewPatches(savedViews, vid), + ), + ); + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to set default view:', err); + // objectui#11583: said like its siblings, with the door's message. + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, objectName, savedViews, isSavedView, t]); + + /** + * A drag in Manage Views reorders the tabs FOR THIS USER (objectui#12418). + * + * It used to write `viewOrder:` to this browser and then PATCH a + * `sortOrder` onto every saved view's shared row, "so other sessions / + * users can pick up the order": one user's drag reordered the tabs for + * everyone, and under a walled posture the metadata write was refused. The + * order is now the user's preference (the `ui.list.` row, through + * `listPrefs`), and nothing here writes `/meta/view`. The authored order — + * the declared sequence, then a stored `sortOrder` — stays every user's + * default, and the view editor is where an author changes it. + */ + const handleReorderViews = useCallback((orderedIds: string[]) => { + listPrefs.setViewOrder(orderedIds); + }, [listPrefs.setViewOrder]); + + const handleConfigView = useCallback((vid: string) => { + // System (metadata-defined) views are read-only — opening the + // ViewConfigPanel against one would let the user save changes that + // never persist. + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + if (vid !== activeViewId) handleViewChange(vid); + setViewConfigPanelMode('edit'); + setShowViewConfigPanel(true); + }, [activeViewId, handleViewChange, isSavedView, t]); + + const handleAddView = useCallback(() => { + setShowCreateViewDialog(true); + }, []); + + return { + handleViewConfigSave, + handleViewCreate, + handleViewUpdate, + handleViewChange, + handleCreateView, + handleViewAction, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleReorderViews, + handleConfigView, + handleAddView, + }; +} diff --git a/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts b/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts index 82dc539d393..27061f0fae0 100644 --- a/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts +++ b/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts @@ -43,7 +43,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const objectViewPath = path.join(here, 'ObjectView.tsx'); const widgetsPath = path.join(here, 'metadata-admin/widgets.tsx'); -const objectViewSrc = [objectViewPath, path.join(here, 'objectViewListRenderer.tsx')].map((file) => readFileSync(file, 'utf8')).join('\n'); +const objectViewSrc = [objectViewPath, path.join(here, 'objectViewListRenderer.tsx'), path.join(here, 'objectViewViewCrud.tsx')].map((file) => readFileSync(file, 'utf8')).join('\n'); const widgetsSrc = readFileSync(widgetsPath, 'utf8'); /** diff --git a/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts b/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts index 175181f9384..4fee778d117 100644 --- a/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts +++ b/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts @@ -88,32 +88,32 @@ const LEDGER: Row[] = [ reason: 'Issues one `updateView` per patch and hands every promise back; set-as-default and reorder await them and answer for the refusal.', }, { - file: 'ObjectView.tsx', symbol: 'handleViewConfigSave', callee: 'persistRuntimeMetadata', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleViewConfigSave', callee: 'persistRuntimeMetadata', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'The view-config panel\'s edit Save: a toast with the door\'s message, and the panel is told the save failed, so it stays dirty (objectui#11583).', }, { - file: 'ObjectView.tsx', symbol: 'handleViewCreate', callee: 'createRuntimeMetadata', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleViewCreate', callee: 'createRuntimeMetadata', calls: 1, kind: 'surfaces', pin: 'ObjectView.createViewRefusal-11578.test.tsx', reason: 'Create View: a toast with the door\'s message, and the dialog stays open (objectui#11578).', }, { - file: 'ObjectView.tsx', symbol: 'handleRenameView', callee: 'updateView', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleRenameView', callee: 'updateView', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Rename: the translated `objectViewActions.renameFailed` title, with the door\'s message as the description (objectui#12417).', }, { - file: 'ObjectView.tsx', symbol: 'handleDeleteView', callee: 'deleteView', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleDeleteView', callee: 'deleteView', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Delete: the translated `objectViewActions.deleteFailed` title, with the door\'s message as the description (objectui#12398).', }, { - file: 'ObjectView.tsx', symbol: 'handlePinView', callee: 'updateView', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handlePinView', callee: 'updateView', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Pin and unpin: a toast with the door\'s message (objectui#11583).', }, { - file: 'ObjectView.tsx', symbol: 'handleSetDefaultView', callee: 'dispatchViewPatches', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleSetDefaultView', callee: 'dispatchViewPatches', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Set as default: a toast with the door\'s message; it was a bare untranslated literal with no description until the objectui#11583 patch round.', }, diff --git a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts index c2d0e4b0333..55eda0eec34 100644 --- a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts +++ b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts @@ -1088,7 +1088,7 @@ export const A = (name: string, label: string, n: number) => { "t('marketplace.detail.reseedLocalSuccess', { inserted, updated })", ], [ - 'packages/app-shell/src/views/ObjectView.tsx', + 'packages/app-shell/src/views/objectViewViewCrud.tsx', // Tight on purpose: the file still holds a legitimate *computed* // `defaultValue` ending in the same sentence (line ~1657), and a marker // loose enough to catch that one would fail for the wrong reason.