diff --git a/website/src/components/SearchPage/Table.tsx b/website/src/components/SearchPage/Table.tsx index bc9fb84edf..72766b1fdb 100644 --- a/website/src/components/SearchPage/Table.tsx +++ b/website/src/components/SearchPage/Table.tsx @@ -1,8 +1,11 @@ import { capitalCase } from 'change-case'; -import type { FC } from 'react'; +import type { FC, ReactElement } from 'react'; import { routes } from '../../routes.ts'; -import type { Schema } from '../../types/config.ts'; +import type { Filter, Schema } from '../../types/config.ts'; +import type { OrderBy } from '../../types/lapis.ts'; +import MdiTriangle from '~icons/mdi/triangle'; +import MdiTriangleDown from '~icons/mdi/triangle-down'; export type TableSequenceData = { [key: string]: string | number | null; @@ -12,9 +15,12 @@ type TableProps = { organism: string; schema: Schema; data: TableSequenceData[]; + filters: Filter[]; + page: number; + orderBy?: OrderBy; }; -export const Table: FC = ({ organism, data, schema }) => { +export const Table: FC = ({ organism, data, schema, filters, page, orderBy }) => { const primaryKey = schema.primaryKey; const columns = schema.tableColumns.map((field) => ({ @@ -22,15 +28,38 @@ export const Table: FC = ({ organism, data, schema }) => { headerName: capitalCase(field), })); + const handleSort = (field: string) => { + if (orderBy?.field === field) { + if (orderBy.type === 'ascending') { + location.href = routes.searchPage(organism, filters, page, { field, type: 'descending' }); + } else { + location.href = routes.searchPage(organism, filters); + } + } else { + location.href = routes.searchPage(organism, filters, page, { field, type: 'ascending' }); + } + }; + + let orderIcon: ReactElement | undefined; + if (orderBy?.type === 'ascending') { + orderIcon = ; + } else if (orderBy?.type === 'descending') { + orderIcon = ; + } + return (
{data.length !== 0 ? ( - + {columns.map((c) => ( - + ))} diff --git a/website/src/pages/[organism]/search/index.astro b/website/src/pages/[organism]/search/index.astro index 542b41d7ec..7828166385 100644 --- a/website/src/pages/[organism]/search/index.astro +++ b/website/src/pages/[organism]/search/index.astro @@ -1,5 +1,5 @@ --- -import { getData, getSearchFormFilters } from './search'; +import { getData, getOrderBy, getSearchFormFilters } from './search'; import { cleanOrganism } from '../../../components/Navigation/cleanOrganism'; import { Pagination } from '../../../components/SearchPage/Pagination'; import { SearchForm } from '../../../components/SearchPage/SearchForm'; @@ -22,8 +22,9 @@ const searchFormFilter = getSearchFormFilters(getSearchParams, organism); const pageParam = Astro.url.searchParams.get('page'); const page = pageParam !== null ? Number.parseInt(pageParam, 10) : 1; const offset = (page - 1) * pageSize; +const orderBy = getOrderBy(Astro.url.searchParams); -const data = await getData(organism, searchFormFilter, offset, pageSize); +const data = await getData(organism, searchFormFilter, offset, pageSize, orderBy); --- @@ -45,7 +46,15 @@ const data = await getData(organism, searchFormFilter, offset, pageSize); Search returned {data.totalCount.toLocaleString()} sequence{data.totalCount === 1 ? '' : 's'} -
{capitalCase(primaryKey)} handleSort(primaryKey)} className='cursor-pointer'> + {capitalCase(primaryKey)} {orderBy?.field === primaryKey && orderIcon} + {c.headerName} handleSort(c.field)} className='cursor-pointer'> + {c.headerName} {orderBy?.field === c.field && orderIcon} +
+
diff --git a/website/src/pages/[organism]/search/search.ts b/website/src/pages/[organism]/search/search.ts index 4bcb2b7368..7de367ac07 100644 --- a/website/src/pages/[organism]/search/search.ts +++ b/website/src/pages/[organism]/search/search.ts @@ -6,6 +6,7 @@ import { LapisClient } from '../../../services/lapisClient.ts'; import { hiddenDefaultSearchFilters } from '../../../settings.ts'; import type { ProblemDetail } from '../../../types/backend.ts'; import type { Filter } from '../../../types/config.ts'; +import { type LapisBaseRequest, type OrderBy, type OrderByType, orderByType } from '../../../types/lapis.ts'; export type SearchResponse = { data: TableSequenceData[]; @@ -23,6 +24,7 @@ export const getData = async ( searchFormFilter: Filter[], offset: number, limit: number, + orderBy?: OrderBy, hiddenDefaultFilters: Filter[] = hiddenDefaultSearchFilters, ): Promise> => { const filters = addHiddenFilters(searchFormFilter, hiddenDefaultFilters); @@ -47,12 +49,16 @@ export const getData = async ( }); } - const detailsResult = await lapisClient.call('details', { + // @ts-expect-error Bug in Zod: https://github.com/colinhacks/zod/issues/3136 + const request: LapisBaseRequest = { fields: [...config.tableColumns, config.primaryKey], limit, offset, ...searchFilters, - }); + orderBy: orderBy !== undefined ? [orderBy] : undefined, + }; + + const detailsResult = await lapisClient.call('details', request); return Result.combine([detailsResult, aggregateResult]).map(([details, aggregate]) => { return { @@ -89,3 +95,15 @@ export const getSearchFormFilters = (getSearchParams: (param: string) => string, } }); }; + +export const getOrderBy = (searchParams: URLSearchParams): OrderBy | undefined => { + const orderByTypeParam = searchParams.get('order'); + const orderByTypeParsed = orderByTypeParam !== null ? orderByType.safeParse(orderByTypeParam) : undefined; + const orderByTypeValue: OrderByType = orderByTypeParsed?.success === true ? orderByTypeParsed.data : 'ascending'; + return searchParams.get('orderBy') !== null + ? { + field: searchParams.get('orderBy')!, + type: orderByTypeValue, + } + : undefined; +}; diff --git a/website/src/routes.ts b/website/src/routes.ts index 84d10826ef..455598816e 100644 --- a/website/src/routes.ts +++ b/website/src/routes.ts @@ -1,5 +1,6 @@ import type { AccessionVersion } from './types/backend.ts'; import type { FilterValue } from './types/config.ts'; +import type { OrderBy } from './types/lapis.ts'; import { getAccessionVersionString } from './utils/extractAccessionVersion.ts'; export const routes = { @@ -8,8 +9,12 @@ export const routes = { governancePage: () => '/governance', statusPage: () => '/status', organismStartPage: (organism: string) => `/${organism}`, - searchPage: (organism: string, searchFilter: Filter[] = [], page: number = 1) => - withOrganism(organism, `/search?${buildSearchParams(searchFilter, page).toString()}`), + searchPage: ( + organism: string, + searchFilter: Filter[] = [], + page: number = 1, + orderBy?: OrderBy, + ) => withOrganism(organism, `/search?${buildSearchParams(searchFilter, page, orderBy).toString()}`), sequencesDetailsPage: (organism: string, accessionVersion: AccessionVersion | string) => `/${organism}/seq/${getAccessionVersionString(accessionVersion)}`, sequencesVersionsPage: (organism: string, accessionVersion: AccessionVersion | string) => @@ -32,13 +37,21 @@ export const routes = { logout: () => '/logout', }; -const buildSearchParams = (searchFilter: Filter[] = [], page: number = 1) => { +const buildSearchParams = ( + searchFilter: Filter[] = [], + page: number = 1, + orderBy?: OrderBy, +) => { const params = new URLSearchParams(); searchFilter.forEach((filter) => { if (filter.filterValue !== '') { params.set(filter.name, filter.filterValue); } }); + if (orderBy !== undefined) { + params.set('orderBy', orderBy.field); + params.set('order', orderBy.type); + } params.set('page', page.toString()); return params; }; diff --git a/website/src/services/lapisClient.ts b/website/src/services/lapisClient.ts index dac1033608..1dec8d7447 100644 --- a/website/src/services/lapisClient.ts +++ b/website/src/services/lapisClient.ts @@ -8,7 +8,12 @@ import { getInstanceLogger, type InstanceLogger } from '../logger.ts'; import { ACCESSION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD } from '../settings.ts'; import { accessionVersion, type AccessionVersion, type ProblemDetail } from '../types/backend.ts'; import type { Schema } from '../types/config.ts'; -import { sequenceEntryHistory, type SequenceEntryHistory, siloVersionStatuses } from '../types/lapis.ts'; +import { + type LapisBaseRequest, + sequenceEntryHistory, + type SequenceEntryHistory, + siloVersionStatuses, +} from '../types/lapis.ts'; import type { BaseType } from '../utils/sequenceTypeHelpers.ts'; export class LapisClient extends ZodiosWrapperClient { @@ -76,11 +81,13 @@ export class LapisClient extends ZodiosWrapperClient { public async getAllSequenceEntryHistoryForAccession( accession: string, ): Promise> { - const result = await this.call('details', { + // @ts-expect-error Bug in Zod: https://github.com/colinhacks/zod/issues/3136 + const request: LapisBaseRequest = { accession, fields: [ACCESSION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD], - orderBy: [VERSION_FIELD], - }); + orderBy: [{ field: VERSION_FIELD, type: 'ascending' }], + }; + const result = await this.call('details', request); const createSequenceHistoryProblemDetail = (detail: string): ProblemDetail => ({ type: 'about:blank', diff --git a/website/src/types/lapis.ts b/website/src/types/lapis.ts index 71162311a0..a9771d30e0 100644 --- a/website/src/types/lapis.ts +++ b/website/src/types/lapis.ts @@ -2,11 +2,21 @@ import z, { type ZodTypeAny } from 'zod'; import { accessionVersion, type ProblemDetail } from './backend.ts'; +export const orderByType = z.enum(['ascending', 'descending']); +export type OrderByType = z.infer; + +export const orderBy = z.object({ + field: z.string(), + type: orderByType, +}); +export type OrderBy = z.infer; + export const lapisBaseRequest = z .object({ limit: z.number().optional(), offset: z.number().optional(), fields: z.array(z.string()).optional(), + orderBy: z.array(orderBy).optional(), }) .catchall(z.union([z.string(), z.number(), z.null(), z.array(z.string())])); export type LapisBaseRequest = z.infer; diff --git a/website/tests/pages/search/index.spec.ts b/website/tests/pages/search/index.spec.ts index 40de634110..0afd691082 100644 --- a/website/tests/pages/search/index.spec.ts +++ b/website/tests/pages/search/index.spec.ts @@ -1,5 +1,6 @@ import { DateTime } from 'luxon'; +import { ACCESSION_VERSION } from './search.page.ts'; import { routes } from '../../../src/routes.ts'; import { baseUrl, dummyOrganism, expect, test, testSequenceEntry } from '../../e2e.fixture'; @@ -53,4 +54,18 @@ test.describe('The search page', () => { await expect(searchPage.getEmptyAccessionVersionField()).toHaveValue(''); }); + + test('should sort result table', async ({ searchPage }) => { + await searchPage.goto(); + + await searchPage.clickTableHeader(ACCESSION_VERSION); + const ascendingColumn = (await searchPage.getTableContent()).map((row) => row[0]); + const isAscending = ascendingColumn.every((_, i, arr) => i === 0 || arr[i - 1] <= arr[i]); + expect(isAscending).toBeTruthy(); + + await searchPage.clickTableHeader(ACCESSION_VERSION); + const descendingColumn = (await searchPage.getTableContent()).map((row) => row[0]); + const isDescending = descendingColumn.every((_, i, arr) => i === 0 || arr[i - 1] >= arr[i]); + expect(isDescending).toBeTruthy(); + }); }); diff --git a/website/tests/pages/search/search.page.ts b/website/tests/pages/search/search.page.ts index f3f61a92a1..9085913a72 100644 --- a/website/tests/pages/search/search.page.ts +++ b/website/tests/pages/search/search.page.ts @@ -4,7 +4,7 @@ import { baseUrl, dummyOrganism } from '../../e2e.fixture'; import { routes } from '../../../src/routes.ts'; import type { FilterValue } from '../../../src/types/config.ts'; -const ACCESSION_VERSION = 'Accession version'; +export const ACCESSION_VERSION = 'Accession version'; export class SearchPage { public readonly searchButton: Locator; @@ -41,4 +41,24 @@ export class SearchPage { public async searchFor(params: FilterValue[]) { await this.page.goto(`${baseUrl}${routes.searchPage(dummyOrganism.key, params)}`); } + + public async clickTableHeader(headerLabel: string) { + await this.page.locator(`th:has-text("${headerLabel}")`).click(); + } + + public async getTableContent() { + const tableData: string[][] = []; + const rowCount = await this.page.locator('table >> css=tr').count(); + for (let i = 1; i < rowCount; i++) { + const rowCells = this.page.locator(`table >> css=tr:nth-child(${i}) >> css=td`); + const cellCount = await rowCells.count(); + const rowData: string[] = []; + for (let j = 0; j < cellCount; j++) { + const cellText = await rowCells.nth(j).textContent(); + rowData.push(cellText ?? ''); + } + tableData.push(rowData); + } + return tableData; + } }