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",
"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",

View file

@ -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>
</>
)}

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 { 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} />

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 { 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 />} />

View file

@ -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