kvm/ui/src/components/MacroForm.tsx

271 lines
9.0 KiB
TypeScript

import { useState } from "react";
import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores";
import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset";
import { MacroStepCard } from "@/components/MacroStepCard";
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
interface ValidationErrors {
name?: string;
steps?: Record<number, {
keys?: string;
modifiers?: string;
delay?: string;
}>;
}
interface MacroFormProps {
initialData: Partial<KeySequence>;
onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
onCancel: () => void;
isSubmitting?: boolean;
submitText?: string;
}
export function MacroForm({
initialData,
onSubmit,
onCancel,
isSubmitting = false,
submitText = "Save Macro",
}: MacroFormProps) {
const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const showTemporaryError = (message: string) => {
setErrorMessage(message);
setTimeout(() => setErrorMessage(null), 3000);
};
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {};
// Name validation
if (!macro.name?.trim()) {
newErrors.name = "Name is required";
} else if (macro.name.trim().length > 50) {
newErrors.name = "Name must be less than 50 characters";
}
if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } };
} else {
const hasKeyOrModifier = macro.steps.some(step =>
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
);
if (!hasKeyOrModifier) {
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
showTemporaryError("Please fix the validation errors");
return;
}
try {
await onSubmit(macro);
} catch (error) {
if (error instanceof Error) {
showTemporaryError(error.message);
} else {
showTemporaryError("An error occurred while saving");
}
}
};
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
const newSteps = [...(macro.steps || [])];
if (!newSteps[stepIndex]) return;
if (option.keys) {
newSteps[stepIndex].keys = option.keys;
} else if (option.value) {
if (!newSteps[stepIndex].keys) {
newSteps[stepIndex].keys = [];
}
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
return;
}
newSteps[stepIndex].keys = [...keysArray, option.value];
}
setMacro({ ...macro, steps: newSteps });
if (errors.steps?.[stepIndex]?.keys) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleKeyQueryChange = (stepIndex: number, query: string) => {
setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
};
const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].modifiers = modifiers;
setMacro({ ...macro, steps: newSteps });
// Clear step errors when modifiers are added
if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleDelayChange = (stepIndex: number, delay: number) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].delay = delay;
setMacro({ ...macro, steps: newSteps });
};
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
const newSteps = [...(macro.steps || [])];
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
setMacro({ ...macro, steps: newSteps });
};
const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
return (
<>
<div className="space-y-4">
<Fieldset>
<InputFieldWithLabel
type="text"
label="Macro Name"
placeholder="Macro Name"
value={macro.name}
error={errors.name}
onChange={e => {
setMacro(prev => ({ ...prev, name: e.target.value }));
if (errors.name) {
const newErrors = { ...errors };
delete newErrors.name;
setErrors(newErrors);
}
}}
/>
</Fieldset>
<div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<FieldLabel label="Steps" info={`Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`} />
</div>
<span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
</span>
</div>
{errors.steps && errors.steps[0]?.keys && (
<div className="mt-2">
<FieldError error={errors.steps[0].keys} />
</div>
)}
<Fieldset>
<div className="mt-2 space-y-4">
{(macro.steps || []).map((step, stepIndex) => (
<MacroStepCard
key={stepIndex}
step={step}
stepIndex={stepIndex}
onDelete={macro.steps && macro.steps.length > 1 ? () => {
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
} : undefined}
onMoveUp={() => handleStepMove(stepIndex, 'up')}
onMoveDown={() => handleStepMove(stepIndex, 'down')}
onKeySelect={(option) => handleKeySelect(stepIndex, option)}
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ''}
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
/>
))}
</div>
</Fieldset>
<div className="mt-4">
<Button
size="MD"
theme="light"
fullWidth
LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
onClick={() => {
if (isMaxStepsReached) {
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
return;
}
setMacro(prev => ({
...prev,
steps: [
...(prev.steps || []),
{ keys: [], modifiers: [], delay: DEFAULT_DELAY }
],
}));
setErrors({});
}}
disabled={isMaxStepsReached}
/>
</div>
{errorMessage && (
<div className="mt-4">
<FieldError error={errorMessage} />
</div>
)}
<div className="mt-6 flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={isSubmitting ? "Saving..." : submitText}
onClick={handleSubmit}
disabled={isSubmitting}
/>
<Button
size="SM"
theme="light"
text="Cancel"
onClick={onCancel}
/>
</div>
</div>
</div>
</>
);
}