feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { listBrowserSessionProfiles } from '../../api';
|
||||
|
||||
export interface ParamHint {
|
||||
name: string;
|
||||
valueToReplace: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
}
|
||||
|
||||
interface SaveAsScriptDialogProps {
|
||||
/** The name of the recording (without .json extension). */
|
||||
recordingName: string;
|
||||
onClose: () => void;
|
||||
/** Called with the new script filename (e.g. "my-script.js") after a successful compile. */
|
||||
onSuccess: (scriptName: string) => void;
|
||||
}
|
||||
|
||||
const SCRIPT_NAME_RE = /^[A-Za-z0-9_\-.]+$/;
|
||||
|
||||
async function apiCompileScript(body: {
|
||||
recordingName: string;
|
||||
scriptName: string;
|
||||
description: string;
|
||||
sessionProfileId?: number;
|
||||
paramHints?: ParamHint[];
|
||||
overwrite?: boolean;
|
||||
}): Promise<{ ok: boolean; scriptName: string }> {
|
||||
const { overwrite, ...rest } = body;
|
||||
const qs = overwrite ? '?overwrite=true' : '';
|
||||
const res = await fetch(`/api/users/me/browser-macros/compile${qs}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rest),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
|
||||
(err as any).status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function emptyHint(): ParamHint {
|
||||
return { name: '', valueToReplace: '', type: 'string' };
|
||||
}
|
||||
|
||||
export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAsScriptDialogProps) {
|
||||
const qc = useQueryClient();
|
||||
const [scriptName, setScriptName] = useState(recordingName);
|
||||
const [description, setDescription] = useState('');
|
||||
const [sessionProfileId, setSessionProfileId] = useState('');
|
||||
const [paramHints, setParamHints] = useState<ParamHint[]>([]);
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [conflictError, setConflictError] = useState<string | null>(null);
|
||||
|
||||
const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({
|
||||
queryKey: ['browser-session-profiles'],
|
||||
queryFn: listBrowserSessionProfiles,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
|
||||
|
||||
const compileMutation = useMutation({
|
||||
mutationFn: apiCompileScript,
|
||||
onSuccess: (data) => {
|
||||
// Invalidate browser-macros list so the new file appears
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
|
||||
onSuccess(data.scriptName);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (err.status === 409) {
|
||||
setConflictError('Script already exists; check the overwrite box and retry.');
|
||||
}
|
||||
// Other errors surface via compileMutation.error
|
||||
},
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
if (!scriptName.trim()) {
|
||||
setValidationError('Script name is required.');
|
||||
return false;
|
||||
}
|
||||
const nameWithoutExt = scriptName.endsWith('.js') ? scriptName.slice(0, -3) : scriptName;
|
||||
if (!SCRIPT_NAME_RE.test(nameWithoutExt)) {
|
||||
setValidationError('Script name may only contain letters, numbers, dashes, underscores, and dots.');
|
||||
return false;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setValidationError('Description is required.');
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < paramHints.length; i++) {
|
||||
const h = paramHints[i]!;
|
||||
if (!h.name.trim() || !h.valueToReplace.trim()) {
|
||||
setValidationError(`Param hint #${i + 1} must have a name and value to replace.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setValidationError(null);
|
||||
setConflictError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
const profileId = sessionProfileId.trim() !== '' ? parseInt(sessionProfileId, 10) : undefined;
|
||||
compileMutation.mutate({
|
||||
recordingName,
|
||||
scriptName: scriptName.trim(),
|
||||
description: description.trim(),
|
||||
sessionProfileId: profileId !== undefined && !isNaN(profileId) ? profileId : undefined,
|
||||
paramHints: paramHints.length > 0 ? paramHints : undefined,
|
||||
overwrite,
|
||||
});
|
||||
}
|
||||
|
||||
function addHint() {
|
||||
setParamHints(prev => [...prev, emptyHint()]);
|
||||
}
|
||||
|
||||
function updateHint(idx: number, patch: Partial<ParamHint>) {
|
||||
setParamHints(prev => prev.map((h, i) => i === idx ? { ...h, ...patch } : h));
|
||||
}
|
||||
|
||||
function removeHint(idx: number) {
|
||||
setParamHints(prev => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
const isSubmitting = compileMutation.isPending;
|
||||
const submitError = compileMutation.isError && !conflictError
|
||||
? ((compileMutation.error as any)?.message ?? 'Compile failed')
|
||||
: null;
|
||||
|
||||
return (
|
||||
/* Backdrop */
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-hairline">
|
||||
<span className="text-sm font-semibold text-slate-800 flex-1">Save as Script</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-surface-2 text-slate-400 hover:text-slate-700 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 3l10 10M13 3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-5 py-4 overflow-y-auto flex-1">
|
||||
{/* Recording name (read-only) */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Recording
|
||||
</label>
|
||||
<div className="px-3 py-2 rounded-md bg-surface-2 text-xs font-mono text-slate-700 border border-hairline">
|
||||
{recordingName}.json
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Script name */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="sas-script-name" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Script name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="sas-script-name"
|
||||
type="text"
|
||||
value={scriptName}
|
||||
onChange={e => setScriptName(e.target.value)}
|
||||
placeholder="my-script"
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] font-mono focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-400">Alphanumeric, dashes, underscores, dots. A .js extension will be added automatically.</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="sas-description" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="sas-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="What does this script do?"
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session profile */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="sas-session-profile" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Session profile <span className="text-slate-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
{profilesLoading ? (
|
||||
<div className="px-3 py-2 text-xs text-slate-500">Loading profiles…</div>
|
||||
) : activeSessionProfiles.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-slate-500">
|
||||
No active session profiles. Create one in the Browser tab to enable authenticated automation.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="sas-session-profile"
|
||||
value={sessionProfileId}
|
||||
onChange={e => setSessionProfileId(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{activeSessionProfiles.map(p => (
|
||||
<option key={p.id} value={String(p.id)}>{p.label} (#{p.id})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Param hints */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide flex-1">
|
||||
Param hints <span className="text-slate-400 font-normal">(optional)</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHint}
|
||||
className="text-2xs px-2 py-1 rounded border border-hairline text-slate-600 hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
+ Add hint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paramHints.map((hint, idx) => (
|
||||
<div key={idx} className="flex gap-2 items-start p-2 rounded-md border border-hairline bg-surface-2">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={hint.name}
|
||||
onChange={e => updateHint(idx, { name: e.target.value })}
|
||||
placeholder="param name"
|
||||
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={hint.valueToReplace}
|
||||
onChange={e => updateHint(idx, { valueToReplace: e.target.value })}
|
||||
placeholder="value to replace (literal)"
|
||||
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<select
|
||||
value={hint.type}
|
||||
onChange={e => updateHint(idx, { type: e.target.value as ParamHint['type'] })}
|
||||
className="px-2 py-1 rounded border border-hairline text-2xs bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHint(idx)}
|
||||
aria-label="Remove hint"
|
||||
className="mt-1 w-5 h-5 flex-shrink-0 flex items-center justify-center rounded hover:bg-red-100 hover:text-red-600 text-slate-400 transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overwrite checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwrite}
|
||||
onChange={e => { setOverwrite(e.target.checked); setConflictError(null); }}
|
||||
className="rounded border-hairline accent-accent"
|
||||
/>
|
||||
<span className="text-xs text-slate-700">Overwrite if script already exists</span>
|
||||
</label>
|
||||
|
||||
{/* Errors */}
|
||||
{(validationError || conflictError || submitError) && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 border border-red-200 text-xs text-red-700">
|
||||
{validationError ?? conflictError ?? submitError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-hairline bg-surface-2/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-medium text-slate-700 hover:bg-surface-2 disabled:opacity-50 transition-colors border border-hairline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form=""
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Compiling…' : 'Save as Script'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user