mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
Feature: List releases (#52)
* feat: list releases * feat: find releases and count
This commit is contained in:
parent
c83ebdc1a4
commit
b75c40f6a4
7 changed files with 506 additions and 20 deletions
|
@ -76,9 +76,12 @@ func (repo *ReleaseRepo) UpdatePushStatusRejected(ctx context.Context, id int64,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([]domain.Release, int64, error) {
|
func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([]domain.Release, int64, int64, error) {
|
||||||
|
|
||||||
queryBuilder := sq.Select("id", "filter_status", "push_status", "rejections", "indexer", "filter", "protocol", "title", "torrent_name", "size", "timestamp").From("release").OrderBy("timestamp DESC")
|
queryBuilder := sq.
|
||||||
|
Select("id", "filter_status", "push_status", "rejections", "indexer", "filter", "protocol", "title", "torrent_name", "size", "timestamp", "COUNT() OVER() AS total_count").
|
||||||
|
From("release").
|
||||||
|
OrderBy("timestamp DESC")
|
||||||
|
|
||||||
if params.Limit > 0 {
|
if params.Limit > 0 {
|
||||||
queryBuilder = queryBuilder.Limit(params.Limit)
|
queryBuilder = queryBuilder.Limit(params.Limit)
|
||||||
|
@ -86,8 +89,11 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([
|
||||||
queryBuilder = queryBuilder.Limit(20)
|
queryBuilder = queryBuilder.Limit(20)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if params.Offset > 0 {
|
||||||
|
queryBuilder = queryBuilder.Offset(params.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
if params.Cursor > 0 {
|
if params.Cursor > 0 {
|
||||||
//queryBuilder = queryBuilder.Where(sq.Gt{"id": params.Cursor})
|
|
||||||
queryBuilder = queryBuilder.Where(sq.Lt{"id": params.Cursor})
|
queryBuilder = queryBuilder.Where(sq.Lt{"id": params.Cursor})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,26 +114,27 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([
|
||||||
rows, err := repo.db.QueryContext(ctx, query, args...)
|
rows, err := repo.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Stack().Err(err).Msg("error fetching releases")
|
log.Error().Stack().Err(err).Msg("error fetching releases")
|
||||||
//return
|
return res, 0, 0, nil
|
||||||
return res, 0, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
log.Error().Stack().Err(err)
|
log.Error().Stack().Err(err)
|
||||||
return res, 0, err
|
return res, 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var countItems int64 = 0
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var rls domain.Release
|
var rls domain.Release
|
||||||
|
|
||||||
var indexer, filter sql.NullString
|
var indexer, filter sql.NullString
|
||||||
var timestamp string
|
var timestamp string
|
||||||
|
|
||||||
if err := rows.Scan(&rls.ID, &rls.FilterStatus, &rls.PushStatus, pq.Array(&rls.Rejections), &indexer, &filter, &rls.Protocol, &rls.Title, &rls.TorrentName, &rls.Size, ×tamp); err != nil {
|
if err := rows.Scan(&rls.ID, &rls.FilterStatus, &rls.PushStatus, pq.Array(&rls.Rejections), &indexer, &filter, &rls.Protocol, &rls.Title, &rls.TorrentName, &rls.Size, ×tamp, &countItems); err != nil {
|
||||||
log.Error().Stack().Err(err).Msg("release.find: error scanning data to struct")
|
log.Error().Stack().Err(err).Msg("release.find: error scanning data to struct")
|
||||||
return res, 0, err
|
return res, 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rls.Indexer = indexer.String
|
rls.Indexer = indexer.String
|
||||||
|
@ -146,7 +153,7 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([
|
||||||
//nextCursor, _ = strconv.ParseInt(lastID, 10, 64)
|
//nextCursor, _ = strconv.ParseInt(lastID, 10, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res, nextCursor, nil
|
return res, nextCursor, countItems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *ReleaseRepo) Stats(ctx context.Context) (*domain.ReleaseStats, error) {
|
func (repo *ReleaseRepo) Stats(ctx context.Context) (*domain.ReleaseStats, error) {
|
||||||
|
|
|
@ -18,7 +18,7 @@ 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, err error)
|
Find(ctx context.Context, params QueryParams) (res []Release, nextCursor int64, count int64, err error)
|
||||||
Stats(ctx context.Context) (*ReleaseStats, error)
|
Stats(ctx context.Context) (*ReleaseStats, error)
|
||||||
UpdatePushStatus(ctx context.Context, id int64, status ReleasePushStatus) error
|
UpdatePushStatus(ctx context.Context, id int64, status ReleasePushStatus) error
|
||||||
UpdatePushStatusRejected(ctx context.Context, id int64, rejections string) error
|
UpdatePushStatusRejected(ctx context.Context, id int64, rejections string) error
|
||||||
|
@ -913,6 +913,7 @@ const (
|
||||||
|
|
||||||
type QueryParams struct {
|
type QueryParams struct {
|
||||||
Limit uint64
|
Limit uint64
|
||||||
|
Offset uint64
|
||||||
Cursor uint64
|
Cursor uint64
|
||||||
Sort map[string]string
|
Sort map[string]string
|
||||||
Filter map[string]string
|
Filter map[string]string
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseService interface {
|
type releaseService interface {
|
||||||
Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, err error)
|
Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error)
|
||||||
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,15 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offsetP := r.URL.Query().Get("offset")
|
||||||
|
offset, err := strconv.Atoi(offsetP)
|
||||||
|
if err != nil && offsetP != "" {
|
||||||
|
h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{
|
||||||
|
"code": "BAD_REQUEST_PARAMS",
|
||||||
|
"message": "offset parameter is invalid",
|
||||||
|
}, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
cursorP := r.URL.Query().Get("cursor")
|
cursorP := r.URL.Query().Get("cursor")
|
||||||
cursor, err := strconv.Atoi(cursorP)
|
cursor, err := strconv.Atoi(cursorP)
|
||||||
if err != nil && cursorP != "" {
|
if err != nil && cursorP != "" {
|
||||||
|
@ -56,12 +65,13 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
query := domain.QueryParams{
|
query := domain.QueryParams{
|
||||||
Limit: uint64(limit),
|
Limit: uint64(limit),
|
||||||
|
Offset: uint64(offset),
|
||||||
Cursor: uint64(cursor),
|
Cursor: uint64(cursor),
|
||||||
Sort: nil,
|
Sort: nil,
|
||||||
//Filter: "",
|
//Filter: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
releases, nextCursor, err := h.service.Find(r.Context(), query)
|
releases, nextCursor, count, err := h.service.Find(r.Context(), query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.encoder.StatusNotFound(r.Context(), w)
|
h.encoder.StatusNotFound(r.Context(), w)
|
||||||
return
|
return
|
||||||
|
@ -70,9 +80,11 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
ret := struct {
|
ret := struct {
|
||||||
Data []domain.Release `json:"data"`
|
Data []domain.Release `json:"data"`
|
||||||
NextCursor int64 `json:"next_cursor"`
|
NextCursor int64 `json:"next_cursor"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
}{
|
}{
|
||||||
Data: releases,
|
Data: releases,
|
||||||
NextCursor: nextCursor,
|
NextCursor: nextCursor,
|
||||||
|
Count: count,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK)
|
h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK)
|
||||||
|
|
|
@ -11,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, err error)
|
Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err 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
|
||||||
UpdatePushStatus(ctx context.Context, id int64, status domain.ReleasePushStatus) error
|
UpdatePushStatus(ctx context.Context, id int64, status domain.ReleasePushStatus) error
|
||||||
|
@ -31,16 +31,12 @@ func NewService(repo domain.ReleaseRepo, actionService action.Service) Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, err error) {
|
func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) {
|
||||||
//releases, err := s.repo.Find(ctx, query)
|
res, nextCursor, count, err = s.repo.Find(ctx, query)
|
||||||
res, nextCursor, err = s.repo.Find(ctx, query)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//return nil, err
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
//return releases, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) Stats(ctx context.Context) (*domain.ReleaseStats, error) {
|
func (s *service) Stats(ctx context.Context) (*domain.ReleaseStats, error) {
|
||||||
|
|
|
@ -188,6 +188,7 @@ export interface Release {
|
||||||
export interface ReleaseFindResponse {
|
export interface ReleaseFindResponse {
|
||||||
data: Release[];
|
data: Release[];
|
||||||
next_cursor: number;
|
next_cursor: number;
|
||||||
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReleaseStats {
|
export interface ReleaseStats {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import Settings from "./Settings";
|
||||||
import { Dashboard } from "./Dashboard";
|
import { Dashboard } from "./Dashboard";
|
||||||
import { FilterDetails, Filters } from "./filters";
|
import { FilterDetails, Filters } from "./filters";
|
||||||
import Logs from './Logs';
|
import Logs from './Logs';
|
||||||
|
import { Releases } from "./Releases";
|
||||||
import logo from '../logo.png';
|
import logo from '../logo.png';
|
||||||
|
|
||||||
function classNames(...classes: string[]) {
|
function classNames(...classes: string[]) {
|
||||||
|
@ -13,7 +14,7 @@ function classNames(...classes: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Base() {
|
export default function Base() {
|
||||||
const nav = [{ name: 'Dashboard', path: "/" }, { name: 'Filters', path: "/filters" }, { name: "Settings", path: "/settings" }, { name: "Logs", path: "/logs" }]
|
const nav = [{ name: 'Dashboard', path: "/" }, { name: 'Filters', path: "/filters" }, { name: 'Releases', path: "/releases" }, { name: "Settings", path: "/settings" }, { name: "Logs", path: "/logs" }]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
|
@ -212,6 +213,10 @@ export default function Base() {
|
||||||
<Settings />
|
<Settings />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/releases">
|
||||||
|
<Releases />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route exact={true} path="/filters">
|
<Route exact={true} path="/filters">
|
||||||
<Filters />
|
<Filters />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
464
web/src/screens/Releases.tsx
Normal file
464
web/src/screens/Releases.tsx
Normal file
|
@ -0,0 +1,464 @@
|
||||||
|
import { ChevronDoubleLeftIcon, ChevronLeftIcon, ChevronRightIcon, ChevronDoubleRightIcon } from "@heroicons/react/solid"
|
||||||
|
import { formatDistanceToNowStrict } from "date-fns"
|
||||||
|
import React from "react"
|
||||||
|
import { useQuery } from "react-query"
|
||||||
|
import { useTable, useSortBy, usePagination } from "react-table"
|
||||||
|
import APIClient from "../api/APIClient"
|
||||||
|
import { EmptyListState } from "../components/emptystates"
|
||||||
|
import { classNames } from "../utils"
|
||||||
|
|
||||||
|
export function Releases() {
|
||||||
|
return (
|
||||||
|
<main className="-mt-48">
|
||||||
|
|
||||||
|
<header className="py-10">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
|
||||||
|
<h1 className="text-3xl font-bold text-white capitalize">Releases</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="px-4 pb-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||||
|
<Table />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a custom filter UI for selecting
|
||||||
|
// a unique option from a list
|
||||||
|
export function SelectColumnFilter({
|
||||||
|
column: { filterValue, setFilter, preFilteredRows, id, render },
|
||||||
|
}: any) {
|
||||||
|
// Calculate the options for filtering
|
||||||
|
// using the preFilteredRows
|
||||||
|
const options = React.useMemo(() => {
|
||||||
|
const options: any = new Set()
|
||||||
|
preFilteredRows.forEach((row: { values: { [x: string]: unknown } }) => {
|
||||||
|
options.add(row.values[id])
|
||||||
|
})
|
||||||
|
return [...options.values()]
|
||||||
|
}, [id, preFilteredRows])
|
||||||
|
|
||||||
|
// Render a multi-select box
|
||||||
|
return (
|
||||||
|
<label className="flex items-baseline gap-x-2">
|
||||||
|
<span className="text-gray-700">{render("Header")}: </span>
|
||||||
|
<select
|
||||||
|
className="border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||||
|
name={id}
|
||||||
|
id={id}
|
||||||
|
value={filterValue}
|
||||||
|
onChange={e => {
|
||||||
|
setFilter(e.target.value || undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{options.map((option, i) => (
|
||||||
|
<option key={i} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// export function StatusPill({ value }: any) {
|
||||||
|
|
||||||
|
// const status = value ? value.toLowerCase() : "unknown";
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <span
|
||||||
|
// className={
|
||||||
|
// classNames(
|
||||||
|
// "px-3 py-1 uppercase leading-wide font-bold text-xs rounded-full shadow-sm",
|
||||||
|
// status.startsWith("active") ? "bg-green-100 text-green-800" : "",
|
||||||
|
// status.startsWith("inactive") ? "bg-yellow-100 text-yellow-800" : "",
|
||||||
|
// status.startsWith("offline") ? "bg-red-100 text-red-800" : "",
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// >
|
||||||
|
// {status}
|
||||||
|
// </span>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
export function StatusPill({ value }: any) {
|
||||||
|
|
||||||
|
const statusMap: any = {
|
||||||
|
"FILTER_APPROVED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-blue-100 text-blue-800 ">Approved</span>,
|
||||||
|
"FILTER_REJECTED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-red-100 text-red-800">Rejected</span>,
|
||||||
|
"PUSH_REJECTED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-pink-100 text-pink-800">Rejected</span>,
|
||||||
|
"PUSH_APPROVED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-green-100 text-green-800">Approved</span>,
|
||||||
|
"PENDING": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-yellow-100 text-yellow-800">PENDING</span>,
|
||||||
|
"MIXED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-yellow-100 text-yellow-800">MIXED</span>,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
statusMap[value]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgeCell({ value, column, row }: any) {
|
||||||
|
|
||||||
|
const formatDate = formatDistanceToNowStrict(
|
||||||
|
new Date(value),
|
||||||
|
{ addSuffix: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-500" title={value}>{formatDate}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReleaseCell({ value, column, row }: any) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-300" title={value}>{value}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
queryPageIndex: 0,
|
||||||
|
queryPageSize: 10,
|
||||||
|
totalCount: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_CHANGED = 'PAGE_CHANGED';
|
||||||
|
const PAGE_SIZE_CHANGED = 'PAGE_SIZE_CHANGED';
|
||||||
|
const TOTAL_COUNT_CHANGED = 'TOTAL_COUNT_CHANGED';
|
||||||
|
|
||||||
|
const reducer = (state: any, { type, payload }: any) => {
|
||||||
|
switch (type) {
|
||||||
|
case PAGE_CHANGED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queryPageIndex: payload,
|
||||||
|
};
|
||||||
|
case PAGE_SIZE_CHANGED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queryPageSize: payload,
|
||||||
|
};
|
||||||
|
case TOTAL_COUNT_CHANGED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
totalCount: payload,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
throw new Error(`Unhandled action type: ${type}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function Table() {
|
||||||
|
const columns = React.useMemo(() => [
|
||||||
|
{
|
||||||
|
Header: "Age",
|
||||||
|
accessor: 'timestamp',
|
||||||
|
Cell: AgeCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Release",
|
||||||
|
accessor: 'torrent_name',
|
||||||
|
Cell: ReleaseCell,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// Header: "Filter Status",
|
||||||
|
// accessor: 'filter_status',
|
||||||
|
// Cell: StatusPill,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
Header: "Push Status",
|
||||||
|
accessor: 'push_status',
|
||||||
|
Cell: StatusPill,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Indexer",
|
||||||
|
accessor: 'indexer',
|
||||||
|
Filter: SelectColumnFilter, // new
|
||||||
|
filter: 'includes',
|
||||||
|
},
|
||||||
|
], [])
|
||||||
|
|
||||||
|
const [{ queryPageIndex, queryPageSize, totalCount }, dispatch] =
|
||||||
|
React.useReducer(reducer, initialState);
|
||||||
|
|
||||||
|
const { isLoading, error, data, isSuccess } = useQuery(
|
||||||
|
['releases', queryPageIndex, queryPageSize],
|
||||||
|
() => APIClient.release.find(`?offset=${queryPageIndex * queryPageSize}&limit=${queryPageSize}`),
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: Infinity,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the state and functions returned from useTable to build your UI
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
prepareRow,
|
||||||
|
page, // Instead of using 'rows', we'll use page,
|
||||||
|
// which has only the rows for the active page
|
||||||
|
|
||||||
|
// The rest of these things are super handy, too ;)
|
||||||
|
canPreviousPage,
|
||||||
|
canNextPage,
|
||||||
|
pageOptions,
|
||||||
|
pageCount,
|
||||||
|
gotoPage,
|
||||||
|
nextPage,
|
||||||
|
previousPage,
|
||||||
|
setPageSize,
|
||||||
|
|
||||||
|
state: { pageIndex, pageSize },
|
||||||
|
// preGlobalFilteredRows,
|
||||||
|
// setGlobalFilter,
|
||||||
|
} = useTable({
|
||||||
|
columns,
|
||||||
|
data: isSuccess ? data.data : [],
|
||||||
|
initialState: {
|
||||||
|
pageIndex: queryPageIndex,
|
||||||
|
pageSize: queryPageSize,
|
||||||
|
},
|
||||||
|
manualPagination: true,
|
||||||
|
manualSortBy: true,
|
||||||
|
pageCount: isSuccess ? Math.ceil(totalCount / queryPageSize) : 0,
|
||||||
|
},
|
||||||
|
// useFilters, // useFilters!
|
||||||
|
// useGlobalFilter,
|
||||||
|
useSortBy,
|
||||||
|
usePagination, // new
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch({ type: PAGE_CHANGED, payload: pageIndex });
|
||||||
|
}, [pageIndex]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch({ type: PAGE_SIZE_CHANGED, payload: pageSize });
|
||||||
|
gotoPage(0);
|
||||||
|
}, [pageSize, gotoPage]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (data?.count) {
|
||||||
|
dispatch({
|
||||||
|
type: TOTAL_COUNT_CHANGED,
|
||||||
|
payload: data.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data?.count]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <p>Error</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the UI for your table
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sm:flex sm:gap-x-2">
|
||||||
|
{/* <GlobalFilter
|
||||||
|
preGlobalFilteredRows={preGlobalFilteredRows}
|
||||||
|
globalFilter={state.globalFilter}
|
||||||
|
setGlobalFilter={setGlobalFilter}
|
||||||
|
/> */}
|
||||||
|
{/* {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>
|
||||||
|
{isSuccess ?
|
||||||
|
<div className="flex flex-col mt-4">
|
||||||
|
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
|
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
|
<div className="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-lg">
|
||||||
|
<table {...getTableProps()} className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
{headerGroups.map((headerGroup: { getHeaderGroupProps: () => JSX.IntrinsicAttributes & React.ClassAttributes<HTMLTableRowElement> & React.HTMLAttributes<HTMLTableRowElement>; headers: any[] }) => (
|
||||||
|
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||||
|
{headerGroup.headers.map(column => (
|
||||||
|
// Add the sorting props to control sorting. For this example
|
||||||
|
// we can add them into the header props
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
|
||||||
|
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{column.render('Header')}
|
||||||
|
{/* Add a sort direction indicator */}
|
||||||
|
<span>
|
||||||
|
{column.isSorted
|
||||||
|
? column.isSortedDesc
|
||||||
|
? <SortDownIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
: <SortUpIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
: (
|
||||||
|
<SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
{...getTableBodyProps()}
|
||||||
|
className="divide-y divide-gray-200 dark:divide-gray-700"
|
||||||
|
>
|
||||||
|
{page.map((row: any, i: any) => { // new
|
||||||
|
prepareRow(row)
|
||||||
|
return (
|
||||||
|
<tr {...row.getRowProps()}>
|
||||||
|
{row.cells.map((cell: any) => {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
{...cell.getCellProps()}
|
||||||
|
className="px-6 py-4 whitespace-nowrap"
|
||||||
|
role="cell"
|
||||||
|
>
|
||||||
|
{cell.column.Cell.name === "defaultRenderer"
|
||||||
|
? <div className="text-sm text-gray-500">{cell.render('Cell')}</div>
|
||||||
|
: cell.render('Cell')
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex justify-between flex-1 sm:hidden">
|
||||||
|
<Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</Button>
|
||||||
|
<Button onClick={() => nextPage()} disabled={!canNextPage}>Next</Button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-baseline gap-x-2">
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
|
||||||
|
</span>
|
||||||
|
<label>
|
||||||
|
<span className="sr-only">Items Per Page</span>
|
||||||
|
<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"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={e => {
|
||||||
|
setPageSize(Number(e.target.value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[5, 10, 20, 50].map(pageSize => (
|
||||||
|
<option key={pageSize} value={pageSize}>
|
||||||
|
Show {pageSize}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="relative z-0 inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
|
<PageButton
|
||||||
|
className="rounded-l-md"
|
||||||
|
onClick={() => gotoPage(0)}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
|
>
|
||||||
|
<span className="sr-only">First</span>
|
||||||
|
<ChevronDoubleLeftIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</PageButton>
|
||||||
|
<PageButton
|
||||||
|
onClick={() => previousPage()}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous</span>
|
||||||
|
<ChevronLeftIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</PageButton>
|
||||||
|
<PageButton
|
||||||
|
onClick={() => nextPage()}
|
||||||
|
disabled={!canNextPage
|
||||||
|
}>
|
||||||
|
<span className="sr-only">Next</span>
|
||||||
|
<ChevronRightIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</PageButton>
|
||||||
|
<PageButton
|
||||||
|
className="rounded-r-md"
|
||||||
|
onClick={() => gotoPage(pageCount - 1)}
|
||||||
|
disabled={!canNextPage}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Last</span>
|
||||||
|
<ChevronDoubleRightIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</PageButton>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
: <EmptyListState text="No recent activity" />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortIcon({ className }: any) {
|
||||||
|
return (
|
||||||
|
<svg className={className} stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 320 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M41 288h238c21.4 0 32.1 25.9 17 41L177 448c-9.4 9.4-24.6 9.4-33.9 0L24 329c-15.1-15.1-4.4-41 17-41zm255-105L177 64c-9.4-9.4-24.6-9.4-33.9 0L24 183c-15.1 15.1-4.4 41 17 41h238c21.4 0 32.1-25.9 17-41z"></path></svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortUpIcon({ className }: any) {
|
||||||
|
return (
|
||||||
|
<svg className={className} stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 320 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M279 224H41c-21.4 0-32.1-25.9-17-41L143 64c9.4-9.4 24.6-9.4 33.9 0l119 119c15.2 15.1 4.5 41-16.9 41z"></path></svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortDownIcon({ className }: any) {
|
||||||
|
return (
|
||||||
|
<svg className={className} stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 320 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M41 288h238c21.4 0 32.1 25.9 17 41L177 448c-9.4 9.4-24.6 9.4-33.9 0L24 329c-15.1-15.1-4.4-41 17-41z"></path></svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button({ children, className, ...rest }: any) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageButton({ children, className, ...rest }: any) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue