add cards table and pagination

This commit is contained in:
2023-10-07 23:44:20 +02:00
parent 450d664f34
commit 33f3fc6137
21 changed files with 635 additions and 52 deletions

View File

@@ -10,3 +10,5 @@ export { default as PersonOutline } from './person-outline'
export { default as ChevronUp } from './chevron-up' export { default as ChevronUp } from './chevron-up'
export { default as Close } from './close' export { default as Close } from './close'
export { default as Search } from './search' export { default as Search } from './search'
export { default as KeyboardArrowLeft } from './keyboard-arrow-left'
export { default as KeyboardArrowRight } from './keyboard-arrow-right'

View File

@@ -0,0 +1,27 @@
import { SVGProps, Ref, forwardRef, memo } from 'react'
const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
ref={ref}
{...props}
>
<g clipPath="url(#clip0_5928_3055)">
<path
d="M10.2733 11.06L7.21998 8L10.2733 4.94L9.33331 4L5.33331 8L9.33331 12L10.2733 11.06Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_5928_3055">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
)
const ForwardRef = forwardRef(SvgComponent)
export default memo(ForwardRef)

View File

@@ -0,0 +1,27 @@
import { SVGProps, Ref, forwardRef, memo } from 'react'
const SvgComponent = (props: SVGProps<SVGSVGElement>, ref: Ref<SVGSVGElement>) => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
ref={ref}
{...props}
>
<g clipPath="url(#clip0_5928_3027)">
<path
d="M5.72665 11.06L8.77999 8L5.72665 4.94L6.66665 4L10.6667 8L6.66665 12L5.72665 11.06Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_5928_3027">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
)
const ForwardRef = forwardRef(SvgComponent)
export default memo(ForwardRef)

View File

@@ -0,0 +1,47 @@
import { Column, Table, TableBody, TableCell, TableHeader, TableRow } from '@/components'
import { Card } from '@/services/decks'
import { formatDate } from '@/utils'
const columns: Column[] = [
{
key: 'question',
title: 'Question',
sortable: true,
},
{
key: 'answer',
title: 'Answer',
sortable: true,
},
{
key: 'updated',
title: 'Last Updated',
sortable: true,
},
{
key: 'grade',
title: 'Grade',
sortable: true,
},
]
type Props = {
cards: Card[] | undefined
}
export const CardsTable = ({ cards }: Props) => {
return (
<Table>
<TableHeader columns={columns} />
<TableBody>
{cards?.map(card => (
<TableRow key={card.id}>
<TableCell>{card.question}</TableCell>
<TableCell>{card.answer}</TableCell>
<TableCell>{formatDate(card.updated)}</TableCell>
<TableCell>{card.grade}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@@ -0,0 +1,62 @@
import { Link } from 'react-router-dom'
import {
Column,
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
Typography,
} from '@/components'
import { Deck } from '@/services/decks'
import { formatDate } from '@/utils'
const columns: Column[] = [
{
key: 'name',
title: 'Name',
},
{
key: 'cardsCount',
title: 'Cards',
},
{
key: 'updated',
title: 'Last Updated',
},
{
key: 'author',
title: 'Created By',
},
{
key: 'actions',
title: '',
},
]
type Props = {
decks: Deck[] | undefined
}
export const DecksTable = ({ decks }: Props) => {
return (
<Table>
<TableHeader columns={columns} />
<TableBody>
{decks?.map(deck => (
<TableRow key={deck.id}>
<TableCell>
<Typography variant={'body2'} as={Link} to={`/decks/${deck.id}`}>
{deck.name}
</Typography>
</TableCell>
<TableCell>{deck.cardsCount}</TableCell>
<TableCell>{formatDate(deck.updated)}</TableCell>
<TableCell>{deck.author.name}</TableCell>
<TableCell>...</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@@ -0,0 +1,2 @@
export * from './decks-table.tsx'
export * from './cards-table.tsx'

View File

@@ -1,3 +1,4 @@
export * from './ui' export * from './ui'
export * from './auth' export * from './auth'
export * from './profile' export * from './profile'
export * from './decks'

View File

@@ -0,0 +1 @@
export * from './pagination'

View File

@@ -0,0 +1,75 @@
.root {
display: flex;
gap: 25px;
align-items: center;
}
.container {
display: flex;
gap: 12px;
list-style-type: none;
}
@mixin item {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--color-light-100);
border-radius: 2px;
}
.item {
@include item;
cursor: pointer;
&:focus-visible {
outline: var(--outline-focus);
}
&:disabled {
cursor: initial;
opacity: 1;
}
&:hover:not(&:disabled) {
background-color: var(--color-dark-500);
}
&.selected {
color: var(--color-dark-900);
background-color: var(--color-light-100);
}
}
.dots {
@include item;
}
.icon {
.item:disabled & {
// important because icons have style prop
color: var(--color-action-disabled) !important;
}
}
.selectBox {
display: flex;
gap: 12px;
align-items: center;
color: var(--color-light-100);
white-space: nowrap;
}
.select {
flex-shrink: 0;
width: 50px;
}

View File

@@ -0,0 +1,192 @@
import { FC } from 'react'
import { clsx } from 'clsx'
import s from './pagination.module.scss'
import { usePagination } from './usePagination'
import { KeyboardArrowLeft, KeyboardArrowRight } from '@/assets'
type PaginationConditionals =
| {
perPage?: null
perPageOptions?: never
onPerPageChange?: never
}
| {
perPage: number
perPageOptions: number[]
onPerPageChange: (itemPerPage: number) => void
}
export type PaginationProps = {
count: number
page: number
onChange: (page: number) => void
siblings?: number
perPage?: number
perPageOptions?: number[]
onPerPageChange?: (itemPerPage: number) => void
} & PaginationConditionals
const classNames = {
root: s.root,
container: s.container,
selectBox: s.selectBox,
select: s.select,
item: s.item,
dots: s.dots,
icon: s.icon,
pageButton(selected?: boolean) {
return clsx(this.item, selected && s.selected)
},
}
export const Pagination: FC<PaginationProps> = ({
onChange,
count,
page,
perPage = null,
perPageOptions,
onPerPageChange,
siblings,
}) => {
const {
paginationRange,
isLastPage,
isFirstPage,
handlePreviousPageClicked,
handleNextPageClicked,
handleMainPageClicked,
} = usePagination({
page,
count,
onChange,
siblings,
})
const showPerPageSelect = !!perPage && !!perPageOptions && !!onPerPageChange
return (
<div className={classNames.root}>
<div className={classNames.container}>
<PrevButton onClick={handlePreviousPageClicked} disabled={isFirstPage} />
<MainPaginationButtons
currentPage={page}
onClick={handleMainPageClicked}
paginationRange={paginationRange}
/>
<NextButton onClick={handleNextPageClicked} disabled={isLastPage} />
</div>
{showPerPageSelect && (
<PerPageSelect
{...{
perPage,
perPageOptions,
onPerPageChange,
}}
/>
)}
</div>
)
}
type NavigationButtonProps = {
onClick: () => void
disabled?: boolean
}
type PageButtonProps = NavigationButtonProps & {
page: number
selected: boolean
}
const Dots: FC = () => {
return <span className={classNames.dots}>&#8230;</span>
}
const PageButton: FC<PageButtonProps> = ({ onClick, disabled, selected, page }) => {
return (
<button
onClick={onClick}
disabled={selected || disabled}
className={classNames.pageButton(selected)}
>
{page}
</button>
)
}
const PrevButton: FC<NavigationButtonProps> = ({ onClick, disabled }) => {
return (
<button className={classNames.item} onClick={onClick} disabled={disabled}>
<KeyboardArrowLeft className={classNames.icon} />
</button>
)
}
const NextButton: FC<NavigationButtonProps> = ({ onClick, disabled }) => {
return (
<button className={classNames.item} onClick={onClick} disabled={disabled}>
<KeyboardArrowRight className={classNames.icon} />
</button>
)
}
type MainPaginationButtonsProps = {
paginationRange: (number | string)[]
currentPage: number
onClick: (pageNumber: number) => () => void
}
const MainPaginationButtons: FC<MainPaginationButtonsProps> = ({
paginationRange,
currentPage,
onClick,
}) => {
return (
<>
{paginationRange.map((page: number | string, index) => {
const isSelected = page === currentPage
if (typeof page !== 'number') {
return <Dots key={index} />
}
return <PageButton key={index} page={page} selected={isSelected} onClick={onClick(page)} />
})}
</>
)
}
export type PerPageSelectProps = {
perPage: number
perPageOptions: number[]
onPerPageChange: (itemPerPage: number) => void
}
export const PerPageSelect: FC<PerPageSelectProps> = ({
perPage,
perPageOptions,
onPerPageChange,
}) => {
const selectOptions = perPageOptions.map(value => ({
label: value,
value,
}))
return (
<div className={classNames.selectBox}>
Показать
{/*<Select*/}
{/* className={classNames.select}*/}
{/* value={perPage}*/}
{/* options={selectOptions}*/}
{/* onChange={onPerPageChange}*/}
{/* variant="pagination"*/}
{/*/>*/}
на странице
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useMemo } from 'react'
// original code: https://www.freecodecamp.org/news/build-a-custom-pagination-component-in-react/
const range = (start: number, end: number) => {
let length = end - start + 1
/*
Create an array of certain length and set the elements within it from
start value to end value.
*/
return Array.from({ length }, (_, idx) => idx + start)
}
const DOTS = '...'
type UsePaginationParamType = {
count: number
siblings?: number
page: number
onChange: (pageNumber: number) => void
}
type PaginationRange = (number | '...')[]
export const usePagination = ({ count, siblings = 1, page, onChange }: UsePaginationParamType) => {
const paginationRange = useMemo(() => {
// Pages count is determined as siblingCount + firstPage + lastPage + page + 2*DOTS
const totalPageNumbers = siblings + 5
/*
Case 1:
If the number of pages is less than the page numbers we want to show in our
paginationComponent, we return the range [1..totalPageCount]
*/
if (totalPageNumbers >= count) {
return range(1, count)
}
/*
Calculate left and right sibling index and make sure they are within range 1 and totalPageCount
*/
const leftSiblingIndex = Math.max(page - siblings, 1)
const rightSiblingIndex = Math.min(page + siblings, count)
/*
We do not show dots when there is only one page number to be inserted
between the extremes of siblings and the page limits i.e 1 and totalPageCount.
Hence, we are using leftSiblingIndex > 2 and rightSiblingIndex < totalPageCount - 2
*/
const shouldShowLeftDots = leftSiblingIndex > 2
const shouldShowRightDots = rightSiblingIndex < count - 2
const firstPageIndex = 1
const lastPageIndex = count
/*
Case 2: No left dots to show, but rights dots to be shown
*/
if (!shouldShowLeftDots && shouldShowRightDots) {
let leftItemCount = 3 + 2 * siblings
let leftRange = range(1, leftItemCount)
return [...leftRange, DOTS, count]
}
/*
Case 3: No right dots to show, but left dots to be shown
*/
if (shouldShowLeftDots && !shouldShowRightDots) {
let rightItemCount = 3 + 2 * siblings
let rightRange = range(count - rightItemCount + 1, count)
return [firstPageIndex, DOTS, ...rightRange]
}
/*
Case 4: Both left and right dots to be shown
*/
if (shouldShowLeftDots && shouldShowRightDots) {
let middleRange = range(leftSiblingIndex, rightSiblingIndex)
return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex]
}
}, [siblings, page, count]) as PaginationRange
const lastPage = paginationRange.at(-1)
const isFirstPage = page === 1
const isLastPage = page === lastPage
const handleNextPageClicked = useCallback(() => {
onChange(page + 1)
}, [page, onChange])
const handlePreviousPageClicked = useCallback(() => {
onChange(page - 1)
}, [page, onChange])
function handleMainPageClicked(pageNumber: number) {
return () => onChange(pageNumber)
}
return {
paginationRange,
isFirstPage,
isLastPage,
handleMainPageClicked,
handleNextPageClicked,
handlePreviousPageClicked,
}
}

View File

@@ -0,0 +1,32 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Button, TextField, Typography, CardsTable } from '@/components'
import { Pagination } from '@/components/ui/pagination'
import { useGetDeckByIdQuery, useGetDeckCardsQuery } from '@/services'
export const DeckPage = () => {
const { deckId } = useParams()
const [currentPage, setCurrentPage] = useState(1)
const { data: deckData } = useGetDeckByIdQuery({ id: deckId || '' })
const { data: cardsData } = useGetDeckCardsQuery({ id: deckId || '' })
const learnLink = `/decks/${deckId}/learn`
return (
<div>
<Typography variant={'large'}>{deckData?.name}</Typography>
<Button as={Link} to={learnLink}>
Learn
</Button>
<TextField search placeholder={'Search cards'} />
<CardsTable cards={cardsData?.items} />
<Pagination
count={cardsData?.pagination?.totalPages || 1}
page={currentPage}
onChange={setCurrentPage}
/>
</div>
)
}

View File

@@ -2,46 +2,15 @@ import { useState } from 'react'
import s from './decks-page.module.scss' import s from './decks-page.module.scss'
import { import { Button, Page, Slider, TextField, Typography } from '@/components'
Button, import { DecksTable } from '@/components/decks/decks-table.tsx'
Page, import { Pagination } from '@/components/ui/pagination'
Typography,
Column,
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
TextField,
Slider,
} from '@/components'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useGetDecksQuery } from '@/services/decks' import { useGetDecksQuery } from '@/services/decks'
const columns: Column[] = [
{
key: 'name',
title: 'Name',
},
{
key: 'cardsCount',
title: 'Cards',
},
{
key: 'updated',
title: 'Last Updated',
},
{
key: 'author',
title: 'Created By',
},
{
key: 'actions',
title: '',
},
]
export const DecksPage = () => { export const DecksPage = () => {
const [currentPage, setCurrentPage] = useState(1)
const { data: decks } = useGetDecksQuery() const { data: decks } = useGetDecksQuery()
const [activeTab, setActiveTab] = useState('my') const [activeTab, setActiveTab] = useState('my')
const [range, setRange] = useState([0, 100]) const [range, setRange] = useState([0, 100])
@@ -67,20 +36,12 @@ export const DecksPage = () => {
<Slider onValueCommit={setRange} value={rangeValue} onValueChange={setRangeValue} /> <Slider onValueCommit={setRange} value={rangeValue} onValueChange={setRangeValue} />
<Button variant={'secondary'}>Clear filters</Button> <Button variant={'secondary'}>Clear filters</Button>
</div> </div>
<Table> <DecksTable decks={decks?.items} />
<TableHeader columns={columns} /> <Pagination
<TableBody> count={decks?.pagination?.totalPages || 1}
{decks?.items.map(deck => ( page={currentPage}
<TableRow key={deck.id}> onChange={setCurrentPage}
<TableCell>{deck.name}</TableCell> />
<TableCell>{deck.cardsCount}</TableCell>
<TableCell>{deck.updated}</TableCell>
<TableCell>{deck.author.name}</TableCell>
<TableCell>...</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</Page> </Page>
) )

View File

@@ -8,6 +8,8 @@ import {
import { SignInPage, DecksPage } from './pages' import { SignInPage, DecksPage } from './pages'
import { DeckPage } from '@/pages/deck-page/deck-page.tsx'
const publicRoutes: RouteObject[] = [ const publicRoutes: RouteObject[] = [
{ {
element: <Outlet />, element: <Outlet />,
@@ -25,6 +27,10 @@ const privateRoutes: RouteObject[] = [
path: '/', path: '/',
element: <DecksPage />, element: <DecksPage />,
}, },
{
path: '/decks/:deckId',
element: <DeckPage />,
},
] ]
const router = createBrowserRouter([ const router = createBrowserRouter([

View File

@@ -1,4 +1,4 @@
import { DecksResponse } from './decks.types' import { CardsResponse, DeckResponse, DecksResponse } from './decks.types'
import { baseApi } from '@/services' import { baseApi } from '@/services'
@@ -7,7 +7,13 @@ const decksService = baseApi.injectEndpoints({
getDecks: builder.query<DecksResponse, void>({ getDecks: builder.query<DecksResponse, void>({
query: () => `v1/decks`, query: () => `v1/decks`,
}), }),
getDeckById: builder.query<DeckResponse, { id: string }>({
query: ({ id }) => `v1/decks/${id}`,
}),
getDeckCards: builder.query<CardsResponse, { id: string }>({
query: ({ id }) => `v1/decks/${id}/cards`,
}),
}), }),
}) })
export const { useGetDecksQuery } = decksService export const { useGetDecksQuery, useGetDeckByIdQuery, useGetDeckCardsQuery } = decksService

View File

@@ -31,3 +31,24 @@ export type DecksResponse = {
pagination: Pagination pagination: Pagination
items: Deck[] items: Deck[]
} }
export type DeckResponse = Deck
export type CardsResponse = {
pagination: Pagination
items: Card[]
}
export type Card = {
id: string
question: string
answer: string
deckId: string
questionImg?: string | null
answerImg?: string | null
created: string
updated: string
shots: number
grade: number
userId: string
}

View File

@@ -1 +1,2 @@
export * from './base-api' export * from './base-api'
export * from './decks'

View File

@@ -1,5 +1,6 @@
:root { :root {
/* outlines */ /* outlines */
--color-outline-focus: var(--color-info-900);
--outline-focus: 2px solid var(--color-outline-focus); --outline-focus: 2px solid var(--color-outline-focus);
/* transitions */ /* transitions */

View File

@@ -0,0 +1,5 @@
export function formatDate(date: string | undefined) {
if (!date) return ''
return new Date(date).toLocaleDateString('ru-RU')
}

1
src/utils/date/index.ts Normal file
View File

@@ -0,0 +1 @@
export { formatDate } from './format-date'

1
src/utils/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './date'