diff --git a/.changeset/action-modal-transport.md b/.changeset/action-modal-transport.md new file mode 100644 index 000000000..1acc953d7 --- /dev/null +++ b/.changeset/action-modal-transport.md @@ -0,0 +1,7 @@ +--- +"@object-ui/app-shell": minor +--- + +Action modal transport with placement (SDUI opt #2). + +`useActionModal` provides a reusable `onModal` handler that renders an action's modal envelope in the right container by `placement`: `center` (Dialog), `side` (Sheet), `bottom` (Drawer), `fullscreen`. `content` is an arbitrary SchemaNode rendered via `SchemaRenderer`, so a modal action can open any page/form/list; string targets / `{objectName, mode}` keep opening a `ModalForm`. Wired into `RecordDetailView` so `type:'modal'` actions open client-side (previously routed to a server POST). diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index c1ccd4f09..a407a8174 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -17,6 +17,7 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-route import { AuthProvider, AuthGuard, useAuth } from '@object-ui/auth'; import { DevMasterDetail } from './dev/DevMasterDetail'; import { DevLists } from './dev/DevLists'; +import { DevModal } from './dev/DevModal'; import { ConsoleShell, ConnectedShell, @@ -193,6 +194,12 @@ export function App() { } /> + {/* Dev-only: action modal transport (center/side/bottom/fullscreen). */} + + + + } /> diff --git a/apps/console/src/dev/DevModal.tsx b/apps/console/src/dev/DevModal.tsx new file mode 100644 index 000000000..a2efe487a --- /dev/null +++ b/apps/console/src/dev/DevModal.tsx @@ -0,0 +1,50 @@ +/** + * Dev-only harness for the action modal transport (SDUI opt #2): + * one handler, four placements (center / side / bottom / fullscreen), each + * rendering arbitrary SchemaNode content. Not part of the product nav. + */ +import React from 'react'; +import { Button } from '@object-ui/components'; +import { useActionModal } from '@object-ui/app-shell'; + +const content = { + type: 'element:definition-list', + properties: { + columns: 2, + items: [ + { term: 'Status', description: 'Active' }, + { term: 'Owner', description: 'Ada Lovelace' }, + { term: 'Plan', description: 'Enterprise' }, + { term: 'Renewal', description: '2026-12-01' }, + ], + }, +}; + +export const DevModal: React.FC = () => { + const { modalHandler, modalElement } = useActionModal(); + return ( +
+

Dev · Action modal transport

+

+ One onModal handler, four placements, arbitrary SchemaNode content. +

+
+ + + + +
+ {modalElement} +
+ ); +}; + +export default DevModal; diff --git a/packages/app-shell/src/hooks/__tests__/useActionModal.test.ts b/packages/app-shell/src/hooks/__tests__/useActionModal.test.ts new file mode 100644 index 000000000..66594dcdc --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useActionModal.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeModalSchema } from '../useActionModal'; + +describe('normalizeModalSchema', () => { + it('maps create_/new_/add_ string targets to a create object-form', () => { + expect(normalizeModalSchema('create_opportunity')).toEqual({ objectName: 'opportunity', mode: 'create' }); + expect(normalizeModalSchema('new_task')).toEqual({ objectName: 'task', mode: 'create' }); + expect(normalizeModalSchema('add_note')).toEqual({ objectName: 'note', mode: 'create' }); + }); + + it('maps edit_/update_ string targets to an edit object-form', () => { + expect(normalizeModalSchema('edit_account')).toEqual({ objectName: 'account', mode: 'edit' }); + expect(normalizeModalSchema('update_lead')).toEqual({ objectName: 'lead', mode: 'edit' }); + }); + + it('treats a bare string as a create form for that object', () => { + expect(normalizeModalSchema('contact')).toEqual({ objectName: 'contact', mode: 'create' }); + }); + + it('treats a bare SchemaNode (has type, no descriptor keys) as content', () => { + const node = { type: 'element:definition-list', properties: { items: [] } }; + expect(normalizeModalSchema(node)).toEqual({ content: node }); + }); + + it('passes a modal descriptor through unchanged', () => { + const desc = { placement: 'side', title: 'Details', content: { type: 'x' } }; + expect(normalizeModalSchema(desc)).toBe(desc); + }); + + it('keeps an object-form descriptor (objectName) as-is', () => { + const desc = { objectName: 'task', mode: 'edit', recordId: '1' }; + expect(normalizeModalSchema(desc)).toBe(desc); + }); +}); diff --git a/packages/app-shell/src/hooks/index.ts b/packages/app-shell/src/hooks/index.ts index 7895766a6..c983b86f0 100644 --- a/packages/app-shell/src/hooks/index.ts +++ b/packages/app-shell/src/hooks/index.ts @@ -1,4 +1,5 @@ export { useFavorites, type FavoriteItem } from './useFavorites'; +export { useActionModal, type ModalDescriptor } from './useActionModal'; export { useMetadataService } from './useMetadataService'; export { useNavPins } from './useNavPins'; export { diff --git a/packages/app-shell/src/hooks/useActionModal.tsx b/packages/app-shell/src/hooks/useActionModal.tsx new file mode 100644 index 000000000..606909f09 --- /dev/null +++ b/packages/app-shell/src/hooks/useActionModal.tsx @@ -0,0 +1,210 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * useActionModal — a reusable `onModal` handler for the ActionProvider that + * renders an action's modal envelope in the right container by `placement`: + * + * placement: 'center' → Dialog (sized sm|default|lg|xl) + * placement: 'fullscreen' → Dialog, near-viewport + * placement: 'side' → Sheet (right|left) + * placement: 'bottom' → Drawer (bottom sheet) + * + * `content` is an arbitrary SchemaNode rendered via , so a + * modal action can open any page/form/list. Back-compat: a string target + * (e.g. "create_opportunity") or `{ objectName, mode }` opens a . + * + * Returns `{ modalHandler, modalElement }`: pass `modalHandler` as the + * ActionProvider `onModal`, and render `modalElement` once in the subtree. + */ +import React, { useCallback, useState } from 'react'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + cn, +} from '@object-ui/components'; +import { SchemaRenderer } from '@object-ui/react'; +import { ModalForm } from '@object-ui/plugin-form'; + +type Placement = 'center' | 'side' | 'bottom' | 'fullscreen'; +type ModalSize = 'sm' | 'default' | 'lg' | 'xl' | 'full'; + +export interface ModalDescriptor { + placement?: Placement; + side?: 'left' | 'right'; + size?: ModalSize; + title?: string; + description?: string; + /** Arbitrary SchemaNode rendered inside the chosen container. */ + content?: any; + /** Back-compat: open an object form. */ + objectName?: string; + mode?: string; + recordId?: string; + fields?: any; +} + +type ActionResult = { success: boolean; reload?: boolean; data?: any; [k: string]: any }; + +const SIZE_CLASS: Record = { + sm: 'sm:max-w-sm', + default: 'sm:max-w-lg', + lg: 'sm:max-w-2xl', + xl: 'sm:max-w-5xl', + full: 'sm:max-w-[95vw] sm:w-full', +}; +const SIDE_SIZE_CLASS: Partial> = { + lg: 'sm:max-w-2xl', + xl: 'sm:max-w-3xl', + full: 'sm:max-w-[95vw]', +}; + +/** Normalize the opaque `schema` arg the ActionRunner passes into a descriptor. */ +export function normalizeModalSchema(schema: any): ModalDescriptor { + if (typeof schema === 'string') { + const m = schema.match(/^(create|new|add|edit|update)_(.+)$/); + if (m) return { objectName: m[2], mode: m[1] === 'edit' || m[1] === 'update' ? 'edit' : 'create' }; + return { objectName: schema, mode: 'create' }; + } + if (schema && typeof schema === 'object') { + // A bare SchemaNode (has `type` but isn't a modal descriptor) → render as content. + if (schema.type && !schema.content && !schema.objectName && !schema.placement) { + return { content: schema }; + } + return schema as ModalDescriptor; + } + return {}; +} + +export function useActionModal(dataSource?: any) { + const [state, setState] = useState<{ d: ModalDescriptor; resolve: (r: ActionResult) => void } | null>(null); + + const close = useCallback((r: ActionResult) => { + setState((s) => { + s?.resolve(r); + return null; + }); + }, []); + + const modalHandler = useCallback( + (schema: any) => + new Promise((resolve) => { + setState({ d: normalizeModalSchema(schema), resolve }); + }), + [], + ); + + let modalElement: React.ReactNode = null; + if (state) { + const d = state.d; + const onOpenChange = (open: boolean) => { + if (!open) close({ success: false }); + }; + + if (d.objectName && !d.content) { + modalElement = ( + close({ success: true, reload: true, data }), + onCancel: () => close({ success: false }), + showSubmit: true, + showCancel: true, + }} + dataSource={dataSource} + /> + ); + } else { + const placement: Placement = d.placement || 'center'; + const body = d.content ? ( + + ) : d.description ? ( +

{d.description}

+ ) : null; + + if (placement === 'side') { + modalElement = ( + + + {d.title && ( + + {d.title} + {d.description && {d.description}} + + )} +
{body}
+
+
+ ); + } else if (placement === 'bottom') { + modalElement = ( + + + {d.title && ( + + {d.title} + {d.description && {d.description}} + + )} +
{body}
+
+
+ ); + } else { + modalElement = ( + + + {d.title && ( + + {d.title} + {d.description && {d.description}} + + )} +
{body}
+ {!d.content && ( +
+ +
+ )} +
+
+ ); + } + } + } + + return { modalHandler, modalElement, closeModal: close }; +} diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index dc253ffb1..e5115390e 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -122,6 +122,7 @@ export { navigationEqual, generateNavId, useResponsiveSidebar, + useActionModal, } from './hooks'; export type { FavoriteItem } from './hooks'; diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 0734f60fc..12f43b07d 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -32,6 +32,7 @@ import type { ActionDef, ActionParamDef } from '@object-ui/core'; import { useRecordApprovals } from '../hooks/useRecordApprovals'; import { getRecordDisplayName } from '../utils'; import { useFavorites } from '../hooks/useFavorites'; +import { useActionModal } from '../hooks/useActionModal'; import { useRecentItems } from '../hooks/useRecentItems'; interface RecordDetailViewProps { @@ -373,6 +374,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } }, [dataSource, objectName, pureRecordId]); + // Client-side modal transport: `type:'modal'` actions open here (Dialog / + // Sheet / Drawer by `placement`) and render arbitrary SchemaNode content. + const { modalHandler, modalElement } = useActionModal(dataSource); + // Authenticated fetch for direct backend calls (e.g. flow trigger). const authFetch = useMemo(() => createAuthenticatedFetch(), []); @@ -1649,7 +1654,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri onNavigate={navigateHandler} onParamCollection={paramCollectionHandler} onResultDialog={resultDialogHandler} - handlers={{ api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }} + onModal={modalHandler} + handlers={{ api: apiHandler, flow: flowHandler, script: serverActionHandler, approval: approvalHandler }} >
@@ -1693,6 +1699,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri sections={[{ title: 'Page Schema', data: renderedPage }]} />
+ {modalElement} @@ -1757,7 +1764,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri onNavigate={navigateHandler} onParamCollection={paramCollectionHandler} onResultDialog={resultDialogHandler} - handlers={{ api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }} + onModal={modalHandler} + handlers={{ api: apiHandler, flow: flowHandler, script: serverActionHandler, approval: approvalHandler }} > } /> + {modalElement}