From 706b4aa509783af4291c594d41365af2e5d5a4b4 Mon Sep 17 00:00:00 2001 From: Tim Jennison Date: Fri, 24 Jan 2025 20:50:18 +0000 Subject: [PATCH] Add support for adding criteria from a list of codes --- docs/generated/PROTOCOL_BUFFERS.md | 1 + ui/src/addByCode.tsx | 274 ++++++++++++++++++ ui/src/addCriteria.tsx | 17 +- ui/src/appRoutes.tsx | 5 + ui/src/cohort.ts | 51 +++- ui/src/cohortContext.ts | 2 +- ui/src/components/checkbox.tsx | 22 +- ui/src/criteria/attribute.tsx | 6 +- ui/src/criteria/classification.tsx | 172 +++++++++-- ui/src/criteria/filterableGroup.tsx | 30 +- ui/src/criteria/multiAttribute.tsx | 17 +- ui/src/criteria/survey.tsx | 36 ++- ui/src/plugins/vumc/biovu.tsx | 10 +- ui/src/router.tsx | 4 + ui/src/underlaysSlice.ts | 1 + .../configschema/entity_group.proto | 6 + .../conditions/conditions.json | 4 + .../criteriaselector/drugs/drugs.json | 4 + .../observations/observations.json | 4 + .../procedures/procedures.json | 4 + .../config/underlay/cmssynpuf/ui.json | 3 + 21 files changed, 587 insertions(+), 86 deletions(-) create mode 100644 ui/src/addByCode.tsx diff --git a/docs/generated/PROTOCOL_BUFFERS.md b/docs/generated/PROTOCOL_BUFFERS.md index ae0fbb114..ea39c5882 100644 --- a/docs/generated/PROTOCOL_BUFFERS.md +++ b/docs/generated/PROTOCOL_BUFFERS.md @@ -246,6 +246,7 @@ which have condition_name of "Diabetes"). | default_sort | [tanagra.SortOrder](#tanagra-SortOrder) | | The sort order to use in the list view, or in hierarchies where no sort order has been specified. | | limit | [int32](#int32) | optional | Number of values to display in the list view for each entity group. Otherwise, a default value is applied. | | nameAttribute | [string](#string) | optional | The attribute used to name selections if not the first column. This can be used to include extra context with the selected values that's not visible in the table view. | +| codeAttributes | [string](#string) | repeated | Optional attributes to search when adding criteria by code. It's recommended to enable multi_select when using codeAttributes because multiple codes can be added at the same time which forces the criteria into multi_select mode regardless of the setting. | diff --git a/ui/src/addByCode.tsx b/ui/src/addByCode.tsx new file mode 100644 index 000000000..1ba25c85c --- /dev/null +++ b/ui/src/addByCode.tsx @@ -0,0 +1,274 @@ +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import { createCriteria, lookupCriteria, LookupEntry } from "cohort"; +import Checkbox from "components/checkbox"; +import Loading from "components/loading"; +import { + TreeGrid, + TreeGridColumn, + TreeGridId, + useArrayAsTreeGridData, +} from "components/treegrid"; +import ActionBar from "actionBar"; +import { DataKey } from "data/types"; +import { useUnderlaySource } from "data/underlaySourceContext"; +import { useUnderlay, useCohortGroupSectionAndGroup } from "hooks"; +import { GridBox } from "layout/gridBox"; +import GridLayout from "layout/gridLayout"; +import { useCallback, useMemo, useState } from "react"; +import useSWRMutation from "swr/mutation"; +import { useImmer } from "use-immer"; +import { TextField } from "@mui/material"; +import { useNavigate } from "util/searchState"; +import { cohortURL, useIsSecondBlock } from "router"; +import { insertCohortCriteria, useCohortContext } from "cohortContext"; +import { isValid } from "util/valid"; + +type LookupEntryItem = { + config?: JSX.Element; + code: DataKey; + name?: string; + entry?: LookupEntry; +}; + +export function AddByCode() { + const underlay = useUnderlay(); + const underlaySource = useUnderlaySource(); + const context = useCohortContext(); + const navigate = useNavigate(); + const { cohort, section } = useCohortGroupSectionAndGroup(); + const secondBlock = useIsSecondBlock(); + + const [query, setQuery] = useState(); + const [selected, updateSelected] = useImmer(new Set()); + + const configMap = useMemo( + () => + new Map(underlay.criteriaSelectors.map((s) => [s.name, s.displayName])), + [underlay.criteriaSelectors] + ); + + const lookupEntriesState = useSWRMutation( + { + component: "AddByCode", + query, + }, + async () => { + const codes = [ + ...new Set( + query + ?.split(/[\s,]+/) + .map((c) => c.trim()) + .filter((c) => c.length) + ), + ]; + if (!codes) { + return []; + } + + const entries = ( + await lookupCriteria( + underlaySource, + underlay.criteriaSelectors.filter((s) => s.isEnabledForCohorts), + codes + ) + )?.data; + if (!entries) { + return []; + } + + const entryMap = new Map(entries.map((e) => [e.code, e])); + const mappedEntries = codes.map((c) => { + const entry = entryMap.get(c); + if (!entry) { + return { + config: , + code: c, + name: "There were no matches for this code", + }; + } + return { + config: , + code: entry.code, + name: entry.name, + entry: entry, + }; + }); + + updateSelected((s) => { + s.clear(); + mappedEntries.forEach((e) => { + if (e.entry) { + s.add(e.code); + } + }); + }); + return mappedEntries; + } + ); + + const data = useArrayAsTreeGridData(lookupEntriesState?.data ?? [], "code"); + + const onInsert = useCallback(() => { + const configMap = new Map(); + lookupEntriesState.data?.forEach((e) => { + if (!e.entry || !selected.has(e.code)) { + return; + } + + const list = configMap.get(e.entry.config) ?? []; + list.push(e); + configMap.set(e.entry.config, list); + }); + + const criteria = Array.from(configMap.entries()).map(([c, entries]) => { + const config = underlay.criteriaSelectors.find( + (config) => config.name === c + ); + if (!config) { + throw new Error(`Config ${c} not found.`); + } + + return createCriteria( + underlaySource, + config, + entries.map((e) => e.entry?.data).filter(isValid) + ); + }); + + const group = insertCohortCriteria( + context, + section.id, + criteria, + secondBlock + ); + navigate("../../../" + cohortURL(cohort.id, section.id, group.id)); + }, [context, cohort.id, section.id, navigate]); + + return ( + + + theme.palette.background.paper, + }} + > + + + theme.palette.info.main, + borderRadius: "16px", + "& .MuiOutlinedInput-root": { + borderRadius: "16px", + }, + }} + onChange={(event: React.ChangeEvent) => { + setQuery(event.target.value); + }} + /> + + + + + + + {lookupEntriesState.data?.length ? ( + { + if (!lookupEntriesState.data) { + return undefined; + } + + const item = data[id]?.data as LookupEntryItem; + if (!item) { + return undefined; + } + + const sel = selected.has(id); + return [ + { + column: 2, + prefixElements: ( + { + updateSelected((selected) => { + if (sel) { + selected.delete(id); + } else { + selected.add(id); + } + }); + }} + /> + ), + }, + ]; + }} + /> + ) : null} + + + + + + + ); +} + +const columns: TreeGridColumn[] = [ + { + key: "config", + width: "160px", + }, + { + key: "code", + width: "130px", + title: "Code", + }, + { + key: "name", + width: "100%", + title: "Name", + }, +]; diff --git a/ui/src/addCriteria.tsx b/ui/src/addCriteria.tsx index 0e966a36a..a2fe0c468 100644 --- a/ui/src/addCriteria.tsx +++ b/ui/src/addCriteria.tsx @@ -47,6 +47,7 @@ import { useCallback, useMemo } from "react"; import { addCohortCriteriaURL, addFeatureSetCriteriaURL, + addByCodeURL, cohortURL, featureSetURL, newCriteriaURL, @@ -214,6 +215,20 @@ function AddCriteria(props: AddCriteriaProps) { }, }); + if (underlay.uiConfiguration.featureConfig?.enableAddByCode) { + options.push({ + name: "tAddByCode", + title: "Add criteria by code", + category: "Other", + tags: [], + cohort: true, + showMore: false, + fn: () => { + navigate(addByCodeURL()); + }, + }); + } + return options; }, [props.onInsertPredefinedCriteria, selectors, predefinedCriteria]); @@ -322,7 +337,7 @@ function AddCriteria(props: AddCriteriaProps) { const criteria = createCriteria( underlaySource, option.selector, - dataEntry + dataEntry ? [dataEntry] : undefined ); if (!!getCriteriaPlugin(criteria).renderEdit && !dataEntry) { navigate(newCriteriaURL(option.name)); diff --git a/ui/src/appRoutes.tsx b/ui/src/appRoutes.tsx index 3e5d64b33..6baf66cda 100644 --- a/ui/src/appRoutes.tsx +++ b/ui/src/appRoutes.tsx @@ -1,5 +1,6 @@ import { CohortRevision } from "activityLog/cohortRevision"; import { AddCohort } from "addCohort"; +import { AddByCode } from "addByCode"; import { AddCohortCriteria, AddFeatureSetCriteria } from "addCriteria"; import { LoginPage, LogoutPage } from "auth/provider"; import { CohortReview } from "cohortReview/cohortReview"; @@ -86,6 +87,10 @@ function cohortRootRoutes() { path: "tAddFeatureSet", element: , }, + { + path: "tAddByCode", + element: , + }, ], }, { diff --git a/ui/src/cohort.ts b/ui/src/cohort.ts index 4fc263ecf..50a24b854 100644 --- a/ui/src/cohort.ts +++ b/ui/src/cohort.ts @@ -11,7 +11,7 @@ import { GroupSectionReducingOperator, UnderlaySource, } from "data/source"; -import { DataEntry } from "data/types"; +import { DataEntry, DataKey } from "data/types"; import { useUnderlaySource } from "data/underlaySourceContext"; import { useStudyId, useUnderlay } from "hooks"; import { generate } from "randomstring"; @@ -164,6 +164,27 @@ export function searchCriteria( })); } +export function lookupCriteria( + underlaySource: UnderlaySource, + configs: CommonSelectorConfig[], + codes: string[] +): Promise { + const promises: Promise[] = configs + .map((config) => { + const entry = criteriaRegistry.get(config.plugin); + if (!entry?.lookup) { + return null; + } + + return entry.lookup(underlaySource, config, codes); + }) + .filter(isValid); + + return Promise.all(promises).then((responses) => ({ + data: responses.flat(), + })); +} + export type OccurrenceFilters = { id: string; name: string; @@ -242,13 +263,15 @@ export function useOccurrenceList( export function registerCriteriaPlugin( type: string, initializeData: InitializeDataFn, - search?: SearchFn + search?: SearchFn, + lookup?: LookupFn ) { return (constructor: T): void => { criteriaRegistry.set(type, { initializeData, constructor, search, + lookup, }); }; } @@ -256,7 +279,7 @@ export function registerCriteriaPlugin( type InitializeDataFn = ( underlaySource: UnderlaySource, config: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ) => string; type SearchFn = ( @@ -269,16 +292,33 @@ export type SearchResponse = { data: MergedItem[]; }; +type LookupFn = ( + underlaySource: UnderlaySource, + config: CommonSelectorConfig, + codes: string[] +) => Promise; + +export type LookupEntry = { + config: string; + code: DataKey; + name: string; + data: DataEntry; +}; + +export type LookupResponse = { + data: LookupEntry[]; +}; + export function createCriteria( underlaySource: UnderlaySource, config: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ): Criteria { const entry = getCriteriaEntry(config.plugin); return { id: generateId(), type: config.plugin, - data: entry.initializeData(underlaySource, config, dataEntry), + data: entry.initializeData(underlaySource, config, dataEntries), config: config, }; } @@ -316,6 +356,7 @@ type RegistryEntry = { initializeData: InitializeDataFn; constructor: CriteriaPluginConstructor; search?: SearchFn; + lookup?: LookupFn; }; const criteriaRegistry = new Map(); diff --git a/ui/src/cohortContext.ts b/ui/src/cohortContext.ts index 1cd985846..63dad15ef 100644 --- a/ui/src/cohortContext.ts +++ b/ui/src/cohortContext.ts @@ -150,7 +150,7 @@ export function useNewCohortContext(showSnackbar: (message: string) => void) { mutate( (key: { type: string; studyId: string; list: boolean }) => - key.type === "cohort" && key.studyId === studyId && key.list, + key && key.type === "cohort" && key.studyId === studyId && key.list, undefined, { revalidate: true } ); diff --git a/ui/src/components/checkbox.tsx b/ui/src/components/checkbox.tsx index f5c4ce545..32bb2d749 100644 --- a/ui/src/components/checkbox.tsx +++ b/ui/src/components/checkbox.tsx @@ -1,12 +1,13 @@ import CheckBoxIcon from "@mui/icons-material/CheckBox"; import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; import IconButton from "@mui/material/IconButton"; -import { useTheme } from "@mui/material/styles"; +import { useTheme, alpha } from "@mui/material/styles"; import { cloneElement, ReactElement } from "react"; export type CheckboxProps = { checked?: boolean; faded?: boolean; + disabled?: boolean; onChange?: () => void; size?: "small" | "medium" | "large"; fontSize?: "small" | "medium" | "large" | "inherit"; @@ -21,6 +22,7 @@ const defaultUncheckedIcon = ; export default function Checkbox({ checked, faded, + disabled, onChange, size, fontSize, @@ -35,6 +37,7 @@ export default function Checkbox({ role={"checkbox"} size={size} name={name} + disabled={disabled} onClick={() => { if (onChange) { onChange(); @@ -45,19 +48,20 @@ export default function Checkbox({ ? cloneElement(checkedIcon, { size: size, fontSize: fontSize, - ...(faded - ? { - sx: { - fill: theme.palette.primary.light, - }, - } - : { color: "primary" }), + sx: { + fill: alpha( + faded + ? theme.palette.primary.light + : theme.palette.primary.main, + !disabled ? 1 : theme.palette.action.disabledOpacity + ), + }, }) : cloneElement(uncheckedIcon, { size: size, fontSize: fontSize, sx: { - fill: "inherit", + fill: !disabled ? "inherit" : theme.palette.action.disabled, }, })} diff --git a/ui/src/criteria/attribute.tsx b/ui/src/criteria/attribute.tsx index da42e98e0..d210042bb 100644 --- a/ui/src/criteria/attribute.tsx +++ b/ui/src/criteria/attribute.tsx @@ -45,7 +45,7 @@ interface Data { ( underlaySource: UnderlaySource, c: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ) => { const entity = underlaySource.lookupEntity(""); const config = decodeConfig(c); @@ -54,8 +54,8 @@ interface Data { ); return encodeData({ - selected: dataEntry - ? [{ value: dataEntry.key, name: dataEntry.name as string }] + selected: dataEntries + ? dataEntries.map((e) => ({ value: e.key, name: e.name as string })) : attribute?.dataType === tanagraUnderlay.SZDataType.BOOLEAN ? [{ value: true, name: attribute.name }] : [], diff --git a/ui/src/criteria/classification.tsx b/ui/src/criteria/classification.tsx index b89785f75..f9f449088 100644 --- a/ui/src/criteria/classification.tsx +++ b/ui/src/criteria/classification.tsx @@ -1,3 +1,4 @@ +import * as tanagra from "tanagra-api"; import AccountTreeIcon from "@mui/icons-material/AccountTree"; import DeleteIcon from "@mui/icons-material/Delete"; import Button from "@mui/material/Button"; @@ -6,7 +7,7 @@ import Link from "@mui/material/Link"; import Paper from "@mui/material/Paper"; import Popover from "@mui/material/Popover"; import Typography from "@mui/material/Typography"; -import { CriteriaPlugin, registerCriteriaPlugin } from "cohort"; +import { CriteriaPlugin, registerCriteriaPlugin, LookupEntry } from "cohort"; import Checkbox from "components/checkbox"; import Empty from "components/empty"; import Loading from "components/loading"; @@ -37,8 +38,14 @@ import { EntityNode, protoFromDataKey, UnderlaySource, + literalFromDataValue, + makeBooleanLogicFilter, } from "data/source"; -import { DataEntry, DataKey } from "data/types"; +import { + convertAttributeStringToDataValue, + DataEntry, + DataKey, +} from "data/types"; import { useUnderlaySource } from "data/underlaySourceContext"; import { useIsNewCriteria, useUpdateCriteria } from "hooks"; import emptyImage from "images/empty.svg"; @@ -82,7 +89,7 @@ export interface Data { ( underlaySource: UnderlaySource, c: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ) => { const config = decodeConfig(c); @@ -91,25 +98,28 @@ export interface Data { valueData: { ...ANY_VALUE_DATA }, }; - if (dataEntry) { - const name = String(dataEntry[nameAttribute(config)]); - const entityGroup = String(dataEntry.entityGroup); - if (!name || !entityGroup) { - throw new Error( - `Invalid parameters from search [${name}, ${entityGroup}].` - ); - } + if (dataEntries) { + dataEntries?.forEach((e) => { + const name = String(e[nameAttribute(config)]); + const entityGroup = String(e.entityGroup); + if (!name || !entityGroup) { + throw new Error( + `Invalid parameters from search [${name}, ${entityGroup}].` + ); + } - data.selected.push({ - key: dataEntry.key, - name, - entityGroup, + data.selected.push({ + key: e.key, + name, + entityGroup, + }); }); } return encodeData(data); }, - search + search, + lookup ) // eslint-disable-next-line @typescript-eslint/no-unused-vars class _ implements CriteriaPlugin { @@ -231,6 +241,15 @@ function ClassificationEdit(props: ClassificationEditProps) { const [localCriteria, updateLocalCriteria] = useImmer(decodedData); + // TODO(tjennison): AddByCode creates the possiblity that there can be more + // than one selected item in single select mode. In order to solve it properly + // there would need to be an option at the selector level that indicates + // whether a criteria can accept more than one DataEnrty on creation. For now, + // the docs recommend not using AddByCode without multi select and this forces + // multi select behavior in that case. + const multiSelect = + props.config.multiSelect || decodedData.selected.length > 1; + const selectedSets = useMemo(() => { const sets = new Map>(); localCriteria.selected.forEach((s) => { @@ -287,10 +306,7 @@ function ClassificationEdit(props: ClassificationEditProps) { }; } - if ( - !props.config.multiSelect || - isDataEqual(decodedData, localCriteria) - ) { + if (!multiSelect || isDataEqual(decodedData, localCriteria)) { return undefined; } else { return unconfirmedChangesCallback; @@ -524,9 +540,7 @@ function ClassificationEdit(props: ClassificationEditProps) { const hierarchyColumns: TreeGridColumn[] = useMemo( () => [ ...(fromProtoColumns(props.config.hierarchyColumns) ?? []), - ...(!props.config.multiSelect - ? [{ key: "t_add_button", width: 60 }] - : []), + ...(!multiSelect ? [{ key: "t_add_button", width: 60 }] : []), ], [props.config.hierarchyColumns] ); @@ -534,13 +548,11 @@ function ClassificationEdit(props: ClassificationEditProps) { const allColumns: TreeGridColumn[] = useMemo( () => [ ...fromProtoColumns(props.config.columns), - ...(hasHierarchies || !props.config.multiSelect + ...(hasHierarchies || !multiSelect ? [ { key: "view_hierarchy", - width: - (hasHierarchies ? 160 : 0) + - (!props.config.multiSelect ? 40 : 0), + width: (hasHierarchies ? 160 : 0) + (!multiSelect ? 40 : 0), }, ] : []), @@ -712,7 +724,7 @@ function ClassificationEdit(props: ClassificationEditProps) { {entityGroup.selectionEntity.hierarchies?.length ? hierarchyButton : null} - {!props.config.multiSelect ? addButton : null} + {!multiSelect ? addButton : null} ), }, @@ -720,7 +732,7 @@ function ClassificationEdit(props: ClassificationEditProps) { : []; const hierarchyContent = - searchState?.hierarchy && !props.config.multiSelect + searchState?.hierarchy && !multiSelect ? [ { column: hierarchyColumns.length - 1, @@ -733,7 +745,7 @@ function ClassificationEdit(props: ClassificationEditProps) { ] : []; - if (props.config.multiSelect) { + if (multiSelect) { const entityGroupSet = selectedSets.get(item.entityGroup); const found = !!entityGroupSet?.has(item.node.data.key); const foundAncestor = !!item.node.ancestors?.reduce( @@ -838,7 +850,7 @@ function ClassificationEdit(props: ClassificationEditProps) { )} - {props.config.multiSelect ? ( + {multiSelect ? ( { + const config = decodeConfig(c); + if (!config.codeAttributes?.length) { + return []; + } + + const codesSet = new Set(codes); + + const results = await Promise.all( + (config.classificationEntityGroups ?? []).map((eg) => + underlaySource + .searchEntityGroup( + config.columns.map(({ key }) => key), + eg.id, + fromProtoSortOrder(config.defaultSort ?? DEFAULT_SORT_ORDER), + { + filters: generateLookupFilters(underlaySource, config, eg, codes), + fetchAll: true, + } + ) + .then((res) => + res.nodes.map((node) => { + let code: string | undefined; + for (const a of config.codeAttributes) { + const val = String(node.data[a]); + if (codesSet.has(val)) { + code = val; + } + } + + if (!code) { + throw new Error( + `Result ${JSON.stringify( + node + )} does not contain a code from ${codes}.` + ); + } + + return { + config: c.name, + code, + name: String(node.data[nameAttribute(config)]), + data: { + ...node.data, + entityGroup: eg.id, + }, + }; + }) + ) + ) + ); + + return results.flat(); +} + +function generateLookupFilters( + underlaySource: UnderlaySource, + config: configProto.EntityGroup, + entityGroup: configProto.EntityGroup_EntityGroupConfig, + codes: string[] +): tanagra.Filter[] | undefined { + const [entity] = underlaySource.lookupRelatedEntity(entityGroup.id); + const operands: tanagra.Filter[] = []; + + for (const ca of config.codeAttributes) { + const attribute = entity.attributes.find((a) => a.name === ca); + if (!attribute) { + throw new Error(`Unknown attribute "${ca}" in entity "${entity.name}".`); + } + + operands.push({ + filterType: tanagra.FilterFilterTypeEnum.Attribute, + filterUnion: { + attributeFilter: { + attribute: ca, + operator: tanagra.AttributeFilterOperatorEnum.In, + values: codes.map((c) => + literalFromDataValue( + convertAttributeStringToDataValue(c, attribute) + ) + ), + }, + }, + }); + } + + const combined = + makeBooleanLogicFilter( + tanagra.BooleanLogicFilterOperatorEnum.Or, + operands + ) ?? undefined; + return combined ? [combined] : undefined; +} + function useEntityData( config: configProto.EntityGroup ): [boolean, EntityGroupData[], EntityGroupData[]] { diff --git a/ui/src/criteria/filterableGroup.tsx b/ui/src/criteria/filterableGroup.tsx index 9e5ae06aa..a6d194f71 100644 --- a/ui/src/criteria/filterableGroup.tsx +++ b/ui/src/criteria/filterableGroup.tsx @@ -123,10 +123,10 @@ interface Data { ( underlaySource: UnderlaySource, c: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ) => { - if (dataEntry && dataEntry.encoded) { - return String(dataEntry.encoded); + if (dataEntries?.length && dataEntries[0].encoded) { + return String(dataEntries[0].encoded); } return encodeData({ @@ -956,17 +956,19 @@ function useSelectAllCohort( () => newCohort( underlaySource.underlay.name, - createCriteria(underlaySource, selector, { - key: generateId(), - encoded: encodeData({ - selected: [ - { - id: generateId(), - all: selectAll, - }, - ], - }), - }) + createCriteria(underlaySource, selector, [ + { + key: generateId(), + encoded: encodeData({ + selected: [ + { + id: generateId(), + all: selectAll, + }, + ], + }), + }, + ]) ), [selectAll] ); diff --git a/ui/src/criteria/multiAttribute.tsx b/ui/src/criteria/multiAttribute.tsx index 6df59c05c..605493d42 100644 --- a/ui/src/criteria/multiAttribute.tsx +++ b/ui/src/criteria/multiAttribute.tsx @@ -31,15 +31,24 @@ export interface Data { ( underlaySource: UnderlaySource, c: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ) => { const valueData: ValueData[] = []; - if (dataEntry) { + if (dataEntries?.length) { valueData.push({ ...ANY_VALUE_DATA, - attribute: String(dataEntry.t_attribute), + attribute: String(dataEntries[0].t_attribute), numeric: false, - selected: [{ name: String(dataEntry.name), value: dataEntry.key }], + selected: dataEntries.map((e) => { + if (e.t_attribute !== dataEntries[0].t_attribute) { + throw new Error( + `All entries must be for the same attribute ${JSON.stringify( + dataEntries + )}` + ); + } + return { name: String(e.name), value: e.key }; + }), }); } diff --git a/ui/src/criteria/survey.tsx b/ui/src/criteria/survey.tsx index ed66a1185..ab832edaa 100644 --- a/ui/src/criteria/survey.tsx +++ b/ui/src/criteria/survey.tsx @@ -89,7 +89,7 @@ export interface Data { ( underlaySource: UnderlaySource, c: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ) => { const config = decodeConfig(c); @@ -98,25 +98,29 @@ export interface Data { valueData: { ...ANY_VALUE_DATA }, }; - if (dataEntry) { - const name = String(dataEntry[nameAttribute(config)]); - const entityGroup = String(dataEntry.entityGroup); - if (!name || !entityGroup) { - throw new Error( - `Invalid parameters from search [${name}, ${entityGroup}].` - ); - } - + if (dataEntries?.length) { // TODO(tjennison): There's no way to get the question information for // answers added via global search. We're currently not showing answers // there so this isn't an issue, but if we were, that information would // need to be made available at index time. - data.selected.push({ - key: dataEntry.key, - name, - entityGroup, - questionName: "", - }); + data.selected.push( + ...dataEntries?.map((e) => { + const name = String(e[nameAttribute(config)]); + const entityGroup = String(e.entityGroup); + if (!name || !entityGroup) { + throw new Error( + `Invalid parameters from search [${name}, ${entityGroup}].` + ); + } + + return { + key: e.key, + name, + entityGroup, + questionName: "", + }; + }) + ); } return encodeData(data); diff --git a/ui/src/plugins/vumc/biovu.tsx b/ui/src/plugins/vumc/biovu.tsx index 06716d78b..9e18fd2d2 100644 --- a/ui/src/plugins/vumc/biovu.tsx +++ b/ui/src/plugins/vumc/biovu.tsx @@ -55,10 +55,16 @@ interface Data { ( underlaySource: UnderlaySource, c: CommonSelectorConfig, - dataEntry?: DataEntry + dataEntries?: DataEntry[] ) => { + if (dataEntries?.length ?? 0 > 1) { + throw new Error( + `Only one entry is supported, got ${JSON.stringify(dataEntries)}` + ); + } + return encodeData({ - sampleFilter: (dataEntry?.key as SampleFilter) ?? SampleFilter.ANY, + sampleFilter: (dataEntries?.[0]?.key as SampleFilter) ?? SampleFilter.ANY, }); }, search diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 4305d197b..2fb0a5974 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -215,6 +215,10 @@ export function addCohortCriteriaURL() { return "tAddCohort"; } +export function addByCodeURL() { + return "tAddByCode"; +} + export function absoluteExportURL(params: BaseParams) { return absolutePrefix(params) + "export"; } diff --git a/ui/src/underlaysSlice.ts b/ui/src/underlaysSlice.ts index 9b08a5390..589031abf 100644 --- a/ui/src/underlaysSlice.ts +++ b/ui/src/underlaysSlice.ts @@ -33,6 +33,7 @@ export type FeatureConfig = { disableExportButton?: boolean; overrideExportButton?: boolean; disableFeatureSets?: boolean; + enableAddByCode?: boolean; }; export type DemographicChartConfig = { diff --git a/underlay/src/main/proto/criteriaselector/configschema/entity_group.proto b/underlay/src/main/proto/criteriaselector/configschema/entity_group.proto index a81ab16c3..75ab75184 100644 --- a/underlay/src/main/proto/criteriaselector/configschema/entity_group.proto +++ b/underlay/src/main/proto/criteriaselector/configschema/entity_group.proto @@ -67,4 +67,10 @@ message EntityGroup { // used to include extra context with the selected values that's not visible // in the table view. optional string nameAttribute = 10; + + // Optional attributes to search when adding criteria by code. It's + // recommended to enable multi_select when using codeAttributes because + // multiple codes can be added at the same time which forces the criteria into + // multi_select mode regardless of the setting. + repeated string codeAttributes = 11; } diff --git a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/conditions/conditions.json b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/conditions/conditions.json index 718254fcf..af9e4c341 100644 --- a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/conditions/conditions.json +++ b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/conditions/conditions.json @@ -67,5 +67,9 @@ { "id": "conditionPerson" } + ], + "codeAttributes": [ + "concept_code", + "id" ] } \ No newline at end of file diff --git a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/drugs/drugs.json b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/drugs/drugs.json index d0a7ebad3..085f8bb15 100644 --- a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/drugs/drugs.json +++ b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/drugs/drugs.json @@ -76,5 +76,9 @@ "direction": "SORT_ORDER_DIRECTION_ASCENDING" } } + ], + "codeAttributes": [ + "concept_code", + "id" ] } \ No newline at end of file diff --git a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/observations/observations.json b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/observations/observations.json index 741c8f1a3..d57b89c76 100644 --- a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/observations/observations.json +++ b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/observations/observations.json @@ -67,5 +67,9 @@ { "id": "observationPerson" } + ], + "codeAttributes": [ + "concept_code", + "id" ] } \ No newline at end of file diff --git a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/procedures/procedures.json b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/procedures/procedures.json index 175e73370..026760797 100644 --- a/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/procedures/procedures.json +++ b/underlay/src/main/resources/config/criteria/cmssynpuf/criteriaselector/procedures/procedures.json @@ -67,5 +67,9 @@ { "id": "procedurePerson" } + ], + "codeAttributes": [ + "concept_code", + "id" ] } \ No newline at end of file diff --git a/underlay/src/main/resources/config/underlay/cmssynpuf/ui.json b/underlay/src/main/resources/config/underlay/cmssynpuf/ui.json index 54cfbdc92..b554ab211 100644 --- a/underlay/src/main/resources/config/underlay/cmssynpuf/ui.json +++ b/underlay/src/main/resources/config/underlay/cmssynpuf/ui.json @@ -1,4 +1,7 @@ { + "featureConfig": { + "enableAddByCode": true + }, "criteriaSearchConfig": { "criteriaTypeWidth": 120, "columns": [