mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-07 02:18:07 +01:00
Trigger Wizard (#20691)
* add reusable component for combined name / internal name form field * fix labels * refactor utilities * refactor image picker * lazy loading * don't clear text box * trigger wizard * image picker fixes * use name and ID field in trigger edit dialog * ensure wizard resets when reopening * icon size tweak * multiple triggers can trigger at once * remove scrolling * mobile tweaks * remove duplicated component * fix types * use table on desktop and keep cards on mobile * provide default
This commit is contained in:
127
web/src/components/input/NameAndIdFields.tsx
Normal file
127
web/src/components/input/NameAndIdFields.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Control, FieldValues, Path, PathValue } from "react-hook-form";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { generateFixedHash, isValidId } from "@/utils/stringUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
||||
control: Control<T>;
|
||||
type?: string;
|
||||
nameField: Path<T>;
|
||||
idField: Path<T>;
|
||||
nameLabel: string;
|
||||
nameDescription?: string;
|
||||
idLabel?: string;
|
||||
idDescription?: string;
|
||||
processId?: (name: string) => string;
|
||||
placeholderName?: string;
|
||||
placeholderId?: string;
|
||||
};
|
||||
|
||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
control,
|
||||
type,
|
||||
nameField,
|
||||
idField,
|
||||
nameLabel,
|
||||
nameDescription,
|
||||
idLabel,
|
||||
idDescription,
|
||||
processId,
|
||||
placeholderName,
|
||||
placeholderId,
|
||||
}: NameAndIdFieldsProps<T>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { watch, setValue, trigger } = useFormContext<T>();
|
||||
const [isIdVisible, setIsIdVisible] = useState(false);
|
||||
|
||||
const defaultProcessId = (name: string) => {
|
||||
const normalized = name.replace(/\s+/g, "_").toLowerCase();
|
||||
if (isValidId(normalized)) {
|
||||
return normalized;
|
||||
} else {
|
||||
return generateFixedHash(name, type);
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveProcessId = processId || defaultProcessId;
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name }) => {
|
||||
if (name === nameField) {
|
||||
const processedId = effectiveProcessId(value[nameField] || "");
|
||||
setValue(idField, processedId as PathValue<T, Path<T>>);
|
||||
trigger(idField);
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name={nameField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{nameLabel}</FormLabel>
|
||||
<span
|
||||
className="cursor-pointer text-right text-xs text-muted-foreground"
|
||||
onClick={() => setIsIdVisible(!isIdVisible)}
|
||||
>
|
||||
{isIdVisible
|
||||
? t("label.hide", { item: idLabel ?? t("label.ID") })
|
||||
: t("label.show", {
|
||||
item: idLabel ?? t("label.ID"),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={placeholderName}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{nameDescription && (
|
||||
<FormDescription>{nameDescription}</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isIdVisible && (
|
||||
<FormField
|
||||
control={control}
|
||||
name={idField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md"
|
||||
placeholder={placeholderId}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{idDescription ?? t("field.internalID")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
MobilePageHeader,
|
||||
MobilePageTitle,
|
||||
} from "../mobile/MobilePage";
|
||||
import NameAndIdFields from "@/components/input/NameAndIdFields";
|
||||
|
||||
type CreateTriggerDialogProps = {
|
||||
show: boolean;
|
||||
@@ -89,6 +90,19 @@ export default function CreateTriggerDialog({
|
||||
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
|
||||
@@ -103,7 +117,15 @@ export default function CreateTriggerDialog({
|
||||
!existingTriggerNames.includes(value) || value === trigger?.name,
|
||||
t("triggers.dialog.form.name.error.alreadyExists"),
|
||||
),
|
||||
friendly_name: z.string().optional(),
|
||||
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(["thumbnail", "description"]),
|
||||
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
|
||||
threshold: z
|
||||
@@ -138,7 +160,7 @@ export default function CreateTriggerDialog({
|
||||
values.data,
|
||||
values.threshold,
|
||||
values.actions,
|
||||
values.friendly_name ?? "",
|
||||
values.friendly_name,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -159,7 +181,7 @@ export default function CreateTriggerDialog({
|
||||
{
|
||||
enabled: trigger.enabled,
|
||||
name: trigger.name,
|
||||
friendly_name: trigger.friendly_name ?? "",
|
||||
friendly_name: trigger.friendly_name ?? trigger.name,
|
||||
type: trigger.type,
|
||||
data: trigger.data,
|
||||
threshold: trigger.threshold,
|
||||
@@ -219,47 +241,14 @@ export default function CreateTriggerDialog({
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-5 pt-4"
|
||||
>
|
||||
<FormField
|
||||
<NameAndIdFields
|
||||
type="trigger"
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("triggers.dialog.form.name.placeholder")}
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="friendly_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("triggers.dialog.form.friendly_name.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"triggers.dialog.form.friendly_name.placeholder",
|
||||
)}
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("triggers.dialog.form.friendly_name.description")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
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
|
||||
@@ -335,9 +324,6 @@ export default function CreateTriggerDialog({
|
||||
camera={selectedCamera}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("triggers.dialog.form.content.imageDesc")}
|
||||
</FormDescription>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -455,7 +441,7 @@ export default function CreateTriggerDialog({
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className="size-5" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -15,25 +15,33 @@ import { cn } from "@/lib/utils";
|
||||
import { Event } from "@/types/event";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
|
||||
type ImagePickerProps = {
|
||||
selectedImageId?: string;
|
||||
setSelectedImageId?: (id: string) => void;
|
||||
camera: string;
|
||||
limit?: number;
|
||||
direct?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ImagePicker({
|
||||
selectedImageId,
|
||||
setSelectedImageId,
|
||||
camera,
|
||||
limit = 100,
|
||||
direct = false,
|
||||
className,
|
||||
}: ImagePickerProps) {
|
||||
const { t } = useTranslation(["components/dialog"]);
|
||||
const { t } = useTranslation(["components/dialog", "views/settings"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: events } = useSWR<Event[]>(
|
||||
`events?camera=${camera}&limit=100`,
|
||||
`events?camera=${camera}&limit=${limit}`,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
@@ -62,12 +70,77 @@ export default function ImagePicker({
|
||||
if (setSelectedImageId) {
|
||||
setSelectedImageId(id);
|
||||
}
|
||||
setSearchTerm("");
|
||||
setOpen(false);
|
||||
if (!direct) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setSelectedImageId],
|
||||
[setSelectedImageId, direct],
|
||||
);
|
||||
|
||||
const handleImageLoad = useCallback((imageId: string) => {
|
||||
setLoadedImages((prev) => new Set(prev).add(imageId));
|
||||
}, []);
|
||||
|
||||
const renderSearchInput = () => (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("imagePicker.search.placeholder")}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
// Clear selected image when user starts typing
|
||||
if (setSelectedImageId) {
|
||||
setSelectedImageId("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderImageGrid = () => (
|
||||
<div className="grid grid-cols-2 gap-4 pr-1 sm:grid-cols-6">
|
||||
{images.length === 0 ? (
|
||||
<div className="col-span-2 text-center text-sm text-muted-foreground sm:col-span-6">
|
||||
{t("imagePicker.noImages")}
|
||||
</div>
|
||||
) : (
|
||||
images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={cn(
|
||||
"relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 bg-background transition-all",
|
||||
selectedImageId === image.id &&
|
||||
"border-selected ring-2 ring-selected",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
|
||||
alt={image.label}
|
||||
className="h-full w-full object-cover"
|
||||
onClick={() => handleImageSelect(image.id)}
|
||||
onLoad={() => handleImageLoad(image.id)}
|
||||
loading="lazy"
|
||||
/>
|
||||
{!loadedImages.has(image.id) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (direct) {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
{renderSearchInput()}
|
||||
{renderImageGrid()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Dialog
|
||||
@@ -87,18 +160,27 @@ export default function ImagePicker({
|
||||
) : (
|
||||
<div className="hover:cursor-pointer">
|
||||
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
selectedImage
|
||||
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
||||
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
|
||||
}
|
||||
alt={selectedImage?.label || "Selected image"}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
/>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="relative size-16">
|
||||
<img
|
||||
src={
|
||||
selectedImage
|
||||
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
||||
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
|
||||
}
|
||||
alt={selectedImage?.label || "Selected image"}
|
||||
className="size-16 rounded object-cover"
|
||||
onLoad={() => handleImageLoad(selectedImageId || "")}
|
||||
loading="lazy"
|
||||
/>
|
||||
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm smart-capitalize">
|
||||
{selectedImage?.label || selectedImageId}
|
||||
{selectedImage?.label || t("imagePicker.unknownLabel")}
|
||||
{selectedImage?.sub_label
|
||||
? ` (${selectedImage.sub_label})`
|
||||
: ""}
|
||||
@@ -122,48 +204,23 @@ export default function ImagePicker({
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-3xl",
|
||||
isMobile && "px-4",
|
||||
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-[70%]",
|
||||
isMobile && "scrollbar-container max-h-[90%] overflow-y-auto px-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex flex-row items-center justify-between">
|
||||
<div className="mb-3 flex flex-col items-start justify-start">
|
||||
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("triggers.dialog.form.content.imageDesc", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</div>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("imagePicker.search.placeholder")}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
{renderSearchInput()}
|
||||
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-2 pr-1">
|
||||
{images.length === 0 ? (
|
||||
<div className="col-span-3 text-center text-sm text-muted-foreground">
|
||||
{t("imagePicker.noImages")}
|
||||
</div>
|
||||
) : (
|
||||
images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-center rounded-lg p-1 hover:cursor-pointer",
|
||||
selectedImageId === image.id
|
||||
? "bg-selected text-white"
|
||||
: "hover:bg-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
|
||||
alt={image.label}
|
||||
className="rounded object-cover"
|
||||
onClick={() => handleImageSelect(image.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{renderImageGrid()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
255
web/src/components/trigger/TriggerWizardDialog.tsx
Normal file
255
web/src/components/trigger/TriggerWizardDialog.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StepIndicator from "../indicators/StepIndicator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { useReducer, useEffect } 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
dispatch({ type: "RESET" });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset wizard state when opening for a different trigger or when creating new
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
dispatch({ type: "RESET" });
|
||||
}
|
||||
}, [open, trigger]);
|
||||
|
||||
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 || "",
|
||||
);
|
||||
}
|
||||
// Remove handleClose() - let the parent component handle closing after save completes
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
dispatch({ type: "PREVIOUS_STEP" });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch({ type: "RESET" });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isLoading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
200
web/src/components/trigger/wizard/Step1NameAndType.tsx
Normal file
200
web/src/components/trigger/wizard/Step1NameAndType.tsx
Normal 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/input/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>
|
||||
);
|
||||
}
|
||||
133
web/src/components/trigger/wizard/Step2ConfigureData.tsx
Normal file
133
web/src/components/trigger/wizard/Step2ConfigureData.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
web/src/components/trigger/wizard/Step3ThresholdAndActions.tsx
Normal file
194
web/src/components/trigger/wizard/Step3ThresholdAndActions.tsx
Normal 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-5" />}
|
||||
{isLoading
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("triggers.dialog.form.save", {
|
||||
defaultValue: "Save Trigger",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user