Skip to content

Commit

Permalink
feat(website): add sorting to the search result table (#758)
Browse files Browse the repository at this point in the history
  • Loading branch information
chaoran-chen authored Jan 22, 2024
1 parent 8d77d63 commit 25bfadf
Show file tree
Hide file tree
Showing 8 changed files with 139 additions and 18 deletions.
39 changes: 34 additions & 5 deletions website/src/components/SearchPage/Table.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,25 +15,51 @@ type TableProps = {
organism: string;
schema: Schema;
data: TableSequenceData[];
filters: Filter[];
page: number;
orderBy?: OrderBy;
};

export const Table: FC<TableProps> = ({ organism, data, schema }) => {
export const Table: FC<TableProps> = ({ organism, data, schema, filters, page, orderBy }) => {
const primaryKey = schema.primaryKey;

const columns = schema.tableColumns.map((field) => ({
field,
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 = <MdiTriangle className='w-3 h-3 ml-1 inline' />;
} else if (orderBy?.type === 'descending') {
orderIcon = <MdiTriangleDown className='w-3 h-3 ml-1 inline' />;
}

return (
<div className='w-full overflow-x-auto'>
{data.length !== 0 ? (
<table className='table'>
<thead>
<tr>
<th>{capitalCase(primaryKey)}</th>
<th onClick={() => handleSort(primaryKey)} className='cursor-pointer'>
{capitalCase(primaryKey)} {orderBy?.field === primaryKey && orderIcon}
</th>
{columns.map((c) => (
<th key={c.field}>{c.headerName}</th>
<th key={c.field} onClick={() => handleSort(c.field)} className='cursor-pointer'>
{c.headerName} {orderBy?.field === c.field && orderIcon}
</th>
))}
</tr>
</thead>
Expand Down
15 changes: 12 additions & 3 deletions website/src/pages/[organism]/search/index.astro
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
---

<BaseLayout title={`${cleanedOrganism!.displayName} - Browse`}>
Expand All @@ -45,7 +46,15 @@ const data = await getData(organism, searchFormFilter, offset, pageSize);
Search returned {data.totalCount.toLocaleString()}
sequence{data.totalCount === 1 ? '' : 's'}
</div>
<Table organism={organism} data={data.data} schema={schema} client:load />
<Table
organism={organism}
data={data.data}
schema={schema}
filters={searchFormFilter}
page={page}
orderBy={orderBy}
client:load
/>

<div class='mt-4 flex justify-center'>
<Pagination client:only='react' count={Math.ceil(data.totalCount / pageSize)} />
Expand Down
22 changes: 20 additions & 2 deletions website/src/pages/[organism]/search/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -23,6 +24,7 @@ export const getData = async (
searchFormFilter: Filter[],
offset: number,
limit: number,
orderBy?: OrderBy,
hiddenDefaultFilters: Filter[] = hiddenDefaultSearchFilters,
): Promise<Result<SearchResponse, ProblemDetail>> => {
const filters = addHiddenFilters(searchFormFilter, hiddenDefaultFilters);
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
};
19 changes: 16 additions & 3 deletions website/src/routes.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -8,8 +9,12 @@ export const routes = {
governancePage: () => '/governance',
statusPage: () => '/status',
organismStartPage: (organism: string) => `/${organism}`,
searchPage: <Filter extends FilterValue>(organism: string, searchFilter: Filter[] = [], page: number = 1) =>
withOrganism(organism, `/search?${buildSearchParams(searchFilter, page).toString()}`),
searchPage: <Filter extends FilterValue>(
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) =>
Expand All @@ -32,13 +37,21 @@ export const routes = {
logout: () => '/logout',
};

const buildSearchParams = <Filter extends FilterValue>(searchFilter: Filter[] = [], page: number = 1) => {
const buildSearchParams = <Filter extends FilterValue>(
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;
};
Expand Down
15 changes: 11 additions & 4 deletions website/src/services/lapisClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof lapisApi> {
Expand Down Expand Up @@ -76,11 +81,13 @@ export class LapisClient extends ZodiosWrapperClient<typeof lapisApi> {
public async getAllSequenceEntryHistoryForAccession(
accession: string,
): Promise<Result<SequenceEntryHistory, ProblemDetail>> {
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',
Expand Down
10 changes: 10 additions & 0 deletions website/src/types/lapis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof orderByType>;

export const orderBy = z.object({
field: z.string(),
type: orderByType,
});
export type OrderBy = z.infer<typeof orderBy>;

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<typeof lapisBaseRequest>;
Expand Down
15 changes: 15 additions & 0 deletions website/tests/pages/search/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
});
});
22 changes: 21 additions & 1 deletion website/tests/pages/search/search.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}

0 comments on commit 25bfadf

Please sign in to comment.