Skip to content

Commit

Permalink
feat(website): allow customizing columns (#1993)
Browse files Browse the repository at this point in the history
* hide also in search

* customize columns

* try to fix merge that didn't quite work

* fixup

* add basic test of column visibility

* Automated code formatting

* update readme (unconnected)

---------

Co-authored-by: Cornelius Roemer <cornelius.roemer@gmail.com>
Co-authored-by: Loculus bot <bot@loculus.org>
  • Loading branch information
3 people committed Jun 19, 2024
1 parent 461f3e3 commit 85d71e8
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 44 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Loculus is a software package to power microbial genomial databases.

Additional documentation for development is available in each folder's README. This file contains a high-level overview of the project and shared development information that is best kept in one place.

If you would like to develop with a full local loculus instance you need to first:
If you would like to develop with a full local loculus instance for development you need to:

1. Deploy a local kubernetes instance: [kubernetes](/kubernetes/README.md)
2. Deploy the backend: [backend](/backend/README.md)
Expand Down
13 changes: 11 additions & 2 deletions website/src/components/SearchPage/CustomizeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Dialog, Transition } from '@headlessui/react';

const titleCaseWords = (str: string) => {
return str
.split(' ')
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(' ');
};

interface CheckboxFieldProps {
label: string;
checked: boolean;
Expand Down Expand Up @@ -29,6 +36,7 @@ interface CustomizeModalProps {
visibilities: Map<string, boolean>;
setAVisibility: (fieldName: string, isVisible: boolean) => void;
nameToLabelMap: Record<string, string>;
thingToCustomize: string;
}

export const CustomizeModal: React.FC<CustomizeModalProps> = ({
Expand All @@ -38,6 +46,7 @@ export const CustomizeModal: React.FC<CustomizeModalProps> = ({
visibilities,
setAVisibility,
nameToLabelMap,
thingToCustomize,
}) => {
return (
<Transition appear show={isCustomizeModalOpen}>
Expand All @@ -51,10 +60,10 @@ export const CustomizeModal: React.FC<CustomizeModalProps> = ({

<div className='inline-block w-full max-w-md p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl text-sm'>
<Dialog.Title as='h3' className='text-lg font-medium leading-6 text-gray-900'>
Customize Search Fields
Customize {titleCaseWords(thingToCustomize)}s
</Dialog.Title>

<div className='mt-4 text-gray-700 text-sm'>Toggle the visibility of search fields</div>
<div className='mt-4 text-gray-700 text-sm'>Toggle the visibility of {thingToCustomize}s</div>

<div className='mt-4'>
{alwaysPresentFieldNames.map((fieldName) => (
Expand Down
8 changes: 4 additions & 4 deletions website/src/components/SearchPage/SearchForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ const defaultReferenceGenomesSequenceNames: ReferenceGenomesSequenceNames = {
genes: ['gene1', 'gene2'],
};

const visibilities = new Map<string, boolean>([
const searchVisibilities = new Map<string, boolean>([
['field1', true],
['field3', true],
]);

const setAFieldValue = vi.fn();
const setAVisibility = vi.fn();
const setASearchVisibility = vi.fn();

const renderSearchForm = ({
consolidatedMetadataSchema = [...defaultSearchFormFilters],
Expand All @@ -61,8 +61,8 @@ const renderSearchForm = ({
fieldValues,
setAFieldValue,
lapisUrl: 'http://lapis.dummy.url',
visibilities,
setAVisibility,
searchVisibilities,
setASearchVisibility,
referenceGenomesSequenceNames,
lapisSearchParameters,
};
Expand Down
15 changes: 8 additions & 7 deletions website/src/components/SearchPage/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ interface SearchFormProps {
fieldValues: FieldValues;
setAFieldValue: SetAFieldValue;
lapisUrl: string;
visibilities: Map<string, boolean>;
setAVisibility: (fieldName: string, value: boolean) => void;
searchVisibilities: Map<string, boolean>;
setASearchVisibility: (fieldName: string, value: boolean) => void;
referenceGenomesSequenceNames: ReferenceGenomesSequenceNames;
lapisSearchParameters: Record<string, any>;
}
Expand All @@ -33,12 +33,12 @@ export const SearchForm = ({
fieldValues,
setAFieldValue,
lapisUrl,
visibilities,
setAVisibility,
searchVisibilities,
setASearchVisibility,
referenceGenomesSequenceNames,
lapisSearchParameters,
}: SearchFormProps) => {
const visibleFields = consolidatedMetadataSchema.filter((field) => visibilities.get(field.name));
const visibleFields = consolidatedMetadataSchema.filter((field) => searchVisibilities.get(field.name));

const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false);
const { isOpen: isMobileOpen, close: closeOnMobile, toggle: toggleMobileOpen } = useOffCanvas();
Expand Down Expand Up @@ -78,11 +78,12 @@ export const SearchForm = ({
</div>{' '}
</div>
<CustomizeModal
thingToCustomize='search field'
isCustomizeModalOpen={isCustomizeModalOpen}
toggleCustomizeModal={toggleCustomizeModal}
alwaysPresentFieldNames={[]}
visibilities={visibilities}
setAVisibility={setAVisibility}
visibilities={searchVisibilities}
setAVisibility={setASearchVisibility}
nameToLabelMap={consolidatedMetadataSchema.reduce(
(acc, field) => {
acc[field.name] = field.displayName ?? field.label ?? sentenceCase(field.name);
Expand Down
24 changes: 24 additions & 0 deletions website/src/components/SearchPage/SearchFullUI.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ const defaultSearchFormFilters: MetadataFilter[] = [
autocomplete: true,
initiallyVisible: true,
},
{
name: 'field4',
type: 'string',
autocomplete: false,
label: 'Field 4',
displayName: 'Field 4',
notSearchable: true,
},
];

const defaultReferenceGenomesSequenceNames: ReferenceGenomesSequenceNames = {
Expand Down Expand Up @@ -230,4 +238,20 @@ describe('SearchFullUI', () => {
expect(window.history.state.path).toContain('?field1=abc');
});
});

it('toggle column visibility', async () => {
renderSearchFullUI({});
// expect we can't see field 4
expect(screen.queryByRole('columnheader', { name: 'Field 4' })).not.toBeInTheDocument();
const customizeButton = await screen.findByRole('button', { name: 'Customize columns' });
await userEvent.click(customizeButton);
const field4Checkbox = await screen.findByRole('checkbox', { name: 'Field 4' });
expect(field4Checkbox).not.toBeChecked();
await userEvent.click(field4Checkbox);
expect(field4Checkbox).toBeChecked();
const closeButton = await screen.findByRole('button', { name: 'Close' });
await userEvent.click(closeButton);
screen.logTestingPlaygroundURL();
expect(screen.getByRole('columnheader', { name: 'Field 4' })).toBeVisible();
});
});
122 changes: 93 additions & 29 deletions website/src/components/SearchPage/SearchFullUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { sentenceCase } from 'change-case';
import { useEffect, useMemo, useState } from 'react';

import { CustomizeModal } from './CustomizeModal.tsx';
import { DownloadDialog } from './DownloadDialog/DownloadDialog.tsx';
import { RecentSequencesBanner } from './RecentSequencesBanner.tsx';
import { SearchForm } from './SearchForm';
Expand All @@ -23,6 +24,8 @@ const orderDirectionKey = 'order';

const VISIBILITY_PREFIX = 'visibility_';

const COLUMN_VISIBILITY_PREFIX = 'column_';

interface InnerSearchFullUIProps {
accessToken?: string;
referenceGenomesSequenceNames: ReferenceGenomesSequenceNames;
Expand Down Expand Up @@ -50,6 +53,8 @@ export const InnerSearchFullUI = ({
}
const metadataSchema = schema.metadata;

const [isColumnModalOpen, setIsColumnModalOpen] = useState(false);

const metadataSchemaWithExpandedRanges = useMemo(() => {
const result = [];
for (const field of metadataSchema) {
Expand Down Expand Up @@ -82,23 +87,7 @@ export const InnerSearchFullUI = ({
const [state, setState] = useQueryAsState({});
const [page, setPage] = useState(1);

const orderByField = state.orderBy ?? schema.defaultOrderBy ?? schema.primaryKey;
const orderDirection = state.order ?? schema.defaultOrder ?? 'ascending';

const setOrderByField = (field: string) => {
setState((prev: QueryState) => ({
...prev,
orderBy: field,
}));
};
const setOrderDirection = (direction: string) => {
setState((prev: QueryState) => ({
...prev,
order: direction,
}));
};

const visibilities = useMemo(() => {
const searchVisibilities = useMemo(() => {
const visibilities = new Map<string, boolean>();
schema.metadata.forEach((field) => {
if (field.hideOnSequenceDetailsPage === true) {
Expand All @@ -115,9 +104,53 @@ export const InnerSearchFullUI = ({
return visibilities;
}, [schema.metadata, state]);

const columnVisibilities = useMemo(() => {
const visibilities = new Map<string, boolean>();
schema.metadata.forEach((field) => {
if (field.hideOnSequenceDetailsPage === true) {
return;
}
visibilities.set(field.name, schema.tableColumns.includes(field.name));
});

const visibilityKeys = Object.keys(state).filter((key) => key.startsWith(COLUMN_VISIBILITY_PREFIX));

for (const key of visibilityKeys) {
visibilities.set(key.slice(COLUMN_VISIBILITY_PREFIX.length), state[key] === 'true');
}

return visibilities;
}, [schema.metadata, schema.tableColumns, state]);

const columnsToShow = useMemo(() => {
return schema.metadata
.filter((field) => columnVisibilities.get(field.name) === true)
.map((field) => field.name);
}, [schema.metadata, columnVisibilities]);

let orderByField = state.orderBy ?? schema.defaultOrderBy ?? schema.primaryKey;
if (!columnsToShow.includes(orderByField)) {
orderByField = schema.primaryKey;
}

const orderDirection = state.order ?? schema.defaultOrder ?? 'ascending';

const setOrderByField = (field: string) => {
setState((prev: QueryState) => ({
...prev,
orderBy: field,
}));
};
const setOrderDirection = (direction: string) => {
setState((prev: QueryState) => ({
...prev,
order: direction,
}));
};

const fieldValues = useMemo(() => {
const fieldKeys = Object.keys(state)
.filter((key) => !key.startsWith(VISIBILITY_PREFIX))
.filter((key) => !key.startsWith(VISIBILITY_PREFIX) && !key.startsWith(COLUMN_VISIBILITY_PREFIX))
.filter((key) => key !== orderKey && key !== orderDirectionKey);

const values: Record<string, any> = { ...hiddenFieldValues };
Expand All @@ -141,7 +174,7 @@ export const InnerSearchFullUI = ({
setPage(1);
};

const setAVisibility = (fieldName: string, visible: boolean) => {
const setASearchVisibility = (fieldName: string, visible: boolean) => {
setState((prev: any) => ({
...prev,
[`${VISIBILITY_PREFIX}${fieldName}`]: visible ? 'true' : 'false',
Expand All @@ -152,6 +185,13 @@ export const InnerSearchFullUI = ({
}
};

const setAColumnVisibility = (fieldName: string, visible: boolean) => {
setState((prev: any) => ({
...prev,
[`${COLUMN_VISIBILITY_PREFIX}${fieldName}`]: visible ? 'true' : 'false',
}));
};

const lapisUrl = getLapisUrl(clientConfig, organism);

const consolidatedMetadataSchema = consolidateGroupedFields(metadataSchemaWithExpandedRanges);
Expand Down Expand Up @@ -204,7 +244,7 @@ export const InnerSearchFullUI = ({
// @ts-expect-error because the hooks don't accept OrderBy
detailsHook.mutate({
...lapisSearchParameters,
fields: [...schema.tableColumns, schema.primaryKey],
fields: [...columnsToShow, schema.primaryKey],
limit: pageSize,
offset: (page - 1) * pageSize,
orderBy: OrderByList,
Expand All @@ -231,6 +271,21 @@ export const InnerSearchFullUI = ({

return (
<div className='flex flex-col md:flex-row gap-8 md:gap-4'>
<CustomizeModal
thingToCustomize='column'
isCustomizeModalOpen={isColumnModalOpen}
toggleCustomizeModal={() => setIsColumnModalOpen(!isColumnModalOpen)}
alwaysPresentFieldNames={[]}
visibilities={columnVisibilities}
setAVisibility={setAColumnVisibility}
nameToLabelMap={consolidatedMetadataSchema.reduce(
(acc, field) => {
acc[field.name] = field.displayName ?? field.label ?? sentenceCase(field.name);
return acc;
},
{} as Record<string, string>,
)}
/>
<SeqPreviewModal
seqId={previewedSeqId ?? ''}
accessToken={accessToken}
Expand All @@ -251,8 +306,8 @@ export const InnerSearchFullUI = ({
setAFieldValue={setAFieldValue}
consolidatedMetadataSchema={consolidatedMetadataSchema}
lapisUrl={lapisUrl}
visibilities={visibilities}
setAVisibility={setAVisibility}
searchVisibilities={searchVisibilities}
setASearchVisibility={setASearchVisibility}
lapisSearchParameters={lapisSearchParameters}
/>
</div>
Expand Down Expand Up @@ -299,13 +354,21 @@ export const InnerSearchFullUI = ({
<span className='loading loading-spinner loading-xs ml-3 appearSlowly'></span>
) : null}
</div>

<DownloadDialog
lapisUrl={lapisUrl}
lapisSearchParameters={lapisSearchParameters}
referenceGenomesSequenceNames={referenceGenomesSequenceNames}
hiddenFieldValues={hiddenFieldValues}
/>
<div className='flex'>
<button
className='text-gray-800 hover:text-gray-600 mr-4 underline text-primary-700 hover:text-primary-500'
onClick={() => setIsColumnModalOpen(true)}
>
Customize columns
</button>

<DownloadDialog
lapisUrl={lapisUrl}
lapisSearchParameters={lapisSearchParameters}
referenceGenomesSequenceNames={referenceGenomesSequenceNames}
hiddenFieldValues={hiddenFieldValues}
/>
</div>
</div>

<Table
Expand All @@ -325,6 +388,7 @@ export const InnerSearchFullUI = ({
}
setOrderByField={setOrderByField}
setOrderDirection={setOrderDirection}
columnsToShow={columnsToShow}
/>

<div className='mt-4 flex justify-center'>
Expand Down
4 changes: 3 additions & 1 deletion website/src/components/SearchPage/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type TableProps = {
orderBy: OrderBy;
setOrderByField: (field: string) => void;
setOrderDirection: (direction: 'ascending' | 'descending') => void;
columnsToShow: string[];
};

export const Table: FC<TableProps> = ({
Expand All @@ -40,12 +41,13 @@ export const Table: FC<TableProps> = ({
orderBy,
setOrderByField,
setOrderDirection,
columnsToShow,
}) => {
const primaryKey = schema.primaryKey;

const maxLengths = Object.fromEntries(schema.metadata.map((m) => [m.name, m.truncateColumnDisplayTo ?? 100]));

const columns = schema.tableColumns.map((field) => ({
const columns = columnsToShow.map((field) => ({
field,
headerName: schema.metadata.find((m) => m.name === field)?.displayName ?? capitalCase(field),
maxLength: maxLengths[field],
Expand Down

0 comments on commit 85d71e8

Please sign in to comment.