trigger wizard

This commit is contained in:
Josh Hawkins 2025-10-27 12:10:34 -05:00
parent 5846fc866f
commit 85f89cac0c
6 changed files with 811 additions and 12 deletions

View File

@ -922,10 +922,11 @@
"form": {
"name": {
"title": "Name",
"placeholder": "Enter trigger name",
"placeholder": "Name this trigger",
"description": "Enter a unique name or description to identify this trigger",
"error": {
"minLength": "Name must be at least 2 characters long.",
"invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.",
"minLength": "Field must be at least 2 characters long.",
"invalidCharacters": "Field can only contain letters, numbers, underscores, and hyphens.",
"alreadyExists": "A trigger with this name already exists for this camera."
}
},
@ -934,18 +935,15 @@
},
"type": {
"title": "Type",
"placeholder": "Select trigger type"
},
"friendly_name": {
"title": "Friendly Name",
"placeholder": "Name or describe this trigger",
"description": "An optional friendly name or descriptive text for this trigger."
"placeholder": "Select trigger type",
"description": "Trigger when a similar tracked object description is detected",
"thumbnail": "Trigger when a similar tracked object thumbnail is detected"
},
"content": {
"title": "Content",
"imagePlaceholder": "Select an image",
"imagePlaceholder": "Select a thumbnail",
"textPlaceholder": "Enter text content",
"imageDesc": "Select an image to trigger this action when a similar image is detected.",
"imageDesc": "Only the most recent 100 thumbnails are displayed. If you can't find your desired thumbnail, please review earlier objects in Explore and set up a trigger from the menu there.",
"textDesc": "Enter text to trigger this action when a similar tracked object description is detected.",
"error": {
"required": "Content is required."
@ -953,6 +951,7 @@
},
"threshold": {
"title": "Threshold",
"desc": "Set the similarity threshold for this trigger. A higher threshold means a closer match is required to fire the trigger.",
"error": {
"min": "Threshold must be at least 0",
"max": "Threshold must be at most 1"
@ -967,6 +966,23 @@
}
}
},
"wizard": {
"title": "Create Trigger",
"step1": {
"description": "Configure the basic settings for your trigger."
},
"step2": {
"description": "Set up the content that will trigger this action."
},
"step3": {
"description": "Configure the threshold and actions for this trigger."
},
"steps": {
"nameAndType": "Name and Type",
"configureData": "Configure Data",
"thresholdAndActions": "Threshold and Actions"
}
},
"toast": {
"success": {
"createTrigger": "Trigger {{name}} created successfully.",

View File

@ -0,0 +1,242 @@
import { useTranslation } from "react-i18next";
import StepIndicator from "../indicators/StepIndicator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { useReducer } from "react";
import Step1NameAndType, {
Step1FormData,
} from "@/components/trigger/wizard/Step1NameAndType";
import Step2ConfigureData, {
Step2FormData,
} from "@/components/trigger/wizard/Step2ConfigureData";
import Step3ThresholdAndActions, {
Step3FormData,
} from "@/components/trigger/wizard/Step3ThresholdAndActions";
import { cn } from "@/lib/utils";
import { isDesktop } from "react-device-detect";
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
const TRIGGER_STEPS = [
"wizard.steps.nameAndType",
"wizard.steps.configureData",
"wizard.steps.thresholdAndActions",
];
type TriggerWizardDialogProps = {
open: boolean;
onClose: () => void;
selectedCamera: string;
trigger?: Trigger | null;
onCreate: (
enabled: boolean,
name: string,
type: TriggerType,
data: string,
threshold: number,
actions: TriggerAction[],
friendly_name: string,
) => void;
onEdit: (trigger: Trigger) => void;
isLoading?: boolean;
};
type WizardState = {
currentStep: number;
step1Data?: Step1FormData;
step2Data?: Step2FormData;
step3Data?: Step3FormData;
};
type WizardAction =
| { type: "NEXT_STEP"; payload?: Partial<WizardState> }
| { type: "PREVIOUS_STEP" }
| { type: "SET_STEP_1"; payload: Step1FormData }
| { type: "SET_STEP_2"; payload: Step2FormData }
| { type: "SET_STEP_3"; payload: Step3FormData }
| { type: "RESET" };
const initialState: WizardState = {
currentStep: 0,
};
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "SET_STEP_1":
return {
...state,
step1Data: action.payload,
step2Data: undefined,
step3Data: undefined,
currentStep: 1,
};
case "SET_STEP_2":
return {
...state,
step2Data: action.payload,
currentStep: 2,
};
case "SET_STEP_3":
return {
...state,
step3Data: action.payload,
currentStep: 3,
};
case "NEXT_STEP":
return {
...state,
...action.payload,
currentStep: state.currentStep + 1,
};
case "PREVIOUS_STEP":
return {
...state,
currentStep: Math.max(0, state.currentStep - 1),
};
case "RESET":
return initialState;
default:
return state;
}
}
export default function TriggerWizardDialog({
open,
onClose,
selectedCamera,
trigger,
onCreate,
onEdit,
isLoading,
}: TriggerWizardDialogProps) {
const { t } = useTranslation(["views/settings"]);
const [wizardState, dispatch] = useReducer(wizardReducer, initialState);
const handleStep1Next = (data: Step1FormData) => {
dispatch({ type: "SET_STEP_1", payload: data });
};
const handleStep2Next = (data: Step2FormData) => {
dispatch({ type: "SET_STEP_2", payload: data });
};
const handleStep3Next = (data: Step3FormData) => {
// Combine all step data and call the appropriate callback
const combinedData = {
...wizardState.step1Data!,
...wizardState.step2Data!,
...data,
};
if (trigger) {
onEdit(combinedData);
} else {
onCreate(
combinedData.enabled,
combinedData.name,
combinedData.type,
combinedData.data,
combinedData.threshold,
combinedData.actions,
combinedData.friendly_name || "",
);
}
handleClose();
};
const handleBack = () => {
dispatch({ type: "PREVIOUS_STEP" });
};
const handleClose = () => {
dispatch({ type: "RESET" });
onClose();
};
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<DialogContent
className={cn(
"",
isDesktop &&
wizardState.currentStep == 1 &&
wizardState.step1Data?.type == "thumbnail"
? "max-h-[90%] max-w-[70%] overflow-y-auto xl:max-h-[80%]"
: "max-h-[90%] overflow-y-auto xl:max-h-[80%]",
)}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<StepIndicator
steps={TRIGGER_STEPS}
currentStep={wizardState.currentStep}
variant="dots"
className="mb-4 justify-start"
/>
<DialogHeader>
<DialogTitle>{t("triggers.wizard.title")}</DialogTitle>
{wizardState.currentStep === 0 && (
<DialogDescription>
{t("triggers.wizard.step1.description")}
</DialogDescription>
)}
{wizardState.currentStep === 1 && (
<DialogDescription>
{t("triggers.wizard.step2.description")}
</DialogDescription>
)}
{wizardState.currentStep === 2 && (
<DialogDescription>
{t("triggers.wizard.step3.description")}
</DialogDescription>
)}
</DialogHeader>
<div className="pb-4">
{wizardState.currentStep === 0 && (
<Step1NameAndType
initialData={wizardState.step1Data}
trigger={trigger}
selectedCamera={selectedCamera}
onNext={handleStep1Next}
onCancel={handleClose}
/>
)}
{wizardState.currentStep === 1 && wizardState.step1Data && (
<Step2ConfigureData
initialData={wizardState.step2Data}
triggerType={wizardState.step1Data.type}
selectedCamera={selectedCamera}
onNext={handleStep2Next}
onBack={handleBack}
/>
)}
{wizardState.currentStep === 2 &&
wizardState.step1Data &&
wizardState.step2Data && (
<Step3ThresholdAndActions
initialData={wizardState.step3Data}
trigger={trigger}
onNext={handleStep3Next}
onBack={handleBack}
isLoading={isLoading}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,200 @@
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import useSWR from "swr";
import NameAndIdFields from "@/components/ui/NameAndIdFields";
import { Form, FormDescription } from "@/components/ui/form";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { FrigateConfig } from "@/types/frigateConfig";
import { Trigger, TriggerType } from "@/types/trigger";
export type Step1FormData = {
enabled: boolean;
name: string;
friendly_name: string;
type: TriggerType;
};
type Step1NameAndTypeProps = {
initialData?: Step1FormData;
trigger?: Trigger | null;
selectedCamera: string;
onNext: (data: Step1FormData) => void;
onCancel: () => void;
};
export default function Step1NameAndType({
initialData,
trigger,
selectedCamera,
onNext,
onCancel,
}: Step1NameAndTypeProps) {
const { t } = useTranslation("views/settings");
const { data: config } = useSWR<FrigateConfig>("config");
const existingTriggerNames = useMemo(() => {
if (
!config ||
!selectedCamera ||
!config.cameras[selectedCamera]?.semantic_search?.triggers
) {
return [];
}
return Object.keys(config.cameras[selectedCamera].semantic_search.triggers);
}, [config, selectedCamera]);
const existingTriggerFriendlyNames = useMemo(() => {
if (
!config ||
!selectedCamera ||
!config.cameras[selectedCamera]?.semantic_search?.triggers
) {
return [];
}
return Object.values(
config.cameras[selectedCamera].semantic_search.triggers,
).map((trigger) => trigger.friendly_name);
}, [config, selectedCamera]);
const formSchema = z.object({
enabled: z.boolean(),
name: z
.string()
.min(2, t("triggers.dialog.form.name.error.minLength"))
.regex(
/^[a-zA-Z0-9_-]+$/,
t("triggers.dialog.form.name.error.invalidCharacters"),
)
.refine(
(value) =>
!existingTriggerNames.includes(value) || value === trigger?.name,
t("triggers.dialog.form.name.error.alreadyExists"),
),
friendly_name: z
.string()
.min(2, t("triggers.dialog.form.name.error.minLength"))
.refine(
(value) =>
!existingTriggerFriendlyNames.includes(value) ||
value === trigger?.friendly_name,
t("triggers.dialog.form.name.error.alreadyExists"),
),
type: z.enum(["description", "thumbnail"]),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
enabled: true,
name: initialData?.name ?? trigger?.name ?? "",
friendly_name: initialData?.friendly_name ?? trigger?.friendly_name ?? "",
type: initialData?.type ?? trigger?.type ?? "description",
},
});
const onSubmit = (values: z.infer<typeof formSchema>) => {
onNext({
enabled: true,
name: values.name,
friendly_name: values.friendly_name || "",
type: values.type,
});
};
useEffect(() => {
if (initialData) {
form.reset(initialData);
} else if (trigger) {
form.reset({
enabled: trigger.enabled,
name: trigger.name,
friendly_name: trigger.friendly_name || "",
type: trigger.type,
});
}
}, [initialData, trigger, form]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<NameAndIdFields
type="trigger"
control={form.control}
nameField="friendly_name"
idField="name"
nameLabel={t("triggers.dialog.form.name.title")}
nameDescription={t("triggers.dialog.form.name.description")}
placeholderName={t("triggers.dialog.form.name.placeholder")}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.type.title")}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-10">
<SelectValue
placeholder={t("triggers.dialog.form.type.placeholder")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="description">
{t("triggers.type.description")}
</SelectItem>
<SelectItem value="thumbnail">
{t("triggers.type.thumbnail")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("triggers.dialog.form.type.description")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,133 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import ImagePicker from "@/components/overlay/ImagePicker";
import { TriggerType } from "@/types/trigger";
export type Step2FormData = {
data: string;
};
type Step2ConfigureDataProps = {
initialData?: Step2FormData;
triggerType: TriggerType;
selectedCamera: string;
onNext: (data: Step2FormData) => void;
onBack: () => void;
};
export default function Step2ConfigureData({
initialData,
triggerType,
selectedCamera,
onNext,
onBack,
}: Step2ConfigureDataProps) {
const { t } = useTranslation("views/settings");
const formSchema = z.object({
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
data: initialData?.data ?? "",
},
});
const onSubmit = (values: z.infer<typeof formSchema>) => {
onNext({
data: values.data,
});
};
useEffect(() => {
if (initialData) {
form.reset(initialData);
}
}, [initialData, form]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="data"
render={({ field }) => (
<FormItem>
{triggerType === "thumbnail" ? (
<>
<FormLabel className="font-normal">
{t("triggers.dialog.form.content.imagePlaceholder")}
</FormLabel>
<FormControl>
<ImagePicker
selectedImageId={field.value}
setSelectedImageId={field.onChange}
camera={selectedCamera}
direct
className="max-h-[50dvh] overflow-y-auto rounded-lg border p-4"
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.content.imageDesc")}
</FormDescription>
</>
) : (
<>
<FormControl>
<Textarea
placeholder={t(
"triggers.dialog.form.content.textPlaceholder",
)}
{...field}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.content.textDesc")}
</FormDescription>
</>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
{t("button.back", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,194 @@
import { useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Trigger, TriggerAction } from "@/types/trigger";
import ActivityIndicator from "@/components/indicators/activity-indicator";
export type Step3FormData = {
threshold: number;
actions: TriggerAction[];
};
type Step3ThresholdAndActionsProps = {
initialData?: Step3FormData;
trigger?: Trigger | null;
onNext: (data: Step3FormData) => void;
onBack: () => void;
isLoading?: boolean;
};
export default function Step3ThresholdAndActions({
initialData,
trigger,
onNext,
onBack,
isLoading = false,
}: Step3ThresholdAndActionsProps) {
const { t } = useTranslation("views/settings");
const formSchema = z.object({
threshold: z
.number()
.min(0, t("triggers.dialog.form.threshold.error.min"))
.max(1, t("triggers.dialog.form.threshold.error.max")),
actions: z.array(z.enum(["notification"])),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
threshold: initialData?.threshold ?? trigger?.threshold ?? 0.5,
actions:
initialData?.actions ?? (trigger?.actions as TriggerAction[]) ?? [],
},
});
const onSubmit = useCallback(
(values: z.infer<typeof formSchema>) => {
onNext({
threshold: values.threshold,
actions: values.actions,
});
},
[onNext],
);
const handleSave = useCallback(() => {
const formData = form.getValues();
// Basic validation
if (formData.threshold < 0 || formData.threshold > 1) {
return;
}
onNext(formData);
}, [form, onNext]);
useEffect(() => {
if (initialData) {
form.reset(initialData);
} else if (trigger) {
form.reset({
threshold: trigger.threshold,
actions: trigger.actions,
});
}
}, [initialData, trigger, form]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="threshold"
render={({ field }) => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.threshold.title")}</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min="0"
max="1"
placeholder="0.50"
className="h-10"
{...field}
onChange={(e) => {
const value = parseFloat(e.target.value);
field.onChange(isNaN(value) ? 0 : value);
}}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.threshold.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="actions"
render={() => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.actions.title")}</FormLabel>
<div className="space-y-2">
{["notification"].map((action) => (
<div key={action} className="flex items-center space-x-2">
<FormControl>
<Checkbox
checked={form
.watch("actions")
.includes(action as TriggerAction)}
onCheckedChange={(checked) => {
const currentActions = form.getValues("actions");
if (checked) {
form.setValue("actions", [
...currentActions,
action as TriggerAction,
]);
} else {
form.setValue(
"actions",
currentActions.filter((a) => a !== action),
);
}
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{t(`triggers.actions.${action}`)}
</FormLabel>
</div>
))}
</div>
<FormDescription>
{t("triggers.dialog.form.actions.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isLoading}
className="flex-1"
variant="select"
>
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
{isLoading
? t("button.saving", { ns: "common" })
: t("triggers.dialog.form.save", {
defaultValue: "Save Trigger",
})}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -21,6 +21,7 @@ import {
LuExternalLink,
} from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TriggerWizardDialog from "@/components/trigger/TriggerWizardDialog";
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
import { FrigateConfig } from "@/types/frigateConfig";
@ -641,8 +642,21 @@ export default function TriggerView({
</>
)}
</div>
<TriggerWizardDialog
open={showCreate && (!selectedTrigger || selectedTrigger.name === "")}
onClose={() => {
setShowCreate(false);
setSelectedTrigger(null);
setUnsavedChanges(false);
}}
selectedCamera={selectedCamera}
trigger={null}
onCreate={onCreate}
onEdit={onEdit}
isLoading={isLoading}
/>
<CreateTriggerDialog
show={showCreate}
show={showCreate && !!selectedTrigger && selectedTrigger.name !== ""}
trigger={selectedTrigger}
selectedCamera={selectedCamera}
isLoading={isLoading}