mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
feat(web): releases list filtering (#136)
This commit is contained in:
parent
279d4ff7a3
commit
246e3ddc26
7 changed files with 459 additions and 48 deletions
|
@ -84,14 +84,14 @@ func (repo *ReleaseRepo) StoreReleaseActionStatus(ctx context.Context, a *domain
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([]domain.Release, int64, int64, error) {
|
func (repo *ReleaseRepo) Find(ctx context.Context, params domain.ReleaseQueryParams) ([]domain.Release, int64, int64, error) {
|
||||||
//r.db.lock.RLock()
|
//r.db.lock.RLock()
|
||||||
//defer r.db.lock.RUnlock()
|
//defer r.db.lock.RUnlock()
|
||||||
|
|
||||||
queryBuilder := sq.
|
queryBuilder := sq.
|
||||||
Select("id", "filter_status", "rejections", "indexer", "filter", "protocol", "title", "torrent_name", "size", "timestamp", "COUNT() OVER() AS total_count").
|
Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.title", "r.torrent_name", "r.size", "r.timestamp", "COUNT() OVER() AS total_count").
|
||||||
From("release").
|
From("release r").
|
||||||
OrderBy("timestamp DESC")
|
OrderBy("r.timestamp DESC")
|
||||||
|
|
||||||
if params.Limit > 0 {
|
if params.Limit > 0 {
|
||||||
queryBuilder = queryBuilder.Limit(params.Limit)
|
queryBuilder = queryBuilder.Limit(params.Limit)
|
||||||
|
@ -104,18 +104,22 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.Cursor > 0 {
|
if params.Cursor > 0 {
|
||||||
queryBuilder = queryBuilder.Where(sq.Lt{"id": params.Cursor})
|
queryBuilder = queryBuilder.Where(sq.Lt{"r.id": params.Cursor})
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.Filter != nil {
|
if params.Filters.Indexers != nil {
|
||||||
filter := sq.And{}
|
filter := sq.And{}
|
||||||
for k, v := range params.Filter {
|
for _, v := range params.Filters.Indexers {
|
||||||
filter = append(filter, sq.Eq{k: v})
|
filter = append(filter, sq.Eq{"r.indexer": v})
|
||||||
}
|
}
|
||||||
|
|
||||||
queryBuilder = queryBuilder.Where(filter)
|
queryBuilder = queryBuilder.Where(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if params.Filters.PushStatus != "" {
|
||||||
|
queryBuilder = queryBuilder.InnerJoin("release_action_status ras ON r.id = ras.release_id").Where(sq.Eq{"ras.status": params.Filters.PushStatus})
|
||||||
|
}
|
||||||
|
|
||||||
query, args, err := queryBuilder.ToSql()
|
query, args, err := queryBuilder.ToSql()
|
||||||
log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args)
|
log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args)
|
||||||
|
|
||||||
|
@ -171,6 +175,46 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([
|
||||||
return res, nextCursor, countItems, nil
|
return res, nextCursor, countItems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (repo *ReleaseRepo) GetIndexerOptions(ctx context.Context) ([]string, error) {
|
||||||
|
//r.db.lock.RLock()
|
||||||
|
//defer r.db.lock.RUnlock()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT indexer FROM "release"
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT identifier indexer FROM indexer;`
|
||||||
|
|
||||||
|
log.Trace().Str("database", "release.get_indexers").Msgf("query: '%v'", query)
|
||||||
|
|
||||||
|
res := make([]string, 0)
|
||||||
|
|
||||||
|
rows, err := repo.db.handler.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Stack().Err(err).Msg("error fetching indexer list")
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
log.Error().Stack().Err(err)
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var indexer string
|
||||||
|
|
||||||
|
if err := rows.Scan(&indexer); err != nil {
|
||||||
|
log.Error().Stack().Err(err).Msg("release.find: error scanning data to struct")
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res = append(res, indexer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]domain.ReleaseActionStatus, error) {
|
func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]domain.ReleaseActionStatus, error) {
|
||||||
//r.db.lock.RLock()
|
//r.db.lock.RLock()
|
||||||
//defer r.db.lock.RUnlock()
|
//defer r.db.lock.RUnlock()
|
||||||
|
|
|
@ -26,7 +26,8 @@ import (
|
||||||
|
|
||||||
type ReleaseRepo interface {
|
type ReleaseRepo interface {
|
||||||
Store(ctx context.Context, release *Release) (*Release, error)
|
Store(ctx context.Context, release *Release) (*Release, error)
|
||||||
Find(ctx context.Context, params QueryParams) (res []Release, nextCursor int64, count int64, err error)
|
Find(ctx context.Context, params ReleaseQueryParams) (res []Release, nextCursor int64, count int64, err error)
|
||||||
|
GetIndexerOptions(ctx context.Context) ([]string, error)
|
||||||
GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]ReleaseActionStatus, error)
|
GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]ReleaseActionStatus, error)
|
||||||
Stats(ctx context.Context) (*ReleaseStats, error)
|
Stats(ctx context.Context) (*ReleaseStats, error)
|
||||||
StoreReleaseActionStatus(ctx context.Context, actionStatus *ReleaseActionStatus) error
|
StoreReleaseActionStatus(ctx context.Context, actionStatus *ReleaseActionStatus) error
|
||||||
|
@ -1485,11 +1486,14 @@ const (
|
||||||
ReleaseImplementationIRC ReleaseImplementation = "IRC"
|
ReleaseImplementationIRC ReleaseImplementation = "IRC"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QueryParams struct {
|
type ReleaseQueryParams struct {
|
||||||
Limit uint64
|
Limit uint64
|
||||||
Offset uint64
|
Offset uint64
|
||||||
Cursor uint64
|
Cursor uint64
|
||||||
Sort map[string]string
|
Sort map[string]string
|
||||||
Filter map[string]string
|
Filters struct {
|
||||||
|
Indexers []string
|
||||||
|
PushStatus string
|
||||||
|
}
|
||||||
Search string
|
Search string
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package http
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
@ -10,7 +11,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseService interface {
|
type releaseService interface {
|
||||||
Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error)
|
Find(ctx context.Context, query domain.ReleaseQueryParams) (res []domain.Release, nextCursor int64, count int64, err error)
|
||||||
|
GetIndexerOptions(ctx context.Context) ([]string, error)
|
||||||
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +31,7 @@ func newReleaseHandler(encoder encoder, service releaseService) *releaseHandler
|
||||||
func (h releaseHandler) Routes(r chi.Router) {
|
func (h releaseHandler) Routes(r chi.Router) {
|
||||||
r.Get("/", h.findReleases)
|
r.Get("/", h.findReleases)
|
||||||
r.Get("/stats", h.getStats)
|
r.Get("/stats", h.getStats)
|
||||||
|
r.Get("/indexers", h.getIndexerOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -40,6 +43,7 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
"code": "BAD_REQUEST_PARAMS",
|
"code": "BAD_REQUEST_PARAMS",
|
||||||
"message": "limit parameter is invalid",
|
"message": "limit parameter is invalid",
|
||||||
}, http.StatusBadRequest)
|
}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
limit = 20
|
limit = 20
|
||||||
|
@ -52,23 +56,44 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
"code": "BAD_REQUEST_PARAMS",
|
"code": "BAD_REQUEST_PARAMS",
|
||||||
"message": "offset parameter is invalid",
|
"message": "offset parameter is invalid",
|
||||||
}, http.StatusBadRequest)
|
}, http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cursorP := r.URL.Query().Get("cursor")
|
cursorP := r.URL.Query().Get("cursor")
|
||||||
cursor, err := strconv.Atoi(cursorP)
|
cursor := 0
|
||||||
if err != nil && cursorP != "" {
|
if cursorP != "" {
|
||||||
h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{
|
cursor, err = strconv.Atoi(cursorP)
|
||||||
"code": "BAD_REQUEST_PARAMS",
|
if err != nil && cursorP != "" {
|
||||||
"message": "cursor parameter is invalid",
|
h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{
|
||||||
}, http.StatusBadRequest)
|
"code": "BAD_REQUEST_PARAMS",
|
||||||
|
"message": "cursor parameter is invalid",
|
||||||
|
}, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
query := domain.QueryParams{
|
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()
|
||||||
|
indexer := vals["indexer"]
|
||||||
|
|
||||||
|
pushStatus := r.URL.Query().Get("push_status")
|
||||||
|
|
||||||
|
query := domain.ReleaseQueryParams{
|
||||||
Limit: uint64(limit),
|
Limit: uint64(limit),
|
||||||
Offset: uint64(offset),
|
Offset: uint64(offset),
|
||||||
Cursor: uint64(cursor),
|
Cursor: uint64(cursor),
|
||||||
Sort: nil,
|
Sort: nil,
|
||||||
//Filter: "",
|
Filters: struct {
|
||||||
|
Indexers []string
|
||||||
|
PushStatus string
|
||||||
|
}{Indexers: indexer, PushStatus: pushStatus},
|
||||||
}
|
}
|
||||||
|
|
||||||
releases, nextCursor, count, err := h.service.Find(r.Context(), query)
|
releases, nextCursor, count, err := h.service.Find(r.Context(), query)
|
||||||
|
@ -90,6 +115,16 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK)
|
h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h releaseHandler) getIndexerOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.service.GetIndexerOptions(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.encoder.StatusNotFound(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.encoder.StatusResponse(r.Context(), w, stats, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func (h releaseHandler) getStats(w http.ResponseWriter, r *http.Request) {
|
func (h releaseHandler) getStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
stats, err := h.service.Stats(r.Context())
|
stats, err := h.service.Stats(r.Context())
|
||||||
|
|
|
@ -10,7 +10,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error)
|
Find(ctx context.Context, query domain.ReleaseQueryParams) (res []domain.Release, nextCursor int64, count int64, err error)
|
||||||
|
GetIndexerOptions(ctx context.Context) ([]string, error)
|
||||||
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
||||||
Store(ctx context.Context, release *domain.Release) error
|
Store(ctx context.Context, release *domain.Release) error
|
||||||
StoreReleaseActionStatus(ctx context.Context, actionStatus *domain.ReleaseActionStatus) error
|
StoreReleaseActionStatus(ctx context.Context, actionStatus *domain.ReleaseActionStatus) error
|
||||||
|
@ -29,7 +30,7 @@ func NewService(repo domain.ReleaseRepo, actionService action.Service) Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) {
|
func (s *service) Find(ctx context.Context, query domain.ReleaseQueryParams) (res []domain.Release, nextCursor int64, count int64, err error) {
|
||||||
res, nextCursor, count, err = s.repo.Find(ctx, query)
|
res, nextCursor, count, err = s.repo.Find(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
@ -38,6 +39,10 @@ func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []dom
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *service) GetIndexerOptions(ctx context.Context) ([]string, error) {
|
||||||
|
return s.repo.GetIndexerOptions(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *service) Stats(ctx context.Context) (*domain.ReleaseStats, error) {
|
func (s *service) Stats(ctx context.Context) (*domain.ReleaseStats, error) {
|
||||||
stats, err := s.repo.Stats(ctx)
|
stats, err := s.repo.Stats(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -97,6 +97,30 @@ export const APIClient = {
|
||||||
},
|
},
|
||||||
release: {
|
release: {
|
||||||
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
|
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
|
||||||
|
findQuery: (offset?: number, limit?: number, filters?: any[]) => {
|
||||||
|
let queryString = "?"
|
||||||
|
|
||||||
|
if (offset != 0) {
|
||||||
|
queryString += `offset=${offset}`
|
||||||
|
}
|
||||||
|
if (limit != 0) {
|
||||||
|
queryString += `&limit=${limit}`
|
||||||
|
}
|
||||||
|
if (filters && filters?.length > 0) {
|
||||||
|
filters?.map((filter) => {
|
||||||
|
if (filter.id === "indexer" && filter.value != "") {
|
||||||
|
queryString += `&indexer=${filter.value}`
|
||||||
|
}
|
||||||
|
// using action_status instead of push_status because thats the column accessor
|
||||||
|
if (filter.id === "action_status" && filter.value != "") {
|
||||||
|
queryString += `&push_status=${filter.value}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return appClient.Get<ReleaseFindResponse>(`api/release${queryString}`)
|
||||||
|
},
|
||||||
|
indexerOptions: () => appClient.Get<string[]>(`api/release/indexers`),
|
||||||
stats: () => appClient.Get<ReleaseStats>("api/release/stats")
|
stats: () => appClient.Get<ReleaseStats>("api/release/stats")
|
||||||
}
|
}
|
||||||
};
|
};
|
|
@ -210,4 +210,19 @@ export const ActionTypeNameMap = {
|
||||||
"RADARR": "Radarr",
|
"RADARR": "Radarr",
|
||||||
"SONARR": "Sonarr",
|
"SONARR": "Sonarr",
|
||||||
"LIDARR": "Lidarr",
|
"LIDARR": "Lidarr",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PushStatusOptions: any[] = [
|
||||||
|
{
|
||||||
|
label: "Rejected",
|
||||||
|
value: "PUSH_REJECTED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Approved",
|
||||||
|
value: "PUSH_APPROVED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Error",
|
||||||
|
value: "PUSH_ERROR"
|
||||||
|
},
|
||||||
|
];
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
import { useTable, useSortBy, usePagination, Column } from "react-table";
|
import { useTable, useSortBy, usePagination, useAsyncDebounce, useFilters, Column } from "react-table";
|
||||||
import {
|
import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
BanIcon,
|
BanIcon,
|
||||||
|
@ -12,13 +12,18 @@ import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ChevronDoubleRightIcon,
|
ChevronDoubleRightIcon,
|
||||||
CheckIcon
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
} from "@heroicons/react/solid";
|
} from "@heroicons/react/solid";
|
||||||
|
|
||||||
import { APIClient } from "../api/APIClient";
|
import { APIClient } from "../api/APIClient";
|
||||||
import { EmptyListState } from "../components/emptystates";
|
import { EmptyListState } from "../components/emptystates";
|
||||||
import { classNames, simplifyDate } from "../utils";
|
import { classNames, simplifyDate } from "../utils";
|
||||||
|
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
|
import { PushStatusOptions } from "../domain/constants";
|
||||||
|
|
||||||
export function Releases() {
|
export function Releases() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
|
@ -34,6 +39,33 @@ export function Releases() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// // Define a default UI for filtering
|
||||||
|
// function GlobalFilter({
|
||||||
|
// preGlobalFilteredRows,
|
||||||
|
// globalFilter,
|
||||||
|
// setGlobalFilter,
|
||||||
|
// }: any) {
|
||||||
|
// const count = preGlobalFilteredRows.length
|
||||||
|
// const [value, setValue] = React.useState(globalFilter)
|
||||||
|
// const onChange = useAsyncDebounce(value => {
|
||||||
|
// setGlobalFilter(value || undefined)
|
||||||
|
// }, 200)
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <span>
|
||||||
|
// Search:{' '}
|
||||||
|
// <input
|
||||||
|
// value={value || ""}
|
||||||
|
// onChange={e => {
|
||||||
|
// setValue(e.target.value);
|
||||||
|
// onChange(e.target.value);
|
||||||
|
// }}
|
||||||
|
// placeholder={`${count} records...`}
|
||||||
|
// />
|
||||||
|
// </span>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
// This is a custom filter UI for selecting
|
// This is a custom filter UI for selecting
|
||||||
// a unique option from a list
|
// a unique option from a list
|
||||||
export function SelectColumnFilter({
|
export function SelectColumnFilter({
|
||||||
|
@ -46,11 +78,16 @@ export function SelectColumnFilter({
|
||||||
preFilteredRows.forEach((row: { values: { [x: string]: unknown } }) => {
|
preFilteredRows.forEach((row: { values: { [x: string]: unknown } }) => {
|
||||||
options.add(row.values[id])
|
options.add(row.values[id])
|
||||||
})
|
})
|
||||||
|
|
||||||
return [...options.values()]
|
return [...options.values()]
|
||||||
}, [id, preFilteredRows])
|
}, [id, preFilteredRows])
|
||||||
|
|
||||||
|
const opts = ["PUSH_REJECTED"]
|
||||||
|
|
||||||
// Render a multi-select box
|
// Render a multi-select box
|
||||||
return (
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
|
||||||
<label className="flex items-baseline gap-x-2">
|
<label className="flex items-baseline gap-x-2">
|
||||||
<span className="text-gray-700">{render("Header")}: </span>
|
<span className="text-gray-700">{render("Header")}: </span>
|
||||||
<select
|
<select
|
||||||
|
@ -63,13 +100,211 @@ export function SelectColumnFilter({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
{options.map((option, i) => (
|
{opts.map((option, i: number) => (
|
||||||
<option key={i} value={option}>
|
<option key={i} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a custom filter UI for selecting
|
||||||
|
// a unique option from a list
|
||||||
|
export function IndexerSelectColumnFilter({
|
||||||
|
column: { filterValue, setFilter, id },
|
||||||
|
}: any) {
|
||||||
|
const { data, isSuccess } = useQuery(
|
||||||
|
['release_indexers'],
|
||||||
|
() => APIClient.release.indexerOptions(),
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: Infinity,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const opts = isSuccess && data?.map(i => ({ value: i, label: i})) as any[]
|
||||||
|
|
||||||
|
// Render a multi-select box
|
||||||
|
return (
|
||||||
|
<div className="mr-3">
|
||||||
|
<div className="w-48">
|
||||||
|
<Listbox
|
||||||
|
refName={id}
|
||||||
|
value={filterValue}
|
||||||
|
onChange={setFilter}
|
||||||
|
>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white dark:bg-gray-800 rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 dark:text-gray-400 sm:text-sm">
|
||||||
|
<span className="block truncate">{filterValue ? filterValue : "Indexer"}</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="w-5 h-5 ml-2 -mr-1 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="absolute w-full py-1 mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||||
|
<Listbox.Option
|
||||||
|
key={0}
|
||||||
|
className={({ active }) =>
|
||||||
|
`cursor-default select-none relative py-2 pl-10 pr-4 ${
|
||||||
|
active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={undefined}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`block truncate ${
|
||||||
|
selected ? 'font-medium' : 'font-normal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</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>
|
||||||
|
{isSuccess && data?.map((indexer, idx) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={idx}
|
||||||
|
className={({ active }) =>
|
||||||
|
`cursor-default select-none relative py-2 pl-10 pr-4 ${
|
||||||
|
active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={indexer}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`block truncate ${
|
||||||
|
selected ? 'font-medium' : 'font-normal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{indexer}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushStatusSelectColumnFilter({
|
||||||
|
column: { filterValue, setFilter, id },
|
||||||
|
}: any) {
|
||||||
|
return (
|
||||||
|
<div className="mr-3">
|
||||||
|
|
||||||
|
<div className="w-48">
|
||||||
|
<Listbox
|
||||||
|
refName={id}
|
||||||
|
value={filterValue}
|
||||||
|
onChange={setFilter}
|
||||||
|
>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white dark:bg-gray-800 rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 dark:text-gray-400 sm:text-sm">
|
||||||
|
<span className="block truncate">{filterValue ? PushStatusOptions.find((o) => o.value === filterValue && o.value)!.label : "Push status"}</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="w-5 h-5 ml-2 -mr-1 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="absolute w-full py-1 mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||||
|
<Listbox.Option
|
||||||
|
key={0}
|
||||||
|
className={({ active }) =>
|
||||||
|
`cursor-default select-none relative py-2 pl-10 pr-4 ${
|
||||||
|
active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={undefined}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`block truncate ${
|
||||||
|
selected ? 'font-medium' : 'font-normal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</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>
|
||||||
|
{PushStatusOptions.map((status, idx) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={idx}
|
||||||
|
className={({ active }) =>
|
||||||
|
`cursor-default select-none relative py-2 pl-10 pr-4 ${
|
||||||
|
active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={status.value}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`block truncate ${
|
||||||
|
selected ? 'font-medium' : 'font-normal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.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>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,11 +399,13 @@ const initialState = {
|
||||||
queryPageIndex: 0,
|
queryPageIndex: 0,
|
||||||
queryPageSize: 10,
|
queryPageSize: 10,
|
||||||
totalCount: null,
|
totalCount: null,
|
||||||
|
queryFilters: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_CHANGED = 'PAGE_CHANGED';
|
const PAGE_CHANGED = 'PAGE_CHANGED';
|
||||||
const PAGE_SIZE_CHANGED = 'PAGE_SIZE_CHANGED';
|
const PAGE_SIZE_CHANGED = 'PAGE_SIZE_CHANGED';
|
||||||
const TOTAL_COUNT_CHANGED = 'TOTAL_COUNT_CHANGED';
|
const TOTAL_COUNT_CHANGED = 'TOTAL_COUNT_CHANGED';
|
||||||
|
const FILTER_CHANGED = 'FILTER_CHANGED';
|
||||||
|
|
||||||
const reducer = (state: any, { type, payload }: any) => {
|
const reducer = (state: any, { type, payload }: any) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
@ -187,6 +424,12 @@ const reducer = (state: any, { type, payload }: any) => {
|
||||||
...state,
|
...state,
|
||||||
totalCount: payload,
|
totalCount: payload,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case FILTER_CHANGED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queryFilters: payload,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled action type: ${type}`);
|
throw new Error(`Unhandled action type: ${type}`);
|
||||||
}
|
}
|
||||||
|
@ -213,28 +456,38 @@ function Table() {
|
||||||
Header: "Actions",
|
Header: "Actions",
|
||||||
accessor: 'action_status',
|
accessor: 'action_status',
|
||||||
Cell: ReleaseStatusCell,
|
Cell: ReleaseStatusCell,
|
||||||
|
Filter: PushStatusSelectColumnFilter, // new
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Indexer",
|
Header: "Indexer",
|
||||||
accessor: 'indexer',
|
accessor: 'indexer',
|
||||||
Cell: IndexerCell,
|
Cell: IndexerCell,
|
||||||
Filter: SelectColumnFilter, // new
|
Filter: IndexerSelectColumnFilter, // new
|
||||||
filter: 'includes',
|
filter: 'equal',
|
||||||
|
// filter: 'includes',
|
||||||
},
|
},
|
||||||
] as Column<Release>[], [])
|
] as Column<Release>[], [])
|
||||||
|
|
||||||
const [{ queryPageIndex, queryPageSize, totalCount }, dispatch] =
|
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
|
||||||
React.useReducer(reducer, initialState);
|
React.useReducer(reducer, initialState);
|
||||||
|
|
||||||
const { isLoading, error, data, isSuccess } = useQuery(
|
const { isLoading, error, data, isSuccess } = useQuery(
|
||||||
['releases', queryPageIndex, queryPageSize],
|
['releases', queryPageIndex, queryPageSize, queryFilters],
|
||||||
() => APIClient.release.find(`?offset=${queryPageIndex * queryPageSize}&limit=${queryPageSize}`),
|
// () => APIClient.release.find(`?offset=${queryPageIndex * queryPageSize}&limit=${queryPageSize}${filterIndexer && `&indexer=${filterIndexer}`}`),
|
||||||
|
() => APIClient.release.findQuery(queryPageIndex * queryPageSize, queryPageSize, queryFilters),
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// const initialFilters = React.useMemo(() => [
|
||||||
|
// {
|
||||||
|
// id: "indexer",
|
||||||
|
// value: "",
|
||||||
|
// }
|
||||||
|
// ], [])
|
||||||
|
|
||||||
// Use the state and functions returned from useTable to build your UI
|
// Use the state and functions returned from useTable to build your UI
|
||||||
const {
|
const {
|
||||||
getTableProps,
|
getTableProps,
|
||||||
|
@ -254,21 +507,28 @@ function Table() {
|
||||||
previousPage,
|
previousPage,
|
||||||
setPageSize,
|
setPageSize,
|
||||||
|
|
||||||
state: { pageIndex, pageSize },
|
state: { pageIndex, pageSize, globalFilter, filters },
|
||||||
// preGlobalFilteredRows,
|
// preGlobalFilteredRows,
|
||||||
// setGlobalFilter,
|
// setGlobalFilter,
|
||||||
|
// preFilteredRows,
|
||||||
} = useTable({
|
} = useTable({
|
||||||
columns,
|
columns,
|
||||||
data: data && isSuccess ? data.data : [],
|
data: data && isSuccess ? data.data : [],
|
||||||
initialState: {
|
initialState: {
|
||||||
pageIndex: queryPageIndex,
|
pageIndex: queryPageIndex,
|
||||||
pageSize: queryPageSize,
|
pageSize: queryPageSize,
|
||||||
|
filters: []
|
||||||
|
// filters: initialFilters
|
||||||
},
|
},
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
|
manualFilters: true,
|
||||||
manualSortBy: true,
|
manualSortBy: true,
|
||||||
pageCount: isSuccess ? Math.ceil(totalCount / queryPageSize) : 0,
|
pageCount: isSuccess ? Math.ceil(totalCount / queryPageSize) : 0,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
autoResetExpanded: false,
|
||||||
|
autoResetPage: false
|
||||||
},
|
},
|
||||||
// useFilters, // useFilters!
|
useFilters, // useFilters!
|
||||||
// useGlobalFilter,
|
// useGlobalFilter,
|
||||||
useSortBy,
|
useSortBy,
|
||||||
usePagination, // new
|
usePagination, // new
|
||||||
|
@ -292,6 +552,11 @@ function Table() {
|
||||||
}
|
}
|
||||||
}, [data?.count]);
|
}, [data?.count]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch({ type: FILTER_CHANGED, payload: filters });
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <p>Error</p>;
|
return <p>Error</p>;
|
||||||
}
|
}
|
||||||
|
@ -305,6 +570,25 @@ function Table() {
|
||||||
<>
|
<>
|
||||||
{isSuccess && data ? (
|
{isSuccess && data ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
{/* <GlobalFilter
|
||||||
|
preGlobalFilteredRows={preGlobalFilteredRows}
|
||||||
|
globalFilter={globalFilter}
|
||||||
|
setGlobalFilter={setGlobalFilter}
|
||||||
|
preFilteredRows={preFilteredRows}
|
||||||
|
/> */}
|
||||||
|
<div className="flex mb-6">
|
||||||
|
|
||||||
|
{headerGroups.map((headerGroup: { headers: any[] }) =>
|
||||||
|
headerGroup.headers.map((column) =>
|
||||||
|
column.Filter ? (
|
||||||
|
<div className="mt-2 sm:mt-0" key={column.id}>
|
||||||
|
{column.render("Filter")}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden bg-white shadow-lg dark:bg-gray-800 sm:rounded-lg">
|
<div className="overflow-hidden bg-white shadow-lg dark:bg-gray-800 sm:rounded-lg">
|
||||||
<table {...getTableProps()} className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table {...getTableProps()} className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
@ -388,7 +672,7 @@ function Table() {
|
||||||
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
|
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
|
||||||
</span>
|
</span>
|
||||||
<label>
|
<label>
|
||||||
<span className="sr-only">Items Per Page</span>
|
<span className="sr-only bg-gray-700">Items Per Page</span>
|
||||||
<select
|
<select
|
||||||
className="block w-full border-gray-300 rounded-md shadow-sm cursor-pointer dark:bg-gray-800 dark:border-gray-800 dark:text-gray-600 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
|
className="block w-full border-gray-300 rounded-md shadow-sm cursor-pointer dark:bg-gray-800 dark:border-gray-800 dark:text-gray-600 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
|
@ -411,29 +695,29 @@ function Table() {
|
||||||
onClick={() => gotoPage(0)}
|
onClick={() => gotoPage(0)}
|
||||||
disabled={!canPreviousPage}
|
disabled={!canPreviousPage}
|
||||||
>
|
>
|
||||||
<span className="sr-only">First</span>
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">First</span>
|
||||||
<ChevronDoubleLeftIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
<ChevronDoubleLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
</PageButton>
|
</PageButton>
|
||||||
<PageButton
|
<PageButton
|
||||||
onClick={() => previousPage()}
|
onClick={() => previousPage()}
|
||||||
disabled={!canPreviousPage}
|
disabled={!canPreviousPage}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Previous</span>
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Previous</span>
|
||||||
<ChevronLeftIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
<ChevronLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
</PageButton>
|
</PageButton>
|
||||||
<PageButton
|
<PageButton
|
||||||
onClick={() => nextPage()}
|
onClick={() => nextPage()}
|
||||||
disabled={!canNextPage}>
|
disabled={!canNextPage}>
|
||||||
<span className="sr-only">Next</span>
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Next</span>
|
||||||
<ChevronRightIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
<ChevronRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
</PageButton>
|
</PageButton>
|
||||||
<PageButton
|
<PageButton
|
||||||
className="rounded-r-md"
|
className="rounded-r-md"
|
||||||
onClick={() => gotoPage(pageCount - 1)}
|
onClick={() => gotoPage(pageCount - 1)}
|
||||||
disabled={!canNextPage}
|
disabled={!canNextPage}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Last</span>
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Last</span>
|
||||||
<ChevronDoubleRightIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
<ChevronDoubleRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
</PageButton>
|
</PageButton>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
@ -470,7 +754,7 @@ function Button({ children, className, ...rest }: any) {
|
||||||
type="button"
|
type="button"
|
||||||
className={
|
className={
|
||||||
classNames(
|
classNames(
|
||||||
"relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
|
"relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-800 text-sm font-medium rounded-md text-gray-700 dark:text-gray-500 bg-white dark:bg-gray-800 hover:bg-gray-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
@ -486,7 +770,7 @@ function PageButton({ children, className, ...rest }: any) {
|
||||||
type="button"
|
type="button"
|
||||||
className={
|
className={
|
||||||
classNames(
|
classNames(
|
||||||
"relative inline-flex items-center px-2 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600",
|
"relative inline-flex items-center px-2 py-2 border border-gray-300 dark:border-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue