diff --git a/packages/libs/eda/src/lib/core/api/DataClient/types.ts b/packages/libs/eda/src/lib/core/api/DataClient/types.ts index cc310577f3..98cd72ac99 100755 --- a/packages/libs/eda/src/lib/core/api/DataClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/types.ts @@ -767,6 +767,12 @@ export const BinDefinitions = array( }) ); +export type AllValuesDefinition = TypeOf; +export const AllValuesDefinition = type({ + label: string, + count: number, +}); + export type OverlayConfig = TypeOf; export const OverlayConfig = intersection([ type({ @@ -793,7 +799,7 @@ export interface StandaloneMapMarkersRequestParams { geoAggregateVariable: VariableDescriptor; latitudeVariable: VariableDescriptor; longitudeVariable: VariableDescriptor; - overlayConfig?: OverlayConfig; + overlayConfig?: Omit; valueSpec: 'count' | 'proportion'; viewport: { latitude: { diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index 14ee42ccb4..dceeef3dd5 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -1,7 +1,9 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { + AllValuesDefinition, AnalysisState, + CategoricalVariableDataShape, DEFAULT_ANALYSIS_NAME, EntityDiagram, OverlayConfig, @@ -85,8 +87,17 @@ import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers'; import { TabbedDisplayProps } from '@veupathdb/coreui/lib/components/grids/TabbedDisplay'; import { GeoConfig } from '../../core/types/geoConfig'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; -import DonutMarkerComponent from '@veupathdb/components/lib/map/DonutMarker'; -import ChartMarkerComponent from '@veupathdb/components/lib/map/ChartMarker'; +import DonutMarkerComponent, { + DonutMarkerProps, + DonutMarkerStandalone, +} from '@veupathdb/components/lib/map/DonutMarker'; +import ChartMarkerComponent, { + ChartMarkerProps, + ChartMarkerStandalone, +} from '@veupathdb/components/lib/map/ChartMarker'; +import { sharedStandaloneMarkerProperties } from './MarkerConfiguration/CategoricalMarkerPreview'; +import { mFormatter, kFormatter } from '../../core/utils/big-number-formatters'; +import { getCategoricalValues } from './utils/categoricalValues'; enum MapSideNavItemLabels { Download = 'Download', @@ -96,7 +107,7 @@ enum MapSideNavItemLabels { Share = 'Share', StudyDetails = 'View Study Details', MyAnalyses = 'My Analyses', - MapType = 'Map Type', + ConfigureMap = 'Configure Map', } enum MarkerTypeLabels { @@ -272,21 +283,102 @@ function MapAnalysisImpl(props: ImplProps) { [markerConfigurations, setMarkerConfigurations] ); + const filtersIncludingViewport = useMemo(() => { + const viewportFilters = appState.boundsZoomLevel + ? filtersFromBoundingBox( + appState.boundsZoomLevel.bounds, + { + variableId: geoConfig.latitudeVariableId, + entityId: geoConfig.entity.id, + }, + { + variableId: geoConfig.longitudeVariableId, + entityId: geoConfig.entity.id, + } + ) + : []; + return [ + ...(props.analysisState.analysis?.descriptor.subset.descriptor ?? []), + ...viewportFilters, + ]; + }, [ + appState.boundsZoomLevel, + geoConfig.entity.id, + geoConfig.latitudeVariableId, + geoConfig.longitudeVariableId, + props.analysisState.analysis?.descriptor.subset.descriptor, + ]); + + const allFilteredCategoricalValues = usePromise( + useCallback(async (): Promise => { + /** + * We only need this data for categorical vars, so we can return early if var isn't categorical + */ + if ( + !overlayVariable || + !CategoricalVariableDataShape.is(overlayVariable.dataShape) + ) + return; + return getCategoricalValues({ + overlayEntity, + subsettingClient, + studyId, + overlayVariable, + filters, + }); + }, [overlayEntity, overlayVariable, subsettingClient, studyId, filters]) + ); + + const allVisibleCategoricalValues = usePromise( + useCallback(async (): Promise => { + /** + * Return early if: + * - overlay var isn't categorical + * - "Show counts for" toggle isn't set to 'visible' + */ + if ( + !overlayVariable || + !CategoricalVariableDataShape.is(overlayVariable.dataShape) || + activeMarkerConfiguration?.selectedCountsOption !== 'visible' + ) + return; + + return getCategoricalValues({ + overlayEntity, + subsettingClient, + studyId, + overlayVariable, + filters: filtersIncludingViewport, + }); + }, [ + overlayEntity, + overlayVariable, + subsettingClient, + studyId, + filtersIncludingViewport, + activeMarkerConfiguration?.selectedCountsOption, + ]) + ); + // If the variable or filters have changed on the active marker config // get the default overlay config. const activeOverlayConfig = usePromise( useCallback(async (): Promise => { - // TODO Use `selectedValues` to generate the overlay config. Something like this: - // if (activeMarkerConfiguration?.selectedValues) { - // return { - // overlayType: CategoryVariableDataShape.is(overlayVariable?.dataShape) ? 'categorical' : 'continuous', - // overlayVariable: { - // variableId: overlayVariable.id, - // entityId: overlayEntity.id, - // }, - // overlayValues: activeMarkerConfiguration.selectedValues - // } as OverlayConfig - // } + // Use `selectedValues` to generate the overlay config for categorical variables + if ( + activeMarkerConfiguration?.selectedValues && + CategoricalVariableDataShape.is(overlayVariable?.dataShape) + ) { + return { + overlayType: 'categorical', + overlayVariable: { + variableId: overlayVariable?.id, + entityId: overlayEntity?.id, + }, + overlayValues: activeMarkerConfiguration.selectedValues, + } as OverlayConfig; + } + return getDefaultOverlayConfig({ studyId, filters, @@ -294,6 +386,7 @@ function MapAnalysisImpl(props: ImplProps) { overlayEntity, dataClient, subsettingClient, + binningMethod: activeMarkerConfiguration?.binningMethod, }); }, [ dataClient, @@ -302,6 +395,8 @@ function MapAnalysisImpl(props: ImplProps) { overlayVariable, studyId, subsettingClient, + activeMarkerConfiguration?.selectedValues, + activeMarkerConfiguration?.binningMethod, ]) ); @@ -333,9 +428,73 @@ function MapAnalysisImpl(props: ImplProps) { selectedOverlayVariable: activeMarkerConfiguration?.selectedVariable, overlayConfig: activeOverlayConfig.value, outputEntityId: outputEntity?.id, - //TO DO: maybe dependentAxisLogScale + dependentAxisLogScale: + activeMarkerConfiguration && + 'dependentAxisLogScale' in activeMarkerConfiguration + ? activeMarkerConfiguration.dependentAxisLogScale + : false, }); + const { markersData: previewMarkerData } = useStandaloneMapMarkers({ + boundsZoomLevel: undefined, + geoConfig: geoConfig, + studyId, + filters, + markerType, + selectedOverlayVariable: activeMarkerConfiguration?.selectedVariable, + overlayConfig: activeOverlayConfig.value, + outputEntityId: outputEntity?.id, + }); + + const continuousMarkerPreview = useMemo(() => { + if (!previewMarkerData || !previewMarkerData.length) return; + const initialDataObject = previewMarkerData[0].data.map((data) => ({ + label: data.label, + value: 0, + ...(data.color ? { color: data.color } : {}), + })); + const typedData = + markerType === 'pie' + ? ([...previewMarkerData] as DonutMarkerProps[]) + : ([...previewMarkerData] as ChartMarkerProps[]); + const finalData = typedData.reduce( + (prevData, currData) => + currData.data.map((data, index) => ({ + label: data.label, + value: data.value + prevData[index].value, + ...('color' in prevData[index] + ? { color: prevData[index].color } + : 'color' in data + ? { color: data.color } + : {}), + })), + initialDataObject + ); + if (markerType === 'pie') { + return ( + p + c.value, 0))} + {...sharedStandaloneMarkerProperties} + /> + ); + } else { + return ( + p + c.value, 0))} + dependentAxisLogScale={ + activeMarkerConfiguration && + 'dependentAxisLogScale' in activeMarkerConfiguration + ? activeMarkerConfiguration.dependentAxisLogScale + : false + } + {...sharedStandaloneMarkerProperties} + /> + ); + } + }, [previewMarkerData]); + const markers = useMemo( () => markersData?.map((markerProps) => @@ -467,13 +626,13 @@ function MapAnalysisImpl(props: ImplProps) { const sideNavigationButtonConfigurationObjects: SideNavigationItemConfigurationObject[] = [ { - labelText: MapSideNavItemLabels.MapType, + labelText: MapSideNavItemLabels.ConfigureMap, icon: , isExpandable: true, subMenuConfig: [ { // concatenating the parent and subMenu labels creates a unique ID - id: MapSideNavItemLabels.MapType + MarkerTypeLabels.pie, + id: MapSideNavItemLabels.ConfigureMap + MarkerTypeLabels.pie, labelText: MarkerTypeLabels.pie, icon: , onClick: () => setActiveMarkerConfigurationType('pie'), @@ -481,7 +640,7 @@ function MapAnalysisImpl(props: ImplProps) { }, { // concatenating the parent and subMenu labels creates a unique ID - id: MapSideNavItemLabels.MapType + MarkerTypeLabels.barplot, + id: MapSideNavItemLabels.ConfigureMap + MarkerTypeLabels.barplot, labelText: MarkerTypeLabels.barplot, icon: , onClick: () => setActiveMarkerConfigurationType('barplot'), @@ -516,6 +675,18 @@ function MapAnalysisImpl(props: ImplProps) { } toggleStarredVariable={toggleStarredVariable} constraints={markerVariableConstraints} + overlayConfiguration={activeOverlayConfig.value} + overlayVariable={overlayVariable} + subsettingClient={subsettingClient} + studyId={studyId} + filters={filters} + allFilteredCategoricalValues={ + allFilteredCategoricalValues.value + } + allVisibleCategoricalValues={ + allVisibleCategoricalValues.value + } + continuousMarkerPreview={continuousMarkerPreview} /> ) : ( <> @@ -541,6 +712,18 @@ function MapAnalysisImpl(props: ImplProps) { toggleStarredVariable={toggleStarredVariable} configuration={activeMarkerConfiguration} constraints={markerVariableConstraints} + overlayConfiguration={activeOverlayConfig.value} + overlayVariable={overlayVariable} + subsettingClient={subsettingClient} + studyId={studyId} + filters={filters} + allFilteredCategoricalValues={ + allFilteredCategoricalValues.value + } + allVisibleCategoricalValues={ + allVisibleCategoricalValues.value + } + continuousMarkerPreview={continuousMarkerPreview} /> ) : ( <> @@ -793,7 +976,7 @@ function MapAnalysisImpl(props: ImplProps) { function isMapTypeSubMenuItemSelected() { const mapTypeSideNavObject = sideNavigationButtonConfigurationObjects.find( - (navObject) => navObject.labelText === MapSideNavItemLabels.MapType + (navObject) => navObject.labelText === MapSideNavItemLabels.ConfigureMap ); if ( mapTypeSideNavObject && @@ -826,7 +1009,7 @@ function MapAnalysisImpl(props: ImplProps) { MarkerTypeLabels[appState.activeMarkerConfigurationType] ) return ( - MapSideNavItemLabels.MapType + + MapSideNavItemLabels.ConfigureMap + MarkerTypeLabels[appState.activeMarkerConfigurationType] ); @@ -840,32 +1023,6 @@ function MapAnalysisImpl(props: ImplProps) { const toggleStarredVariable = useToggleStarredVariable(analysisState); - const filtersIncludingViewport = useMemo(() => { - const viewportFilters = appState.boundsZoomLevel - ? filtersFromBoundingBox( - appState.boundsZoomLevel.bounds, - { - variableId: geoConfig.latitudeVariableId, - entityId: geoConfig.entity.id, - }, - { - variableId: geoConfig.longitudeVariableId, - entityId: geoConfig.entity.id, - } - ) - : []; - return [ - ...(props.analysisState.analysis?.descriptor.subset.descriptor ?? []), - ...viewportFilters, - ]; - }, [ - appState.boundsZoomLevel, - geoConfig.entity.id, - geoConfig.latitudeVariableId, - geoConfig.longitudeVariableId, - props.analysisState.analysis?.descriptor.subset.descriptor, - ]); - const [sideNavigationIsExpanded, setSideNavigationIsExpanded] = useState(true); diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx index fb50e124cd..d3d3f376d7 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx @@ -1,22 +1,35 @@ -import { H6 } from '@veupathdb/coreui'; +import { useState, useEffect, useCallback } from 'react'; import { InputVariables, Props as InputVariablesProps, } from '../../../core/components/visualizations/InputVariables'; import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; -import { VariableDescriptor } from '../../../core/types/variable'; import { VariablesByInputName } from '../../../core/utils/data-element-constraints'; -import { BinDefinitions } from '../../../core'; +import { + usePromise, + AllValuesDefinition, + OverlayConfig, + Variable, + Filter, +} from '../../../core'; +import { CategoricalMarkerConfigurationTable } from './CategoricalMarkerConfigurationTable'; +import { CategoricalMarkerPreview } from './CategoricalMarkerPreview'; +import Barplot from '@veupathdb/components/lib/plots/Barplot'; +import { SubsettingClient } from '../../../core/api'; +import LabelledGroup from '@veupathdb/components/lib/components/widgets/LabelledGroup'; +import { Toggle } from '@veupathdb/coreui'; +import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; +import { useUncontrolledSelections } from '../hooks/uncontrolledSelections'; interface MarkerConfiguration { type: T; } export interface BarPlotMarkerConfiguration - extends MarkerConfiguration<'barplot'> { - selectedVariable: VariableDescriptor; - selectedValues: BinDefinitions | undefined; + extends MarkerConfiguration<'barplot'>, + SharedMarkerConfigurations { selectedPlotMode: 'count' | 'proportion'; + dependentAxisLogScale: boolean; } interface Props @@ -26,8 +39,24 @@ interface Props > { onChange: (configuration: BarPlotMarkerConfiguration) => void; configuration: BarPlotMarkerConfiguration; + overlayConfiguration: OverlayConfig | undefined; + overlayVariable: Variable | undefined; + subsettingClient: SubsettingClient; + studyId: string; + filters: Filter[] | undefined; + continuousMarkerPreview: JSX.Element | undefined; + /** + * Always used for categorical marker preview. Also used in categorical table if selectedCountsOption is 'filtered' + */ + allFilteredCategoricalValues: AllValuesDefinition[] | undefined; + /** + * Only defined and used in categorical table if selectedCountsOption is 'visible' + */ + allVisibleCategoricalValues: AllValuesDefinition[] | undefined; } +// TODO: generalize this and PieMarkerConfigMenu into MarkerConfigurationMenu. Lots of code repetition... + export function BarPlotMarkerConfigurationMenu({ entities, onChange, @@ -35,7 +64,75 @@ export function BarPlotMarkerConfigurationMenu({ toggleStarredVariable, configuration, constraints, + overlayConfiguration, + overlayVariable, + subsettingClient, + studyId, + filters, + continuousMarkerPreview, + allFilteredCategoricalValues, + allVisibleCategoricalValues, }: Props) { + /** + * Used to track the CategoricalMarkerConfigurationTable's selection state, which allows users to + * select more than the allowable limit. Doing so results in a message to the user that they've selected + * too many values. The state is lifted up (versus living in CategoricalMarkerConfigurationTable) in order + * to pass its length to CategoricalMarkerPreview. + */ + const { uncontrolledSelections, setUncontrolledSelections } = + useUncontrolledSelections( + overlayConfiguration?.overlayType === 'categorical' + ? overlayConfiguration?.overlayValues + : undefined + ); + + const barplotData = usePromise( + useCallback(async () => { + if ( + !overlayVariable || + overlayConfiguration?.overlayType !== 'continuous' || + !('distributionDefaults' in overlayVariable) + ) + return; + const binSpec = { + displayRangeMin: + overlayVariable.distributionDefaults.rangeMin + + (overlayVariable.type === 'date' ? 'T00:00:00Z' : ''), + displayRangeMax: + overlayVariable.distributionDefaults.rangeMax + + (overlayVariable.type === 'date' ? 'T00:00:00Z' : ''), + binWidth: overlayVariable.distributionDefaults.binWidth ?? 1, + binUnits: + 'binUnits' in overlayVariable.distributionDefaults + ? overlayVariable.distributionDefaults.binUnits + : undefined, + }; + const distributionResponse = await subsettingClient.getDistribution( + studyId, + configuration.selectedVariable.entityId, + configuration.selectedVariable.variableId, + { + valueSpec: 'count', + filters: filters ?? [], + binSpec, + } + ); + return { + name: '', + value: distributionResponse.histogram.map((d) => d.value), + label: distributionResponse.histogram.map((d) => d.binLabel), + showValues: false, + color: '#333', + }; + }, [ + overlayVariable, + overlayConfiguration?.overlayType, + subsettingClient, + filters, + configuration.selectedVariable, + ]) + ); + function handleInputVariablesOnChange(selection: VariablesByInputName) { if (!selection.overlayVariable) { console.error( @@ -53,7 +150,20 @@ export function BarPlotMarkerConfigurationMenu({ function handlePlotModeSelection(option: string) { onChange({ ...configuration, - selectedPlotMode: option as 'count' | 'proportion', + selectedPlotMode: + option as BarPlotMarkerConfiguration['selectedPlotMode'], + }); + } + function handleBinningMethodSelection(option: string) { + onChange({ + ...configuration, + binningMethod: option as BarPlotMarkerConfiguration['binningMethod'], + }); + } + function handleLogScaleChange(option: boolean) { + onChange({ + ...configuration, + dependentAxisLogScale: option, }); } @@ -61,7 +171,6 @@ export function BarPlotMarkerConfigurationMenu({

- +

+ + Summary marker (all filtered data) + + {overlayConfiguration?.overlayType === 'categorical' ? ( + <> + + + ) : ( + continuousMarkerPreview + )} +
+ + + + + + + + {overlayConfiguration?.overlayType === 'categorical' && ( + + )} + {overlayConfiguration?.overlayType === 'continuous' && barplotData.value && ( +
+ + Raw distribution of overall filtered data + + +
+ )}
); } diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx new file mode 100644 index 0000000000..6b1b3af8d6 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react'; +import Mesa from '@veupathdb/wdk-client/lib/Components/Mesa'; +import { MesaSortObject } from '@veupathdb/wdk-client/lib/Core/CommonTypes'; +import { AllValuesDefinition } from '../../../core'; +import { Tooltip } from '@veupathdb/components/lib/components/widgets/Tooltip'; +import { ColorPaletteDefault } from '@veupathdb/components/lib/types/plots'; +import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; +import { UNSELECTED_TOKEN } from '../../'; +import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; +import { orderBy } from 'lodash'; + +type Props = { + overlayValues: string[]; + onChange: (configuration: T) => void; + configuration: T; + uncontrolledSelections: Set; + setUncontrolledSelections: (v: Set) => void; + allCategoricalValues: AllValuesDefinition[] | undefined; + selectedCountsOption: SharedMarkerConfigurations['selectedCountsOption']; +}; + +const DEFAULT_SORTING: MesaSortObject = { + columnKey: 'count', + direction: 'desc', +}; + +export const MAXIMUM_ALLOWABLE_VALUES = ColorPaletteDefault.length; + +export function CategoricalMarkerConfigurationTable({ + overlayValues, + configuration, + onChange, + uncontrolledSelections, + setUncontrolledSelections, + allCategoricalValues = [], + selectedCountsOption, +}: Props) { + const [sort, setSort] = useState(DEFAULT_SORTING); + const totalCount = allCategoricalValues.reduce( + (prev, curr) => prev + curr.count, + 0 + ); + + function handleSelection(data: AllValuesDefinition) { + if (overlayValues.length < MAXIMUM_ALLOWABLE_VALUES) { + // return early if we somehow duplicate a selection + if (uncontrolledSelections.has(data.label)) return; + const nextSelections = new Set(uncontrolledSelections); + nextSelections.add(data.label); + setUncontrolledSelections(nextSelections); + // check if we have the "All other values" label so we can do some extra processing as needed + if (overlayValues.includes(UNSELECTED_TOKEN)) { + onChange({ + ...configuration, + /** + * This logic ensures that the "All other values" label: + * 1. renders as the last overlayValue value + * 2. renders as the last legend item + */ + selectedValues: overlayValues + .slice(0, overlayValues.length - 1) + .concat(data.label, UNSELECTED_TOKEN), + }); + // can set the new configuration without worrying about the "All other values" data + } else { + onChange({ + ...configuration, + selectedValues: overlayValues.concat(data.label), + }); + } + // we're already at the limit for selections, so just track the selections for the table state, but don't set the configuration + } else { + const nextSelections = new Set(uncontrolledSelections); + nextSelections.add(data.label); + setUncontrolledSelections(nextSelections); + } + } + + function handleDeselection(data: AllValuesDefinition) { + /** + * After we delete the value from our newly initialized Set, we'll check if we're within the allowable selections limit. + * - When true, we remove the "All other values" label if it exists, tack it back onto the end, then set the new configuration + * - When false, we set the table's selection state without setting a new configuration + */ + const nextSelections = new Set(uncontrolledSelections); + nextSelections.delete(data.label); + if (nextSelections.size <= MAXIMUM_ALLOWABLE_VALUES) { + if (nextSelections.has(UNSELECTED_TOKEN)) { + nextSelections.delete(UNSELECTED_TOKEN); + nextSelections.add(UNSELECTED_TOKEN); + } + onChange({ + ...configuration, + selectedValues: Array.from(nextSelections), + }); + } + setUncontrolledSelections(nextSelections); + } + + function handleCountsSelection(option: string) { + onChange({ + ...configuration, + selectedCountsOption: option as 'filtered' | 'visible', + }); + } + + const tableState = { + options: { + isRowSelected: (value: AllValuesDefinition) => + uncontrolledSelections.has(value.label), + }, + eventHandlers: { + onRowSelect: handleSelection, + onRowDeselect: handleDeselection, + onMultipleRowSelect: () => { + /** + * This handler actually selects all values, but we may exceed the allowable selections. Thus, we have to check if we're within the allowable selection limit. + * - When true, we can set both the table state and the configuration to all the values + * - When false, we only set the table state to all the values and bypass setting the configuration + * Note we also need to make sure we include the "All other labels" value if necessary + */ + const nextSelections = new Set( + allCategoricalValues.map((v) => v.label) + ); + if (overlayValues.includes(UNSELECTED_TOKEN)) { + nextSelections.add(UNSELECTED_TOKEN); + } + setUncontrolledSelections(nextSelections); + if (nextSelections.size < MAXIMUM_ALLOWABLE_VALUES) { + onChange({ + ...configuration, + selectedValues: allCategoricalValues.map((v) => v.label), + }); + } + }, + onMultipleRowDeselect: () => { + /** + * This handler actually deselects all values by setting the table state and the configuration to the "All other labels" value + */ + setUncontrolledSelections(new Set([UNSELECTED_TOKEN])); + onChange({ + ...configuration, + selectedValues: [UNSELECTED_TOKEN], + }); + }, + onSort: ( + { key: columnKey }: { key: string }, + direction: MesaSortObject['direction'] + ) => setSort({ columnKey, direction }), + }, + actions: [], + uiState: { sort }, + rows: + sort === null + ? (allCategoricalValues as AllValuesDefinition[]) + : orderBy(allCategoricalValues, [sort.columnKey], [sort.direction]), + columns: [ + { + /** + * For proper sorting in Mesa, the column keys must match the data object's keys. The data objects + * used in this table are defined by AllValuesDefinition, hence the divergence of the column keys + * from the column names for the two sortable columns. + */ + key: 'label', + name: 'Values', + sortable: true, + renderCell: (data: { row: AllValuesDefinition }) => ( + <>{data.row.label} + ), + }, + { + key: 'count', + name: 'Counts', + sortable: true, + renderCell: (data: { row: AllValuesDefinition }) => ( + <>{data.row.count} + ), + }, + { + key: 'distribution', + name: 'Distribution', + renderCell: (data: { row: AllValuesDefinition }) => ( + + ), + }, + ], + }; + return ( +
+
+ +
+ +
+ ); +} + +type DistributionProps = { + count: number; + filteredTotal: number; +}; + +function Distribution({ count, filteredTotal }: DistributionProps) { + return ( + +
+
+
+
+ ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx new file mode 100644 index 0000000000..4b1f0e2f96 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx @@ -0,0 +1,148 @@ +import { AllValuesDefinition, OverlayConfig } from '../../../core'; +import { ColorPaletteDefault } from '@veupathdb/components/lib/types/plots'; +import { ChartMarkerStandalone } from '@veupathdb/components/lib/map/ChartMarker'; +import { DonutMarkerStandalone } from '@veupathdb/components/lib/map/DonutMarker'; +import { UNSELECTED_TOKEN } from '../..'; +import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; +import { + kFormatter, + mFormatter, +} from '../../../core/utils/big-number-formatters'; +import { MAXIMUM_ALLOWABLE_VALUES } from './CategoricalMarkerConfigurationTable'; + +type Props = { + overlayConfiguration: OverlayConfig | undefined; + mapType: 'barplot' | 'pie'; + numberSelected: number; + allFilteredCategoricalValues: AllValuesDefinition[] | undefined; + isDependentAxisLogScaleActive?: boolean; +}; + +export const sharedStandaloneMarkerProperties = { + markerScale: 3, + containerStyles: { + width: 'fit-content', + height: 'fit-content', + margin: 'auto', + }, +}; + +export function CategoricalMarkerPreview({ + overlayConfiguration, + allFilteredCategoricalValues, + mapType, + numberSelected, + isDependentAxisLogScaleActive = false, +}: Props) { + if (!overlayConfiguration || !allFilteredCategoricalValues) return <>; + if (overlayConfiguration.overlayType === 'categorical') { + const { overlayValues } = overlayConfiguration; + + const showTooManySelectionsOverlay = + overlayValues.includes(UNSELECTED_TOKEN) && + numberSelected > MAXIMUM_ALLOWABLE_VALUES; + /** + * When overlayValues includes UNSELECTED_TOKEN, numberSelected will be calculated with the inclusion of UNSELECTED_TOKEN. + * Since UNSELECTED_TOKEN is not user-generated, we subtract 1 to indicate the actual number of values the user can select. + */ + const adjustedNumberSelected = overlayValues.includes(UNSELECTED_TOKEN) + ? numberSelected - 1 + : numberSelected; + const tooManySelectionsOverlay = showTooManySelectionsOverlay ? ( + + ) : null; + + const allOtherValuesCount = allFilteredCategoricalValues.reduce( + (prev, curr) => + prev + (overlayValues.includes(curr.label) ? 0 : curr.count), + 0 + ); + + const plotData = overlayValues.map((val, index) => ({ + label: val, + color: ColorPaletteDefault[index], + value: + val === UNSELECTED_TOKEN + ? allOtherValuesCount + : allFilteredCategoricalValues.find((v) => v.label === val)?.count ?? + 0, + })); + if (mapType === 'barplot') { + return ( +
+ {tooManySelectionsOverlay} + p + c.value, 0))} + dependentAxisLogScale={isDependentAxisLogScaleActive} + {...sharedStandaloneMarkerProperties} + /> +
+ ); + } else if (mapType === 'pie') { + return ( +
+ {tooManySelectionsOverlay} + p + c.value, 0))} + {...sharedStandaloneMarkerProperties} + /> +
+ ); + } else { + return null; + } + } else { + return null; + } +} + +function TooManySelectionsOverlay({ + numberSelected, +}: { + numberSelected: number; +}) { + return ( +
+ +

Please select fewer values.

+

+ {/** + * MAXIMUM_ALLOWABLE_VALUES is derived by the color palette and the color palette saves space for + * the UNSELECTED_TOKEN, hence the user can only select 1 less than the max. + */} + Only {MAXIMUM_ALLOWABLE_VALUES - 1} values may be selected. You + have selected {numberSelected} values. +

+ + ), + spacing: { + margin: 0, + }, + }} + /> +
+ ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx index 82805cbd76..ee761c61c3 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx @@ -1,18 +1,39 @@ -import { H6 } from '@veupathdb/coreui'; +import { useState, useCallback, useEffect } from 'react'; import { InputVariables, Props as InputVariablesProps, } from '../../../core/components/visualizations/InputVariables'; import { VariableDescriptor } from '../../../core/types/variable'; import { VariablesByInputName } from '../../../core/utils/data-element-constraints'; +import { + usePromise, + AllValuesDefinition, + OverlayConfig, + Variable, + Filter, +} from '../../../core'; +import { CategoricalMarkerConfigurationTable } from './CategoricalMarkerConfigurationTable'; +import { CategoricalMarkerPreview } from './CategoricalMarkerPreview'; +import Barplot from '@veupathdb/components/lib/plots/Barplot'; +import { SubsettingClient } from '../../../core/api'; +import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; +import LabelledGroup from '@veupathdb/components/lib/components/widgets/LabelledGroup'; +import { useUncontrolledSelections } from '../hooks/uncontrolledSelections'; interface MarkerConfiguration { type: T; } -export interface PieMarkerConfiguration extends MarkerConfiguration<'pie'> { + +export interface SharedMarkerConfigurations { selectedVariable: VariableDescriptor; + binningMethod: 'equalInterval' | 'quantile' | 'standardDeviation' | undefined; + selectedCountsOption: 'filtered' | 'visible' | undefined; selectedValues: string[] | undefined; } +export interface PieMarkerConfiguration + extends MarkerConfiguration<'pie'>, + SharedMarkerConfigurations {} + interface Props extends Omit< InputVariablesProps, @@ -20,8 +41,24 @@ interface Props > { onChange: (configuration: PieMarkerConfiguration) => void; configuration: PieMarkerConfiguration; + overlayConfiguration: OverlayConfig | undefined; + overlayVariable: Variable | undefined; + subsettingClient: SubsettingClient; + studyId: string; + filters: Filter[] | undefined; + continuousMarkerPreview: JSX.Element | undefined; + /** + * Always used for categorical marker preview. Also used in categorical table if selectedCountsOption is 'filtered' + */ + allFilteredCategoricalValues: AllValuesDefinition[] | undefined; + /** + * Only defined and used in categorical table if selectedCountsOption is 'visible' + */ + allVisibleCategoricalValues: AllValuesDefinition[] | undefined; } +// TODO: generalize this and BarPlotMarkerConfigMenu into MarkerConfigurationMenu. Lots of code repetition... + export function PieMarkerConfigurationMenu({ entities, configuration, @@ -29,7 +66,75 @@ export function PieMarkerConfigurationMenu({ starredVariables, toggleStarredVariable, constraints, + overlayConfiguration, + overlayVariable, + subsettingClient, + studyId, + filters, + continuousMarkerPreview, + allFilteredCategoricalValues, + allVisibleCategoricalValues, }: Props) { + /** + * Used to track the CategoricalMarkerConfigurationTable's selection state, which allows users to + * select more than the allowable limit. Doing so results in a message to the user that they've selected + * too many values. The state is lifted up (versus living in CategoricalMarkerConfigurationTable) in order + * to pass its length to CategoricalMarkerPreview. + */ + const { uncontrolledSelections, setUncontrolledSelections } = + useUncontrolledSelections( + overlayConfiguration?.overlayType === 'categorical' + ? overlayConfiguration?.overlayValues + : undefined + ); + + const barplotData = usePromise( + useCallback(async () => { + if ( + !overlayVariable || + overlayConfiguration?.overlayType !== 'continuous' || + !('distributionDefaults' in overlayVariable) + ) + return; + const binSpec = { + displayRangeMin: + overlayVariable.distributionDefaults.rangeMin + + (overlayVariable.type === 'date' ? 'T00:00:00Z' : ''), + displayRangeMax: + overlayVariable.distributionDefaults.rangeMax + + (overlayVariable.type === 'date' ? 'T00:00:00Z' : ''), + binWidth: overlayVariable.distributionDefaults.binWidth ?? 1, + binUnits: + 'binUnits' in overlayVariable.distributionDefaults + ? overlayVariable.distributionDefaults.binUnits + : undefined, + }; + const distributionResponse = await subsettingClient.getDistribution( + studyId, + configuration.selectedVariable.entityId, + configuration.selectedVariable.variableId, + { + valueSpec: 'count', + filters: filters ?? [], + binSpec, + } + ); + return { + name: '', + value: distributionResponse.histogram.map((d) => d.value), + label: distributionResponse.histogram.map((d) => d.binLabel), + showValues: false, + color: '#333', + }; + }, [ + overlayVariable, + overlayConfiguration?.overlayType, + subsettingClient, + filters, + configuration.selectedVariable, + ]) + ); + function handleInputVariablesOnChange(selection: VariablesByInputName) { if (!selection.overlayVariable) { console.error( @@ -44,12 +149,17 @@ export function PieMarkerConfigurationMenu({ selectedValues: undefined, }); } + function handleBinningMethodSelection(option: string) { + onChange({ + ...configuration, + binningMethod: option as PieMarkerConfiguration['binningMethod'], + }); + } return (

+

+ + Summary marker (all filtered data) + + {overlayConfiguration?.overlayType === 'categorical' ? ( + + ) : ( + continuousMarkerPreview + )} +
+ + + + {overlayConfiguration?.overlayType === 'categorical' && ( + + )} + {overlayConfiguration?.overlayType === 'continuous' && barplotData.value && ( +
+ + Raw distribution of overall filtered data + + +
+ )}
); } diff --git a/packages/libs/eda/src/lib/map/analysis/appState.ts b/packages/libs/eda/src/lib/map/analysis/appState.ts index 7896b8f69d..776dd3b6d4 100644 --- a/packages/libs/eda/src/lib/map/analysis/appState.ts +++ b/packages/libs/eda/src/lib/map/analysis/appState.ts @@ -5,7 +5,6 @@ import { isEqual } from 'lodash'; import { useCallback, useEffect } from 'react'; import { AnalysisState, - BinDefinitions, useGetDefaultVariableDescriptor, useStudyMetadata, } from '../../core'; @@ -28,12 +27,35 @@ export const MarkerConfiguration = t.intersection([ t.union([ t.type({ type: t.literal('barplot'), - selectedValues: t.union([BinDefinitions, t.undefined]), // user-specified selection + selectedValues: t.union([t.array(t.string), t.undefined]), // user-specified selection selectedPlotMode: t.union([t.literal('count'), t.literal('proportion')]), + binningMethod: t.union([ + t.literal('equalInterval'), + t.literal('quantile'), + t.literal('standardDeviation'), + t.undefined, + ]), + dependentAxisLogScale: t.boolean, + selectedCountsOption: t.union([ + t.literal('filtered'), + t.literal('visible'), + t.undefined, + ]), }), t.type({ type: t.literal('pie'), selectedValues: t.union([t.array(t.string), t.undefined]), // user-specified selection + binningMethod: t.union([ + t.literal('equalInterval'), + t.literal('quantile'), + t.literal('standardDeviation'), + t.undefined, + ]), + selectedCountsOption: t.union([ + t.literal('filtered'), + t.literal('visible'), + t.undefined, + ]), }), ]), ]); @@ -103,12 +125,17 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { type: 'pie', selectedVariable: defaultVariable, selectedValues: undefined, + binningMethod: undefined, + selectedCountsOption: 'filtered', }, { type: 'barplot', selectedPlotMode: 'count', selectedVariable: defaultVariable, selectedValues: undefined, + binningMethod: undefined, + dependentAxisLogScale: false, + selectedCountsOption: 'filtered', }, ], }; diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx index 635cdd06fa..294db71d8f 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx @@ -28,6 +28,20 @@ import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../..'; import { DonutMarkerProps } from '@veupathdb/components/lib/map/DonutMarker'; import { ChartMarkerProps } from '@veupathdb/components/lib/map/ChartMarker'; +/** + * We can use this viewport to request all available data + */ +export const GLOBAL_VIEWPORT = { + latitude: { + xMin: -90, + xMax: 90, + }, + longitude: { + left: -180, + right: 180, + }, +}; + /** * Provides markers for use in the MapVEuMap component * Also provides associated data (stats, legend items), pending status and back end errors. @@ -35,6 +49,12 @@ import { ChartMarkerProps } from '@veupathdb/components/lib/map/ChartMarker'; */ export interface StandaloneMapMarkersProps { + /** + * If boundsZoomLevel is undefined, then: + * - geoAggregateVariable will default to { entityId: geoConfig.entity.id, variableId: geoConfig.aggregationVariableIds[0] } + * - viewport will be set to the GLOBAL_VIEWPORT object + * - example use-case: data requests for MarkerPreview components pass in an undefined boundsZoomLevel in order to render a marker that aggregates all filtered data + */ boundsZoomLevel: BoundsViewport | undefined; //vizConfig: MapConfig; geoConfig: GeoConfig | undefined; @@ -117,14 +137,17 @@ export function useStandaloneMapMarkers( const geoAggregateVariable = useMemo( () => - geoConfig != null && boundsZoomLevel?.zoomLevel != null + geoConfig != null ? { entityId: geoConfig.entity.id, variableId: + // if boundsZoomLevel is undefined, we'll default to geoConfig.aggregationVariableIds[0] geoConfig.aggregationVariableIds[ - geoConfig.zoomLevelToAggregationLevel( - boundsZoomLevel?.zoomLevel - ) - 1 + boundsZoomLevel + ? geoConfig.zoomLevelToAggregationLevel( + boundsZoomLevel.zoomLevel + ) - 1 + : 0 ], } : undefined, @@ -143,7 +166,6 @@ export function useStandaloneMapMarkers( useCallback(async () => { // check all required vizConfigs are provided if ( - boundsZoomLevel == null || geoConfig == null || latitudeVariable == null || longitudeVariable == null || @@ -162,10 +184,18 @@ export function useStandaloneMapMarkers( ) return undefined; - const { - northEast: { lat: xMax, lng: right }, - southWest: { lat: xMin, lng: left }, - } = boundsZoomLevel.bounds; + const viewport = boundsZoomLevel + ? { + latitude: { + xMin: boundsZoomLevel.bounds.southWest.lat, + xMax: boundsZoomLevel.bounds.northEast.lat, + }, + longitude: { + left: boundsZoomLevel.bounds.southWest.lng, + right: boundsZoomLevel.bounds.northEast.lng, + }, + } + : GLOBAL_VIEWPORT; // now prepare the rest of the request params const requestParams: StandaloneMapMarkersRequestParams = { @@ -178,16 +208,7 @@ export function useStandaloneMapMarkers( overlayConfig, outputEntityId, valueSpec: markerType === 'pie' ? 'count' : markerType, - viewport: { - latitude: { - xMin, - xMax, - }, - longitude: { - left, - right, - }, - }, + viewport, }, }; diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/uncontrolledSelections.ts b/packages/libs/eda/src/lib/map/analysis/hooks/uncontrolledSelections.ts new file mode 100644 index 0000000000..7ef569d97c --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/hooks/uncontrolledSelections.ts @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +/** + * This hook is used to sync up the uncontrolled selections that are set synchronously with categorical overlayValues + * that are set asynchronously. Instead of using a useEffect, we can use an internal previousOverlays state and compare + * it to the overlayValues prop. Implementation is inspired by the "Better" example from this section of React's + * docs: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + * + * @param overlayValues Only categorical overlayValues should be passed in, otherwise pass in undefined + * @returns A Set of string overlayValues and a setter function + */ + +export function useUncontrolledSelections(overlayValues: string[] | undefined) { + const [uncontrolledSelections, setUncontrolledSelections] = useState( + new Set(overlayValues) + ); + + const [previousOverlays, setPreviousOverlays] = useState(overlayValues); + if (previousOverlays !== overlayValues) { + setUncontrolledSelections(new Set(overlayValues)); + setPreviousOverlays(overlayValues); + } + + return { + uncontrolledSelections, + setUncontrolledSelections, + }; +} diff --git a/packages/libs/eda/src/lib/map/analysis/utils/categoricalValues.ts b/packages/libs/eda/src/lib/map/analysis/utils/categoricalValues.ts new file mode 100644 index 0000000000..303b86646c --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/utils/categoricalValues.ts @@ -0,0 +1,64 @@ +import { DefaultOverlayConfigProps } from './defaultOverlayConfig'; + +type CategoricalValuesProps = Omit< + DefaultOverlayConfigProps, + 'dataClient' | 'binningMethod' +>; + +export async function getCategoricalValues({ + overlayEntity, + subsettingClient, + studyId, + overlayVariable, + filters, +}: CategoricalValuesProps) { + if (overlayEntity && overlayVariable) { + /** + * The goal of this function is to return all possible values and a count for each value when a categorical variable is selected. This is acheived as follows: + * 1. Get all the values and counts by applying no filters to the distribution request + * 2. If no filters are applied, we can just return unfilteredValues (the processed response) from this function + * 3. If filters are applied, we send a new request to get the filtered counts and convert it into filteredValues. Then, we + * 1. map over unfilteredValues while checking against the filteredValues data + * 2. if the data exists on filteredValues, we'll use its count; otherwise, we assign it a count of 0 + * 3. return the mapped unfilteredValues, which should include a count for all values now (even if 0) + */ + const unfilteredDistributionResponse = + await subsettingClient.getDistribution( + studyId, + overlayEntity.id, + overlayVariable.id, + { + valueSpec: 'count', + filters: [], + } + ); + const unfilteredValues = unfilteredDistributionResponse.histogram.map( + (bin) => ({ label: bin.binLabel, count: bin.value }) + ); + + if (filters) { + const filteredDistributionResponse = + await subsettingClient.getDistribution( + studyId, + overlayEntity.id, + overlayVariable.id, + { + valueSpec: 'count', + filters, + } + ); + const filteredValues = filteredDistributionResponse.histogram.map( + (bin) => ({ label: bin.binLabel, count: bin.value }) + ); + return unfilteredValues.map( + (uv) => + filteredValues.find((fv) => fv.label === uv.label) ?? { + ...uv, + count: 0, + } + ); + } else { + return unfilteredValues; + } + } +} diff --git a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts index 72ba08cde8..c2618be7da 100644 --- a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts +++ b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts @@ -10,6 +10,7 @@ import { Variable, } from '../../../core'; import { DataClient, SubsettingClient } from '../../../core/api'; +import { MarkerConfiguration } from '../appState'; // This async function fetches the default overlay config. // For continuous variables, this involves calling the filter-aware-metadata/continuous-variable @@ -25,6 +26,7 @@ export interface DefaultOverlayConfigProps { overlayEntity: StudyEntity | undefined; dataClient: DataClient; subsettingClient: SubsettingClient; + binningMethod?: MarkerConfiguration['binningMethod']; } export async function getDefaultOverlayConfig( @@ -37,6 +39,7 @@ export async function getDefaultOverlayConfig( overlayEntity, dataClient, subsettingClient, + binningMethod = 'equalInterval', } = props; if (overlayVariable != null && overlayEntity != null) { @@ -67,6 +70,7 @@ export async function getDefaultOverlayConfig( ...overlayVariableDescriptor, filters: filters ?? [], dataClient, + binningMethod, }); return { @@ -89,8 +93,7 @@ type GetMostFrequentValuesProps = { subsettingClient: SubsettingClient; }; -// get the most frequent values for the entire dataset, no filters at all -// (for now at least) +// get the most frequent values for the entire dataset async function getMostFrequentValues({ studyId, variableId, @@ -112,6 +115,7 @@ async function getMostFrequentValues({ const sortedValues = distributionResponse.histogram .sort((bin1, bin2) => bin2.value - bin1.value) .map((bin) => bin.binLabel); + return sortedValues.length <= numValues ? sortedValues : [...sortedValues.slice(0, numValues), UNSELECTED_TOKEN]; @@ -123,6 +127,7 @@ type GetBinRangesProps = { entityId: string; dataClient: DataClient; filters: Filter[]; + binningMethod: MarkerConfiguration['binningMethod']; }; // get the equal spaced bin definitions (for now at least) @@ -132,6 +137,7 @@ async function getBinRanges({ entityId, dataClient, filters, + binningMethod = 'equalInterval', }: GetBinRangesProps): Promise { const response = await dataClient.getContinousVariableMetadata({ studyId, @@ -145,6 +151,6 @@ async function getBinRanges({ }, }); - const binRanges = response.binRanges?.equalInterval!; // if asking for binRanges, the response WILL contain binRanges + const binRanges = response.binRanges?.[binningMethod]!; // if asking for binRanges, the response WILL contain binRanges return binRanges; } diff --git a/packages/libs/wdk-client/src/Components/Mesa/Components/Checkbox.jsx b/packages/libs/wdk-client/src/Components/Mesa/Components/Checkbox.jsx index 004b5f99a3..d1f077e96f 100644 --- a/packages/libs/wdk-client/src/Components/Mesa/Components/Checkbox.jsx +++ b/packages/libs/wdk-client/src/Components/Mesa/Components/Checkbox.jsx @@ -1,6 +1,5 @@ import React from 'react'; - -import Icon from '../../../Components/Mesa/Components/Icon'; +import IndeterminateCheckbox from '@veupathdb/coreui/lib/components/inputs/checkboxes/IndeterminateCheckbox'; class Checkbox extends React.Component { constructor(props) { @@ -14,14 +13,26 @@ class Checkbox extends React.Component { } render() { - let { checked, className, disabled } = this.props; + let { checked, className, disabled, indeterminate = false } = this.props; className = 'Checkbox' + (className ? ' ' + className : ''); className += ' ' + (checked ? 'Checkbox-Checked' : 'Checkbox-Unchecked'); className += disabled ? ' Checkbox-Disabled' : ''; return (
- + {indeterminate ? ( + + ) : ( + + )}
); } diff --git a/packages/libs/wdk-client/src/Components/Mesa/Ui/SelectionCell.jsx b/packages/libs/wdk-client/src/Components/Mesa/Ui/SelectionCell.jsx index 05f6d834b4..4ab3d37470 100644 --- a/packages/libs/wdk-client/src/Components/Mesa/Ui/SelectionCell.jsx +++ b/packages/libs/wdk-client/src/Components/Mesa/Ui/SelectionCell.jsx @@ -33,15 +33,20 @@ class SelectionCell extends React.PureComponent { const { rows, isRowSelected, eventHandlers, inert } = this.props; const selection = rows.filter(isRowSelected); const checked = rows.length && rows.every(isRowSelected); + const isIndeterminate = selection.length > 0 && !checked; let handler = (e) => { e.stopPropagation(); - return checked ? this.deselectAllRows() : this.selectAllRows(); + return checked || isIndeterminate + ? this.deselectAllRows() + : this.selectAllRows(); }; return ( - {inert ? null : } + {inert ? null : ( + + )} ); }