diff --git a/src/actions/measurements.js b/src/actions/measurements.ts similarity index 86% rename from src/actions/measurements.js rename to src/actions/measurements.ts index 7e43a8800..2a53a6623 100644 --- a/src/actions/measurements.js +++ b/src/actions/measurements.ts @@ -1,9 +1,10 @@ import { cloneDeep, pick } from "lodash"; +import { AppDispatch, ThunkFunction } from "../store"; import { measurementIdSymbol } from "../util/globals"; -import { defaultMeasurementsControlState } from "../reducers/controls"; +import { ControlsState, defaultMeasurementsControlState, MeasurementsControlState } from "../reducers/controls"; import { getDefaultMeasurementsState } from "../reducers/measurements"; import { warningNotification } from "./notifications"; -import { +import{ APPLY_MEASUREMENTS_FILTER, CHANGE_MEASUREMENTS_COLLECTION, CHANGE_MEASUREMENTS_DISPLAY, @@ -11,6 +12,42 @@ import { TOGGLE_MEASUREMENTS_OVERALL_MEAN, TOGGLE_MEASUREMENTS_THRESHOLD, } from "./types"; +import { + Collection, + MeasurementMetadata, + MeasurementsDisplay, + MeasurementsJson, + MeasurementsState +} from "../reducers/measurements/types"; + +/** + * Temp object for groupings to keep track of values and their counts so that + * we can create a stable default order for grouping field values + */ +interface GroupingValues { + [key: string]: { + [key: string]: number + } +} + + +/* mf_ correspond to active measurements filters */ +const filterQueryPrefix = "mf_"; +type MeasurementsFilterQuery = `mf_${string}` +type QueryBoolean = "show" | "hide" +/* Measurements query parameters that are constructed and/or parsed here. */ +interface MeasurementsQuery { + m_collection?: string + m_display?: MeasurementsDisplay + m_groupBy?: string + m_overallMean?: QueryBoolean + m_threshold?: QueryBoolean + [key: MeasurementsFilterQuery]: string +} +/* Central Query type placeholder! */ +interface Query extends MeasurementsQuery { + [key: string]: unknown +} /** * Find the collection within collections that has a key matching the provided @@ -19,13 +56,12 @@ import { * If collectionKey is not provided, returns the default collection. * If no matches are found, returns the default collection. * If multiple matches are found, only returns the first matching collection. - * - * @param {Array} collections - * @param {string} collectionKey - * @param {string} defaultKey - * @returns {Object} */ -const getCollectionToDisplay = (collections, collectionKey, defaultKey) => { +const getCollectionToDisplay = ( + collections: Collection[], + collectionKey: string, + defaultKey: string +): Collection => { const defaultCollection = collections.filter((collection) => collection.key === defaultKey)[0]; if (!collectionKey) return defaultCollection; const potentialCollections = collections.filter((collection) => collection.key === collectionKey); @@ -43,7 +79,10 @@ const getCollectionToDisplay = (collections, collectionKey, defaultKey) => { * @param {Object} collection * @returns {*} */ -function getCollectionDefaultControl(controlKey, collection) { +function getCollectionDefaultControl( + controlKey: string, + collection: Collection +): string | boolean | undefined { const collectionControlToDisplayDefaults = { measurementsGroupBy: 'group_by', measurementsDisplay: 'measurements_display', @@ -106,10 +145,8 @@ function getCollectionDefaultControl(controlKey, collection) { /** * Returns the default control state for the provided collection * Returns teh default control state for the app if the collection is not loaded yet - * @param {Object} collection - * @returns {MeasurementsControlState} */ -function getCollectionDefaultControls(collection) { +function getCollectionDefaultControls(collection: Collection): MeasurementsControlState { const defaultControls = {...defaultMeasurementsControlState}; if (Object.keys(collection).length) { for (const [key, value] of Object.entries(defaultControls)) { @@ -130,7 +167,10 @@ function getCollectionDefaultControls(collection) { * @param {Object} collection * @returns {MeasurementsControlState} */ -const getCollectionDisplayControls = (controls, collection) => { +const getCollectionDisplayControls = ( + controls: ControlsState, + collection: Collection +): MeasurementsControlState => { // Copy current control options for measurements const newControls = cloneDeep(pick(controls, Object.keys(defaultMeasurementsControlState))); // Checks the current group by is available as a grouping in collection @@ -142,7 +182,7 @@ const getCollectionDisplayControls = (controls, collection) => { // Verify that current filters are valid for the new collection newControls.measurementsFilters = Object.fromEntries( Object.entries(newControls.measurementsFilters) - .map(([field, valuesMap]) => { + .map(([field, valuesMap]): [string, Map] => { // 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]) => { @@ -169,7 +209,7 @@ const getCollectionDisplayControls = (controls, collection) => { return newControls; }; -const parseMeasurementsJSON = (json) => { +const parseMeasurementsJSON = (json: MeasurementsJson): MeasurementsState => { // Avoid editing the original json values since they are cached for narratives const collections = cloneDeep(json["collections"]); if (!collections || collections.length === 0) { @@ -211,7 +251,7 @@ const parseMeasurementsJSON = (json) => { // Create a temp object for groupings to keep track of values and their // counts so that we can create a stable default order for grouping field values - const groupingsValues = collection.groupings.reduce((tempObject, {key}) => { + const groupingsValues: GroupingValues = collection.groupings.reduce((tempObject, {key}) => { tempObject[key] = {}; return tempObject; }, {}); @@ -277,7 +317,10 @@ const parseMeasurementsJSON = (json) => { } }; -export const loadMeasurements = (measurementsData, dispatch) => { +export const loadMeasurements = ( + measurementsData: MeasurementsJson | Error, + dispatch: AppDispatch +): MeasurementsState => { let measurementState = getDefaultMeasurementsState(); /* Just return default state there are no measurements data to load */ if (!measurementsData) { @@ -305,7 +348,9 @@ export const loadMeasurements = (measurementsData, dispatch) => { return measurementState; }; -export const changeMeasurementsCollection = (newCollectionKey) => (dispatch, getState) => { +export const changeMeasurementsCollection = ( + newCollectionKey: string +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const collectionToDisplay = getCollectionToDisplay(measurements.collections, newCollectionKey, measurements.defaultCollectionKey); const newControls = getCollectionDisplayControls(controls, collectionToDisplay); @@ -325,7 +370,11 @@ export const changeMeasurementsCollection = (newCollectionKey) => (dispatch, get * Tried to use lodash.cloneDeep(), but it did not work for the nested Map * - Jover, 19 January 2022 */ -export const applyMeasurementFilter = (field, value, active) => (dispatch, getState) => { +export const applyMeasurementFilter = ( + field: string, + value: MeasurementMetadata, + active: boolean +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -338,7 +387,10 @@ export const applyMeasurementFilter = (field, value, active) => (dispatch, getSt }); }; -export const removeSingleFilter = (field, value) => (dispatch, getState) => { +export const removeSingleFilter = ( + field: string, + value: MeasurementMetadata +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -357,7 +409,9 @@ export const removeSingleFilter = (field, value) => (dispatch, getState) => { }); }; -export const removeAllFieldFilters = (field) => (dispatch, getState) => { +export const removeAllFieldFilters = ( + field: string +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; delete measurementsFilters[field]; @@ -369,7 +423,10 @@ export const removeAllFieldFilters = (field) => (dispatch, getState) => { }); }; -export const toggleAllFieldFilters = (field, active) => (dispatch, getState) => { +export const toggleAllFieldFilters = ( + field: string, + active: boolean +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -383,7 +440,7 @@ export const toggleAllFieldFilters = (field, active) => (dispatch, getState) => }); }; -export const toggleOverallMean = () => (dispatch, getState) => { +export const toggleOverallMean = (): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const controlKey = "measurementsShowOverallMean"; const newControls = { [controlKey]: !controls[controlKey] }; @@ -395,7 +452,7 @@ export const toggleOverallMean = () => (dispatch, getState) => { }); } -export const toggleThreshold = () => (dispatch, getState) => { +export const toggleThreshold = (): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const controlKey = "measurementsShowThreshold"; const newControls = { [controlKey]: !controls[controlKey] }; @@ -407,7 +464,9 @@ export const toggleThreshold = () => (dispatch, getState) => { }); }; -export const changeMeasurementsDisplay = (newDisplay) => (dispatch, getState) => { +export const changeMeasurementsDisplay = ( + newDisplay: MeasurementsDisplay +): ThunkFunction => (dispatch, getState) => { const { measurements } = getState(); const controlKey = "measurementsDisplay"; const newControls = { [controlKey]: newDisplay }; @@ -419,7 +478,9 @@ export const changeMeasurementsDisplay = (newDisplay) => (dispatch, getState) => }); } -export const changeMeasurementsGroupBy = (newGroupBy) => (dispatch, getState) => { +export const changeMeasurementsGroupBy = ( + newGroupBy: string +): ThunkFunction => (dispatch, getState) => { const { measurements } = getState(); const controlKey = "measurementsGroupBy"; const newControls = { [controlKey]: newGroupBy }; @@ -438,9 +499,10 @@ const controlToQueryParamMap = { measurementsShowThreshold: "m_threshold", }; -/* mf_ correspond to active measurements filters */ -const filterQueryPrefix = "mf_"; -export function removeInvalidMeasurementsFilterQuery(query, newQueryParams) { +export function removeInvalidMeasurementsFilterQuery( + query: Query, + newQueryParams: {[key: MeasurementsFilterQuery]: string} +): Query { const newQuery = cloneDeep(query); // Remove measurements filter query params that are not included in the newQueryParams Object.keys(query) @@ -449,7 +511,11 @@ export function removeInvalidMeasurementsFilterQuery(query, newQueryParams) { return newQuery } -function createMeasurementsQueryFromControls(measurementControls, collection, defaultCollectionKey) { +function createMeasurementsQueryFromControls( + measurementControls: Partial, + collection: Collection, + defaultCollectionKey: string +): MeasurementsQuery { const newQuery = { m_collection: collection.key === defaultCollectionKey ? "" : collection.key }; @@ -505,11 +571,15 @@ function createMeasurementsQueryFromControls(measurementControls, collection, de * * In cases where the query param is invalid, the query param is removed from the * returned query object. - * @param {Object} measurements - * @param {Object} query - * @returns {Object} */ -export const combineMeasurementsControlsAndQuery = (measurements, query) => { +export const combineMeasurementsControlsAndQuery = ( + measurements: MeasurementsState, + query: Query +): { + collectionToDisplay: Collection, + collectionControls: MeasurementsControlState, + updatedQuery: Query +} => { const updatedQuery = cloneDeep(query); const collectionKeys = measurements.collections.map((collection) => collection.key); // Remove m_collection query if it's invalid or the default collection key diff --git a/src/components/controls/measurementsOptions.js b/src/components/controls/measurementsOptions.tsx similarity index 79% rename from src/components/controls/measurementsOptions.js rename to src/components/controls/measurementsOptions.tsx index 78ace0c37..7e1d78e82 100644 --- a/src/components/controls/measurementsOptions.js +++ b/src/components/controls/measurementsOptions.tsx @@ -13,15 +13,22 @@ import { controlsWidth } from "../../util/globals"; import { SidebarSubtitle, SidebarButton } from "./styles"; import Toggle from "./toggle"; import CustomSelect from "./customSelect"; +import { Collection } from "../../reducers/measurements/types"; +import { RootState } from "../../store"; + +interface SelectOption { + value: string + label: string +} /** * React Redux selector function that takes the key and title for the * available collections to create the object expected for the Select library. * The label defaults to the key if a collection does not have a set title. - * @param {Array} collections - * @returns {Array} */ -const collectionOptionsSelector = (collections) => { +const collectionOptionsSelector = ( + collections: Collection[] +): SelectOption[] => { return collections.map((collection) => { return { value: collection.key, @@ -32,15 +39,15 @@ const collectionOptionsSelector = (collections) => { const MeasurementsOptions = () => { const dispatch = useAppDispatch(); - const collection = useSelector((state) => state.measurements.collectionToDisplay); - const collectionOptions = useSelector((state) => collectionOptionsSelector(state.measurements.collections), isEqual); - const groupBy = useSelector((state) => state.controls.measurementsGroupBy); - const display = useSelector((state) => state.controls.measurementsDisplay); - const showOverallMean = useSelector((state) => state.controls.measurementsShowOverallMean); - const showThreshold = useSelector((state) => state.controls.measurementsShowThreshold); + const collection = useSelector((state: RootState) => state.measurements.collectionToDisplay); + const collectionOptions = useSelector((state: RootState) => collectionOptionsSelector(state.measurements.collections), isEqual); + const groupBy = useSelector((state: RootState) => state.controls.measurementsGroupBy); + const display = useSelector((state: RootState) => state.controls.measurementsDisplay); + const showOverallMean = useSelector((state: RootState) => state.controls.measurementsShowOverallMean); + const showThreshold = useSelector((state: RootState) => state.controls.measurementsShowThreshold); // Create grouping options for the Select library - let groupingOptions = []; + let groupingOptions: SelectOption[] = []; if (collection.groupings) { groupingOptions = [...collection.groupings.keys()].map((grouping) => { return { diff --git a/src/components/measurements/hoverPanel.js b/src/components/measurements/hoverPanel.tsx similarity index 89% rename from src/components/measurements/hoverPanel.js rename to src/components/measurements/hoverPanel.tsx index eafdbf93b..8fb478f3b 100644 --- a/src/components/measurements/hoverPanel.js +++ b/src/components/measurements/hoverPanel.tsx @@ -1,11 +1,23 @@ -import React from "react"; +import React, { CSSProperties } from "react"; import { infoPanelStyles } from "../../globalStyles"; import { InfoLine } from "../tree/infoPanels/hover"; -const HoverPanel = ({hoverData}) => { +export interface HoverData { + hoverTitle: string + mouseX: number + mouseY: number + containerId: string + data: Map +} + +const HoverPanel = ({ + hoverData +}: { + hoverData: HoverData +}) => { if (hoverData === null) return null; const { hoverTitle, mouseX, mouseY, containerId, data } = hoverData; - const panelStyle = { + const panelStyle: CSSProperties = { position: "absolute", minWidth: 200, padding: "5px", diff --git a/src/components/measurements/index.js b/src/components/measurements/index.tsx similarity index 78% rename from src/components/measurements/index.js rename to src/components/measurements/index.tsx index 9d6fe6dee..e0226d4bf 100644 --- a/src/components/measurements/index.js +++ b/src/components/measurements/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useEffect, useMemo, useState } from "react"; +import React, { CSSProperties, MutableRefObject, useCallback, useRef, useEffect, useMemo, useState } from "react"; import { useSelector } from "react-redux"; import { isEqual, orderBy } from "lodash"; import { NODE_VISIBLE } from "../../util/globals"; @@ -8,7 +8,7 @@ import ErrorBoundary from "../../util/errorBoundary"; import Flex from "../framework/flex"; import Card from "../framework/card"; import Legend from "../tree/legend/legend"; -import HoverPanel from "./hoverPanel"; +import HoverPanel, { HoverData } from "./hoverPanel"; import { createXScale, createYScale, @@ -25,16 +25,32 @@ import { layout, jitterRawMeansByColorBy } from "./measurementsD3"; +import { RootState } from "../../store"; +import { MeasurementFilters } from "../../reducers/controls"; +import { Visibility } from "../../reducers/tree/types"; +import { Measurement, MeasurementMetadata } from "../../reducers/measurements/types"; + +interface TreeStrainColors { + [strain: string]: { + attribute: string + color: string + } +} +interface TreeStrainVisibility { + [strain: string]: Visibility +} +interface TreeStrainProperties { + treeStrainVisibility: TreeStrainVisibility + treeStrainColors: TreeStrainColors +} /** * A custom React Hook that returns a memoized value that will only change * if a deep comparison using lodash.isEqual determines the value is not * equivalent to the previous value. - * @param {*} value - * @returns {*} */ -const useDeepCompareMemo = (value) => { - const ref = useRef(); +function useDeepCompareMemo(value: T): T { + const ref: MutableRefObject = useRef(); if (!isEqual(value, ref.current)) { ref.current = value; } @@ -42,7 +58,7 @@ const useDeepCompareMemo = (value) => { }; // Checks visibility against global NODE_VISIBLE -const isVisible = (visibility) => visibility === NODE_VISIBLE; +const isVisible = (visibility: Visibility): boolean => visibility === NODE_VISIBLE; /** * A custom React Redux Selector that reduces the tree redux state to an object @@ -52,13 +68,13 @@ const isVisible = (visibility) => visibility === NODE_VISIBLE; * * tree.visibility and tree.nodeColors need to be arrays that have the same * order as tree.nodes - * @param {Object} state - * @returns {Object} */ -const treeStrainPropertySelector = (state) => { +const treeStrainPropertySelector = ( + state: RootState +): TreeStrainProperties => { const { tree, controls } = state; const { colorScale } = controls; - const intitialTreeStrainProperty = { + const initialTreeStrainProperty: TreeStrainProperties = { treeStrainVisibility: {}, treeStrainColors: {} }; @@ -85,7 +101,7 @@ const treeStrainPropertySelector = (state) => { } return treeStrainProperty; - }, intitialTreeStrainProperty); + }, initialTreeStrainProperty); }; /** @@ -96,14 +112,17 @@ const treeStrainPropertySelector = (state) => { * treeStrainVisibility object for strain. * * Returns the active filters object and the filtered measurements - * @param {Array} measurements - * @param {Object} treeStrainVisibility - * @param {Object} filters - * @returns {Object} */ -const filterMeasurements = (measurements, treeStrainVisibility, filters) => { +const filterMeasurements = ( + measurements: Measurement[], + treeStrainVisibility: TreeStrainVisibility, + filters: MeasurementFilters +): { + activeFilters: {string?: MeasurementMetadata[]} + filteredMeasurements: Measurement[] +} => { // Find active filters to filter measurements - const activeFilters = {}; + const activeFilters: {string?: MeasurementMetadata[]} = {}; Object.entries(filters).forEach(([field, valuesMap]) => { activeFilters[field] = activeFilters[field] || []; valuesMap.forEach(({active}, fieldValue) => { @@ -128,25 +147,25 @@ const filterMeasurements = (measurements, treeStrainVisibility, filters) => { const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { // Use `lodash.isEqual` to deep compare object states to prevent unnecessary re-renderings of the component - const { treeStrainVisibility, treeStrainColors } = useSelector((state) => treeStrainPropertySelector(state), isEqual); - const legendValues = useSelector((state) => state.controls.colorScale.legendValues, isEqual); - const colorings = useSelector((state) => state.metadata.colorings); - const colorBy = useSelector((state) => state.controls.colorBy); - const groupBy = useSelector((state) => state.controls.measurementsGroupBy); - const filters = useSelector((state) => state.controls.measurementsFilters); - const display = useSelector((state) => state.controls.measurementsDisplay); - const showOverallMean = useSelector((state) => state.controls.measurementsShowOverallMean); - const showThreshold = useSelector((state) => state.controls.measurementsShowThreshold); - const collection = useSelector((state) => state.measurements.collectionToDisplay, isEqual); + const { treeStrainVisibility, treeStrainColors } = useSelector((state: RootState) => treeStrainPropertySelector(state), isEqual); + const legendValues = useSelector((state: RootState) => state.controls.colorScale.legendValues, isEqual); + const colorings = useSelector((state: RootState) => state.metadata.colorings); + const colorBy = useSelector((state: RootState) => state.controls.colorBy); + const groupBy = useSelector((state: RootState) => state.controls.measurementsGroupBy); + const filters = useSelector((state: RootState) => state.controls.measurementsFilters); + const display = useSelector((state: RootState) => state.controls.measurementsDisplay); + const showOverallMean = useSelector((state: RootState) => state.controls.measurementsShowOverallMean); + const showThreshold = useSelector((state: RootState) => state.controls.measurementsShowThreshold); + const collection = useSelector((state: RootState) => state.measurements.collectionToDisplay, isEqual); const { title, x_axis_label, thresholds, fields, measurements, groupings } = collection; // Ref to access the D3 SVG - const svgContainerRef = useRef(null); - const d3Ref = useRef(null); - const d3XAxisRef = useRef(null); + const svgContainerRef: MutableRefObject = useRef(null); + const d3Ref: MutableRefObject = useRef(null); + const d3XAxisRef: MutableRefObject = useRef(null); // State for storing data for the HoverPanel - const [hoverData, setHoverData] = useState(null); + const [hoverData, setHoverData] = useState(null); // Filter and group measurements const {activeFilters, filteredMeasurements} = filterMeasurements(measurements, treeStrainVisibility, filters); @@ -182,7 +201,13 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { groupedMeasurements }); // Cache handleHover function to avoid extra useEffect calls - const handleHover = useCallback((data, dataType, mouseX, mouseY, colorByAttr=null) => { + const handleHover = useCallback(( + data: Measurement | { mean: number, standardDeviation: number }, + dataType: "measurement" | "mean", + mouseX: number, + mouseY: number, + colorByAttr: string = null + ): void => { let newHoverData = null; if (data !== null) { // Set color-by attribute as title if provided @@ -255,7 +280,7 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { toggleDisplay(d3Ref.current, "threshold", showThreshold); }, [svgData, showThreshold]); - const getSVGContainerStyle = () => { + const getSVGContainerStyle = (): CSSProperties => { return { overflowY: "auto", position: "relative", @@ -268,7 +293,7 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { * Sticky x-axis with a set height to make sure the x-axis is always * at the bottom of the measurements panel */ - const getStickyXAxisSVGStyle = () => { + const getStickyXAxisSVGStyle = (): CSSProperties => { return { width: "100%", height: layout.xAxisHeight, @@ -282,7 +307,7 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { * allow x-axis to fit in the bottom of the panel when scrolling all the way * to the bottom of the measurements SVG */ - const getMainSVGStyle = () => { + const getMainSVGStyle = (): CSSProperties => { return { width: "100%", position: "relative", @@ -320,9 +345,9 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { }; const Measurements = ({height, width, showLegend}) => { - const measurementsLoaded = useSelector((state) => state.measurements.loaded); - const measurementsError = useSelector((state) => state.measurements.error); - const showOnlyPanels = useSelector((state) => state.controls.showOnlyPanels); + const measurementsLoaded = useSelector((state: RootState) => state.measurements.loaded); + const measurementsError = useSelector((state: RootState) => state.measurements.error); + const showOnlyPanels = useSelector((state: RootState) => state.controls.showOnlyPanels); const [title, setTitle] = useState("Measurements"); diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index c04271d0c..81c3c6c9f 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -13,6 +13,7 @@ import { calcBrowserDimensionsInitialState } from "./browserDimensions"; import { doesColorByHaveConfidence } from "../actions/recomputeReduxState"; import { hasMultipleGridPanels } from "../actions/panelDisplay"; import { Distance } from "../components/tree/phyloTree/types"; +import { MeasurementMetadata, MeasurementsDisplay } from "./measurements/types"; export interface ColorScale { @@ -151,14 +152,15 @@ export interface BasicControlsState { zoomMin?: number } +export interface MeasurementFilters { + [key: string]: Map +} export interface MeasurementsControlState { measurementsGroupBy: string | undefined, - measurementsDisplay: string | undefined, + measurementsDisplay: MeasurementsDisplay | undefined, measurementsShowOverallMean: boolean | undefined, measurementsShowThreshold: boolean | undefined, - measurementsFilters: { - [key: string]: Map - } + measurementsFilters: MeasurementFilters } export interface ControlsState extends BasicControlsState, MeasurementsControlState {} diff --git a/src/reducers/measurements/index.ts b/src/reducers/measurements/index.ts index f7805e951..e0027d86b 100644 --- a/src/reducers/measurements/index.ts +++ b/src/reducers/measurements/index.ts @@ -1,28 +1,35 @@ +import { AnyAction } from "@reduxjs/toolkit"; import { CHANGE_MEASUREMENTS_COLLECTION, CLEAN_START, URL_QUERY_CHANGE_WITH_COMPUTED_STATE -} from "../actions/types"; +} from "../../actions/types"; +import { MeasurementsState } from "./types"; -export const getDefaultMeasurementsState = () => ({ +export const getDefaultMeasurementsState = (): MeasurementsState => ({ error: undefined, loaded: false, - defaultCollectionKey: "", - collections: [], - collectionToDisplay: {} + defaultCollectionKey: undefined, + collections: undefined, + collectionToDisplay: undefined }); -const measurements = (state = getDefaultMeasurementsState(), action) => { +const measurements = ( + state: MeasurementsState = getDefaultMeasurementsState(), + action: AnyAction, +): MeasurementsState => { switch (action.type) { case CLEAN_START: // fallthrough case URL_QUERY_CHANGE_WITH_COMPUTED_STATE: - return { ...action.measurements }; + return action.measurements; case CHANGE_MEASUREMENTS_COLLECTION: - return { - ...state, - loaded: true, - collectionToDisplay: action.collectionToDisplay - }; + if (state.loaded) { + return { + ...state, + collectionToDisplay: action.collectionToDisplay + }; + } + return state; default: return state; } diff --git a/src/reducers/measurements/types.ts b/src/reducers/measurements/types.ts new file mode 100644 index 000000000..d4c9edce7 --- /dev/null +++ b/src/reducers/measurements/types.ts @@ -0,0 +1,79 @@ +import { measurementIdSymbol } from "../../util/globals"; + +// -- Measurements JSON types -- // + +/** + * Measurements are allowed to have arbitrary metadata. + * Matching types allowed in Augur's measurements schema + * + */ +export type MeasurementMetadata = string | number | boolean +export type MeasurementsDisplay = 'raw' | 'mean' + +interface JsonMeasurement { + readonly strain: string + readonly value: number + readonly [key: string]: MeasurementMetadata +} + +interface JsonCollectionDisplayDefaults { + readonly group_by?: string + readonly measurements_display?: MeasurementsDisplay + readonly show_overall_mean?: boolean + readonly show_threshold?: boolean +} + +interface JsonCollectionField { + readonly key: string + readonly title?: string +} + +interface JsonCollectionGrouping { + readonly key: string + readonly order?: MeasurementMetadata[] +} + +export interface JsonCollection { + readonly display_defaults?: JsonCollectionDisplayDefaults + readonly fields?: JsonCollectionField[] + readonly filters?: string[] + readonly groupings: JsonCollectionGrouping[] + readonly key: string + readonly measurements: JsonMeasurement[] + readonly threshold?: number + readonly thresholds?: number[] + readonly title?: string + readonly x_axis_label: string +} + +export interface MeasurementsJson { + readonly collections: JsonCollection[] + readonly default_collection?: string +} + +// -- Measurements state types -- // + +export interface Measurement extends JsonMeasurement { + [measurementIdSymbol]: number +} + +export interface Collection { + // TODO: Convert this to MeasurementsControlState during parseMeasurementsJSON + display_defaults?: JsonCollectionDisplayDefaults + fields: Map + filters: Map}> + groupings: Map + key: string + measurements: Measurement[] + thresholds?: number[] + title?: string + x_axis_label: string +} + +export interface MeasurementsState { + loaded: boolean + error: string | undefined + collections: Collection[] | undefined + collectionToDisplay: Collection | undefined + defaultCollectionKey: string | undefined +}