Skip to content

Commit

Permalink
feat: Id prefixes in filters (#1124)
Browse files Browse the repository at this point in the history
Issue #1015 

When filtering by an id, do not include the prefix in the url query
params. Do include the prefix in the display chip for active filters.

Note: Does this need to be behind a feature flag?


![id-prefixes](https://github.com/user-attachments/assets/e37da985-db75-432d-8068-42c15f3470fd)
  • Loading branch information
ehoops-cz authored Sep 10, 2024
1 parent 6669411 commit d62f382
Show file tree
Hide file tree
Showing 13 changed files with 243 additions and 99 deletions.
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { RegexFilter } from 'app/components/Filters'
import {
GO_PREFIX,
UNIPROTKB_PREFIX,
} from 'app/constants/annotationObjectIdLinks'
import { QueryParams } from 'app/constants/query'
import { useI18n } from 'app/hooks/useI18n'
import { OBJECT_ID_REGEX } from 'app/utils/idPrefixes'

export function ObjectIdFilter() {
const { t } = useI18n()
Expand All @@ -15,7 +12,7 @@ export function ObjectIdFilter() {
title={t('filterByObjectId')}
label={t('objectId')}
queryParam={QueryParams.ObjectId}
regex={RegExp(`^(?:${GO_PREFIX}|${UNIPROTKB_PREFIX}).+$`)}
regex={OBJECT_ID_REGEX}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { EntityIdFilter } from 'app/components/Filters'
import { IdPrefix } from 'app/constants/idPrefixes'
import { QueryParams } from 'app/constants/query'
import { useI18n } from 'app/hooks/useI18n'

Expand All @@ -12,7 +11,6 @@ export function DepositionIdFilter() {
title={t('filterByDepositionId')}
label={t('depositionId')}
queryParam={QueryParams.DepositionId}
prefix={IdPrefix.Deposition}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { Button, Icon } from '@czi-sds/components'
import Popover from '@mui/material/Popover'
import { ReactNode, useRef, useState } from 'react'

import { QueryParams } from 'app/constants/query'
import { i18n } from 'app/i18n'
import { cns } from 'app/utils/cns'
import { getPrefixedId } from 'app/utils/idPrefixes'

export interface ActiveDropdownFilterData {
value: string
label?: string
queryParam?: QueryParams
value: string
}

export function DropdownFilterButton({
Expand Down Expand Up @@ -71,35 +74,37 @@ export function DropdownFilterButton({
{/* active filter chips */}
{activeFilters.length > 0 && (
<div className="flex flex-col gap-sds-xs">
{activeFilters.map((filter) => (
<div className="pl-sds-s flex flex-col">
{filter.label && (
<p className="text-sds-body-xs leading-sds-body-xs text-sds-gray-500 uppercase">
{filter.label}
</p>
)}
{activeFilters.map((filter) => {
return (
<div className="pl-sds-s flex flex-col">
{filter.label && (
<p className="text-sds-body-xs leading-sds-body-xs text-sds-gray-500 uppercase">
{filter.label}
</p>
)}

<div>
<div className="bg-sds-primary-400 rounded-sds-m py-sds-xxs px-sds-s inline-flex items-center gap-sds-s">
<span className="text-sds-body-xs leading-sds-body-xs font-semibold text-white">
{filter.value}
</span>
<div>
<div className="bg-sds-primary-400 rounded-sds-m py-sds-xxs px-sds-s inline-flex items-center gap-sds-s">
<span className="text-sds-body-xs leading-sds-body-xs font-semibold text-white">
{getPrefixedId(filter.value, filter.queryParam)}
</span>

<Button
className="!min-w-0 !w-0"
onClick={() => onRemoveFilter(filter)}
>
<Icon
className="!fill-white !w-[10px] !h-[10px]"
sdsIcon="xMark"
sdsSize="xs"
sdsType="static"
/>
</Button>
<Button
className="!min-w-0 !w-0"
onClick={() => onRemoveFilter(filter)}
>
<Icon
className="!fill-white !w-[10px] !h-[10px]"
sdsIcon="xMark"
sdsSize="xs"
sdsType="static"
/>
</Button>
</div>
</div>
</div>
</div>
))}
)
})}
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { useMemo } from 'react'

import { IdPrefix } from 'app/constants/idPrefixes'
import { QueryParams } from 'app/constants/query'
import {
ALL_DIGITS_REGEX,
getEntityIdPrefixRegex,
getPrefixedId,
QueryParamToIdPrefixMap,
removeIdPrefix,
} from 'app/utils/idPrefixes'

import { RegexFilter } from './RegexFilter'

Expand All @@ -10,16 +16,15 @@ export function EntityIdFilter({
label,
title,
queryParam,
prefix,
}: {
id: string
label: string
title: string
queryParam: QueryParams
prefix?: IdPrefix
}) {
const prefix = QueryParamToIdPrefixMap[queryParam]
const validationRegex = useMemo(
() => (prefix ? RegExp(`^(${prefix}-)?\\d+$`, 'i') : /^\d+$/),
() => (prefix ? getEntityIdPrefixRegex(prefix) : ALL_DIGITS_REGEX),
[prefix],
)

Expand All @@ -30,12 +35,8 @@ export function EntityIdFilter({
title={title}
queryParam={queryParam}
regex={validationRegex}
displayNormalizer={(value) =>
prefix && value.startsWith(prefix) ? value : `${prefix}-${value}`
}
paramNormalizer={(value) =>
value.replace(new RegExp(`^${prefix}-`, 'i'), '')
}
displayNormalizer={(value) => getPrefixedId(value, queryParam)}
paramNormalizer={(value) => removeIdPrefix(value, queryParam) ?? ''}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react'
import { QueryParams } from 'app/constants/query'
import { i18n } from 'app/i18n'
import { cns } from 'app/utils/cns'
import { isFilterPrefixValid, removeIdPrefix } from 'app/utils/idPrefixes'

import { DropdownFilterButton } from './DropdownFilterButton'
import { InputFilter } from './InputFilter'
Expand Down Expand Up @@ -42,10 +43,13 @@ export function MultiInputFilter({

const [values, setValues] = useState(getQueryParamValues)

const isDisabled = useMemo(
() => isEqual(values, getQueryParamValues()),
[getQueryParamValues, values],
)
const isDisabled = useMemo(() => {
const hasInvalidPrefix = !!filters.find(
(filter) => !isFilterPrefixValid(values[filter.id], filter.queryParam),
)

return hasInvalidPrefix || isEqual(values, getQueryParamValues())
}, [filters, getQueryParamValues, values])

return (
<DropdownFilterButton
Expand All @@ -54,6 +58,7 @@ export function MultiInputFilter({
.map((filter) => ({
label: filters.length > 1 ? filter.label : '',
value: values[filter.id],
queryParam: filter.queryParam,
}))}
description={
<>
Expand All @@ -74,7 +79,12 @@ export function MultiInputFilter({
const value = values[filter.id]

if (value) {
prev.set(filter.queryParam, value)
// Our filters currently support numeric IDs and use the queryParam as the key
// The filter will show the prefix, but we do not need to store it in the query params
prev.set(
filter.queryParam,
removeIdPrefix(value, filter.queryParam) ?? '',
)
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function RegexFilter({

return (
<DropdownFilterButton
activeFilters={paramValue ? [{ value: displayValue }] : []}
activeFilters={paramValue ? [{ value: displayValue, queryParam }] : []}
description={
<>
<p className="text-sds-header-xs leading-sds-header-xs font-semibold">
Expand Down
4 changes: 4 additions & 0 deletions frontend/packages/data-portal/app/constants/idPrefixes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export enum IdPrefix {
Annotation = 'AN',
Dataset = 'DS',
Deposition = 'CZCDP',
Run = 'RN',
TiltSeries = 'TS',
}
111 changes: 111 additions & 0 deletions frontend/packages/data-portal/app/utils/idPrefixes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { QueryParams } from 'app/constants/query'

import {
getPrefixedId,
isFilterPrefixValid,
removeIdPrefix,
} from './idPrefixes'

describe('removeIdPrefix()', () => {
it('should extract numeric id from string', () => {
const testCases = [
{
queryParam: QueryParams.DepositionId,
input: 'id1',
output: '1',
},
{
queryParam: QueryParams.DatasetId,
input: 'id-123',
output: '123',
},
{
queryParam: QueryParams.AnnotationId,
input: 'id-1234',
output: '1234',
},
{
queryParam: QueryParams.DepositionId,
input: 'xyz-123',
output: '123',
},
{
queryParam: QueryParams.DepositionId,
input: 'id-0008',
output: '0008',
},
]

testCases.forEach((testCase) =>
expect(removeIdPrefix(testCase.input, testCase.queryParam)).toEqual(
testCase.output,
),
)
})
it('should return the same string if the queryParam does not have a prefix', () => {
const testCases = [
{
queryParam: QueryParams.AuthorName,
input: 'Jane Doe',
output: 'Jane Doe',
},
]

testCases.forEach((testCase) =>
expect(removeIdPrefix(testCase.input, testCase.queryParam)).toEqual(
testCase.output,
),
)
})
})

describe('getPrefixedId()', () => {
it('should add prefix to id', () => {
const testCases = [
{ queryParam: QueryParams.DepositionId, id: '123', output: 'CZCDP-123' },
{
queryParam: QueryParams.DepositionId,
id: 'deposition-123',
output: 'CZCDP-123',
},
{
queryParam: QueryParams.DepositionId,
id: 'deposition-123-456',
output: 'CZCDP-123456',
},
{
queryParam: QueryParams.DepositionId,
id: 'deposition-0008',
output: 'CZCDP-0008',
},
]

testCases.forEach((testCase) =>
expect(getPrefixedId(testCase.id, testCase.queryParam)).toEqual(
testCase.output,
),
)
})
})

describe('isFilterPrefixValid()', () => {
it('should validate filter prefix', () => {
const testCases = [
{
queryParam: QueryParams.DepositionId,
value: 'CZCDP-123',
output: true,
},
{ queryParam: QueryParams.DepositionId, value: '123', output: true },
{ queryParam: QueryParams.AnnotationId, value: 'AN123', output: true },
{ queryParam: QueryParams.AnnotationId, value: 'NAN-123', output: false },
{ queryParam: QueryParams.DatasetId, value: '1-23', output: false },
]

testCases.forEach((testCase) =>
expect(isFilterPrefixValid(testCase.value, testCase.queryParam)).toEqual(
testCase.output,
),
)
})
})
55 changes: 55 additions & 0 deletions frontend/packages/data-portal/app/utils/idPrefixes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
GO_PREFIX,
UNIPROTKB_PREFIX,
} from 'app/constants/annotationObjectIdLinks'
import { IdPrefix } from 'app/constants/idPrefixes'
import { QueryParams } from 'app/constants/query'

// TODO: as we add more prefixes, we need to update this map
export const QueryParamToIdPrefixMap: Partial<Record<QueryParams, IdPrefix>> = {
[QueryParams.AnnotationId]: IdPrefix.Annotation,
[QueryParams.DatasetId]: IdPrefix.Dataset,
[QueryParams.DepositionId]: IdPrefix.Deposition,
// Currently we cannot filter by Run or Tiltseries ID, so they are not here
}

// This function takes a value string and returns the all non-prefix portions.
// Inputs can be in the form of "id-123", "123", "id123", or "id-123-456"
// Inputs can also be strings like "Author Name"
// NOTE: If we need prefixes for string values, like "PREFIX-Author Name", we will need to update this function
export function removeIdPrefix(value: string, queryParam?: QueryParams) {
if (!queryParam) return value
const prefix = QueryParamToIdPrefixMap[queryParam]
if (!prefix) return value

// Use a regular expression to match the numeric portion of the ID
const matches = value.match(/\d+/g)

// If a match is found, return it, otherwise return null
return matches ? matches.join('') : null
}

export function getPrefixedId(id: string, queryParam?: QueryParams) {
if (!queryParam) return id
// ID may or may not already be prefixed, so take it off just in case
const cleanId = removeIdPrefix(id, queryParam)
const prefix = QueryParamToIdPrefixMap[queryParam] ?? ''
return prefix ? `${prefix}-${cleanId}` : id
}

export const getEntityIdPrefixRegex = (prefix: string) =>
RegExp(`^(${prefix})?(-)?\\d+$`, 'i')

export const ALL_DIGITS_REGEX = /^\d+$/
export const OBJECT_ID_REGEX = RegExp(
`^(?:${GO_PREFIX}|${UNIPROTKB_PREFIX}).+$`,
)

export function isFilterPrefixValid(value: string, queryParam?: QueryParams) {
if (!queryParam || value === '') return true
const prefix = QueryParamToIdPrefixMap[queryParam]
if (!prefix) return true

const validationRegex = prefix ? getEntityIdPrefixRegex(prefix) : /^\d+$/
return validationRegex.test(value)
}
Loading

0 comments on commit d62f382

Please sign in to comment.