Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(website): allow customizing columns #1993

Merged
merged 10 commits into from
Jun 19, 2024
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
Loading