From 06ecb2363a63d2261852cd167a38c57db2cde82e Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:10:29 -0500 Subject: [PATCH 01/35] useOverlayTriggerState update to not close popover on document click that is a select option target --- packages/components/src/internal/OverlayTrigger.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/components/src/internal/OverlayTrigger.tsx b/packages/components/src/internal/OverlayTrigger.tsx index 31b6e633ec..7dbd6b4e4b 100644 --- a/packages/components/src/internal/OverlayTrigger.tsx +++ b/packages/components/src/internal/OverlayTrigger.tsx @@ -86,7 +86,8 @@ export function useOverlayTriggerState( event => { const isToggle = event.target === targetRef.current; const insideToggle = portalEl?.contains(event.target); - if (!isToggle && !insideToggle) { + const isSelectOption = (event.target as HTMLElement).classList.contains('select-input__option'); + if (!isToggle && !insideToggle && !isSelectOption) { setShow(false); } }, From ff21fa43c7fcab20915545a7b02b6697a0767eed Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:11:07 -0500 Subject: [PATCH 02/35] move shouldShowRangeScaleOptions() to utils.ts and add shouldShowAggregateOptions() --- .../internal/components/chart/utils.test.ts | 58 ++++++++++++++++++- .../src/internal/components/chart/utils.ts | 13 +++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/components/src/internal/components/chart/utils.test.ts b/packages/components/src/internal/components/chart/utils.test.ts index 643f243efa..c500a9d47b 100644 --- a/packages/components/src/internal/components/chart/utils.test.ts +++ b/packages/components/src/internal/components/chart/utils.test.ts @@ -1,6 +1,13 @@ import { Map } from 'immutable'; -import { createHorizontalBarLegendData, createHorizontalBarCountLegendData, getFieldDataType } from './utils'; +import { + createHorizontalBarCountLegendData, + createHorizontalBarLegendData, + getFieldDataType, + shouldShowAggregateOptions, + shouldShowRangeScaleOptions, +} from './utils'; +import { ChartFieldInfo, ChartTypeInfo } from './models'; describe('createHorizontalBarLegendData', () => { test('all different', () => { @@ -301,3 +308,52 @@ describe('getFieldDataType', () => { expect(getFieldDataType({ displayFieldJsonType: undefined, jsonType: undefined, type: 'date' })).toBe('date'); }); }); + +const BAR_CHART_TYPE = { + name: 'bar_chart', +} as ChartTypeInfo; +const BOX_PLOT_TYPE = { + name: 'box_plot', +} as ChartTypeInfo; +const SCATTER_PLOT_TYPE = { + name: 'scatter_plot', +} as ChartTypeInfo; +const LINE_PLOT_TYPE = { + name: 'line_plot', +} as ChartTypeInfo; + +const xField = { name: 'x' } as ChartFieldInfo; +const yField = { name: 'y' } as ChartFieldInfo; + +describe('shouldShowRangeScaleOptions', () => { + test('based on chart type', () => { + expect(shouldShowRangeScaleOptions(xField, BAR_CHART_TYPE)).toBe(false); + expect(shouldShowRangeScaleOptions(yField, BAR_CHART_TYPE)).toBe(false); + expect(shouldShowRangeScaleOptions(xField, BOX_PLOT_TYPE)).toBe(false); + expect(shouldShowRangeScaleOptions(yField, BOX_PLOT_TYPE)).toBe(true); + expect(shouldShowRangeScaleOptions(xField, SCATTER_PLOT_TYPE)).toBe(true); + expect(shouldShowRangeScaleOptions(yField, SCATTER_PLOT_TYPE)).toBe(true); + expect(shouldShowRangeScaleOptions(xField, LINE_PLOT_TYPE)).toBe(true); + expect(shouldShowRangeScaleOptions(yField, LINE_PLOT_TYPE)).toBe(true); + }); + + test('based on field name', () => { + expect(shouldShowRangeScaleOptions({ name: 'series' } as ChartFieldInfo, BAR_CHART_TYPE)).toBe(false); + expect(shouldShowRangeScaleOptions({ name: 'series' } as ChartFieldInfo, BOX_PLOT_TYPE)).toBe(false); + expect(shouldShowRangeScaleOptions({ name: 'series' } as ChartFieldInfo, SCATTER_PLOT_TYPE)).toBe(false); + expect(shouldShowRangeScaleOptions({ name: 'series' } as ChartFieldInfo, LINE_PLOT_TYPE)).toBe(false); + }); +}); + +describe('shouldShowAggregateOptions', () => { + test('based on chart type', () => { + expect(shouldShowAggregateOptions(xField, BAR_CHART_TYPE)).toBe(false); + expect(shouldShowAggregateOptions(yField, BAR_CHART_TYPE)).toBe(true); + expect(shouldShowAggregateOptions(xField, BOX_PLOT_TYPE)).toBe(false); + expect(shouldShowAggregateOptions(yField, BOX_PLOT_TYPE)).toBe(false); + expect(shouldShowAggregateOptions(xField, SCATTER_PLOT_TYPE)).toBe(false); + expect(shouldShowAggregateOptions(yField, SCATTER_PLOT_TYPE)).toBe(false); + expect(shouldShowAggregateOptions(xField, LINE_PLOT_TYPE)).toBe(false); + expect(shouldShowAggregateOptions(yField, LINE_PLOT_TYPE)).toBe(false); + }); +}); diff --git a/packages/components/src/internal/components/chart/utils.ts b/packages/components/src/internal/components/chart/utils.ts index cc560032a3..cac2c3a64e 100644 --- a/packages/components/src/internal/components/chart/utils.ts +++ b/packages/components/src/internal/components/chart/utils.ts @@ -1,4 +1,5 @@ import { Map } from 'immutable'; +import { ChartFieldInfo, ChartTypeInfo } from './models'; export interface HorizontalBarData { backgroundColor?: string; @@ -74,3 +75,15 @@ export const getFieldDataType = (fieldData: Record): string => { if (!fieldData) return undefined; return fieldData.displayFieldJsonType || fieldData.jsonType || fieldData.type; }; + +export const shouldShowRangeScaleOptions = (field: ChartFieldInfo, selectedType: ChartTypeInfo): boolean => { + const showForX = field.name === 'x' && (selectedType.name === 'scatter_plot' || selectedType.name === 'line_plot'); + const showForY = + field.name === 'y' && + (selectedType.name === 'scatter_plot' || selectedType.name === 'line_plot' || selectedType.name === 'box_plot'); + return showForX || showForY; +}; + +export const shouldShowAggregateOptions = (field: ChartFieldInfo, selectedType: ChartTypeInfo): boolean => { + return field.name === 'y' && selectedType.name === 'bar_chart'; +}; From f900b78cf485a84b1436a07b9c1ec45d426626ed Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:11:34 -0500 Subject: [PATCH 03/35] move ChartFieldInfo and ChartTypeInfo interfaces to models.ts --- .../src/internal/components/chart/models.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/components/src/internal/components/chart/models.ts b/packages/components/src/internal/components/chart/models.ts index dbcb415cae..92109b4c4a 100644 --- a/packages/components/src/internal/components/chart/models.ts +++ b/packages/components/src/internal/components/chart/models.ts @@ -46,3 +46,28 @@ export interface TrendlineType { showMin?: boolean; value: string; } + +interface AggregateFieldInfo { + name: string; + value: string; +} + +export interface ChartFieldInfo { + aggregate?: AggregateFieldInfo; + altSelectionOnly?: boolean; + // allowMultiple?: boolean; // not yet supported, will be part of a future dev story + label: string; + name: string; + nonNumericOnly?: boolean; + numericOnly?: boolean; + numericOrDateOnly?: boolean; + required: boolean; +} + +export interface ChartTypeInfo { + fields: ChartFieldInfo[]; + hidden?: boolean; + imgUrl: string; + name: string; + title: string; +} From 37747e5f8555e4a9ed78b683023f3e2d115e93cb Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:11:50 -0500 Subject: [PATCH 04/35] move constants out of ChartBuilderModal.tsx --- .../src/internal/components/chart/constants.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/components/src/internal/components/chart/constants.ts diff --git a/packages/components/src/internal/components/chart/constants.ts b/packages/components/src/internal/components/chart/constants.ts new file mode 100644 index 0000000000..b1d95ee3b3 --- /dev/null +++ b/packages/components/src/internal/components/chart/constants.ts @@ -0,0 +1,13 @@ +export const HIDDEN_CHART_TYPES = ['time_chart']; +export const RIGHT_COL_FIELDS = ['color', 'shape', 'series', 'trendline']; +export const MAX_ROWS_PREVIEW = 10000; +export const MAX_POINT_DISPLAY = 10000; +export const BLUE_HEX_COLOR = '3366FF'; +export const BAR_CHART_AGGREGATE_NAME = 'aggregate-method'; +export const ICONS = { + bar_chart: 'bar_chart', + box_plot: 'box_plot', + pie_chart: 'pie_chart', + scatter_plot: 'xy_scatter', + line_plot: 'xy_line', +}; From aaa3df013bf30880bc7df8b483ca1a33769f8cc0 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:14:28 -0500 Subject: [PATCH 05/35] Factor ChartFieldRangeScaleOptions.tsx out of ChartFieldOption.tsx --- .../chart/ChartFieldOption.test.tsx | 27 +-- .../components/chart/ChartFieldOption.tsx | 186 +++--------------- .../chart/ChartFieldRangeScaleOptions.tsx | 156 +++++++++++++++ 3 files changed, 186 insertions(+), 183 deletions(-) create mode 100644 packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx index 33aaceac27..c26f51fe23 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx @@ -13,8 +13,8 @@ import { SchemaQuery } from '../../../public/SchemaQuery'; import { QueryInfo } from '../../../public/QueryInfo'; import { ViewInfo } from '../../ViewInfo'; -import { ChartFieldOption, getSelectOptions, shouldShowFieldOptions } from './ChartFieldOption'; -import { ChartFieldInfo, ChartTypeInfo } from './ChartBuilderModal'; +import { ChartFieldOption, getSelectOptions } from './ChartFieldOption'; +import { ChartFieldInfo, ChartTypeInfo } from './models'; LABKEY_VIS = { GenericChartHelper: { @@ -82,29 +82,6 @@ describe('getSelectOptions', () => { }); }); -describe('shouldShowFieldOptions', () => { - const xField = { name: 'x' } as ChartFieldInfo; - const yField = { name: 'y' } as ChartFieldInfo; - - test('based on chart type', () => { - expect(shouldShowFieldOptions(xField, BAR_CHART_TYPE)).toBe(false); - expect(shouldShowFieldOptions(yField, BAR_CHART_TYPE)).toBe(false); - expect(shouldShowFieldOptions(xField, BOX_PLOT_TYPE)).toBe(false); - expect(shouldShowFieldOptions(yField, BOX_PLOT_TYPE)).toBe(true); - expect(shouldShowFieldOptions(xField, SCATTER_PLOT_TYPE)).toBe(true); - expect(shouldShowFieldOptions(yField, SCATTER_PLOT_TYPE)).toBe(true); - expect(shouldShowFieldOptions(xField, LINE_PLOT_TYPE)).toBe(true); - expect(shouldShowFieldOptions(yField, LINE_PLOT_TYPE)).toBe(true); - }); - - test('based on field name', () => { - expect(shouldShowFieldOptions({ name: 'series' } as ChartFieldInfo, BAR_CHART_TYPE)).toBe(false); - expect(shouldShowFieldOptions({ name: 'series' } as ChartFieldInfo, BOX_PLOT_TYPE)).toBe(false); - expect(shouldShowFieldOptions({ name: 'series' } as ChartFieldInfo, SCATTER_PLOT_TYPE)).toBe(false); - expect(shouldShowFieldOptions({ name: 'series' } as ChartFieldInfo, LINE_PLOT_TYPE)).toBe(false); - }); -}); - describe('ChartFieldOption', () => { test('line chart for x, showFieldOptions for int', async () => { render( diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index fbe1bce17c..358b693329 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; @@ -7,12 +7,10 @@ import { QueryModel } from '../../../public/QueryModel/QueryModel'; import { LABKEY_VIS } from '../../constants'; import { naturalSortByProperty } from '../../../public/sort'; -import { OverlayTrigger } from '../../OverlayTrigger'; -import { Popover } from '../../Popover'; -import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; - -import { ChartFieldInfo, ChartTypeInfo } from './ChartBuilderModal'; -import { getFieldDataType } from './utils'; +import { ChartFieldRangeScaleOptions } from './ChartFieldRangeScaleOptions'; +import { ChartFieldInfo, ChartTypeInfo } from './models'; +import { getFieldDataType, shouldShowAggregateOptions, shouldShowRangeScaleOptions } from './utils'; +import { ChartFieldAggregateOptions } from './ChartFieldAggregateOptions'; export const getSelectOptions = ( model: QueryModel, @@ -38,70 +36,39 @@ export const getSelectOptions = ( .map(col => ({ label: col.caption, value: col.fieldKey, data: col })); }; -export const shouldShowFieldOptions = (field: ChartFieldInfo, selectedType: ChartTypeInfo): boolean => { - const showForX = field.name === 'x' && (selectedType.name === 'scatter_plot' || selectedType.name === 'line_plot'); - const showForY = - field.name === 'y' && - (selectedType.name === 'scatter_plot' || selectedType.name === 'line_plot' || selectedType.name === 'box_plot'); - return showForX || showForY; -}; - -const SCALE_TRANS_TYPES = [ - { value: 'linear', label: 'Linear' }, - { value: 'log', label: 'Log' }, -]; - -const SCALE_RANGE_TYPES = [ - { value: 'automatic', label: 'Automatic' }, - { value: 'manual', label: 'Manual' }, -]; - const DEFAULT_SCALE_VALUES = { type: 'automatic', trans: 'linear' }; interface ChartFieldOptionProps { field: ChartFieldInfo; - fieldValue?: SelectInputOption; + fieldValues?: Record; model: QueryModel; - onScaleChange: (field: string, key: string, value: string | number, reset?: boolean) => void; + onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; - scaleValues?: Record; + scaleValues?: Record; selectedType: ChartTypeInfo; } export const ChartFieldOption: FC = memo(props => { - const { field, model, selectedType, onSelectFieldChange, scaleValues, fieldValue, onScaleChange } = props; + const { field, model, selectedType, onSelectFieldChange, scaleValues, fieldValues, onScaleChange } = props; + const fieldValue = fieldValues?.[field.name]; + const [scale, setScale] = useState>(scaleValues ?? {}); + const options = useMemo(() => getSelectOptions(model, selectedType, field), [model, selectedType, field]); const isNumericType = useMemo( () => LABKEY_VIS.GenericChartHelper.isNumericType(getFieldDataType(fieldValue?.data)), [fieldValue?.data] ); - const showFieldOptions = isNumericType && shouldShowFieldOptions(field, selectedType); - const [scale, setScale] = useState>(scaleValues ?? {}); - const invalidRange = useMemo( - () => - scale.min !== undefined && - scale.min !== null && - scale.max !== undefined && - scale.max !== null && - parseFloat(scale.max.toString()) <= parseFloat(scale.min.toString()), - [scale] - ); + const showRangeScaleOptions = isNumericType && shouldShowRangeScaleOptions(field, selectedType); + const showAggregateOptions = isNumericType && shouldShowAggregateOptions(field, selectedType); + // Issue 52050: use fieldKey for special characters const selectInputValue = useMemo(() => fieldValue?.data.fieldKey ?? fieldValue?.value, [fieldValue]); useEffect(() => { - if (showFieldOptions && !scale.type) { + if (showRangeScaleOptions && !scale.type) { setScale(scaleValues?.type ? scaleValues : DEFAULT_SCALE_VALUES); } - }, [showFieldOptions, scale.type, scaleValues]); - - const onScaleTransChange = useCallback( - (selected: string) => { - onScaleChange(field.name, 'trans', selected); - setScale(prev => ({ ...prev, trans: selected })); - }, - [field.name, onScaleChange] - ); + }, [showRangeScaleOptions, scale.type, scaleValues]); const onSelectFieldChange_ = useCallback( (name: string, value: string, selectedOption: SelectInputOption) => { @@ -112,54 +79,6 @@ export const ChartFieldOption: FC = memo(props => { [field.name, onScaleChange, onSelectFieldChange] ); - const onScaleTypeChange = useCallback( - (selected: string) => { - let scale_: Record = { ...scale, type: selected }; - onScaleChange(field.name, 'type', selected); - if (selected === 'automatic') { - onScaleChange(field.name, 'min', undefined); - onScaleChange(field.name, 'max', undefined); - scale_ = { ...scale_, min: undefined, max: undefined }; - } - setScale(scale_); - }, - [field.name, onScaleChange, scale] - ); - - const onScaleMinChange = useCallback((event: ChangeEvent) => { - setScale(prev => ({ ...prev, min: event.target.value })); - }, []); - - const onScaleMaxChange = useCallback((event: ChangeEvent) => { - setScale(prev => ({ ...prev, max: event.target.value })); - }, []); - - const onScaleRangeBlur = useCallback(() => { - if (invalidRange) return; - onScaleChange(field.name, 'min', parseFloat(scale.min?.toString())); - onScaleChange(field.name, 'max', parseFloat(scale.max?.toString())); - }, [field.name, onScaleChange, scale.max, scale.min, invalidRange]); - - const scaleTransOptions = useMemo(() => { - return SCALE_TRANS_TYPES.map( - option => - ({ - ...option, - selected: scale.trans === option.value, - }) as RadioGroupOption - ); - }, [scale.trans]); - - const scaleTypeOptions = useMemo(() => { - return SCALE_RANGE_TYPES.map( - option => - ({ - ...option, - selected: scale.type === option.value, - }) as RadioGroupOption - ); - }, [scale.type]); - return (
- {showFieldOptions && ( -
- -
- - -
-
- - -
- {scale.type === 'manual' && ( -
- -   -  - - {invalidRange && ( -
Invalid range (Max <= Min)
- )} -
- )} - - } - > - -
-
+ {showRangeScaleOptions && ( + )}
diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx new file mode 100644 index 0000000000..a067dfed88 --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx @@ -0,0 +1,156 @@ +import React, { ChangeEvent, FC, memo, useCallback, useMemo } from 'react'; +import { OverlayTrigger } from '../../OverlayTrigger'; +import { Popover } from '../../Popover'; +import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; + +import { ChartFieldInfo } from './models'; + +const SCALE_TRANS_TYPES = [ + { value: 'linear', label: 'Linear' }, + { value: 'log', label: 'Log' }, +]; + +const SCALE_RANGE_TYPES = [ + { value: 'automatic', label: 'Automatic' }, + { value: 'manual', label: 'Manual' }, +]; + +interface Props { + field: ChartFieldInfo; + onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; + scale: Record; + setScale: (scale) => void; +} + +export const ChartFieldRangeScaleOptions: FC = memo(props => { + const { field, scale, setScale, onScaleChange } = props; + + const scaleTransOptions = useMemo(() => { + return SCALE_TRANS_TYPES.map( + option => + ({ + ...option, + selected: scale.trans === option.value, + }) as RadioGroupOption + ); + }, [scale.trans]); + + const scaleTypeOptions = useMemo(() => { + return SCALE_RANGE_TYPES.map( + option => + ({ + ...option, + selected: scale.type === option.value, + }) as RadioGroupOption + ); + }, [scale.type]); + + const invalidRange = useMemo( + () => + scale.min !== undefined && + scale.min !== null && + scale.max !== undefined && + scale.max !== null && + parseFloat(scale.max.toString()) <= parseFloat(scale.min.toString()), + [scale] + ); + + const onScaleTransChange = useCallback( + (selected: string) => { + onScaleChange(field.name, 'trans', selected); + setScale(prev => ({ ...prev, trans: selected })); + }, + [field.name, onScaleChange, setScale] + ); + + const onScaleTypeChange = useCallback( + (selected: string) => { + let scale_: Record = { ...scale, type: selected }; + onScaleChange(field.name, 'type', selected); + if (selected === 'automatic') { + onScaleChange(field.name, 'min', undefined); + onScaleChange(field.name, 'max', undefined); + scale_ = { ...scale_, min: undefined, max: undefined }; + } + setScale(scale_); + }, + [field.name, onScaleChange, scale, setScale] + ); + + const onScaleMinChange = useCallback( + (event: ChangeEvent) => { + setScale(prev => ({ ...prev, min: event.target.value })); + }, + [setScale] + ); + + const onScaleMaxChange = useCallback( + (event: ChangeEvent) => { + setScale(prev => ({ ...prev, max: event.target.value })); + }, + [setScale] + ); + + const onScaleRangeBlur = useCallback(() => { + if (invalidRange) return; + onScaleChange(field.name, 'min', parseFloat(scale.min?.toString())); + onScaleChange(field.name, 'max', parseFloat(scale.max?.toString())); + }, [field.name, onScaleChange, scale.max, scale.min, invalidRange]); + + return ( +
+ +
+ + +
+
+ + +
+ {scale.type === 'manual' && ( +
+ +   -  + + {invalidRange &&
Invalid range (Max <= Min)
} +
+ )} + + } + triggerType="click" + > + +
+
+ ); +}); +ChartFieldRangeScaleOptions.displayName = 'ChartFieldRangeScaleOptions'; From 18023a5dead879382c07f52c0e3021bc9bc12b97 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:25:35 -0500 Subject: [PATCH 06/35] ChartFieldAggregateOptions.tsx and move y-axis bar chart aggregate method dropdown into tooltip --- .../components/releaseNotes/components.md | 7 ++ .../components/chart/ChartBuilderModal.tsx | 89 +++------------- .../chart/ChartFieldAggregateOptions.tsx | 100 ++++++++++++++++++ .../components/chart/ChartFieldOption.tsx | 7 ++ 4 files changed, 128 insertions(+), 75 deletions(-) create mode 100644 packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 71f7c76aff..e321768f79 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,13 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version TBD +*Released*: TBD +- ChartBuilderModal support for bar/line chart aggregate method and error bar options + - useOverlayTriggerState update to not close popover on document click that is a select option target + - Factor ChartFieldRangeScaleOptions.tsx out of ChartFieldOption.tsx + - ChartFieldAggregateOptions.tsx and move y-axis bar chart aggregate method dropdown into tooltip + ### version 6.63.2 *Released*: 8 October 2025 - Issue 53324: LKSM: Custom Grid View Column Limit diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 199e6d3f0e..720b3c93de 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -7,7 +7,7 @@ import { generateId } from '../../util/utils'; import { LABKEY_VIS } from '../../constants'; import { Modal } from '../../Modal'; -import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; +import { SelectInputOption } from '../forms/input/SelectInput'; import { LoadingSpinner } from '../base/LoadingSpinner'; @@ -24,66 +24,23 @@ import { getContainerFilterForFolder } from '../../query/api'; import { SVGIcon } from '../base/SVGIcon'; -import { LabelOverlay } from '../forms/LabelOverlay'; - import { isAppHomeFolder } from '../../app/utils'; - import { deleteChart, saveChart, SaveReportConfig } from './actions'; - -import { ChartConfig, ChartQueryConfig, GenericChartModel, TrendlineType } from './models'; +import { + BAR_CHART_AGGREGATE_NAME, + BLUE_HEX_COLOR, + HIDDEN_CHART_TYPES, + ICONS, + MAX_POINT_DISPLAY, + MAX_ROWS_PREVIEW, + RIGHT_COL_FIELDS, +} from './constants'; + +import { ChartConfig, ChartQueryConfig, ChartTypeInfo, GenericChartModel, TrendlineType } from './models'; import { TrendlineOption } from './TrendlineOption'; import { ChartFieldOption } from './ChartFieldOption'; import { getFieldDataType } from './utils'; -interface AggregateFieldInfo { - name: string; - value: string; -} - -export interface ChartFieldInfo { - aggregate?: AggregateFieldInfo; - altSelectionOnly?: boolean; - // allowMultiple?: boolean; // not yet supported, will be part of a future dev story - label: string; - name: string; - nonNumericOnly?: boolean; - numericOnly?: boolean; - numericOrDateOnly?: boolean; - required: boolean; -} - -export interface ChartTypeInfo { - fields: ChartFieldInfo[]; - hidden?: boolean; - imgUrl: string; - name: string; - title: string; -} - -const HIDDEN_CHART_TYPES = ['time_chart']; -const RIGHT_COL_FIELDS = ['color', 'shape', 'series', 'trendline']; -export const MAX_ROWS_PREVIEW = 10000; -export const MAX_POINT_DISPLAY = 10000; -const BLUE_HEX_COLOR = '3366FF'; -const BAR_CHART_AGGREGATE_NAME = 'aggregate-method'; -const BAR_CHART_AGGREGATE_METHODS = [ - { label: 'Count (non-blank)', value: 'COUNT' }, - { label: 'Sum', value: 'SUM' }, - { label: 'Min', value: 'MIN' }, - { label: 'Max', value: 'MAX' }, - { label: 'Mean', value: 'MEAN' }, - { label: 'Median', value: 'MEDIAN' }, -]; -const BAR_CHART_AGGREGATE_METHOD_TIP = - 'The aggregate method that will be used to determine the bar height for a given x-axis category / dimension. Field values that are blank are not included in calculated aggregate values.'; -const ICONS = { - bar_chart: 'bar_chart', - box_plot: 'box_plot', - pie_chart: 'pie_chart', - scatter_plot: 'xy_scatter', - line_plot: 'xy_line', -}; - export const getChartRenderMsg = (chartConfig: ChartConfig, rowCount: number, isPreview: boolean): string => { const msg = []; if (isPreview && rowCount === MAX_ROWS_PREVIEW) { @@ -384,7 +341,7 @@ const ChartTypeQueryForm: FC = memo(props => { onSelectFieldChange={onSelectFieldChange} onScaleChange={onFieldScaleChange} selectedType={selectedType} - fieldValue={fieldValues[field.name]} + fieldValues={fieldValues} scaleValues={fieldValues.scales?.value[field.name]} /> ))} @@ -399,27 +356,9 @@ const ChartTypeQueryForm: FC = memo(props => { onSelectFieldChange={onSelectFieldChange} onScaleChange={onFieldScaleChange} selectedType={selectedType} - fieldValue={fieldValues[field.name]} + fieldValues={fieldValues} scaleValues={fieldValues.scales?.value[field.name]} /> - {selectedType.name === 'bar_chart' && fieldValues.y?.value && ( -
- - -
- )} ))} {hasTrendlineOption && ( diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx new file mode 100644 index 0000000000..72a8ec8ce3 --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx @@ -0,0 +1,100 @@ +import React, { ChangeEvent, FC, memo, useCallback, useMemo } from 'react'; +import { OverlayTrigger } from '../../OverlayTrigger'; +import { Popover } from '../../Popover'; +import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; + +import { BAR_CHART_AGGREGATE_NAME } from './constants'; +import { ChartFieldInfo, ChartTypeInfo } from './models'; +import { LabelOverlay } from '../forms/LabelOverlay'; +import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; + +const BAR_CHART_AGGREGATE_METHODS = [ + { label: 'Count (non-blank)', value: 'COUNT' }, + { label: 'Sum', value: 'SUM' }, + { label: 'Min', value: 'MIN' }, + { label: 'Max', value: 'MAX' }, + { label: 'Mean', value: 'MEAN' }, + { label: 'Median', value: 'MEDIAN' }, +]; +const BAR_CHART_AGGREGATE_METHOD_TIP = + 'The aggregate method that will be used to determine the bar height for a given x-axis category / dimension. Field values that are blank are not included in calculated aggregate values.'; + +const ERROR_BAR_TYPES = [ + { value: null, label: 'None' }, + { value: 'SD', label: 'Standard Deviation' }, + { value: 'SEM', label: 'Standard Error of the Mean' }, +]; + +interface Props { + field: ChartFieldInfo; + fieldValues: Record; + onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; +} + +export const ChartFieldAggregateOptions: FC = memo(props => { + const { field, fieldValues, onSelectFieldChange } = props; + const fieldValue = fieldValues?.[field.name]; + const aggregateValue = fieldValues?.[BAR_CHART_AGGREGATE_NAME]?.value; + + const errorBarOptions = useMemo(() => { + return ERROR_BAR_TYPES.map( + option => + ({ + ...option, + selected: null === option.value, // TODO + }) as RadioGroupOption + ); + }, []); + + const onErrorBarChange = useCallback( + (selected: string) => { + console.log(field.name, selected); + }, + [field.name] + ); + + if (!fieldValue?.value) { + return null; + } + + return ( +
+ +
+ + +
+ {/*
*/} + {/* */} + {/*
*/} + {/* */} + {/*
*/} + + } + triggerType="click" + > + +
+
+ ); +}); +ChartFieldAggregateOptions.displayName = 'ChartFieldAggregateOptions'; diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index 358b693329..0e8ed07093 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -104,6 +104,13 @@ export const ChartFieldOption: FC = memo(props => { setScale={setScale} /> )} + {showAggregateOptions && ( + + )} ); From 28906698278c98a44ee0d0661937d64ecb82ea53 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:28:09 -0500 Subject: [PATCH 07/35] jest fixes for prop change on ChartFieldOption --- .../components/chart/ChartFieldOption.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx index c26f51fe23..b9b65d9ab5 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx @@ -87,7 +87,7 @@ describe('ChartFieldOption', () => { render( { render( { render( { render( { render( { render( { render( Date: Thu, 9 Oct 2025 12:37:31 -0500 Subject: [PATCH 08/35] jest test fixes for move of y-axis aggregate method to tooltip --- .../chart/ChartBuilderModal.test.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx index 2c6816ec73..6e75c1d4fd 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx @@ -19,15 +19,13 @@ import { import { ChartBuilderModal, - ChartTypeInfo, getChartBuilderChartConfig, getChartBuilderQueryConfig, getChartRenderMsg, getDefaultBarChartAxisLabel, - MAX_POINT_DISPLAY, - MAX_ROWS_PREVIEW, } from './ChartBuilderModal'; -import { ChartConfig, ChartQueryConfig, GenericChartModel } from './models'; +import { MAX_POINT_DISPLAY, MAX_ROWS_PREVIEW } from './constants'; +import { ChartConfig, ChartQueryConfig, ChartTypeInfo, GenericChartModel } from './models'; const BAR_CHART_TYPE = { name: 'bar_chart', @@ -319,7 +317,7 @@ describe('ChartBuilderModal', () => { validate(false, true, true); }); - test('init from bar chart with y axis value and default aggregate method', () => { + test('init from bar chart with y axis value and default aggregate method', async () => { const savedChartModel = { canShare: true, canDelete: true, @@ -348,12 +346,15 @@ describe('ChartBuilderModal', () => { ); validate(false, true, true); - expect(document.querySelectorAll('input')).toHaveLength(8); + expect(document.querySelectorAll('input')).toHaveLength(6); expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); + expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); // gear icon for y-axis + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('input')).toHaveLength(8); expect(document.querySelector('input[name=aggregate-method]').getAttribute('value')).toBe('SUM'); }); - test('init from bar chart with y axis value and aggregate method', () => { + test('init from bar chart with y axis value and aggregate method', async () => { const savedChartModel = { canShare: true, canDelete: true, @@ -382,8 +383,11 @@ describe('ChartBuilderModal', () => { ); validate(false, true, true); - expect(document.querySelectorAll('input')).toHaveLength(8); + expect(document.querySelectorAll('input')).toHaveLength(6); expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); + expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); // gear icon for y-axis + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('input')).toHaveLength(8); expect(document.querySelector('input[name=aggregate-method]').getAttribute('value')).toBe('MEAN'); expect(document.querySelectorAll('input[name=trendlineType]')).toHaveLength(0); }); From b73e8d96fc91a934e7bdb82a94f92046e131ccf8 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 12:38:05 -0500 Subject: [PATCH 09/35] 6.63.2-chartErrorBars.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index cb3ef7cb0d..292c63f296 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.63.2", + "version": "6.63.2-chartErrorBars.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.63.2", + "version": "6.63.2-chartErrorBars.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 85fe139a50..c33ece431c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.63.2", + "version": "6.63.2-chartErrorBars.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 1f82a66f659cf1dd5c57eec1ffcc3d6d7de98e18 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 15:15:39 -0500 Subject: [PATCH 10/35] RadioGroupInput to only call onSetValue if the option is not disabled --- .../components/forms/input/RadioGroupInput.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx b/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx index 8a07ba9558..7b7cbad469 100644 --- a/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx +++ b/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx @@ -25,14 +25,14 @@ const RadioGroupOption: FC = memo(props => { const { isSelected, name, option, showDescriptions, onSetValue } = props; const onLabelClick = useCallback(() => { - onSetValue(option.value); - }, [onSetValue, option.value]); + if (!option.disabled) onSetValue(option.value); + }, [onSetValue, option.value, option.disabled]); const onRadioChange = useCallback( (evt: ChangeEvent) => { - onSetValue(evt.target.value); + if (!option.disabled) onSetValue(evt.target.value); }, - [onSetValue] + [onSetValue, option.disabled] ); return ( @@ -107,7 +107,7 @@ const RadioGroupInputImpl: FC = memo(props => { <> {options?.map(option => ( Date: Thu, 9 Oct 2025 15:26:18 -0500 Subject: [PATCH 11/35] ChartFieldAggregateOptions.tsx support for error bar radio options --- .../components/releaseNotes/components.md | 3 +- .../chart/ChartBuilderModal.test.tsx | 12 +++- .../components/chart/ChartBuilderModal.tsx | 33 ++++++---- .../chart/ChartFieldAggregateOptions.tsx | 60 ++++++++++++------- .../components/chart/ChartFieldOption.tsx | 13 +++- .../internal/components/chart/constants.ts | 1 + 6 files changed, 85 insertions(+), 37 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index e321768f79..cd8d28670e 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -6,7 +6,8 @@ Components, models, actions, and utility functions for LabKey applications and p - ChartBuilderModal support for bar/line chart aggregate method and error bar options - useOverlayTriggerState update to not close popover on document click that is a select option target - Factor ChartFieldRangeScaleOptions.tsx out of ChartFieldOption.tsx - - ChartFieldAggregateOptions.tsx and move y-axis bar chart aggregate method dropdown into tooltip + - Create ChartFieldAggregateOptions.tsx and move y-axis bar chart aggregate method dropdown into tooltip + - ChartFieldAggregateOptions.tsx support for error bar radio options ### version 6.63.2 *Released*: 8 October 2025 diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx index 6e75c1d4fd..9a8d3b47c4 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx @@ -350,8 +350,11 @@ describe('ChartBuilderModal', () => { expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); // gear icon for y-axis await userEvent.click(document.querySelector('.fa-gear')); - expect(document.querySelectorAll('input')).toHaveLength(8); + expect(document.querySelectorAll('input')).toHaveLength(11); expect(document.querySelector('input[name=aggregate-method]').getAttribute('value')).toBe('SUM'); + expect(document.querySelectorAll('input[name=error-bar-method]')).toHaveLength(3); + expect(document.querySelector('input[value=SD]').hasAttribute('checked')).toBe(false); + expect(document.querySelector('input[value=SEM]').hasAttribute('checked')).toBe(false); }); test('init from bar chart with y axis value and aggregate method', async () => { @@ -364,7 +367,7 @@ describe('ChartBuilderModal', () => { visualizationConfig: { chartConfig: { renderType: 'bar_chart', - measures: { x: { name: 'field1' }, y: { name: 'field2', aggregate: { value: 'MEAN' } } }, + measures: { x: { name: 'field1' }, y: { name: 'field2', aggregate: { value: 'MEAN' }, errorBars: 'SEM' } }, labels: { x: 'Field 1', y: 'Field 2' }, }, queryConfig: { @@ -387,8 +390,11 @@ describe('ChartBuilderModal', () => { expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); // gear icon for y-axis await userEvent.click(document.querySelector('.fa-gear')); - expect(document.querySelectorAll('input')).toHaveLength(8); + expect(document.querySelectorAll('input')).toHaveLength(11); expect(document.querySelector('input[name=aggregate-method]').getAttribute('value')).toBe('MEAN'); + expect(document.querySelectorAll('input[name=error-bar-method]')).toHaveLength(3); + expect(document.querySelector('input[value=SD]').hasAttribute('checked')).toBe(false); + expect(document.querySelector('input[value=SEM]').hasAttribute('checked')).toBe(true); expect(document.querySelectorAll('input[name=trendlineType]')).toHaveLength(0); }); diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 720b3c93de..e67719bde1 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -28,6 +28,7 @@ import { isAppHomeFolder } from '../../app/utils'; import { deleteChart, saveChart, SaveReportConfig } from './actions'; import { BAR_CHART_AGGREGATE_NAME, + BAR_CHART_ERROR_BAR_NAME, BLUE_HEX_COLOR, HIDDEN_CHART_TYPES, ICONS, @@ -155,9 +156,12 @@ export const getChartBuilderChartConfig = ( type: getFieldDataType(fieldConfig.data), }; - // check if the field has an aggregate method (bar chart y-axis only) + // check if the field has an aggregate method and error bar method (bar chart y-axis only) if (fieldValues[BAR_CHART_AGGREGATE_NAME] && field.name === 'y') { config.measures[field.name].aggregate = { ...fieldValues[BAR_CHART_AGGREGATE_NAME] }; + if (fieldValues[BAR_CHART_ERROR_BAR_NAME]) { + config.measures[field.name].errorBars = fieldValues[BAR_CHART_ERROR_BAR_NAME]?.value; + } } // update axis label if it is a new report or if the saved report that didn't have this measure or was using the default field label for the axis label @@ -279,6 +283,10 @@ const ChartTypeQueryForm: FC = memo(props => { [selectedType] ); + const onErrorBarChange = useCallback((name: string, value: string) => { + onFieldChange(name, { value } as SelectInputOption); + }, [onFieldChange]); + const onSelectFieldChange = useCallback( (key: string, _: never, selectedOption: SelectInputOption) => { // clear / reset trendline option here if x change @@ -335,14 +343,15 @@ const ChartTypeQueryForm: FC = memo(props => {
{leftColFields.map(field => ( ))}
@@ -350,14 +359,15 @@ const ChartTypeQueryForm: FC = memo(props => { {rightColFields.map(field => ( ))} @@ -637,9 +647,12 @@ export const ChartBuilderModal: FC = memo(({ actions, mo fieldValues_['scales'] = { value: { ...chartConfig.scales } }; } - // handle bar chart aggregate method + // handle bar chart aggregate method and error bars if (measures.y?.aggregate) { fieldValues_[BAR_CHART_AGGREGATE_NAME] = { ...measures.y.aggregate }; + if (measures.y.errorBars) { + fieldValues_[BAR_CHART_ERROR_BAR_NAME] = { value: measures.y.errorBars }; + } } // handle trendline options diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx index 72a8ec8ce3..782211a1b2 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx @@ -1,10 +1,10 @@ -import React, { ChangeEvent, FC, memo, useCallback, useMemo } from 'react'; +import React, { FC, memo, useCallback, useMemo } from 'react'; import { OverlayTrigger } from '../../OverlayTrigger'; import { Popover } from '../../Popover'; import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; -import { BAR_CHART_AGGREGATE_NAME } from './constants'; -import { ChartFieldInfo, ChartTypeInfo } from './models'; +import { BAR_CHART_AGGREGATE_NAME, BAR_CHART_ERROR_BAR_NAME } from './constants'; +import { ChartFieldInfo } from './models'; import { LabelOverlay } from '../forms/LabelOverlay'; import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; @@ -18,9 +18,11 @@ const BAR_CHART_AGGREGATE_METHODS = [ ]; const BAR_CHART_AGGREGATE_METHOD_TIP = 'The aggregate method that will be used to determine the bar height for a given x-axis category / dimension. Field values that are blank are not included in calculated aggregate values.'; +const BAR_CHART_ERROR_BAR_TIP = + 'Show error bars on each bar representing Standard Deviation or Standard Error of the Mean. Only applicable for MEAN aggregate method.'; const ERROR_BAR_TYPES = [ - { value: null, label: 'None' }, + { value: undefined, label: 'None' }, { value: 'SD', label: 'Standard Deviation' }, { value: 'SEM', label: 'Standard Error of the Mean' }, ]; @@ -28,31 +30,43 @@ const ERROR_BAR_TYPES = [ interface Props { field: ChartFieldInfo; fieldValues: Record; + onErrorBarChange: (name: string, value: string) => void; onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; } export const ChartFieldAggregateOptions: FC = memo(props => { - const { field, fieldValues, onSelectFieldChange } = props; + const { field, fieldValues, onSelectFieldChange, onErrorBarChange } = props; const fieldValue = fieldValues?.[field.name]; const aggregateValue = fieldValues?.[BAR_CHART_AGGREGATE_NAME]?.value; + const errorBarValue = fieldValues?.[BAR_CHART_ERROR_BAR_NAME]?.value; + const errorBarRadioEnabled = useMemo(() => aggregateValue === 'MEAN', [aggregateValue]); const errorBarOptions = useMemo(() => { return ERROR_BAR_TYPES.map( option => ({ ...option, - selected: null === option.value, // TODO + disabled: !errorBarRadioEnabled, + selected: errorBarValue === option.value, }) as RadioGroupOption ); - }, []); + }, [errorBarRadioEnabled, errorBarValue]); - const onErrorBarChange = useCallback( - (selected: string) => { - console.log(field.name, selected); + const onAggregateChange = useCallback( + (name: string, value: string, selectedOption: SelectInputOption) => { + onSelectFieldChange(name, value, selectedOption); }, - [field.name] + [onSelectFieldChange] ); + const onErrorBarValueChange = useCallback( + (value: string) => { + onErrorBarChange(BAR_CHART_ERROR_BAR_NAME, value); + }, + [onErrorBarChange] + ); + + // Only show the aggregate options if there is a field selected if (!fieldValue?.value) { return null; } @@ -71,23 +85,25 @@ export const ChartFieldAggregateOptions: FC = memo(props => { clearable={false} inputClass="col-xs-12" name={BAR_CHART_AGGREGATE_NAME} - onChange={onSelectFieldChange} + onChange={onAggregateChange} options={BAR_CHART_AGGREGATE_METHODS} placeholder="Select aggregate method" showLabel={false} value={aggregateValue ?? 'SUM'} /> - {/*
*/} - {/* */} - {/*
*/} - {/* */} - {/*
*/} +
+ +
+ +
} triggerType="click" diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index 0e8ed07093..bb87d54d43 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -42,6 +42,7 @@ interface ChartFieldOptionProps { field: ChartFieldInfo; fieldValues?: Record; model: QueryModel; + onErrorBarChange: (name: string, value: string) => void; onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; scaleValues?: Record; @@ -49,7 +50,16 @@ interface ChartFieldOptionProps { } export const ChartFieldOption: FC = memo(props => { - const { field, model, selectedType, onSelectFieldChange, scaleValues, fieldValues, onScaleChange } = props; + const { + field, + model, + selectedType, + onSelectFieldChange, + scaleValues, + fieldValues, + onScaleChange, + onErrorBarChange, + } = props; const fieldValue = fieldValues?.[field.name]; const [scale, setScale] = useState>(scaleValues ?? {}); @@ -108,6 +118,7 @@ export const ChartFieldOption: FC = memo(props => { )} diff --git a/packages/components/src/internal/components/chart/constants.ts b/packages/components/src/internal/components/chart/constants.ts index b1d95ee3b3..2b71cd4f97 100644 --- a/packages/components/src/internal/components/chart/constants.ts +++ b/packages/components/src/internal/components/chart/constants.ts @@ -4,6 +4,7 @@ export const MAX_ROWS_PREVIEW = 10000; export const MAX_POINT_DISPLAY = 10000; export const BLUE_HEX_COLOR = '3366FF'; export const BAR_CHART_AGGREGATE_NAME = 'aggregate-method'; +export const BAR_CHART_ERROR_BAR_NAME = 'error-bar-method'; export const ICONS = { bar_chart: 'bar_chart', box_plot: 'box_plot', From 9efa2594bca9a5fb6301b1d81278debda8d9b057 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 15:27:11 -0500 Subject: [PATCH 12/35] 6.63.2-chartErrorBars.1 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 292c63f296..77c4b31a22 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.63.2-chartErrorBars.0", + "version": "6.63.2-chartErrorBars.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.63.2-chartErrorBars.0", + "version": "6.63.2-chartErrorBars.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index c33ece431c..83f6ba2746 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.63.2-chartErrorBars.0", + "version": "6.63.2-chartErrorBars.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 02161659d78ae4eab68fbc8e7e3ed7c278cc4685 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 16:40:06 -0500 Subject: [PATCH 13/35] support for error bar radio options as separate overlay or to be included in axis options overlay --- .../components/releaseNotes/components.md | 3 +- .../chart/ChartFieldAggregateOptions.tsx | 106 ++++++++++++------ .../components/chart/ChartFieldOption.tsx | 24 +++- .../chart/ChartFieldRangeScaleOptions.tsx | 9 +- .../internal/components/chart/utils.test.ts | 2 +- .../src/internal/components/chart/utils.ts | 13 ++- packages/components/src/theme/charts.scss | 7 ++ 7 files changed, 117 insertions(+), 47 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index cd8d28670e..21cda073b3 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -7,7 +7,8 @@ Components, models, actions, and utility functions for LabKey applications and p - useOverlayTriggerState update to not close popover on document click that is a select option target - Factor ChartFieldRangeScaleOptions.tsx out of ChartFieldOption.tsx - Create ChartFieldAggregateOptions.tsx and move y-axis bar chart aggregate method dropdown into tooltip - - ChartFieldAggregateOptions.tsx support for error bar radio options + - support for error bar radio options as separate overlay or to be included in axis options overlay + - Update ChartBuilderModal to pass down aggregate and error bar options to ChartConfig ### version 6.63.2 *Released*: 8 October 2025 diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx index 782211a1b2..544e5962c3 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx @@ -9,6 +9,7 @@ import { LabelOverlay } from '../forms/LabelOverlay'; import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; const BAR_CHART_AGGREGATE_METHODS = [ + { label: 'None', value: '' }, { label: 'Count (non-blank)', value: 'COUNT' }, { label: 'Sum', value: 'SUM' }, { label: 'Min', value: 'MIN' }, @@ -19,7 +20,7 @@ const BAR_CHART_AGGREGATE_METHODS = [ const BAR_CHART_AGGREGATE_METHOD_TIP = 'The aggregate method that will be used to determine the bar height for a given x-axis category / dimension. Field values that are blank are not included in calculated aggregate values.'; const BAR_CHART_ERROR_BAR_TIP = - 'Show error bars on each bar representing Standard Deviation or Standard Error of the Mean. Only applicable for MEAN aggregate method.'; + "Show error bars on each bar representing Standard Deviation or Standard Error of the Mean. Only applicable for 'Mean' aggregate method."; const ERROR_BAR_TYPES = [ { value: undefined, label: 'None' }, @@ -27,25 +28,58 @@ const ERROR_BAR_TYPES = [ { value: 'SEM', label: 'Standard Error of the Mean' }, ]; -interface Props { +interface OwnProps { + asOverlay?: boolean; field: ChartFieldInfo; fieldValues: Record; + includeCount: boolean; + includeNone: boolean; onErrorBarChange: (name: string, value: string) => void; onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; } -export const ChartFieldAggregateOptions: FC = memo(props => { - const { field, fieldValues, onSelectFieldChange, onErrorBarChange } = props; +export const ChartFieldAggregateOptions: FC = memo(props => { + const { + field, + fieldValues, + onSelectFieldChange, + onErrorBarChange, + includeCount, + includeNone, + asOverlay = true, + } = props; const fieldValue = fieldValues?.[field.name]; const aggregateValue = fieldValues?.[BAR_CHART_AGGREGATE_NAME]?.value; const errorBarValue = fieldValues?.[BAR_CHART_ERROR_BAR_NAME]?.value; + const defaultAggregateValue = useMemo(() => (includeNone ? '' : 'SUM'), [includeNone]); const errorBarRadioEnabled = useMemo(() => aggregateValue === 'MEAN', [aggregateValue]); + const aggregateOptions = useMemo(() => { + const options = BAR_CHART_AGGREGATE_METHODS.filter(option => { + if (option.value === 'COUNT' && !includeCount) { + return false; + } + if (option.value === '' && !includeNone) { + return false; + } + return true; + }); + + return options.map( + option => + ({ + ...option, + selected: aggregateValue === option.value, + }) as RadioGroupOption + ); + }, [aggregateValue, includeCount, includeNone]); + const errorBarOptions = useMemo(() => { return ERROR_BAR_TYPES.map( option => ({ ...option, + className: 'display-block', disabled: !errorBarRadioEnabled, selected: errorBarValue === option.value, }) as RadioGroupOption @@ -71,39 +105,47 @@ export const ChartFieldAggregateOptions: FC = memo(props => { return null; } + const inputs = ( + <> +
+ + +
+
+ + +
+ + ); + + if (!asOverlay) { + return inputs; + } + return (
-
- - -
-
- -
- -
+ {inputs} } triggerType="click" diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index bb87d54d43..d68dea43c8 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -38,7 +38,7 @@ export const getSelectOptions = ( const DEFAULT_SCALE_VALUES = { type: 'automatic', trans: 'linear' }; -interface ChartFieldOptionProps { +interface OwnProps { field: ChartFieldInfo; fieldValues?: Record; model: QueryModel; @@ -49,7 +49,7 @@ interface ChartFieldOptionProps { selectedType: ChartTypeInfo; } -export const ChartFieldOption: FC = memo(props => { +export const ChartFieldOption: FC = memo(props => { const { field, model, @@ -70,6 +70,8 @@ export const ChartFieldOption: FC = memo(props => { ); const showRangeScaleOptions = isNumericType && shouldShowRangeScaleOptions(field, selectedType); const showAggregateOptions = isNumericType && shouldShowAggregateOptions(field, selectedType); + const isBar = selectedType.name === 'bar_chart'; + const isLine = selectedType.name === 'line_plot'; // Issue 52050: use fieldKey for special characters const selectInputValue = useMemo(() => fieldValue?.data.fieldKey ?? fieldValue?.value, [fieldValue]); @@ -112,12 +114,26 @@ export const ChartFieldOption: FC = memo(props => { onScaleChange={onScaleChange} scale={scale} setScale={setScale} - /> + > + {isLine && showAggregateOptions && ( + + )} + )} - {showAggregateOptions && ( + {isBar && showAggregateOptions && ( diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx index a067dfed88..f31bcd28f2 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FC, memo, useCallback, useMemo } from 'react'; +import React, {ChangeEvent, FC, memo, PropsWithChildren, useCallback, useMemo} from 'react'; import { OverlayTrigger } from '../../OverlayTrigger'; import { Popover } from '../../Popover'; import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; @@ -15,15 +15,15 @@ const SCALE_RANGE_TYPES = [ { value: 'manual', label: 'Manual' }, ]; -interface Props { +interface OwnProps extends PropsWithChildren { field: ChartFieldInfo; onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; scale: Record; setScale: (scale) => void; } -export const ChartFieldRangeScaleOptions: FC = memo(props => { - const { field, scale, setScale, onScaleChange } = props; +export const ChartFieldRangeScaleOptions: FC = memo(props => { + const { field, scale, setScale, onScaleChange, children } = props; const scaleTransOptions = useMemo(() => { return SCALE_TRANS_TYPES.map( @@ -102,6 +102,7 @@ export const ChartFieldRangeScaleOptions: FC = memo(props => { + {children}
{ expect(shouldShowAggregateOptions(xField, SCATTER_PLOT_TYPE)).toBe(false); expect(shouldShowAggregateOptions(yField, SCATTER_PLOT_TYPE)).toBe(false); expect(shouldShowAggregateOptions(xField, LINE_PLOT_TYPE)).toBe(false); - expect(shouldShowAggregateOptions(yField, LINE_PLOT_TYPE)).toBe(false); + expect(shouldShowAggregateOptions(yField, LINE_PLOT_TYPE)).toBe(true); }); }); diff --git a/packages/components/src/internal/components/chart/utils.ts b/packages/components/src/internal/components/chart/utils.ts index cac2c3a64e..71f90d3e19 100644 --- a/packages/components/src/internal/components/chart/utils.ts +++ b/packages/components/src/internal/components/chart/utils.ts @@ -77,13 +77,16 @@ export const getFieldDataType = (fieldData: Record): string => { }; export const shouldShowRangeScaleOptions = (field: ChartFieldInfo, selectedType: ChartTypeInfo): boolean => { - const showForX = field.name === 'x' && (selectedType.name === 'scatter_plot' || selectedType.name === 'line_plot'); - const showForY = - field.name === 'y' && - (selectedType.name === 'scatter_plot' || selectedType.name === 'line_plot' || selectedType.name === 'box_plot'); + const isScatter = selectedType.name === 'scatter_plot'; + const isLine = selectedType.name === 'line_plot'; + const isBox = selectedType.name === 'box_plot'; + const showForX = field.name === 'x' && (isScatter || isLine); + const showForY = field.name === 'y' && (isScatter || isLine || isBox); return showForX || showForY; }; export const shouldShowAggregateOptions = (field: ChartFieldInfo, selectedType: ChartTypeInfo): boolean => { - return field.name === 'y' && selectedType.name === 'bar_chart'; + const isBar = selectedType.name === 'bar_chart'; + const isLine = selectedType.name === 'line_plot'; + return field.name === 'y' && (isBar || isLine); }; diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss index a97d61314e..c12362481a 100644 --- a/packages/components/src/theme/charts.scss +++ b/packages/components/src/theme/charts.scss @@ -286,6 +286,13 @@ cursor: default; } } +.field-option-radio-group-block { + padding-bottom: 10px; + + .radio-input-wrapper { + display: block; + } +} .chart-builder-preview-body { min-height: 355px; From bbe064c8a2b876ba8c01690cc2be0f71891ff3c0 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 16:51:57 -0500 Subject: [PATCH 14/35] jest test fix --- .../internal/components/chart/ChartBuilderModal.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx index 9a8d3b47c4..4d9caa3fe1 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx @@ -500,10 +500,12 @@ describe('ChartBuilderModal', () => { await userEvent.click(document.querySelectorAll('.fa-gear')[0]); // x-axis options icon, click to close await userEvent.click(document.querySelectorAll('.fa-gear')[1]); // y-axis options icon - expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('Log'); + expect(document.querySelectorAll('.radioinput-label.selected')).toHaveLength(3); // error bar, scale, range + expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('None'); + expect(document.querySelectorAll('.radioinput-label.selected')[1].textContent).toBe('Log'); + expect(document.querySelectorAll('.radioinput-label.selected')[2].textContent).toBe('Automatic'); expect(document.querySelectorAll('input[name=scaleTrans]')[0].hasAttribute('checked')).toBe(false); // linear expect(document.querySelectorAll('input[name=scaleTrans]')[1].hasAttribute('checked')).toBe(true); // log - expect(document.querySelectorAll('.radioinput-label.selected')[1].textContent).toBe('Automatic'); }); test('canDelete and canShare false', () => { From 64e716a93919590c5a7bc764cb167a90b479263c Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 16:52:50 -0500 Subject: [PATCH 15/35] 6.63.2-chartErrorBars.2 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 77c4b31a22..6c1c78bb7c 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.63.2-chartErrorBars.1", + "version": "6.63.2-chartErrorBars.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.63.2-chartErrorBars.1", + "version": "6.63.2-chartErrorBars.2", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 83f6ba2746..617174874f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.63.2-chartErrorBars.1", + "version": "6.63.2-chartErrorBars.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 2f82388d98ff1716657f492d98b84a87adc1cfa6 Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 10 Oct 2025 14:02:25 -0500 Subject: [PATCH 16/35] 6.64.1-chartErrorBars.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 161ab60f06..ad1372a5c0 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.1", + "version": "6.64.1-chartErrorBars.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.1", + "version": "6.64.1-chartErrorBars.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 5a994324eb..db656f8e41 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.1", + "version": "6.64.1-chartErrorBars.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 639db1cd73f6762cb1d09cfcdf417ec07d77bbe2 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 13 Oct 2025 09:04:54 -0500 Subject: [PATCH 17/35] 6.64.2-chartErrorBars.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index f8213951c2..2a0401afd2 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.2", + "version": "6.64.2-chartErrorBars.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.2", + "version": "6.64.2-chartErrorBars.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 01ba679eb6..be2e4441c2 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.2", + "version": "6.64.2-chartErrorBars.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 08e6ab29ddccbaac3b5fcb64113b3d200712f758 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 13 Oct 2025 11:44:30 -0500 Subject: [PATCH 18/35] 6.64.3-chartErrorBars.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 9dcfae3c08..4f7460b637 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.3", + "version": "6.64.3-chartErrorBars.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.3", + "version": "6.64.3-chartErrorBars.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index d843b88f24..680adb97c3 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.3", + "version": "6.64.3-chartErrorBars.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From c3075b325421e6cef7593e1b0c7e7369960d8a78 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 13 Oct 2025 15:51:20 -0500 Subject: [PATCH 19/35] github CR feedback --- packages/components/src/internal/OverlayTrigger.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/components/src/internal/OverlayTrigger.tsx b/packages/components/src/internal/OverlayTrigger.tsx index 7dbd6b4e4b..efb8291a5c 100644 --- a/packages/components/src/internal/OverlayTrigger.tsx +++ b/packages/components/src/internal/OverlayTrigger.tsx @@ -86,7 +86,8 @@ export function useOverlayTriggerState( event => { const isToggle = event.target === targetRef.current; const insideToggle = portalEl?.contains(event.target); - const isSelectOption = (event.target as HTMLElement).classList.contains('select-input__option'); + const isSelectOption = + event.target instanceof HTMLElement && event.target.classList.contains('select-input__option'); if (!isToggle && !insideToggle && !isSelectOption) { setShow(false); } From 91e4ab44eceaf3922cbfa1bdc5fcb3790bf71622 Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 14 Oct 2025 16:42:42 -0500 Subject: [PATCH 20/35] jest tests for chart field option components --- .../chart/ChartFieldAggregateOptions.test.tsx | 99 +++++++++++++++++++ .../ChartFieldRangeScaleOptions.test.tsx | 72 ++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx create mode 100644 packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx new file mode 100644 index 0000000000..d3b06a8597 --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { ChartFieldAggregateOptions } from './ChartFieldAggregateOptions'; +import { BAR_CHART_AGGREGATE_NAME, BAR_CHART_ERROR_BAR_NAME } from "./constants"; + +const field = { name: 'testField', label: 'Test Label', required: false }; +const fieldValues = { + testField: { value: 'ABC' }, + [BAR_CHART_AGGREGATE_NAME]: { value: 'SUM' }, + [BAR_CHART_ERROR_BAR_NAME]: undefined, +}; + +function renderComponent(props = {}) { + return render( + + ); +} + +describe('ChartFieldAggregateOptions', () => { + test('renders gear icon and overlay', async () => { + renderComponent(); + expect(document.querySelectorAll('.field-option-icon')).toHaveLength(1); + expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); + expect(document.querySelectorAll('.lk-popover')).toHaveLength(0); + + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('.lk-popover')).toHaveLength(1); + }); + + test('shows aggregate method select and error bar radio group in overlay', async () => { + renderComponent(); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('label')[0].textContent).toContain('Aggregate Method'); + expect(document.querySelectorAll('label')[1].textContent).toContain('Error Bars'); + expect(document.querySelectorAll('.field-option-radio-group')).toHaveLength(1); + expect(document.querySelectorAll('.select-input-container')).toHaveLength(1); + expect(document.querySelectorAll('input[type="radio"]')).toHaveLength(3); // None, SD, SEM + expect(document.querySelectorAll('input[type="radio"]:disabled')).toHaveLength(3); // None, SD, SEM disabled + expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('None'); + }); + + test('error bar radios are enabled for aggregate MEAN', async () => { + const fieldValuesMean = { + ...fieldValues, + [BAR_CHART_AGGREGATE_NAME]: { value: 'MEAN' }, + }; + renderComponent({ fieldValues: fieldValuesMean }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('input[type="radio"]:disabled')).toHaveLength(0); + expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('None'); + }); + + test('error bar radio value selected when fieldValues set', async () => { + const fieldValuesSEM = { + ...fieldValues, + [BAR_CHART_AGGREGATE_NAME]: { value: 'MEAN' }, + [BAR_CHART_ERROR_BAR_NAME]: { value: 'SEM' }, + }; + renderComponent({ fieldValues: fieldValuesSEM }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('input[type="radio"]:disabled')).toHaveLength(0); + expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('Standard Error of the Mean'); + }); + + test('does not render if no field is selected', async () => { + const emptyFieldValues = { + testField: undefined, + [BAR_CHART_AGGREGATE_NAME]: { value: 'MEAN' }, + [BAR_CHART_ERROR_BAR_NAME]: { value: 'SEM' }, + }; + renderComponent({ fieldValues: emptyFieldValues }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('label')).toHaveLength(0); + expect(document.querySelectorAll('input[type="radio"]')).toHaveLength(0); + }); + + test('renders inline inputs when asOverlay is false', () => { + const fieldValuesSEM = { + ...fieldValues, + [BAR_CHART_AGGREGATE_NAME]: { value: 'MEAN' }, + [BAR_CHART_ERROR_BAR_NAME]: { value: 'SEM' }, + }; + renderComponent({ fieldValues: fieldValuesSEM, asOverlay: false }); + expect(document.querySelectorAll('.field-option-icon')).toHaveLength(0); + expect(document.querySelectorAll('.fa-gear')).toHaveLength(0); + expect(document.querySelectorAll('.lk-popover')).toHaveLength(0); + expect(document.querySelectorAll('.select-input-container')).toHaveLength(1); + expect(document.querySelectorAll('.field-option-radio-group')).toHaveLength(1); + }); +}); diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx new file mode 100644 index 0000000000..72d41fa370 --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { ChartFieldRangeScaleOptions } from './ChartFieldRangeScaleOptions'; + +const field = { name: 'testField', label: 'Test Label', required: false }; + +function renderComponent(scale = {}) { + return render( + +
Children Content
+
+ ); +} + +describe('ChartFieldRangeScaleOptions', () => { + test('renders gear icon and children in overlay', async () => { + renderComponent(); + expect(document.querySelectorAll('.field-option-icon')).toHaveLength(1); + expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); + expect(document.querySelectorAll('.lk-popover')).toHaveLength(0); + expect(document.querySelectorAll('.child-content')).toHaveLength(0); + + // Simulate click to show overlay + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('.lk-popover')).toHaveLength(1); + expect(document.querySelectorAll('.child-content')).toHaveLength(1); + }); + + test('shows scale and range radio groups', async () => { + renderComponent({ trans: 'linear', type: 'automatic' }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('label')[0].textContent).toBe('Scale'); + expect(document.querySelectorAll('label')[1].textContent).toBe('Range'); + expect(document.querySelectorAll('.select-input-container')).toHaveLength(0); + expect(document.querySelectorAll('input[type="radio"]')).toHaveLength(4); // 2 for scale, 2 for range + expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('Linear'); + expect(document.querySelectorAll('.radioinput-label.selected')[1].textContent).toBe('Automatic'); + expect(document.querySelectorAll('input[type="number"]')).toHaveLength(0); // manual range inputs hidden by default + }); + + test('shows manual range inputs when scale.type is manual', async () => { + renderComponent({ trans: 'log', type: 'manual', min: '1', max: '2' }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('.select-input-container')).toHaveLength(0); + expect(document.querySelectorAll('input[type="radio"]')).toHaveLength(4); // 2 for scale, 2 for range + expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('Log'); + expect(document.querySelectorAll('.radioinput-label.selected')[1].textContent).toBe('Manual'); + expect(document.querySelectorAll('input[type="number"]')).toHaveLength(2); + expect(document.querySelector('input[name="scaleMin"]').getAttribute('value')).toBe('1'); + expect(document.querySelector('input[name="scaleMax"]').getAttribute('value')).toBe('2'); + expect(document.querySelectorAll('.text-danger')).toHaveLength(0); + }); + + test('shows invalid range warning when max <= min', async () => { + renderComponent({ trans: 'linear', type: 'manual', min: 10, max: 5 }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('.text-danger')).toHaveLength(1); + expect(document.querySelector('.text-danger').textContent).toBe('Invalid range (Max <= Min)'); + }); + + test('does not show invalid range warning when min is undefined', async () => { + renderComponent({ type: 'manual', min: undefined, max: 10 }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('.text-danger')).toHaveLength(0); + }); + test('does not show invalid range warning when max is undefined', async () => { + renderComponent({ type: 'manual', min: 5, max: undefined }); + await userEvent.click(document.querySelector('.fa-gear')); + expect(document.querySelectorAll('.text-danger')).toHaveLength(0); + }); +}); From 2b356e1affbc61cc031e0ae5306dafa81536892c Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 15 Oct 2025 08:22:42 -0500 Subject: [PATCH 21/35] RadioGroupInput.tsx label disabled styling --- .../src/internal/components/forms/input/RadioGroupInput.tsx | 5 ++++- packages/components/src/theme/charts.scss | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx b/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx index 7b7cbad469..41fafdc650 100644 --- a/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx +++ b/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx @@ -46,7 +46,10 @@ const RadioGroupOption: FC = memo(props => { onChange={onRadioChange} disabled={option.disabled} /> - + {option.label} {showDescriptions && option.description && ( diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss index c12362481a..1d050ce9da 100644 --- a/packages/components/src/theme/charts.scss +++ b/packages/components/src/theme/charts.scss @@ -285,6 +285,10 @@ padding-left: 5px; cursor: default; } + + .radioinput-label.disabled { + color: $text-muted; + } } .field-option-radio-group-block { padding-bottom: 10px; From d7f71fdc6ade0797bfc6fcb6f09ce2b1290883c8 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 15 Oct 2025 08:24:53 -0500 Subject: [PATCH 22/35] 6.64.3-chartErrorBars.1 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 4f7460b637..7137df2a2f 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.0", + "version": "6.64.3-chartErrorBars.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.0", + "version": "6.64.3-chartErrorBars.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 680adb97c3..b480ddee5e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.0", + "version": "6.64.3-chartErrorBars.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 47abfc11ba92524b4f3fb34a5cb4739ca2ba3ef5 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 08:16:44 -0500 Subject: [PATCH 23/35] CR feedback re: RadioGroupOption key --- .../src/internal/components/forms/input/RadioGroupInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx b/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx index 41fafdc650..5b107ec7a7 100644 --- a/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx +++ b/packages/components/src/internal/components/forms/input/RadioGroupInput.tsx @@ -110,7 +110,7 @@ const RadioGroupInputImpl: FC = memo(props => { <> {options?.map(option => ( Date: Thu, 16 Oct 2025 08:17:14 -0500 Subject: [PATCH 24/35] add ScaleType interface --- .../components/chart/ChartFieldOption.tsx | 6 ++--- .../chart/ChartFieldRangeScaleOptions.tsx | 22 +++++++++---------- .../src/internal/components/chart/models.ts | 7 ++++++ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index d68dea43c8..b45f63a070 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -8,7 +8,7 @@ import { LABKEY_VIS } from '../../constants'; import { naturalSortByProperty } from '../../../public/sort'; import { ChartFieldRangeScaleOptions } from './ChartFieldRangeScaleOptions'; -import { ChartFieldInfo, ChartTypeInfo } from './models'; +import { ChartFieldInfo, ChartTypeInfo, ScaleType } from './models'; import { getFieldDataType, shouldShowAggregateOptions, shouldShowRangeScaleOptions } from './utils'; import { ChartFieldAggregateOptions } from './ChartFieldAggregateOptions'; @@ -45,7 +45,7 @@ interface OwnProps { onErrorBarChange: (name: string, value: string) => void; onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; - scaleValues?: Record; + scaleValues?: ScaleType; selectedType: ChartTypeInfo; } @@ -61,7 +61,7 @@ export const ChartFieldOption: FC = memo(props => { onErrorBarChange, } = props; const fieldValue = fieldValues?.[field.name]; - const [scale, setScale] = useState>(scaleValues ?? {}); + const [scale, setScale] = useState(scaleValues ?? ({} as ScaleType)); const options = useMemo(() => getSelectOptions(model, selectedType, field), [model, selectedType, field]); const isNumericType = useMemo( diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx index f31bcd28f2..bd8cb105e4 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx @@ -1,9 +1,9 @@ -import React, {ChangeEvent, FC, memo, PropsWithChildren, useCallback, useMemo} from 'react'; +import React, { ChangeEvent, FC, memo, PropsWithChildren, useCallback, useMemo } from 'react'; import { OverlayTrigger } from '../../OverlayTrigger'; import { Popover } from '../../Popover'; import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; -import { ChartFieldInfo } from './models'; +import { ChartFieldInfo, ScaleType } from './models'; const SCALE_TRANS_TYPES = [ { value: 'linear', label: 'Linear' }, @@ -18,8 +18,8 @@ const SCALE_RANGE_TYPES = [ interface OwnProps extends PropsWithChildren { field: ChartFieldInfo; onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; - scale: Record; - setScale: (scale) => void; + scale: ScaleType; + setScale: (scale: ScaleType) => void; } export const ChartFieldRangeScaleOptions: FC = memo(props => { @@ -58,14 +58,14 @@ export const ChartFieldRangeScaleOptions: FC = memo(props => { const onScaleTransChange = useCallback( (selected: string) => { onScaleChange(field.name, 'trans', selected); - setScale(prev => ({ ...prev, trans: selected })); + setScale({ ...scale, trans: selected }); }, - [field.name, onScaleChange, setScale] + [field.name, onScaleChange, setScale, scale] ); const onScaleTypeChange = useCallback( (selected: string) => { - let scale_: Record = { ...scale, type: selected }; + let scale_ = { ...scale, type: selected }; onScaleChange(field.name, 'type', selected); if (selected === 'automatic') { onScaleChange(field.name, 'min', undefined); @@ -79,16 +79,16 @@ export const ChartFieldRangeScaleOptions: FC = memo(props => { const onScaleMinChange = useCallback( (event: ChangeEvent) => { - setScale(prev => ({ ...prev, min: event.target.value })); + setScale({ ...scale, min: event.target.value }); }, - [setScale] + [setScale, scale] ); const onScaleMaxChange = useCallback( (event: ChangeEvent) => { - setScale(prev => ({ ...prev, max: event.target.value })); + setScale({ ...scale, max: event.target.value }); }, - [setScale] + [setScale, scale] ); const onScaleRangeBlur = useCallback(() => { diff --git a/packages/components/src/internal/components/chart/models.ts b/packages/components/src/internal/components/chart/models.ts index 92109b4c4a..999f8dfcb0 100644 --- a/packages/components/src/internal/components/chart/models.ts +++ b/packages/components/src/internal/components/chart/models.ts @@ -71,3 +71,10 @@ export interface ChartTypeInfo { name: string; title: string; } + +export interface ScaleType { + max?: number | string; + min?: number | string; + trans: string; + type: string; +} From 4dd953c559df2c06a9380bdcdb80892e2b2d4f88 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 09:59:26 -0500 Subject: [PATCH 25/35] CR changes - useMemo instead of useEffect, test selector updates, prop optional/required updates --- .../components/chart/ChartBuilderModal.tsx | 41 +++++++++---------- .../chart/ChartFieldAggregateOptions.test.tsx | 12 ++++-- .../chart/ChartFieldAggregateOptions.tsx | 27 ++++-------- .../components/chart/ChartFieldOption.tsx | 14 ++----- .../ChartFieldRangeScaleOptions.test.tsx | 2 + .../chart/ChartFieldRangeScaleOptions.tsx | 18 ++------ 6 files changed, 45 insertions(+), 69 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index e67719bde1..655a5a23c4 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -284,7 +284,7 @@ const ChartTypeQueryForm: FC = memo(props => { ); const onErrorBarChange = useCallback((name: string, value: string) => { - onFieldChange(name, { value } as SelectInputOption); + onFieldChange(name, { value }); }, [onFieldChange]); const onSelectFieldChange = useCallback( @@ -408,8 +408,6 @@ const ChartPreview: FC = memo(props => { if (!hasRequiredValues) return; - const width = ref?.current.getBoundingClientRect().width || 750; - const chartConfig = getChartBuilderChartConfig( selectedType, fieldValues, @@ -469,6 +467,7 @@ const ChartPreview: FC = memo(props => { } // adjust height, width, and marginTop for the chart config for the preview, but not to save with the chart + const width = ref?.current.getBoundingClientRect().width || 750; const chartConfig_ = { ...chartConfig, height: 350, @@ -596,8 +595,11 @@ interface ChartBuilderModalProps extends RequiresModelAndActions { } export const ChartBuilderModal: FC = memo(({ actions, model, onHide, savedChartModel }) => { - const CHART_TYPES = LABKEY_VIS?.GenericChartHelper.getRenderTypes(); - const TRENDLINE_OPTIONS: TrendlineType[] = Object.values(LABKEY_VIS.GenericChartHelper.TRENDLINE_OPTIONS); + const CHART_TYPES = useMemo(() => LABKEY_VIS?.GenericChartHelper.getRenderTypes(), []); + const TRENDLINE_OPTIONS: TrendlineType[] = useMemo( + () => Object.values(LABKEY_VIS.GenericChartHelper.TRENDLINE_OPTIONS), + [] + ); const { user, container, moduleContext } = useServerContext(); const canShare = useMemo( @@ -613,24 +615,20 @@ export const ChartBuilderModal: FC = memo(({ actions, mo () => CHART_TYPES.filter(type => !type.hidden && !HIDDEN_CHART_TYPES.includes(type.name)), [CHART_TYPES] ); + const chartConfig = useMemo(() => savedChartModel?.visualizationConfig?.chartConfig, [savedChartModel]); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [reportConfig, setReportConfig] = useState(); - const [selectedType, setSelectedChartType] = useState(chartTypes[0]); - const [name, setName] = useState(''); - const [shared, setShared] = useState(canShare); - const [inheritable, setInheritable] = useState(false); - const [fieldValues, setFieldValues] = useState>({}); + const [selectedType, setSelectedChartType] = useState( + chartTypes.find(c => chartConfig?.renderType === c.name) ?? chartTypes[0] + ); + const [name, setName] = useState(savedChartModel?.name ?? ''); + const [shared, setShared] = useState(savedChartModel?.shared ?? canShare); + const [inheritable, setInheritable] = useState(savedChartModel?.inheritable ?? false); - useEffect( + const initFieldValues = useMemo( () => { if (savedChartModel) { - const chartConfig = savedChartModel.visualizationConfig?.chartConfig; - setSelectedChartType(chartTypes.find(c => chartConfig?.renderType === c.name)); - setName(savedChartModel.name); - setShared(savedChartModel.shared); - setInheritable(savedChartModel.inheritable); - const measures = chartConfig?.measures || {}; const fieldValues_ = Object.keys(measures).reduce((result, key) => { let measure = measures[key]; @@ -672,13 +670,14 @@ export const ChartBuilderModal: FC = memo(({ actions, mo } } - setFieldValues(fieldValues_); + return fieldValues_; } + + return {}; }, - [ - /* on mount only */ - ] + [savedChartModel, chartConfig, TRENDLINE_OPTIONS] ); + const [fieldValues, setFieldValues] = useState>(initFieldValues); const hasName = useMemo(() => name?.trim().length > 0, [name]); const hasRequiredValues = useMemo(() => { diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx index d3b06a8597..6ee662fd6e 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx @@ -44,7 +44,9 @@ describe('ChartFieldAggregateOptions', () => { expect(document.querySelectorAll('.field-option-radio-group')).toHaveLength(1); expect(document.querySelectorAll('.select-input-container')).toHaveLength(1); expect(document.querySelectorAll('input[type="radio"]')).toHaveLength(3); // None, SD, SEM - expect(document.querySelectorAll('input[type="radio"]:disabled')).toHaveLength(3); // None, SD, SEM disabled + expect(document.querySelector('input[name="error-bar-method"][value=""]').hasAttribute('disabled')).toBeTruthy(); + expect(document.querySelector('input[name="error-bar-method"][value="SD"]').hasAttribute('disabled')).toBeTruthy(); + expect(document.querySelector('input[name="error-bar-method"][value="SEM"]').hasAttribute('disabled')).toBeTruthy(); expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('None'); }); @@ -55,7 +57,9 @@ describe('ChartFieldAggregateOptions', () => { }; renderComponent({ fieldValues: fieldValuesMean }); await userEvent.click(document.querySelector('.fa-gear')); - expect(document.querySelectorAll('input[type="radio"]:disabled')).toHaveLength(0); + expect(document.querySelector('input[name="error-bar-method"][value=""]').hasAttribute('disabled')).toBeFalsy(); + expect(document.querySelector('input[name="error-bar-method"][value="SD"]').hasAttribute('disabled')).toBeFalsy(); + expect(document.querySelector('input[name="error-bar-method"][value="SEM"]').hasAttribute('disabled')).toBeFalsy(); expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('None'); }); @@ -67,7 +71,9 @@ describe('ChartFieldAggregateOptions', () => { }; renderComponent({ fieldValues: fieldValuesSEM }); await userEvent.click(document.querySelector('.fa-gear')); - expect(document.querySelectorAll('input[type="radio"]:disabled')).toHaveLength(0); + expect(document.querySelector('input[name="error-bar-method"][value=""]').hasAttribute('disabled')).toBeFalsy(); + expect(document.querySelector('input[name="error-bar-method"][value="SD"]').hasAttribute('disabled')).toBeFalsy(); + expect(document.querySelector('input[name="error-bar-method"][value="SEM"]').hasAttribute('disabled')).toBeFalsy(); expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('Standard Error of the Mean'); }); diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx index 544e5962c3..083b6fa8f4 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx @@ -59,31 +59,18 @@ export const ChartFieldAggregateOptions: FC = memo(props => { if (option.value === 'COUNT' && !includeCount) { return false; } - if (option.value === '' && !includeNone) { - return false; - } - return true; + return !(option.value === '' && !includeNone); }); - return options.map( - option => - ({ - ...option, - selected: aggregateValue === option.value, - }) as RadioGroupOption - ); + return options.map(option => ({ ...option, selected: aggregateValue === option.value })); }, [aggregateValue, includeCount, includeNone]); const errorBarOptions = useMemo(() => { - return ERROR_BAR_TYPES.map( - option => - ({ - ...option, - className: 'display-block', - disabled: !errorBarRadioEnabled, - selected: errorBarValue === option.value, - }) as RadioGroupOption - ); + return ERROR_BAR_TYPES.map(option => ({ + ...option, + disabled: !errorBarRadioEnabled, + selected: errorBarValue === option.value, + })); }, [errorBarRadioEnabled, errorBarValue]); const onAggregateChange = useCallback( diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index b45f63a070..11309eb260 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -1,4 +1,4 @@ -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, memo, useCallback, useMemo, useState } from 'react'; import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; @@ -40,12 +40,12 @@ const DEFAULT_SCALE_VALUES = { type: 'automatic', trans: 'linear' }; interface OwnProps { field: ChartFieldInfo; - fieldValues?: Record; + fieldValues: Record; model: QueryModel; onErrorBarChange: (name: string, value: string) => void; onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; - scaleValues?: ScaleType; + scaleValues: ScaleType; selectedType: ChartTypeInfo; } @@ -61,7 +61,7 @@ export const ChartFieldOption: FC = memo(props => { onErrorBarChange, } = props; const fieldValue = fieldValues?.[field.name]; - const [scale, setScale] = useState(scaleValues ?? ({} as ScaleType)); + const [scale, setScale] = useState(scaleValues?.type ? scaleValues : DEFAULT_SCALE_VALUES); const options = useMemo(() => getSelectOptions(model, selectedType, field), [model, selectedType, field]); const isNumericType = useMemo( @@ -76,12 +76,6 @@ export const ChartFieldOption: FC = memo(props => { // Issue 52050: use fieldKey for special characters const selectInputValue = useMemo(() => fieldValue?.data.fieldKey ?? fieldValue?.value, [fieldValue]); - useEffect(() => { - if (showRangeScaleOptions && !scale.type) { - setScale(scaleValues?.type ? scaleValues : DEFAULT_SCALE_VALUES); - } - }, [showRangeScaleOptions, scale.type, scaleValues]); - const onSelectFieldChange_ = useCallback( (name: string, value: string, selectedOption: SelectInputOption) => { onScaleChange(field.name, undefined, undefined, true); diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx index 72d41fa370..96177a4543 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx @@ -34,6 +34,8 @@ describe('ChartFieldRangeScaleOptions', () => { expect(document.querySelectorAll('label')[1].textContent).toBe('Range'); expect(document.querySelectorAll('.select-input-container')).toHaveLength(0); expect(document.querySelectorAll('input[type="radio"]')).toHaveLength(4); // 2 for scale, 2 for range + expect(document.querySelectorAll('input[name="scaleTrans"]')).toHaveLength(2); + expect(document.querySelectorAll('input[name="scaleType"]')).toHaveLength(2); expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('Linear'); expect(document.querySelectorAll('.radioinput-label.selected')[1].textContent).toBe('Automatic'); expect(document.querySelectorAll('input[type="number"]')).toHaveLength(0); // manual range inputs hidden by default diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx index bd8cb105e4..2945814d7b 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx @@ -26,23 +26,11 @@ export const ChartFieldRangeScaleOptions: FC = memo(props => { const { field, scale, setScale, onScaleChange, children } = props; const scaleTransOptions = useMemo(() => { - return SCALE_TRANS_TYPES.map( - option => - ({ - ...option, - selected: scale.trans === option.value, - }) as RadioGroupOption - ); + return SCALE_TRANS_TYPES.map(option => ({ ...option, selected: scale.trans === option.value })); }, [scale.trans]); const scaleTypeOptions = useMemo(() => { - return SCALE_RANGE_TYPES.map( - option => - ({ - ...option, - selected: scale.type === option.value, - }) as RadioGroupOption - ); + return SCALE_RANGE_TYPES.map(option => ({ ...option, selected: scale.type === option.value })); }, [scale.type]); const invalidRange = useMemo( @@ -132,7 +120,7 @@ export const ChartFieldRangeScaleOptions: FC = memo(props => { type="number" value={scale.min ?? ''} /> -   -  + - Date: Thu, 16 Oct 2025 10:00:14 -0500 Subject: [PATCH 26/35] 6.64.3-chartErrorBars.2 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 7137df2a2f..7aaf903695 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.1", + "version": "6.64.3-chartErrorBars.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.1", + "version": "6.64.3-chartErrorBars.2", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index b480ddee5e..423a15f519 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.1", + "version": "6.64.3-chartErrorBars.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From acbab6dfb405067fe23a792768b3afe379449ed0 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 10:52:49 -0500 Subject: [PATCH 27/35] show range options for bar chart y-axis (but not linear / log) --- .../chart/ChartBuilderModal.test.tsx | 8 ++++-- .../chart/ChartFieldAggregateOptions.test.tsx | 6 ++--- .../chart/ChartFieldAggregateOptions.tsx | 19 +++++--------- .../components/chart/ChartFieldOption.tsx | 18 +++---------- .../ChartFieldRangeScaleOptions.test.tsx | 15 ++++++++--- .../chart/ChartFieldRangeScaleOptions.tsx | 26 +++++++++++-------- .../internal/components/chart/utils.test.ts | 2 +- .../src/internal/components/chart/utils.ts | 3 ++- 8 files changed, 47 insertions(+), 50 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx index 4d9caa3fe1..7941a689c8 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx @@ -350,7 +350,9 @@ describe('ChartBuilderModal', () => { expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); // gear icon for y-axis await userEvent.click(document.querySelector('.fa-gear')); - expect(document.querySelectorAll('input')).toHaveLength(11); + expect(document.querySelectorAll('input')).toHaveLength(13); + expect(document.querySelector('input[value=automatic]').hasAttribute('checked')).toBe(true); + expect(document.querySelector('input[value=manual]').hasAttribute('checked')).toBe(false); expect(document.querySelector('input[name=aggregate-method]').getAttribute('value')).toBe('SUM'); expect(document.querySelectorAll('input[name=error-bar-method]')).toHaveLength(3); expect(document.querySelector('input[value=SD]').hasAttribute('checked')).toBe(false); @@ -390,7 +392,9 @@ describe('ChartBuilderModal', () => { expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); expect(document.querySelectorAll('.fa-gear')).toHaveLength(1); // gear icon for y-axis await userEvent.click(document.querySelector('.fa-gear')); - expect(document.querySelectorAll('input')).toHaveLength(11); + expect(document.querySelectorAll('input')).toHaveLength(13); + expect(document.querySelector('input[value=automatic]').hasAttribute('checked')).toBe(true); + expect(document.querySelector('input[value=manual]').hasAttribute('checked')).toBe(false); expect(document.querySelector('input[name=aggregate-method]').getAttribute('value')).toBe('MEAN'); expect(document.querySelectorAll('input[name=error-bar-method]')).toHaveLength(3); expect(document.querySelector('input[value=SD]').hasAttribute('checked')).toBe(false); diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx index 6ee662fd6e..85cbd33b3d 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { ChartFieldAggregateOptions } from './ChartFieldAggregateOptions'; -import { BAR_CHART_AGGREGATE_NAME, BAR_CHART_ERROR_BAR_NAME } from "./constants"; +import { BAR_CHART_AGGREGATE_NAME, BAR_CHART_ERROR_BAR_NAME } from './constants'; +import { ChartTypeInfo } from './models'; const field = { name: 'testField', label: 'Test Label', required: false }; const fieldValues = { @@ -16,10 +17,9 @@ function renderComponent(props = {}) { ); diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx index 083b6fa8f4..bc61847f5c 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx @@ -1,10 +1,10 @@ import React, { FC, memo, useCallback, useMemo } from 'react'; import { OverlayTrigger } from '../../OverlayTrigger'; import { Popover } from '../../Popover'; -import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; +import { RadioGroupInput } from '../forms/input/RadioGroupInput'; import { BAR_CHART_AGGREGATE_NAME, BAR_CHART_ERROR_BAR_NAME } from './constants'; -import { ChartFieldInfo } from './models'; +import { ChartFieldInfo, ChartTypeInfo } from './models'; import { LabelOverlay } from '../forms/LabelOverlay'; import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; @@ -32,25 +32,18 @@ interface OwnProps { asOverlay?: boolean; field: ChartFieldInfo; fieldValues: Record; - includeCount: boolean; - includeNone: boolean; onErrorBarChange: (name: string, value: string) => void; onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; + selectedType: ChartTypeInfo; } export const ChartFieldAggregateOptions: FC = memo(props => { - const { - field, - fieldValues, - onSelectFieldChange, - onErrorBarChange, - includeCount, - includeNone, - asOverlay = true, - } = props; + const { field, fieldValues, onSelectFieldChange, onErrorBarChange, asOverlay = true, selectedType } = props; const fieldValue = fieldValues?.[field.name]; const aggregateValue = fieldValues?.[BAR_CHART_AGGREGATE_NAME]?.value; const errorBarValue = fieldValues?.[BAR_CHART_ERROR_BAR_NAME]?.value; + const includeNone = selectedType.name === 'line_plot'; + const includeCount = selectedType.name === 'bar_chart'; const defaultAggregateValue = useMemo(() => (includeNone ? '' : 'SUM'), [includeNone]); const errorBarRadioEnabled = useMemo(() => aggregateValue === 'MEAN', [aggregateValue]); diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index 11309eb260..cc716ee5a6 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -70,8 +70,6 @@ export const ChartFieldOption: FC = memo(props => { ); const showRangeScaleOptions = isNumericType && shouldShowRangeScaleOptions(field, selectedType); const showAggregateOptions = isNumericType && shouldShowAggregateOptions(field, selectedType); - const isBar = selectedType.name === 'bar_chart'; - const isLine = selectedType.name === 'line_plot'; // Issue 52050: use fieldKey for special characters const selectInputValue = useMemo(() => fieldValue?.data.fieldKey ?? fieldValue?.value, [fieldValue]); @@ -108,30 +106,20 @@ export const ChartFieldOption: FC = memo(props => { onScaleChange={onScaleChange} scale={scale} setScale={setScale} + showScaleTrans={selectedType.name !== 'bar_chart'} > - {isLine && showAggregateOptions && ( + {showAggregateOptions && ( )} )} - {isBar && showAggregateOptions && ( - - )}
); diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx index 96177a4543..6bde631519 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx @@ -2,12 +2,19 @@ import React from 'react'; import { render } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { ChartFieldRangeScaleOptions } from './ChartFieldRangeScaleOptions'; +import { ScaleType } from './models'; const field = { name: 'testField', label: 'Test Label', required: false }; -function renderComponent(scale = {}) { +function renderComponent(scale = {} as ScaleType) { return render( - +
Children Content
); @@ -62,12 +69,12 @@ describe('ChartFieldRangeScaleOptions', () => { }); test('does not show invalid range warning when min is undefined', async () => { - renderComponent({ type: 'manual', min: undefined, max: 10 }); + renderComponent({ type: 'manual', min: undefined, max: 10 } as ScaleType); await userEvent.click(document.querySelector('.fa-gear')); expect(document.querySelectorAll('.text-danger')).toHaveLength(0); }); test('does not show invalid range warning when max is undefined', async () => { - renderComponent({ type: 'manual', min: 5, max: undefined }); + renderComponent({ type: 'manual', min: 5, max: undefined } as ScaleType); await userEvent.click(document.querySelector('.fa-gear')); expect(document.querySelectorAll('.text-danger')).toHaveLength(0); }); diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx index 2945814d7b..b3d63663fd 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx @@ -20,10 +20,12 @@ interface OwnProps extends PropsWithChildren { onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; scale: ScaleType; setScale: (scale: ScaleType) => void; + showScaleTrans: boolean; } export const ChartFieldRangeScaleOptions: FC = memo(props => { - const { field, scale, setScale, onScaleChange, children } = props; + const { field, scale, setScale, onScaleChange, showScaleTrans, children } = props; + const placement = useMemo(() => (!showScaleTrans && children ? 'left' : 'bottom'), [showScaleTrans, children]); const scaleTransOptions = useMemo(() => { return SCALE_TRANS_TYPES.map(option => ({ ...option, selected: scale.trans === option.value })); @@ -89,17 +91,19 @@ export const ChartFieldRangeScaleOptions: FC = memo(props => {
+ {children} -
- - -
+ {showScaleTrans && ( +
+ + +
+ )}
{ test('based on chart type', () => { expect(shouldShowRangeScaleOptions(xField, BAR_CHART_TYPE)).toBe(false); - expect(shouldShowRangeScaleOptions(yField, BAR_CHART_TYPE)).toBe(false); + expect(shouldShowRangeScaleOptions(yField, BAR_CHART_TYPE)).toBe(true); expect(shouldShowRangeScaleOptions(xField, BOX_PLOT_TYPE)).toBe(false); expect(shouldShowRangeScaleOptions(yField, BOX_PLOT_TYPE)).toBe(true); expect(shouldShowRangeScaleOptions(xField, SCATTER_PLOT_TYPE)).toBe(true); diff --git a/packages/components/src/internal/components/chart/utils.ts b/packages/components/src/internal/components/chart/utils.ts index 71f90d3e19..14baf1acf0 100644 --- a/packages/components/src/internal/components/chart/utils.ts +++ b/packages/components/src/internal/components/chart/utils.ts @@ -80,8 +80,9 @@ export const shouldShowRangeScaleOptions = (field: ChartFieldInfo, selectedType: const isScatter = selectedType.name === 'scatter_plot'; const isLine = selectedType.name === 'line_plot'; const isBox = selectedType.name === 'box_plot'; + const isBar = selectedType.name === 'bar_chart'; const showForX = field.name === 'x' && (isScatter || isLine); - const showForY = field.name === 'y' && (isScatter || isLine || isBox); + const showForY = field.name === 'y' && (isScatter || isLine || isBox || isBar); return showForX || showForY; }; From bdc6e002e991523d392b01e58c701358635d4147 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 14:05:03 -0500 Subject: [PATCH 28/35] 6.64.3-chartErrorBars.3 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 7aaf903695..0a5c045ac1 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.2", + "version": "6.64.3-chartErrorBars.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.2", + "version": "6.64.3-chartErrorBars.3", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 423a15f519..ffa93d7e70 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.2", + "version": "6.64.3-chartErrorBars.3", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From a4d3a4d96b6be4b403e66dafa95266ba00a4fa7d Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 14:24:23 -0500 Subject: [PATCH 29/35] support LKS saved config with multiple y-axis measures (only use first) --- .../src/internal/components/chart/ChartBuilderModal.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 655a5a23c4..3752f83c94 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -646,10 +646,11 @@ export const ChartBuilderModal: FC = memo(({ actions, mo } // handle bar chart aggregate method and error bars - if (measures.y?.aggregate) { - fieldValues_[BAR_CHART_AGGREGATE_NAME] = { ...measures.y.aggregate }; - if (measures.y.errorBars) { - fieldValues_[BAR_CHART_ERROR_BAR_NAME] = { value: measures.y.errorBars }; + const y = Utils.isArray(measures.y) ? measures.y[0] : measures.y; + if (y?.aggregate) { + fieldValues_[BAR_CHART_AGGREGATE_NAME] = Utils.isObject(y.aggregate) ? { ...y.aggregate } : { value: y.aggregate }; + if (y.errorBars) { + fieldValues_[BAR_CHART_ERROR_BAR_NAME] = { value: y.errorBars }; } } From 7dfae7f1e9db6345dccd1599d8c5ee8e36ff829b Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 14:25:28 -0500 Subject: [PATCH 30/35] 6.64.3-chartErrorBars.4 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 0a5c045ac1..331d2087b0 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.3", + "version": "6.64.3-chartErrorBars.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.3", + "version": "6.64.3-chartErrorBars.4", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index ffa93d7e70..977cc41f42 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.64.3-chartErrorBars.3", + "version": "6.64.3-chartErrorBars.4", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From c8e1679de35afef1c99e8c65e6c0c63fbed732c4 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 20 Oct 2025 16:42:44 -0500 Subject: [PATCH 31/35] 6.65.1-chartErrorBars.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 125eda9bc1..c2dfc0de30 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.65.1", + "version": "6.65.1-chartErrorBars.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.65.1", + "version": "6.65.1-chartErrorBars.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 4940c54efe..9d511bb049 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.65.1", + "version": "6.65.1-chartErrorBars.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 7fd4bb3d158af74d9d4f88e8b63f0be7ebed539c Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 22 Oct 2025 15:54:21 -0500 Subject: [PATCH 32/35] 6.65.2-chartErrorBars.0 --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 9f6382cb40..61a871c038 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.65.2", + "version": "6.65.2-chartErrorBars.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.65.2", + "version": "6.65.2-chartErrorBars.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 2e5513e213..5a0f1f3a8e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.65.2", + "version": "6.65.2-chartErrorBars.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 0327b018d6e4356cb06363a7e1025c50f6286417 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 23 Oct 2025 08:34:31 -0500 Subject: [PATCH 33/35] Update release notes with version number and release date --- packages/components/releaseNotes/components.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 362ff62b59..32ae27edd9 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,8 +1,8 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages -### version TBD -*Released*: TBD +### version 6.66.0 +*Released*: 23 October 2025 - ChartBuilderModal support for bar/line chart aggregate method and error bar options - useOverlayTriggerState update to not close popover on document click that is a select option target - Factor ChartFieldRangeScaleOptions.tsx out of ChartFieldOption.tsx From 3fc89a4472966ac6fa93a55d3560e45f021f9123 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 23 Oct 2025 08:35:29 -0500 Subject: [PATCH 34/35] npm run lint-branch-fix --- .../src/internal/OverlayTrigger.tsx | 16 +-- .../chart/ChartBuilderModal.test.tsx | 5 +- .../components/chart/ChartBuilderModal.tsx | 130 +++++++++--------- .../chart/ChartFieldAggregateOptions.test.tsx | 32 +++-- .../chart/ChartFieldOption.test.tsx | 18 +-- .../forms/input/RadioGroupInput.tsx | 14 +- 6 files changed, 118 insertions(+), 97 deletions(-) diff --git a/packages/components/src/internal/OverlayTrigger.tsx b/packages/components/src/internal/OverlayTrigger.tsx index efb8291a5c..e5d11057a2 100644 --- a/packages/components/src/internal/OverlayTrigger.tsx +++ b/packages/components/src/internal/OverlayTrigger.tsx @@ -1,16 +1,16 @@ import React, { - cloneElement, Children, + cloneElement, + CSSProperties, FC, - useRef, - ReactElement, - useState, - useCallback, MutableRefObject, - CSSProperties, - useMemo, PropsWithChildren, + ReactElement, + useCallback, useEffect, + useMemo, + useRef, + useState, } from 'react'; import { createPortal } from 'react-dom'; @@ -170,9 +170,9 @@ export const OverlayTrigger: FC = ({ return (
{clonedChild} diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx index 7941a689c8..24d3b8e15a 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx @@ -369,7 +369,10 @@ describe('ChartBuilderModal', () => { visualizationConfig: { chartConfig: { renderType: 'bar_chart', - measures: { x: { name: 'field1' }, y: { name: 'field2', aggregate: { value: 'MEAN' }, errorBars: 'SEM' } }, + measures: { + x: { name: 'field1' }, + y: { name: 'field2', aggregate: { value: 'MEAN' }, errorBars: 'SEM' }, + }, labels: { x: 'Field 1', y: 'Field 2' }, }, queryConfig: { diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 370289becf..d39e674ae5 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -218,9 +218,9 @@ const ChartTypeSideBar: FC = memo(props => { return (
@@ -283,9 +283,12 @@ const ChartTypeQueryForm: FC = memo(props => { [selectedType] ); - const onErrorBarChange = useCallback((name: string, value: string) => { - onFieldChange(name, { value }); - }, [onFieldChange]); + const onErrorBarChange = useCallback( + (name: string, value: string) => { + onFieldChange(name, { value }); + }, + [onFieldChange] + ); const onSelectFieldChange = useCallback( (key: string, _: never, selectedOption: SelectInputOption) => { @@ -300,7 +303,7 @@ const ChartTypeQueryForm: FC = memo(props => { ); const onFieldScaleChange = useCallback( - (field: string, key: string, value: string | number, reset = false) => { + (field: string, key: string, value: number | string, reset = false) => { const scales = fieldValues.scales?.value ?? {}; if (!scales[field] || reset) scales[field] = { type: 'automatic', trans: 'linear' }; if (key) scales[field][key] = value; @@ -317,24 +320,24 @@ const ChartTypeQueryForm: FC = memo(props => { {canShare && (
- + Make this chart available to all users
)} {allowInherit && (
Make this chart available in child folders
@@ -496,7 +499,7 @@ const ChartPreview: FC = memo(props => { {loadingData && (
- +
)}
@@ -554,10 +557,10 @@ const ChartBuilderFooter: FC = memo(props => {
Are you sure you want to permanently delete this chart? - -
@@ -575,7 +578,7 @@ const ChartBuilderFooter: FC = memo(props => { Delete Chart )} -