feat: add filter dropdown #26 (#142)

feat(FilterItemDropdown): added a filter dropdown component from #26 

* fix(react-multi-select-component): adjusted the component to fit in with other components when comparing across multiple browsers. (where firefox consistently handles pixels and rem's, chromium doesn't agree).

refactor(input): removed shadow from input components to match react-multi-select-component look where needed.

refactor(SwitchGroup): added a small top margin for a less dense look. cleaned up surrounding code.

refactor(DeleteModal): adjusted the background color to fit more nicely across dark/light themes. made the exclamation icon bigger and removed the circle container.

refactor(Logs): adjusted text color on the light theme. cleaned up the code.

refactor(FilterAddForm): adapted to conform with the changes.

feat(FilterItemDropdown): added a dropdown component to the filter list as proposed in #26. could use a better look, though. also, cleaned up surrounding code and got rid of pesky negative margins.

refactor(FilterListItem): made the table striped when using the dark theme. adapter for the dropdown component.

refactor(filters/details):
- removed needless border properties from remove button, which left artifacts after de-focusing the button. also, removed the shadow from the cancel button.
- added invalidation of the filter list on a delete mutation before redirecting to /filters.
- modified certain group descriptions a bit in order to make them a bit more concise.
- overall, cleaned up the surrounding code further.
This commit is contained in:
stacksmash76 2022-02-17 20:59:06 +01:00 committed by GitHub
parent d4d9169210
commit 279d4ff7a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 356 additions and 206 deletions

View file

@ -41,7 +41,7 @@ export const TextField = ({
id={name} id={name}
type="text" type="text"
autoComplete={autoComplete} autoComplete={autoComplete}
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm" className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100"
placeholder={placeholder} placeholder={placeholder}
/> />
@ -99,7 +99,7 @@ export const PasswordField = ({
id={name} id={name}
type={isVisible ? "text" : "password"} type={isVisible ? "text" : "password"}
autoComplete={autoComplete} autoComplete={autoComplete}
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700", "mt-2 block w-full dark:bg-gray-800 shadow-sm dark:text-gray-100 sm:text-sm rounded-md")} className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700", "mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md")}
placeholder={placeholder} placeholder={placeholder}
/> />
@ -150,7 +150,7 @@ export const NumberField = ({
meta.touched && meta.error meta.touched && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500" ? "focus:ring-red-500 focus:border-red-500 border-red-500"
: "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300", : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300",
"mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 shadow-sm dark:text-gray-100 sm:text-sm rounded-md" "mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-100 rounded-md"
)} )}
placeholder={placeholder} placeholder={placeholder}
/> />

View file

@ -77,7 +77,6 @@ const SwitchGroup = ({
label, label,
description description
}: SwitchGroupProps) => ( }: SwitchGroupProps) => (
<ul className="mt-2 divide-y divide-gray-200">
<HeadlessSwitch.Group as="li" className="py-4 flex items-center justify-between"> <HeadlessSwitch.Group as="li" className="py-4 flex items-center justify-between">
{label && <div className="flex flex-col"> {label && <div className="flex flex-col">
<HeadlessSwitch.Label as="p" className="text-sm font-medium text-gray-900 dark:text-gray-100" <HeadlessSwitch.Label as="p" className="text-sm font-medium text-gray-900 dark:text-gray-100"
@ -85,7 +84,7 @@ const SwitchGroup = ({
{label} {label}
</HeadlessSwitch.Label> </HeadlessSwitch.Label>
{description && ( {description && (
<HeadlessSwitch.Description className="text-sm text-gray-500 dark:text-gray-400"> <HeadlessSwitch.Description className="text-sm mt-1 text-gray-500 dark:text-gray-400">
{description} {description}
</HeadlessSwitch.Description> </HeadlessSwitch.Description>
)} )}
@ -110,7 +109,6 @@ const SwitchGroup = ({
'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500' 'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
)} )}
> >
{/* <span className="sr-only">{label}</span> */}
<span <span
aria-hidden="true" aria-hidden="true"
className={classNames( className={classNames(
@ -122,33 +120,7 @@ const SwitchGroup = ({
)} )}
</Field> </Field>
{/* <Field
name={name}
defaultValue={defaultValue as any}
render={({input: {onChange, checked, value}}) => (
<Switch
value={value}
checked={value}
onChange={onChange}
className={classNames(
value ? 'bg-teal-500' : 'bg-gray-200',
'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
value ? 'translate-x-5' : 'translate-x-0',
'inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200'
)}
/>
</Switch>
)}
/> */}
</HeadlessSwitch.Group> </HeadlessSwitch.Group>
</ul> );
)
export { SwitchGroup } export { SwitchGroup }

View file

@ -31,7 +31,7 @@ export const DeleteModal: FC<DeleteModalProps> = ({ isOpen, buttonRef, toggle, d
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <Dialog.Overlay className="fixed inset-0 bg-gray-700/60 dark:bg-black/60 transition-opacity" />
</Transition.Child> </Transition.Child>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
@ -46,12 +46,10 @@ export const DeleteModal: FC<DeleteModalProps> = ({ isOpen, buttonRef, toggle, d
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="inline-block align-bottom rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-gray-700 px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-400 sm:mx-0 sm:h-10 sm:w-10"> <ExclamationIcon className="h-16 w-16 text-red-500 dark:text-red-500" aria-hidden="true" />
<ExclamationIcon className="h-6 w-6 text-red-600 dark:text-red-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900 dark:text-white"> <Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
{title} {title}

View file

@ -12,14 +12,17 @@ import DEBUG from "../../components/debug";
import Toast from '../../components/notifications/Toast'; import Toast from '../../components/notifications/Toast';
function FilterAddForm({ isOpen, toggle }: any) { function FilterAddForm({ isOpen, toggle }: any) {
const mutation = useMutation((filter: Filter) => APIClient.filters.create(filter), { const mutation = useMutation(
onSuccess: () => { (filter: Filter) => APIClient.filters.create(filter),
queryClient.invalidateQueries('filter'); {
toast.custom((t) => <Toast type="success" body="Filter was added" t={t} />) onSuccess: (_, filter) => {
queryClient.invalidateQueries("filters");
toast.custom((t) => <Toast type="success" body={`Filter ${filter.name} was added`} t={t} />);
toggle() toggle();
} }
}) }
)
const handleSubmit = (data: any) => mutation.mutate(data); const handleSubmit = (data: any) => mutation.mutate(data);
const validate = (values: any) => values.name ? {} : { name: "Required" }; const validate = (values: any) => values.name ? {} : { name: "Required" };

File diff suppressed because one or more lines are too long

View file

@ -5,9 +5,9 @@ import { Disclosure, Menu, Transition } from "@headlessui/react";
import { ExternalLinkIcon } from "@heroicons/react/solid"; import { ExternalLinkIcon } from "@heroicons/react/solid";
import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline"; import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline";
import Logs from "./Logs";
import Settings from "./Settings"; import Settings from "./Settings";
import { Logs } from "./Logs";
import { Releases } from "./Releases"; import { Releases } from "./Releases";
import { Dashboard } from "./Dashboard"; import { Dashboard } from "./Dashboard";
import { FilterDetails, Filters } from "./filters"; import { FilterDetails, Filters } from "./filters";

View file

@ -7,10 +7,9 @@ type LogEvent = {
message: string; message: string;
}; };
export default function Logs() { export const Logs = () => {
const [logs, setLogs] = useState<LogEvent[]>([]) const messagesEndRef = useRef<HTMLDivElement>(null);
const [logs, setLogs] = useState<LogEvent[]>([]);
const messagesEndRef: any = useRef(null)
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" }) messagesEndRef.current?.scrollIntoView({ behavior: "auto" })
@ -41,12 +40,12 @@ export default function Logs() {
<div className=" overflow-y-auto p-2 rounded-lg h-96 bg-gray-100 dark:bg-gray-900"> <div className=" overflow-y-auto p-2 rounded-lg h-96 bg-gray-100 dark:bg-gray-900">
{logs.map((a, idx) => ( {logs.map((a, idx) => (
<p key={idx}> <p key={idx}>
<span className="font-mono text-gray-600 mr-2">{a.time}</span> <span className="font-mono text-gray-500 dark:text-gray-600 mr-2">{a.time}</span>
{a.level === "TRACE" && <span className="font-mono font-semibold text-purple-300">{a.level}</span>} {a.level === "TRACE" && <span className="font-mono font-semibold text-purple-300">{a.level}</span>}
{a.level === "DEBUG" && <span className="font-mono font-semibold text-yellow-500">{a.level}</span>} {a.level === "DEBUG" && <span className="font-mono font-semibold text-yellow-500">{a.level}</span>}
{a.level === "INFO" && <span className="font-mono font-semibold text-green-500">{a.level} </span>} {a.level === "INFO" && <span className="font-mono font-semibold text-green-500">{a.level} </span>}
{a.level === "ERROR" && <span className="font-mono font-semibold text-red-500">{a.level}</span>} {a.level === "ERROR" && <span className="font-mono font-semibold text-red-500">{a.level}</span>}
<span className="ml-2 text-gray-300">{a.message}</span> <span className="ml-2 text-black dark:text-gray-300">{a.message}</span>
</p> </p>
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View file

@ -102,7 +102,7 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: any) => {
<div className="mt-4 pt-4 flex justify-between"> <div className="mt-4 pt-4 flex justify-between">
<button <button
type="button" type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-red-500 light:bg-red-100 light:hover:bg-red-200 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm" className="inline-flex items-center justify-center px-4 py-2 rounded-md text-red-700 dark:text-red-500 light:bg-red-100 light:hover:bg-red-200 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal} onClick={toggleDeleteModal}
> >
Remove Remove
@ -112,7 +112,7 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: any) => {
{/* {dirty && <span className="mr-4 text-sm text-gray-500">Unsaved changes..</span>} */} {/* {dirty && <span className="mr-4 text-sm text-gray-500">Unsaved changes..</span>} */}
<button <button
type="button" type="button"
className="light:bg-white light:border light:border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 dark:text-gray-500 light:hover:bg-gray-50 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" className="light:bg-white light:border light:border-gray-300 rounded-md py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 dark:text-gray-500 light:hover:bg-gray-50 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={reset} onClick={reset}
> >
Cancel Cancel
@ -135,7 +135,7 @@ export default function FilterDetails() {
const { filterId } = useParams<{ filterId: string }>(); const { filterId } = useParams<{ filterId: string }>();
const { isLoading, data: filter } = useQuery( const { isLoading, data: filter } = useQuery(
['filter', +filterId], ["filter", +filterId],
() => APIClient.filters.getByID(parseInt(filterId)), () => APIClient.filters.getByID(parseInt(filterId)),
{ {
retry: false, retry: false,
@ -147,16 +147,23 @@ export default function FilterDetails() {
const updateMutation = useMutation( const updateMutation = useMutation(
(filter: Filter) => APIClient.filters.update(filter), (filter: Filter) => APIClient.filters.update(filter),
{ {
onSuccess: () => { onSuccess: (_, currentFilter) => {
toast.custom((t) => <Toast type="success" body={`${filter?.name} was updated successfully`} t={t} />) toast.custom((t) => (
queryClient.invalidateQueries(["filter", filter?.id]); <Toast type="success" body={`${currentFilter.name} was updated successfully`} t={t} />
));
queryClient.invalidateQueries(["filter", currentFilter.id]);
} }
} }
); );
const deleteMutation = useMutation((id: number) => APIClient.filters.delete(id), { const deleteMutation = useMutation((id: number) => APIClient.filters.delete(id), {
onSuccess: () => { onSuccess: () => {
toast.custom((t) => <Toast type="success" body={`${filter?.name} was deleted`} t={t} />) toast.custom((t) => (
<Toast type="success" body={`${filter?.name} was deleted`} t={t} />
));
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries("filters");
// redirect // redirect
history.push("/filters") history.push("/filters")
@ -346,7 +353,7 @@ function General() {
</div> </div>
<div className="mt-6 lg:pb-8"> <div className="mt-6 lg:pb-8">
<TitleSubtitle title="Rules" subtitle="Set rules" /> <TitleSubtitle title="Rules" subtitle="Specify rules on how torrents should be handled/selected" />
<div className="mt-6 grid grid-cols-12 gap-6"> <div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="min_size" label="Min size" columns={6} placeholder="" /> <TextField name="min_size" label="Min size" columns={6} placeholder="" />
@ -356,7 +363,7 @@ function General() {
</div> </div>
<div className="border-t dark:border-gray-700"> <div className="border-t dark:border-gray-700">
<SwitchGroup name="enabled" label="Enabled" description="Enabled or disable filter." /> <SwitchGroup name="enabled" label="Enabled" description="Enable or disable this filter" />
</div> </div>
</div> </div>
@ -372,7 +379,7 @@ function MoviesTv() {
</div> </div>
<div className="mt-6 lg:pb-8"> <div className="mt-6 lg:pb-8">
<TitleSubtitle title="Seasons and Episodes" subtitle="Set seaons and episodes" /> <TitleSubtitle title="Seasons and Episodes" subtitle="Set season and episode match constraints" />
<div className="mt-6 grid grid-cols-12 gap-6"> <div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="seasons" label="Seasons" columns={8} placeholder="eg. 1,3,2-6" /> <TextField name="seasons" label="Seasons" columns={8} placeholder="eg. 1,3,2-6" />
@ -381,7 +388,7 @@ function MoviesTv() {
</div> </div>
<div className="mt-6 lg:pb-8"> <div className="mt-6 lg:pb-8">
<TitleSubtitle title="Quality" subtitle="Resolution, source etc." /> <TitleSubtitle title="Quality" subtitle="Set resolution, source, codec and related match constraints" />
<div className="mt-6 grid grid-cols-12 gap-6"> <div className="mt-6 grid grid-cols-12 gap-6">
<MultiSelect name="resolutions" options={RESOLUTION_OPTIONS} label="resolutions" columns={6} /> <MultiSelect name="resolutions" options={RESOLUTION_OPTIONS} label="resolutions" columns={6} />
@ -467,7 +474,7 @@ function Advanced() {
<div className="flex justify-between items-center cursor-pointer" onClick={toggleReleases}> <div className="flex justify-between items-center cursor-pointer" onClick={toggleReleases}>
<div className="-ml-2 -mt-2 flex flex-wrap items-baseline"> <div className="-ml-2 -mt-2 flex flex-wrap items-baseline">
<h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Releases</h3> <h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Releases</h3>
<p className="ml-2 mt-1 text-sm text-gray-500 dark:text-gray-400 truncate">Match or ignore</p> <p className="ml-2 mt-1 text-sm text-gray-500 dark:text-gray-400 truncate">Match only certain release names and/or ignore other release names</p>
</div> </div>
<div className="mt-3 sm:mt-0 sm:ml-4"> <div className="mt-3 sm:mt-0 sm:ml-4">
<button <button
@ -490,7 +497,7 @@ function Advanced() {
<div className="flex justify-between items-center cursor-pointer" onClick={toggleGroups}> <div className="flex justify-between items-center cursor-pointer" onClick={toggleGroups}>
<div className="-ml-2 -mt-2 flex flex-wrap items-baseline"> <div className="-ml-2 -mt-2 flex flex-wrap items-baseline">
<h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Groups</h3> <h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Groups</h3>
<p className="ml-2 mt-1 text-sm text-gray-500 dark:text-gray-400 truncate">Match or ignore</p> <p className="ml-2 mt-1 text-sm text-gray-500 dark:text-gray-400 truncate">Match only certain groups and/or ignore other groups</p>
</div> </div>
<div className="mt-3 sm:mt-0 sm:ml-4"> <div className="mt-3 sm:mt-0 sm:ml-4">
<button <button
@ -594,7 +601,7 @@ interface FilterActionsProps {
function FilterActions({ filter, values }: FilterActionsProps) { function FilterActions({ filter, values }: FilterActionsProps) {
const { data } = useQuery( const { data } = useQuery(
['filter', 'download_clients'], ["filter", "download_clients"],
APIClient.download_clients.getAll, APIClient.download_clients.getAll,
{ refetchOnWindowFocus: false } { refetchOnWindowFocus: false }
); );
@ -669,21 +676,6 @@ function FilterActionsItem({ action, clients, idx, remove }: FilterActionsItemPr
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const [edit, toggleEdit] = useToggle(false); const [edit, toggleEdit] = useToggle(false);
// const enabledMutation = useMutation(
// (actionID: number) => APIClient.actions.toggleEnable(actionID),
// {
// onSuccess: () => {
// // queryClient.invalidateQueries(["filter", filterID]);
// },
// }
// );
// const toggleActive = () => {
// console.log("action: ", action);
// enabledMutation.mutate(action.id);
// };
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const TypeForm = (actionType: ActionType) => { const TypeForm = (actionType: ActionType) => {

View file

@ -1,8 +1,14 @@
import { useState } from "react"; import { Fragment, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Switch } from "@headlessui/react"; import { Menu, Switch, Transition } from "@headlessui/react";
import { useMutation, useQuery } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import {
TrashIcon,
PencilAltIcon,
SwitchHorizontalIcon,
DotsHorizontalIcon,
} from "@heroicons/react/outline";
import { queryClient } from "../../App"; import { queryClient } from "../../App";
import { classNames } from "../../utils"; import { classNames } from "../../utils";
@ -11,19 +17,19 @@ import { useToggle } from "../../hooks/hooks";
import { APIClient } from "../../api/APIClient"; import { APIClient } from "../../api/APIClient";
import Toast from "../../components/notifications/Toast"; import Toast from "../../components/notifications/Toast";
import { EmptyListState } from "../../components/emptystates"; import { EmptyListState } from "../../components/emptystates";
import { DeleteModal } from "../../components/modals";
export default function Filters() { export default function Filters() {
const [createFilterIsOpen, toggleCreateFilter] = useToggle(false) const [createFilterIsOpen, toggleCreateFilter] = useToggle(false)
const { isLoading, error, data } = useQuery( const { isLoading, error, data } = useQuery(
'filter', "filters",
APIClient.filters.getAll, APIClient.filters.getAll,
{ refetchOnWindowFocus: false } { refetchOnWindowFocus: false }
); );
if (isLoading) { if (isLoading)
return null return null;
}
if (error) if (error)
return (<p>An error has occurred: </p>); return (<p>An error has occurred: </p>);
@ -48,13 +54,12 @@ export default function Filters() {
</div> </div>
</header> </header>
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8 relative">
<div className="bg-white dark:bg-gray-800 light:rounded-lg shadow-lg"> {data && data.length > 0 ? (
<div className="relative inset-0 light:py-3 light:px-3 light:sm:px-3 light:lg:px-3 h-full"> <FilterList filters={data} />
{data && data.length > 0 ? <FilterList filters={data} /> : ) : (
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />} <EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />
</div> )}
</div>
</div> </div>
</main> </main>
) )
@ -66,49 +71,161 @@ interface FilterListProps {
function FilterList({ filters }: FilterListProps) { function FilterList({ filters }: FilterListProps) {
return ( return (
<div className="flex flex-col"> <div className="overflow-x-auto align-middle min-w-full rounded-lg shadow-lg">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <table className="min-w-full">
<div className="light:py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"> <thead className="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<div className="shadow overflow-hidden border-b border-gray-200 dark:border-gray-800 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
<tr> <tr>
{["Enabled", "Name", "Indexers"].map((label) => (
<th <th
key={`th-${label}`}
scope="col" scope="col"
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" className="px-6 py-2.5 text-left text-xs font-medium uppercase tracking-wider"
> >
Enabled {label}
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider"
>
Indexers
</th> </th>
))}
<th scope="col" className="relative px-6 py-3"> <th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span> <span className="sr-only">Edit</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filters.map((filter: Filter, idx) => ( {filters.map((filter: Filter, idx) => (
<FilterListItem filter={filter} key={filter.id} idx={idx} /> <FilterListItem filter={filter} key={filter.id} idx={idx} />
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
</div>
) )
} }
interface FilterItemDropdownProps {
filter: Filter;
onToggle: (newState: boolean) => void;
}
const FilterItemDropdown = ({
filter,
onToggle
}: FilterItemDropdownProps) => {
const cancelModalButtonRef = useRef(null);
const queryClient = useQueryClient();
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const deleteMutation = useMutation(
(id: number) => APIClient.filters.delete(id),
{
onSuccess: () => {
queryClient.invalidateQueries("filters");
queryClient.invalidateQueries(["filter", filter.id]);
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} was deleted`} t={t} />);
}
}
);
return (
<Menu as="div">
<DeleteModal
isOpen={deleteModalIsOpen}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={() => {
deleteMutation.mutate(filter.id);
toggleDeleteModal();
}}
title={`Remove filter: ${filter.name}`}
text="Are you sure you want to remove this filter? This action cannot be undone."
/>
<Menu.Button className="px-4 py-2">
<DotsHorizontalIcon
className="w-5 h-5 text-gray-700 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-400"
aria-hidden="true"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 w-56 mt-2 origin-top-right bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<Link
to={`filters/${filter.id.toString()}`}
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
>
<PencilAltIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Edit
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
onClick={() => onToggle(!filter.enabled)}
>
<SwitchHorizontalIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Toggle
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
onClick={() => toggleDeleteModal()}
>
<TrashIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Delete
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}
interface FilterListItemProps { interface FilterListItemProps {
filter: Filter; filter: Filter;
idx: number; idx: number;
@ -117,24 +234,39 @@ interface FilterListItemProps {
function FilterListItem({ filter, idx }: FilterListItemProps) { function FilterListItem({ filter, idx }: FilterListItemProps) {
const [enabled, setEnabled] = useState(filter.enabled) const [enabled, setEnabled] = useState(filter.enabled)
const updateMutation = useMutation((status: boolean) => APIClient.filters.toggleEnable(filter.id, status), { const updateMutation = useMutation(
(status: boolean) => APIClient.filters.toggleEnable(filter.id, status),
{
onSuccess: () => { onSuccess: () => {
toast.custom((t) => <Toast type="success" body={`${filter.name} was ${enabled ? "disabled" : "enabled"} successfully`} t={t} />) toast.custom((t) => <Toast type="success" body={`${filter.name} was ${enabled ? "disabled" : "enabled"} successfully`} t={t} />)
queryClient.invalidateQueries("filter"); // We need to invalidate both keys here.
// The filters key is used on the /filters page,
// while the ["filter", filter.id] key is used on the details page.
queryClient.invalidateQueries("filters");
queryClient.invalidateQueries(["filter", filter?.id]);
} }
}) }
);
const toggleActive = (status: boolean) => { const toggleActive = (status: boolean) => {
setEnabled(status) setEnabled(status);
// call api updateMutation.mutate(status);
updateMutation.mutate(status)
} }
return ( return (
<tr key={filter.id} <tr
className={idx % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-800'}> key={filter.id}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100"> className={classNames(
idx % 2 === 0 ?
"bg-white dark:bg-[#2e2e31]" :
"bg-gray-50 dark:bg-gray-800",
"hover:bg-gray-100 dark:hover:bg-[#222225]"
)}
>
<td
className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100"
>
<Switch <Switch
checked={enabled} checked={enabled}
onChange={toggleActive} onChange={toggleActive}
@ -154,16 +286,28 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
</Switch> </Switch>
</td> </td>
<td className="px-6 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100"> <td className="px-6 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<Link to={`filters/${filter.id.toString()}`} className="dark:hover:text-gray-400 w-full py-4 flex"> <Link
to={`filters/${filter.id.toString()}`}
className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex"
>
{filter.name} {filter.name}
</Link> </Link>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{filter.indexers && filter.indexers.map(t => <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<span key={t.id} className="mr-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400">{t.name}</span>)}</td> {filter.indexers && filter.indexers.map((t) => (
<span
key={t.id}
className="mr-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
>
{t.name}
</span>
))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link to={`filters/${filter.id.toString()}`} className="text-indigo-600 dark:text-gray-200 hover:text-indigo-900 dark:hover:text-gray-400"> <FilterItemDropdown
Edit filter={filter}
</Link> onToggle={toggleActive}
/>
</td> </td>
</tr> </tr>
) )