330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|