diff --git a/README.md b/README.md index 732196626..a4a8e0750 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/website/src/components/SearchPage/CustomizeModal.tsx b/website/src/components/SearchPage/CustomizeModal.tsx index cf9b3138f..fc2313fd2 100644 --- a/website/src/components/SearchPage/CustomizeModal.tsx +++ b/website/src/components/SearchPage/CustomizeModal.tsx @@ -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; @@ -29,6 +36,7 @@ interface CustomizeModalProps { visibilities: Map; setAVisibility: (fieldName: string, isVisible: boolean) => void; nameToLabelMap: Record; + thingToCustomize: string; } export const CustomizeModal: React.FC = ({ @@ -38,6 +46,7 @@ export const CustomizeModal: React.FC = ({ visibilities, setAVisibility, nameToLabelMap, + thingToCustomize, }) => { return ( @@ -51,10 +60,10 @@ export const CustomizeModal: React.FC = ({
- Customize Search Fields + Customize {titleCaseWords(thingToCustomize)}s -
Toggle the visibility of search fields
+
Toggle the visibility of {thingToCustomize}s
{alwaysPresentFieldNames.map((fieldName) => ( diff --git a/website/src/components/SearchPage/SearchForm.spec.tsx b/website/src/components/SearchPage/SearchForm.spec.tsx index a296920dd..70616429a 100644 --- a/website/src/components/SearchPage/SearchForm.spec.tsx +++ b/website/src/components/SearchPage/SearchForm.spec.tsx @@ -40,13 +40,13 @@ const defaultReferenceGenomesSequenceNames: ReferenceGenomesSequenceNames = { genes: ['gene1', 'gene2'], }; -const visibilities = new Map([ +const searchVisibilities = new Map([ ['field1', true], ['field3', true], ]); const setAFieldValue = vi.fn(); -const setAVisibility = vi.fn(); +const setASearchVisibility = vi.fn(); const renderSearchForm = ({ consolidatedMetadataSchema = [...defaultSearchFormFilters], @@ -61,8 +61,8 @@ const renderSearchForm = ({ fieldValues, setAFieldValue, lapisUrl: 'http://lapis.dummy.url', - visibilities, - setAVisibility, + searchVisibilities, + setASearchVisibility, referenceGenomesSequenceNames, lapisSearchParameters, }; diff --git a/website/src/components/SearchPage/SearchForm.tsx b/website/src/components/SearchPage/SearchForm.tsx index e00b88321..2ffdcb1a4 100644 --- a/website/src/components/SearchPage/SearchForm.tsx +++ b/website/src/components/SearchPage/SearchForm.tsx @@ -22,8 +22,8 @@ interface SearchFormProps { fieldValues: FieldValues; setAFieldValue: SetAFieldValue; lapisUrl: string; - visibilities: Map; - setAVisibility: (fieldName: string, value: boolean) => void; + searchVisibilities: Map; + setASearchVisibility: (fieldName: string, value: boolean) => void; referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; lapisSearchParameters: Record; } @@ -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(); @@ -78,11 +78,12 @@ export const SearchForm = ({
{' '}
{ acc[field.name] = field.displayName ?? field.label ?? sentenceCase(field.name); diff --git a/website/src/components/SearchPage/SearchFullUI.spec.tsx b/website/src/components/SearchPage/SearchFullUI.spec.tsx index be5d88a25..d4721e59d 100644 --- a/website/src/components/SearchPage/SearchFullUI.spec.tsx +++ b/website/src/components/SearchPage/SearchFullUI.spec.tsx @@ -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 = { @@ -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(); + }); }); diff --git a/website/src/components/SearchPage/SearchFullUI.tsx b/website/src/components/SearchPage/SearchFullUI.tsx index 694ca20f6..ec272d9ed 100644 --- a/website/src/components/SearchPage/SearchFullUI.tsx +++ b/website/src/components/SearchPage/SearchFullUI.tsx @@ -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'; @@ -23,6 +24,8 @@ const orderDirectionKey = 'order'; const VISIBILITY_PREFIX = 'visibility_'; +const COLUMN_VISIBILITY_PREFIX = 'column_'; + interface InnerSearchFullUIProps { accessToken?: string; referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; @@ -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) { @@ -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(); schema.metadata.forEach((field) => { if (field.hideOnSequenceDetailsPage === true) { @@ -115,9 +104,53 @@ export const InnerSearchFullUI = ({ return visibilities; }, [schema.metadata, state]); + const columnVisibilities = useMemo(() => { + const visibilities = new Map(); + 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 = { ...hiddenFieldValues }; @@ -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', @@ -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); @@ -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, @@ -231,6 +271,21 @@ export const InnerSearchFullUI = ({ return (
+ 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, + )} + />
@@ -299,13 +354,21 @@ export const InnerSearchFullUI = ({ ) : null} - - +
+ + + +
diff --git a/website/src/components/SearchPage/Table.tsx b/website/src/components/SearchPage/Table.tsx index cd4f3d002..2979557c1 100644 --- a/website/src/components/SearchPage/Table.tsx +++ b/website/src/components/SearchPage/Table.tsx @@ -30,6 +30,7 @@ type TableProps = { orderBy: OrderBy; setOrderByField: (field: string) => void; setOrderDirection: (direction: 'ascending' | 'descending') => void; + columnsToShow: string[]; }; export const Table: FC = ({ @@ -40,12 +41,13 @@ export const Table: FC = ({ 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],