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:
ze0s 2023-05-01 14:44:17 +02:00 committed by GitHub
parent 83e9232b98
commit 124031f510
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 146 additions and 37 deletions

View file

@ -31,7 +31,9 @@
"react-tooltip": "^5.5.2", "react-tooltip": "^5.5.2",
"stacktracey": "^2.1.8", "stacktracey": "^2.1.8",
"vite-plugin-pwa": "^0.14.6", "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": { "scripts": {
"start": "vite", "start": "vite",

View file

@ -116,21 +116,27 @@ export const IndexerMultiSelect = ({
<Field name={name} type="select" multiple={true}> <Field name={name} type="select" multiple={true}>
{({ {({
field, field,
meta,
form: { setFieldValue } form: { setFieldValue }
}: FieldProps) => ( }: FieldProps) => (
<RMSC <>
{...field} <RMSC
options={options} {...field}
labelledBy={name} options={options}
value={field.value && field.value.map((item: IndexerMultiSelectOption) => ({ labelledBy={name}
value: item.id, label: item.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 })); onChange={(values: MultiSelectOption[]) => {
setFieldValue(field.name, item); const item = values && values.map((i) => ({ id: i.value, name: i.label }));
}} setFieldValue(field.name, item);
className={settingsContext.darkTheme ? "dark" : ""} }}
/> className={settingsContext.darkTheme ? "dark" : ""}
/>
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</>
)} )}
</Field> </Field>
</div> </div>
@ -153,6 +159,7 @@ export function DownloadClientSelect({
<Field name={name} type="select"> <Field name={name} type="select">
{({ {({
field, field,
meta,
form: { setFieldValue } form: { setFieldValue }
}: FieldProps) => ( }: FieldProps) => (
<Listbox <Listbox
@ -231,6 +238,9 @@ export function DownloadClientSelect({
))} ))}
</Listbox.Options> </Listbox.Options>
</Transition> </Transition>
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</div> </div>
</> </>
)} )}

View file

@ -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 { AlertWarning } from "@components/alerts";
import { DownloadClientSelect, NumberField, Select, SwitchGroup, TextField } from "@components/inputs"; 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 { APIClient } from "@api/APIClient";
import { Field, FieldArray, FieldProps, FormikValues } from "formik";
import { EmptyListState } from "@components/emptystates"; import { EmptyListState } from "@components/emptystates";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils"; 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 { DeleteModal } from "@components/modals";
import { CollapsableSection } from "./details"; 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"; import { TextArea } from "@components/inputs/input";
interface FilterActionsProps { interface FilterActionsProps {
@ -29,7 +33,8 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
{ refetchOnWindowFocus: false } { refetchOnWindowFocus: false }
); );
const newAction = { const newAction: Action = {
id: 0,
name: "new action", name: "new action",
enabled: true, enabled: true,
type: "TEST", type: "TEST",
@ -43,7 +48,7 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
paused: false, paused: false,
ignore_rules: false, ignore_rules: false,
skip_hash_check: false, skip_hash_check: false,
content_layout: "", content_layout: "" || undefined,
limit_upload_speed: 0, limit_upload_speed: 0,
limit_download_speed: 0, limit_download_speed: 0,
limit_ratio: 0, limit_ratio: 0,
@ -57,8 +62,8 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
webhook_type: "", webhook_type: "",
webhook_method: "", webhook_method: "",
webhook_data: "", webhook_data: "",
webhook_headers: [] webhook_headers: [],
// client_id: 0, client_id: 0
}; };
return ( return (
@ -108,7 +113,7 @@ interface TypeFormProps {
} }
const TypeForm = ({ action, idx, clients }: TypeFormProps) => { const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue } = useFormikContext();
const resetClientField = (action: Action, idx: number, prevActionType: string): void => { const resetClientField = (action: Action, idx: number, prevActionType: string): void => {
const fieldName = `actions.${idx}.client_id`; const fieldName = `actions.${idx}.client_id`;
@ -127,7 +132,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
action.type === "READARR" || action.type === "READARR" ||
action.type === "SABNZBD" 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); resetClientField(action, idx, prevActionType);
} }
setPrevActionType(action.type); setPrevActionType(action.type);
}, [action.type, idx, setFieldValue]); }, [action.type, idx, setFieldValue]);
switch (action.type) { switch (action.type) {
case "TEST": case "TEST":
return ( return (
@ -209,7 +215,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
label="Save path" label="Save path"
columns={6} columns={6}
placeholder="eg. /full/path/to/download_folder" 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>
</div> </div>
@ -219,13 +225,13 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
label="Category" label="Category"
columns={6} columns={6}
placeholder="eg. category" 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 <TextField
name={`actions.${idx}.tags`} name={`actions.${idx}.tags`}
label="Tags" label="Tags"
columns={6} columns={6}
placeholder="eg. tag1,tag2" 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> </div>
<CollapsableSection title="Rules" subtitle="client options"> <CollapsableSection title="Rules" subtitle="client options">
@ -266,7 +272,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
<SwitchGroup <SwitchGroup
name={`actions.${idx}.ignore_rules`} name={`actions.${idx}.ignore_rules`}
label="Ignore client 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>
<div className="col-span-6"> <div className="col-span-6">
<Select <Select
@ -508,7 +514,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
label="Category" label="Category"
columns={6} columns={6}
placeholder="eg. category" 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>
</div> </div>
); );
@ -626,7 +632,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
label="Type" label="Type"
optionDefaultText="Select type" optionDefaultText="Select type"
options={ActionTypeOptions} 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} /> <TextField name={`actions.${idx}.name`} label="Name" columns={6} />

View file

@ -1,8 +1,10 @@
import React, { useRef } from "react"; import React, { useEffect, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom"; import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Form, Formik, FormikValues, useFormikContext } from "formik"; 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 { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
import { 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() { export default function FilterDetails() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
@ -209,6 +276,9 @@ export default function FilterDetails() {
if (a.type === "WEBHOOK") { if (a.type === "WEBHOOK") {
a.webhook_method = "POST"; a.webhook_method = "POST";
a.webhook_type = "JSON"; a.webhook_type = "JSON";
} else {
a.webhook_method = "";
a.webhook_type = "";
} }
}); });
@ -312,9 +382,11 @@ export default function FilterDetails() {
} as Filter} } as Filter}
onSubmit={handleSubmit} onSubmit={handleSubmit}
enableReinitialize={true} enableReinitialize={true}
validationSchema={toFormikValidationSchema(schema)}
> >
{({ values, dirty, resetForm }) => ( {({ values, dirty, resetForm }) => (
<Form> <Form>
<FormErrorNotification />
<Routes> <Routes>
<Route index element={<General />} /> <Route index element={<General />} />
<Route path="movies-tv" element={<MoviesTv />} /> <Route path="movies-tv" element={<MoviesTv />} />

View file

@ -6973,6 +6973,8 @@ __metadata:
vite: ^4.2.1 vite: ^4.2.1
vite-plugin-pwa: ^0.14.6 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
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -7272,3 +7274,20 @@ __metadata:
checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700
languageName: node languageName: node
linkType: hard 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