diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 9f6382cb40..3597e92b54 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.66.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.65.2", + "version": "6.66.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..ffb2ec821f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.65.2", + "version": "6.66.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index c3afb8d245..32ae27edd9 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,8 +1,17 @@ # @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 + - Create ChartFieldAggregateOptions.tsx and move y-axis bar chart aggregate method dropdown into tooltip + - 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.65.2 +*Released*: 22 October 2025 - Various minor fixes for exception reports ### version 6.65.1 diff --git a/packages/components/src/internal/OverlayTrigger.tsx b/packages/components/src/internal/OverlayTrigger.tsx index 31b6e633ec..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'; @@ -86,7 +86,9 @@ export function useOverlayTriggerState( event => { const isToggle = event.target === targetRef.current; const insideToggle = portalEl?.contains(event.target); - if (!isToggle && !insideToggle) { + const isSelectOption = + event.target instanceof HTMLElement && event.target.classList.contains('select-input__option'); + if (!isToggle && !insideToggle && !isSelectOption) { setShow(false); } }, @@ -168,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 2c6816ec73..24d3b8e15a 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,20 @@ 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(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); + expect(document.querySelector('input[value=SEM]').hasAttribute('checked')).toBe(false); }); - 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, @@ -363,7 +369,10 @@ 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: { @@ -382,9 +391,17 @@ 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(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); + expect(document.querySelector('input[value=SEM]').hasAttribute('checked')).toBe(true); expect(document.querySelectorAll('input[name=trendlineType]')).toHaveLength(0); }); @@ -490,10 +507,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', () => { diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 1201d2dba7..d39e674ae5 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,24 @@ 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, + BAR_CHART_ERROR_BAR_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) { @@ -198,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 @@ -257,9 +218,9 @@ const ChartTypeSideBar: FC = memo(props => { return (
@@ -322,6 +283,13 @@ const ChartTypeQueryForm: FC = memo(props => { [selectedType] ); + const onErrorBarChange = useCallback( + (name: string, value: string) => { + onFieldChange(name, { value }); + }, + [onFieldChange] + ); + const onSelectFieldChange = useCallback( (key: string, _: never, selectedOption: SelectInputOption) => { // clear / reset trendline option here if x change @@ -335,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; @@ -352,24 +320,24 @@ const ChartTypeQueryForm: FC = memo(props => { {canShare && (
- + Make this chart available to all users
)} {allowInherit && (
Make this chart available in child folders
@@ -378,14 +346,15 @@ const ChartTypeQueryForm: FC = memo(props => {
{leftColFields.map(field => ( ))}
@@ -393,33 +362,16 @@ const ChartTypeQueryForm: FC = memo(props => { {rightColFields.map(field => ( - {selectedType.name === 'bar_chart' && fieldValues.y?.value && ( -
- - -
- )}
))} {hasTrendlineOption && ( @@ -459,8 +411,6 @@ const ChartPreview: FC = memo(props => { if (!hasRequiredValues) return; - const width = ref?.current.getBoundingClientRect().width || 750; - const chartConfig = getChartBuilderChartConfig( selectedType, fieldValues, @@ -520,6 +470,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, @@ -548,7 +499,7 @@ const ChartPreview: FC = memo(props => { {loadingData && (
- +
)}
@@ -606,10 +557,10 @@ const ChartBuilderFooter: FC = memo(props => {
Are you sure you want to permanently delete this chart? - -
@@ -627,7 +578,7 @@ const ChartBuilderFooter: FC = memo(props => { Delete Chart )} -