Skip to content

Commit

Permalink
measurements: Add URL query param mf_<field>
Browse files Browse the repository at this point in the history
Use query param `mf_<field>=<value>` to specify active filters for
the measurements panel. Invalid fields or values are ignored and
removed.

Multiple values for the same field are expected to give the query param
multiple times with different values, e.g.
`mf_reference_strain=A/Stockholm/18/2011&mf_reference_strain=A/Alabama/5/2010`
This is slightly different than the behavior of the tree filter param,
but I'm following this pattern to avoid the issue described in
<#1846>
  • Loading branch information
joverlee521 committed Sep 9, 2024
1 parent 1ad12c4 commit 051d432
Show file tree
Hide file tree
Showing 6 changed files with 81 additions and 32 deletions.
94 changes: 69 additions & 25 deletions src/actions/measurements.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { pick } from "lodash";
import { cloneDeep, pick } from "lodash";
import { measurementIdSymbol } from "../util/globals";
import { defaultMeasurementsControlState, MeasurementsControlState } from "../reducers/controls";
import {
Expand Down Expand Up @@ -106,28 +106,30 @@ function getCollectionDefaultControl(controlKey, collection) {
*/
const getCollectionDisplayControls = (controls, collection) => {
// Copy current control options for measurements
const newControls = pick(controls, Object.keys(defaultMeasurementsControlState));
const newControls = cloneDeep(pick(controls, Object.keys(defaultMeasurementsControlState)));
// Checks the current group by is available as a grouping in collection
// If it doesn't exist, set to undefined so it will get filled in with collection's default
if (!collection.groupings.has(newControls.measurementsGroupBy)) {
newControls.measurementsGroupBy = undefined
}

// Verify that current filters are valid for the new collection
Object.entries(newControls.measurementsFilters).forEach(([field, valuesMap]) => {
// Delete filter for field that does not exist in the new collection filters
if (!collection.filters.has(field)) {
return delete newControls.measurementsFilters[field];
}
// Clone nested Map to avoid changing redux state in place
newControls.measurementsFilters[field] = new Map(valuesMap);
return [...valuesMap.keys()].forEach((value) => {
// Delete filter for values that do not exist within the field of the new collection
if (!collection.filters.get(field).values.has(value)) {
newControls.measurementsFilters[field].delete(value);
}
});
});
newControls.measurementsFilters = Object.fromEntries(
Object.entries(newControls.measurementsFilters)
.map(([field, valuesMap]) => {
// Clone nested Map to avoid changing redux state in place
// Delete filter for values that do not exist within the field of the new collection
const newValuesMap = new Map([...valuesMap].filter(([value]) => {
return collection.filters.get(field)?.values.has(value)
}));
return [field, newValuesMap];
})
.filter(([field, valuesMap]) => {
// Delete filter for field that does not exist in the new collection filters
// or filters where none of the values are valid
return collection.filters.has(field) && valuesMap.size;
})
)

// Ensure controls use collection's defaults or app defaults if this is
// the initial loading of the measurements JSON
Expand Down Expand Up @@ -278,19 +280,20 @@ export const changeMeasurementsCollection = (newCollectionKey) => (dispatch, get
* - Jover, 19 January 2022
*/
export const applyMeasurementFilter = (field, value, active) => (dispatch, getState) => {
const { controls } = getState();
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
measurementsFilters[field].set(value, {active});

dispatch({
type: APPLY_MEASUREMENTS_FILTER,
data: measurementsFilters
controls: { measurementsFilters },
queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay)
});
};

export const removeSingleFilter = (field, value) => (dispatch, getState) => {
const { controls } = getState();
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
measurementsFilters[field].delete(value);
Expand All @@ -303,31 +306,34 @@ export const removeSingleFilter = (field, value) => (dispatch, getState) => {

dispatch({
type: APPLY_MEASUREMENTS_FILTER,
data: measurementsFilters
controls: { measurementsFilters },
queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay)
});
};

export const removeAllFieldFilters = (field) => (dispatch, getState) => {
const { controls } = getState();
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
delete measurementsFilters[field];

dispatch({
type: APPLY_MEASUREMENTS_FILTER,
data: measurementsFilters
controls: { measurementsFilters },
queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay)
});
};

export const toggleAllFieldFilters = (field, active) => (dispatch, getState) => {
const { controls } = getState();
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
for (const fieldValue of measurementsFilters[field].keys()) {
measurementsFilters[field].set(fieldValue, {active});
}
dispatch({
type: APPLY_MEASUREMENTS_FILTER,
data: measurementsFilters
controls: { measurementsFilters },
queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay)
});
};

Expand Down Expand Up @@ -386,10 +392,21 @@ const controlToQueryParamMap = {
measurementsShowThreshold: "m_threshold",
};

/* mf_<field> correspond to active measurements filters */
const filterQueryPrefix = "mf_";
export function removeInvalidMeasurementsFilterQuery(query, newQueryParams) {
const newQuery = cloneDeep(query);
// Remove measurements filter query params that are not included in the newQueryParams
Object.keys(query)
.filter((queryParam) => queryParam.startsWith(filterQueryPrefix) && !(queryParam in newQueryParams))
.forEach((queryParam) => delete newQuery[queryParam]);
return newQuery
}

function createMeasurementsQueryFromControls(measurementControls, collection) {
const newQuery = {};
for (const [controlKey, controlValue] of Object.entries(measurementControls)) {
const queryKey = controlToQueryParamMap[controlKey];
let queryKey = controlToQueryParamMap[controlKey];
const collectionDefault = getCollectionDefaultControl(controlKey, collection);
const controlDefault = collectionDefault !== undefined ? collectionDefault : defaultMeasurementsControlState[controlKey];
// Remove URL param if control state is the same as the default state
Expand All @@ -411,6 +428,21 @@ function createMeasurementsQueryFromControls(measurementControls, collection) {
newQuery[queryKey] = "";
}
break;
case "measurementsFilters":
// First clear all of the measurements filter query params
for (const field of collection.filters.keys()) {
queryKey = filterQueryPrefix + field;
newQuery[queryKey] = "";
}
// Then add back measurements filter query params for active filters only
for (const [field, values] of Object.entries(controlValue)) {
queryKey = filterQueryPrefix + field;
const activeFilterValues = [...values]
.filter(([fieldValue, {active}]) => active)
.map(([fieldValue]) => fieldValue);
newQuery[queryKey] = activeFilterValues;
}
break;
default:
console.error(`Ignoring unsupported control ${controlKey}`);
}
Expand Down Expand Up @@ -450,5 +482,17 @@ export function createMeasurementsControlsFromQuery(query){
console.error(`Ignoring invalid query param ${queryKey}=${queryValue}, value should be one of ${expectedValues}`);
}
}

// Accept any value here because we cannot validate the query before the measurements JSON is loaded
for (const filterKey of Object.keys(query).filter((c) => c.startsWith(filterQueryPrefix))) {
const field = filterKey.replace(filterQueryPrefix, '');
const filterValues = Array.isArray(query[filterKey]) ? query[filterKey] : [query[filterKey]];
const measurementsFilters = {...newState.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
for (const value of filterValues) {
measurementsFilters[field].set(value, {active: true});
}
newState.measurementsFilters = measurementsFilters;
}
return newState;
}
2 changes: 1 addition & 1 deletion src/actions/recomputeReduxState.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ const modifyStateViaURLQuery = (state, query) => {
if (query.scatterX) state.scatterVariables.x = query.scatterX;
if (query.scatterY) state.scatterVariables.y = query.scatterY;

/* Process query params for measurements panel. These all start with `m_` prefix to avoid conflicts */
/* Process query params for measurements panel. These all start with `m_` or `mf_` prefix to avoid conflicts */
state = {...state, ...createMeasurementsControlsFromQuery(query)}

return state;
Expand Down
3 changes: 2 additions & 1 deletion src/components/controls/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class FilterData extends React.Component {
...(this.props.totalStateCounts[traitName]?.keys() || []),
...(this.props.totalStateCountsSecondTree?.[traitName]?.keys() || []),
]);

this.props.totalStateCounts[traitName];
const traitTitle = this.getFilterTitle(traitName);
const filterValuesCurrentlyActive = new Set((this.props.activeFilters[traitName] || []).filter((x) => x.active).map((x) => x.value));
Expand Down Expand Up @@ -175,6 +175,7 @@ class FilterData extends React.Component {
});
}
summariseMeasurementsFilters = () => {
if (this.props.measurementsFieldsMap === undefined) return [];
return Object.entries(this.props.measurementsFilters).map(([field, valuesMap]) => {
const activeFiltersCount = Array.from(valuesMap.values()).reduce((prevCount, currentValue) => {
return currentValue.active ? prevCount + 1 : prevCount;
Expand Down
2 changes: 1 addition & 1 deletion src/components/info/filtersSummary.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ class FiltersSummary extends React.Component {
{". "}
</>
}
{Object.keys(this.props.measurementsFilters).length > 0 &&
{(Object.keys(this.props.measurementsFilters).length > 0 && this.props.measurementsFields !== undefined) &&
<>
<br/>
{t("Measurements filtered to") + " "}
Expand Down
7 changes: 6 additions & 1 deletion src/middleware/changeURL.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { numericToCalendar } from "../util/dateHelpers";
import { shouldDisplayTemporalConfidence } from "../reducers/controls";
import { genotypeSymbol, nucleotide_gene, strainSymbol } from "../util/globals";
import { encodeGenotypeFilters, decodeColorByGenotype, isColorByGenotype } from "../util/getGenotype";
import { removeInvalidMeasurementsFilterQuery } from "../actions/measurements";

export const strainSymbolUrlString = "__strain__";

Expand Down Expand Up @@ -224,10 +225,14 @@ export const changeURLMiddleware = (store) => (next) => (action) => {
}
case types.LOAD_MEASUREMENTS: // fallthrough
case types.CHANGE_MEASUREMENTS_COLLECTION: // fallthrough
case types.APPLY_MEASUREMENTS_FILTER:
query = removeInvalidMeasurementsFilterQuery(query, action.queryParams)
query = {...query, ...action.queryParams};
break;
case types.CHANGE_MEASUREMENTS_DISPLAY: // fallthrough
case types.CHANGE_MEASUREMENTS_GROUP_BY: // fallthrough
case types.TOGGLE_MEASUREMENTS_OVERALL_MEAN: // fallthrough
case types.TOGGLE_MEASUREMENTS_THRESHOLD:
case types.TOGGLE_MEASUREMENTS_THRESHOLD: // fallthrough
query = {...query, ...action.queryParams};
break;
default:
Expand Down
5 changes: 2 additions & 3 deletions src/reducers/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,9 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con
case types.CHANGE_MEASUREMENTS_DISPLAY: // fallthrough
case types.CHANGE_MEASUREMENTS_GROUP_BY: // fallthrough
case types.TOGGLE_MEASUREMENTS_OVERALL_MEAN: // fallthrough
case types.TOGGLE_MEASUREMENTS_THRESHOLD:
return {...state, ...action.controls};
case types.TOGGLE_MEASUREMENTS_THRESHOLD: // fallthrough
case types.APPLY_MEASUREMENTS_FILTER:
return {...state, measurementsFilters: action.data};
return {...state, ...action.controls};
/**
* Currently the CHANGE_ZOOM action (entropy panel zoom changed) does not
* update the zoomMin/zoomMax, and as such they only represent the initially
Expand Down

0 comments on commit 051d432

Please sign in to comment.