Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 147 additions & 25 deletions packages/ui/src/viewer-table/DictionaryTableViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>(
(acc, key) => (acc != null && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined),
obj,
);

type ParsedHashTarget = {
index: number;
schemaName: string;
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -152,20 +210,56 @@ 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();

const [isCollapsed, setIsCollapsed] = useState(false);
const [selectedSchemaIndex, setSelectedSchemaIndex] = useState<number | undefined>(undefined);
const [highlightedField, setHighlightedField] = useState<{ schemaName: string; fieldName: string } | null>(null);
const [customFilterSelections, setCustomFilterSelections] = useState<Record<string, string | undefined>>({});

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<string>(),
}));

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;
Expand Down Expand Up @@ -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: (
<SchemaTable
schema={getFilteredSchema(schema, filters)}
highlightedFieldName={highlightedField?.schemaName === schema.name ? highlightedField.fieldName : null}
/>
),
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: (
<SchemaTable
schema={filtered}
highlightedFieldName={highlightedField?.schemaName === schema.name ? highlightedField.fieldName : null}
/>
),
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);
Expand Down Expand Up @@ -257,8 +372,15 @@ const DictionaryTableViewerContent = () => {
<div css={headerPanelBlockStyle}>
<DictionaryHeader />
</div>
<Toolbar onSelect={handleAccordionSelect} setIsCollapsed={setIsCollapsed} isCollapsed={isCollapsed} />
<Accordion accordionItems={accordionItems} collapseAll={isCollapsed} selectedIndex={selectedSchemaIndex} />
<Toolbar
onSelect={handleAccordionSelect}
setIsCollapsed={setIsCollapsed}
isCollapsed={isCollapsed}
customFilterDropdowns={toolbarCustomDropdowns}
/>
{accordionItems.length === 0 && activeFilters.length > 0 ?
<div css={emptyFilterMessageStyle(theme)}>No schemas match the selected filter criteria.</div>
: <Accordion accordionItems={accordionItems} collapseAll={isCollapsed} selectedIndex={selectedSchemaIndex} />}
</div>
{relationshipMap && !loading && errors.length === 0 && (
<ActiveRelationshipProvider relationshipMap={relationshipMap}>
Expand All @@ -269,9 +391,9 @@ const DictionaryTableViewerContent = () => {
);
};

export const DictionaryTableViewer = () => (
export const DictionaryTableViewer = ({ customFilterDropdowns }: DictionaryTableViewerProps) => (
<DiagramViewProvider>
<DictionaryTableViewerContent />
<DictionaryTableViewerContent customFilterDropdowns={customFilterDropdowns} />
</DiagramViewProvider>
);

Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/viewer-table/DictionaryViewerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DictionaryLecternDataProvider lecternUrl={lecternUrl} dictionaryName={dictionaryName}>
<DictionaryStateProvider>
<DictionaryTableViewer />
<DictionaryTableViewer customFilterDropdowns={customFilterDropdowns} />
</DictionaryStateProvider>
</DictionaryLecternDataProvider>
);
Expand Down
19 changes: 18 additions & 1 deletion packages/ui/src/viewer-table/Toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +40,7 @@ export type ToolbarProps = {
onSelect: (schemaNameIndex: number) => void;
setIsCollapsed: (collapsed: boolean) => void;
isCollapsed: boolean;
customFilterDropdowns?: ToolbarCustomDropdown[];
};

const panelStyles = (theme: Theme) => css`
Expand All @@ -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();
Expand All @@ -79,6 +82,20 @@ const Toolbar = ({ onSelect, setIsCollapsed, isCollapsed }: ToolbarProps) => {
<div css={sectionStyles}>
<TableOfContentsDropdown schemas={selectedDictionary?.schemas ?? []} onSelect={onSelect} />
<DiagramViewButton />
{customFilterDropdowns &&
customFilterDropdowns.map((dropdown) => (
<Dropdown
key={dropdown.label}
title={dropdown.selectedValue ?? dropdown.label}
menuItems={[
{ label: 'All', action: () => dropdown.onSelect(undefined) },
...dropdown.options.map((option) => ({
label: option,
action: () => dropdown.onSelect(option),
})),
]}
/>
))}
{isCollapsed ?
<ExpandAllButton onClick={() => setIsCollapsed(false)} />
: <CollapseAllButton onClick={() => setIsCollapsed(true)} />}
Expand Down
31 changes: 31 additions & 0 deletions packages/ui/stories/dictionaryDecorator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,37 @@ export const withErrorState = (): Decorator => {
);
};

const schemaMetaMap: Record<string, { category: string; tier: string }> = {
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);
11 changes: 11 additions & 0 deletions packages/ui/stories/viewer-table/DictionaryViewerPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
withLecternUrl,
withMultipleDictionaries,
withSingleDictionary,
withCustomFilterDictionary,
} from '../dictionaryDecorator';
import themeDecorator from '../themeDecorator';

Expand Down Expand Up @@ -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' },
],
},
};