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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/action-modal-transport.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 7 additions & 0 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -193,6 +194,12 @@ export function App() {
<DevLists />
</ProtectedRoute>
} />
{/* Dev-only: action modal transport (center/side/bottom/fullscreen). */}
<Route path="/dev/modal" element={
<ProtectedRoute>
<DevModal />
</ProtectedRoute>
} />
<Route path="/organizations" element={
<ProtectedRoute requireOrganization={false}>
<DefaultOrganizationsLayout><DefaultOrganizationsPage /></DefaultOrganizationsLayout>
Expand Down
50 changes: 50 additions & 0 deletions apps/console/src/dev/DevModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4 p-6">
<h1 className="text-lg font-semibold">Dev · Action modal transport</h1>
<p className="text-sm text-muted-foreground">
One <code>onModal</code> handler, four placements, arbitrary SchemaNode content.
</p>
<div className="flex flex-wrap gap-2">
<Button data-testid="open-center" onClick={() => modalHandler({ placement: 'center', size: 'lg', title: 'Center modal', content })}>
Center
</Button>
<Button data-testid="open-side" variant="outline" onClick={() => modalHandler({ placement: 'side', title: 'Side drawer', content })}>
Side drawer
</Button>
<Button data-testid="open-bottom" variant="outline" onClick={() => modalHandler({ placement: 'bottom', title: 'Bottom sheet', content })}>
Bottom sheet
</Button>
<Button data-testid="open-fullscreen" variant="outline" onClick={() => modalHandler({ placement: 'fullscreen', title: 'Fullscreen', content })}>
Fullscreen
</Button>
</div>
{modalElement}
</div>
);
};

export default DevModal;
34 changes: 34 additions & 0 deletions packages/app-shell/src/hooks/__tests__/useActionModal.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions packages/app-shell/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
210 changes: 210 additions & 0 deletions packages/app-shell/src/hooks/useActionModal.tsx
Original file line number Diff line number Diff line change
@@ -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 <SchemaRenderer>, so a
* modal action can open any page/form/list. Back-compat: a string target
* (e.g. "create_opportunity") or `{ objectName, mode }` opens a <ModalForm>.
*
* 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<ModalSize, string> = {
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<Record<ModalSize, string>> = {
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<ActionResult>((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 = (
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: d.objectName,
mode: d.mode || 'create',
recordId: d.recordId,
title: d.title,
description: d.description,
fields: d.fields,
modalSize: d.size,
open: true,
onOpenChange,
onSuccess: (data: any) => 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 ? (
<SchemaRenderer schema={d.content} />
) : d.description ? (
<p className="text-sm text-muted-foreground">{d.description}</p>
) : null;

if (placement === 'side') {
modalElement = (
<Sheet open onOpenChange={onOpenChange}>
<SheetContent side={d.side || 'right'} className={cn('w-full overflow-y-auto', SIDE_SIZE_CLASS[d.size || 'default'])}>
{d.title && (
<SheetHeader>
<SheetTitle>{d.title}</SheetTitle>
{d.description && <SheetDescription>{d.description}</SheetDescription>}
</SheetHeader>
)}
<div className="py-3">{body}</div>
</SheetContent>
</Sheet>
);
} else if (placement === 'bottom') {
modalElement = (
<Drawer open onOpenChange={onOpenChange}>
<DrawerContent>
{d.title && (
<DrawerHeader>
<DrawerTitle>{d.title}</DrawerTitle>
{d.description && <DrawerDescription>{d.description}</DrawerDescription>}
</DrawerHeader>
)}
<div className="max-h-[75vh] overflow-y-auto px-4 pb-6">{body}</div>
</DrawerContent>
</Drawer>
);
} else {
modalElement = (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
className={cn(
placement === 'fullscreen'
? 'h-[95vh] w-full max-w-[98vw] overflow-y-auto'
: SIZE_CLASS[d.size || 'default'],
)}
>
{d.title && (
<DialogHeader>
<DialogTitle>{d.title}</DialogTitle>
{d.description && <DialogDescription>{d.description}</DialogDescription>}
</DialogHeader>
)}
<div>{body}</div>
{!d.content && (
<div className="flex justify-end">
<Button onClick={() => close({ success: true })}>OK</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
}
}

return { modalHandler, modalElement, closeModal: close };
}
1 change: 1 addition & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export {
navigationEqual,
generateNavId,
useResponsiveSidebar,
useActionModal,
} from './hooks';
export type { FavoriteItem } from './hooks';

Expand Down
13 changes: 11 additions & 2 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(), []);

Expand Down Expand Up @@ -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 }}
>
<div className="flex-1 overflow-hidden flex flex-row">
<div className="flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48">
Expand Down Expand Up @@ -1693,6 +1699,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
sections={[{ title: 'Page Schema', data: renderedPage }]}
/>
</div>
{modalElement}
</ActionProvider>
</DiscussionContextProvider>
</HighlightFieldsProvider>
Expand Down Expand Up @@ -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 }}
>
<DetailView
key={actionRefreshKey}
Expand Down Expand Up @@ -1797,6 +1805,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
/>
}
/>
{modalElement}
</ActionProvider>
</div>
<MetadataPanel
Expand Down
Loading