mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
feat(filters): improve list view with filtering (#465)
This commit is contained in:
parent
63d4c21e54
commit
f5faf066a9
7 changed files with 576 additions and 94 deletions
|
@ -3,6 +3,8 @@ package database
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"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) {
|
func (r *FilterRepo) ListFilters(ctx context.Context) ([]domain.Filter, error) {
|
||||||
actionCountQuery := r.db.squirrel.
|
actionCountQuery := r.db.squirrel.
|
||||||
Select("COUNT(*)").
|
Select("COUNT(*)").
|
||||||
|
@ -37,6 +130,7 @@ func (r *FilterRepo) ListFilters(ctx context.Context) ([]domain.Filter, error) {
|
||||||
"f.id",
|
"f.id",
|
||||||
"f.enabled",
|
"f.enabled",
|
||||||
"f.name",
|
"f.name",
|
||||||
|
"f.priority",
|
||||||
"f.created_at",
|
"f.created_at",
|
||||||
"f.updated_at",
|
"f.updated_at",
|
||||||
).
|
).
|
||||||
|
@ -60,7 +154,7 @@ func (r *FilterRepo) ListFilters(ctx context.Context) ([]domain.Filter, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f domain.Filter
|
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")
|
return nil, errors.Wrap(err, "error scanning row")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ https://autodl-community.github.io/autodl-irssi/configuration/filter/
|
||||||
type FilterRepo interface {
|
type FilterRepo interface {
|
||||||
FindByID(ctx context.Context, filterID int) (*Filter, error)
|
FindByID(ctx context.Context, filterID int) (*Filter, error)
|
||||||
FindByIndexerIdentifier(indexer string) ([]Filter, error)
|
FindByIndexerIdentifier(indexer string) ([]Filter, error)
|
||||||
|
Find(ctx context.Context, params FilterQueryParams) ([]Filter, error)
|
||||||
ListFilters(ctx context.Context) ([]Filter, error)
|
ListFilters(ctx context.Context) ([]Filter, error)
|
||||||
Store(ctx context.Context, filter Filter) (*Filter, error)
|
Store(ctx context.Context, filter Filter) (*Filter, error)
|
||||||
Update(ctx context.Context, filter Filter) (*Filter, error)
|
Update(ctx context.Context, filter Filter) (*Filter, error)
|
||||||
|
@ -49,6 +50,14 @@ const (
|
||||||
FilterMaxDownloadsEver FilterMaxDownloadsUnit = "EVER"
|
FilterMaxDownloadsEver FilterMaxDownloadsUnit = "EVER"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type FilterQueryParams struct {
|
||||||
|
Sort map[string]string
|
||||||
|
Filters struct {
|
||||||
|
Indexers []string
|
||||||
|
}
|
||||||
|
Search string
|
||||||
|
}
|
||||||
|
|
||||||
type Filter struct {
|
type Filter struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
@ -58,7 +67,7 @@ type Filter struct {
|
||||||
MinSize string `json:"min_size,omitempty"`
|
MinSize string `json:"min_size,omitempty"`
|
||||||
MaxSize string `json:"max_size,omitempty"`
|
MaxSize string `json:"max_size,omitempty"`
|
||||||
Delay int `json:"delay,omitempty"`
|
Delay int `json:"delay,omitempty"`
|
||||||
Priority int32 `json:"priority,omitempty"`
|
Priority int32 `json:"priority"`
|
||||||
MaxDownloads int `json:"max_downloads,omitempty"`
|
MaxDownloads int `json:"max_downloads,omitempty"`
|
||||||
MaxDownloadsUnit FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
|
MaxDownloadsUnit FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
|
||||||
MatchReleases string `json:"match_releases,omitempty"`
|
MatchReleases string `json:"match_releases,omitempty"`
|
||||||
|
|
|
@ -24,6 +24,7 @@ import (
|
||||||
type Service interface {
|
type Service interface {
|
||||||
FindByID(ctx context.Context, filterID int) (*domain.Filter, error)
|
FindByID(ctx context.Context, filterID int) (*domain.Filter, error)
|
||||||
FindByIndexerIdentifier(indexer string) ([]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)
|
CheckFilter(f domain.Filter, release *domain.Release) (bool, error)
|
||||||
ListFilters(ctx context.Context) ([]domain.Filter, error)
|
ListFilters(ctx context.Context) ([]domain.Filter, error)
|
||||||
Store(ctx context.Context, filter domain.Filter) (*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) {
|
func (s *service) ListFilters(ctx context.Context) ([]domain.Filter, error) {
|
||||||
// get filters
|
// get filters
|
||||||
filters, err := s.repo.ListFilters(ctx)
|
filters, err := s.repo.ListFilters(ctx)
|
||||||
|
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
@ -14,6 +16,7 @@ import (
|
||||||
type filterService interface {
|
type filterService interface {
|
||||||
ListFilters(ctx context.Context) ([]domain.Filter, error)
|
ListFilters(ctx context.Context) ([]domain.Filter, error)
|
||||||
FindByID(ctx context.Context, filterID int) (*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)
|
Store(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
|
||||||
Delete(ctx context.Context, filterID int) error
|
Delete(ctx context.Context, filterID int) error
|
||||||
Update(ctx context.Context, filter domain.Filter) (*domain.Filter, 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) {
|
func (h filterHandler) getFilters(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
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 {
|
if err != nil {
|
||||||
//
|
//
|
||||||
h.encoder.Error(w, err)
|
h.encoder.Error(w, err)
|
||||||
|
|
|
@ -77,7 +77,7 @@ export const APIClient = {
|
||||||
apikeys: {
|
apikeys: {
|
||||||
getAll: () => appClient.Get<APIKey[]>("api/keys"),
|
getAll: () => appClient.Get<APIKey[]>("api/keys"),
|
||||||
create: (key: APIKey) => appClient.Post("api/keys", key),
|
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: {
|
config: {
|
||||||
get: () => appClient.Get<Config>("api/config")
|
get: () => appClient.Get<Config>("api/config")
|
||||||
|
@ -91,6 +91,24 @@ export const APIClient = {
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
getAll: () => appClient.Get<Filter[]>("api/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}`),
|
getByID: (id: number) => appClient.Get<Filter>(`api/filters/${id}`),
|
||||||
create: (filter: Filter) => appClient.Post("api/filters", filter),
|
create: (filter: Filter) => appClient.Post("api/filters", filter),
|
||||||
update: (filter: Filter) => appClient.Put(`api/filters/${filter.id}`, filter),
|
update: (filter: Filter) => appClient.Put(`api/filters/${filter.id}`, filter),
|
||||||
|
|
|
@ -452,7 +452,7 @@ export function Music() {
|
||||||
export function Advanced() {
|
export function Advanced() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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="grid col-span-12 gap-6">
|
||||||
<div
|
<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"
|
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>
|
</div>
|
||||||
</CollapsableSection>
|
</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="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" />
|
<TextField name="except_release_groups" label="Except release groups" columns={6} placeholder="eg. badgroup1,badgroup2" />
|
||||||
</CollapsableSection>
|
</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="match_categories" label="Match categories" columns={6} placeholder="eg. *category*,category1" />
|
||||||
<TextField name="except_categories" label="Except categories" columns={6} placeholder="eg. *category*" />
|
<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" />
|
<TextField name="except_tags" label="Except tags" columns={6} placeholder="eg. tag1,tag2" />
|
||||||
</CollapsableSection>
|
</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="match_uploaders" label="Match uploaders" columns={6} placeholder="eg. uploader1" />
|
||||||
<TextField name="except_uploaders" label="Except uploaders" columns={6} placeholder="eg. anonymous" />
|
<TextField name="except_uploaders" label="Except uploaders" columns={6} placeholder="eg. anonymous" />
|
||||||
</CollapsableSection>
|
</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="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} />
|
<MultiSelect name="except_origins" options={ORIGIN_OPTIONS} label="Except Origins" columns={6} creatable={true} />
|
||||||
</CollapsableSection>
|
</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">
|
<div className="col-span-6">
|
||||||
<SwitchGroup name="freeleech" label="Freeleech" />
|
<SwitchGroup name="freeleech" label="Freeleech" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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 { Link } from "react-router-dom";
|
||||||
import { toast } from "react-hot-toast";
|
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 { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import {
|
import {
|
||||||
TrashIcon,
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
DotsHorizontalIcon,
|
||||||
|
DuplicateIcon,
|
||||||
PencilAltIcon,
|
PencilAltIcon,
|
||||||
SwitchHorizontalIcon,
|
SwitchHorizontalIcon,
|
||||||
DotsHorizontalIcon, DuplicateIcon
|
TrashIcon
|
||||||
} from "@heroicons/react/outline";
|
} from "@heroicons/react/outline";
|
||||||
|
|
||||||
import { queryClient } from "../../App";
|
import { queryClient } from "../../App";
|
||||||
|
@ -19,21 +22,57 @@ import Toast from "../../components/notifications/Toast";
|
||||||
import { EmptyListState } from "../../components/emptystates";
|
import { EmptyListState } from "../../components/emptystates";
|
||||||
import { DeleteModal } from "../../components/modals";
|
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() {
|
export default function Filters() {
|
||||||
const [createFilterIsOpen, toggleCreateFilter] = useToggle(false);
|
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 (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
||||||
|
@ -55,51 +94,111 @@ export default function Filters() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8 relative">
|
<FilterList toggleCreateFilter={toggleCreateFilter} />
|
||||||
{data && data.length > 0 ? (
|
|
||||||
<FilterList filters={data} />
|
|
||||||
) : (
|
|
||||||
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterListProps {
|
function filteredData(data: Filter[], status: string) {
|
||||||
filters: Filter[];
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto align-middle min-w-full rounded-t-md rounded-b-lg shadow-lg">
|
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8 relative">
|
||||||
<table className="min-w-full">
|
<div className="align-middle min-w-full rounded-t-md rounded-b-lg shadow-lg bg-gray-50 dark:bg-gray-800">
|
||||||
<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="flex justify-between px-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<div className="flex gap-4">
|
||||||
{["Enabled", "Name", "Actions", "Indexers"].map((label) => (
|
<StatusButton data={filtered.all} label="All" value="" currentValue={status} dispatch={dispatchFilter} />
|
||||||
<th
|
<StatusButton data={filtered.enabled} label="Enabled" value="enabled" currentValue={status} dispatch={dispatchFilter} />
|
||||||
key={`th-${label}`}
|
<StatusButton data={filtered.disabled} label="Disabled" value="disabled" currentValue={status} dispatch={dispatchFilter} />
|
||||||
scope="col"
|
</div>
|
||||||
className="px-4 pt-4 pb-3 text-left text-xs font-medium uppercase tracking-wider"
|
|
||||||
>
|
<div className="flex items-center gap-5">
|
||||||
{label}
|
<IndexerSelectFilter dispatch={dispatchFilter} />
|
||||||
</th>
|
<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">
|
</ol>
|
||||||
<span className="sr-only">Edit</span>
|
) : (
|
||||||
</th>
|
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />
|
||||||
</tr>
|
)}
|
||||||
</thead>
|
</div>
|
||||||
<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>
|
|
||||||
</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 {
|
interface FilterItemDropdownProps {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
onToggle: (newState: boolean) => void;
|
onToggle: (newState: boolean) => void;
|
||||||
|
@ -259,8 +358,8 @@ const FilterItemDropdown = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FilterListItemProps {
|
interface FilterListItemProps {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
idx: number;
|
idx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterListItem({ filter, idx }: FilterListItemProps) {
|
function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||||
|
@ -287,16 +386,16 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<li
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
"flex items-center hover:bg-gray-100 dark:hover:bg-[#222225]",
|
||||||
idx % 2 === 0 ?
|
idx % 2 === 0 ?
|
||||||
"bg-white dark:bg-[#2e2e31]" :
|
"bg-white dark:bg-[#2e2e31]" :
|
||||||
"bg-gray-50 dark:bg-gray-800",
|
"bg-gray-50 dark:bg-gray-800"
|
||||||
"hover:bg-gray-100 dark:hover:bg-[#222225]"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td
|
<span
|
||||||
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100"
|
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
|
@ -316,39 +415,238 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</td>
|
</span>
|
||||||
<td className="px-4 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div className="flex flex-col w-full justify-center">
|
||||||
<Link
|
<span className="whitespace-nowrap text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
to={filter.id.toString()}
|
<Link
|
||||||
className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex"
|
to={filter.id.toString()}
|
||||||
>
|
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">
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
{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>
|
</span>
|
||||||
))}
|
<span className="whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
</td>
|
<Link
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
|
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
|
<FilterItemDropdown
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onToggle={toggleActive}
|
onToggle={toggleActive}
|
||||||
/>
|
/>
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue