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

@ -3,6 +3,8 @@ package database
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/autobrr/autobrr/internal/domain"
@ -26,6 +28,97 @@ func NewFilterRepo(log logger.Logger, db *DB) domain.FilterRepo {
}
}
func (r *FilterRepo) Find(ctx context.Context, params domain.FilterQueryParams) ([]domain.Filter, error) {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil, errors.Wrap(err, "error begin transaction")
}
defer tx.Rollback()
filters, err := r.find(ctx, tx, params)
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, errors.Wrap(err, "error commit transaction find releases")
}
return filters, nil
}
func (r *FilterRepo) find(ctx context.Context, tx *Tx, params domain.FilterQueryParams) ([]domain.Filter, error) {
actionCountQuery := r.db.squirrel.
Select("COUNT(*)").
From("action a").
Where("a.filter_id = f.id")
queryBuilder := r.db.squirrel.
Select(
"f.id",
"f.enabled",
"f.name",
"f.priority",
"f.created_at",
"f.updated_at",
).
Distinct().
Column(sq.Alias(actionCountQuery, "action_count")).
LeftJoin("filter_indexer fi ON f.id = fi.filter_id").
LeftJoin("indexer i ON i.id = fi.indexer_id").
From("filter f")
if params.Search != "" {
queryBuilder = queryBuilder.Where("f.name LIKE ?", fmt.Sprint("%", params.Search, "%"))
}
if len(params.Sort) > 0 {
for field, order := range params.Sort {
queryBuilder = queryBuilder.OrderBy(fmt.Sprintf("f.%v %v", field, strings.ToUpper(order)))
}
} else {
queryBuilder = queryBuilder.OrderBy("f.name ASC")
}
if params.Filters.Indexers != nil {
filter := sq.And{}
for _, v := range params.Filters.Indexers {
filter = append(filter, sq.Eq{"i.identifier": v})
}
queryBuilder = queryBuilder.Where(filter)
}
query, args, err := queryBuilder.ToSql()
if err != nil {
return nil, errors.Wrap(err, "error building query")
}
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.Wrap(err, "error executing query")
}
defer rows.Close()
var filters []domain.Filter
for rows.Next() {
var f domain.Filter
if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &f.Priority, &f.CreatedAt, &f.UpdatedAt, &f.ActionsCount); err != nil {
return nil, errors.Wrap(err, "error scanning row")
}
filters = append(filters, f)
}
if err := rows.Err(); err != nil {
return nil, errors.Wrap(err, "row error")
}
return filters, nil
}
func (r *FilterRepo) ListFilters(ctx context.Context) ([]domain.Filter, error) {
actionCountQuery := r.db.squirrel.
Select("COUNT(*)").
@ -37,6 +130,7 @@ func (r *FilterRepo) ListFilters(ctx context.Context) ([]domain.Filter, error) {
"f.id",
"f.enabled",
"f.name",
"f.priority",
"f.created_at",
"f.updated_at",
).
@ -60,7 +154,7 @@ func (r *FilterRepo) ListFilters(ctx context.Context) ([]domain.Filter, error) {
for rows.Next() {
var f domain.Filter
if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &f.CreatedAt, &f.UpdatedAt, &f.ActionsCount); err != nil {
if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &f.Priority, &f.CreatedAt, &f.UpdatedAt, &f.ActionsCount); err != nil {
return nil, errors.Wrap(err, "error scanning row")
}

View file

@ -20,6 +20,7 @@ https://autodl-community.github.io/autodl-irssi/configuration/filter/
type FilterRepo interface {
FindByID(ctx context.Context, filterID int) (*Filter, error)
FindByIndexerIdentifier(indexer string) ([]Filter, error)
Find(ctx context.Context, params FilterQueryParams) ([]Filter, error)
ListFilters(ctx context.Context) ([]Filter, error)
Store(ctx context.Context, filter Filter) (*Filter, error)
Update(ctx context.Context, filter Filter) (*Filter, error)
@ -49,6 +50,14 @@ const (
FilterMaxDownloadsEver FilterMaxDownloadsUnit = "EVER"
)
type FilterQueryParams struct {
Sort map[string]string
Filters struct {
Indexers []string
}
Search string
}
type Filter struct {
ID int `json:"id"`
Name string `json:"name"`
@ -58,7 +67,7 @@ type Filter struct {
MinSize string `json:"min_size,omitempty"`
MaxSize string `json:"max_size,omitempty"`
Delay int `json:"delay,omitempty"`
Priority int32 `json:"priority,omitempty"`
Priority int32 `json:"priority"`
MaxDownloads int `json:"max_downloads,omitempty"`
MaxDownloadsUnit FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
MatchReleases string `json:"match_releases,omitempty"`

View file

@ -24,6 +24,7 @@ import (
type Service interface {
FindByID(ctx context.Context, filterID int) (*domain.Filter, error)
FindByIndexerIdentifier(indexer string) ([]domain.Filter, error)
Find(ctx context.Context, params domain.FilterQueryParams) ([]domain.Filter, error)
CheckFilter(f domain.Filter, release *domain.Release) (bool, error)
ListFilters(ctx context.Context) ([]domain.Filter, error)
Store(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
@ -52,6 +53,29 @@ func NewService(log logger.Logger, repo domain.FilterRepo, actionRepo domain.Act
}
}
func (s *service) Find(ctx context.Context, params domain.FilterQueryParams) ([]domain.Filter, error) {
// get filters
filters, err := s.repo.Find(ctx, params)
if err != nil {
s.log.Error().Err(err).Msgf("could not find list filters")
return nil, err
}
ret := make([]domain.Filter, 0)
for _, filter := range filters {
indexers, err := s.indexerSvc.FindByFilterID(ctx, filter.ID)
if err != nil {
return ret, err
}
filter.Indexers = indexers
ret = append(ret, filter)
}
return ret, nil
}
func (s *service) ListFilters(ctx context.Context) ([]domain.Filter, error) {
// get filters
filters, err := s.repo.ListFilters(ctx)

View file

@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
@ -14,6 +16,7 @@ import (
type filterService interface {
ListFilters(ctx context.Context) ([]domain.Filter, error)
FindByID(ctx context.Context, filterID int) (*domain.Filter, error)
Find(ctx context.Context, params domain.FilterQueryParams) ([]domain.Filter, error)
Store(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
Delete(ctx context.Context, filterID int) error
Update(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
@ -48,7 +51,43 @@ func (h filterHandler) Routes(r chi.Router) {
func (h filterHandler) getFilters(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
trackers, err := h.service.ListFilters(ctx)
params := domain.FilterQueryParams{
Sort: map[string]string{},
Filters: struct {
Indexers []string
}{},
Search: "",
}
sort := r.URL.Query().Get("sort")
if sort != "" && strings.Contains(sort, "-") {
field := ""
order := ""
s := strings.Split(sort, "-")
if s[0] == "name" || s[0] == "priority" {
field = s[0]
}
if s[1] == "asc" || s[1] == "desc" {
order = s[1]
}
params.Sort[field] = order
}
u, err := url.Parse(r.URL.String())
if err != nil {
h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{
"code": "BAD_REQUEST_PARAMS",
"message": "indexer parameter is invalid",
}, http.StatusBadRequest)
return
}
vals := u.Query()
params.Filters.Indexers = vals["indexer"]
trackers, err := h.service.Find(ctx, params)
if err != nil {
//
h.encoder.Error(w, err)

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>
))}
<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) => (
<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} />
))}
</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;
@ -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">
</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 w-full py-4 flex"
className="hover:text-black dark:hover:text-gray-300"
>
{filter.name}
</Link>
</td>
<td className="px-4 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
</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>
<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 w-full py-4 flex"
className="hover:text-black dark:hover:text-gray-300"
>
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>{filter.actions_count}</span>
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>Actions: {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"
>
{t.name}
</span>
))}
</td>
<td className="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
</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>
);
};