From f5faf066a9e36969e6a10dc5051a9e870480a3b6 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Thu, 22 Sep 2022 11:54:17 +0200 Subject: [PATCH] feat(filters): improve list view with filtering (#465) --- internal/database/filter.go | 96 +++++- internal/domain/filter.go | 11 +- internal/filter/service.go | 24 ++ internal/http/filter.go | 41 ++- web/src/api/APIClient.ts | 20 +- web/src/screens/filters/details.tsx | 12 +- web/src/screens/filters/list.tsx | 466 +++++++++++++++++++++++----- 7 files changed, 576 insertions(+), 94 deletions(-) diff --git a/internal/database/filter.go b/internal/database/filter.go index 424d696..77dce66 100644 --- a/internal/database/filter.go +++ b/internal/database/filter.go @@ -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") } diff --git a/internal/domain/filter.go b/internal/domain/filter.go index dbe1e9f..8ec8378 100644 --- a/internal/domain/filter.go +++ b/internal/domain/filter.go @@ -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"` diff --git a/internal/filter/service.go b/internal/filter/service.go index afa445f..b77301d 100644 --- a/internal/filter/service.go +++ b/internal/filter/service.go @@ -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) diff --git a/internal/http/filter.go b/internal/http/filter.go index d3ef678..1c4ca6c 100644 --- a/internal/http/filter.go +++ b/internal/http/filter.go @@ -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) diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 80ee50b..93aff5d 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -77,7 +77,7 @@ export const APIClient = { apikeys: { getAll: () => appClient.Get("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("api/config") @@ -91,6 +91,24 @@ export const APIClient = { }, filters: { getAll: () => appClient.Get("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(`api/filters${q}`); + }, getByID: (id: number) => appClient.Get(`api/filters/${id}`), create: (filter: Filter) => appClient.Post("api/filters", filter), update: (filter: Filter) => appClient.Put(`api/filters/${filter.id}`, filter), diff --git a/web/src/screens/filters/details.tsx b/web/src/screens/filters/details.tsx index fecc6fe..4ff8f00 100644 --- a/web/src/screens/filters/details.tsx +++ b/web/src/screens/filters/details.tsx @@ -452,7 +452,7 @@ export function Music() { export function Advanced() { return (
- +
- + - + @@ -490,17 +490,17 @@ export function Advanced() { - + - + - +
diff --git a/web/src/screens/filters/list.tsx b/web/src/screens/filters/list.tsx index 1a7e656..8af11ca 100644 --- a/web/src/screens/filters/list.tsx +++ b/web/src/screens/filters/list.tsx @@ -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 (

An error has occurred:

); - return (
@@ -55,51 +94,111 @@ export default function Filters() {
-
- {data && data.length > 0 ? ( - - ) : ( - - )} -
+ ); } -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 (

An error has occurred:

); + } + + const filtered = filteredData(data ?? [], status); + return ( -
- - - - {["Enabled", "Name", "Actions", "Indexers"].map((label) => ( - +
+
+
+
+ + + +
+ +
+ + +
+
+ + {data && data.length > 0 ? ( +
    + {filtered.filtered.map((filter: Filter, idx) => ( + ))} -
- - - - {filters.map((filter: Filter, idx) => ( - - ))} - -
- {label} - - Edit -
+ + ) : ( + + )} +
); } +interface StatusButtonProps { + data: unknown[]; + label: string; + value: string; + currentValue: string; + dispatch: Dispatch; +} + +const StatusButton = ({ data, label, value, currentValue, dispatch }: StatusButtonProps) => { + const setFilter: MouseEventHandler = (e: React.MouseEvent) => { + e.preventDefault(); + if (value == undefined || value == "") { + dispatch({ type: ActionType.STATUS_RESET, payload: "" }); + } else { + dispatch({ type: ActionType.STATUS_CHANGE, payload: e.currentTarget.value }); + } + }; + + return ( + + ); +}; + 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 ( - - - - - - {filter.name} - - - - - {filter.actions_count} - - - - {filter.indexers && filter.indexers.map((t) => ( - +
+ + - {t.name} + {filter.name} + + +
+ + Priority: {filter.priority} - ))} - - + + + Actions: {filter.actions_count} + + +
+
+ + + + - - + + ); -} \ No newline at end of file +} + +interface IndexerTagProps { + indexer: Indexer; +} + +const IndexerTag: FC = ({ indexer }) => ( + + {indexer.name} + +); + +interface FilterIndexersProps { + indexers: Indexer[]; +} + +function FilterIndexers({ indexers }: FilterIndexersProps) { + if (indexers.length <= 2) { + return ( + <> + {indexers.length > 0 + ? indexers.map((indexer, idx) => ( + + )) + : NO INDEXERS SELECTED + } + + ); + } + + const res = indexers.slice(2); + + return ( + <> + + + v.name).toString()} + > + +{indexers.length - 2} + + + ); +} + +interface ListboxFilterProps { + id: string; + label: string; + currentValue: string; + onChange: (newValue: string) => void; + children: React.ReactNode; +} + +const ListboxFilter = ({ + id, + label, + currentValue, + onChange, + children +}: ListboxFilterProps) => ( +
+ +
+ + {label} + + + + + + {children} + + +
+
+
+); + +// 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 ( + + + {isSuccess && data?.map((indexer, idx) => ( + + ))} + + ); +}; + +interface FilterOptionProps { + label: string; + value?: string; +} + +const FilterOption = ({ label, value }: FilterOptionProps) => ( + 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 }) => ( + <> + + {label} + + {selected ? ( + + + ) : null} + + )} + +); + +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 ( + + <> + + {options.map((f, idx) => + + )} + + + ); +};