mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-01 12:26:08 +08:00
Merge pull request #111 from parcelvoy/feat/adds-sorting-to-tables
Adds sorting to tables
This commit is contained in:
commit
a035276986
14 changed files with 191 additions and 70 deletions
|
@ -2,7 +2,7 @@ import Router from '@koa/router'
|
|||
import { JSONSchemaType, validate } from '../core/validate'
|
||||
import Campaign, { CampaignCreateParams, CampaignUpdateParams } from './Campaign'
|
||||
import { archiveCampaign, createCampaign, deleteCampaign, duplicateCampaign, getCampaign, getCampaignUsers, pagedCampaigns, updateCampaign } from './CampaignService'
|
||||
import { searchParamsSchema } from '../core/searchParams'
|
||||
import { searchParamsSchema, SearchSchema } from '../core/searchParams'
|
||||
import { extractQueryParams } from '../utilities'
|
||||
import { ProjectState } from '../auth/AuthMiddleware'
|
||||
import { projectRoleMiddleware } from '../projects/ProjectService'
|
||||
|
@ -14,7 +14,11 @@ const router = new Router<ProjectState & { campaign?: Campaign }>({
|
|||
router.use(projectRoleMiddleware('editor'))
|
||||
|
||||
router.get('/', async ctx => {
|
||||
const params = extractQueryParams(ctx.query, searchParamsSchema)
|
||||
const searchSchema = SearchSchema('campaignSearchSchema', {
|
||||
sort: 'id',
|
||||
direction: 'desc',
|
||||
})
|
||||
const params = extractQueryParams(ctx.query, searchSchema)
|
||||
ctx.body = await pagedCampaigns(params, ctx.state.project.id)
|
||||
})
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ export const pagedCampaigns = async (params: SearchParams, projectId: number) =>
|
|||
params,
|
||||
['name'],
|
||||
b => {
|
||||
b.where({ project_id: projectId }).orderBy('id', 'desc')
|
||||
b.where({ project_id: projectId })
|
||||
params.tag?.length && b.whereIn('id', createTagSubquery(Campaign, projectId, params.tag))
|
||||
return b
|
||||
},
|
||||
|
|
|
@ -168,7 +168,7 @@ export default class Model {
|
|||
query: Query = qb => qb,
|
||||
db: Database = App.main.db,
|
||||
) {
|
||||
let { page, itemsPerPage, sort, q, id } = params
|
||||
const { page, itemsPerPage, sort, direction, q, id } = params
|
||||
return await this.search(
|
||||
b => {
|
||||
b = query(b)
|
||||
|
@ -188,12 +188,7 @@ export default class Model {
|
|||
})
|
||||
}
|
||||
if (sort) {
|
||||
let desc = false
|
||||
if (sort.charAt(0) === '-') {
|
||||
desc = true
|
||||
sort = sort.substring(1)
|
||||
}
|
||||
b.orderBy(sort, desc ? 'desc' : 'asc')
|
||||
b.orderBy(sort, direction ?? 'asc')
|
||||
}
|
||||
if (id?.length) {
|
||||
b.whereIn('id', id)
|
||||
|
|
|
@ -5,47 +5,59 @@ export interface SearchParams {
|
|||
itemsPerPage: number
|
||||
q?: string
|
||||
sort?: string
|
||||
direction?: string
|
||||
tag?: string[]
|
||||
id?: number[]
|
||||
}
|
||||
|
||||
export const searchParamsSchema: JSONSchemaType<SearchParams> = {
|
||||
$id: 'searchParams',
|
||||
type: 'object',
|
||||
required: ['page', 'itemsPerPage'],
|
||||
properties: {
|
||||
page: {
|
||||
type: 'integer',
|
||||
default: 0,
|
||||
minimum: 0,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: 'integer',
|
||||
default: 25,
|
||||
minimum: -1, // -1 for all
|
||||
},
|
||||
q: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
sort: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
tag: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
id: {
|
||||
type: 'array',
|
||||
items: {
|
||||
export const SearchSchema = (id: string, defaults?: Partial<SearchParams>): JSONSchemaType<SearchParams> => {
|
||||
return {
|
||||
$id: id,
|
||||
type: 'object',
|
||||
required: ['page', 'itemsPerPage'],
|
||||
properties: {
|
||||
page: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: defaults?.page ?? 0,
|
||||
minimum: 0,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: 'integer',
|
||||
default: defaults?.itemsPerPage ?? 25,
|
||||
minimum: -1, // -1 for all
|
||||
},
|
||||
q: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
sort: {
|
||||
type: 'string',
|
||||
default: defaults?.sort,
|
||||
nullable: true,
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
default: defaults?.direction,
|
||||
enum: ['asc', 'desc'],
|
||||
},
|
||||
tag: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
id: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const searchParamsSchema: JSONSchemaType<SearchParams> = SearchSchema('searchParams')
|
||||
|
|
|
@ -6,7 +6,7 @@ import UserPatchJob from './UserPatchJob'
|
|||
import { JSONSchemaType, validate } from '../core/validate'
|
||||
import { User, UserParams } from './User'
|
||||
import { extractQueryParams } from '../utilities'
|
||||
import { searchParamsSchema } from '../core/searchParams'
|
||||
import { searchParamsSchema, SearchSchema } from '../core/searchParams'
|
||||
import { getUser, pagedUsers } from './UserRepository'
|
||||
import { getUserLists } from '../lists/ListService'
|
||||
import { getUserSubscriptions, toggleSubscription } from '../subscriptions/SubscriptionService'
|
||||
|
@ -21,7 +21,11 @@ const router = new Router<
|
|||
})
|
||||
|
||||
router.get('/', async ctx => {
|
||||
const params = extractQueryParams(ctx.query, searchParamsSchema)
|
||||
const searchSchema = SearchSchema('usersSearchSchema', {
|
||||
sort: 'id',
|
||||
direction: 'desc',
|
||||
})
|
||||
const params = extractQueryParams(ctx.query, searchSchema)
|
||||
ctx.body = await pagedUsers(params, ctx.state.project.id)
|
||||
})
|
||||
|
||||
|
|
|
@ -80,7 +80,9 @@ export interface Preferences {
|
|||
export interface SearchParams {
|
||||
page: number
|
||||
itemsPerPage: number
|
||||
q: string
|
||||
q?: string
|
||||
sort?: string
|
||||
direction?: string
|
||||
tag?: string[]
|
||||
id?: Array<number | string>
|
||||
}
|
||||
|
|
|
@ -34,6 +34,24 @@
|
|||
.ui-table .table-header-cell {
|
||||
padding: 10px 5px;
|
||||
white-space: nowrap;
|
||||
vertical-align: center;
|
||||
}
|
||||
|
||||
.ui-table .table-header-cell .header-cell-content {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ui-table .table-header-cell .header-sort {
|
||||
display: inline-flex;
|
||||
border-radius: var(--border-radius-inner);
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-table .table-header-cell .header-sort:hover {
|
||||
background: var(--color-grey);
|
||||
}
|
||||
|
||||
.ui-table .table-cell {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import clsx from 'clsx'
|
||||
import { Key, ReactNode, useContext } from 'react'
|
||||
import { formatDate, snakeToTitle } from '../utils'
|
||||
import Button from './Button'
|
||||
import './DataTable.css'
|
||||
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from './icons'
|
||||
import { PreferencesContext } from './PreferencesContext'
|
||||
|
||||
type DataTableResolver<T, R> = (args: {
|
||||
|
@ -12,6 +14,49 @@ export interface DataTableCol<T> {
|
|||
key: string
|
||||
title?: ReactNode
|
||||
cell?: DataTableResolver<T, ReactNode>
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
export interface ColSort {
|
||||
sort: string
|
||||
direction: string
|
||||
}
|
||||
|
||||
interface HeaderCellProps<T> {
|
||||
col: DataTableCol<T>
|
||||
columnSort?: ColSort
|
||||
onColumnSort?: (sort?: ColSort) => void
|
||||
}
|
||||
|
||||
export function HeaderCell<T>({ col, columnSort, onColumnSort }: HeaderCellProps<T>) {
|
||||
const { key, title, sortable } = col
|
||||
const handleSort = () => {
|
||||
if (columnSort?.sort !== key) {
|
||||
onColumnSort?.({ sort: key, direction: 'asc' })
|
||||
} else if (columnSort?.direction === 'desc') {
|
||||
onColumnSort?.()
|
||||
} else {
|
||||
onColumnSort?.({ sort: key, direction: 'desc' })
|
||||
}
|
||||
}
|
||||
return <div className="table-header-cell">
|
||||
<div className="header-cell-content">
|
||||
<span>{title ?? snakeToTitle(key)}</span>
|
||||
{sortable && (
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="secondary"
|
||||
onClick={() => handleSort()}
|
||||
icon={
|
||||
columnSort?.sort === key
|
||||
? columnSort?.direction === 'asc'
|
||||
? <ChevronUpIcon />
|
||||
: <ChevronDownIcon />
|
||||
: <ChevronUpDownIcon />
|
||||
} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export interface DataTableProps<T, C = {}> {
|
||||
|
@ -22,6 +67,8 @@ export interface DataTableProps<T, C = {}> {
|
|||
emptyMessage?: ReactNode
|
||||
selectedRow?: Key
|
||||
onSelectRow?: (row: T) => void
|
||||
columnSort?: ColSort
|
||||
onColumnSort?: (sort?: ColSort) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
|
@ -32,6 +79,8 @@ export function DataTable<T>({
|
|||
itemKey,
|
||||
selectedRow,
|
||||
onSelectRow,
|
||||
columnSort,
|
||||
onColumnSort,
|
||||
isLoading = false,
|
||||
}: DataTableProps<T>) {
|
||||
const [preferences] = useContext(PreferencesContext)
|
||||
|
@ -40,9 +89,11 @@ export function DataTable<T>({
|
|||
<div className="table-header">
|
||||
{
|
||||
columns.map(col => (
|
||||
<div className="table-header-cell" key={col.key}>
|
||||
{col.title ?? snakeToTitle(col.key)}
|
||||
</div>
|
||||
<HeaderCell<T>
|
||||
key={col.key}
|
||||
col={col}
|
||||
onColumnSort={onColumnSort}
|
||||
columnSort={columnSort} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useState, ReactNode, useCallback, useMemo } from 'react'
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useDebounceControl, useResolver } from '../hooks'
|
||||
import { SearchParams, SearchResult } from '../types'
|
||||
import { prune } from '../utils'
|
||||
import { TagPicker } from '../views/settings/TagPicker'
|
||||
import { DataTable, DataTableProps } from './DataTable'
|
||||
import TextInput from './form/TextInput'
|
||||
|
@ -22,32 +23,35 @@ export interface SearchTableProps<T extends Record<string, any>> extends Omit<Da
|
|||
tagEntity?: 'journeys' | 'lists' | 'users' | 'campaigns' // anything else we want to tag?
|
||||
}
|
||||
|
||||
const DEFAULT_ITEMS_PER_PAGE = 10
|
||||
const DEFAULT_ITEMS_PER_PAGE = 25
|
||||
const DEFAULT_PAGE = 0
|
||||
|
||||
const toTableParams = (searchParams: URLSearchParams) => {
|
||||
return {
|
||||
page: parseInt(searchParams.get('page') ?? '0'),
|
||||
itemsPerPage: parseInt(searchParams.get('itemsPerPage') ?? '10'),
|
||||
q: searchParams.get('q') ?? '',
|
||||
q: searchParams.get('q') ?? undefined,
|
||||
tag: searchParams.getAll('tag'),
|
||||
sort: searchParams.get('sort') ?? undefined,
|
||||
direction: searchParams.get('direction') ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const fromTableParams = (params: SearchParams) => {
|
||||
return {
|
||||
const fromTableParams = (params: SearchParams): Record<string, string> => {
|
||||
return prune({
|
||||
page: params.page.toString(),
|
||||
itemsPerPage: params.itemsPerPage.toString(),
|
||||
q: params.q,
|
||||
tag: params.tag ?? [],
|
||||
}
|
||||
sort: params.sort,
|
||||
direction: params.direction,
|
||||
})
|
||||
}
|
||||
|
||||
export const useTableSearchParams = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams({
|
||||
page: DEFAULT_PAGE.toString(),
|
||||
itemsPerPage: DEFAULT_ITEMS_PER_PAGE.toString(),
|
||||
q: '',
|
||||
})
|
||||
|
||||
const setParams = useCallback<(params: SearchParams | ((prev: SearchParams) => SearchParams)) => void>(next => {
|
||||
|
@ -123,7 +127,9 @@ export function SearchTable<T extends Record<string, any>>({
|
|||
}: SearchTableProps<T>) {
|
||||
|
||||
const [search, setSearch] = useDebounceControl(params.q ?? '', q => setParams({ ...params, q }))
|
||||
|
||||
const columnSort = params.sort
|
||||
? { sort: params.sort, direction: params.direction ?? 'asc' }
|
||||
: undefined
|
||||
const filters: ReactNode[] = []
|
||||
|
||||
if (enableSearch) {
|
||||
|
@ -173,7 +179,14 @@ export function SearchTable<T extends Record<string, any>>({
|
|||
</Stack>
|
||||
)
|
||||
}
|
||||
<DataTable {...rest} items={results?.results} isLoading={!results} />
|
||||
<DataTable {...rest}
|
||||
items={results?.results}
|
||||
isLoading={!results}
|
||||
columnSort={columnSort}
|
||||
onColumnSort={(onSort) => {
|
||||
const { sort, direction, ...prevParams } = params
|
||||
setParams({ ...prevParams, ...onSort })
|
||||
}} />
|
||||
{results && (
|
||||
<Pagination
|
||||
page={results.page}
|
||||
|
|
|
@ -10,6 +10,18 @@ export const ChevronUpDownIcon = () => <svg xmlns="http://www.w3.org/2000/svg" f
|
|||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
|
||||
export const ChevronDownIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="icon">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ChevronUpIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="icon">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CheckIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="icon">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
|
|
|
@ -20,6 +20,13 @@ export function round(n: number, places?: number) {
|
|||
return Math.round(n)
|
||||
}
|
||||
|
||||
export const prune = (obj: Record<string, any>): Record<string, any> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.filter(([_, v]) => v != null && v !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
export function snakeToTitle(snake: string) {
|
||||
return (snake ?? '').split('_').map(p => p.charAt(0).toUpperCase() + p.substring(1)).join(' ')
|
||||
}
|
||||
|
|
|
@ -56,15 +56,15 @@ export default function Campaigns() {
|
|||
<SearchTable
|
||||
{...state}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
},
|
||||
{ key: 'name', sortable: true },
|
||||
{
|
||||
key: 'state',
|
||||
sortable: true,
|
||||
cell: ({ item: { state } }) => CampaignTag({ state }),
|
||||
},
|
||||
{
|
||||
key: 'channel',
|
||||
sortable: true,
|
||||
cell: ({ item: { channel } }) => ChannelTag({ channel }),
|
||||
},
|
||||
{
|
||||
|
@ -73,9 +73,10 @@ export default function Campaigns() {
|
|||
},
|
||||
{
|
||||
key: 'send_at',
|
||||
sortable: true,
|
||||
title: 'Launched At',
|
||||
},
|
||||
{ key: 'updated_at' },
|
||||
{ key: 'updated_at', sortable: true },
|
||||
{
|
||||
key: 'options',
|
||||
cell: ({ item: { id } }) => (
|
||||
|
|
|
@ -34,18 +34,20 @@ export default function ListTable({ search, selectedRow, onSelectRow, title }: L
|
|||
title={title}
|
||||
itemKey={({ item }) => item.id}
|
||||
columns={[
|
||||
{ key: 'name' },
|
||||
{ key: 'name', sortable: true },
|
||||
{
|
||||
key: 'type',
|
||||
cell: ({ item: { type } }) => snakeToTitle(type),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
cell: ({ item: { state } }) => ListTag({ state }),
|
||||
sortable: true,
|
||||
},
|
||||
{ key: 'users_count' },
|
||||
{ key: 'created_at' },
|
||||
{ key: 'updated_at' },
|
||||
{ key: 'created_at', sortable: true },
|
||||
{ key: 'updated_at', sortable: true },
|
||||
]}
|
||||
selectedRow={selectedRow}
|
||||
onSelectRow={list => handleOnSelectRow(list)}
|
||||
|
|
|
@ -2,13 +2,13 @@ import { useCallback } from 'react'
|
|||
import { useParams } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import PageContent from '../../ui/PageContent'
|
||||
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
|
||||
import { SearchTable, useSearchTableQueryState } from '../../ui/SearchTable'
|
||||
import { useRoute } from '../router'
|
||||
|
||||
export default function UserTabs() {
|
||||
const { projectId = '' } = useParams()
|
||||
const route = useRoute()
|
||||
const state = useSearchTableState(useCallback(async params => await api.users.search(projectId, params), [projectId]))
|
||||
const state = useSearchTableQueryState(useCallback(async params => await api.users.search(projectId, params), [projectId]))
|
||||
|
||||
return <PageContent title="Users">
|
||||
<SearchTable
|
||||
|
@ -19,7 +19,7 @@ export default function UserTabs() {
|
|||
{ key: 'email' },
|
||||
{ key: 'phone' },
|
||||
{ key: 'locale' },
|
||||
{ key: 'created_at' },
|
||||
{ key: 'created_at', sortable: true },
|
||||
]}
|
||||
onSelectRow={({ id }) => route(`users/${id}`)}
|
||||
enableSearch
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue