From e25d5e48c6271257d99899ccfac5e6398bc3fc35 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Tue, 4 Apr 2023 09:46:35 -0400 Subject: [PATCH] Adds sorting to main tables --- .../src/campaigns/CampaignController.ts | 8 +- .../platform/src/campaigns/CampaignService.ts | 2 +- apps/platform/src/core/Model.ts | 9 +- apps/platform/src/core/searchParams.ts | 84 +++++++++++-------- apps/platform/src/users/UserController.ts | 8 +- apps/ui/src/types.ts | 4 +- apps/ui/src/ui/DataTable.css | 18 ++++ apps/ui/src/ui/DataTable.tsx | 57 ++++++++++++- apps/ui/src/ui/SearchTable.tsx | 29 +++++-- apps/ui/src/ui/icons.tsx | 12 +++ apps/ui/src/utils.ts | 7 ++ apps/ui/src/views/campaign/Campaigns.tsx | 9 +- apps/ui/src/views/users/ListTable.tsx | 8 +- apps/ui/src/views/users/Users.tsx | 6 +- 14 files changed, 191 insertions(+), 70 deletions(-) diff --git a/apps/platform/src/campaigns/CampaignController.ts b/apps/platform/src/campaigns/CampaignController.ts index 37c1fa9c..29322f34 100644 --- a/apps/platform/src/campaigns/CampaignController.ts +++ b/apps/platform/src/campaigns/CampaignController.ts @@ -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({ 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) }) diff --git a/apps/platform/src/campaigns/CampaignService.ts b/apps/platform/src/campaigns/CampaignService.ts index 3c4ca0c4..8e14e265 100644 --- a/apps/platform/src/campaigns/CampaignService.ts +++ b/apps/platform/src/campaigns/CampaignService.ts @@ -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 }, diff --git a/apps/platform/src/core/Model.ts b/apps/platform/src/core/Model.ts index cddea05e..b6d123c2 100644 --- a/apps/platform/src/core/Model.ts +++ b/apps/platform/src/core/Model.ts @@ -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) diff --git a/apps/platform/src/core/searchParams.ts b/apps/platform/src/core/searchParams.ts index 6d6de479..a4d3a0ce 100644 --- a/apps/platform/src/core/searchParams.ts +++ b/apps/platform/src/core/searchParams.ts @@ -5,47 +5,59 @@ export interface SearchParams { itemsPerPage: number q?: string sort?: string + direction?: string tag?: string[] id?: number[] } -export const searchParamsSchema: JSONSchemaType = { - $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): JSONSchemaType => { + 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 = SearchSchema('searchParams') diff --git a/apps/platform/src/users/UserController.ts b/apps/platform/src/users/UserController.ts index 1ea82125..e9ad0d84 100644 --- a/apps/platform/src/users/UserController.ts +++ b/apps/platform/src/users/UserController.ts @@ -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) }) diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index e32e8d1a..3fc7d16c 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -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 } diff --git a/apps/ui/src/ui/DataTable.css b/apps/ui/src/ui/DataTable.css index b1ca1434..b58d47b4 100644 --- a/apps/ui/src/ui/DataTable.css +++ b/apps/ui/src/ui/DataTable.css @@ -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 { diff --git a/apps/ui/src/ui/DataTable.tsx b/apps/ui/src/ui/DataTable.tsx index c970f4e9..02e69f0f 100644 --- a/apps/ui/src/ui/DataTable.tsx +++ b/apps/ui/src/ui/DataTable.tsx @@ -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 = (args: { @@ -12,6 +14,49 @@ export interface DataTableCol { key: string title?: ReactNode cell?: DataTableResolver + sortable?: boolean +} + +export interface ColSort { + sort: string + direction: string +} + +interface HeaderCellProps { + col: DataTableCol + columnSort?: ColSort + onColumnSort?: (sort?: ColSort) => void +} + +export function HeaderCell({ col, columnSort, onColumnSort }: HeaderCellProps) { + 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
+
+ {title ?? snakeToTitle(key)} + {sortable && ( +
+
} export interface DataTableProps { @@ -22,6 +67,8 @@ export interface DataTableProps { emptyMessage?: ReactNode selectedRow?: Key onSelectRow?: (row: T) => void + columnSort?: ColSort + onColumnSort?: (sort?: ColSort) => void isLoading?: boolean } @@ -32,6 +79,8 @@ export function DataTable({ itemKey, selectedRow, onSelectRow, + columnSort, + onColumnSort, isLoading = false, }: DataTableProps) { const [preferences] = useContext(PreferencesContext) @@ -40,9 +89,11 @@ export function DataTable({
{ columns.map(col => ( -
- {col.title ?? snakeToTitle(col.key)} -
+ + key={col.key} + col={col} + onColumnSort={onColumnSort} + columnSort={columnSort} /> )) }
diff --git a/apps/ui/src/ui/SearchTable.tsx b/apps/ui/src/ui/SearchTable.tsx index 0290f8b3..a2727d35 100644 --- a/apps/ui/src/ui/SearchTable.tsx +++ b/apps/ui/src/ui/SearchTable.tsx @@ -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> extends Omit { 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 => { + 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>({ }: SearchTableProps) { 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>({ ) } - + { + const { sort, direction, ...prevParams } = params + setParams({ ...prevParams, ...onSort }) + }} /> {results && ( +export const ChevronDownIcon = () => ( + + + +) + +export const ChevronUpIcon = () => ( + + + +) + export const CheckIcon = () => diff --git a/apps/ui/src/utils.ts b/apps/ui/src/utils.ts index cfa813b6..f67dae76 100644 --- a/apps/ui/src/utils.ts +++ b/apps/ui/src/utils.ts @@ -20,6 +20,13 @@ export function round(n: number, places?: number) { return Math.round(n) } +export const prune = (obj: Record): Record => { + 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(' ') } diff --git a/apps/ui/src/views/campaign/Campaigns.tsx b/apps/ui/src/views/campaign/Campaigns.tsx index f0eeec95..88846807 100644 --- a/apps/ui/src/views/campaign/Campaigns.tsx +++ b/apps/ui/src/views/campaign/Campaigns.tsx @@ -56,15 +56,15 @@ export default function Campaigns() { 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 } }) => ( diff --git a/apps/ui/src/views/users/ListTable.tsx b/apps/ui/src/views/users/ListTable.tsx index ae115300..203cdce5 100644 --- a/apps/ui/src/views/users/ListTable.tsx +++ b/apps/ui/src/views/users/ListTable.tsx @@ -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)} diff --git a/apps/ui/src/views/users/Users.tsx b/apps/ui/src/views/users/Users.tsx index f900be6c..2f5224a5 100644 --- a/apps/ui/src/views/users/Users.tsx +++ b/apps/ui/src/views/users/Users.tsx @@ -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 route(`users/${id}`)} enableSearch