Skip to content

Commit 8708c83

Browse files
Copilothotlong
andcommitted
feat: implement FormField.dependsOn/visibleOn runtime, ActionParam collection, and Page.template support
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 462d00e commit 8708c83

10 files changed

Lines changed: 513 additions & 152 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ apps/site/.source
5959
# Test artifacts
6060
test-screenshots
6161

62+
# Vite timestamp files
63+
*.timestamp-*.mjs
64+
6265
# Backup files
6366
*.bak
6467
*.backup
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* ObjectUI — Action Param Dialog
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* Renders a dialog to collect ActionParam values before action execution.
9+
* Used by the ActionRunner when an action defines params to collect.
10+
*/
11+
12+
import React, { useState, useCallback } from 'react';
13+
import type { ActionParamDef } from '@object-ui/core';
14+
import {
15+
Dialog,
16+
DialogContent,
17+
DialogDescription,
18+
DialogFooter,
19+
DialogHeader,
20+
DialogTitle,
21+
} from '../ui/dialog';
22+
import { Button } from '../ui/button';
23+
import { Input } from '../ui/input';
24+
import { Label } from '../ui/label';
25+
import { Textarea } from '../ui/textarea';
26+
import { Checkbox } from '../ui/checkbox';
27+
import {
28+
Select,
29+
SelectContent,
30+
SelectItem,
31+
SelectTrigger,
32+
SelectValue,
33+
} from '../ui/select';
34+
35+
export interface ActionParamDialogProps {
36+
/** The param definitions to render */
37+
params: ActionParamDef[];
38+
/** Whether the dialog is open */
39+
open: boolean;
40+
/** Called when the user submits the form */
41+
onSubmit: (values: Record<string, any>) => void;
42+
/** Called when the user cancels */
43+
onCancel: () => void;
44+
/** Dialog title */
45+
title?: string;
46+
/** Dialog description */
47+
description?: string;
48+
}
49+
50+
/**
51+
* ActionParamDialog renders a dialog with form fields for each ActionParam.
52+
* It collects user input and returns the values on submit.
53+
*/
54+
export const ActionParamDialog: React.FC<ActionParamDialogProps> = ({
55+
params,
56+
open,
57+
onSubmit,
58+
onCancel,
59+
title = 'Action Parameters',
60+
description = 'Please provide the required parameters.',
61+
}) => {
62+
// Initialize values from defaultValues
63+
const [values, setValues] = useState<Record<string, any>>(() => {
64+
const initial: Record<string, any> = {};
65+
params.forEach((p) => {
66+
if (p.defaultValue !== undefined) {
67+
initial[p.name] = p.defaultValue;
68+
} else {
69+
initial[p.name] = p.type === 'boolean' ? false : '';
70+
}
71+
});
72+
return initial;
73+
});
74+
75+
const [errors, setErrors] = useState<Record<string, string>>({});
76+
77+
const handleChange = useCallback((name: string, value: any) => {
78+
setValues((prev) => ({ ...prev, [name]: value }));
79+
// Clear error on change
80+
setErrors((prev) => {
81+
const next = { ...prev };
82+
delete next[name];
83+
return next;
84+
});
85+
}, []);
86+
87+
const handleSubmit = useCallback(() => {
88+
// Validate required fields
89+
const newErrors: Record<string, string> = {};
90+
params.forEach((p) => {
91+
if (p.required) {
92+
const val = values[p.name];
93+
if (val === undefined || val === null || val === '') {
94+
newErrors[p.name] = `${p.label} is required`;
95+
}
96+
}
97+
});
98+
99+
if (Object.keys(newErrors).length > 0) {
100+
setErrors(newErrors);
101+
return;
102+
}
103+
104+
onSubmit(values);
105+
}, [params, values, onSubmit]);
106+
107+
const renderField = (param: ActionParamDef) => {
108+
const value = values[param.name];
109+
const error = errors[param.name];
110+
111+
switch (param.type) {
112+
case 'textarea':
113+
return (
114+
<div key={param.name} className="space-y-2">
115+
<Label htmlFor={param.name}>
116+
{param.label}
117+
{param.required && <span className="text-destructive ml-1">*</span>}
118+
</Label>
119+
<Textarea
120+
id={param.name}
121+
value={value || ''}
122+
onChange={(e) => handleChange(param.name, e.target.value)}
123+
placeholder={param.placeholder}
124+
/>
125+
{param.helpText && (
126+
<p className="text-sm text-muted-foreground">{param.helpText}</p>
127+
)}
128+
{error && <p className="text-sm text-destructive">{error}</p>}
129+
</div>
130+
);
131+
132+
case 'number':
133+
return (
134+
<div key={param.name} className="space-y-2">
135+
<Label htmlFor={param.name}>
136+
{param.label}
137+
{param.required && <span className="text-destructive ml-1">*</span>}
138+
</Label>
139+
<Input
140+
id={param.name}
141+
type="number"
142+
value={value ?? ''}
143+
onChange={(e) => handleChange(param.name, e.target.valueAsNumber || '')}
144+
placeholder={param.placeholder}
145+
/>
146+
{param.helpText && (
147+
<p className="text-sm text-muted-foreground">{param.helpText}</p>
148+
)}
149+
{error && <p className="text-sm text-destructive">{error}</p>}
150+
</div>
151+
);
152+
153+
case 'boolean':
154+
return (
155+
<div key={param.name} className="flex items-center space-x-2">
156+
<Checkbox
157+
id={param.name}
158+
checked={!!value}
159+
onCheckedChange={(checked) => handleChange(param.name, !!checked)}
160+
/>
161+
<Label htmlFor={param.name}>{param.label}</Label>
162+
{param.helpText && (
163+
<p className="text-sm text-muted-foreground ml-2">{param.helpText}</p>
164+
)}
165+
</div>
166+
);
167+
168+
case 'select':
169+
return (
170+
<div key={param.name} className="space-y-2">
171+
<Label htmlFor={param.name}>
172+
{param.label}
173+
{param.required && <span className="text-destructive ml-1">*</span>}
174+
</Label>
175+
<Select
176+
value={value || ''}
177+
onValueChange={(val) => handleChange(param.name, val)}
178+
>
179+
<SelectTrigger>
180+
<SelectValue placeholder={param.placeholder || 'Select...'} />
181+
</SelectTrigger>
182+
<SelectContent>
183+
{param.options?.map((opt) => (
184+
<SelectItem key={opt.value} value={opt.value}>
185+
{opt.label}
186+
</SelectItem>
187+
))}
188+
</SelectContent>
189+
</Select>
190+
{param.helpText && (
191+
<p className="text-sm text-muted-foreground">{param.helpText}</p>
192+
)}
193+
{error && <p className="text-sm text-destructive">{error}</p>}
194+
</div>
195+
);
196+
197+
case 'date':
198+
return (
199+
<div key={param.name} className="space-y-2">
200+
<Label htmlFor={param.name}>
201+
{param.label}
202+
{param.required && <span className="text-destructive ml-1">*</span>}
203+
</Label>
204+
<Input
205+
id={param.name}
206+
type="date"
207+
value={value || ''}
208+
onChange={(e) => handleChange(param.name, e.target.value)}
209+
/>
210+
{param.helpText && (
211+
<p className="text-sm text-muted-foreground">{param.helpText}</p>
212+
)}
213+
{error && <p className="text-sm text-destructive">{error}</p>}
214+
</div>
215+
);
216+
217+
// text and all other types default to text input
218+
default:
219+
return (
220+
<div key={param.name} className="space-y-2">
221+
<Label htmlFor={param.name}>
222+
{param.label}
223+
{param.required && <span className="text-destructive ml-1">*</span>}
224+
</Label>
225+
<Input
226+
id={param.name}
227+
type="text"
228+
value={value || ''}
229+
onChange={(e) => handleChange(param.name, e.target.value)}
230+
placeholder={param.placeholder}
231+
/>
232+
{param.helpText && (
233+
<p className="text-sm text-muted-foreground">{param.helpText}</p>
234+
)}
235+
{error && <p className="text-sm text-destructive">{error}</p>}
236+
</div>
237+
);
238+
}
239+
};
240+
241+
return (
242+
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel(); }}>
243+
<DialogContent className="sm:max-w-[425px]">
244+
<DialogHeader>
245+
<DialogTitle>{title}</DialogTitle>
246+
<DialogDescription>{description}</DialogDescription>
247+
</DialogHeader>
248+
<div className="space-y-4 py-4">
249+
{params.map(renderField)}
250+
</div>
251+
<DialogFooter>
252+
<Button variant="outline" onClick={onCancel}>
253+
Cancel
254+
</Button>
255+
<Button onClick={handleSubmit}>
256+
Continue
257+
</Button>
258+
</DialogFooter>
259+
</DialogContent>
260+
</Dialog>
261+
);
262+
};
263+
264+
ActionParamDialog.displayName = 'ActionParamDialog';

packages/components/src/custom/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './native-select';
1111
export * from './navigation-overlay';
1212
export * from './spinner';
1313
export * from './sort-builder';
14+
export * from './action-param-dialog';

0 commit comments

Comments
 (0)