mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29:12 +00:00
feat(filters): add form validation (#890)
* feat(filters): add form validation * feat(filters): show toast on validation error * chore: update package.json
This commit is contained in:
parent
83e9232b98
commit
124031f510
5 changed files with 146 additions and 37 deletions
|
@ -31,7 +31,9 @@
|
|||
"react-tooltip": "^5.5.2",
|
||||
"stacktracey": "^2.1.8",
|
||||
"vite-plugin-pwa": "^0.14.6",
|
||||
"workbox-window": "^6.5.4"
|
||||
"workbox-window": "^6.5.4",
|
||||
"zod": "^3.21.4",
|
||||
"zod-formik-adapter": "^1.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
|
|
|
@ -116,21 +116,27 @@ export const IndexerMultiSelect = ({
|
|||
<Field name={name} type="select" multiple={true}>
|
||||
{({
|
||||
field,
|
||||
meta,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<RMSC
|
||||
{...field}
|
||||
options={options}
|
||||
labelledBy={name}
|
||||
value={field.value && field.value.map((item: IndexerMultiSelectOption) => ({
|
||||
value: item.id, label: item.name
|
||||
}))}
|
||||
onChange={(values: MultiSelectOption[]) => {
|
||||
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
|
||||
setFieldValue(field.name, item);
|
||||
}}
|
||||
className={settingsContext.darkTheme ? "dark" : ""}
|
||||
/>
|
||||
<>
|
||||
<RMSC
|
||||
{...field}
|
||||
options={options}
|
||||
labelledBy={name}
|
||||
value={field.value && field.value.map((item: IndexerMultiSelectOption) => ({
|
||||
value: item.id, label: item.name
|
||||
}))}
|
||||
onChange={(values: MultiSelectOption[]) => {
|
||||
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
|
||||
setFieldValue(field.name, item);
|
||||
}}
|
||||
className={settingsContext.darkTheme ? "dark" : ""}
|
||||
/>
|
||||
{meta.touched && meta.error && (
|
||||
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
@ -153,6 +159,7 @@ export function DownloadClientSelect({
|
|||
<Field name={name} type="select">
|
||||
{({
|
||||
field,
|
||||
meta,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<Listbox
|
||||
|
@ -231,6 +238,9 @@ export function DownloadClientSelect({
|
|||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
{meta.touched && meta.error && (
|
||||
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
import React, { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Field, FieldArray, FieldProps, FormikValues, useFormikContext } from "formik";
|
||||
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
ActionContentLayoutOptions,
|
||||
ActionRtorrentRenameOptions,
|
||||
ActionTypeNameMap,
|
||||
ActionTypeOptions
|
||||
} from "@domain/constants";
|
||||
import { AlertWarning } from "@components/alerts";
|
||||
import { DownloadClientSelect, NumberField, Select, SwitchGroup, TextField } from "@components/inputs";
|
||||
import { ActionContentLayoutOptions, ActionRtorrentRenameOptions, ActionTypeNameMap, ActionTypeOptions } from "@domain/constants";
|
||||
import React, { Fragment, useRef, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { Field, FieldArray, FieldProps, FormikValues } from "formik";
|
||||
import { EmptyListState } from "@components/emptystates";
|
||||
import { useToggle } from "@hooks/hooks";
|
||||
import { classNames } from "@utils";
|
||||
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { DeleteModal } from "@components/modals";
|
||||
import { CollapsableSection } from "./details";
|
||||
import { CustomTooltip } from "@components/tooltips/CustomTooltip";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFormikContext } from "formik";
|
||||
import { TextArea } from "@components/inputs/input";
|
||||
|
||||
interface FilterActionsProps {
|
||||
|
@ -29,7 +33,8 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
|
|||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const newAction = {
|
||||
const newAction: Action = {
|
||||
id: 0,
|
||||
name: "new action",
|
||||
enabled: true,
|
||||
type: "TEST",
|
||||
|
@ -43,7 +48,7 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
|
|||
paused: false,
|
||||
ignore_rules: false,
|
||||
skip_hash_check: false,
|
||||
content_layout: "",
|
||||
content_layout: "" || undefined,
|
||||
limit_upload_speed: 0,
|
||||
limit_download_speed: 0,
|
||||
limit_ratio: 0,
|
||||
|
@ -57,8 +62,8 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
|
|||
webhook_type: "",
|
||||
webhook_method: "",
|
||||
webhook_data: "",
|
||||
webhook_headers: []
|
||||
// client_id: 0,
|
||||
webhook_headers: [],
|
||||
client_id: 0
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -108,7 +113,7 @@ interface TypeFormProps {
|
|||
}
|
||||
|
||||
const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
const resetClientField = (action: Action, idx: number, prevActionType: string): void => {
|
||||
const fieldName = `actions.${idx}.client_id`;
|
||||
|
@ -127,7 +132,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
|||
action.type === "READARR" ||
|
||||
action.type === "SABNZBD"
|
||||
)) {
|
||||
setFieldValue(fieldName, ""); // Reset the client_id field value
|
||||
setFieldValue(fieldName, 0); // Reset the client_id field value
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -137,7 +142,8 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
|||
resetClientField(action, idx, prevActionType);
|
||||
}
|
||||
setPrevActionType(action.type);
|
||||
}, [action.type, idx, setFieldValue]);
|
||||
}, [action.type, idx, setFieldValue]);
|
||||
|
||||
switch (action.type) {
|
||||
case "TEST":
|
||||
return (
|
||||
|
@ -209,7 +215,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
|||
label="Save path"
|
||||
columns={6}
|
||||
placeholder="eg. /full/path/to/download_folder"
|
||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.save_path`} clickable={true}><div><p>Set a custom save path for this action. Automatic Torrent Management will take care of this if using qBittorrent with categories.</p><br /><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div></CustomTooltip>} />
|
||||
tooltip={<div><p>Set a custom save path for this action. Automatic Torrent Management will take care of this if using qBittorrent with categories.</p><br /><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div>} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -219,13 +225,13 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
|||
label="Category"
|
||||
columns={6}
|
||||
placeholder="eg. category"
|
||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.category`} clickable={true}><div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div></CustomTooltip>} />
|
||||
tooltip={<div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div>} />
|
||||
<TextField
|
||||
name={`actions.${idx}.tags`}
|
||||
label="Tags"
|
||||
columns={6}
|
||||
placeholder="eg. tag1,tag2"
|
||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.tags`} clickable={true}><div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div></CustomTooltip>} />
|
||||
tooltip={<div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div>} />
|
||||
</div>
|
||||
|
||||
<CollapsableSection title="Rules" subtitle="client options">
|
||||
|
@ -266,7 +272,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
|||
<SwitchGroup
|
||||
name={`actions.${idx}.ignore_rules`}
|
||||
label="Ignore client rules"
|
||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.ignore_rules`} clickable={true}><div><p>Choose to ignore rules set in <Link className='text-blue-400 visited:text-blue-400' to="/settings/clients">Client Settings</Link>.</p></div></CustomTooltip>} />
|
||||
tooltip={<div><p>Choose to ignore rules set in <Link className='text-blue-400 visited:text-blue-400' to="/settings/clients">Client Settings</Link>.</p></div>} />
|
||||
</div>
|
||||
<div className="col-span-6">
|
||||
<Select
|
||||
|
@ -508,7 +514,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
|||
label="Category"
|
||||
columns={6}
|
||||
placeholder="eg. category"
|
||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.category`} clickable={true}><p>Category must exist already.</p></CustomTooltip>} />
|
||||
tooltip={<p>Category must exist already.</p>} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -626,7 +632,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
|
|||
label="Type"
|
||||
optionDefaultText="Select type"
|
||||
options={ActionTypeOptions}
|
||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.type`} clickable={true}><div><p>Select the download client type for this action.</p></div></CustomTooltip>}
|
||||
tooltip={<div><p>Select the download client type for this action.</p></div>}
|
||||
/>
|
||||
|
||||
<TextField name={`actions.${idx}.name`} label="Name" columns={6} />
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useRef } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Form, Formik, FormikValues, useFormikContext } from "formik";
|
||||
import { z } from "zod";
|
||||
import { toFormikValidationSchema } from "zod-formik-adapter";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
import {
|
||||
|
@ -140,6 +142,71 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
|
|||
);
|
||||
};
|
||||
|
||||
const FormErrorNotification = () => {
|
||||
const { isValid, isValidating, isSubmitting, errors } = useFormikContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValid && !isValidating && isSubmitting) {
|
||||
console.log("validation errors: ", errors);
|
||||
toast.custom((t) => <Toast type="error" body={`Validation error. Check fields: ${Object.keys(errors)}`} t={t}/>);
|
||||
}
|
||||
}, [isSubmitting, isValid, isValidating]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const allowedClientType = ["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION","PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD"];
|
||||
|
||||
const actionSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
name: z.string(),
|
||||
type: z.enum(["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION","PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD", "TEST", "EXEC", "WATCH_FOLDER", "WEBHOOK"]),
|
||||
client_id: z.number().optional(),
|
||||
exec_cmd: z.string().optional(),
|
||||
exec_args: z.string().optional(),
|
||||
watch_folder: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
save_path: z.string().optional(),
|
||||
paused: z.boolean().optional(),
|
||||
ignore_rules: z.boolean().optional(),
|
||||
limit_upload_speed: z.number().optional(),
|
||||
limit_download_speed: z.number().optional(),
|
||||
limit_ratio: z.number().optional(),
|
||||
limit_seed_time: z.number().optional(),
|
||||
reannounce_skip: z.boolean().optional(),
|
||||
reannounce_delete: z.boolean().optional(),
|
||||
reannounce_interval: z.number().optional(),
|
||||
reannounce_max_attempts: z.number().optional(),
|
||||
webhook_host: z.string().optional(),
|
||||
webhook_type: z.string().optional(),
|
||||
webhook_method: z.string().optional(),
|
||||
webhook_data: z.string().optional()
|
||||
}).superRefine((value, ctx) => {
|
||||
if (allowedClientType.includes(value.type)) {
|
||||
if (value.client_id === 0) {
|
||||
ctx.addIssue({
|
||||
message: "Must select client",
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["client_id"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const indexerSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().optional()
|
||||
});
|
||||
|
||||
// Define the schema for the entire object
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
indexers: z.array(indexerSchema).min(1, { message: "Must select at least one indexer" }),
|
||||
actions: z.array(actionSchema)
|
||||
});
|
||||
|
||||
export default function FilterDetails() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
@ -209,6 +276,9 @@ export default function FilterDetails() {
|
|||
if (a.type === "WEBHOOK") {
|
||||
a.webhook_method = "POST";
|
||||
a.webhook_type = "JSON";
|
||||
} else {
|
||||
a.webhook_method = "";
|
||||
a.webhook_type = "";
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -312,9 +382,11 @@ export default function FilterDetails() {
|
|||
} as Filter}
|
||||
onSubmit={handleSubmit}
|
||||
enableReinitialize={true}
|
||||
validationSchema={toFormikValidationSchema(schema)}
|
||||
>
|
||||
{({ values, dirty, resetForm }) => (
|
||||
<Form>
|
||||
<FormErrorNotification />
|
||||
<Routes>
|
||||
<Route index element={<General />} />
|
||||
<Route path="movies-tv" element={<MoviesTv />} />
|
||||
|
|
|
@ -6973,6 +6973,8 @@ __metadata:
|
|||
vite: ^4.2.1
|
||||
vite-plugin-pwa: ^0.14.6
|
||||
workbox-window: ^6.5.4
|
||||
zod: ^3.21.4
|
||||
zod-formik-adapter: ^1.2.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
@ -7272,3 +7274,20 @@ __metadata:
|
|||
checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod-formik-adapter@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "zod-formik-adapter@npm:1.2.0"
|
||||
peerDependencies:
|
||||
formik: ^2.2.9
|
||||
zod: ^3.14.4
|
||||
checksum: 41f9dc4550fb529bdeb9de2711a7e307140c44c5b65c28f7349da9b030276c3747962b15fbbc50b6fda1959b3a3fdcaff707f1cdfef0b0b6a3e745d017c8f73a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.21.4":
|
||||
version: 3.21.4
|
||||
resolution: "zod@npm:3.21.4"
|
||||
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue