feat(filters): improve list view with filtering (#465)

This commit is contained in:
ze0s 2022-09-22 11:54:17 +02:00 committed by GitHub
parent 63d4c21e54
commit f5faf066a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 576 additions and 94 deletions

View file

@ -77,7 +77,7 @@ export const APIClient = {
apikeys: {
getAll: () => appClient.Get<APIKey[]>("api/keys"),
create: (key: APIKey) => appClient.Post("api/keys", key),
delete: (key: string) => appClient.Delete(`api/keys/${key}`),
delete: (key: string) => appClient.Delete(`api/keys/${key}`)
},
config: {
get: () => appClient.Get<Config>("api/config")
@ -91,6 +91,24 @@ export const APIClient = {
},
filters: {
getAll: () => appClient.Get<Filter[]>("api/filters"),
find: (indexers: string[], sortOrder: string) => {
const params = new URLSearchParams();
if (sortOrder.length > 0) {
params.append("sort", sortOrder);
}
indexers?.forEach((i) => {
if (i !== undefined || i !== "") {
params.append("indexer", i);
}
});
const p = params.toString();
const q = p ? `?${p}` : "";
return appClient.Get<Filter[]>(`api/filters${q}`);
},
getByID: (id: number) => appClient.Get<Filter>(`api/filters/${id}`),
create: (filter: Filter) => appClient.Post("api/filters", filter),
update: (filter: Filter) => appClient.Put(`api/filters/${filter.id}`, filter),

View file

@ -452,7 +452,7 @@ export function Music() {
export function Advanced() {
return (
<div>
<CollapsableSection title="Releases" subtitle="Match only certain release names and/or ignore other release names">
<CollapsableSection defaultOpen={true} title="Releases" subtitle="Match only certain release names and/or ignore other release names">
<div className="grid col-span-12 gap-6">
<div
className="col-span-12 flex p-4 text-sm text-yellow-700 bg-yellow-100 rounded-lg dark:bg-yellow-200 dark:text-yellow-800"
@ -477,12 +477,12 @@ export function Advanced() {
</div>
</CollapsableSection>
<CollapsableSection title="Groups" subtitle="Match only certain groups and/or ignore other groups">
<CollapsableSection defaultOpen={true} title="Groups" subtitle="Match only certain groups and/or ignore other groups">
<TextField name="match_release_groups" label="Match release groups" columns={6} placeholder="eg. group1,group2" />
<TextField name="except_release_groups" label="Except release groups" columns={6} placeholder="eg. badgroup1,badgroup2" />
</CollapsableSection>
<CollapsableSection title="Categories and tags" subtitle="Match or ignore categories or tags">
<CollapsableSection defaultOpen={true} title="Categories and tags" subtitle="Match or ignore categories or tags">
<TextField name="match_categories" label="Match categories" columns={6} placeholder="eg. *category*,category1" />
<TextField name="except_categories" label="Except categories" columns={6} placeholder="eg. *category*" />
@ -490,17 +490,17 @@ export function Advanced() {
<TextField name="except_tags" label="Except tags" columns={6} placeholder="eg. tag1,tag2" />
</CollapsableSection>
<CollapsableSection title="Uploaders" subtitle="Match or ignore uploaders">
<CollapsableSection defaultOpen={true} title="Uploaders" subtitle="Match or ignore uploaders">
<TextField name="match_uploaders" label="Match uploaders" columns={6} placeholder="eg. uploader1" />
<TextField name="except_uploaders" label="Except uploaders" columns={6} placeholder="eg. anonymous" />
</CollapsableSection>
<CollapsableSection title="Origins" subtitle="Match Internals, scene, p2p etc if announced">
<CollapsableSection defaultOpen={true} title="Origins" subtitle="Match Internals, scene, p2p etc if announced">
<MultiSelect name="origins" options={ORIGIN_OPTIONS} label="Match Origins" columns={6} creatable={true} />
<MultiSelect name="except_origins" options={ORIGIN_OPTIONS} label="Except Origins" columns={6} creatable={true} />
</CollapsableSection>
<CollapsableSection title="Freeleech" subtitle="Match only freeleech and freeleech percent">
<CollapsableSection defaultOpen={true} title="Freeleech" subtitle="Match only freeleech and freeleech percent">
<div className="col-span-6">
<SwitchGroup name="freeleech" label="Freeleech" />
</div>

View file

@ -1,13 +1,16 @@
import { Fragment, useRef, useState } from "react";
import { Dispatch, FC, Fragment, MouseEventHandler, useReducer, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "react-hot-toast";
import { Menu, Switch, Transition } from "@headlessui/react";
import { Listbox, Menu, Switch, Transition } from "@headlessui/react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
TrashIcon,
CheckIcon,
ChevronDownIcon,
DotsHorizontalIcon,
DuplicateIcon,
PencilAltIcon,
SwitchHorizontalIcon,
DotsHorizontalIcon, DuplicateIcon
TrashIcon
} from "@heroicons/react/outline";
import { queryClient } from "../../App";
@ -19,21 +22,57 @@ import Toast from "../../components/notifications/Toast";
import { EmptyListState } from "../../components/emptystates";
import { DeleteModal } from "../../components/modals";
type FilterListState = {
indexerFilter: string[],
sortOrder: string;
status: string;
};
const initialState: FilterListState = {
indexerFilter: [],
sortOrder: "",
status: ""
};
enum ActionType {
INDEXER_FILTER_CHANGE = "INDEXER_FILTER_CHANGE",
INDEXER_FILTER_RESET = "INDEXER_FILTER_RESET",
SORT_ORDER_CHANGE = "SORT_ORDER_CHANGE",
SORT_ORDER_RESET = "SORT_ORDER_RESET",
STATUS_CHANGE = "STATUS_RESET",
STATUS_RESET = "STATUS_RESET"
}
type Actions =
| { type: ActionType.STATUS_CHANGE; payload: string }
| { type: ActionType.STATUS_RESET; payload: "" }
| { type: ActionType.SORT_ORDER_CHANGE; payload: string }
| { type: ActionType.SORT_ORDER_RESET; payload: "" }
| { type: ActionType.INDEXER_FILTER_CHANGE; payload: string[] }
| { type: ActionType.INDEXER_FILTER_RESET; payload: [] };
const FilterListReducer = (state: FilterListState, action: Actions): FilterListState => {
switch (action.type) {
case ActionType.INDEXER_FILTER_CHANGE:
return { ...state, indexerFilter: action.payload };
case ActionType.INDEXER_FILTER_RESET:
return { ...state, indexerFilter: [] };
case ActionType.SORT_ORDER_CHANGE:
return { ...state, sortOrder: action.payload };
case ActionType.SORT_ORDER_RESET:
return { ...state, sortOrder: "" };
case ActionType.STATUS_CHANGE:
return { ...state, status: action.payload };
case ActionType.STATUS_RESET:
return { ...state };
default:
throw new Error(`Unhandled action type: ${action}`);
}
};
export default function Filters() {
const [createFilterIsOpen, toggleCreateFilter] = useToggle(false);
const { isLoading, error, data } = useQuery(
["filters"],
() => APIClient.filters.getAll(),
{ refetchOnWindowFocus: false }
);
if (isLoading)
return null;
if (error)
return (<p>An error has occurred: </p>);
return (
<main>
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
@ -55,51 +94,111 @@ export default function Filters() {
</div>
</header>
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8 relative">
{data && data.length > 0 ? (
<FilterList filters={data} />
) : (
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />
)}
</div>
<FilterList toggleCreateFilter={toggleCreateFilter} />
</main>
);
}
interface FilterListProps {
filters: Filter[];
function filteredData(data: Filter[], status: string) {
let filtered: Filter[];
const enabledItems = data?.filter(f => f.enabled);
const disabledItems = data?.filter(f => !f.enabled);
if (status === "enabled") {
filtered = enabledItems;
} else if (status === "disabled") {
filtered = disabledItems;
} else {
filtered = data;
}
return {
all: data,
filtered: filtered,
enabled: enabledItems,
disabled: disabledItems
};
}
function FilterList({ filters }: FilterListProps) {
function FilterList({ toggleCreateFilter }: any) {
const [{ indexerFilter, sortOrder, status }, dispatchFilter] =
useReducer(FilterListReducer, initialState);
const { error, data } = useQuery(
["filters", indexerFilter, sortOrder],
() => APIClient.filters.find(indexerFilter, sortOrder),
{ refetchOnWindowFocus: false }
);
if (error) {
return (<p>An error has occurred: </p>);
}
const filtered = filteredData(data ?? [], status);
return (
<div className="overflow-x-auto align-middle min-w-full rounded-t-md rounded-b-lg shadow-lg">
<table className="min-w-full">
<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">
<tr>
{["Enabled", "Name", "Actions", "Indexers"].map((label) => (
<th
key={`th-${label}`}
scope="col"
className="px-4 pt-4 pb-3 text-left text-xs font-medium uppercase tracking-wider"
>
{label}
</th>
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8 relative">
<div className="align-middle min-w-full rounded-t-md rounded-b-lg shadow-lg bg-gray-50 dark:bg-gray-800">
<div className="flex justify-between px-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-4">
<StatusButton data={filtered.all} label="All" value="" currentValue={status} dispatch={dispatchFilter} />
<StatusButton data={filtered.enabled} label="Enabled" value="enabled" currentValue={status} dispatch={dispatchFilter} />
<StatusButton data={filtered.disabled} label="Disabled" value="disabled" currentValue={status} dispatch={dispatchFilter} />
</div>
<div className="flex items-center gap-5">
<IndexerSelectFilter dispatch={dispatchFilter} />
<SortSelectFilter dispatch={dispatchFilter} />
</div>
</div>
{data && data.length > 0 ? (
<ol className="min-w-full">
{filtered.filtered.map((filter: Filter, idx) => (
<FilterListItem filter={filter} key={filter.id} idx={idx} />
))}
<th scope="col" className="relative px-4 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filters.map((filter: Filter, idx) => (
<FilterListItem filter={filter} key={filter.id} idx={idx} />
))}
</tbody>
</table>
</ol>
) : (
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />
)}
</div>
</div>
);
}
interface StatusButtonProps {
data: unknown[];
label: string;
value: string;
currentValue: string;
dispatch: Dispatch<Actions>;
}
const StatusButton = ({ data, label, value, currentValue, dispatch }: StatusButtonProps) => {
const setFilter: MouseEventHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (value == undefined || value == "") {
dispatch({ type: ActionType.STATUS_RESET, payload: "" });
} else {
dispatch({ type: ActionType.STATUS_CHANGE, payload: e.currentTarget.value });
}
};
return (
<button
className={classNames(
currentValue == value ? "font-bold border-b-2 border-blue-500 dark:text-gray-100 text-gray-900" : "font-medium text-gray-600 dark:text-gray-400",
"py-4 pb-4 text-left text-xs tracking-wider"
)}
onClick={setFilter}
value={value}
>
{data?.length ?? 0} {label}
</button>
);
};
interface FilterItemDropdownProps {
filter: Filter;
onToggle: (newState: boolean) => void;
@ -259,8 +358,8 @@ const FilterItemDropdown = ({
};
interface FilterListItemProps {
filter: Filter;
idx: number;
filter: Filter;
idx: number;
}
function FilterListItem({ filter, idx }: FilterListItemProps) {
@ -287,16 +386,16 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
};
return (
<tr
<li
key={filter.id}
className={classNames(
"flex items-center hover:bg-gray-100 dark:hover:bg-[#222225]",
idx % 2 === 0 ?
"bg-white dark:bg-[#2e2e31]" :
"bg-gray-50 dark:bg-gray-800",
"hover:bg-gray-100 dark:hover:bg-[#222225]"
"bg-gray-50 dark:bg-gray-800"
)}
>
<td
<span
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100"
>
<Switch
@ -316,39 +415,238 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
)}
/>
</Switch>
</td>
<td className="px-4 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<Link
to={filter.id.toString()}
className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex"
>
{filter.name}
</Link>
</td>
<td className="px-4 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<Link
to={`${filter.id.toString()}/actions`}
className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex"
>
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>{filter.actions_count}</span>
</Link>
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{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"
</span>
<div className="flex flex-col w-full justify-center">
<span className="whitespace-nowrap text-sm font-bold text-gray-900 dark:text-gray-100">
<Link
to={filter.id.toString()}
className="hover:text-black dark:hover:text-gray-300"
>
{t.name}
{filter.name}
</Link>
</span>
<div className="flex-col">
<span className="mr-2 whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
Priority: {filter.priority}
</span>
))}
</td>
<td className="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<span className="whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
<Link
to={`${filter.id.toString()}/actions`}
className="hover:text-black dark:hover:text-gray-300"
>
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>Actions: {filter.actions_count}</span>
</Link>
</span>
</div>
</div>
<span className="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<FilterIndexers indexers={filter.indexers} />
</span>
<span className="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<FilterItemDropdown
filter={filter}
onToggle={toggleActive}
/>
</td>
</tr>
</span>
</li>
);
}
}
interface IndexerTagProps {
indexer: Indexer;
}
const IndexerTag: FC<IndexerTagProps> = ({ indexer }) => (
<span
key={indexer.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"
>
{indexer.name}
</span>
);
interface FilterIndexersProps {
indexers: Indexer[];
}
function FilterIndexers({ indexers }: FilterIndexersProps) {
if (indexers.length <= 2) {
return (
<>
{indexers.length > 0
? indexers.map((indexer, idx) => (
<IndexerTag key={idx} indexer={indexer} />
))
: <span className="text-red-400 dark:text-red-800 p-1 text-xs tracking-wide rounded border border-red-400 dark:border-red-700 bg-red-100 dark:bg-red-400">NO INDEXERS SELECTED</span>
}
</>
);
}
const res = indexers.slice(2);
return (
<>
<IndexerTag indexer={indexers[0]} />
<IndexerTag indexer={indexers[1]} />
<span
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"
title={res.map(v => v.name).toString()}
>
+{indexers.length - 2}
</span>
</>
);
}
interface ListboxFilterProps {
id: string;
label: string;
currentValue: string;
onChange: (newValue: string) => void;
children: React.ReactNode;
}
const ListboxFilter = ({
id,
label,
currentValue,
onChange,
children
}: ListboxFilterProps) => (
<div className="">
<Listbox
refName={id}
value={currentValue}
onChange={onChange}
>
<div className="relative">
<Listbox.Button className="relative w-full py-2 pr-5 text-left cursor-default dark:text-gray-400 sm:text-sm">
<span className="block truncate">{label}</span>
<span className="absolute inset-y-0 right-0 flex items-center pointer-events-none">
<ChevronDownIcon
className="w-3 h-3 text-gray-600 hover:text-gray-600"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className="w-48 absolute z-10 w-full mt-1 right-0 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 border border-opacity-5 border-black dark:border-gray-700 dark:border-opacity-40 focus:outline-none sm:text-sm"
>
{children}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
// a unique option from a list
const IndexerSelectFilter = ({ dispatch }: any) => {
const { data, isSuccess } = useQuery(
"release_indexers",
() => APIClient.indexers.getOptions(),
{
keepPreviousData: true,
staleTime: Infinity
}
);
const setFilter = (value: string) => {
if (value == undefined || value == "") {
dispatch({ type: ActionType.INDEXER_FILTER_RESET, payload: [] });
} else {
dispatch({ type: ActionType.INDEXER_FILTER_CHANGE, payload: [value] });
}
};
// Render a multi-select box
return (
<ListboxFilter
id="1"
key="indexer-select"
label="Indexer"
currentValue={""}
onChange={setFilter}
>
<FilterOption label="All" />
{isSuccess && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer.name} value={indexer.identifier} />
))}
</ListboxFilter>
);
};
interface FilterOptionProps {
label: string;
value?: string;
}
const FilterOption = ({ label, value }: FilterOptionProps) => (
<Listbox.Option
className={({ active }) => classNames(
"cursor-pointer select-none relative py-2 pl-10 pr-4",
active ? "text-black dark:text-gray-200 bg-gray-100 dark:bg-gray-900" : "text-gray-700 dark:text-gray-400"
)}
value={value}
>
{({ selected }) => (
<>
<span
className={classNames(
"block truncate",
selected ? "font-medium text-black dark:text-white" : "font-normal"
)}
>
{label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
);
export const SortSelectFilter = ({ dispatch }: any) => {
const setFilter = (value: string) => {
if (value == undefined || value == "") {
dispatch({ type: ActionType.SORT_ORDER_RESET, payload: "" });
} else {
dispatch({ type: ActionType.SORT_ORDER_CHANGE, payload: value });
}
};
const options = [
{ label: "Name A-Z", value: "name-asc" },
{ label: "Name Z-A", value: "name-desc" },
{ label: "Priority highest", value: "priority-desc" },
{ label: "Priority lowest", value: "priority-asc" }
];
// Render a multi-select box
return (
<ListboxFilter
id="sort"
key="sort-select"
label="Sort"
currentValue={""}
onChange={setFilter}
>
<>
<FilterOption label="Reset" />
{options.map((f, idx) =>
<FilterOption key={idx} label={f.label} value={f.value} />
)}
</>
</ListboxFilter>
);
};