diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 0c41b9a91b..bbf591d762 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1", + "version": "7.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1", + "version": "7.2.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 65e8c8bc11..be5e9a96e2 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1", + "version": "7.2.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 e31814bf10..97861c8f53 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,15 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.2.0 +*Released*: 9 December 2025 +- Chart builder updates for series color scale options + - ChartColorInputs for single color geomOptions and series specific color and shape value map + - ChartConfig measuresOptions to store per series mapping object + - Hide and show color options based on selected chart type and measures + - ColorPickerInput update to use fixed position by default and new DEFAULT_COLORS constant + - Add LetterIcon for use with "Auto" set series values + ### version 7.1.1 *Released*: 8 December 2025 - Sample Amount/Units polish: part 2 diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx index 20e85ed3e6..082fa3c65c 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.test.tsx @@ -117,16 +117,9 @@ describe('ChartBuilderModal', () => { 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('.btn:not(.color-picker__button)')).toHaveLength(canDelete ? 3 : 2); expect(document.querySelectorAll('.alert')).toHaveLength(0); - - // 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'); + expect(document.querySelectorAll('.chart-settings__chart-type')).toHaveLength(isNew ? 1 : 0); expect(document.querySelectorAll('input[name="name"]')).toHaveLength(1); expect(document.querySelectorAll('input[name="shared"]')).toHaveLength(canShare ? 1 : 0); @@ -291,7 +284,7 @@ describe('ChartBuilderModal', () => { // click delete button and verify confirm text / buttons await userEvent.click(document.querySelector('.btn-danger')); - const btnItems = document.querySelectorAll('.btn'); + const btnItems = document.querySelectorAll('.btn:not(.color-picker__button)'); expect(btnItems).toHaveLength(2); expect(btnItems[0].textContent).toBe('Cancel'); expect(btnItems[1].textContent).toBe('Delete'); diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 9a2cb2a6ef..f7d77e3711 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -14,7 +14,6 @@ import { RequiresModelAndActions } from '../../../public/QueryModel/withQueryMod import { useServerContext } from '../base/ServerContext'; import { hasPermissions } from '../base/models/User'; -import { Alert } from '../base/Alert'; import { FormButtons } from '../../FormButtons'; import { getContainerFilterForFolder } from '../../query/api'; @@ -354,13 +353,13 @@ export const ChartBuilderModal: FC = memo(({ actions, mo onCancel={onCancel} title={savedChartModel ? 'Edit Chart' : 'Create Chart'} > - {error && {error}} { + test('boxFillColor', () => { + expect(showColorOption({ renderType: 'bar_chart' } as ChartConfig, 'boxFillColor')).toBe(true); + expect(showColorOption({ renderType: 'box_plot' } as ChartConfig, 'boxFillColor')).toBe(true); + expect(showColorOption({ renderType: 'line_plot' } as ChartConfig, 'boxFillColor')).toBe(false); + expect(showColorOption({ renderType: 'scatter_plot' } as ChartConfig, 'boxFillColor')).toBe(false); + expect(showColorOption({ renderType: 'pie_chart' } as ChartConfig, 'boxFillColor')).toBe(false); + + expect( + showColorOption( + { renderType: 'bar_chart', measures: { xSub: { name: 'test' } } } as ChartConfig, + 'boxFillColor' + ) + ).toBe(false); + }); + + test('colorPaletteScale', () => { + expect(showColorOption({ renderType: 'bar_chart' } as ChartConfig, 'colorPaletteScale')).toBe(false); + expect(showColorOption({ renderType: 'box_plot' } as ChartConfig, 'colorPaletteScale')).toBe(false); + expect(showColorOption({ renderType: 'line_plot' } as ChartConfig, 'colorPaletteScale')).toBe(false); + expect(showColorOption({ renderType: 'scatter_plot' } as ChartConfig, 'colorPaletteScale')).toBe(false); + expect(showColorOption({ renderType: 'pie_chart' } as ChartConfig, 'colorPaletteScale')).toBe(true); + + expect( + showColorOption( + { renderType: 'line_plot', measures: { series: { name: 'test' } } } as ChartConfig, + 'colorPaletteScale' + ) + ).toBe(true); + expect( + showColorOption( + { renderType: 'bar_chart', measures: { xSub: { name: 'test' } } } as ChartConfig, + 'colorPaletteScale' + ) + ).toBe(true); + expect( + showColorOption( + { renderType: 'box_plot', measures: { color: { name: 'test' } } } as ChartConfig, + 'colorPaletteScale' + ) + ).toBe(true); + expect( + showColorOption( + { renderType: 'scatter_plot', measures: { color: { name: 'test' } } } as ChartConfig, + 'colorPaletteScale' + ) + ).toBe(true); + }); + + test('lineColor', () => { + expect(showColorOption({ renderType: 'bar_chart' } as ChartConfig, 'lineColor')).toBe(true); + expect(showColorOption({ renderType: 'box_plot' } as ChartConfig, 'lineColor')).toBe(true); + expect(showColorOption({ renderType: 'line_plot' } as ChartConfig, 'lineColor')).toBe(false); + expect(showColorOption({ renderType: 'scatter_plot' } as ChartConfig, 'lineColor')).toBe(false); + expect(showColorOption({ renderType: 'pie_chart' } as ChartConfig, 'lineColor')).toBe(false); + + expect( + showColorOption( + { renderType: 'bar_chart', measures: { xSub: { name: 'test' } } } as ChartConfig, + 'lineColor' + ) + ).toBe(false); + }); + + test('pointFillColor', () => { + expect(showColorOption({ renderType: 'bar_chart' } as ChartConfig, 'pointFillColor')).toBe(false); + expect(showColorOption({ renderType: 'box_plot' } as ChartConfig, 'pointFillColor')).toBe(true); + expect(showColorOption({ renderType: 'line_plot' } as ChartConfig, 'pointFillColor')).toBe(true); + expect(showColorOption({ renderType: 'scatter_plot' } as ChartConfig, 'pointFillColor')).toBe(true); + expect(showColorOption({ renderType: 'pie_chart' } as ChartConfig, 'pointFillColor')).toBe(false); + + expect( + showColorOption( + { renderType: 'line_plot', measures: { series: { name: 'test' } } } as ChartConfig, + 'pointFillColor' + ) + ).toBe(false); + expect( + showColorOption( + { renderType: 'box_plot', measures: { color: { name: 'test' } } } as ChartConfig, + 'pointFillColor' + ) + ).toBe(false); + expect( + showColorOption( + { renderType: 'scatter_plot', measures: { color: { name: 'test' } } } as ChartConfig, + 'pointFillColor' + ) + ).toBe(false); + }); +}); + +describe('ShapeOptionRenderer', () => { + test('isValueRenderer false', () => { + render(); + expect(document.querySelectorAll('.chart-builder-type-option')).toHaveLength(1); + expect(document.querySelectorAll('.chart-builder-type-option--value')).toHaveLength(0); + }); + + test('isValueRenderer true', () => { + render(); + expect(document.querySelectorAll('.chart-builder-type-option')).toHaveLength(1); + expect(document.querySelectorAll('.chart-builder-type-option--value')).toHaveLength(1); + }); +}); + +describe('SeriesOptionRenderer', () => { + test('isValueRenderer false', () => { + render(); + expect(document.querySelectorAll('.chart-builder-type-option')).toHaveLength(1); + expect(document.querySelectorAll('.chart-builder-type-option--value')).toHaveLength(0); + }); + + test('isValueRenderer true', () => { + render(); + expect(document.querySelectorAll('.chart-builder-type-option')).toHaveLength(1); + expect(document.querySelectorAll('.chart-builder-type-option--value')).toHaveLength(1); + }); + + test('without seriesOptionMap value', () => { + render(); + expect(document.querySelector('.chart-builder-type-option').textContent).toBe('A series1'); + expect(document.querySelectorAll('.color-icon__chip-small')).toHaveLength(0); + expect(document.querySelectorAll('i')).toHaveLength(0); + expect(document.querySelectorAll('.letter-icon')).toHaveLength(1); + }); + + test('with seriesOptionMap value', () => { + render(); + expect(document.querySelector('.chart-builder-type-option').textContent).toBe(' series1'); + expect(document.querySelectorAll('.color-icon__chip-small')).toHaveLength(1); + expect(document.querySelectorAll('i')).toHaveLength(1); + expect(document.querySelector('i').getAttribute('style')).toBe('background-color: red;'); + expect(document.querySelectorAll('.letter-icon')).toHaveLength(0); + }); +}); + +describe('ChartColorInputs', () => { + const model = makeTestQueryModel(new SchemaQuery('schema', 'query'), undefined, [], 0); + + test('default bar chart', () => { + render( + + ); + expect(document.querySelectorAll('.row')).toHaveLength(1); + expect(document.querySelectorAll('.color-picker')).toHaveLength(2); + expect(document.querySelectorAll('.select-input')).toHaveLength(0); + }); + + test('default pie chart', () => { + render( + + ); + expect(document.querySelectorAll('.row')).toHaveLength(2); + expect(document.querySelectorAll('.color-picker')).toHaveLength(0); + expect(document.querySelectorAll('.select-input')).toHaveLength(1); + }); + + test('default box plot', () => { + render( + + ); + expect(document.querySelectorAll('.row')).toHaveLength(1); + expect(document.querySelectorAll('.color-picker')).toHaveLength(3); + expect(document.querySelectorAll('.select-input')).toHaveLength(0); + }); + + test('default scatter plot', () => { + render( + + ); + expect(document.querySelectorAll('.row')).toHaveLength(1); + expect(document.querySelectorAll('.color-picker')).toHaveLength(1); + expect(document.querySelectorAll('.select-input')).toHaveLength(0); + }); + + test('scatter plot with color', () => { + render( + + ); + expect(document.querySelectorAll('.row')).toHaveLength(2); + expect(document.querySelectorAll('.color-picker')).toHaveLength(0); + expect(document.querySelectorAll('.select-input')).toHaveLength(1); + }); + + test('default line plot', () => { + render( + + ); + expect(document.querySelectorAll('.row')).toHaveLength(1); + expect(document.querySelectorAll('.color-picker')).toHaveLength(1); + expect(document.querySelectorAll('.select-input')).toHaveLength(0); + }); + + test('line plot with series', () => { + render( + + ); + expect(document.querySelectorAll('.row')).toHaveLength(2); + expect(document.querySelectorAll('.color-picker')).toHaveLength(0); + expect(document.querySelectorAll('.select-input')).toHaveLength(1); + }); +}); diff --git a/packages/components/src/internal/components/chart/ChartColorInputs.tsx b/packages/components/src/internal/components/chart/ChartColorInputs.tsx new file mode 100644 index 0000000000..6ad57a187a --- /dev/null +++ b/packages/components/src/internal/components/chart/ChartColorInputs.tsx @@ -0,0 +1,425 @@ +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { Utils } from '@labkey/api'; +import { ChartConfig, ChartConfigSetter, MeasureOption } from './models'; +import { ColorPickerInput } from '../forms/input/ColorPickerInput'; +import { COLOR_OPTIONS_PER_TYPE, COLOR_PALETTE_OPTIONS, SHAPE_OPTIONS } from './constants'; +import { SelectInput } from '../forms/input/SelectInput'; +import { selectDistinctRows } from '../../query/api'; +import { QueryModel } from '../../../public/QueryModel/QueryModel'; +import { ColorIcon } from '../base/ColorIcon'; +import { LABKEY_VIS } from '../../constants'; +import { RemoveEntityButton } from '../buttons/RemoveEntityButton'; + +enum COLOR_OPTIONS { + BOX_FILL_COLOR = 'boxFillColor', + COLOR_PALETTE_SCALE = 'colorPaletteScale', + LINE_COLOR = 'lineColor', + POINT_FILL_COLOR = 'pointFillColor', +} + +// export for jest testing +export const showColorOption = function (chartConfig: ChartConfig, optionName: COLOR_OPTIONS): boolean { + const chartType = chartConfig.renderType; + const isBarChart = chartType === 'bar_chart'; + const isBoxPlot = chartType === 'box_plot'; + const isLinePlot = chartType === 'line_plot'; + const isScatterPlot = chartType === 'scatter_plot'; + const hasSeries = chartConfig.measures?.series !== undefined; + const hasXSub = chartConfig.measures?.xSub !== undefined; + const hasColor = chartConfig.measures?.color !== undefined; + + switch (optionName) { + case COLOR_OPTIONS.BOX_FILL_COLOR: + return COLOR_OPTIONS_PER_TYPE.boxFillColor.indexOf(chartType) > -1 && (!isBarChart || !hasXSub); // bar chart if config has groupBy measure + case COLOR_OPTIONS.COLOR_PALETTE_SCALE: + return ( + COLOR_OPTIONS_PER_TYPE.colorPaletteScale.indexOf(chartType) > -1 && + (!isLinePlot || hasSeries) && // line plot if config has series measure + (!isBarChart || hasXSub) && // bar chart if config has groupBy measure + (!isBoxPlot || hasColor) && // box plot if config has color measure + (!isScatterPlot || hasColor) // scatter plot if config has color measure + ); + case COLOR_OPTIONS.LINE_COLOR: + return COLOR_OPTIONS_PER_TYPE.lineColor.indexOf(chartType) > -1 && (!isBarChart || !hasXSub); // bar chart if config has groupBy measure + case COLOR_OPTIONS.POINT_FILL_COLOR: + return ( + COLOR_OPTIONS_PER_TYPE.pointFillColor.indexOf(chartType) > -1 && + (!isLinePlot || !hasSeries) && // line plot if config has series measure + (!isBoxPlot || !hasColor) && // box plot if config has color measure + (!isScatterPlot || !hasColor) // scatter plot if config has color measure + ); + default: + return false; + } +}; + +interface ShapeOptionRendererProps { + isValueRenderer: boolean; + name: string; +} + +// export for jest testing +export const ShapeOptionRenderer: FC = memo(({ name, isValueRenderer }) => { + const size = 10; + const iconSize = name === 'diamond' ? size / 2.5 : size / 2; + const icon = LABKEY_VIS.Scale.ShapeMap[name](iconSize); + const className = classNames('chart-builder-type-option', { 'chart-builder-type-option--value': isValueRenderer }); + return ( + + + + + + ); +}); +ShapeOptionRenderer.displayName = 'ShapeOptionRenderer'; + +function shapeOptionRenderer(option) { + return ; +} + +function shapeValueRenderer(option) { + return ; +} + +interface SeriesOptionRendererProps { + isValueRenderer: boolean; + name: string; + seriesOptionMap: Record>; +} + +// export for jest testing +export const SeriesOptionRenderer: FC = memo( + ({ name, seriesOptionMap, isValueRenderer }) => { + const value = seriesOptionMap?.[name]?.color; + const className = classNames('chart-builder-type-option', { + 'chart-builder-type-option--value': isValueRenderer, + }); + return ( + + {value && ( + <> + {name} + + )} + {!value && ( + <> + {name} + + )} + + ); + } +); +SeriesOptionRenderer.displayName = 'SeriesOptionRenderer'; + +function seriesOptionRenderer(option, seriesOptionMap) { + return ( + + ); +} + +interface ChartColorInputsProps { + chartConfig: ChartConfig; + model: QueryModel; + setChartConfig: ChartConfigSetter; +} + +export const ChartColorInputs: FC = memo(({ chartConfig, model, setChartConfig }) => { + const isBoxPlot = chartConfig.renderType === 'box_plot'; + const isLinePlot = chartConfig.renderType === 'line_plot'; + + const boxFillColor = + chartConfig.geomOptions.boxFillColor === 'none' ? undefined : (chartConfig.geomOptions.boxFillColor as string); + const showBoxFillColor = useMemo(() => showColorOption(chartConfig, COLOR_OPTIONS.BOX_FILL_COLOR), [chartConfig]); + const lineColor = chartConfig.geomOptions.lineColor as string; + const showLineColor = useMemo(() => showColorOption(chartConfig, COLOR_OPTIONS.LINE_COLOR), [chartConfig]); + const pointFillColor = chartConfig.geomOptions.pointFillColor as string; + const showPointFillColor = useMemo( + () => showColorOption(chartConfig, COLOR_OPTIONS.POINT_FILL_COLOR), + [chartConfig] + ); + const colorPaletteScale = chartConfig.geomOptions.colorPaletteScale; + const showColorPaletteScale = useMemo( + () => showColorOption(chartConfig, COLOR_OPTIONS.COLOR_PALETTE_SCALE), + [chartConfig] + ); + const showSeriesLineStyle = isLinePlot && chartConfig.measures?.series !== undefined; + const showAnyColorOptions = showBoxFillColor || showLineColor || showPointFillColor; + + const setGeomOptions = useCallback( + options => { + setChartConfig(current => ({ + ...current, + geomOptions: { ...current.geomOptions, ...options }, + })); + }, + [setChartConfig] + ); + + const onColorPaletteChange = useCallback( + (_: never, value: string) => { + setGeomOptions({ colorPaletteScale: value }); + }, + [setGeomOptions] + ); + + const onColorChange = useCallback( + (name: string, value: string) => { + // value comes in as #FFFFFF from ColorPickerInput, but we want it without the # + if (value?.startsWith('#')) { + value = value.substring(1); + } + setGeomOptions({ [name]: value }); + }, + [setGeomOptions] + ); + const onBoxFillColorChange = useCallback( + (_: never, value: string) => { + onColorChange(COLOR_OPTIONS.BOX_FILL_COLOR, value ?? 'none'); + }, + [onColorChange] + ); + const onLineColorChange = useCallback( + (_: never, value: string) => { + onColorChange(COLOR_OPTIONS.LINE_COLOR, value); + }, + [onColorChange] + ); + const onPointFillColorChange = useCallback( + (_: never, value: string) => { + onColorChange(COLOR_OPTIONS.POINT_FILL_COLOR, value); + }, + [onColorChange] + ); + + return ( + <> + {showAnyColorOptions && ( +
+ {showBoxFillColor && ( +
+ + +
+ )} + {showLineColor && ( +
+ + +
+ )} + {showPointFillColor && ( +
+ + +
+ )} +
+ )} + {showColorPaletteScale && ( +
+
+ + +
+
+ )} + {showSeriesLineStyle && ( + + )} + + ); +}); +ChartColorInputs.displayName = 'ChartColorInputs'; + +interface SeriesLineStyleInputProps { + chartConfig: ChartConfig; + model: QueryModel; + setChartConfig: ChartConfigSetter; +} +const SeriesLineStyleInput: FC = memo(({ chartConfig, model, setChartConfig }) => { + const [distinctSeriesOptions, setDistinctSeriesOptions] = useState<{ label: string; value: string }[]>(); + const [selectedSeries, setSelectedSeries] = useState(); + const [seriesOptionMap, setSeriesOptionMap] = useState>( + chartConfig.measuresOptions?.series ?? {} + ); + + useEffect(() => { + const fetchDistinctSeries = async () => { + setDistinctSeriesOptions(undefined); + setSelectedSeries(undefined); + + if (chartConfig.measures?.series) { + try { + const response = await selectDistinctRows({ + schemaName: model.schemaQuery.schemaName, + queryName: model.schemaQuery.queryName, + viewName: model.schemaQuery.viewName, + column: chartConfig.measures?.series.fieldKey, + }); + + // map response.values to SelectOption format + const options = response.values.map(value => ({ + label: value === null ? 'n/a' : value.toString(), + value: value === null ? 'n/a' : value.toString(), + })); + setDistinctSeriesOptions(options); + } catch (error) { + console.error(error); + } + } + }; + + fetchDistinctSeries(); + }, [model.schemaQuery, chartConfig.measures?.series]); + + // call setChartConfig whenever seriesOptionMap changes + useEffect(() => { + setChartConfig(current => ({ + ...current, + measuresOptions: { + ...current.measuresOptions, + series: seriesOptionMap, + }, + })); + }, [seriesOptionMap, setChartConfig]); + + const onSeriesSelectChange = useCallback((_: never, value: string) => { + setSelectedSeries(value); + }, []); + + const onSeriesOptionChange = useCallback((series: string, optionName: string, value: any) => { + setSeriesOptionMap(prev => { + const seriesOptions = prev[series] || {}; + const updatedSeriesOptions = { ...seriesOptions }; + // if the value is undefined, remove the option from the seriesOptionMap + if (value === undefined) { + delete updatedSeriesOptions[optionName]; + } else { + updatedSeriesOptions[optionName] = value; + } + // if the updatedSeriesOptions is empty, remove the series from the seriesOptionMap + if (Utils.isEmptyObj(updatedSeriesOptions)) { + const { [series]: _, ...rest } = prev; + return rest; + } + return { + ...prev, + [series]: updatedSeriesOptions, + }; + }); + }, []); + + const onSeriesColorChange = useCallback( + (_: never, value: string) => { + onSeriesOptionChange(selectedSeries, 'color', value); + }, + [onSeriesOptionChange, selectedSeries] + ); + + const onSeriesShapeChange = useCallback( + (_: never, value: string) => { + onSeriesOptionChange(selectedSeries, 'shape', value); + }, + [onSeriesOptionChange, selectedSeries] + ); + + const onSeriesShapeRemove = useCallback(() => { + onSeriesOptionChange(selectedSeries, 'shape', undefined); + }, [onSeriesOptionChange, selectedSeries]); + + const seriesValueRenderer = useCallback(option => seriesOptionRenderer(option, seriesOptionMap), [seriesOptionMap]); + + if (!distinctSeriesOptions) { + return null; + } + + return ( + <> +
+
+ + +
+
+ {selectedSeries && ( +
+
+
Color
+ +
+
+
Shape
+ + {seriesOptionMap[selectedSeries]?.shape && ( + + )} +
+
+ )} + + ); +}); +SeriesLineStyleInput.displayName = 'SeriesLineStyleInput'; + +const LetterIcon: FC<{ letter: string }> = ({ letter }) => { + return
{letter}
; +}; +LetterIcon.displayName = 'LetterIcon'; diff --git a/packages/components/src/internal/components/chart/ChartFieldOption.tsx b/packages/components/src/internal/components/chart/ChartFieldOption.tsx index 69051d62f2..20f3bc8f83 100644 --- a/packages/components/src/internal/components/chart/ChartFieldOption.tsx +++ b/packages/components/src/internal/components/chart/ChartFieldOption.tsx @@ -54,6 +54,7 @@ export const ChartFieldOption: FC = memo(props => { setScale(DEFAULT_SCALE_VALUES); setChartConfig(current => { let geomOptions = current.geomOptions; + const measuresOptions = current.measuresOptions; const measures = { ...current.measures }; const scales = { ...current.scales }; const labels = { ...current.labels }; @@ -78,7 +79,12 @@ export const ChartFieldOption: FC = memo(props => { geomOptions = { ...geomOptions, trendlineType }; } - const updatedConfig = { ...current, geomOptions, measures, labels }; + // reset the measureOptions.series when field changes + if (name === 'series' && measuresOptions.series !== undefined) { + delete measuresOptions.series; + } + + const updatedConfig = { ...current, geomOptions, measures, measuresOptions, labels }; if (selectedType.name === 'bar_chart') { updatedConfig.labels.y = getBarChartAxisLabel(updatedConfig, current); diff --git a/packages/components/src/internal/components/chart/ChartSettingsPanel.tsx b/packages/components/src/internal/components/chart/ChartSettingsPanel.tsx index 28d7694721..115c71802c 100644 --- a/packages/components/src/internal/components/chart/ChartSettingsPanel.tsx +++ b/packages/components/src/internal/components/chart/ChartSettingsPanel.tsx @@ -1,4 +1,5 @@ import React, { ChangeEvent, FC, memo, useCallback, useMemo, useState } from 'react'; +import classNames from 'classnames'; import { BaseChartModel, BaseChartModelSetter, @@ -16,9 +17,10 @@ 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'; +import { ChartColorInputs } from './ChartColorInputs'; +import { Alert } from '../base/Alert'; function changedIntValue(strVal: string, currentVal: number): [value: number, changed: boolean] { strVal = strVal.trim(); @@ -257,6 +259,7 @@ interface Props { chartConfig: ChartConfig; chartModel: BaseChartModel; chartType: ChartTypeInfo; + error: string; isNew: boolean; model: QueryModel; setChartConfig: ChartConfigSetter; @@ -264,8 +267,18 @@ interface Props { } export const ChartSettingsPanel: FC = memo(props => { - const { allowInherit, canShare, chartConfig, chartType, chartModel, isNew, model, setChartConfig, setChartModel } = - props; + const { + allowInherit, + canShare, + chartConfig, + chartType, + chartModel, + error, + isNew, + model, + setChartConfig, + setChartModel, + } = props; const showTrendline = hasTrendline(chartType); const fields = chartType.fields.filter(f => f.name !== 'trendline'); @@ -315,6 +328,7 @@ export const ChartSettingsPanel: FC = memo(props => { return (
+ {error && {error}}

Settings

@@ -378,6 +392,7 @@ export const ChartSettingsPanel: FC = memo(props => { value={chartConfig?.labels?.subtitle} /> +
); }); diff --git a/packages/components/src/internal/components/chart/constants.ts b/packages/components/src/internal/components/chart/constants.ts index a92d60e7cb..eeb55ea396 100644 --- a/packages/components/src/internal/components/chart/constants.ts +++ b/packages/components/src/internal/components/chart/constants.ts @@ -19,3 +19,25 @@ export const AGGREGATE_METHODS = [ { label: 'Mean', value: 'MEAN' }, { label: 'Median', value: 'MEDIAN' }, ]; + +// see vis/src/scale.js for options +export const COLOR_PALETTE_OPTIONS = [ + { label: 'Light (Default)', value: 'ColorDiscrete' }, + { label: 'Dark', value: 'DarkColorDiscrete' }, +]; + +// see vis/src/scale.js for options +export const SHAPE_OPTIONS = [ + { label: 'Circle', value: 'circle' }, + { label: 'Diamond', value: 'diamond' }, + { label: 'Square', value: 'square' }, + { label: 'Triangle', value: 'triangle' }, + { label: 'Cross', value: 'x' }, +]; + +export const COLOR_OPTIONS_PER_TYPE = { + boxFillColor: ['bar_chart', 'box_plot'], + colorPaletteScale: ['bar_chart', 'box_plot', 'line_plot', 'scatter_plot', 'pie_chart'], + lineColor: ['bar_chart', 'box_plot'], + pointFillColor: ['box_plot', 'line_plot', 'scatter_plot'], +}; diff --git a/packages/components/src/internal/components/chart/models.ts b/packages/components/src/internal/components/chart/models.ts index a06fdbb90b..4f898787f4 100644 --- a/packages/components/src/internal/components/chart/models.ts +++ b/packages/components/src/internal/components/chart/models.ts @@ -7,12 +7,19 @@ export interface ChartLabels { y?: string; } +export interface MeasureOption { + color?: string; + shape?: string; + // lineType?: string; +} + export interface ChartConfig { geomOptions: Record; gridLinesVisible: string; height?: number; labels: ChartLabels; measures: Record>; // TODO: we can probably do better than any + measuresOptions?: Record>; // map from measures to the options for the distinct values of that measure pointType: string; renderType: string; scales: Record; diff --git a/packages/components/src/internal/components/chart/utils.ts b/packages/components/src/internal/components/chart/utils.ts index 53a6f42418..3587dcf5ee 100644 --- a/packages/components/src/internal/components/chart/utils.ts +++ b/packages/components/src/internal/components/chart/utils.ts @@ -145,6 +145,7 @@ export function deepCopyChartConfig(chartConfig: ChartConfig, chartType = 'bar_c height: 500, labels: {}, measures: {}, + measuresOptions: {}, pointType: 'all', renderType: chartType, scales: {}, @@ -156,6 +157,7 @@ export function deepCopyChartConfig(chartConfig: ChartConfig, chartType = 'bar_c geomOptions: { ...chartConfig.geomOptions }, labels: { ...chartConfig.labels }, measures: { ...chartConfig.measures }, + measuresOptions: { ...chartConfig.measuresOptions }, scales: { ...chartConfig.scales }, }; } diff --git a/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap b/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap index f44c73838a..c0fbacf3d4 100644 --- a/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap +++ b/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap @@ -224,14 +224,19 @@ exports[`SampleTypePropertiesPanel appPropertiesOnly 1`] = ` >
{ test('default props', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); test('without value', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); test('with button text', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); + test('with placeholder', () => { + render(); + expect(document.querySelector('.color-picker__button')?.textContent).toBe('Auto'); + expect(document.querySelectorAll('.color-picker__placeholder')).toHaveLength(1); + }); + test('showPicker', async () => { - const { container } = render(); + const { container } = render(); await userEvent.click(document.querySelector('.color-picker__button')); expect(container).toMatchSnapshot(); }); test('allowRemove', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); test('disabled', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); }); diff --git a/packages/components/src/internal/components/forms/input/ColorPickerInput.tsx b/packages/components/src/internal/components/forms/input/ColorPickerInput.tsx index cbd7d79a5f..17f246ad78 100644 --- a/packages/components/src/internal/components/forms/input/ColorPickerInput.tsx +++ b/packages/components/src/internal/components/forms/input/ColorPickerInput.tsx @@ -1,22 +1,89 @@ -import React, { FC, memo, useCallback, useState } from 'react'; +import React, { FC, memo, useCallback, useRef, useState } from 'react'; import { ColorResult, CompactPicker } from 'react-color'; import classNames from 'classnames'; import { ColorIcon } from '../../base/ColorIcon'; import { RemoveEntityButton } from '../../buttons/RemoveEntityButton'; +const DEFAULT_COLORS = [ + '#FFFFFF', + '#F07575', + '#F4AE71', + '#F0E075', + '#E3F075', + '#A8E477', + '#7FF0C3', + '#81C6E9', + '#AC8EEB', + '#D983EC', + '#EE96BC', + '#D6C1A4', + '#BFBFBF', + '#EA4545', + '#EC7812', + '#E3C919', + '#BCCF17', + '#6BC026', + '#1ADB8E', + '#269BD6', + '#7C4DE0', + '#B921DB', + '#E1478A', + '#BB9868', + '#808080', + '#D11717', + '#D26B10', + '#DCB118', + '#A9B314', + '#589E1F', + '#16BB79', + '#2084B6', + '#5B25D0', + '#961BB1', + '#B81E61', + '#8D6C3F', + '#404040', + '#A11212', + '#AF590D', + '#AA8813', + '#868E10', + '#4A841A', + '#13A067', + '#1B6E98', + '#481DA5', + '#7C1692', + '#95184E', + '#745934', + '#000000', + '#7C0E0E', + '#8A460A', + '#8A6E0F', + '#6C730D', + '#396614', + '#108456', + '#175E82', + '#351579', + '#651278', + '#72133C', + '#634C2C', +]; + interface Props { allowRemove?: boolean; colors?: string[]; disabled?: boolean; name?: string; onChange: (name: string, value: string) => void; + placeholder?: string; text?: string; value: string; } export const ColorPickerInput: FC = memo(props => { - const { allowRemove, colors, disabled, name, onChange, text, value } = props; + const { allowRemove, colors = DEFAULT_COLORS, disabled, name, onChange, text, value, placeholder = 'None' } = props; + const buttonRef = useRef(null); + const [fixedTop, setFixedTop] = useState(); + const [fixedLeft, setFixedLeft] = useState(); const [showPicker, setShowPicker] = useState(false); const onChangeComplete = useCallback( (color?: ColorResult) => { @@ -28,32 +95,54 @@ export const ColorPickerInput: FC = memo(props => { onChangeComplete(); }, [onChangeComplete]); const togglePicker = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setFixedTop(rect.bottom + 5); + setFixedLeft(rect.left); + } + setShowPicker(s => !s); }, []); + // if value doesn't start with '#', add it + const value_ = value && !value.startsWith('#') ? `#${value}` : value; + + const compactPicker = ; + return ( -
+
- {text !== undefined && } + {text !== undefined && } - {allowRemove && value && !disabled && ( - + {allowRemove && value_ && !disabled && ( + )}
{showPicker && ( <>
- + {fixedTop && fixedLeft ? ( +
{compactPicker}
+ ) : ( + compactPicker + )} )}
diff --git a/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap b/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap index 8834b7215d..4defd4bba3 100644 --- a/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap +++ b/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap @@ -14,7 +14,7 @@ exports[`ColorPickerInput allowRemove 1`] = ` style="background-color: rgb(0, 0, 0);" />
+
+ + +
+
+ +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
Select color... - None + + None +
i { display: inline-block; margin-left: 0.5em; + color: $border-color; + font-weight: bold; } .color-picker__chip { display: inline-block; @@ -273,6 +278,9 @@ textarea.form-control { display: inline-block; padding-left: 4px; } +.color-picker__placeholder { + color: #808080; // match select input placeholder text color +} .color-icon__circle-small { display: inline-block; @@ -282,6 +290,14 @@ textarea.form-control { width: 12px; } +.color-icon__chip-small { + display: inline-block; + border-radius: 2px; + border: solid 1px $gray; + height: 12px; + width: 12px; +} + .color-icon__circle { display: inline-block; border-radius: 50%;