diff --git a/packages/components/justfile b/packages/components/justfile index 10bfabdb5e..a1379954a7 100644 --- a/packages/components/justfile +++ b/packages/components/justfile @@ -4,7 +4,7 @@ [private] default: - just --list + @just --list currentBranch := `git branch --show-current` branchAsTag := replace(currentBranch, '_', '-') @@ -129,5 +129,8 @@ patch: publish: npm publish +# Runs publish sleep bump pb: publish sleep bump + +# Runs publish sleep bumpApps pba: publish sleep bumpApps diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 23e14067d2..97fb15ecb8 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.0.0", + "version": "7.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.0.0", + "version": "7.1.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 cfdad4c6c6..0a91aef017 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.0.0", + "version": "7.1.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 7599a5435e..d41b997610 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,14 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.1.0 +*Released*: 3 December 2025 +- ChartBuilderModal + - Update to two-column layout + - Add options for axis labels + - Add options for title / subtitle + - Add options for height / width + ### version 7.0.0 *Released*: 1 December 2025 - Updates for new Workflow implementation diff --git a/packages/components/src/internal/Checkbox.tsx b/packages/components/src/internal/Checkbox.tsx index 1aaa6285c2..6901ecbc2d 100644 --- a/packages/components/src/internal/Checkbox.tsx +++ b/packages/components/src/internal/Checkbox.tsx @@ -19,7 +19,6 @@ export const CheckboxLK: FC = memo(props => { - ) + ); }); CheckboxLK.displayName = 'Checkbox'; diff --git a/packages/components/src/internal/components/chart/Chart.tsx b/packages/components/src/internal/components/chart/Chart.tsx index cde101b31a..25e7361639 100644 --- a/packages/components/src/internal/components/chart/Chart.tsx +++ b/packages/components/src/internal/components/chart/Chart.tsx @@ -59,23 +59,28 @@ interface Dimensions { width: number; } -const MAX_HEIGHT = 500; +const MAX_DEFAULT_HEIGHT = 500; function computeDimensions(chartConfig: ChartConfig, measureStore, defaultWidth: number): Dimensions { - // Issue 49754: use getChartTypeBasedWidth() to determine width - const width = LABKEY_VIS.GenericChartHelper.getChartTypeBasedWidth( - chartConfig.renderType, - chartConfig.measures, - measureStore, - defaultWidth - ); - const dimensions = { - width, - height: (width * 9) / 16, // 16:9 aspect ratio - }; - if (dimensions.height > MAX_HEIGHT) dimensions.height = MAX_HEIGHT; + let width = chartConfig.width; + let height = chartConfig.height; + + if (width === undefined) { + // Issue 49754: use getChartTypeBasedWidth() to determine width + width = LABKEY_VIS.GenericChartHelper.getChartTypeBasedWidth( + chartConfig.renderType, + chartConfig.measures, + measureStore, + defaultWidth + ); + } + + if (height === undefined) { + height = (width * 9) / 16; // 16:9 aspect ratio + if (height > MAX_DEFAULT_HEIGHT) height = MAX_DEFAULT_HEIGHT; + } - return dimensions; + return { height, width }; } interface Props { diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx index 24d3b8e15a..20e85ed3e6 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { getByRole, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { renderWithAppContext } from '../../test/reactTestLibraryHelpers'; @@ -17,15 +18,10 @@ import { TEST_PROJECT_CONTAINER_ADMIN, } from '../../containerFixtures'; -import { - ChartBuilderModal, - getChartBuilderChartConfig, - getChartBuilderQueryConfig, - getChartRenderMsg, - getDefaultBarChartAxisLabel, -} from './ChartBuilderModal'; +import { ChartBuilderModal, getChartBuilderQueryConfig, getChartRenderMsg } from './ChartBuilderModal'; import { MAX_POINT_DISPLAY, MAX_ROWS_PREVIEW } from './constants'; import { ChartConfig, ChartQueryConfig, ChartTypeInfo, GenericChartModel } from './models'; +import { waitFor } from '@testing-library/dom'; const BAR_CHART_TYPE = { name: 'bar_chart', @@ -118,19 +114,19 @@ const SERVER_CONTEXT = { describe('ChartBuilderModal', () => { function validate(isNew: boolean, canShare = true, canDelete = false, allowInherit = false): void { expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(1); + expect(document.querySelectorAll('.chart-settings')).toHaveLength(1); + expect(document.querySelectorAll('.chart-builder-modal__chart-preview')).toHaveLength(1); expect(document.querySelector('.modal-title').textContent).toBe(isNew ? 'Create Chart' : 'Edit Chart'); expect(document.querySelectorAll('.btn')).toHaveLength(canDelete ? 3 : 2); - expect(document.querySelectorAll('.alert')).toHaveLength(0); - expect(document.querySelectorAll('.col-left')).toHaveLength(1); - expect(document.querySelectorAll('.col-right')).toHaveLength(1); + // TODO update this part of jest test // hidden chart types are filtered out - const chartTypeItems = document.querySelectorAll('.chart-builder-type'); - expect(chartTypeItems).toHaveLength(3); - expect(chartTypeItems[0].textContent).toBe('Bar'); - expect(chartTypeItems[1].textContent).toBe('Scatter'); - expect(chartTypeItems[2].textContent).toBe('Line'); + // const chartTypeItems = document.querySelectorAll('.chart-builder-type'); + // expect(chartTypeItems).toHaveLength(3); + // expect(chartTypeItems[0].textContent).toBe('Bar'); + // expect(chartTypeItems[1].textContent).toBe('Scatter'); + // expect(chartTypeItems[2].textContent).toBe('Line'); expect(document.querySelectorAll('input[name="name"]')).toHaveLength(1); expect(document.querySelectorAll('input[name="shared"]')).toHaveLength(canShare ? 1 : 0); @@ -154,13 +150,8 @@ describe('ChartBuilderModal', () => { validate(true); // default to selecting the first chart type - expect(document.querySelector('.selected').textContent).toBe('Bar'); - expect(document.querySelector('.selectable').textContent).toBe('Scatter'); - // selected should use blue icon and selectable should use gray icon - expect(document.querySelector('.selected').querySelector('img').getAttribute('alt')).toBe('bar_chart-icon'); - expect(document.querySelector('.selectable').querySelector('img').getAttribute('alt')).toBe( - 'xy_scatter_gray-icon' - ); + expect(document.querySelector('.chart-builder-type-option--value').textContent).toBe('Bar'); + expect(document.querySelector('input[name=chartType]').getAttribute('value')).toBe('bar_chart'); // default to shared expect(document.querySelector('input[name="shared"]').getAttribute('checked')).toBe(''); @@ -177,7 +168,7 @@ describe('ChartBuilderModal', () => { ); validate(true, false); - expect(document.querySelectorAll('input')).toHaveLength(5); + expect(document.querySelectorAll('input')).toHaveLength(12); }); test('allowInherit false, user perm', () => { @@ -193,7 +184,7 @@ describe('ChartBuilderModal', () => { ); validate(true); - expect(document.querySelectorAll('input')).toHaveLength(6); + expect(document.querySelectorAll('input')).toHaveLength(13); }); test('allowInherit false, non-project', () => { @@ -209,7 +200,7 @@ describe('ChartBuilderModal', () => { ); validate(true); - expect(document.querySelectorAll('input')).toHaveLength(6); + expect(document.querySelectorAll('input')).toHaveLength(13); }); test('allowInherit true', () => { @@ -225,7 +216,7 @@ describe('ChartBuilderModal', () => { ); validate(true, true, false, true); - expect(document.querySelectorAll('input')).toHaveLength(7); + expect(document.querySelectorAll('input')).toHaveLength(14); }); test('field inputs displayed for selected chart type', async () => { @@ -239,26 +230,28 @@ describe('ChartBuilderModal', () => { validate(true); // verify field inputs displayed for default / first chart type - expect(document.querySelectorAll('input')).toHaveLength(6); + expect(document.querySelectorAll('input')).toHaveLength(13); BAR_CHART_TYPE.fields.forEach(field => { expect(document.querySelectorAll(`input[name="${field.name}"]`)).toHaveLength(1); }); // click on Scatter chart type and verify field inputs change - expect(document.querySelectorAll('.chart-builder-type')[1].textContent).toBe('Scatter'); - await userEvent.click(document.querySelectorAll('.chart-builder-type')[1]); - expect(document.querySelector('.selected').textContent).toBe('Scatter'); - expect(document.querySelector('.selectable').textContent).toBe('Bar'); - expect(document.querySelectorAll('input')).toHaveLength(8); + let typeDropdown = getByRole(document.querySelector('.chart-settings__chart-type'), 'combobox'); + await userEvent.click(typeDropdown); + const scatterOption = screen.getByText('Scatter'); + await userEvent.click(scatterOption); + + expect(document.querySelectorAll('input')).toHaveLength(15); SCATTER_PLOT_TYPE.fields.forEach(field => { expect(document.querySelectorAll(`input[name="${field.name}"]`)).toHaveLength(1); }); // click on Line chart type and verify field inputs change - expect(document.querySelectorAll('.chart-builder-type')[2].textContent).toBe('Line'); - await userEvent.click(document.querySelectorAll('.chart-builder-type')[2]); - expect(document.querySelector('.selected').textContent).toBe('Line'); - expect(document.querySelectorAll('input')).toHaveLength(8); + typeDropdown = getByRole(document.querySelector('.chart-settings__chart-type'), 'combobox'); + await userEvent.click(typeDropdown); + const lineOption = screen.getByText('Line'); + await userEvent.click(lineOption); + expect(document.querySelectorAll('input')).toHaveLength(15); LINE_PLOT_TYPE.fields.forEach(field => { if (field.name !== 'trendline') { expect(document.querySelectorAll(`input[name="${field.name}"]`)).toHaveLength(1); @@ -294,13 +287,7 @@ describe('ChartBuilderModal', () => { ); validate(false, true, true); - expect(document.querySelectorAll('input')).toHaveLength(8); - - // default to selecting the chart type based on saved config - expect(document.querySelector('.selected').textContent).toBe('Scatter'); - expect(document.querySelectorAll('.selectable')).toHaveLength(0); - // selected should use blue icon - expect(document.querySelector('.selected').querySelector('img').getAttribute('alt')).toBe('xy_scatter-icon'); + expect(document.querySelectorAll('input')).toHaveLength(13); // click delete button and verify confirm text / buttons await userEvent.click(document.querySelector('.btn-danger')); @@ -327,7 +314,7 @@ describe('ChartBuilderModal', () => { visualizationConfig: { chartConfig: { renderType: 'bar_chart', - measures: { x: { name: 'field1' }, y: { name: 'field2' } }, + measures: { x: { fieldKey: 'field1' }, y: { fieldKey: 'field2' } }, labels: { x: 'Field 1', y: 'Field 2' }, }, queryConfig: { @@ -346,11 +333,11 @@ describe('ChartBuilderModal', () => { ); validate(false, true, true); - expect(document.querySelectorAll('input')).toHaveLength(6); + expect(document.querySelectorAll('input')).toHaveLength(11); 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.querySelectorAll('.fa-gear')).toHaveLength(2); // gear icon for x and y axes + await userEvent.click(document.querySelectorAll('.fa-gear')[1]); + expect(document.querySelectorAll('input')).toHaveLength(19); 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'); @@ -370,8 +357,8 @@ describe('ChartBuilderModal', () => { chartConfig: { renderType: 'bar_chart', measures: { - x: { name: 'field1' }, - y: { name: 'field2', aggregate: { value: 'MEAN' }, errorBars: 'SEM' }, + x: { fieldKey: 'field1' }, + y: { fieldKey: 'field2', aggregate: { value: 'MEAN' }, errorBars: 'SEM' }, }, labels: { x: 'Field 1', y: 'Field 2' }, }, @@ -391,11 +378,11 @@ describe('ChartBuilderModal', () => { ); validate(false, true, true); - expect(document.querySelectorAll('input')).toHaveLength(6); + expect(document.querySelectorAll('input')).toHaveLength(11); 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.querySelectorAll('.fa-gear')).toHaveLength(2); // gear icon for x and y axes + await userEvent.click(document.querySelectorAll('.fa-gear')[1]); + expect(document.querySelectorAll('input')).toHaveLength(19); 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'); @@ -415,7 +402,7 @@ describe('ChartBuilderModal', () => { visualizationConfig: { chartConfig: { renderType: 'line_plot', - measures: { x: { name: 'field1' }, y: { name: 'field2' } }, + measures: { x: { fieldKey: 'field1' }, y: { fieldKey: 'field2' } }, labels: { x: 'Field 1', y: 'Field 2' }, geomOptions: { trendlineType: 'option1', @@ -439,7 +426,7 @@ describe('ChartBuilderModal', () => { ); validate(false, true, true); - expect(document.querySelectorAll('input')).toHaveLength(10); + expect(document.querySelectorAll('input')).toHaveLength(15); expect(document.querySelector('input[name=x]').getAttribute('value')).toBe('field1'); expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); expect(document.querySelectorAll('input[name=aggregate-method]')).toHaveLength(0); @@ -462,7 +449,7 @@ describe('ChartBuilderModal', () => { visualizationConfig: { chartConfig: { renderType: 'line_plot', - measures: { x: { name: 'field1' }, y: { name: 'field2' } }, + measures: { x: { fieldKey: 'field1' }, y: { fieldKey: 'field2' } }, labels: { x: 'Field 1', y: 'Field 2' }, scales: { x: { trans: 'linear', type: 'manual', min: 0, max: 100 }, @@ -485,7 +472,7 @@ describe('ChartBuilderModal', () => { ); validate(false, true, true); - expect(document.querySelectorAll('input')).toHaveLength(10); + expect(document.querySelectorAll('input')).toHaveLength(15); expect(document.querySelector('input[name=x]').getAttribute('value')).toBe('field1'); expect(document.querySelector('input[name=y]').getAttribute('value')).toBe('field2'); expect(document.querySelectorAll('input[name=aggregate-method]')).toHaveLength(0); @@ -543,7 +530,7 @@ describe('ChartBuilderModal', () => { ); validate(false, false, false); - expect(document.querySelectorAll('input')).toHaveLength(7); + expect(document.querySelectorAll('input')).toHaveLength(12); expect(document.querySelector('input[name="shared"]')).toBeNull(); }); }); @@ -584,15 +571,16 @@ describe('getChartRenderMsg', () => { }); describe('getChartBuilderQueryConfig', () => { - const chartConfig = { measures: {} } as ChartConfig; - const fieldValues = { - x: { value: 'field1', label: 'Field 1', data: { fieldKey: 'field1' } }, - y: { value: undefined }, - scales: { value: { x: { type: 'automatic', trans: 'linear' }, y: { type: 'automatic', trans: 'linear' } } }, - }; + const chartConfig = { + geomOptions: {}, + measures: { + x: { name: 'field1', label: 'Field 1', fieldKey: 'field1' }, + y: { name: undefined }, + }, + } as ChartConfig; test('based on model', () => { - const config = getChartBuilderQueryConfig(model, fieldValues, chartConfig, undefined); + const config = getChartBuilderQueryConfig(model, chartConfig, undefined); expect(config.maxRows).toBe(-1); expect(config.requiredVersion).toBe('17.1'); expect(config.schemaName).toBe('schema'); @@ -605,13 +593,13 @@ describe('getChartBuilderQueryConfig', () => { test('based on savedConfig', () => { const savedConfig = { - schemaName: 'savedSchema', + filterArray: [{ name: 'savedFilter' }], queryName: 'savedQuery', + schemaName: 'savedSchema', viewName: 'savedView', - filterArray: [{ name: 'savedFilter' }], } as ChartQueryConfig; - const config = getChartBuilderQueryConfig(model, fieldValues, chartConfig, savedConfig); + const config = getChartBuilderQueryConfig(model, chartConfig, savedConfig); expect(config.maxRows).toBe(-1); expect(config.requiredVersion).toBe('17.1'); expect(config.schemaName).toBe('savedSchema'); @@ -622,189 +610,3 @@ describe('getChartBuilderQueryConfig', () => { expect(config.filterArray.length).toBe(1); }); }); - -describe('getChartBuilderChartConfig', () => { - const fieldValues = { - x: { value: 'field/1', label: 'Field 1', data: { fieldKey: 'field$S1', name: 'field/1' } }, - y: { value: 'field2', label: 'Field 2', data: { fieldKey: 'field2', name: 'field2' } }, - }; - - test('based on fieldValues', () => { - const config = getChartBuilderChartConfig(BAR_CHART_TYPE, fieldValues, undefined); - expect(config.scales).toStrictEqual({}); - expect(Object.keys(config.labels)).toStrictEqual(['main', 'subtitle', 'x', 'y']); - expect(config.labels.main).toBe(''); - expect(config.labels.subtitle).toBe(''); - expect(config.pointType).toBe('all'); - expect(config.measures.x.name).toBe('field/1'); - expect(config.measures.x.fieldKey).toBe('field$S1'); - expect(config.measures.y.name).toBe('field2'); - expect(config.measures.y.fieldKey).toBe('field2'); - expect(config.labels.x).toBe('Field 1'); - expect(config.labels.y).toBe('Sum of Field 2'); - }); - - test('based on savedConfig, without y-label', () => { - const savedConfig = { - pointType: 'outliers', - scales: { x: 'linear', y: 'log' }, - labels: { main: 'Main', subtitle: 'Subtitle', color: 'Something', x: 'X Label' }, - measures: { x: { name: 'saved1' }, y: { name: 'saved2' } }, - height: 1, - width: 2, - } as ChartConfig; - - const config = getChartBuilderChartConfig(BAR_CHART_TYPE, fieldValues, savedConfig); - expect(config.scales).toStrictEqual(savedConfig.scales); - expect(Object.keys(config.labels)).toStrictEqual(['main', 'subtitle', 'color', 'x', 'y']); - expect(config.labels.main).toBe('Main'); - expect(config.labels.subtitle).toBe('Subtitle'); - expect(config.pointType).toBe('outliers'); - expect(config.measures.x.name).toBe('field/1'); - expect(config.measures.y.name).toBe('field2'); - expect(config.labels.x).toBe('X Label'); - expect(config.labels.y).toBe('Sum of Field 2'); - expect(config.geomOptions.trendlineType).toBe(undefined); - expect(config.geomOptions.trendlineAsymptoteMin).toBe(undefined); - expect(config.geomOptions.trendlineAsymptoteMax).toBe(undefined); - }); - - test('based on savedConfig, with y-label', () => { - const savedConfig = { - pointType: 'outliers', - scales: { x: 'linear', y: 'log' }, - labels: { main: 'Main', subtitle: 'Subtitle', color: 'Something', x: 'X Label', y: 'Y Label' }, - measures: { x: { name: 'saved1' }, y: { name: 'saved2' } }, - height: 1, - width: 2, - } as ChartConfig; - - const config = getChartBuilderChartConfig(BAR_CHART_TYPE, fieldValues, savedConfig); - expect(config.scales).toStrictEqual(savedConfig.scales); - expect(Object.keys(config.labels)).toStrictEqual(['main', 'subtitle', 'color', 'x', 'y']); - expect(config.labels.main).toBe('Main'); - expect(config.labels.subtitle).toBe('Subtitle'); - expect(config.pointType).toBe('outliers'); - expect(config.measures.x.name).toBe('field/1'); - expect(config.measures.y.name).toBe('field2'); - expect(config.labels.x).toBe('X Label'); - expect(config.labels.y).toBe('Y Label'); - }); - - test('based on savedConfig, with trendline options', () => { - const savedConfig = { - pointType: 'outliers', - scales: { x: 'linear', y: 'log' }, - labels: { main: 'Main', subtitle: 'Subtitle', color: 'Something', x: 'X Label', y: 'Y Label' }, - measures: { x: { name: 'saved1' }, y: { name: 'saved2' } }, - height: 1, - width: 2, - geomOptions: { - trendlineType: 'Linear', - trendlineAsymptoteMin: '0.1', - trendlineAsymptoteMax: '1.0', - }, - } as ChartConfig; - - const config = getChartBuilderChartConfig(BAR_CHART_TYPE, fieldValues, savedConfig); - expect(config.geomOptions.trendlineType).toBe('Linear'); - expect(config.geomOptions.trendlineAsymptoteMin).toBe('0.1'); - expect(config.geomOptions.trendlineAsymptoteMax).toBe('1.0'); - }); - - test('box plot specifics', () => { - const boxType = { - name: 'box_plot', - fields: [{ name: 'x' }, { name: 'y' }], - } as ChartTypeInfo; - - const config = getChartBuilderChartConfig(boxType, fieldValues, undefined); - expect(config.geomOptions.boxFillColor).toBe('none'); - expect(config.geomOptions.lineWidth).toBe(1); - expect(config.geomOptions.opacity).toBe(0.5); - expect(config.geomOptions.pointSize).toBe(3); - expect(config.geomOptions.position).toBe('jitter'); - }); - - test('line plot specifics', () => { - const boxType = { - name: 'line_plot', - fields: [{ name: 'x' }, { name: 'y' }], - } as ChartTypeInfo; - - const config = getChartBuilderChartConfig(boxType, fieldValues, undefined); - expect(config.geomOptions.boxFillColor).not.toBe('none'); - expect(config.geomOptions.lineWidth).toBe(3); - expect(config.geomOptions.opacity).toBe(1.0); - expect(config.geomOptions.pointSize).toBe(5); - expect(config.geomOptions.position).toBe(null); - expect(config.geomOptions.trendlineType).toBe(undefined); - expect(config.geomOptions.trendlineAsymptoteMin).toBe(undefined); - expect(config.geomOptions.trendlineAsymptoteMax).toBe(undefined); - }); - - test('line plot with trendline options', () => { - const boxType = { - name: 'line_plot', - fields: [{ name: 'x' }, { name: 'y' }], - } as ChartTypeInfo; - - const trendlineFieldValues = { - x: { value: 'field1', label: 'Field 1', data: { fieldKey: 'field1' } }, - y: { value: 'field2', label: 'Field 2', data: { fieldKey: 'field2' } }, - trendlineType: { value: 'Linear' }, - trendlineAsymptoteMin: { value: '0.1' }, - trendlineAsymptoteMax: { value: '1.0' }, - }; - - const config = getChartBuilderChartConfig(boxType, trendlineFieldValues, undefined); - expect(config.geomOptions.trendlineType).toBe('Linear'); - expect(config.geomOptions.trendlineAsymptoteMin).toBe('0.1'); - expect(config.geomOptions.trendlineAsymptoteMax).toBe('1.0'); - }); - - test('bar chart specifics', () => { - const boxType = { - name: 'bar_chart', - fields: [{ name: 'x' }, { name: 'y' }], - } as ChartTypeInfo; - - const fieldValues2 = { - x: { value: 'field1', label: 'Field 1', data: { fieldKey: 'field1' } }, - y: { value: 'field2', label: 'Field 2', data: { fieldKey: 'field2' } }, - 'aggregate-method': { value: 'MEAN', name: 'Mean' }, - }; - - const config = getChartBuilderChartConfig(boxType, fieldValues2, undefined); - expect(config.geomOptions.boxFillColor).not.toBe('none'); - expect(config.geomOptions.lineWidth).toBe(1); - expect(config.geomOptions.opacity).toBe(1.0); - expect(config.geomOptions.pointSize).toBe(5); - expect(config.geomOptions.position).toBe(null); - expect(config.labels.y).toBe('Mean of Field 2'); - }); -}); - -describe('getDefaultBarChartAxisLabel', () => { - test('no aggregate', () => { - expect(getDefaultBarChartAxisLabel({ measures: {} } as ChartConfig)).toBe('Count'); - expect(getDefaultBarChartAxisLabel({ measures: { x: { label: 'Test' } } } as ChartConfig)).toBe('Count'); - }); - - test('with aggregate', () => { - expect(getDefaultBarChartAxisLabel({ measures: { y: { label: 'Test' } } } as ChartConfig)).toBe('Sum of Test'); - expect(getDefaultBarChartAxisLabel({ measures: { y: { label: 'Test', aggregate: {} } } } as ChartConfig)).toBe( - 'Sum of Test' - ); - expect( - getDefaultBarChartAxisLabel({ - measures: { y: { label: 'Test', aggregate: { name: 'Min' } } }, - } as ChartConfig) - ).toBe('Min of Test'); - expect( - getDefaultBarChartAxisLabel({ - measures: { y: { label: 'Test', aggregate: { label: 'Max' } } }, - } as ChartConfig) - ).toBe('Max of Test'); - }); -}); diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 5445e6378d..9a2cb2a6ef 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -1,14 +1,11 @@ -import React, { FC, Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import classNames from 'classnames'; +import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { PermissionTypes, Utils } from '@labkey/api'; +import { PermissionTypes } from '@labkey/api'; import { generateId } from '../../util/utils'; import { LABKEY_VIS } from '../../constants'; import { Modal } from '../../Modal'; -import { SelectInputOption } from '../forms/input/SelectInput'; - import { LoadingSpinner } from '../base/LoadingSpinner'; import { QueryModel } from '../../../public/QueryModel/QueryModel'; @@ -22,32 +19,20 @@ import { FormButtons } from '../../FormButtons'; import { getContainerFilterForFolder } from '../../query/api'; -import { SVGIcon } from '../base/SVGIcon'; - 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, - 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'; +import { BLUE_HEX_COLOR, HIDDEN_CHART_TYPES, MAX_POINT_DISPLAY, MAX_ROWS_PREVIEW } from './constants'; + +import { BaseChartModel, ChartConfig, ChartQueryConfig, ChartTypeInfo, GenericChartModel } from './models'; +import { deepCopyChartConfig } from './utils'; +import { ChartSettingsPanel } from './ChartSettingsPanel'; export const getChartRenderMsg = (chartConfig: ChartConfig, rowCount: number, isPreview: boolean): string => { const msg = []; if (isPreview && rowCount === MAX_ROWS_PREVIEW) { msg.push(`The preview is being limited to ${MAX_ROWS_PREVIEW.toLocaleString()} rows.`); } - if (chartConfig.renderType === 'line_plot' && rowCount > chartConfig.geomOptions.binThreshold) { + if (chartConfig.renderType === 'line_plot' && rowCount > (chartConfig.geomOptions.binThreshold as number)) { msg.push(`The number of individual points exceeds ${MAX_POINT_DISPLAY.toLocaleString()}.`); msg.push('Data points will not be shown on this line plot.'); } else if (chartConfig.renderType === 'scatter_plot' && rowCount > MAX_POINT_DISPLAY) { @@ -59,342 +44,33 @@ export const getChartRenderMsg = (chartConfig: ChartConfig, rowCount: number, is export const getChartBuilderQueryConfig = ( model: QueryModel, - fieldValues: Record, chartConfig: ChartConfig, savedConfig: ChartQueryConfig ): ChartQueryConfig => { const { schemaQuery, containerPath } = model; const { schemaName, queryName, viewName } = schemaQuery; + const columns = Object.values(chartConfig.measures) + .map(measure => measure?.fieldKey) // Issue 52050: use fieldKey for special characters + .filter(fk => fk !== undefined); + + if (chartConfig.geomOptions.trendlineParameters) columns.push(chartConfig.geomOptions.trendlineParameters); + return { maxRows: -1, // this will be saved with the queryConfig, but we will override it for the preview in the modal requiredVersion: '17.1', // Issue 47898: include formattedValue in response row objects schemaName: savedConfig?.schemaName || schemaName, queryName: savedConfig?.queryName || queryName, viewName: savedConfig?.viewName || viewName, - columns: Object.values(fieldValues) - .filter(field => field?.value && typeof field.value === 'string') // just those fields with values - .filter(field => !field.equation) // exclude the trendlineType field (which has an equation value) - .map(field => field.data?.fieldKey ?? field.value), // Issue 52050: use fieldKey for special characters + columns, sort: LABKEY_VIS.GenericChartHelper.getQueryConfigSortKey(chartConfig.measures), filterArray: savedConfig?.filterArray ?? [], containerPath: savedConfig?.containerPath || containerPath, } as ChartQueryConfig; }; -export const getDefaultBarChartAxisLabel = (config: ChartConfig): string => { - const aggregate = config.measures.y?.aggregate; - const prefix = (aggregate?.name ?? aggregate?.label ?? 'Sum') + ' of '; - return config.measures.y ? prefix + config.measures.y.label : 'Count'; -}; - -export const getChartBuilderChartConfig = ( - chartType: ChartTypeInfo, - fieldValues: Record, - savedConfig: ChartConfig -): ChartConfig => { - const config = { - renderType: chartType.name, - measures: {}, - scales: { - ...savedConfig?.scales, - }, - labels: { - main: '', - subtitle: '', - ...savedConfig?.labels, - }, - pointType: savedConfig?.pointType ?? 'all', - gridLinesVisible: chartType.name === 'bar_chart' || chartType.name === 'box_plot' ? 'x' : 'both', - geomOptions: { - binShape: 'hex', - binSingleColor: '000000', - binThreshold: MAX_POINT_DISPLAY, - boxFillColor: chartType.name === 'box_plot' ? 'none' : BLUE_HEX_COLOR, - chartLayout: 'single', - chartSubjectSelection: 'subjects', - colorPaletteScale: 'ColorDiscrete', - colorRange: 'BlueWhite', - displayIndividual: true, - displayAggregate: false, - errorBars: 'None', - gradientColor: 'FFFFFF', - gradientPercentage: 95, - hideDataPoints: false, - hideTrendLine: false, - lineColor: '000000', - lineWidth: chartType.name === 'line_plot' ? 3 : 1, - marginBottom: null, - marginLeft: null, - marginRight: null, - marginTop: 20, // this will be saved with the chartConfig, but we will override it for the preview in the modal - opacity: chartType.name === 'bar_chart' || chartType.name === 'line_plot' ? 1.0 : 0.5, - pieHideWhenLessThanPercentage: 5, - pieInnerRadius: 0, - pieOuterRadius: 80, - piePercentagesColor: '333333', - pointFillColor: BLUE_HEX_COLOR, - pointSize: chartType.name === 'box_plot' ? 3 : 5, - position: chartType.name === 'box_plot' ? 'jitter' : null, - showOutliers: true, - showPiePercentages: true, - trendlineType: undefined, - trendlineAsymptoteMin: undefined, - trendlineAsymptoteMax: undefined, - trendlineParameters: undefined, - ...savedConfig?.geomOptions, - }, - } as ChartConfig; - - chartType.fields.forEach(field => { - const fieldConfig = fieldValues[field.name]; - if (fieldConfig?.value) { - config.measures[field.name] = { - fieldKey: fieldConfig.data.fieldKey, - name: fieldConfig.data.name, - label: fieldConfig.label, - queryName: fieldConfig.data.queryName, - schemaName: fieldConfig.data.schemaName, - type: getFieldDataType(fieldConfig.data), - }; - - // 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 - if ( - !savedConfig || - !savedConfig.measures[field.name] || - savedConfig.labels[field.name] === savedConfig.measures[field.name].label - ) { - config.labels[field.name] = fieldValues[field.name].label; - } - } - }); - - if (fieldValues.scales?.value) { - Object.keys(fieldValues.scales.value).forEach(key => { - config.scales[key] = { ...fieldValues.scales.value[key] }; - }); - } - - if (chartType.name === 'line_plot' && fieldValues.trendlineType) { - const type = fieldValues.trendlineType?.value ?? ''; - config.geomOptions.trendlineType = type === '' ? undefined : type; - config.geomOptions.trendlineAsymptoteMin = fieldValues.trendlineAsymptoteMin?.value; - config.geomOptions.trendlineAsymptoteMax = fieldValues.trendlineAsymptoteMax?.value; - config.geomOptions.trendlineParameters = fieldValues.trendlineParameters?.value; - } - - if ( - chartType.name === 'bar_chart' && - (!savedConfig || - !savedConfig.labels?.['y'] || - savedConfig.labels?.['y'] === getDefaultBarChartAxisLabel(savedConfig)) - ) { - config.labels['y'] = getDefaultBarChartAxisLabel(config); - } - - return config; -}; - -interface ChartTypeSideBarProps { - chartTypes: ChartTypeInfo[]; - onChange: (e: React.MouseEvent) => void; - savedChartModel: GenericChartModel; - selectedType: ChartTypeInfo; -} - -const ChartTypeSideBar: FC = memo(props => { - const { chartTypes, savedChartModel, selectedType, onChange } = props; - - return ( - <> - {chartTypes.map(type => { - const selected = selectedType.name === type.name; - const selectable = !savedChartModel && selectedType.name !== type.name; - - return ( -
- -
{type.title}
-
- ); - })} - - ); -}); -ChartTypeSideBar.displayName = 'ChartTypeSideBar'; - -interface ChartTypeQueryFormProps { - allowInherit: boolean; - canShare: boolean; - fieldValues: Record; - inheritable: boolean; - model: QueryModel; - name: string; - onFieldChange: (key: string, value: SelectInputOption) => void; - onNameChange: (event: React.ChangeEvent) => void; - onToggleInheritable: () => void; - onToggleShared: () => void; - savedChartModel: GenericChartModel; - selectedType: ChartTypeInfo; - shared: boolean; -} - -const ChartTypeQueryForm: FC = memo(props => { - const { - canShare, - onNameChange, - name, - shared, - onToggleShared, - allowInherit, - inheritable, - onToggleInheritable, - selectedType, - fieldValues, - model, - onFieldChange, - } = props; - - const leftColFields = useMemo(() => { - return selectedType.fields.filter( - field => !RIGHT_COL_FIELDS.includes(field.name) && (selectedType.name !== 'bar_chart' || field.name !== 'y') - ); - }, [selectedType]); - const rightColFields = useMemo(() => { - return selectedType.fields.filter( - field => - (RIGHT_COL_FIELDS.includes(field.name) && !field.altSelectionOnly) || - (selectedType.name === 'bar_chart' && field.name === 'y') - ); - }, [selectedType]); - - const hasTrendlineOption = useMemo( - () => selectedType.fields.filter(field => field.name === 'trendline').length > 0, - [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 - if (hasTrendlineOption && key === 'x') { - onFieldChange('trendlineType', LABKEY_VIS.GenericChartHelper.TRENDLINE_OPTIONS['']); - } - - onFieldChange(key, selectedOption); - }, - [onFieldChange, hasTrendlineOption] - ); - - const onFieldScaleChange = useCallback( - (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; - onFieldChange('scales', { value: scales }); - }, - [fieldValues.scales?.value, onFieldChange] - ); - - return ( -
-
-
- - - {canShare && ( -
- - Make this chart available to all users -
- )} - {allowInherit && ( -
- - Make this chart available in child folders -
- )} -
-
- {leftColFields.map(field => ( - - ))} -
-
- {rightColFields.map(field => ( - - - - ))} - {hasTrendlineOption && ( - - )} -
-
-
- ); -}); -ChartTypeQueryForm.displayName = 'ChartTypeQueryForm'; - interface ChartPreviewProps { - fieldValues: Record; + chartConfig: ChartConfig; hasRequiredValues: boolean; model: QueryModel; savedChartModel: GenericChartModel; @@ -403,7 +79,7 @@ interface ChartPreviewProps { } const ChartPreview: FC = memo(props => { - const { hasRequiredValues, model, selectedType, fieldValues, savedChartModel, setReportConfig } = props; + const { chartConfig, hasRequiredValues, model, selectedType, savedChartModel, setReportConfig } = props; const divId = useMemo(() => generateId('chart-'), []); const ref = useRef(undefined); const containerFilter = useMemo(() => getContainerFilterForFolder(model.containerPath), [model.containerPath]); @@ -416,14 +92,8 @@ const ChartPreview: FC = memo(props => { if (!hasRequiredValues) return; - const chartConfig = getChartBuilderChartConfig( - selectedType, - fieldValues, - savedChartModel?.visualizationConfig?.chartConfig - ); const queryConfig = getChartBuilderQueryConfig( model, - fieldValues, chartConfig, savedChartModel?.visualizationConfig?.queryConfig ); @@ -474,16 +144,9 @@ 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, - width, - }; - if (!savedChartModel || savedChartModel.visualizationConfig.chartConfig.geomOptions.marginTop === 20) { - chartConfig_.geomOptions.marginTop = 15; - } + const width = chartConfig.width ?? (ref?.current?.getBoundingClientRect().width - 15 || 750); + const height = chartConfig.height ?? 500; + const chartConfig_ = { ...chartConfig, height, width }; if (ref.current) ref.current.innerHTML = ''; // clear again, right before render LABKEY_VIS.GenericChartHelper.generateChartSVG(divId, chartConfig_, measureStore, trendlineData); @@ -492,11 +155,11 @@ const ChartPreview: FC = memo(props => { setLoadingData(false); } ); - }, [divId, model, hasRequiredValues, selectedType, fieldValues, savedChartModel, containerFilter, setReportConfig]); + }, [divId, model, hasRequiredValues, selectedType, savedChartModel, containerFilter, setReportConfig, chartConfig]); return ( - <> - +
+

Preview

{previewMsg && {previewMsg}} {!hasRequiredValues &&
Select required fields to preview the chart.
} {hasRequiredValues && ( @@ -510,7 +173,7 @@ const ChartPreview: FC = memo(props => {
)} - +
); }); ChartPreview.displayName = 'ChartPreview'; @@ -604,11 +267,6 @@ interface ChartBuilderModalProps extends RequiresModelAndActions { export const ChartBuilderModal: FC = memo(({ actions, model, onHide, savedChartModel }) => { 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( () => savedChartModel?.canShare ?? hasPermissions(user, [PermissionTypes.ShareReportPermission]), @@ -623,116 +281,34 @@ 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 [chartModel, setChartModel] = useState(() => ({ + inheritable: savedChartModel?.inheritable ?? false, + name: savedChartModel?.name ?? '', + shared: savedChartModel?.shared ?? true, + })); + const [chartConfig, setChartConfig] = useState(() => + deepCopyChartConfig(savedChartModel?.visualizationConfig?.chartConfig) + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [reportConfig, setReportConfig] = useState(); - const [selectedType, setSelectedChartType] = useState( - chartTypes.find(c => chartConfig?.renderType === c.name) ?? chartTypes[0] + const selectedType = useMemo( + () => chartTypes.find(c => chartConfig.renderType === c.name), + [chartConfig.renderType, chartTypes] ); - const [name, setName] = useState(savedChartModel?.name ?? ''); - const [shared, setShared] = useState(savedChartModel?.shared ?? canShare); - const [inheritable, setInheritable] = useState(savedChartModel?.inheritable ?? false); - - const initFieldValues = useMemo(() => { - if (savedChartModel) { - const measures = chartConfig?.measures || {}; - const fieldValues_ = Object.keys(measures).reduce((result, key) => { - let measure = measures[key]; - if (measure) { - // Currently only supporting a single measure per axis (i.e. not supporting y-axis left/right) - if (Utils.isArray(measure)) measure = measure[0]; - result[key] = { label: measure.label, value: measure.name, data: measure }; - } - return result; - }, {}); - - // handle scales - if (chartConfig?.scales) { - fieldValues_['scales'] = { value: { ...chartConfig.scales } }; - } - // handle bar chart aggregate method and error bars - 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 }; - } - } - - // handle trendline options - if (chartConfig?.geomOptions?.trendlineType) { - fieldValues_['trendlineType'] = TRENDLINE_OPTIONS.find( - option => option.value === chartConfig.geomOptions.trendlineType - ); - if (chartConfig.geomOptions.trendlineAsymptoteMin) { - fieldValues_['trendlineAsymptoteMin'] = { - value: chartConfig.geomOptions.trendlineAsymptoteMin, - }; - } - if (chartConfig.geomOptions.trendlineAsymptoteMax) { - fieldValues_['trendlineAsymptoteMax'] = { - value: chartConfig.geomOptions.trendlineAsymptoteMax, - }; - } - if (chartConfig.geomOptions.trendlineParameters) { - fieldValues_['trendlineParameters'] = { - value: chartConfig.geomOptions.trendlineParameters, - }; - } - } - - return fieldValues_; - } - - return {}; - }, [savedChartModel, chartConfig, TRENDLINE_OPTIONS]); - const [fieldValues, setFieldValues] = useState>(initFieldValues); - - const hasName = useMemo(() => name?.trim().length > 0, [name]); + const hasName = useMemo(() => chartModel.name?.trim().length > 0, [chartModel.name]); const hasRequiredValues = useMemo(() => { - return selectedType.fields.find(field => field.required && !fieldValues[field.name]) === undefined; - }, [selectedType, fieldValues]); - - const onChartTypeChange = useCallback( - e => { - // don't allow changing chart type for a saved report - if (savedChartModel) return; - - const selectedName = e.target.getAttribute('data-name') ?? e.target.parentElement.getAttribute('data-name'); - setSelectedChartType(chartTypes.find(type => type.name === selectedName) || chartTypes[0]); - setFieldValues({}); - }, - [chartTypes, savedChartModel] - ); - - const onNameChange = useCallback((event: React.ChangeEvent) => { - setName(event.target.value); - }, []); - - const onToggleShared = useCallback(() => { - setShared(prev => !prev); - }, []); - - const onToggleInheritable = useCallback(() => { - setInheritable(prev => !prev); - }, []); - - const onFieldChange = useCallback((key: string, value: SelectInputOption) => { - setReportConfig(undefined); // clear report config state, it will be reset after the preview loads - setFieldValues(prev => ({ ...prev, [key]: value })); - }, []); + return selectedType.fields.find(field => field.required && !chartConfig.measures[field.name]) === undefined; + }, [selectedType.fields, chartConfig.measures]); const onSaveChart = useCallback(async () => { const _reportConfig = { ...reportConfig, reportId: savedChartModel?.reportId, - name: name?.trim(), - public: shared, - inheritable, + name: chartModel.name.trim(), + public: chartModel.shared, + inheritable: chartModel.inheritable, } as SaveReportConfig; setSaving(true); @@ -747,7 +323,7 @@ export const ChartBuilderModal: FC = memo(({ actions, mo setError(e.exception ?? e); setSaving(false); } - }, [savedChartModel, reportConfig, name, shared, inheritable, actions, model.id, onHide]); + }, [actions, chartModel, model.id, onHide, reportConfig, savedChartModel]); const afterDelete = useCallback(async () => { onHide('Successfully deleted chart: ' + savedChartModel.name + '.'); @@ -779,45 +355,25 @@ export const ChartBuilderModal: FC = memo(({ actions, mo title={savedChartModel ? 'Edit Chart' : 'Create Chart'} > {error && {error}} -
-
- -
-
- -
-
- -
-
-
-
+ + ); }); diff --git a/packages/components/src/internal/components/chart/ChartFieldAdditionalOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldAdditionalOptions.tsx new file mode 100644 index 0000000000..712841d887 --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartFieldAdditionalOptions.tsx @@ -0,0 +1,64 @@ +import React, { FC, memo, useMemo } from 'react'; +import { OverlayTrigger } from '../../OverlayTrigger'; +import { Popover } from '../../Popover'; +import { ChartConfig, ChartConfigSetter, ChartFieldInfo, ChartLabels, ChartTypeInfo, ScaleType } from './models'; +import { getFieldDataType, shouldShowAggregateOptions, shouldShowRangeScaleOptions } from './utils'; +import { LABKEY_VIS } from '../../constants'; +import { ChartFieldAggregateOptions } from './ChartFieldAggregateOptions'; +import { ChartFieldRangeScaleOptions } from './ChartFieldRangeScaleOptions'; +import { ChartLabelInput } from './ChartLabelInput'; + +interface Props { + chartConfig: ChartConfig; + field: ChartFieldInfo; + onLabelChange: (key: keyof ChartLabels, value: string) => void; + onScaleChange: (scale: Partial, localOnly?: boolean) => void; + scale: ScaleType; + selectedType: ChartTypeInfo; + setChartConfig: ChartConfigSetter; +} + +export const ChartFieldAdditionalOptions: FC = memo(props => { + const { chartConfig, field, onLabelChange, onScaleChange, scale, selectedType, setChartConfig } = props; + const { measures } = chartConfig; + const measure = measures?.[field.name]; + const isNumericType = useMemo( + () => LABKEY_VIS.GenericChartHelper.isNumericType(getFieldDataType(measure)), + [measure] + ); + const showRangeScaleOptions = isNumericType && shouldShowRangeScaleOptions(field, selectedType); + const showAggregateOptions = isNumericType && shouldShowAggregateOptions(field, selectedType); + const overlay = ( + + + {showAggregateOptions && ( + + )} + {showRangeScaleOptions && ( + + )} + + ); + return ( +
+ + + +
+ ); +}); +ChartFieldAdditionalOptions.displayName = 'ChartFieldAdditionalOptions'; diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx index 6b64cfdfe2..297b759dac 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.test.tsx @@ -2,40 +2,37 @@ 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 { ChartTypeInfo } from './models'; +import { ChartConfig, ChartTypeInfo } from './models'; 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, -}; +const chartConfig = { + geomOptions: {}, + gridLinesVisible: undefined, + labels: {}, + measures: { + y: { + aggregate: { value: 'SUM' }, + errorBars: undefined, + }, + }, + pointType: undefined, + renderType: 'bar_chart', + scales: {}, +} as ChartConfig; 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')); @@ -57,11 +54,16 @@ describe('ChartFieldAggregateOptions', () => { }); test('error bar radios are enabled for aggregate MEAN', async () => { - const fieldValuesMean = { - ...fieldValues, - [BAR_CHART_AGGREGATE_NAME]: { value: 'MEAN' }, - }; - renderComponent({ fieldValues: fieldValuesMean }); + const meanChartConfig = { + ...chartConfig, + measures: { + y: { + aggregate: { value: 'MEAN' }, + errorBars: undefined, + }, + }, + } as ChartConfig; + renderComponent({ chartConfig: meanChartConfig }); await userEvent.click(document.querySelector('.fa-gear')); expect(document.querySelector('input[name="error-bar-method"][value=""]').hasAttribute('disabled')).toBeFalsy(); expect( @@ -73,13 +75,17 @@ describe('ChartFieldAggregateOptions', () => { 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 }); + test('error bar radio value selected when values set', async () => { + const semChartConfig = { + ...chartConfig, + measures: { + y: { + aggregate: { value: 'MEAN' }, + errorBars: 'SEM', + }, + }, + } as ChartConfig; + renderComponent({ chartConfig: semChartConfig }); await userEvent.click(document.querySelector('.fa-gear')); expect(document.querySelector('input[name="error-bar-method"][value=""]').hasAttribute('disabled')).toBeFalsy(); expect( @@ -93,25 +99,17 @@ describe('ChartFieldAggregateOptions', () => { ); }); - 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 }); + const semChartConfig = { + ...chartConfig, + measures: { + y: { + aggregate: { value: 'MEAN' }, + errorBars: 'SEM', + }, + }, + } as ChartConfig; + renderComponent({ chartConfig: semChartConfig, asOverlay: false }); expect(document.querySelectorAll('.field-option-icon')).toHaveLength(0); expect(document.querySelectorAll('.fa-gear')).toHaveLength(0); expect(document.querySelectorAll('.lk-popover')).toHaveLength(0); diff --git a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx index bc61847f5c..cc5d82a7be 100644 --- a/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldAggregateOptions.tsx @@ -1,22 +1,13 @@ import React, { FC, memo, useCallback, useMemo } from 'react'; -import { OverlayTrigger } from '../../OverlayTrigger'; -import { Popover } from '../../Popover'; import { RadioGroupInput } from '../forms/input/RadioGroupInput'; -import { BAR_CHART_AGGREGATE_NAME, BAR_CHART_ERROR_BAR_NAME } from './constants'; -import { ChartFieldInfo, ChartTypeInfo } from './models'; +import { ChartConfig, ChartConfigSetter, ChartFieldInfo, ChartTypeInfo } from './models'; import { LabelOverlay } from '../forms/LabelOverlay'; -import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; +import { SelectInput } from '../forms/input/SelectInput'; +import { Utils } from '@labkey/api'; +import { getBarChartAxisLabel } from './utils'; +import { AGGREGATE_METHODS } from './constants'; -const BAR_CHART_AGGREGATE_METHODS = [ - { label: 'None', value: '' }, - { 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 BAR_CHART_ERROR_BAR_TIP = @@ -29,29 +20,26 @@ const ERROR_BAR_TYPES = [ ]; interface OwnProps { - asOverlay?: boolean; + chartConfig: ChartConfig; field: ChartFieldInfo; - fieldValues: Record; - onErrorBarChange: (name: string, value: string) => void; - onSelectFieldChange: (name: string, value: string, selectedOption: SelectInputOption) => void; selectedType: ChartTypeInfo; + setChartConfig: ChartConfigSetter; } export const ChartFieldAggregateOptions: FC = memo(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 { chartConfig, field, selectedType, setChartConfig } = props; + const yMeasure = Array.isArray(chartConfig.measures.y) ? chartConfig.measures.y[0] : chartConfig.measures.y; + // Some older charts stored aggregate as an object that looked like: { label: 'Mean', value: 'MEAN' } + const aggregateValue = Utils.isObject(yMeasure.aggregate) ? yMeasure.aggregate.value : yMeasure.aggregate; + const errorBarValue = yMeasure.errorBars; const includeNone = selectedType.name === 'line_plot'; const includeCount = selectedType.name === 'bar_chart'; - const defaultAggregateValue = useMemo(() => (includeNone ? '' : 'SUM'), [includeNone]); - const errorBarRadioEnabled = useMemo(() => aggregateValue === 'MEAN', [aggregateValue]); + const defaultAggregateValue = includeNone ? '' : 'SUM'; + const errorBarRadioEnabled = aggregateValue === 'MEAN'; const aggregateOptions = useMemo(() => { - const options = BAR_CHART_AGGREGATE_METHODS.filter(option => { - if (option.value === 'COUNT' && !includeCount) { - return false; - } + const options = AGGREGATE_METHODS.filter(option => { + if (option.value === 'COUNT' && !includeCount) return false; return !(option.value === '' && !includeNone); }); @@ -66,35 +54,43 @@ export const ChartFieldAggregateOptions: FC = memo(props => { })); }, [errorBarRadioEnabled, errorBarValue]); - const onAggregateChange = useCallback( - (name: string, value: string, selectedOption: SelectInputOption) => { - onSelectFieldChange(name, value, selectedOption); - }, - [onSelectFieldChange] - ); + const onChange = useCallback( + (propName: string, value: string) => { + setChartConfig(current => { + const updatedConfig = { + ...current, + measures: { + ...current.measures, + [field.name]: { ...current.measures[field.name], [propName]: value }, + }, + }; - const onErrorBarValueChange = useCallback( - (value: string) => { - onErrorBarChange(BAR_CHART_ERROR_BAR_NAME, value); + if (selectedType.name === 'bar_chart') { + updatedConfig.labels = { + ...updatedConfig.labels, + y: getBarChartAxisLabel(updatedConfig, current), + }; + } + + return updatedConfig; + }); }, - [onErrorBarChange] + [field.name, selectedType.name, setChartConfig] ); - // Only show the aggregate options if there is a field selected - if (!fieldValue?.value) { - return null; - } + const onAggregateChange = useCallback((_: never, value: string) => onChange('aggregate', value), [onChange]); + const onErrorBarValueChange = useCallback((value: string) => onChange('errorBars', value), [onChange]); - const inputs = ( + return ( <>
= memo(props => {
); - - if (!asOverlay) { - return inputs; - } - - return ( -
- - {inputs} - - } - triggerType="click" - > - - -
- ); }); ChartFieldAggregateOptions.displayName = 'ChartFieldAggregateOptions'; diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx index c67e2e8455..c2024c7ee2 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.test.tsx @@ -65,14 +65,14 @@ describe('ChartFieldOption', () => { test('line chart for x, showFieldOptions for int', async () => { render( ); @@ -83,17 +83,17 @@ describe('ChartFieldOption', () => { expect(document.querySelectorAll('.field-option-icon')).toHaveLength(1); }); - test('line chart for x, not showFieldOptions for date', async () => { + test('line chart for x, date field', async () => { render( ); @@ -101,20 +101,20 @@ describe('ChartFieldOption', () => { expect(document.querySelector('label').textContent).toBe('X Axis *'); }); expect(document.querySelectorAll('.select-input')).toHaveLength(1); - expect(document.querySelectorAll('.field-option-icon')).toHaveLength(0); + expect(document.querySelectorAll('.field-option-icon')).toHaveLength(1); }); - test('bar chart for x, not showFieldOptions', async () => { + test('bar chart for x', async () => { render( ); @@ -122,20 +122,20 @@ describe('ChartFieldOption', () => { expect(document.querySelector('label').textContent).toBe('X Axis *'); }); expect(document.querySelectorAll('.select-input')).toHaveLength(1); - expect(document.querySelectorAll('.field-option-icon')).toHaveLength(0); + expect(document.querySelectorAll('.field-option-icon')).toHaveLength(1); }); test('label for not required', async () => { render( ); @@ -147,14 +147,14 @@ describe('ChartFieldOption', () => { test('default values set for scale', async () => { render( ); @@ -164,12 +164,12 @@ describe('ChartFieldOption', () => { expect(document.querySelectorAll('.select-input')).toHaveLength(1); expect(document.querySelectorAll('.field-option-icon')).toHaveLength(1); - // scale options not shown until clicking on the gear icon + // additional options not shown until clicking on the gear icon expect(document.querySelectorAll('.radioinput-label')).toHaveLength(0); expect(document.querySelectorAll('input')).toHaveLength(2); await userEvent.click(document.querySelector('.fa-gear')); expect(document.querySelectorAll('.radioinput-label')).toHaveLength(4); - expect(document.querySelectorAll('input')).toHaveLength(6); + expect(document.querySelectorAll('input')).toHaveLength(7); expect(document.querySelectorAll('.radioinput-label.selected')[0].textContent).toBe('Linear'); expect(document.querySelectorAll('.radioinput-label.selected')[1].textContent).toBe('Automatic'); @@ -180,14 +180,14 @@ describe('ChartFieldOption', () => { test('initial values set from scaleValues', async () => { render( ); @@ -218,14 +218,14 @@ describe('ChartFieldOption', () => { test('invalid scale range, max < min', async () => { render( ); diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index 26e01eda9b..69051d62f2 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -1,61 +1,93 @@ import React, { FC, memo, useCallback, useMemo, useState } from 'react'; -import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; +import { SelectInput } from '../forms/input/SelectInput'; import { QueryModel } from '../../../public/QueryModel/QueryModel'; import { LABKEY_VIS } from '../../constants'; +import { QueryColumn } from '../../../public/QueryColumn'; -import { ChartFieldRangeScaleOptions } from './ChartFieldRangeScaleOptions'; -import { ChartFieldInfo, ChartTypeInfo, ScaleType } from './models'; -import { getFieldDataType, getSelectOptions, shouldShowAggregateOptions, shouldShowRangeScaleOptions } from './utils'; -import { ChartFieldAggregateOptions } from './ChartFieldAggregateOptions'; +import { ChartConfig, ChartConfigSetter, ChartFieldInfo, ChartLabels, ChartTypeInfo, ScaleType } from './models'; +import { getBarChartAxisLabel, getSelectOptions, hasTrendline } from './utils'; + +import { ChartFieldAdditionalOptions } from './ChartFieldAdditionalOptions'; const DEFAULT_SCALE_VALUES = { type: 'automatic', trans: 'linear' }; interface OwnProps { + chartConfig: ChartConfig; 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: ScaleType; + onLabelChange: (key: keyof ChartLabels, value: string) => void; selectedType: ChartTypeInfo; + setChartConfig: ChartConfigSetter; } export const ChartFieldOption: FC = memo(props => { - const { - field, - model, - selectedType, - onSelectFieldChange, - scaleValues, - fieldValues, - onScaleChange, - onErrorBarChange, - } = props; - const fieldValue = fieldValues?.[field.name]; - const [scale, setScale] = useState(scaleValues?.type ? scaleValues : DEFAULT_SCALE_VALUES); - + const { chartConfig, field, model, onLabelChange, selectedType, setChartConfig } = props; + const { measures, scales } = chartConfig; + const measure = measures?.[field.name]; + const [scale, setScale] = useState(() => { + return scales[field.name] ?? DEFAULT_SCALE_VALUES; + }); const options = useMemo(() => getSelectOptions(model, selectedType, field), [model, selectedType, field]); - const isNumericType = useMemo( - () => LABKEY_VIS.GenericChartHelper.isNumericType(getFieldDataType(fieldValue?.data)), - [fieldValue?.data] - ); - const showRangeScaleOptions = isNumericType && shouldShowRangeScaleOptions(field, selectedType); - const showAggregateOptions = isNumericType && shouldShowAggregateOptions(field, selectedType); + const isPieChart = selectedType.name === 'pie_chart'; + const showAdditionalOptions = !isPieChart && measure && (field.name === 'x' || field.name === 'y'); - // Issue 52050: use fieldKey for special characters - const selectInputValue = useMemo(() => fieldValue?.data.fieldKey ?? fieldValue?.value, [fieldValue]); + const onScaleChange = useCallback( + (scale: ScaleType, localOnly = false) => { + setScale(current => ({ ...current, ...scale })); + + if (!localOnly) { + setChartConfig(current => { + let updatedScale = current.scales?.[field.name] ?? DEFAULT_SCALE_VALUES; + updatedScale = { ...updatedScale, ...scale }; + return { ...current, scales: { ...current.scales, [field.name]: updatedScale } }; + }); + } + }, + [field.name, setChartConfig] + ); - const onSelectFieldChange_ = useCallback( - (name: string, value: string, selectedOption: SelectInputOption) => { - onScaleChange(field.name, undefined, undefined, true); + const onSelectChange = useCallback( + (name: string, _: never, col: QueryColumn) => { setScale(DEFAULT_SCALE_VALUES); - onSelectFieldChange(name, value, selectedOption); + setChartConfig(current => { + let geomOptions = current.geomOptions; + const measures = { ...current.measures }; + const scales = { ...current.scales }; + const labels = { ...current.labels }; + + if (!col) { + delete measures[name]; + delete scales[name]; + delete labels[name]; + } else { + measures[name] = { + fieldKey: col.fieldKey, + label: col.caption, + name: col.name, + type: col.jsonType, + }; + scales[name] = DEFAULT_SCALE_VALUES; + labels[name] = col.caption; + } + + if (name === 'x' && hasTrendline(selectedType)) { + const trendlineType = LABKEY_VIS.GenericChartHelper.TRENDLINE_OPTIONS[''].value; + geomOptions = { ...geomOptions, trendlineType }; + } + + const updatedConfig = { ...current, geomOptions, measures, labels }; + + if (selectedType.name === 'bar_chart') { + updatedConfig.labels.y = getBarChartAxisLabel(updatedConfig, current); + } + + return updatedConfig; + }); }, - [field.name, onScaleChange, onSelectFieldChange] + [selectedType, setChartConfig] ); return ( @@ -67,33 +99,27 @@ export const ChartFieldOption: FC = memo(props => {
- {showRangeScaleOptions && ( - - {showAggregateOptions && ( - - )} - + selectedType={selectedType} + setChartConfig={setChartConfig} + /> )}
diff --git a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx index 6bde631519..72e6e6c4bc 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.test.tsx @@ -1,42 +1,15 @@ 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 = {} as ScaleType) { - return render( - -
Children Content
-
- ); + return render(); } 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); @@ -50,7 +23,6 @@ describe('ChartFieldRangeScaleOptions', () => { 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'); @@ -63,19 +35,16 @@ describe('ChartFieldRangeScaleOptions', () => { 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 } 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 } 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 b3d63663fd..2baba4493a 100644 --- a/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldRangeScaleOptions.tsx @@ -1,9 +1,7 @@ -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 React, { ChangeEvent, FC, memo, useCallback, useMemo } from 'react'; +import { RadioGroupInput } from '../forms/input/RadioGroupInput'; -import { ChartFieldInfo, ScaleType } from './models'; +import { ScaleType } from './models'; const SCALE_TRANS_TYPES = [ { value: 'linear', label: 'Linear' }, @@ -15,18 +13,14 @@ const SCALE_RANGE_TYPES = [ { value: 'manual', label: 'Manual' }, ]; -interface OwnProps extends PropsWithChildren { - field: ChartFieldInfo; - onScaleChange: (field: string, key: string, value: number | string, reset?: boolean) => void; +interface Props { + onScaleChange: (scale: Partial, localOnly?: boolean) => void; scale: ScaleType; - setScale: (scale: ScaleType) => void; showScaleTrans: boolean; } -export const ChartFieldRangeScaleOptions: FC = memo(props => { - const { field, scale, setScale, onScaleChange, showScaleTrans, children } = props; - const placement = useMemo(() => (!showScaleTrans && children ? 'left' : 'bottom'), [showScaleTrans, children]); - +export const ChartFieldRangeScaleOptions: FC = memo(props => { + const { scale, onScaleChange, showScaleTrans } = props; const scaleTransOptions = useMemo(() => { return SCALE_TRANS_TYPES.map(option => ({ ...option, selected: scale.trans === option.value })); }, [scale.trans]); @@ -47,102 +41,88 @@ export const ChartFieldRangeScaleOptions: FC = memo(props => { const onScaleTransChange = useCallback( (selected: string) => { - onScaleChange(field.name, 'trans', selected); - setScale({ ...scale, trans: selected }); + onScaleChange({ trans: selected }); }, - [field.name, onScaleChange, setScale, scale] + [onScaleChange] ); const onScaleTypeChange = useCallback( (selected: string) => { - let scale_ = { ...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_); + let scale_: Partial = { type: selected }; + if (selected === 'automatic') scale_ = { ...scale_, min: undefined, max: undefined }; + onScaleChange(scale_); }, - [field.name, onScaleChange, scale, setScale] + [onScaleChange] ); const onScaleMinChange = useCallback( (event: ChangeEvent) => { - setScale({ ...scale, min: event.target.value }); + onScaleChange({ min: event.target.value }, true); }, - [setScale, scale] + [onScaleChange] ); const onScaleMaxChange = useCallback( (event: ChangeEvent) => { - setScale({ ...scale, max: event.target.value }); + onScaleChange({ max: event.target.value }, true); }, - [setScale, scale] + [onScaleChange] ); 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]); + onScaleChange({ + min: parseFloat(scale.min?.toString()), + max: parseFloat(scale.max?.toString()), + }); + }, [invalidRange, onScaleChange, scale]); return ( -
- - {children} - {showScaleTrans && ( -
- - -
- )} -
- - -
- {scale.type === 'manual' && ( -
- - - - - {invalidRange &&
Invalid range (Max <= Min)
} -
- )} - - } - triggerType="click" - > - -
+
+ {showScaleTrans && ( +
+ + +
+ )} +
+ + +
+ {scale.type === 'manual' && ( +
+ + - + + {invalidRange &&
Invalid range (Max <= Min)
} +
+ )}
); }); diff --git a/packages/components/src/internal/components/chart/ChartLabelInput.tsx b/packages/components/src/internal/components/chart/ChartLabelInput.tsx new file mode 100644 index 0000000000..205f44857b --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartLabelInput.tsx @@ -0,0 +1,40 @@ +import { ChartLabels } from './models'; +import React, { ChangeEvent, FC, memo, useCallback, useState } from 'react'; +import { useEnterEscape } from '../../../public/useEnterEscape'; + +type LabelKey = keyof ChartLabels; + +interface Props { + label: string; + name: LabelKey; + onChange: (name: LabelKey, value: string) => void; + value: string; +} + +export const ChartLabelInput: FC = memo(({ label, name, onChange, value }) => { + const [inputValue, setInputValue] = useState(value ?? ''); + const onChange_ = useCallback((e: ChangeEvent) => setInputValue(e.target.value), []); + const onBlur = useCallback(() => { + if (inputValue.trim() !== value) onChange(name, inputValue.trim()); + }, [inputValue, name, onChange, value]); + const onKeyDown = useEnterEscape(onBlur); + const inputName = `${name}-label`; + + return ( +
+
+ + +
+
+ ); +}); +ChartLabelInput.displayName = 'ChartLabelInput'; diff --git a/packages/components/src/internal/components/chart/ChartSettingsPanel.tsx b/packages/components/src/internal/components/chart/ChartSettingsPanel.tsx new file mode 100644 index 0000000000..28d7694721 --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartSettingsPanel.tsx @@ -0,0 +1,384 @@ +import React, { ChangeEvent, FC, memo, useCallback, useMemo, useState } from 'react'; +import { + BaseChartModel, + BaseChartModelSetter, + ChartConfig, + ChartConfigSetter, + ChartLabels, + ChartTypeInfo, +} from './models'; +import { LABKEY_VIS } from '../../constants'; +import { HIDDEN_CHART_TYPES, ICONS } from './constants'; +import { SelectInput } from '../forms/input/SelectInput'; +import { SVGIcon } from '../base/SVGIcon'; +import { CheckboxLK } from '../../Checkbox'; +import { ChartFieldOption } from './ChartFieldOption'; +import { QueryModel } from '../../../public/QueryModel/QueryModel'; +import { TrendlineOption } from './TrendlineOption'; +import { deepCopyChartConfig, hasTrendline } from './utils'; +import classNames from 'classnames'; +import { useEnterEscape } from '../../../public/useEnterEscape'; +import { ChartLabelInput } from './ChartLabelInput'; + +function changedIntValue(strVal: string, currentVal: number): [value: number, changed: boolean] { + strVal = strVal.trim(); + const value = strVal === '' ? undefined : parseInt(strVal, 10); + // If the number is NaN then we don't want to trigger change + const changed = (value === undefined || !isNaN(value)) && value !== currentVal; + + return [value, changed]; +} + +function computeMarginTop(main: string, subtitle: string): number { + let marginTop = 15; + const hasTitle = !!main?.trim(); + const hasSubtitle = !!subtitle?.trim(); + + if (hasTitle && hasSubtitle) marginTop += 50; + else if (hasTitle) marginTop += 25; + // Yes, really, subtitle only gets the most padding. Our charting library is probably setting some + // default amount if main is present + else if (hasSubtitle) marginTop += 55; + + return marginTop; +} + +interface InputProps { + label: string; + name: string; + value: string; +} + +interface BoolSettingInputProps extends Omit { + onChange: (name: string, value: boolean) => void; + value: boolean; +} + +const BoolSettingInput: FC = memo(({ label, name, onChange, value }) => { + const onChange_ = useCallback( + (event: ChangeEvent) => { + onChange(name, event.target.checked); + }, + [name, onChange] + ); + return ( + + {label} + + ); +}); +BoolSettingInput.displayName = 'BoolSettingInput'; + +interface NumberInputProps extends Omit { + disabled: boolean; + name: 'height' | 'width'; + onBlur: () => void; + onChange: (name: 'height' | 'width', value: string) => void; + value: string; +} + +const NumberInput: FC = memo(({ disabled, label, name, onBlur, onChange, value }) => { + const onInputChange = useCallback( + (event: ChangeEvent) => onChange(name, event.target.value), + [name, onChange] + ); + const onKeyDown = useEnterEscape(onBlur); + + return ( +
+ + +
+ ); +}); +NumberInput.displayName = 'NumberInput'; + +interface SizeInputsProps { + height: number; + setChartConfig: ChartConfigSetter; + width: number; +} +const SizeInputs: FC = memo(({ height, setChartConfig, width }) => { + // We store sizes in a separate useState so the user can edit the values without us rerendering on every keystroke, + // and so we can easily handle invalid values. + const [sizes, setSizes] = useState>(() => ({ + height: height?.toString() ?? '', + width: width?.toString() ?? '', + })); + const [useFullWidth, setUseFullWidth] = useState(width === undefined); + const onCheckboxChange = useCallback( + (e: ChangeEvent) => { + const checked = e.target.checked; + + if (checked) { + setChartConfig(current => ({ ...current, width: undefined })); + setSizes(current => ({ ...current, width: '' })); + } + + setUseFullWidth(checked); + }, + [setChartConfig] + ); + const onNumberChange = useCallback((name, value) => setSizes(current => ({ ...current, [name]: value })), []); + const onBlur = useCallback(() => { + setChartConfig(current => { + const [height, heightChanged] = changedIntValue(sizes.height, current.height); + const [width, widthChanged] = changedIntValue(sizes.width, current.width); + + if (!heightChanged && !widthChanged) return current; + + return { + ...current, + height: heightChanged ? height : current.height, + width: widthChanged ? width : current.width, + }; + }); + }, [setChartConfig, sizes]); + + return ( + <> + {/* intentionally not setting form-group class on the div below */} +
+
+ +
+
+ +
+
+
+
+ + Full Width + +
+
+ + ); +}); +SizeInputs.displayName = 'SizeInputs'; + +interface ChartTypeOptionRendererProps { + chartType: ChartTypeInfo; + isValueRenderer: boolean; +} +const ChartTypeOptionRenderer: FC = memo(({ chartType, isValueRenderer }) => { + const icon = ICONS[chartType.name]; + const isSvg = icon.endsWith('.svg'); + const className = classNames('chart-builder-type-option', { 'chart-builder-type-option--value': isValueRenderer }); + return ( + + {isSvg && ( + + )} + {!isSvg && } + {chartType.title} + + ); +}); +ChartTypeOptionRenderer.displayName = 'ChartTypeOptionRenderer'; + +function chartTypeOptionRenderer(option) { + const chartType: ChartTypeInfo = option.data as ChartTypeInfo; + return ; +} + +function chartTypeValueRenderer(option) { + const chartType: ChartTypeInfo = option.data as ChartTypeInfo; + return ; +} + +interface ChartTypeDropdownProps { + onChange: (chartTypeInfo: ChartTypeInfo) => void; + selectedType: ChartTypeInfo; +} + +const ChartTypeDropdown: FC = memo(({ onChange, selectedType }) => { + const chartTypes = useMemo(() => { + const allTypes = LABKEY_VIS?.GenericChartHelper.getRenderTypes(); + return allTypes.filter(type => !type.hidden && !HIDDEN_CHART_TYPES.includes(type.name)); + }, []); + const onChange_ = useCallback( + (_, __, opt) => { + onChange(opt); + }, + [onChange] + ); + + return ( +
+ +
+ +
+
+ ); +}); +ChartTypeDropdown.displayName = 'ChartTypeDropdown'; + +interface Props { + allowInherit: boolean; + canShare: boolean; + chartConfig: ChartConfig; + chartModel: BaseChartModel; + chartType: ChartTypeInfo; + isNew: boolean; + model: QueryModel; + setChartConfig: ChartConfigSetter; + setChartModel: BaseChartModelSetter; +} + +export const ChartSettingsPanel: FC = memo(props => { + const { allowInherit, canShare, chartConfig, chartType, chartModel, isNew, model, setChartConfig, setChartModel } = + props; + const showTrendline = hasTrendline(chartType); + const fields = chartType.fields.filter(f => f.name !== 'trendline'); + + const onChartModelChange = useCallback( + (key: keyof BaseChartModel, value: boolean | string) => { + setChartModel(current => ({ ...current, [key]: value })); + }, + [setChartModel] + ); + + const onNameChange = useCallback( + (event: ChangeEvent) => { + onChartModelChange('name', event.target.value); + }, + [onChartModelChange] + ); + + const onTypeChange = useCallback( + (type: ChartTypeInfo) => { + setChartConfig(current => { + const { main, subtitle } = current.labels; + const newConfig = deepCopyChartConfig(undefined, type.name); + return { + ...newConfig, + labels: { main, subtitle }, + // Keep marginTop to account for main / subtitle labels + geomOptions: { ...newConfig.geomOptions, marginTop: computeMarginTop(main, subtitle) }, + }; + }); + }, + [setChartConfig] + ); + + const onLabelChange = useCallback( + (key: keyof ChartLabels, value: string) => { + setChartConfig(current => { + const labels = { ...current.labels, [key]: value }; + let geomOptions = current.geomOptions; + const marginTop = computeMarginTop(labels.main, labels.subtitle); + if (marginTop != geomOptions.marginTop) geomOptions = { ...geomOptions, marginTop }; + + return { ...current, labels, geomOptions }; + }); + }, + [setChartConfig] + ); + + return ( +
+

Settings

+
+ + +
+ + {canShare && ( + + )} + + {allowInherit && ( + + )} + + {!isNew && } + + {fields.map(field => ( + + ))} + + {showTrendline && ( + + )} + +

Customize

+ + + +
+ ); +}); +ChartSettingsPanel.displayName = 'ChartSettingsPanel'; diff --git a/packages/components/src/internal/components/chart/TrendlineOption.test.tsx b/packages/components/src/internal/components/chart/TrendlineOption.test.tsx index f80075bd5a..b8632f6c32 100644 --- a/packages/components/src/internal/components/chart/TrendlineOption.test.tsx +++ b/packages/components/src/internal/components/chart/TrendlineOption.test.tsx @@ -10,21 +10,31 @@ import { TrendlineOption } from './TrendlineOption'; import { makeTestQueryModel } from '../../../public/QueryModel/testUtils'; import { QueryInfo } from '../../../public/QueryInfo'; import { ViewInfo } from '../../ViewInfo'; -import { ChartTypeInfo } from './models'; +import { ChartConfig, ChartTypeInfo } from './models'; LABKEY_VIS = { GenericChartHelper: { getAllowableTypes: () => ['text'], isMeasureDimensionMatch: () => true, TRENDLINE_OPTIONS: [ - { value: 'option1', label: 'Option 1', schemaPrefix: undefined }, - { value: 'option2', label: 'Option 2', schemaPrefix: null }, - { value: 'option3', label: 'Option 3', schemaPrefix: 'other' }, - { value: 'option4', label: 'Option 4', schemaPrefix: 'assay' }, + { value: 'option1', label: 'Option 1', schemaPrefix: undefined, showMin: false, showMax: false }, + { value: 'option2', label: 'Option 2', schemaPrefix: null, showMin: false, showMax: false }, + { value: 'option3', label: 'Option 3', schemaPrefix: 'other', showMin: true, showMax: true }, + { value: 'option4', label: 'Option 4', schemaPrefix: 'assay', showMin: true, showMax: false }, ], }, }; +const baseChartConfig = { + geomOptions: {}, + gridLinesVisible: undefined, + labels: {}, + measures: {}, + pointType: undefined, + renderType: 'line_plot', + scales: {}, +} as ChartConfig; + const LINE_PLOT_TYPE = { name: 'line_plot', } as ChartTypeInfo; @@ -57,11 +67,10 @@ describe('TrendlineOption', () => { test('hidden without x-axis value selected', async () => { render( ); await waitFor(() => { @@ -75,20 +84,30 @@ describe('TrendlineOption', () => { expect(document.querySelectorAll('input[name="trendlineParameters"]')).toHaveLength(0); }); - test('shown with x-axis value selected, non-date', async () => { - const fieldValues = { - x: { data: { jsonType: 'int' }, value: 'field1' }, - trendlineAsymptoteMin: { value: undefined }, - trendlineAsymptoteMax: { value: undefined }, - trendlineParameters: { value: undefined }, - }; + test('shown with x-axis value selected, options filtered by schemaPrefix', async () => { + const chartConfig = { + ...baseChartConfig, + geomOptions: { + trendlineAsymptoteMin: undefined, + trendlineAsymptoteMax: undefined, + trendlineParameters: undefined, + }, + measures: { + x: { fieldKey: 'field1', jsonType: 'int' }, + }, + } as ChartConfig; + const assayModel = makeTestQueryModel( + new SchemaQuery('assay', 'query'), + QueryInfo.fromJsonForTests({ columns, name: 'query', schemaName: 'assay' }), + [], + 0 + ); render( ); await waitFor(() => { @@ -109,19 +128,23 @@ describe('TrendlineOption', () => { }); test('hidden with x-axis value selected, date', async () => { - const fieldValues = { - x: { data: { jsonType: 'date' }, value: 'field1' }, - trendlineAsymptoteMin: { value: undefined }, - trendlineAsymptoteMax: { value: undefined }, - trendlineParameters: { value: undefined }, - }; + const chartConfig = { + ...baseChartConfig, + geomOptions: { + trendlineAsymptoteMin: undefined, + trendlineAsymptoteMax: undefined, + trendlineParameters: undefined, + }, + measures: { + x: { name: 'field1', jsonType: 'date' }, + }, + } as ChartConfig; render( ); await waitFor(() => { @@ -133,19 +156,23 @@ describe('TrendlineOption', () => { }); test('hidden with x-axis value selected, time', async () => { - const fieldValues = { - x: { data: { type: 'time' }, value: 'field1' }, - trendlineAsymptoteMin: { value: undefined }, - trendlineAsymptoteMax: { value: undefined }, - trendlineParameters: { value: undefined }, - }; + const chartConfig = { + ...baseChartConfig, + geomOptions: { + trendlineAsymptoteMin: undefined, + trendlineAsymptoteMax: undefined, + trendlineParameters: undefined, + }, + measures: { + x: { name: 'field1', jsonType: 'time' }, + }, + } as ChartConfig; render( ); await waitFor(() => { @@ -157,20 +184,24 @@ describe('TrendlineOption', () => { }); test('show asymptote min and max', async () => { - const fieldValues = { - x: { data: { jsonType: 'int' }, value: 'field1' }, - trendlineType: { value: 'option1', showMin: true, showMax: true }, - trendlineAsymptoteMin: { value: '0.1' }, - trendlineAsymptoteMax: { value: '1.0' }, - trendlineParameters: { value: undefined }, - }; + const chartConfig = { + ...baseChartConfig, + geomOptions: { + trendlineType: 'option3', + trendlineAsymptoteMin: 0.1, + trendlineAsymptoteMax: 1.0, + trendlineParameters: undefined, + }, + measures: { + x: { name: 'field1', jsonType: 'int' }, + }, + } as ChartConfig; render( ); await waitFor(() => { @@ -186,7 +217,7 @@ describe('TrendlineOption', () => { await userEvent.click(document.querySelector('input[value="manual"]')); expect(document.querySelectorAll('input[type="number"]')).toHaveLength(2); expect(document.querySelector('input[name="trendlineAsymptoteMin"]').getAttribute('value')).toBe('0.1'); - expect(document.querySelector('input[name="trendlineAsymptoteMax"]').getAttribute('value')).toBe('1.0'); + expect(document.querySelector('input[name="trendlineAsymptoteMax"]').getAttribute('value')).toBe('1'); // clicking automatic should hide the inputs and clear values await userEvent.click(document.querySelector('input[value="automatic"]')); @@ -198,20 +229,24 @@ describe('TrendlineOption', () => { }); test('show asymptote min but not max', async () => { - const fieldValues = { - x: { data: { jsonType: 'int' }, value: 'field1' }, - trendlineType: { value: 'option1', showMin: true, showMax: false }, - trendlineAsymptoteMin: { value: '0.1' }, - trendlineAsymptoteMax: { value: undefined }, - trendlineParameters: { value: undefined }, - }; + const chartConfig = { + ...baseChartConfig, + geomOptions: { + trendlineType: 'option4', + trendlineAsymptoteMin: 0.1, + trendlineAsymptoteMax: undefined, + trendlineParameters: undefined, + }, + measures: { + x: { name: 'field1', jsonType: 'int' }, + }, + } as ChartConfig; render( ); await waitFor(() => { @@ -239,20 +274,24 @@ describe('TrendlineOption', () => { }); test('show provided parameters in trendline gear tooltip', async () => { - const fieldValues = { - x: { data: { jsonType: 'int' }, value: 'field1' }, - trendlineType: { value: 'option1', showMin: true, showMax: true }, - trendlineAsymptoteMin: { value: undefined }, - trendlineAsymptoteMax: { value: undefined }, - trendlineParameters: { value: 'field1' }, + const chartConfig = { + ...baseChartConfig, + geomOptions: { + trendlineType: 'option1', + trendlineAsymptoteMin: undefined, + trendlineAsymptoteMax: undefined, + trendlineParameters: 'field1', + }, + measures: { + x: { name: 'field1', jsonType: 'int' }, + }, }; render( ); await waitFor(() => { diff --git a/packages/components/src/internal/components/chart/TrendlineOption.tsx b/packages/components/src/internal/components/chart/TrendlineOption.tsx index aa2f3c9cce..1a4720977c 100644 --- a/packages/components/src/internal/components/chart/TrendlineOption.tsx +++ b/packages/components/src/internal/components/chart/TrendlineOption.tsx @@ -1,7 +1,6 @@ import React, { ChangeEvent, FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; -import { SchemaQuery } from '../../../public/SchemaQuery'; +import { SelectInput } from '../forms/input/SelectInput'; import { LABKEY_VIS } from '../../constants'; import { LabelOverlay } from '../forms/LabelOverlay'; @@ -10,9 +9,10 @@ import { OverlayTrigger } from '../../OverlayTrigger'; import { Popover } from '../../Popover'; import { RadioGroupInput, RadioGroupOption } from '../forms/input/RadioGroupInput'; -import { ChartFieldInfo, ChartTypeInfo, TrendlineType } from './models'; +import { ChartConfig, ChartConfigSetter, ChartFieldInfo, ChartTypeInfo, TrendlineType } from './models'; import { getFieldDataType, getSelectOptions } from './utils'; import { QueryModel } from '../../../public/QueryModel/QueryModel'; +import { QueryColumn } from '../../../public/QueryColumn'; const ASYMPTOTE_TYPES = [ { value: 'automatic', label: 'Automatic' }, @@ -20,37 +20,46 @@ const ASYMPTOTE_TYPES = [ ]; interface TrendlineOptionProps { - fieldValues: Record; + chartConfig: ChartConfig; model: QueryModel; - onFieldChange: (key: string, value: SelectInputOption) => void; - schemaQuery: SchemaQuery; selectedType: ChartTypeInfo; + setChartConfig: ChartConfigSetter; } export const TrendlineOption: FC = memo(props => { const TRENDLINE_OPTIONS: TrendlineType[] = Object.values(LABKEY_VIS.GenericChartHelper.TRENDLINE_OPTIONS); - const { fieldValues, onFieldChange, schemaQuery, model, selectedType } = props; - const showFieldOptions = fieldValues.trendlineType && fieldValues.trendlineType.value !== ''; + const { chartConfig, model, selectedType, setChartConfig } = props; + const schemaQuery = model.schemaQuery; + const { geomOptions, measures } = chartConfig; + const selectedTrendlineType = useMemo( + () => TRENDLINE_OPTIONS.find(option => option.value === geomOptions.trendlineType), + [TRENDLINE_OPTIONS, geomOptions.trendlineType] + ); + const showFieldOptions = !!geomOptions.trendlineType; // hide the trendline option if no x-axis value selected and for date field selection on x-axis const hidden = useMemo(() => { - const jsonType = getFieldDataType(fieldValues.x?.data); - return !fieldValues.x?.value || jsonType === 'date' || jsonType === 'time'; - }, [fieldValues.x]); + const jsonType = getFieldDataType(measures.x); + return !measures.x || jsonType === 'date' || jsonType === 'time'; + }, [measures.x]); const [loadingTrendlineOptions, setLoadingTrendlineOptions] = useState(true); const [asymptoteType, setAsymptoteType] = useState('automatic'); const [asymptoteMin, setAsymptoteMin] = useState(''); const [asymptoteMax, setAsymptoteMax] = useState(''); + const invalidRange = useMemo( + () => !!asymptoteMin && !!asymptoteMax && asymptoteMax <= asymptoteMin, + [asymptoteMin, asymptoteMax] + ); useEffect(() => { - if (loadingTrendlineOptions && (!!fieldValues.trendlineAsymptoteMin || !!fieldValues.trendlineAsymptoteMax)) { + if (loadingTrendlineOptions && (!!geomOptions.trendlineAsymptoteMin || !!geomOptions.trendlineAsymptoteMax)) { setAsymptoteType('manual'); - setAsymptoteMin(fieldValues.trendlineAsymptoteMin?.value); - setAsymptoteMax(fieldValues.trendlineAsymptoteMax?.value); + setAsymptoteMin(geomOptions.trendlineAsymptoteMin?.toString()); + setAsymptoteMax(geomOptions.trendlineAsymptoteMax?.toString()); setLoadingTrendlineOptions(false); } - }, [fieldValues, loadingTrendlineOptions]); + }, [geomOptions, loadingTrendlineOptions]); const onTrendlineAsymptoteMin = useCallback((event: ChangeEvent) => { setAsymptoteMin(event.target.value); @@ -60,20 +69,43 @@ export const TrendlineOption: FC = memo(props => { setAsymptoteMax(event.target.value); }, []); - const clearTrendlineAsymptote = useCallback(() => { - setAsymptoteMin(''); - onFieldChange('trendlineAsymptoteMin', undefined); - setAsymptoteMax(''); - onFieldChange('trendlineAsymptoteMax', undefined); - }, [onFieldChange]); + const setGeomOptions = useCallback( + options => { + setChartConfig(current => ({ + ...current, + geomOptions: { ...current.geomOptions, ...options }, + })); + }, + [setChartConfig] + ); + + const applyTrendlineAsymptote = useCallback(() => { + if (invalidRange) return; + setGeomOptions({ trendlineAsymptoteMin: asymptoteMin, trendlineAsymptoteMax: asymptoteMax }); + }, [asymptoteMin, asymptoteMax, invalidRange, setGeomOptions]); + + const clearTrendlineAsymptote = useCallback( + (updateChartConfig: boolean) => { + setAsymptoteMin(''); + setAsymptoteMax(''); + if (updateChartConfig) { + setGeomOptions({ trendlineAsymptoteMin: undefined, trendlineAsymptoteMax: undefined }); + } + }, + [setGeomOptions] + ); const onTrendlineFieldChange = useCallback( - (key: string, _, selectedOption: SelectInputOption) => { + (_: never, value: string) => { setAsymptoteType('automatic'); - clearTrendlineAsymptote(); - onFieldChange(key, selectedOption); + clearTrendlineAsymptote(false); + setGeomOptions({ + trendlineType: value, + trendlineAsymptoteMin: undefined, + trendlineAsymptoteMax: undefined, + }); }, - [clearTrendlineAsymptote, onFieldChange] + [clearTrendlineAsymptote, setGeomOptions] ); const trendlineOptions = useMemo(() => { @@ -85,7 +117,7 @@ export const TrendlineOption: FC = memo(props => { const onAsymptoteTypeChange = useCallback( (selected: string) => { if (selected === 'automatic') { - clearTrendlineAsymptote(); + clearTrendlineAsymptote(true); } setAsymptoteType(selected); }, @@ -98,7 +130,7 @@ export const TrendlineOption: FC = memo(props => {