From f33090029f7fc9ad2d74b5043cfdaba0a41e1ed2 Mon Sep 17 00:00:00 2001 From: Timothy Jennison Date: Tue, 24 Sep 2024 17:57:42 +0000 Subject: [PATCH] Edit values for individual items in entity group criteria Add more criteria to the AoU test underlay to avoid changing the actual underlays but still publish config changes. --- ui/src/components/loading.tsx | 9 +- ui/src/criteria/classification.tsx | 52 ++-- ui/src/criteria/multiAttribute.tsx | 4 +- ui/src/criteria/survey.tsx | 86 ++++-- ui/src/criteria/valueData.tsx | 265 +++++++++++------- ui/src/overview.tsx | 2 +- .../impl/core/EntityGroupFilterBuilder.java | 31 +- .../core/EntityGroupFilterBuilderBase.java | 148 ++++++---- .../impl/core/SurveyFilterBuilder.java | 30 +- .../dataschema/entity_group.proto | 5 + .../criteriaselector/dataschema/survey.proto | 7 + .../measurement/ageAtOccurrence.json | 3 + .../measurement/dateGroupByCount.json | 11 + .../measurement/measurement.json | 91 ++++++ .../measurement/selector.json | 37 +++ .../measurement/visitType.json | 3 + .../surveyBasics/selector.json | 17 ++ .../surveyBasics/surveyBasics.json | 44 +++ .../aouSC2023Q3R2_testonly/underlay.json | 4 +- ...ilterBuilderForCriteriaOccurrenceTest.java | 138 +++++++-- 20 files changed, 739 insertions(+), 248 deletions(-) create mode 100644 underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/ageAtOccurrence.json create mode 100644 underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/dateGroupByCount.json create mode 100644 underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/measurement.json create mode 100644 underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/selector.json create mode 100644 underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/visitType.json create mode 100644 underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/selector.json create mode 100644 underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/surveyBasics.json diff --git a/ui/src/components/loading.tsx b/ui/src/components/loading.tsx index bd337072a..ff50e0a30 100644 --- a/ui/src/components/loading.tsx +++ b/ui/src/components/loading.tsx @@ -23,6 +23,7 @@ type Props = { showProgressOnMutate?: boolean; disableReloadButton?: boolean; immediate?: boolean; + noProgress?: boolean; }; export default function Loading(props: Props) { @@ -68,7 +69,8 @@ export default function Loading(props: Props) { isLoading, props.status, props.size ?? "large", - props.disableReloadButton + props.disableReloadButton, + props.noProgress )} ); @@ -80,7 +82,8 @@ function showStatus( isLoading?: boolean, status?: Status, size?: string, - disableReloadButton?: boolean + disableReloadButton?: boolean, + noProgress?: boolean ): ReactNode { if (status?.error && !status?.isLoading) { const defaultMessage = "Something went wrong"; @@ -121,7 +124,7 @@ function showStatus( ); } - return visible ? ( + return !noProgress && visible ? ( { } renderInline(groupId: string) { - if (this.config.multiSelect || !this.config.valueConfigs.length) { + if (!this.config.valueConfigs.length) { return null; } @@ -871,21 +872,27 @@ function ClassificationInline(props: ClassificationInlineProps) { ); return ( - - updateCriteria( - produce(decodedData, (data) => { - data.valueData = valueData[0]; - }) - ) - } - /> + + {decodedData.selected.map((s, i) => ( + + updateCriteria( + produce(decodedData, (data) => { + data.selected[i].valueData = valueData?.[0]; + }) + ) + } + /> + ))} + ); } @@ -986,8 +993,10 @@ function decodeData(data: string): Data { key: dataKeyFromProto(s.key), name: s.name, entityGroup: s.entityGroup, + valueData: + decodeValueDataOptional(s.valueData) ?? + decodeValueDataOptional(message.valueData), })) ?? [], - valueData: decodeValueData(message.valueData), }; } @@ -997,8 +1006,9 @@ function encodeData(data: Data): string { key: protoFromDataKey(s.key), name: s.name, entityGroup: s.entityGroup, + valueData: encodeValueDataOptional(s.valueData), })), - valueData: encodeValueData(data.valueData), + valueData: undefined, }; return JSON.stringify(dataProto.EntityGroup.toJSON(message)); } diff --git a/ui/src/criteria/multiAttribute.tsx b/ui/src/criteria/multiAttribute.tsx index 5e946a3c9..6df59c05c 100644 --- a/ui/src/criteria/multiAttribute.tsx +++ b/ui/src/criteria/multiAttribute.tsx @@ -177,7 +177,9 @@ function MultiAttributeInline(props: MultiAttributeInlineProps) { update={(valueData) => updateCriteria( produce(decodedData, (data) => { - data.valueData = valueData; + if (valueData) { + data.valueData = valueData; + } }) ) } diff --git a/ui/src/criteria/survey.tsx b/ui/src/criteria/survey.tsx index a2ef3e635..da3eca73a 100644 --- a/ui/src/criteria/survey.tsx +++ b/ui/src/criteria/survey.tsx @@ -20,8 +20,8 @@ import { } from "components/treegrid"; import { ANY_VALUE_DATA, - decodeValueData, - encodeValueData, + decodeValueDataOptional, + encodeValueDataOptional, ValueData, ValueDataEdit, } from "criteria/valueData"; @@ -61,6 +61,7 @@ type Selection = { entityGroup: string; questionKey?: DataKey; questionName: string; + valueData?: ValueData; }; enum EntityNodeItemType { @@ -83,7 +84,7 @@ type EntityTreeGridData = TreeGridData; // Exported for testing purposes. export interface Data { selected: Selection[]; - valueData: ValueData; + valueData?: ValueData; } // "survey" plugins are designed to handle medium sized (~<100k rows) amount of @@ -154,9 +155,7 @@ class _ implements CriteriaPlugin { } renderInline(groupId: string) { - const decodedData = decodeData(this.data); - - if (!this.config.valueConfigs.length || decodedData.selected.length) { + if (!this.config.valueConfigs.length) { return null; } @@ -682,6 +681,10 @@ function SurveyInline(props: SurveyInlineProps) { ); const decodedData = useMemo(() => decodeData(props.data), [props.data]); + const groupedSelection = useMemo( + () => groupSelection(decodedData.selected), + [decodedData] + ); if (!props.config.valueConfigs.length || !decodedData.selected.length) { return null; @@ -691,22 +694,56 @@ function SurveyInline(props: SurveyInlineProps) { decodedData.selected[0].entityGroup ); + const contentForItem = (item: GroupedSelectionItem) => { + if (item.index < 0) { + return {item.name}; + } + + const sel = decodedData.selected[item.index]; + return ( + + + updateCriteria( + produce(decodedData, (data) => { + data.selected[item.index].valueData = valueData?.[0]; + }) + ) + } + /> + + ); + }; + return ( - - updateCriteria( - produce(decodedData, (data) => { - data.valueData = valueData[0]; - }) - ) - } - /> + + {groupedSelection.map((s, i) => ( + + `0 -1px 0 ${theme.palette.divider}` + : undefined, + }} + > + {contentForItem(s)} + + + {s.children.map((child) => contentForItem(child))} + + + ))} + ); } @@ -770,8 +807,10 @@ function decodeData(data: string): Data { ? dataKeyFromProto(s.questionKey) : undefined, questionName: s.questionName, + valueData: + decodeValueDataOptional(s.valueData) ?? + decodeValueDataOptional(message.valueData), })) ?? [], - valueData: decodeValueData(message.valueData), }; } @@ -783,8 +822,9 @@ function encodeData(data: Data): string { entityGroup: s.entityGroup, questionKey: s.questionKey ? protoFromDataKey(s.questionKey) : undefined, questionName: s.questionName, + valueData: encodeValueDataOptional(s.valueData), })), - valueData: encodeValueData(data.valueData), + valueData: undefined, }; return JSON.stringify(dataProto.Survey.toJSON(message)); } diff --git a/ui/src/criteria/valueData.tsx b/ui/src/criteria/valueData.tsx index 5e31d8897..25f772941 100644 --- a/ui/src/criteria/valueData.tsx +++ b/ui/src/criteria/valueData.tsx @@ -1,3 +1,5 @@ +import FilterListIcon from "@mui/icons-material/FilterList"; +import FilterListOffIcon from "@mui/icons-material/FilterListOff"; import Chip from "@mui/material/Chip"; import Divider from "@mui/material/Divider"; import FormControl from "@mui/material/FormControl"; @@ -5,6 +7,7 @@ import MenuItem from "@mui/material/MenuItem"; import OutlinedInput from "@mui/material/OutlinedInput"; import Select, { SelectChangeEvent } from "@mui/material/Select"; import Typography from "@mui/material/Typography"; +import Checkbox from "components/checkbox"; import { HintDataSelect } from "components/hintDataSelect"; import Loading from "components/loading"; import { DataRange, RangeSlider } from "components/rangeSlider"; @@ -58,8 +61,16 @@ export type ValueDataEditProps = { singleValue?: boolean; valueConfigs: ValueConfig[]; - valueData: ValueData[]; - update: (data: ValueData[]) => void; + + // The list of ValueData is optional so callers aren't responsible for + // providing a default value, which is awkward for multiselect when a list is + // required. + valueData?: ValueData[]; + update: (data?: ValueData[]) => void; + + // If a title is set, it's assumed that the value data is part of a list and + // the UI is configured accordingly. + title?: string; }; export function ValueDataEdit(props: ValueDataEditProps) { @@ -67,6 +78,14 @@ export function ValueDataEdit(props: ValueDataEditProps) { const entity = underlaySource.lookupEntity(props.hintEntity); + const valueDataList = useMemo((): ValueData[] => { + if (props.valueData) { + return props.valueData; + } + + return props.singleValue ? [ANY_VALUE_DATA] : []; + }, [props.valueData]); + const hintDataState = useSWRImmutable( { type: "hintData", @@ -95,7 +114,7 @@ export function ValueDataEdit(props: ValueDataEditProps) { const attribute = props.valueConfigs.find((c) => c.attribute === sel)?.attribute ?? ANY_VALUE; - if (props.valueData[0] && attribute === props.valueData[0]?.attribute) { + if (valueDataList[0] && attribute === valueDataList[0]?.attribute) { return; } @@ -126,7 +145,7 @@ export function ValueDataEdit(props: ValueDataEditProps) { (a) => a.name === hintData.attribute ); - let valueData = props.valueData.find( + let valueData = valueDataList.find( (data) => data.attribute === valueConfig.attribute ); if (props.singleValue && !valueData) { @@ -147,14 +166,14 @@ export function ValueDataEdit(props: ValueDataEditProps) { }, [ props.singleValue, props.valueConfigs, - props.valueData, + valueDataList, hintDataState.data, entity, ]); const onValueSelect = (sel: ValueSelection[], valueData: ValueData) => { props.update( - produce(props.valueData, (data) => { + produce(valueDataList, (data) => { if (!props.singleValue && sel.length === 0) { return data.filter( (vd) => @@ -185,7 +204,7 @@ export function ValueDataEdit(props: ValueDataEditProps) { valueData: ValueData ) => { props.update( - produce(props.valueData, (data) => { + produce(valueDataList, (data) => { const config = selectedConfigs.find( (c) => c.hintData.attribute === valueData.attribute ); @@ -219,104 +238,138 @@ export function ValueDataEdit(props: ValueDataEditProps) { ); }; + const hasHints = !!hintDataState.data?.hintData?.length; + + // Use two Loading components so that the big spinner will only be shown for + // single select value data but the controls are hidden in both cases. return ( - - {hintDataState.data?.hintData?.length ? ( - - {!!props.valueData.length && props.singleValue ? ( - { - e.preventDefault(); - e.stopPropagation(); - }} - onMouseUp={(e) => { - e.stopPropagation(); - }} - > - - - ) : null} - {selectedConfigs.map((c, i) => { - let component: ReactNode = null; - if (c.hintData.enumHintOptions) { - component = ( - onValueSelect(sel, c.valueData)} - /> - ); - } - if (c.hintData.integerHint) { - component = ( - - onUpdateRange(range, index, min, max, c.valueData) + } + uncheckedIcon={} + onChange={() => + props.update(props.valueData ? undefined : [ANY_VALUE_DATA]) } /> - ); - } - if (!component || props.singleValue) { - return component; - } - - return ( - - {i !== 0 ? ( - - - - ) : null} - - {!!c.valueConfig.title ? ( - - {c.valueConfig.title} - - ) : null} - {component} - - - ); - })} + + ) : null} + ) : null} - + + {hasHints && (!props.title || isValid(props.valueData)) ? ( + + {!!valueDataList.length && props.singleValue ? ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onMouseUp={(e) => { + e.stopPropagation(); + }} + > + + + ) : null} + {selectedConfigs.map((c, i) => { + let component: ReactNode = null; + if (c.hintData.enumHintOptions) { + component = ( + onValueSelect(sel, c.valueData)} + /> + ); + } + if (c.hintData.integerHint) { + component = ( + + onUpdateRange(range, index, min, max, c.valueData) + } + /> + ); + } + if (!component || props.singleValue) { + return component; + } + + return ( + + {i !== 0 ? ( + + + + ) : null} + + {!!c.valueConfig.title ? ( + + {c.valueConfig.title} + + ) : null} + {component} + + + ); + })} + + + ) : null} + + ); } -export function decodeValueData(valueData?: dataProto.ValueData): ValueData { +export function decodeValueDataOptional( + valueData?: dataProto.ValueData +): ValueData | undefined { if (!valueData || !valueData.range) { - throw new Error(`Invalid value data proto ${JSON.stringify(valueData)}`); + return undefined; } return { @@ -335,7 +388,21 @@ export function decodeValueData(valueData?: dataProto.ValueData): ValueData { }; } -export function encodeValueData(valueData: ValueData): dataProto.ValueData { +export function decodeValueData(valueData?: dataProto.ValueData): ValueData { + const decoded = decodeValueDataOptional(valueData); + if (!decoded) { + throw new Error(`Invalid value data proto ${JSON.stringify(valueData)}`); + } + return decoded; +} + +export function encodeValueDataOptional( + valueData?: ValueData +): dataProto.ValueData | undefined { + if (!valueData) { + return undefined; + } + return { attribute: valueData.attribute, numeric: valueData.numeric, @@ -351,6 +418,14 @@ export function encodeValueData(valueData: ValueData): dataProto.ValueData { }; } +export function encodeValueData(valueData: ValueData): dataProto.ValueData { + const encoded = encodeValueDataOptional(valueData); + if (!encoded) { + throw new Error(`Invalid value data ${JSON.stringify(valueData)}`); + } + return encoded; +} + function defaultValueData( hintData: HintData, attribute?: underlayConfig.SZAttribute diff --git a/ui/src/overview.tsx b/ui/src/overview.tsx index 59fad4d5b..a8689a031 100644 --- a/ui/src/overview.tsx +++ b/ui/src/overview.tsx @@ -768,7 +768,7 @@ function ParticipantsGroup(props: { if (selected) { inline = plugin.renderInline(props.group.id); if (inline) { - inline = {inline}; + inline = {inline}; } else if (additionalText) { inline = ( diff --git a/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilder.java b/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilder.java index c78cd4b63..0d6abd1d2 100644 --- a/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilder.java +++ b/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilder.java @@ -7,9 +7,8 @@ import bio.terra.tanagra.proto.criteriaselector.configschema.CFEntityGroup; import bio.terra.tanagra.proto.criteriaselector.dataschema.DTEntityGroup; import bio.terra.tanagra.underlay.uiplugin.CriteriaSelector; -import java.util.HashMap; +import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.stream.Stream; public class EntityGroupFilterBuilder @@ -44,25 +43,27 @@ protected List entityGroupIds() { } @Override - protected Map selectedIdsAndEntityGroups(String serializedSelectionData) { - Map idsAndEntityGroups = new HashMap<>(); + protected List selectedIdsAndGroups(String serializedSelectionData) { + List idsAndGroups = new ArrayList<>(); DTEntityGroup.EntityGroup selectionData = deserializeData(serializedSelectionData); if (selectionData != null) { for (DTEntityGroup.EntityGroup.Selection selectedId : selectionData.getSelectedList()) { if (selectedId.hasKey()) { - idsAndEntityGroups.put( - Literal.forInt64(selectedId.getKey().getInt64Key()), selectedId.getEntityGroup()); + ValueDataOuterClass.ValueData valueData = + selectedId.hasValueData() ? selectedId.getValueData() : null; + if (valueData == null) { + // For backwards compatability, put selection level value data into the individual IDs. + // This will only be the case when there is a single selection and any updates to the + // data will migrate the value data permanently. + valueData = selectionData.hasValueData() ? selectionData.getValueData() : null; + } + idsAndGroups.add( + new SelectionItem( + Literal.forInt64(selectedId.getKey().getInt64Key()), + new SelectionGroup(selectedId.getEntityGroup(), valueData))); } } } - return idsAndEntityGroups; - } - - @Override - protected ValueDataOuterClass.ValueData valueData(String serializedSelectionData) { - DTEntityGroup.EntityGroup selectionData = deserializeData(serializedSelectionData); - return selectionData != null && selectionData.hasValueData() - ? selectionData.getValueData() - : null; + return idsAndGroups; } } diff --git a/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilderBase.java b/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilderBase.java index 203948e04..0624c9431 100644 --- a/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilderBase.java +++ b/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/EntityGroupFilterBuilderBase.java @@ -32,6 +32,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -45,21 +46,63 @@ public EntityGroupFilterBuilderBase(CriteriaSelector criteriaSelector) { super(criteriaSelector); } + protected class SelectionGroup { + public String entityGroupId; + public ValueDataOuterClass.ValueData valueData; + + public SelectionGroup(String entityGroupId, ValueDataOuterClass.ValueData valueData) { + this.entityGroupId = entityGroupId; + this.valueData = valueData; + } + + public String getEntityGroupId() { + return entityGroupId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + SelectionGroup sg = (SelectionGroup) o; + return entityGroupId.equals(sg.entityGroupId) && valueData.equals(sg.valueData); + } + + @Override + public int hashCode() { + return Objects.hash(entityGroupId, valueData); + } + } + + protected class SelectionItem { + public Literal id; + public SelectionGroup group; + + public SelectionItem(Literal id, SelectionGroup group) { + this.id = id; + this.group = group; + } + } + @Override public EntityFilter buildForCohort(Underlay underlay, List selectionData) { String criteriaSelectionData = selectionData.get(0).getPluginData(); List modifiersSelectionData = selectionData.subList(1, selectionData.size()); - // We want to build one filter per entity group, not one filter per selected id. - Map> selectedIdsPerEntityGroup = - selectedIdsPerEntityGroup(underlay, criteriaSelectionData); - List selectedEntityGroups = - selectedEntityGroups(underlay, selectedIdsPerEntityGroup); + // We want to build one filter per selection group, not one filter per selected id. + Map> selectedIdsPerGroup = + selectedIdsPerGroup(underlay, criteriaSelectionData); + List selectedGroups = selectedGroups(underlay, selectedIdsPerGroup); List entityFilters = new ArrayList<>(); - selectedEntityGroups.forEach( - entityGroup -> { - List selectedIds = selectedIdsPerEntityGroup.get(entityGroup); + selectedGroups.forEach( + selectionGroup -> { + EntityGroup entityGroup = underlay.getEntityGroup(selectionGroup.entityGroupId); + List selectedIds = selectedIdsPerGroup.get(selectionGroup); if (selectedIds == null) { selectedIds = new ArrayList<>(); } @@ -71,8 +114,8 @@ public EntityFilter buildForCohort(Underlay underlay, List select underlay, (CriteriaOccurrence) entityGroup, selectedIds, - criteriaSelectionData, - modifiersSelectionData)); + modifiersSelectionData, + selectionGroup.valueData)); break; case GROUP_ITEMS: entityFilters.add( @@ -93,20 +136,20 @@ public EntityFilter buildForCohort(Underlay underlay, List select public List buildForDataFeature( Underlay underlay, List selectionData) { String criteriaSelectionData = selectionData.get(0).getPluginData(); - - Map> selectedIdsPerEntityGroup = - selectedIdsPerEntityGroup(underlay, criteriaSelectionData); List modifiersSelectionData = selectionData.subList(1, selectionData.size()); - List selectedEntityGroups = - selectedEntityGroups(underlay, selectedIdsPerEntityGroup); + // We want to build one filter per selection group, not one filter per selected id. + Map> selectedIdsPerGroup = + selectedIdsPerGroup(underlay, criteriaSelectionData); + List selectedGroups = selectedGroups(underlay, selectedIdsPerGroup); - if (selectedIdsPerEntityGroup.isEmpty() && modifiersSelectionData.isEmpty()) { + if (selectedIdsPerGroup.isEmpty() && modifiersSelectionData.isEmpty()) { // Empty selection data = output all occurrence entities with null filters. // Use the list of all possible entity groups in the config. Set outputEntities = new HashSet<>(); - selectedEntityGroups.forEach( - entityGroup -> { + selectedGroups.forEach( + selectionGroup -> { + EntityGroup entityGroup = underlay.getEntityGroup(selectionGroup.entityGroupId); switch (entityGroup.getType()) { case CRITERIA_OCCURRENCE: CriteriaOccurrence criteriaOccurrence = (CriteriaOccurrence) entityGroup; @@ -134,18 +177,17 @@ public List buildForDataFeature( throw new InvalidQueryException("Group by modifiers are not supported for data features"); } - ValueDataOuterClass.ValueData valueData = valueData(criteriaSelectionData); - // We want to build filters per entity group, not per selected id. Map> filtersPerEntity = new HashMap<>(); - selectedEntityGroups.forEach( - entityGroup -> { - Map> filtersForSingleEntityGroup; - List selectedIds = selectedIdsPerEntityGroup.get(entityGroup); + selectedGroups.forEach( + selectionGroup -> { + EntityGroup entityGroup = underlay.getEntityGroup(selectionGroup.entityGroupId); + List selectedIds = selectedIdsPerGroup.get(selectionGroup); if (selectedIds == null) { selectedIds = new ArrayList<>(); } + Map> filtersForSingleEntityGroup; switch (entityGroup.getType()) { case CRITERIA_OCCURRENCE: CriteriaOccurrence criteriaOccurrence = (CriteriaOccurrence) entityGroup; @@ -156,7 +198,7 @@ public List buildForDataFeature( underlay, criteriaOccurrence.getOccurrenceEntities(), criteriaSelector, - valueData, + selectionGroup.valueData, modifiersSelectionData, filtersForSingleEntityGroup); break; @@ -181,7 +223,7 @@ public List buildForDataFeature( underlay, List.of(notPrimaryEntity), criteriaSelector, - valueData, + selectionGroup.valueData, modifiersSelectionData, filtersForSingleEntityGroup); break; @@ -212,42 +254,42 @@ public List buildForDataFeature( } } - private Map> selectedIdsPerEntityGroup( + private Map> selectedIdsPerGroup( Underlay underlay, String serializedSelectionData) { - Map> selectedIdsPerEntityGroup = new HashMap<>(); - Map selectedIdsAndEntityGroups = - selectedIdsAndEntityGroups(serializedSelectionData); - selectedIdsAndEntityGroups.forEach( - (key, entityGroupId) -> { - EntityGroup entityGroup = underlay.getEntityGroup(entityGroupId); + Map> selectedIdsPerGroup = new HashMap<>(); + List selectedIdsAndGroups = selectedIdsAndGroups(serializedSelectionData); + selectedIdsAndGroups.forEach( + item -> { List selectedIds = - selectedIdsPerEntityGroup.containsKey(entityGroup) - ? selectedIdsPerEntityGroup.get(entityGroup) + selectedIdsPerGroup.containsKey(item.group) + ? selectedIdsPerGroup.get(item.group) : new ArrayList<>(); - selectedIds.add(key); - selectedIdsPerEntityGroup.put(entityGroup, selectedIds); + selectedIds.add(item.id); + selectedIdsPerGroup.put(item.group, selectedIds); }); // Sort selected IDs so they're consistent for tests rather than returning them in the original // selection order. - selectedIdsPerEntityGroup.forEach((entityGroup, selectedIds) -> Collections.sort(selectedIds)); - return selectedIdsPerEntityGroup; + selectedIdsPerGroup.forEach((selectionGroup, selectedIds) -> Collections.sort(selectedIds)); + return selectedIdsPerGroup; } // Returns a list of the union of entity groups covered by the selected items or all configured // entity groups if no items are selected. - private List selectedEntityGroups( - Underlay underlay, Map> selectedIdsPerEntityGroup) { - List selectedEntityGroups; - if (!selectedIdsPerEntityGroup.isEmpty()) { - selectedEntityGroups = new ArrayList<>(selectedIdsPerEntityGroup.keySet()); + private List selectedGroups( + Underlay underlay, Map> selectedIdsPerGroup) { + List selectedGroups; + if (!selectedIdsPerGroup.isEmpty()) { + selectedGroups = new ArrayList<>(selectedIdsPerGroup.keySet()); } else { - selectedEntityGroups = - entityGroupIds().stream().map(underlay::getEntityGroup).collect(Collectors.toList()); + selectedGroups = + entityGroupIds().stream() + .map(entityGroupId -> new SelectionGroup(entityGroupId, null)) + .collect(Collectors.toList()); } - return selectedEntityGroups.stream() - .sorted(Comparator.comparing(EntityGroup::getName)) + return selectedGroups.stream() + .sorted(Comparator.comparing(SelectionGroup::getEntityGroupId)) .collect(Collectors.toList()); } @@ -255,8 +297,8 @@ private EntityFilter buildPrimaryWithCriteriaFilter( Underlay underlay, CriteriaOccurrence criteriaOccurrence, List selectedIds, - String serializedSelectionData, - List modifiersSelectionData) { + List modifiersSelectionData, + ValueDataOuterClass.ValueData valueData) { // Build the criteria sub-filter. EntityFilter criteriaSubFilter = EntityGroupFilterUtils.buildIdSubFilter( @@ -271,7 +313,6 @@ private EntityFilter buildPrimaryWithCriteriaFilter( criteriaOccurrence.getOccurrenceEntities()); // Build the instance-level modifier filters. - ValueDataOuterClass.ValueData valueData = valueData(serializedSelectionData); if (valueData != null && !IGNORED_ATTRIBUTE_NAME_UI_USE_ONLY.equalsIgnoreCase(valueData.getAttribute())) { if (criteriaOccurrence.getOccurrenceEntities().size() > 1) { @@ -348,8 +389,5 @@ private EntityFilter buildGroupItemsFilter( protected abstract List entityGroupIds(); - protected abstract Map selectedIdsAndEntityGroups( - String serializedSelectionData); - - protected abstract ValueDataOuterClass.ValueData valueData(String serializedSelectionData); + protected abstract List selectedIdsAndGroups(String serializedSelectionData); } diff --git a/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/SurveyFilterBuilder.java b/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/SurveyFilterBuilder.java index 14520813e..02635faa4 100644 --- a/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/SurveyFilterBuilder.java +++ b/underlay/src/main/java/bio/terra/tanagra/filterbuilder/impl/core/SurveyFilterBuilder.java @@ -3,14 +3,12 @@ import static bio.terra.tanagra.utils.ProtobufUtils.deserializeFromJsonOrProtoBytes; import bio.terra.tanagra.api.shared.Literal; -import bio.terra.tanagra.proto.criteriaselector.ValueDataOuterClass; import bio.terra.tanagra.proto.criteriaselector.configschema.CFSurvey; import bio.terra.tanagra.proto.criteriaselector.configschema.CFSurvey.Survey.EntityGroupConfig; import bio.terra.tanagra.proto.criteriaselector.dataschema.DTSurvey; import bio.terra.tanagra.underlay.uiplugin.CriteriaSelector; -import java.util.HashMap; +import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; public class SurveyFilterBuilder @@ -41,21 +39,21 @@ protected List entityGroupIds() { } @Override - protected Map selectedIdsAndEntityGroups(String serializedSelectionData) { - Map idsAndEntityGroups = new HashMap<>(); + protected List selectedIdsAndGroups(String serializedSelectionData) { + List idsAndGroups = new ArrayList<>(); DTSurvey.Survey selectionData = deserializeData(serializedSelectionData); - for (DTSurvey.Survey.Selection selectedId : selectionData.getSelectedList()) { - if (selectedId.hasKey()) { - idsAndEntityGroups.put( - Literal.forInt64(selectedId.getKey().getInt64Key()), selectedId.getEntityGroup()); + if (selectionData != null) { + for (DTSurvey.Survey.Selection selectedId : selectionData.getSelectedList()) { + if (selectedId.hasKey()) { + idsAndGroups.add( + new SelectionItem( + Literal.forInt64(selectedId.getKey().getInt64Key()), + new SelectionGroup( + selectedId.getEntityGroup(), + selectedId.hasValueData() ? selectedId.getValueData() : null))); + } } } - return idsAndEntityGroups; - } - - @Override - protected ValueDataOuterClass.ValueData valueData(String serializedSelectionData) { - DTSurvey.Survey selectionData = deserializeData(serializedSelectionData); - return selectionData.hasValueData() ? selectionData.getValueData() : null; + return idsAndGroups; } } diff --git a/underlay/src/main/proto/criteriaselector/dataschema/entity_group.proto b/underlay/src/main/proto/criteriaselector/dataschema/entity_group.proto index 6f90ccb64..3ec0003ec 100644 --- a/underlay/src/main/proto/criteriaselector/dataschema/entity_group.proto +++ b/underlay/src/main/proto/criteriaselector/dataschema/entity_group.proto @@ -23,10 +23,15 @@ message EntityGroup { // The entity group is stored to differentiate between them when multiple // are configured within a single criteria. string entityGroup = 3; + + // Data for additional categorical or numeric values associated with the + // selection (e.g. a measurement value). + ValueData value_data = 6; } repeated Selection selected = 1; // Data for an additional categorical or numeric value associated with the // selection (e.g. a measurement value). + // Deprecated in favor of per-selection value_data. ValueData value_data = 2; } diff --git a/underlay/src/main/proto/criteriaselector/dataschema/survey.proto b/underlay/src/main/proto/criteriaselector/dataschema/survey.proto index ec789141d..485865130 100644 --- a/underlay/src/main/proto/criteriaselector/dataschema/survey.proto +++ b/underlay/src/main/proto/criteriaselector/dataschema/survey.proto @@ -30,10 +30,17 @@ message Survey { // If the selected item is an answer, the visible name of the question it // belongs to. string question_name = 5; + + // Data for additional categorical or numeric values associated with the + // selection (e.g. a numeric answer). + ValueData value_data = 6; } repeated Selection selected = 1; // Data for an additional categorical or numeric value associated with the // selection (e.g. a numeric answer). + // Temporarily unused in favor of per-selection value_data but will + // potentially be used in future to support criteria wide values (e.g. survey + // version or date). ValueData value_data = 2; } diff --git a/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/ageAtOccurrence.json b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/ageAtOccurrence.json new file mode 100644 index 000000000..7353d7dc0 --- /dev/null +++ b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/ageAtOccurrence.json @@ -0,0 +1,3 @@ +{ + "attribute": "age_at_occurrence" +} \ No newline at end of file diff --git a/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/dateGroupByCount.json b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/dateGroupByCount.json new file mode 100644 index 000000000..61a13013a --- /dev/null +++ b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/dateGroupByCount.json @@ -0,0 +1,11 @@ +{ + "groupByCount": true, + "attributes": { + "measurementOccurrence": { + "values": [ + "measurement_datetime", + "standard_concept_name" + ] + } + } +} diff --git a/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/measurement.json b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/measurement.json new file mode 100644 index 000000000..4b2e2602d --- /dev/null +++ b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/measurement.json @@ -0,0 +1,91 @@ +{ + "columns": [ + { + "key": "name", + "widthString": "100%", + "title": "Name" + }, + { + "key": "id", + "widthDouble": 120, + "title": "Concept Id" + }, + { + "key": "standard_concept", + "widthDouble": 180, + "title": "Source/Standard" + }, + { + "key": "vocabulary_t_value", + "widthDouble": 120, + "title": "Vocab" + }, + { + "key": "concept_code", + "widthDouble": 120, + "title": "Code" + }, + { + "key": "t_rollup_count", + "widthDouble": 150, + "title": "Roll-up Count" + }, + { + "key": "t_item_count", + "widthDouble": 120, + "title": "Item Count" + } + ], + "hierarchyColumns": [ + { + "key": "concept_code", + "widthString": "25%", + "title": "Code" + }, + { + "key": "id", + "widthString": "10%", + "title": "Concept Id" + }, + { + "key": "name", + "widthString": "30%", + "title": "Name" + }, + { + "key": "t_rollup_count", + "widthString": "35%", + "title": "Count" + } + ], + "classificationEntityGroups": [ + { + "id": "measurementLoincPerson", + "sortOrder": { + "attribute": "name", + "direction": "SORT_ORDER_DIRECTION_ASCENDING" + } + }, + { + "id": "measurementSnomedPerson", + "sortOrder": { + "attribute": "name", + "direction": "SORT_ORDER_DIRECTION_ASCENDING" + } + }, + { + "id": "measurementNonHierarchyPerson" + } + ], + "multiSelect": true, + "valueConfigs": [ + { + "attribute": "value_enum", + "title": "Categorical value" + }, + { + "attribute": "value_numeric", + "title": "Numeric value" + } + ] +} diff --git a/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/selector.json b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/selector.json new file mode 100644 index 000000000..c6b8c5e8c --- /dev/null +++ b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/selector.json @@ -0,0 +1,37 @@ +{ + "name": "tanagra-measurement", + "displayName": "Labs and Measurements", + "isEnabledForCohorts": true, + "isEnabledForDataFeatureSets": true, + "display": { + "category": "Domains", + "tags": null + }, + "filterBuilder": "core.EntityGroupFilterBuilder", + "plugin": "entityGroup", + "pluginConfig": null, + "pluginConfigFile": "measurement.json", + "supportsTemporalQueries": true, + "modifiers": [ + { + "name": "ageAtOccurrence", + "displayName": "Age at occurrence", + "plugin": "attribute", + "pluginConfigFile": "ageAtOccurrence.json", + "supportsTemporalQueries": true + }, + { + "name": "visitType", + "displayName": "Visit type", + "plugin": "attribute", + "pluginConfigFile": "visitType.json", + "supportsTemporalQueries": true + }, + { + "name": "dateGroupByCount", + "displayName": "Occurrence count", + "plugin": "unhinted-value", + "pluginConfigFile": "dateGroupByCount.json" + } + ] +} \ No newline at end of file diff --git a/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/visitType.json b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/visitType.json new file mode 100644 index 000000000..6bcdcdcac --- /dev/null +++ b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/measurement/visitType.json @@ -0,0 +1,3 @@ +{ + "attribute": "visit_type" +} \ No newline at end of file diff --git a/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/selector.json b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/selector.json new file mode 100644 index 000000000..c7920bfae --- /dev/null +++ b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/selector.json @@ -0,0 +1,17 @@ +{ + "name": "tanagra-surveyBasics", + "displayName": "The Basics", + "isEnabledForCohorts": true, + "isEnabledForDataFeatureSets": true, + "display": { + "category": "Surveys", + "tags": [ + "Standard Codes" + ] + }, + "filterBuilder": "core.SurveyFilterBuilder", + "plugin": "survey", + "pluginConfig": null, + "pluginConfigFile": "surveyBasics.json", + "modifiers": [] +} diff --git a/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/surveyBasics.json b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/surveyBasics.json new file mode 100644 index 000000000..2f3af286c --- /dev/null +++ b/underlay/src/main/resources/config/criteria/aouSC2023Q3R2_testonly/criteriaselector/surveyBasics/surveyBasics.json @@ -0,0 +1,44 @@ +{ + "columns": [ + { + "key": "name", + "widthString": "100%", + "title": "Name" + }, + { + "key": "concept_id", + "widthDouble": 120, + "title": "Concept Id" + }, + { + "key": "value", + "widthDouble": 120, + "title": "Answer Concept Id" + }, + { + "key": "subtype", + "widthDouble": 180, + "title": "Type" + }, + { + "key": "t_rollup_count", + "widthDouble": 150, + "title": "Count" + } + ], + "entityGroups": [ + { + "id": "surveyBasicsPerson", + "sortOrder": { + "attribute": "id", + "direction": "SORT_ORDER_DIRECTION_ASCENDING" + } + } + ], + "valueConfigs": [ + { + "attribute": "value_numeric", + "title": "Numeric value" + } + ] +} diff --git a/underlay/src/main/resources/config/underlay/aouSC2023Q3R2_testonly/underlay.json b/underlay/src/main/resources/config/underlay/aouSC2023Q3R2_testonly/underlay.json index 7a5645bad..78098993a 100644 --- a/underlay/src/main/resources/config/underlay/aouSC2023Q3R2_testonly/underlay.json +++ b/underlay/src/main/resources/config/underlay/aouSC2023Q3R2_testonly/underlay.json @@ -117,7 +117,7 @@ "aouSR2023Q3R2/procedures", "aouSR2023Q3R2/observations", "aouSR2023Q3R2/drugs", - "aouSR2023Q3R2/measurement", + "aouSC2023Q3R2_testonly/measurement", "aouSR2023Q3R2/visits", "aouSR2023Q3R2/devices", "aouSR2023Q3R2/hasEHRData", @@ -153,7 +153,7 @@ "aouSR2023Q3R2/waistCircumference", "aouSR2023Q3R2/hipCircumference", "aouSR2023Q3R2/outputUnfiltered", - "aouSR2023Q3R2/surveyBasics", + "aouSC2023Q3R2_testonly/surveyBasics", "aouSR2023Q3R2/surveyLifestyle", "aouSR2023Q3R2/surveyOverallHealth", "aouSR2023Q3R2/surveyPersonalAndFamilyHealth", diff --git a/underlay/src/test/java/bio/terra/tanagra/filterbuilder/EntityGroupFilterBuilderForCriteriaOccurrenceTest.java b/underlay/src/test/java/bio/terra/tanagra/filterbuilder/EntityGroupFilterBuilderForCriteriaOccurrenceTest.java index e7da4c1b8..2d2ab5cad 100644 --- a/underlay/src/test/java/bio/terra/tanagra/filterbuilder/EntityGroupFilterBuilderForCriteriaOccurrenceTest.java +++ b/underlay/src/test/java/bio/terra/tanagra/filterbuilder/EntityGroupFilterBuilderForCriteriaOccurrenceTest.java @@ -403,15 +403,15 @@ void criteriaWithInstanceLevelModifierCohortFilter() { .setKey(Key.newBuilder().setInt64Key(3_004_501L).build()) .setName("Glucose [Mass/volume] in Serum or Plasma") .setEntityGroup("measurementLoincPerson") + .setValueData( + ValueData.newBuilder() + .setAttribute("value_enum") + .addSelected( + ValueData.Selection.newBuilder() + .setValue(Value.newBuilder().setInt64Value(45_884_084L).build()) + .setName("Positive") + .build())) .build()) - .setValueData( - ValueData.newBuilder() - .setAttribute("value_enum") - .addSelected( - ValueData.Selection.newBuilder() - .setValue(Value.newBuilder().setInt64Value(45_884_084L).build()) - .setName("Positive") - .build())) .build(); SelectionData entityGroupSelectionData = new SelectionData("measurement", serializeToJson(entityGroupData)); @@ -452,11 +452,11 @@ void criteriaWithInstanceLevelModifierCohortFilter() { .setKey(Key.newBuilder().setInt64Key(3_004_501L).build()) .setName("Glucose [Mass/volume] in Serum or Plasma") .setEntityGroup("measurementLoincPerson") + .setValueData( + ValueData.newBuilder() + .setAttribute("value_numeric") + .setRange(DataRange.newBuilder().setMin(0.0).setMax(250.0).build())) .build()) - .setValueData( - ValueData.newBuilder() - .setAttribute("value_numeric") - .setRange(DataRange.newBuilder().setMin(0.0).setMax(250.0).build())) .build(); entityGroupSelectionData = new SelectionData("measurement", serializeToJson(entityGroupData)); cohortFilter = filterBuilder.buildForCohort(underlay, List.of(entityGroupSelectionData)); @@ -488,6 +488,112 @@ void criteriaWithInstanceLevelModifierCohortFilter() { assertEquals(expectedCohortFilter, cohortFilter); } + @Test + void criteriaWithMultipleInstanceLevelModifierCohortFilter() { + CFEntityGroup.EntityGroup measurementConfig = CFEntityGroup.EntityGroup.newBuilder().build(); + CriteriaSelector criteriaSelector = + new CriteriaSelector( + "measurement", + true, + true, + true, + "core.EntityGroupFilterBuilder", + SZCorePlugin.ENTITY_GROUP.getIdInConfig(), + serializeToJson(measurementConfig), + List.of()); + EntityGroupFilterBuilder filterBuilder = new EntityGroupFilterBuilder(criteriaSelector); + + // Enum attribute. + DTEntityGroup.EntityGroup entityGroupData = + DTEntityGroup.EntityGroup.newBuilder() + .addSelected( + DTEntityGroup.EntityGroup.Selection.newBuilder() + .setKey(Key.newBuilder().setInt64Key(3_004_501L).build()) + .setName("Glucose [Mass/volume] in Serum or Plasma") + .setEntityGroup("measurementLoincPerson") + .setValueData( + ValueData.newBuilder() + .setAttribute("value_enum") + .addSelected( + ValueData.Selection.newBuilder() + .setValue(Value.newBuilder().setInt64Value(45_884_084L).build()) + .setName("Positive") + .build())) + .build()) + .addSelected( + DTEntityGroup.EntityGroup.Selection.newBuilder() + .setKey(Key.newBuilder().setInt64Key(3_004_501L).build()) + .setName("Glucose [Mass/volume] in Serum or Plasma") + .setEntityGroup("measurementLoincPerson") + .setValueData( + ValueData.newBuilder() + .setAttribute("value_numeric") + .setRange(DataRange.newBuilder().setMin(0.0).setMax(250.0).build())) + .build()) + .build(); + SelectionData entityGroupSelectionData = + new SelectionData("measurement", serializeToJson(entityGroupData)); + EntityFilter cohortFilter = + filterBuilder.buildForCohort(underlay, List.of(entityGroupSelectionData)); + assertNotNull(cohortFilter); + + EntityFilter expectedCriteriaSubFilter1 = + new HierarchyHasAncestorFilter( + underlay, + underlay.getEntity("measurementLoinc"), + underlay.getEntity("measurementLoinc").getHierarchy(Hierarchy.DEFAULT_NAME), + Literal.forInt64(3_004_501L)); + EntityFilter expectedInstanceLevelSubFilter1 = + new AttributeFilter( + underlay, + underlay.getEntity("measurementOccurrence"), + underlay.getEntity("measurementOccurrence").getAttribute("value_enum"), + BinaryOperator.EQUALS, + Literal.forInt64(45_884_084L)); + EntityFilter expectedCohortFilter1 = + new PrimaryWithCriteriaFilter( + underlay, + (CriteriaOccurrence) underlay.getEntityGroup("measurementLoincPerson"), + expectedCriteriaSubFilter1, + Map.of( + underlay.getEntity("measurementOccurrence"), + List.of(expectedInstanceLevelSubFilter1)), + null, + null, + null); + + EntityFilter expectedCriteriaSubFilter2 = + new HierarchyHasAncestorFilter( + underlay, + underlay.getEntity("measurementLoinc"), + underlay.getEntity("measurementLoinc").getHierarchy(Hierarchy.DEFAULT_NAME), + Literal.forInt64(3_004_501L)); + EntityFilter expectedInstanceLevelSubFilter2 = + new AttributeFilter( + underlay, + underlay.getEntity("measurementOccurrence"), + underlay.getEntity("measurementOccurrence").getAttribute("value_numeric"), + NaryOperator.BETWEEN, + List.of(Literal.forDouble(0.0), Literal.forDouble(250.0))); + EntityFilter expectedCohortFilter2 = + new PrimaryWithCriteriaFilter( + underlay, + (CriteriaOccurrence) underlay.getEntityGroup("measurementLoincPerson"), + expectedCriteriaSubFilter2, + Map.of( + underlay.getEntity("measurementOccurrence"), + List.of(expectedInstanceLevelSubFilter2)), + null, + null, + null); + + EntityFilter expectedCohortFilter = + new BooleanAndOrFilter( + BooleanAndOrFilter.LogicalOperator.OR, + List.of(expectedCohortFilter1, expectedCohortFilter2)); + assertEquals(expectedCohortFilter, cohortFilter); + } + @Test void criteriaWithAttrAndInstanceLevelAndGroupByModifiersCohortFilter() { CFAttribute.Attribute ageAtOccurrenceConfig = @@ -563,11 +669,11 @@ void criteriaWithAttrAndInstanceLevelAndGroupByModifiersCohortFilter() { .setKey(Key.newBuilder().setInt64Key(3_004_501L).build()) .setName("Glucose [Mass/volume] in Serum or Plasma") .setEntityGroup("measurementLoincPerson") + .setValueData( + ValueData.newBuilder() + .setAttribute("value_numeric") + .setRange(DataRange.newBuilder().setMin(0.0).setMax(250.0).build())) .build()) - .setValueData( - ValueData.newBuilder() - .setAttribute("value_numeric") - .setRange(DataRange.newBuilder().setMin(0.0).setMax(250.0).build())) .build(); SelectionData entityGroupSelectionData = new SelectionData("measurement", serializeToJson(entityGroupData));