Merge pull request #111 from parcelvoy/feat/adds-sorting-to-tables

Adds sorting to tables
This commit is contained in:
Chris Hills 2023-04-04 21:28:06 -05:00 committed by GitHub
commit a035276986
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 191 additions and 70 deletions

View file

@ -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)
})

View file

@ -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
},

View file

@ -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)

View file

@ -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')

View file

@ -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)
})

View file

@ -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>
}

View file

@ -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 {

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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(' ')
}

View file

@ -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 } }) => (

View file

@ -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)}

View file

@ -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