Skip to content

Commit a1e523f

Browse files
Copilothotlong
andcommitted
feat: wire ActionProvider in RecordDetailView with confirm/toast/param/API handlers
- Wrap DetailView with ActionProvider in RecordDetailView - Add API action handler mapping logical names to dataSource operations - Add Shadcn-based ActionConfirmDialog for promise-based confirmations - Add ActionParamDialog for collecting user input before action execution - Wire toast handler to Sonner, navigate handler to React Router - Fix action-button.tsx: detect ActionParam[] and pass as actionParams - Fix action-bar.tsx: forward data prop to child renderers for visibility Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 22db1ee commit a1e523f

5 files changed

Lines changed: 329 additions & 12 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* ActionConfirmDialog — Promise-based confirmation dialog for action execution.
3+
*
4+
* Uses Shadcn AlertDialog to replace window.confirm with a styled, accessible
5+
* confirmation dialog. Renders only when state.open is true.
6+
*/
7+
8+
import {
9+
AlertDialog,
10+
AlertDialogAction,
11+
AlertDialogCancel,
12+
AlertDialogContent,
13+
AlertDialogDescription,
14+
AlertDialogFooter,
15+
AlertDialogHeader,
16+
AlertDialogTitle,
17+
} from '@object-ui/components';
18+
19+
export interface ConfirmDialogState {
20+
open: boolean;
21+
message: string;
22+
options?: { title?: string; confirmText?: string; cancelText?: string };
23+
resolve?: (value: boolean) => void;
24+
}
25+
26+
interface ActionConfirmDialogProps {
27+
state: ConfirmDialogState;
28+
onOpenChange: (open: boolean) => void;
29+
}
30+
31+
export function ActionConfirmDialog({ state, onOpenChange }: ActionConfirmDialogProps) {
32+
const handleConfirm = () => {
33+
state.resolve?.(true);
34+
onOpenChange(false);
35+
};
36+
37+
const handleCancel = () => {
38+
state.resolve?.(false);
39+
onOpenChange(false);
40+
};
41+
42+
return (
43+
<AlertDialog open={state.open} onOpenChange={(open) => {
44+
if (!open) handleCancel();
45+
}}>
46+
<AlertDialogContent>
47+
<AlertDialogHeader>
48+
<AlertDialogTitle>{state.options?.title || 'Confirm Action'}</AlertDialogTitle>
49+
<AlertDialogDescription>{state.message}</AlertDialogDescription>
50+
</AlertDialogHeader>
51+
<AlertDialogFooter>
52+
<AlertDialogCancel onClick={handleCancel}>
53+
{state.options?.cancelText || 'Cancel'}
54+
</AlertDialogCancel>
55+
<AlertDialogAction onClick={handleConfirm}>
56+
{state.options?.confirmText || 'Continue'}
57+
</AlertDialogAction>
58+
</AlertDialogFooter>
59+
</AlertDialogContent>
60+
</AlertDialog>
61+
);
62+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* ActionParamDialog — Collects user input for action parameters before execution.
3+
*
4+
* Dynamically renders form fields from ActionParamDef[] definitions:
5+
* - type: 'select' → Shadcn Select component
6+
* - type: 'text' → Shadcn Input component
7+
* - type: 'textarea' → Shadcn Textarea component
8+
* - other types → Shadcn Input with appropriate HTML type
9+
*
10+
* Returns collected param values or null on cancel.
11+
*/
12+
13+
import { useState, useEffect } from 'react';
14+
import {
15+
Dialog,
16+
DialogContent,
17+
DialogDescription,
18+
DialogFooter,
19+
DialogHeader,
20+
DialogTitle,
21+
Button,
22+
Input,
23+
Label,
24+
Textarea,
25+
Select,
26+
SelectContent,
27+
SelectItem,
28+
SelectTrigger,
29+
SelectValue,
30+
} from '@object-ui/components';
31+
import type { ActionParamDef } from '@object-ui/core';
32+
33+
export interface ParamDialogState {
34+
open: boolean;
35+
params: ActionParamDef[];
36+
resolve?: (value: Record<string, any> | null) => void;
37+
}
38+
39+
interface ActionParamDialogProps {
40+
state: ParamDialogState;
41+
onOpenChange: (open: boolean) => void;
42+
}
43+
44+
export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProps) {
45+
const [values, setValues] = useState<Record<string, any>>({});
46+
47+
// Reset values when params change
48+
useEffect(() => {
49+
if (state.open) {
50+
const defaults: Record<string, any> = {};
51+
for (const param of state.params) {
52+
if (param.defaultValue !== undefined) {
53+
defaults[param.name] = param.defaultValue;
54+
}
55+
}
56+
setValues(defaults);
57+
}
58+
}, [state.open, state.params]);
59+
60+
const handleSubmit = () => {
61+
// Validate required fields
62+
for (const param of state.params) {
63+
if (param.required && !values[param.name]) {
64+
return; // Don't submit if required fields are empty
65+
}
66+
}
67+
state.resolve?.(values);
68+
onOpenChange(false);
69+
};
70+
71+
const handleCancel = () => {
72+
state.resolve?.(null);
73+
onOpenChange(false);
74+
};
75+
76+
const updateValue = (name: string, value: any) => {
77+
setValues(prev => ({ ...prev, [name]: value }));
78+
};
79+
80+
return (
81+
<Dialog open={state.open} onOpenChange={(open) => {
82+
if (!open) handleCancel();
83+
}}>
84+
<DialogContent>
85+
<DialogHeader>
86+
<DialogTitle>Action Parameters</DialogTitle>
87+
<DialogDescription>
88+
Please provide the required information to continue.
89+
</DialogDescription>
90+
</DialogHeader>
91+
92+
<div className="grid gap-4 py-4">
93+
{state.params.map((param) => (
94+
<div key={param.name} className="grid gap-2">
95+
<Label htmlFor={param.name}>
96+
{param.label}
97+
{param.required && <span className="text-destructive ml-1">*</span>}
98+
</Label>
99+
100+
{param.type === 'select' && param.options ? (
101+
<Select
102+
value={values[param.name] || ''}
103+
onValueChange={(val) => updateValue(param.name, val)}
104+
>
105+
<SelectTrigger id={param.name}>
106+
<SelectValue placeholder={param.placeholder || `Select ${param.label}`} />
107+
</SelectTrigger>
108+
<SelectContent>
109+
{param.options.map((opt) => (
110+
<SelectItem key={opt.value} value={opt.value}>
111+
{opt.label}
112+
</SelectItem>
113+
))}
114+
</SelectContent>
115+
</Select>
116+
) : param.type === 'textarea' ? (
117+
<Textarea
118+
id={param.name}
119+
value={values[param.name] || ''}
120+
onChange={(e) => updateValue(param.name, e.target.value)}
121+
placeholder={param.placeholder}
122+
/>
123+
) : (
124+
<Input
125+
id={param.name}
126+
type={param.type === 'number' ? 'number' : 'text'}
127+
value={values[param.name] || ''}
128+
onChange={(e) => updateValue(param.name, e.target.value)}
129+
placeholder={param.placeholder}
130+
/>
131+
)}
132+
133+
{param.helpText && (
134+
<p className="text-xs text-muted-foreground">{param.helpText}</p>
135+
)}
136+
</div>
137+
))}
138+
</div>
139+
140+
<DialogFooter>
141+
<Button variant="outline" onClick={handleCancel}>Cancel</Button>
142+
<Button onClick={handleSubmit}>Confirm</Button>
143+
</DialogFooter>
144+
</DialogContent>
145+
</Dialog>
146+
);
147+
}

apps/console/src/components/RecordDetailView.tsx

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@
77
*/
88

99
import { useState, useEffect, useCallback } from 'react';
10-
import { useParams } from 'react-router-dom';
10+
import { useParams, useNavigate } from 'react-router-dom';
1111
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
1212
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
1313
import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration';
1414
import { useAuth } from '@object-ui/auth';
15+
import { ActionProvider } from '@object-ui/react';
16+
import { toast } from 'sonner';
1517
import { Database, Users } from 'lucide-react';
1618
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
1719
import { SkeletonDetail } from './skeletons';
20+
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog';
21+
import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog';
1822
import type { DetailViewSchema, FeedItem } from '@object-ui/types';
23+
import type { ActionDef, ActionContext as ActionCtx, ActionParamDef } from '@object-ui/core';
1924

2025
interface RecordDetailViewProps {
2126
dataSource: any;
@@ -29,16 +34,82 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
2934
const { objectName, recordId } = useParams();
3035
const { showDebug } = useMetadataInspector();
3136
const { user } = useAuth();
37+
const navigate = useNavigate();
3238
const [isLoading, setIsLoading] = useState(true);
3339
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
3440
const [recordViewers, setRecordViewers] = useState<PresenceUser[]>([]);
41+
const [actionRefreshKey, setActionRefreshKey] = useState(0);
3542
const objectDef = objects.find((o: any) => o.name === objectName);
3643

3744
// Use the URL recordId as-is — it contains the actual record _id.
3845
// Navigation code passes `record._id || record.id` directly into the URL
3946
// without adding any prefix, so no stripping is needed.
4047
const pureRecordId = recordId;
4148

49+
// ─── Action Provider Handlers ───────────────────────────────────────
50+
51+
// Confirm dialog state (promise-based)
52+
const [confirmState, setConfirmState] = useState<ConfirmDialogState>({ open: false, message: '' });
53+
54+
// Param collection dialog state (promise-based)
55+
const [paramState, setParamState] = useState<ParamDialogState>({ open: false, params: [] });
56+
57+
const confirmHandler = useCallback((message: string, options?: { title?: string; confirmText?: string; cancelText?: string }) => {
58+
return new Promise<boolean>((resolve) => {
59+
setConfirmState({ open: true, message, options, resolve });
60+
});
61+
}, []);
62+
63+
const paramCollectionHandler = useCallback((params: ActionParamDef[]) => {
64+
return new Promise<Record<string, any> | null>((resolve) => {
65+
setParamState({ open: true, params, resolve });
66+
});
67+
}, []);
68+
69+
const toastHandler = useCallback((message: string, options?: { type?: string }) => {
70+
if (options?.type === 'error') toast.error(message);
71+
else toast.success(message);
72+
}, []);
73+
74+
const navigateHandler = useCallback((url: string, options?: { external?: boolean; newTab?: boolean }) => {
75+
if (options?.external || options?.newTab) {
76+
window.open(url, '_blank', 'noopener,noreferrer');
77+
} else {
78+
navigate(url);
79+
}
80+
}, [navigate]);
81+
82+
// API action handler — maps logical action targets to dataSource operations
83+
const apiHandler = useCallback(async (action: ActionDef, _context: ActionCtx) => {
84+
try {
85+
const target = action.target || action.name;
86+
const params = action.params || {};
87+
88+
switch (target) {
89+
case 'opportunity_change_stage':
90+
await dataSource.update(objectName!, pureRecordId!, { stage: params.new_stage });
91+
break;
92+
case 'opportunity_mark_won':
93+
await dataSource.update(objectName!, pureRecordId!, { stage: 'closed_won' });
94+
break;
95+
case 'opportunity_mark_lost':
96+
await dataSource.update(objectName!, pureRecordId!, { stage: 'closed_lost', loss_reason: params.loss_reason });
97+
break;
98+
default:
99+
// Generic: update record with collected params
100+
if (Object.keys(params).length > 0) {
101+
await dataSource.update(objectName!, pureRecordId!, params);
102+
}
103+
break;
104+
}
105+
106+
setActionRefreshKey(k => k + 1);
107+
return { success: true, reload: true };
108+
} catch (error) {
109+
return { success: false, error: (error as Error).message };
110+
}
111+
}, [dataSource, objectName, pureRecordId]);
112+
42113
const currentUser = user
43114
? { id: user.id, name: user.name, avatar: user.image }
44115
: FALLBACK_USER;
@@ -290,13 +361,23 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
290361

291362
<div className="flex-1 overflow-hidden flex flex-row">
292363
<div className="flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48">
293-
<DetailView
294-
schema={detailSchema}
295-
dataSource={dataSource}
296-
onEdit={() => {
297-
onEdit({ _id: pureRecordId, id: pureRecordId });
298-
}}
299-
/>
364+
<ActionProvider
365+
context={{ record: {}, objectName, user: currentUser }}
366+
onConfirm={confirmHandler}
367+
onToast={toastHandler}
368+
onNavigate={navigateHandler}
369+
onParamCollection={paramCollectionHandler}
370+
handlers={{ api: apiHandler }}
371+
>
372+
<DetailView
373+
key={actionRefreshKey}
374+
schema={detailSchema}
375+
dataSource={dataSource}
376+
onEdit={() => {
377+
onEdit({ _id: pureRecordId, id: pureRecordId });
378+
}}
379+
/>
380+
</ActionProvider>
300381

301382
{/* Comments & Discussion */}
302383
<div className="mt-6 border-t pt-6">
@@ -322,6 +403,22 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
322403
sections={[{ title: 'View Schema', data: detailSchema }]}
323404
/>
324405
</div>
406+
407+
{/* Action Confirm Dialog */}
408+
<ActionConfirmDialog
409+
state={confirmState}
410+
onOpenChange={(open) => {
411+
if (!open) setConfirmState(s => ({ ...s, open: false }));
412+
}}
413+
/>
414+
415+
{/* Action Param Collection Dialog */}
416+
<ActionParamDialog
417+
state={paramState}
418+
onOpenChange={(open) => {
419+
if (!open) setParamState(s => ({ ...s, open: false }));
420+
}}
421+
/>
325422
</div>
326423
);
327424
}

packages/components/src/renderers/action/action-bar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
140140
variant: action.variant || schema.variant,
141141
size: action.size || schema.size,
142142
}}
143+
data={rest.data}
143144
/>
144145
);
145146
})}

0 commit comments

Comments
 (0)