From 0069ec082e067b5380523f21a0379e170a707f50 Mon Sep 17 00:00:00 2001 From: Theo Sanderson Date: Fri, 12 Jul 2024 14:41:00 +0200 Subject: [PATCH] refactor(website): refactor search by extracting re-usable functions (#2288) --- .../components/SearchPage/SearchFullUI.tsx | 106 ++------ .../SearchPage/fields/MutationField.tsx | 136 +--------- .../src/pages/[organism]/search/index.astro | 9 +- .../submission/[groupId]/released.astro | 10 +- website/src/utils/search.ts | 242 +++++++++++++++++- 5 files changed, 273 insertions(+), 230 deletions(-) diff --git a/website/src/components/SearchPage/SearchFullUI.tsx b/website/src/components/SearchPage/SearchFullUI.tsx index 7fe012739..1650bd1e3 100644 --- a/website/src/components/SearchPage/SearchFullUI.tsx +++ b/website/src/components/SearchPage/SearchFullUI.tsx @@ -9,7 +9,6 @@ import { SearchForm } from './SearchForm'; import { SearchPagination } from './SearchPagination'; import { SeqPreviewModal } from './SeqPreviewModal'; import { Table, type TableSequenceData } from './Table'; -import { parseMutationString } from './fields/MutationField.tsx'; import useQueryAsState from './useQueryAsState.js'; import { getLapisUrl } from '../../config.ts'; import { lapisClientHooks } from '../../services/serviceHooks.ts'; @@ -25,12 +24,15 @@ import { import { type OrderBy } from '../../types/lapis.ts'; import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts'; import type { ClientConfig } from '../../types/runtimeConfig.ts'; -const orderKey = 'orderBy'; -const orderDirectionKey = 'order'; - -const VISIBILITY_PREFIX = 'visibility_'; - -const COLUMN_VISIBILITY_PREFIX = 'column_'; +import { + getFieldValuesFromQuery, + getColumnVisibilitiesFromQuery, + getFieldVisibilitiesFromQuery, + VISIBILITY_PREFIX, + COLUMN_VISIBILITY_PREFIX, + getLapisSearchParameters, +} from '../../utils/search.ts'; +import ErrorBox from '../common/ErrorBox.tsx'; interface InnerSearchFullUIProps { accessToken?: string; @@ -94,39 +96,12 @@ export const InnerSearchFullUI = ({ const [page, setPage] = useState(1); const searchVisibilities = useMemo(() => { - const visibilities = new Map(); - schema.metadata.forEach((field) => { - if (field.hideOnSequenceDetailsPage === true) { - return; - } - visibilities.set(field.name, field.initiallyVisible === true); - }); - - const visibilityKeys = Object.keys(state).filter((key) => key.startsWith(VISIBILITY_PREFIX)); - - for (const key of visibilityKeys) { - visibilities.set(key.slice(VISIBILITY_PREFIX.length), state[key] === 'true'); - } - return visibilities; - }, [schema.metadata, state]); + return getFieldVisibilitiesFromQuery(schema, state); + }, [schema, 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]); + return getColumnVisibilitiesFromQuery(schema, state); + }, [schema, state]); const columnsToShow = useMemo(() => { return schema.metadata @@ -155,15 +130,7 @@ export const InnerSearchFullUI = ({ }; const fieldValues = useMemo(() => { - const fieldKeys = Object.keys(state) - .filter((key) => !key.startsWith(VISIBILITY_PREFIX) && !key.startsWith(COLUMN_VISIBILITY_PREFIX)) - .filter((key) => key !== orderKey && key !== orderDirectionKey); - - const values: Record = { ...hiddenFieldValues }; - for (const key of fieldKeys) { - values[key] = state[key]; - } - return values; + return getFieldValuesFromQuery(state, hiddenFieldValues); }, [state, hiddenFieldValues]); const setAFieldValue: SetAFieldValue = (fieldName, value) => { @@ -207,33 +174,7 @@ export const InnerSearchFullUI = ({ const detailsHook = hooks.useDetails({}, {}); const lapisSearchParameters = useMemo(() => { - const sequenceFilters = Object.fromEntries( - Object.entries(fieldValues).filter(([, value]) => value !== undefined && value !== ''), - ); - - if (sequenceFilters.accession !== '' && sequenceFilters.accession !== undefined) { - sequenceFilters.accession = textAccessionsToList(sequenceFilters.accession); - } - - delete sequenceFilters.mutation; - - const mutationFilter = parseMutationString(fieldValues.mutation ?? '', referenceGenomesSequenceNames); - - return { - ...sequenceFilters, - nucleotideMutations: mutationFilter - .filter((m) => m.baseType === 'nucleotide' && m.mutationType === 'substitutionOrDeletion') - .map((m) => m.text), - aminoAcidMutations: mutationFilter - .filter((m) => m.baseType === 'aminoAcid' && m.mutationType === 'substitutionOrDeletion') - .map((m) => m.text), - nucleotideInsertions: mutationFilter - .filter((m) => m.baseType === 'nucleotide' && m.mutationType === 'insertion') - .map((m) => m.text), - aminoAcidInsertions: mutationFilter - .filter((m) => m.baseType === 'aminoAcid' && m.mutationType === 'insertion') - .map((m) => m.text), - }; + return getLapisSearchParameters(fieldValues, referenceGenomesSequenceNames); }, [fieldValues, referenceGenomesSequenceNames]); useEffect(() => { @@ -338,7 +279,7 @@ export const InnerSearchFullUI = ({ ))} {(detailsHook.isPaused || aggregatedHook.isPaused) && (!detailsHook.isSuccess || !aggregatedHook.isSuccess) && ( -
Connection problem
+ Please check your internet connection )} {!(totalSequences === undefined && oldCount === null) && (
{ ); }; - -const textAccessionsToList = (text: string): string[] => { - const accessions = text - .split(/[\t,;\n ]/) - .map((s) => s.trim()) - .filter((s) => s !== '') - .map((s) => { - if (s.includes('.')) { - return s.split('.')[0]; - } - return s; - }); - - return accessions; -}; diff --git a/website/src/components/SearchPage/fields/MutationField.tsx b/website/src/components/SearchPage/fields/MutationField.tsx index 11318d990..f2e29cd57 100644 --- a/website/src/components/SearchPage/fields/MutationField.tsx +++ b/website/src/components/SearchPage/fields/MutationField.tsx @@ -3,7 +3,14 @@ import { type FC, Fragment, useMemo, useState } from 'react'; import * as React from 'react'; import type { ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes.ts'; -import type { BaseType } from '../../../utils/sequenceTypeHelpers.ts'; +import { + parseMutationString, + isValidAminoAcidMutationQuery, + isValidAminoAcidInsertionQuery, + isValidNucleotideInsertionQuery, + isValidNucleotideMutationQuery, + type MutationQuery, +} from '../../../utils/search.ts'; import DisplaySearchDocs from '../DisplaySearchDocs'; interface MutationFieldProps { @@ -12,131 +19,6 @@ interface MutationFieldProps { onChange: (mutationFilter: string) => void; } -type MutationQuery = { - baseType: BaseType; - mutationType: 'substitutionOrDeletion' | 'insertion'; - text: string; -}; - -const isValidNucleotideMutationQuery = ( - text: string, - referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, -): boolean => { - try { - const isMultiSegmented = referenceGenomesSequenceNames.nucleotideSequences.length > 1; - const textUpper = text.toUpperCase(); - let mutation = textUpper; - if (isMultiSegmented) { - const [segment, _mutation] = textUpper.split(':'); - const existingSegments = new Set( - referenceGenomesSequenceNames.nucleotideSequences.map((n) => n.toUpperCase()), - ); - if (!existingSegments.has(segment)) { - return false; - } - mutation = _mutation; - } - return /^[A-Z]?[0-9]+[A-Z-\\.]?$/.test(mutation); - } catch (_) { - return false; - } -}; - -const isValidAminoAcidMutationQuery = ( - text: string, - referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, -): boolean => { - try { - const textUpper = text.toUpperCase(); - const [gene, mutation] = textUpper.split(':'); - const existingGenes = new Set(referenceGenomesSequenceNames.genes.map((g) => g.toUpperCase())); - if (!existingGenes.has(gene)) { - return false; - } - return /^[A-Z*]?[0-9]+[A-Z-*\\.]?$/.test(mutation); - } catch (_) { - return false; - } -}; - -const isValidNucleotideInsertionQuery = ( - text: string, - referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, -): boolean => { - try { - const isMultiSegmented = referenceGenomesSequenceNames.nucleotideSequences.length > 1; - const textUpper = text.toUpperCase(); - if (!textUpper.startsWith('INS_')) { - return false; - } - const query = textUpper.slice(4); - const split = query.split(':'); - const [segment, position, insertion] = isMultiSegmented - ? split - : ([undefined, ...split] as [undefined | string, string, string]); - if (segment !== undefined) { - const existingSegments = new Set( - referenceGenomesSequenceNames.nucleotideSequences.map((n) => n.toUpperCase()), - ); - if (!existingSegments.has(segment)) { - return false; - } - } - if (!Number.isInteger(Number(position))) { - return false; - } - return /^[A-Z*?]+$/.test(insertion); - } catch (_) { - return false; - } -}; - -const isValidAminoAcidInsertionQuery = ( - text: string, - referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, -): boolean => { - try { - const textUpper = text.toUpperCase(); - if (!textUpper.startsWith('INS_')) { - return false; - } - const query = textUpper.slice(4); - const [gene, position, insertion] = query.split(':'); - const existingGenes = new Set(referenceGenomesSequenceNames.genes.map((g) => g.toUpperCase())); - if (!existingGenes.has(gene) || !Number.isInteger(Number(position))) { - return false; - } - return /^[A-Z*?]+$/.test(insertion); - } catch (_) { - return false; - } -}; - -export const parseMutationString = ( - value: string, - referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, -): MutationQuery[] => { - return value - .split(',') - .map((mutation) => { - const trimmedMutation = mutation.trim(); - if (isValidNucleotideMutationQuery(trimmedMutation, referenceGenomesSequenceNames)) { - return { baseType: 'nucleotide', mutationType: 'substitutionOrDeletion', text: trimmedMutation }; - } - if (isValidAminoAcidMutationQuery(trimmedMutation, referenceGenomesSequenceNames)) { - return { baseType: 'aminoAcid', mutationType: 'substitutionOrDeletion', text: trimmedMutation }; - } - if (isValidNucleotideInsertionQuery(trimmedMutation, referenceGenomesSequenceNames)) { - return { baseType: 'nucleotide', mutationType: 'insertion', text: trimmedMutation }; - } - if (isValidAminoAcidInsertionQuery(trimmedMutation, referenceGenomesSequenceNames)) { - return { baseType: 'aminoAcid', mutationType: 'insertion', text: trimmedMutation }; - } - return null; - }) - .filter(Boolean) as MutationQuery[]; -}; - const serializeMutationQueries = (selectedOptions: MutationQuery[]): string => { return selectedOptions.map((option) => option.text).join(', '); }; @@ -162,7 +44,7 @@ export const MutationField: FC = ({ referenceGenomesSequence { baseType: 'aminoAcid', mutationType: 'insertion', test: isValidAminoAcidInsertionQuery }, ] as const; tests.forEach(({ baseType, mutationType, test }) => { - if (test(newValue, referenceGenomesSequenceNames)) { + if (test(newValue, referenceGenomesSequenceNames) === true) { newOptions.push({ baseType, mutationType, text: newValue }); } }); diff --git a/website/src/pages/[organism]/search/index.astro b/website/src/pages/[organism]/search/index.astro index 14067b51b..80f35cd10 100644 --- a/website/src/pages/[organism]/search/index.astro +++ b/website/src/pages/[organism]/search/index.astro @@ -8,6 +8,10 @@ import { siloVersionStatuses } from '../../../types/lapis'; import { getAccessToken } from '../../../utils/getAccessToken'; import { getMyGroups } from '../../../utils/getMyGroups'; import { getReferenceGenomesSequenceNames } from '../../../utils/search'; +const hiddenFieldValues = { + [VERSION_STATUS_FIELD]: siloVersionStatuses.latestVersion, + [IS_REVOCATION_FIELD]: 'false', +}; const { organism: cleanedOrganism } = cleanOrganism(Astro.params.organism); @@ -38,9 +42,6 @@ const referenceGenomeSequenceNames = getReferenceGenomesSequenceNames(cleanedOrg myGroups={myGroups} accessToken={accessToken} referenceGenomesSequenceNames={referenceGenomeSequenceNames} - hiddenFieldValues={{ - [VERSION_STATUS_FIELD]: siloVersionStatuses.latestVersion, - [IS_REVOCATION_FIELD]: 'false', - }} + hiddenFieldValues={hiddenFieldValues} /> diff --git a/website/src/pages/[organism]/submission/[groupId]/released.astro b/website/src/pages/[organism]/submission/[groupId]/released.astro index 1201417f2..65190c5f8 100644 --- a/website/src/pages/[organism]/submission/[groupId]/released.astro +++ b/website/src/pages/[organism]/submission/[groupId]/released.astro @@ -29,6 +29,11 @@ const schema = getSchema(cleanedOrganism.key); const accessToken = getAccessToken(Astro.locals.session); const referenceGenomeSequenceNames = getReferenceGenomesSequenceNames(cleanedOrganism.key); + +const hiddenFieldValues = { + [VERSION_STATUS_FIELD]: siloVersionStatuses.latestVersion, + [GROUP_ID_FIELD]: group.groupId, +}; --- @@ -40,9 +45,6 @@ const referenceGenomeSequenceNames = getReferenceGenomesSequenceNames(cleanedOrg myGroups={[group]} accessToken={accessToken} referenceGenomesSequenceNames={referenceGenomeSequenceNames} - hiddenFieldValues={{ - [VERSION_STATUS_FIELD]: siloVersionStatuses.latestVersion, - [GROUP_ID_FIELD]: group.groupId, - }} + hiddenFieldValues={hiddenFieldValues} /> diff --git a/website/src/utils/search.ts b/website/src/utils/search.ts index f99afba33..f75b058bb 100644 --- a/website/src/utils/search.ts +++ b/website/src/utils/search.ts @@ -1,14 +1,30 @@ -import type { TableSequenceData } from '../components/SearchPage/Table.tsx'; -import { getReferenceGenomes } from '../config.ts'; -import type { MetadataFilter } from '../types/config.ts'; -import type { ReferenceGenomesSequenceNames, ReferenceAccession, NamedSequence } from '../types/referencesGenomes.ts'; +import { type BaseType } from './sequenceTypeHelpers'; +import type { TableSequenceData } from '../components/SearchPage/Table'; +import { getReferenceGenomes } from '../config'; +import type { MetadataFilter, Schema } from '../types/config'; +import type { ReferenceGenomesSequenceNames, ReferenceAccession, NamedSequence } from '../types/referencesGenomes'; +export const VISIBILITY_PREFIX = 'visibility_'; + +export type MutationQuery = { + baseType: BaseType; + mutationType: 'substitutionOrDeletion' | 'insertion'; + text: string; +}; + +export const COLUMN_VISIBILITY_PREFIX = 'column_'; + +const ORDER_KEY = 'orderBy'; +const ORDER_DIRECTION_KEY = 'order'; export type SearchResponse = { data: TableSequenceData[]; totalCount: number; }; -export function addHiddenFilters(searchFormFilter: MetadataFilter[], hiddenFilters: MetadataFilter[]) { +export function addHiddenFilters( + searchFormFilter: MetadataFilter[], + hiddenFilters: MetadataFilter[], +): MetadataFilter[] { const searchFormFilterNames = searchFormFilter.map((filter) => filter.name); const hiddenFiltersToAdd = hiddenFilters.filter((filter) => !searchFormFilterNames.includes(filter.name)); return [...searchFormFilter, ...hiddenFiltersToAdd]; @@ -29,3 +45,219 @@ export const getReferenceGenomesSequenceNames = (organism: string): ReferenceGen insdc_accession_full: referenceGenomes.nucleotideSequences.map((n) => getAccession(n)), }; }; + +type VisibilityAccessor = (field: MetadataFilter) => boolean; + +const getFieldOrColumnVisibilitiesFromQuery = ( + schema: Schema, + state: Record, + visibilityPrefix: string, + initiallyVisibleAccessor: VisibilityAccessor, +): Map => { + const visibilities = new Map(); + schema.metadata.forEach((field) => { + if (field.hideOnSequenceDetailsPage === true) { + return; + } + visibilities.set(field.name, initiallyVisibleAccessor(field) === true); + }); + + const visibilityKeys = Object.keys(state).filter((key) => key.startsWith(visibilityPrefix)); + + for (const key of visibilityKeys) { + visibilities.set(key.slice(visibilityPrefix.length), state[key] === 'true'); + } + return visibilities; +}; + +export const getFieldVisibilitiesFromQuery = (schema: Schema, state: Record): Map => { + const initiallyVisibleAccessor: VisibilityAccessor = (field) => field.initiallyVisible === true; + return getFieldOrColumnVisibilitiesFromQuery(schema, state, VISIBILITY_PREFIX, initiallyVisibleAccessor); +}; + +export const getColumnVisibilitiesFromQuery = (schema: Schema, state: Record): Map => { + const initiallyVisibleAccessor: VisibilityAccessor = (field) => schema.tableColumns.includes(field.name); + return getFieldOrColumnVisibilitiesFromQuery(schema, state, COLUMN_VISIBILITY_PREFIX, initiallyVisibleAccessor); +}; + +export const getFieldValuesFromQuery = ( + state: Record, + hiddenFieldValues: Record, +): Record => { + const fieldKeys = Object.keys(state) + .filter((key) => !key.startsWith(VISIBILITY_PREFIX) && !key.startsWith(COLUMN_VISIBILITY_PREFIX)) + .filter((key) => key !== ORDER_KEY && key !== ORDER_DIRECTION_KEY); + + const values: Record = { ...hiddenFieldValues }; + for (const key of fieldKeys) { + values[key] = state[key]; + } + return values; +}; + +const textAccessionsToList = (text: string): string[] => { + const accessions = text + .split(/[\t,;\n ]/) + .map((s) => s.trim()) + .filter((s) => s !== '') + .map((s) => { + if (s.includes('.')) { + return s.split('.')[0]; + } + return s; + }); + + return accessions; +}; + +export const getLapisSearchParameters = ( + fieldValues: Record, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): Record => { + const sequenceFilters = Object.fromEntries( + Object.entries(fieldValues).filter(([, value]) => value !== undefined && value !== ''), + ); + + if (sequenceFilters.accession !== '' && sequenceFilters.accession !== undefined) { + sequenceFilters.accession = textAccessionsToList(sequenceFilters.accession); + } + + delete sequenceFilters.mutation; + + const mutationFilter = parseMutationString(fieldValues.mutation ?? '', referenceGenomesSequenceNames); + + return { + ...sequenceFilters, + nucleotideMutations: mutationFilter + .filter((m) => m.baseType === 'nucleotide' && m.mutationType === 'substitutionOrDeletion') + .map((m) => m.text), + aminoAcidMutations: mutationFilter + .filter((m) => m.baseType === 'aminoAcid' && m.mutationType === 'substitutionOrDeletion') + .map((m) => m.text), + nucleotideInsertions: mutationFilter + .filter((m) => m.baseType === 'nucleotide' && m.mutationType === 'insertion') + .map((m) => m.text), + aminoAcidInsertions: mutationFilter + .filter((m) => m.baseType === 'aminoAcid' && m.mutationType === 'insertion') + .map((m) => m.text), + }; +}; + +export const parseMutationString = ( + value: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): MutationQuery[] => { + return value + .split(',') + .map((mutation) => { + const trimmedMutation = mutation.trim(); + if (isValidNucleotideMutationQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'nucleotide', mutationType: 'substitutionOrDeletion', text: trimmedMutation }; + } + if (isValidAminoAcidMutationQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'aminoAcid', mutationType: 'substitutionOrDeletion', text: trimmedMutation }; + } + if (isValidNucleotideInsertionQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'nucleotide', mutationType: 'insertion', text: trimmedMutation }; + } + if (isValidAminoAcidInsertionQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'aminoAcid', mutationType: 'insertion', text: trimmedMutation }; + } + return null; + }) + .filter(Boolean) as MutationQuery[]; +}; + +export const isValidAminoAcidInsertionQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const textUpper = text.toUpperCase(); + if (!textUpper.startsWith('INS_')) { + return false; + } + const query = textUpper.slice(4); + const [gene, position, insertion] = query.split(':'); + const existingGenes = new Set(referenceGenomesSequenceNames.genes.map((g) => g.toUpperCase())); + if (!existingGenes.has(gene) || !Number.isInteger(Number(position))) { + return false; + } + return /^[A-Z*?]+$/.test(insertion); + } catch (_) { + return false; + } +}; + +export const isValidAminoAcidMutationQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const textUpper = text.toUpperCase(); + const [gene, mutation] = textUpper.split(':'); + const existingGenes = new Set(referenceGenomesSequenceNames.genes.map((g) => g.toUpperCase())); + if (!existingGenes.has(gene)) { + return false; + } + return /^[A-Z*]?[0-9]+[A-Z-*\\.]?$/.test(mutation); + } catch (_) { + return false; + } +}; + +export const isValidNucleotideInsertionQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const isMultiSegmented = referenceGenomesSequenceNames.nucleotideSequences.length > 1; + const textUpper = text.toUpperCase(); + if (!textUpper.startsWith('INS_')) { + return false; + } + const query = textUpper.slice(4); + const split = query.split(':'); + const [segment, position, insertion] = isMultiSegmented + ? split + : ([undefined, ...split] as [undefined | string, string, string]); + if (segment !== undefined) { + const existingSegments = new Set( + referenceGenomesSequenceNames.nucleotideSequences.map((n) => n.toUpperCase()), + ); + if (!existingSegments.has(segment)) { + return false; + } + } + if (!Number.isInteger(Number(position))) { + return false; + } + return /^[A-Z*?]+$/.test(insertion); + } catch (_) { + return false; + } +}; + +export const isValidNucleotideMutationQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const isMultiSegmented = referenceGenomesSequenceNames.nucleotideSequences.length > 1; + const textUpper = text.toUpperCase(); + let mutation = textUpper; + if (isMultiSegmented) { + const [segment, _mutation] = textUpper.split(':'); + const existingSegments = new Set( + referenceGenomesSequenceNames.nucleotideSequences.map((n) => n.toUpperCase()), + ); + if (!existingSegments.has(segment)) { + return false; + } + mutation = _mutation; + } + return /^[A-Z]?[0-9]+[A-Z-\\.]?$/.test(mutation); + } catch (_) { + return false; + } +};