diff --git a/packages/ui/src/viewer-table/DictionaryTableViewer.tsx b/packages/ui/src/viewer-table/DictionaryTableViewer.tsx index 7966c5ac..77405090 100644 --- a/packages/ui/src/viewer-table/DictionaryTableViewer.tsx +++ b/packages/ui/src/viewer-table/DictionaryTableViewer.tsx @@ -45,6 +45,30 @@ import DictionaryViewerLoadingPage from './DictionaryViewer/DictionaryViewerLoad import DiagramSubtitle from './Toolbar/DiagramSubtitle'; import Toolbar from './Toolbar/index'; +export type CustomFilterDropdown = { + label: string; + filterProperty: string; +}; + +export type DictionaryTableViewerProps = { + customFilterDropdowns?: CustomFilterDropdown[]; +}; + +export type ToolbarCustomDropdown = { + label: string; + options: string[]; + selectedValue: string | undefined; + onSelect: (value: string | undefined) => void; +}; + +const getByDotPath = (obj: unknown, path: string): unknown => + path + .split('.') + .reduce( + (acc, key) => (acc != null && typeof acc === 'object' ? (acc as Record)[key] : undefined), + obj, + ); + type ParsedHashTarget = { index: number; schemaName: string; @@ -92,18 +116,52 @@ const headerPanelBlockStyle = css` gap: 0; `; +const emptyFilterMessageStyle = (theme: Theme) => css` + ${theme.typography.subtitle} + display: flex; + justify-content: center; + align-items: center; + padding: 48px; + color: ${theme.colors.black}; +`; + const isConditionalRestriction = (schemaFieldRestriction: SchemaFieldRestrictions) => { return schemaFieldRestriction && 'if' in schemaFieldRestriction && schemaFieldRestriction.if !== undefined; }; -const getFilteredSchema = (schema: Schema, filters: string[]) => { +const getFilteredSchema = (schema: Schema, filters: string[], activeFilters: [string, string][]): Schema | null => { + // Schema-level: hide entire schema if it doesn't match active custom filters + if (activeFilters.length > 0) { + const matches = activeFilters.every(([filterProperty, value]) => { + const metaValue = getByDotPath(schema, filterProperty); + + if (metaValue == null) { + return false; + } + + if (Array.isArray(metaValue)) { + return metaValue.some((v) => String(v) === value); + } + + return String(metaValue) === value; + }); + + if (!matches) { + return null; + } + } + + // Field-level: filter fields within the schema if (filters.includes('Required')) { - return { - ...schema, - fields: schema.fields.filter((field) => { - return isFieldRequired(field) || isConditionalRestriction(field.restrictions); - }), - }; + const filteredFields = schema.fields.filter((field) => { + return isFieldRequired(field) || isConditionalRestriction(field.restrictions); + }); + + if (filteredFields.length === schema.fields.length) { + return schema; + } + + return { ...schema, fields: filteredFields }; } return schema; }; @@ -152,7 +210,7 @@ const DiagramModal = () => { // TODO: produce a simplified version that accepts a dictionary and produces this same view, // so that there's no requirement for a Lectern server, etc. and without a Toolbar, or a simpler one. -const DictionaryTableViewerContent = () => { +const DictionaryTableViewerContent = ({ customFilterDropdowns }: DictionaryTableViewerProps) => { const theme = useThemeContext(); const { loading, errors } = useDictionaryDataContext(); const { filters, selectedDictionary } = useDictionaryStateContext(); @@ -160,12 +218,48 @@ const DictionaryTableViewerContent = () => { const [isCollapsed, setIsCollapsed] = useState(false); const [selectedSchemaIndex, setSelectedSchemaIndex] = useState(undefined); const [highlightedField, setHighlightedField] = useState<{ schemaName: string; fieldName: string } | null>(null); + const [customFilterSelections, setCustomFilterSelections] = useState>({}); const relationshipMap = useMemo( () => (selectedDictionary ? buildRelationshipMap(selectedDictionary) : null), [selectedDictionary], ); + const toolbarCustomDropdowns: ToolbarCustomDropdown[] | undefined = useMemo(() => { + if (!customFilterDropdowns?.length || !selectedDictionary?.schemas) { + return undefined; + } + + const dropdownContexts = customFilterDropdowns.map((dropdown) => ({ + dropdown, + set: new Set(), + })); + + for (const schema of selectedDictionary.schemas) { + for (const context of dropdownContexts) { + const val = getByDotPath(schema, context.dropdown.filterProperty); + + if (val == null) { + continue; + } + + if (Array.isArray(val)) { + val.forEach((v) => context.set.add(String(v))); + } else { + context.set.add(String(val)); + } + } + } + + return dropdownContexts.map(({ dropdown, set }) => ({ + label: dropdown.label, + options: Array.from(set), + selectedValue: customFilterSelections[dropdown.filterProperty], + onSelect: (value: string | undefined) => + setCustomFilterSelections((prev) => ({ ...prev, [dropdown.filterProperty]: value })), + })); + }, [customFilterDropdowns, selectedDictionary?.schemas, customFilterSelections]); + const handleHash = useCallback(() => { const target = parseHash(window.location.hash, selectedDictionary?.schemas); if (!target) return; @@ -202,21 +296,42 @@ const DictionaryTableViewerContent = () => { return () => window.removeEventListener('hashchange', handleHash); }, [handleHash]); - const accordionItems = - selectedDictionary?.schemas?.map((schema: Schema) => ({ - title: schema.name, - description: schema.description, - content: ( - - ), - schemaName: schema.name, - })) || []; + const activeFilters: [string, string][] = (customFilterDropdowns ?? []).flatMap((dropdown) => { + const value = customFilterSelections[dropdown.filterProperty]; + + if (value === undefined) { + return []; + } + + return [[dropdown.filterProperty, value]]; + }); + + const accordionItems = useMemo(() => { + return (selectedDictionary?.schemas ?? []).flatMap((schema: Schema) => { + const filtered = getFilteredSchema(schema, filters, activeFilters); + + if (!filtered) { + return []; + } + + return [ + { + title: schema.name, + description: schema.description, + content: ( + + ), + schemaName: schema.name, + }, + ]; + }); + }, [selectedDictionary?.schemas, filters, activeFilters, highlightedField]); const handleAccordionSelect = (accordionIndex: number) => { - const schemaName = selectedDictionary?.schemas?.[accordionIndex]?.name; + const schemaName = accordionItems[accordionIndex]?.schemaName; if (schemaName) { const newUrl = `${window.location.pathname}${window.location.search}#${schemaName}`; window.history.pushState(null, '', newUrl); @@ -257,8 +372,15 @@ const DictionaryTableViewerContent = () => {
- - + + {accordionItems.length === 0 && activeFilters.length > 0 ? +
No schemas match the selected filter criteria.
+ : } {relationshipMap && !loading && errors.length === 0 && ( @@ -269,9 +391,9 @@ const DictionaryTableViewerContent = () => { ); }; -export const DictionaryTableViewer = () => ( +export const DictionaryTableViewer = ({ customFilterDropdowns }: DictionaryTableViewerProps) => ( - + ); diff --git a/packages/ui/src/viewer-table/DictionaryViewerPage.tsx b/packages/ui/src/viewer-table/DictionaryViewerPage.tsx index 2f8dbe17..ef1c405c 100644 --- a/packages/ui/src/viewer-table/DictionaryViewerPage.tsx +++ b/packages/ui/src/viewer-table/DictionaryViewerPage.tsx @@ -21,21 +21,23 @@ import { DictionaryLecternDataProvider, DictionaryStateProvider } from '../dictionary-controller/DictionaryDataContext'; import DictionaryTableViewer from './DictionaryTableViewer'; +import type { CustomFilterDropdown } from './DictionaryTableViewer'; export type DictionaryTableProps = { lecternUrl: string; dictionaryName: string; + customFilterDropdowns?: CustomFilterDropdown[]; }; /** * DictionaryViewerPage component. * @param {DictionaryTableProps} props */ -const DictionaryViewerPage = ({ lecternUrl, dictionaryName }: DictionaryTableProps) => { +const DictionaryViewerPage = ({ lecternUrl, dictionaryName, customFilterDropdowns }: DictionaryTableProps) => { return ( - + ); diff --git a/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx b/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx index 1d6faec3..73deb7e2 100644 --- a/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx +++ b/packages/ui/src/viewer-table/Toolbar/Toolbar.tsx @@ -27,6 +27,8 @@ import { useDictionaryDataContext, useDictionaryStateContext } from '../../dicti import { type Theme, useThemeContext } from '../../theme/index'; import { ToolbarSkeleton } from '../Loading'; +import Dropdown from '../../common/Dropdown/index'; +import type { ToolbarCustomDropdown } from '../DictionaryTableViewer'; import AttributeFilterDropdown from './AttributeFilterDropdown'; import CollapseAllButton from './CollapseAllButton'; import DiagramViewButton from './DiagramViewButton'; @@ -38,6 +40,7 @@ export type ToolbarProps = { onSelect: (schemaNameIndex: number) => void; setIsCollapsed: (collapsed: boolean) => void; isCollapsed: boolean; + customFilterDropdowns?: ToolbarCustomDropdown[]; }; const panelStyles = (theme: Theme) => css` @@ -61,7 +64,7 @@ const sectionStyles = css` gap: 16px; `; -const Toolbar = ({ onSelect, setIsCollapsed, isCollapsed }: ToolbarProps) => { +const Toolbar = ({ onSelect, setIsCollapsed, isCollapsed, customFilterDropdowns }: ToolbarProps) => { const theme: Theme = useThemeContext(); const { loading } = useDictionaryDataContext(); const { selectedDictionary } = useDictionaryStateContext(); @@ -79,6 +82,20 @@ const Toolbar = ({ onSelect, setIsCollapsed, isCollapsed }: ToolbarProps) => {
+ {customFilterDropdowns && + customFilterDropdowns.map((dropdown) => ( + dropdown.onSelect(undefined) }, + ...dropdown.options.map((option) => ({ + label: option, + action: () => dropdown.onSelect(option), + })), + ]} + /> + ))} {isCollapsed ? setIsCollapsed(false)} /> : setIsCollapsed(true)} />} diff --git a/packages/ui/stories/dictionaryDecorator.tsx b/packages/ui/stories/dictionaryDecorator.tsx index b55e2e83..4c2f35fa 100644 --- a/packages/ui/stories/dictionaryDecorator.tsx +++ b/packages/ui/stories/dictionaryDecorator.tsx @@ -125,6 +125,37 @@ export const withErrorState = (): Decorator => { ); }; +const schemaMetaMap: Record = { + participant: { category: 'Clinical', tier: 'Required' }, + sociodemographic: { category: 'Clinical', tier: 'Optional' }, + demographic: { category: 'Clinical', tier: 'Required' }, + diagnosis: { category: 'Clinical', tier: 'Required' }, + treatment: { category: 'Clinical', tier: 'Optional' }, + follow_up: { category: 'Clinical', tier: 'Optional' }, + procedure: { category: 'Clinical', tier: 'Optional' }, + medication: { category: 'Clinical', tier: 'Optional' }, + radiation: { category: 'Clinical', tier: 'Optional' }, + measurement: { category: 'Clinical', tier: 'Optional' }, + phenotype: { category: 'Clinical', tier: 'Optional' }, + comorbidity: { category: 'Clinical', tier: 'Optional' }, + exposure: { category: 'Clinical', tier: 'Optional' }, + specimen: { category: 'Biospecimen', tier: 'Required' }, + sample: { category: 'Biospecimen', tier: 'Required' }, + experiment: { category: 'Genomic', tier: 'Required' }, + read_group: { category: 'Genomic', tier: 'Optional' }, +}; + +const customFilterDictionaryData: DictionaryTestData = [ + { + ...DictionarySample, + schemas: DictionarySample.schemas.map((schema) => ({ + ...schema, + meta: schemaMetaMap[schema.name] ?? {}, + })), + } as DictionaryServerUnion, +]; + export const withMultipleDictionaries = withDictionaryContext(multipleDictionaryData); export const withSingleDictionary = withDictionaryContext(singleDictionaryData); export const withEmptyDictionaries = withDictionaryContext(emptyDictionaryData); +export const withCustomFilterDictionary = withDictionaryContext(customFilterDictionaryData); diff --git a/packages/ui/stories/viewer-table/DictionaryViewerPage.stories.tsx b/packages/ui/stories/viewer-table/DictionaryViewerPage.stories.tsx index c9c9db4c..96b846b0 100644 --- a/packages/ui/stories/viewer-table/DictionaryViewerPage.stories.tsx +++ b/packages/ui/stories/viewer-table/DictionaryViewerPage.stories.tsx @@ -32,6 +32,7 @@ import { withLecternUrl, withMultipleDictionaries, withSingleDictionary, + withCustomFilterDictionary, } from '../dictionaryDecorator'; import themeDecorator from '../themeDecorator'; @@ -93,3 +94,13 @@ export const SingleDictionary: Story = { export const LecternServer: Story = { decorators: [withLecternUrl()], }; + +export const WithCustomFilterDropdowns: Story = { + decorators: [withCustomFilterDictionary], + args: { + customFilterDropdowns: [ + { label: 'Category', filterProperty: 'meta.category' }, + { label: 'Tier', filterProperty: 'meta.tier' }, + ], + }, +};