[Lens] Allow non-numeric metrics for metric vis (#169258)

## Summary

Fixes #137756 
Allows to set non numeric metrics for metric visualization for primary
and secondary metric.

<img width="369" alt="Screenshot 2023-10-19 at 13 45 47"
src="d6f00cd1-3be8-4c07-abe0-5bd15e2e9813">
<img width="350" alt="Screenshot 2023-10-19 at 13 46 37"
src="01978149-ca40-44c2-ba73-9698335e819a">


Doesn't include coloring by terms.
When primary metric is non-numeric:
1. when maximum value is empty, we hide maximum value group
2. when maximum value has a value we set an error message on dimension
3. we don’t allow to use a palette for coloring
4. we don’t allow to set a trendline

<img width="681" alt="Screenshot 2023-10-19 at 13 30 16"
src="7464f9cc-c09c-42cd-bd44-f55ffc1dfad9">
<img width="456" alt="Screenshot 2023-10-19 at 13 30 22"
src="e5726ab9-a748-4417-9b66-5bf4d708d833">



### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Marta Bondyra 2023-10-30 20:52:15 +01:00 committed by GitHub
parent beae7b1448
commit c7e785383a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 667 additions and 477 deletions

View file

@ -20,12 +20,14 @@ import {
MetricWTrend,
MetricWNumber,
SettingsProps,
MetricWText,
} from '@elastic/charts';
import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils';
import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common';
import type {
Datatable,
DatatableColumn,
DatatableRow,
IInterpreterRenderHandlers,
RenderMode,
} from '@kbn/expressions-plugin/common';
@ -65,6 +67,28 @@ function enhanceFieldFormat(serializedFieldFormat: SerializedFieldFormat | undef
return serializedFieldFormat ?? { id: formatId };
}
const renderSecondaryMetric = (
columns: DatatableColumn[],
row: DatatableRow,
config: Pick<VisParams, 'metric' | 'dimensions'>
) => {
let secondaryMetricColumn: DatatableColumn | undefined;
let formatSecondaryMetric: ReturnType<typeof getMetricFormatter>;
if (config.dimensions.secondaryMetric) {
secondaryMetricColumn = getColumnByAccessor(config.dimensions.secondaryMetric, columns);
formatSecondaryMetric = getMetricFormatter(config.dimensions.secondaryMetric, columns);
}
const secondaryPrefix = config.metric.secondaryPrefix ?? secondaryMetricColumn?.name;
return (
<span>
{secondaryPrefix}
{secondaryMetricColumn
? `${secondaryPrefix ? ' ' : ''}${formatSecondaryMetric!(row[secondaryMetricColumn.id])}`
: undefined}
</span>
);
};
const getMetricFormatter = (
accessor: ExpressionValueVisDimension | string,
columns: Datatable['columns']
@ -149,13 +173,6 @@ export const MetricVis = ({
const primaryMetricColumn = getColumnByAccessor(config.dimensions.metric, data.columns)!;
const formatPrimaryMetric = getMetricFormatter(config.dimensions.metric, data.columns);
let secondaryMetricColumn: DatatableColumn | undefined;
let formatSecondaryMetric: ReturnType<typeof getMetricFormatter>;
if (config.dimensions.secondaryMetric) {
secondaryMetricColumn = getColumnByAccessor(config.dimensions.secondaryMetric, data.columns);
formatSecondaryMetric = getMetricFormatter(config.dimensions.secondaryMetric, data.columns);
}
let breakdownByColumn: DatatableColumn | undefined;
let formatBreakdownValue: FieldFormatConvertFunction;
if (config.dimensions.breakdownBy) {
@ -172,28 +189,32 @@ export const MetricVis = ({
const metricConfigs: MetricSpec['data'][number] = (
breakdownByColumn ? data.rows : data.rows.slice(0, 1)
).map((row, rowIdx) => {
const value: number = row[primaryMetricColumn.id] !== null ? row[primaryMetricColumn.id] : NaN;
const value: number | string =
row[primaryMetricColumn.id] !== null ? row[primaryMetricColumn.id] : NaN;
const title = breakdownByColumn
? formatBreakdownValue(row[breakdownByColumn.id])
: primaryMetricColumn.name;
const subtitle = breakdownByColumn ? primaryMetricColumn.name : config.metric.subtitle;
const secondaryPrefix = config.metric.secondaryPrefix ?? secondaryMetricColumn?.name;
if (typeof value !== 'number') {
const nonNumericMetric: MetricWText = {
value: formatPrimaryMetric(value),
title: String(title),
subtitle,
icon: config.metric?.icon ? getIcon(config.metric?.icon) : undefined,
extra: renderSecondaryMetric(data.columns, row, config),
color: config.metric.color ?? defaultColor,
};
return nonNumericMetric;
}
const baseMetric: MetricWNumber = {
value,
valueFormatter: formatPrimaryMetric,
title: String(title),
subtitle,
icon: config.metric?.icon ? getIcon(config.metric?.icon) : undefined,
extra: (
<span>
{secondaryPrefix}
{secondaryMetricColumn
? `${secondaryPrefix ? ' ' : ''}${formatSecondaryMetric!(
row[secondaryMetricColumn.id]
)}`
: undefined}
</span>
),
extra: renderSecondaryMetric(data.columns, row, config),
color:
config.metric.palette && value != null
? getColor(

View file

@ -224,6 +224,33 @@ describe('LayerPanel', () => {
const group = instance.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
it('does not render a hidden group', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
{
groupLabel: 'B',
groupId: 'b',
accessors: [],
filterOperations: () => true,
isHidden: true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
],
});
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
const group = instance.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
it('should render the required warning when only one group is configured', async () => {
mockVisualization.getConfiguration.mockReturnValue({

View file

@ -427,239 +427,244 @@ export function LayerPanel(
)}
</header>
{dimensionGroups.map((group, groupIndex) => {
let errorText: string = '';
{dimensionGroups
.filter((group) => !group.isHidden)
.map((group, groupIndex) => {
let errorText: string = '';
if (!isEmptyLayer) {
if (
group.requiredMinDimensionCount &&
group.requiredMinDimensionCount > group.accessors.length
) {
if (group.requiredMinDimensionCount > 1) {
if (!isEmptyLayer) {
if (
group.requiredMinDimensionCount &&
group.requiredMinDimensionCount > group.accessors.length
) {
if (group.requiredMinDimensionCount > 1) {
errorText = i18n.translate(
'xpack.lens.editorFrame.requiresTwoOrMoreFieldsWarningLabel',
{
defaultMessage: 'Requires {requiredMinDimensionCount} fields',
values: {
requiredMinDimensionCount: group.requiredMinDimensionCount,
},
}
);
} else {
errorText = i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', {
defaultMessage: 'Requires field',
});
}
} else if (group.dimensionsTooMany && group.dimensionsTooMany > 0) {
errorText = i18n.translate(
'xpack.lens.editorFrame.requiresTwoOrMoreFieldsWarningLabel',
'xpack.lens.editorFrame.tooManyDimensionsSingularWarningLabel',
{
defaultMessage: 'Requires {requiredMinDimensionCount} fields',
defaultMessage:
'Please remove {dimensionsTooMany, plural, one {a dimension} other {{dimensionsTooMany} dimensions}}',
values: {
requiredMinDimensionCount: group.requiredMinDimensionCount,
dimensionsTooMany: group.dimensionsTooMany,
},
}
);
} else {
errorText = i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', {
defaultMessage: 'Requires field',
});
}
} else if (group.dimensionsTooMany && group.dimensionsTooMany > 0) {
errorText = i18n.translate(
'xpack.lens.editorFrame.tooManyDimensionsSingularWarningLabel',
{
defaultMessage:
'Please remove {dimensionsTooMany, plural, one {a dimension} other {{dimensionsTooMany} dimensions}}',
values: {
dimensionsTooMany: group.dimensionsTooMany,
},
}
);
}
}
const isOptional = !group.requiredMinDimensionCount && !group.suggestedValue;
return (
<EuiFormRow
className="lnsLayerPanel__row"
fullWidth
label={
<>
{group.groupLabel}
{group.groupTooltip && (
<>
<EuiIconTip
color="subdued"
content={group.groupTooltip}
iconProps={{
className: 'eui-alignTop',
}}
position="top"
size="s"
type="questionInCircle"
/>
</>
)}
</>
}
labelAppend={
isOptional ? (
<EuiText color="subdued" size="xs" data-test-subj="lnsGroup_optional">
{i18n.translate('xpack.lens.editorFrame.optionalDimensionLabel', {
defaultMessage: 'Optional',
})}
</EuiText>
) : null
}
labelType="legend"
key={group.groupId}
isInvalid={Boolean(errorText)}
error={errorText}
>
<>
{group.accessors.length ? (
<ReorderProvider className={'lnsLayerPanel__group'} dataTestSubj="lnsDragDrop">
{group.accessors.map((accessorConfig, accessorIndex) => {
const { columnId } = accessorConfig;
const messages =
props?.getUserMessages?.('dimensionButton', {
dimensionId: columnId,
}) ?? [];
return (
<DraggableDimensionButton
activeVisualization={activeVisualization}
registerNewButtonRef={registerNewButtonRef}
order={[2, layerIndex, groupIndex, accessorIndex]}
target={{
id: columnId,
layerId,
columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
prioritizedOperation: group.prioritizedOperation,
isMetricDimension: group?.isMetricDimension,
indexPatternId: layerDatasource
? layerDatasource.getUsedDataView(layerDatasourceState, layerId)
: activeVisualization.getUsedDataView?.(
visualizationState,
layerId
),
humanData: {
label: columnLabelMap?.[columnId] ?? '',
groupLabel: group.groupLabel,
position: accessorIndex + 1,
layerNumber: layerIndex + 1,
},
const isOptional = !group.requiredMinDimensionCount && !group.suggestedValue;
return (
<EuiFormRow
className="lnsLayerPanel__row"
fullWidth
label={
<>
{group.groupLabel}
{group.groupTooltip && (
<>
<EuiIconTip
color="subdued"
content={group.groupTooltip}
iconProps={{
className: 'eui-alignTop',
}}
group={group}
key={columnId}
state={layerDatasourceState}
layerDatasource={layerDatasource}
datasourceLayers={framePublicAPI.datasourceLayers}
onDragStart={() => setHideTooltip(true)}
onDragEnd={() => setHideTooltip(false)}
onDrop={onDrop}
indexPatterns={dataViews.indexPatterns}
>
<DimensionButton
accessorConfig={accessorConfig}
label={columnLabelMap?.[accessorConfig.columnId] ?? ''}
groupLabel={group.groupLabel}
onClick={(id: string) => {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: id,
});
}}
onRemoveClick={(id: string) => {
props.onRemoveDimension({ columnId: id, layerId });
removeButtonRef(id);
}}
message={{
severity: messages[0]?.severity,
content: messages[0]?.shortMessage || messages[0]?.longMessage,
position="top"
size="s"
type="questionInCircle"
/>
</>
)}
</>
}
labelAppend={
isOptional ? (
<EuiText color="subdued" size="xs" data-test-subj="lnsGroup_optional">
{i18n.translate('xpack.lens.editorFrame.optionalDimensionLabel', {
defaultMessage: 'Optional',
})}
</EuiText>
) : null
}
labelType="legend"
key={group.groupId}
isInvalid={Boolean(errorText)}
error={errorText}
>
<>
{group.accessors.length ? (
<ReorderProvider
className={'lnsLayerPanel__group'}
dataTestSubj="lnsDragDrop"
>
{group.accessors.map((accessorConfig, accessorIndex) => {
const { columnId } = accessorConfig;
const messages =
props?.getUserMessages?.('dimensionButton', {
dimensionId: columnId,
}) ?? [];
return (
<DraggableDimensionButton
activeVisualization={activeVisualization}
registerNewButtonRef={registerNewButtonRef}
order={[2, layerIndex, groupIndex, accessorIndex]}
target={{
id: columnId,
layerId,
columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
prioritizedOperation: group.prioritizedOperation,
isMetricDimension: group?.isMetricDimension,
indexPatternId: layerDatasource
? layerDatasource.getUsedDataView(layerDatasourceState, layerId)
: activeVisualization.getUsedDataView?.(
visualizationState,
layerId
),
humanData: {
label: columnLabelMap?.[columnId] ?? '',
groupLabel: group.groupLabel,
position: accessorIndex + 1,
layerNumber: layerIndex + 1,
},
}}
group={group}
key={columnId}
state={layerDatasourceState}
layerDatasource={layerDatasource}
datasourceLayers={framePublicAPI.datasourceLayers}
onDragStart={() => setHideTooltip(true)}
onDragEnd={() => setHideTooltip(false)}
onDrop={onDrop}
indexPatterns={dataViews.indexPatterns}
>
{layerDatasource ? (
<>
{layerDatasource.DimensionTriggerComponent({
...layerDatasourceConfigProps,
columnId: accessorConfig.columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
indexPatterns: dataViews.indexPatterns,
})}
</>
) : (
<>
{activeVisualization?.DimensionTriggerComponent?.({
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
})}
</>
)}
</DimensionButton>
</DraggableDimensionButton>
);
})}
</ReorderProvider>
) : null}
<DimensionButton
accessorConfig={accessorConfig}
label={columnLabelMap?.[accessorConfig.columnId] ?? ''}
groupLabel={group.groupLabel}
onClick={(id: string) => {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: id,
});
}}
onRemoveClick={(id: string) => {
props.onRemoveDimension({ columnId: id, layerId });
removeButtonRef(id);
}}
message={{
severity: messages[0]?.severity,
content: messages[0]?.shortMessage || messages[0]?.longMessage,
}}
>
{layerDatasource ? (
<>
{layerDatasource.DimensionTriggerComponent({
...layerDatasourceConfigProps,
columnId: accessorConfig.columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
indexPatterns: dataViews.indexPatterns,
})}
</>
) : (
<>
{activeVisualization?.DimensionTriggerComponent?.({
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
})}
</>
)}
</DimensionButton>
</DraggableDimensionButton>
);
})}
</ReorderProvider>
) : null}
{group.fakeFinalAccessor && (
<div
css={css`
display: flex;
align-items: center;
border-radius: ${euiThemeVars.euiBorderRadius};
min-height: ${euiThemeVars.euiSizeXL};
{group.fakeFinalAccessor && (
<div
css={css`
display: flex;
align-items: center;
border-radius: ${euiThemeVars.euiBorderRadius};
min-height: ${euiThemeVars.euiSizeXL};
cursor: default !important;
background-color: ${euiThemeVars.euiColorLightShade} !important;
border-color: transparent !important;
box-shadow: none !important;
padding: 0 ${euiThemeVars.euiSizeS};
`}
>
<DimensionTrigger
label={group.fakeFinalAccessor.label}
id="lns-fakeDimension"
data-test-subj="lns-fakeDimension"
cursor: default !important;
background-color: ${euiThemeVars.euiColorLightShade} !important;
border-color: transparent !important;
box-shadow: none !important;
padding: 0 ${euiThemeVars.euiSizeS};
`}
>
<DimensionTrigger
label={group.fakeFinalAccessor.label}
id="lns-fakeDimension"
data-test-subj="lns-fakeDimension"
/>
</div>
)}
{group.supportsMoreColumns ? (
<EmptyDimensionButton
activeVisualization={activeVisualization}
order={[2, layerIndex, groupIndex, group.accessors.length]}
group={group}
target={{
layerId,
groupId: group.groupId,
filterOperations: group.filterOperations,
prioritizedOperation: group.prioritizedOperation,
isNewColumn: true,
isMetricDimension: group?.isMetricDimension,
indexPatternId: layerDatasource
? layerDatasource.getUsedDataView(layerDatasourceState, layerId)
: activeVisualization.getUsedDataView?.(visualizationState, layerId),
humanData: {
groupLabel: group.groupLabel,
layerNumber: layerIndex + 1,
position: group.accessors.length + 1,
label: i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', {
defaultMessage: 'Empty dimension',
}),
},
}}
layerDatasource={layerDatasource}
state={layerDatasourceState}
datasourceLayers={framePublicAPI.datasourceLayers}
onClick={(id) => {
props.onEmptyDimensionAdd(id, group);
setActiveDimension({
activeGroup: group,
activeId: id,
isNew: !group.supportStaticValue && Boolean(layerDatasource),
});
}}
onDrop={onDrop}
indexPatterns={dataViews.indexPatterns}
/>
</div>
)}
{group.supportsMoreColumns ? (
<EmptyDimensionButton
activeVisualization={activeVisualization}
order={[2, layerIndex, groupIndex, group.accessors.length]}
group={group}
target={{
layerId,
groupId: group.groupId,
filterOperations: group.filterOperations,
prioritizedOperation: group.prioritizedOperation,
isNewColumn: true,
isMetricDimension: group?.isMetricDimension,
indexPatternId: layerDatasource
? layerDatasource.getUsedDataView(layerDatasourceState, layerId)
: activeVisualization.getUsedDataView?.(visualizationState, layerId),
humanData: {
groupLabel: group.groupLabel,
layerNumber: layerIndex + 1,
position: group.accessors.length + 1,
label: i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', {
defaultMessage: 'Empty dimension',
}),
},
}}
layerDatasource={layerDatasource}
state={layerDatasourceState}
datasourceLayers={framePublicAPI.datasourceLayers}
onClick={(id) => {
props.onEmptyDimensionAdd(id, group);
setActiveDimension({
activeGroup: group,
activeId: id,
isNew: !group.supportStaticValue && Boolean(layerDatasource),
});
}}
onDrop={onDrop}
indexPatterns={dataViews.indexPatterns}
/>
) : null}
</>
</EuiFormRow>
);
})}
) : null}
</>
</EuiFormRow>
);
})}
</EuiPanel>
</section>
{(layerDatasource?.LayerSettingsComponent || activeVisualization?.LayerSettingsComponent) && (

View file

@ -6,6 +6,7 @@
*/
import { DragContextState, DragContextValue } from '@kbn/dom-drag-drop';
import { DatatableColumnType } from '@kbn/expressions-plugin/common';
import { createMockDataViewsState } from '../data_views_service/mocks';
import { FramePublicAPI, FrameDatasourceAPI } from '../types';
export { mockDataPlugin } from './data_plugin_mock';
@ -83,3 +84,26 @@ export function createMockedDragDropContext(
setState ? setState : jest.fn(),
];
}
export function generateActiveData(
json: Array<{
id: string;
rows: Array<Record<string, number | null>>;
}>
) {
return json.reduce((memo, { id, rows }) => {
const columns = Object.keys(rows[0]).map((columnId) => ({
id: columnId,
name: columnId,
meta: {
type: typeof rows[0][columnId]! as DatatableColumnType,
},
}));
memo[id] = {
type: 'datatable' as const,
columns,
rows,
};
return memo;
}, {} as NonNullable<FramePublicAPI['activeData']>);
}

View file

@ -28,6 +28,7 @@ export function CollapseSetting({
return (
<>
<EuiFormRow
id="lns-indexPattern-collapse-by"
label={
<EuiToolTip
delay="long"

View file

@ -821,6 +821,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
paramEditorCustomProps?: ParamEditorCustomProps;
enableFormatSelector?: boolean;
labels?: { buttonAriaLabel: string; buttonLabel: string };
isHidden?: boolean;
};
export interface VisualizationDimensionChangeProps<T> {

View file

@ -55,6 +55,7 @@ Object {
"groupId": "max",
"groupLabel": "Maximum value",
"groupTooltip": "If the maximum value is specified, the minimum value is fixed at zero.",
"isHidden": false,
"paramEditorCustomProps": Object {
"headingLabel": "Value",
},
@ -100,6 +101,10 @@ Array [
"dataType": "number",
"isBucketed": false,
},
Object {
"dataType": "string",
"isBucketed": false,
},
]
`;
@ -109,6 +114,10 @@ Array [
"dataType": "number",
"isBucketed": false,
},
Object {
"dataType": "string",
"isBucketed": false,
},
]
`;
@ -118,6 +127,10 @@ Array [
"dataType": "number",
"isBucketed": false,
},
Object {
"dataType": "string",
"isBucketed": false,
},
]
`;

View file

@ -24,7 +24,7 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import { LayoutDirection } from '@elastic/charts';
import { act } from 'react-dom/test-utils';
import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker';
import { createMockFramePublicAPI } from '../../mocks';
import { createMockFramePublicAPI, generateActiveData } from '../../mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { euiLightVars } from '@kbn/ui-theme';
import { DebouncedInput } from '@kbn/visualization-ui-components';
@ -47,6 +47,18 @@ const SELECTORS = {
BREAKDOWN_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_breakdown"]',
};
const nonNumericMetricFrame = createMockFramePublicAPI({
activeData: generateActiveData([
{
id: 'first',
rows: Array(3).fill({
'metric-col-id': 'nonNumericData',
'max-col-id': 1000,
}),
},
]),
});
// see https://github.com/facebook/jest/issues/4402#issuecomment-534516219
const expectCalledBefore = (mock1: jest.Mock, mock2: jest.Mock) =>
expect(mock1.mock.invocationCallOrder[0]).toBeLessThan(mock2.mock.invocationCallOrder[0]);
@ -102,7 +114,14 @@ describe('dimension editor', () => {
} as unknown as DatasourcePublicAPI,
removeLayer: jest.fn(),
addLayer: jest.fn(),
frame: createMockFramePublicAPI(),
frame: createMockFramePublicAPI({
activeData: generateActiveData([
{
id: 'first',
rows: Array(3).fill({ 'metric-col-id': 100, 'secondary-metric-col-id': 1 }),
},
]),
}),
setState: jest.fn(),
panelRef: {} as React.MutableRefObject<HTMLDivElement | null>,
paletteService: chartPluginMock.createPaletteRegistry(),
@ -112,27 +131,9 @@ describe('dimension editor', () => {
afterEach(() => jest.clearAllMocks());
describe('primary metric dimension', () => {
const accessor = 'primary-metric-col-id';
const accessor = 'metric-col-id';
const metricAccessorState = { ...fullState, metricAccessor: accessor };
beforeEach(() => {
props.frame.activeData = {
first: {
type: 'datatable',
columns: [
{
id: accessor,
name: 'foo',
meta: {
type: 'number',
},
},
],
rows: [],
},
};
});
class Harness {
public _wrapper;
@ -146,6 +147,10 @@ describe('dimension editor', () => {
return this._wrapper.find(DimensionEditor);
}
public get colorModeSwitch() {
return this._wrapper.find('EuiButtonGroup[data-test-subj="lnsMetric_color_mode_buttons"]');
}
public get colorPicker() {
return this._wrapper.find(EuiColorPicker);
}
@ -163,11 +168,16 @@ describe('dimension editor', () => {
const mockSetState = jest.fn();
const getHarnessWithState = (state: MetricVisualizationState, datasource = props.datasource) =>
const getHarnessWithState = (
state: MetricVisualizationState,
datasource = props.datasource,
propsOverrides: Partial<VisualizationDimensionEditorProps<MetricVisualizationState>> = {}
) =>
new Harness(
mountWithIntl(
<DimensionEditor
{...props}
{...propsOverrides}
datasource={datasource}
state={state}
setState={mockSetState}
@ -191,6 +201,15 @@ describe('dimension editor', () => {
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy();
});
it('Color mode switch is not shown when the primary metric is non-numeric', () => {
expect(getHarnessWithState(fullState, undefined).colorModeSwitch.exists()).toBeTruthy();
expect(
getHarnessWithState(fullState, undefined, {
frame: nonNumericMetricFrame,
}).colorModeSwitch.exists()
).toBeFalsy();
});
describe('static color controls', () => {
it('is hidden when dynamic coloring is enabled', () => {
const harnessWithPalette = getHarnessWithState({ ...metricAccessorState, palette });
@ -202,6 +221,13 @@ describe('dimension editor', () => {
});
expect(harnessNoPalette.colorPicker.exists()).toBeTruthy();
});
it('is visible when metric is non-numeric even if palette is set', () => {
expect(
getHarnessWithState(fullState, undefined, {
frame: nonNumericMetricFrame,
}).colorPicker.exists()
).toBeTruthy();
});
it('fills with default value', () => {
const localHarness = getHarnessWithState({
@ -240,24 +266,6 @@ describe('dimension editor', () => {
describe('secondary metric dimension', () => {
const accessor = 'secondary-metric-col-id';
beforeEach(() => {
props.frame.activeData = {
first: {
type: 'datatable',
columns: [
{
id: accessor,
name: 'foo',
meta: {
type: 'number',
},
},
],
rows: [],
},
};
});
it('renders when the accessor matches', () => {
const component = shallow(
<DimensionEditor
@ -331,7 +339,7 @@ describe('dimension editor', () => {
const setState = jest.fn();
const localState = {
...fullState,
secondaryPrefix: 'foo',
secondaryPrefix: 'secondary-metric-col-id2',
secondaryMetricAccessor: accessor,
};
const component = mount(
@ -341,7 +349,7 @@ describe('dimension editor', () => {
const buttonGroup = component.find(EuiButtonGroup);
// make sure that if the user was to select the "custom" option, they would get the default value
expect(buttonGroup.props().options[1].value).toBe('foo');
expect(buttonGroup.props().options[1].value).toBe('secondary-metric-col-id');
const newVal = 'bar';
@ -461,7 +469,7 @@ describe('dimension editor', () => {
});
describe('additional section', () => {
const accessor = 'primary-metric-col-id';
const accessor = 'metric-col-id';
const metricAccessorState = { ...fullState, metricAccessor: accessor };
class Harness {
@ -473,6 +481,10 @@ describe('dimension editor', () => {
this._wrapper = wrapper;
}
public get wrapper() {
return this._wrapper;
}
private get rootComponent() {
return this._wrapper.find(DimensionEditorAdditionalSection);
}
@ -520,11 +532,16 @@ describe('dimension editor', () => {
const mockSetState = jest.fn();
const getHarnessWithState = (state: MetricVisualizationState, datasource = props.datasource) =>
const getHarnessWithState = (
state: MetricVisualizationState,
datasource = props.datasource,
propsOverrides: Partial<VisualizationDimensionEditorProps<MetricVisualizationState>> = {}
) =>
new Harness(
mountWithIntl(
<DimensionEditorAdditionalSection
{...props}
{...propsOverrides}
datasource={datasource}
state={state}
setState={mockSetState}
@ -623,6 +640,13 @@ describe('dimension editor', () => {
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
});
it('should not show a trendline button group when primary metric dimension is non-numeric', () => {
expect(
getHarnessWithState(fullState, undefined, {
frame: nonNumericMetricFrame,
}).wrapper.isEmptyRender()
).toBeTruthy();
});
describe('responding to buttons', () => {
it('enables trendline', () => {

View file

@ -143,78 +143,77 @@ function SecondaryMetricEditor({ accessor, idPrefix, frame, layerId, setState, s
const defaultPrefix = columnName || '';
return (
<div data-test-subj="lnsMetricDimensionEditor_secondary_metric">
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.prefixText.label', {
defaultMessage: 'Prefix',
})}
>
<>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.prefix.label', {
defaultMessage: 'Prefix',
})}
data-test-subj="lnsMetric_prefix_buttons"
options={[
{
id: `${idPrefix}auto`,
label: i18n.translate('xpack.lens.metric.prefix.auto', {
defaultMessage: 'Auto',
}),
'data-test-subj': 'lnsMetric_prefix_auto',
value: undefined,
},
{
id: `${idPrefix}custom`,
label: i18n.translate('xpack.lens.metric.prefix.custom', {
defaultMessage: 'Custom',
}),
'data-test-subj': 'lnsMetric_prefix_custom',
value: defaultPrefix,
},
{
id: `${idPrefix}none`,
label: i18n.translate('xpack.lens.metric.prefix.none', {
defaultMessage: 'None',
}),
'data-test-subj': 'lnsMetric_prefix_none',
value: '',
},
]}
idSelected={`${idPrefix}${
state.secondaryPrefix === undefined
? 'auto'
: state.secondaryPrefix === ''
? 'none'
: 'custom'
}`}
onChange={(_id, secondaryPrefix) => {
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.prefixText.label', {
defaultMessage: 'Prefix',
})}
>
<>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.prefix.label', {
defaultMessage: 'Prefix',
})}
data-test-subj="lnsMetric_prefix_buttons"
options={[
{
id: `${idPrefix}auto`,
label: i18n.translate('xpack.lens.metric.prefix.auto', {
defaultMessage: 'Auto',
}),
'data-test-subj': 'lnsMetric_prefix_auto',
value: undefined,
},
{
id: `${idPrefix}custom`,
label: i18n.translate('xpack.lens.metric.prefix.custom', {
defaultMessage: 'Custom',
}),
'data-test-subj': 'lnsMetric_prefix_custom',
value: defaultPrefix,
},
{
id: `${idPrefix}none`,
label: i18n.translate('xpack.lens.metric.prefix.none', {
defaultMessage: 'None',
}),
'data-test-subj': 'lnsMetric_prefix_none',
value: '',
},
]}
idSelected={`${idPrefix}${
state.secondaryPrefix === undefined
? 'auto'
: state.secondaryPrefix === ''
? 'none'
: 'custom'
}`}
onChange={(_id, secondaryPrefix) => {
setState({
...state,
secondaryPrefix,
});
}}
/>
<EuiSpacer size="s" />
{state.secondaryPrefix && (
<DebouncedInput
data-test-subj="lnsMetric_prefix_custom_input"
compressed
value={state.secondaryPrefix}
onChange={(newPrefix) => {
setState({
...state,
secondaryPrefix,
secondaryPrefix: newPrefix,
});
}}
/>
<EuiSpacer size="s" />
{state.secondaryPrefix && (
<DebouncedInput
compressed
value={state.secondaryPrefix}
onChange={(newPrefix) => {
setState({
...state,
secondaryPrefix: newPrefix,
});
}}
/>
)}
</>
</EuiFormRow>
</div>
)}
</>
</EuiFormRow>
);
}
@ -225,11 +224,13 @@ function PrimaryMetricEditor(props: SubProps) {
const currentData = frame.activeData?.[state.layerId];
if (accessor == null || !isNumericFieldForDatatable(currentData, accessor)) {
const isMetricNumeric = isNumericFieldForDatatable(currentData, accessor);
if (accessor == null) {
return null;
}
const hasDynamicColoring = Boolean(state?.palette);
const hasDynamicColoring = Boolean(isMetricNumeric && state?.palette);
const supportsPercentPalette = Boolean(
state.maxAccessor ||
@ -265,62 +266,65 @@ function PrimaryMetricEditor(props: SubProps) {
return (
<>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.dynamicColoring.label', {
defaultMessage: 'Color mode',
})}
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.colorMode.label', {
{isMetricNumeric && (
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.dynamicColoring.label', {
defaultMessage: 'Color mode',
})}
data-test-subj="lnsMetric_color_mode_buttons"
options={[
{
id: `${idPrefix}static`,
label: i18n.translate('xpack.lens.metric.colorMode.static', {
defaultMessage: 'Static',
}),
'data-test-subj': 'lnsMetric_color_mode_static',
},
{
id: `${idPrefix}dynamic`,
label: i18n.translate('xpack.lens.metric.colorMode.dynamic', {
defaultMessage: 'Dynamic',
}),
'data-test-subj': 'lnsMetric_color_mode_dynamic',
},
]}
idSelected={`${idPrefix}${state.palette ? 'dynamic' : 'static'}`}
onChange={(id) => {
const colorMode = id.replace(idPrefix, '') as 'static' | 'dynamic';
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.colorMode.label', {
defaultMessage: 'Color mode',
})}
data-test-subj="lnsMetric_color_mode_buttons"
options={[
{
id: `${idPrefix}static`,
label: i18n.translate('xpack.lens.metric.colorMode.static', {
defaultMessage: 'Static',
}),
'data-test-subj': 'lnsMetric_color_mode_static',
},
{
id: `${idPrefix}dynamic`,
label: i18n.translate('xpack.lens.metric.colorMode.dynamic', {
defaultMessage: 'Dynamic',
}),
'data-test-subj': 'lnsMetric_color_mode_dynamic',
},
]}
idSelected={`${idPrefix}${state.palette ? 'dynamic' : 'static'}`}
onChange={(id) => {
const colorMode = id.replace(idPrefix, '') as 'static' | 'dynamic';
const params =
colorMode === 'dynamic'
? {
palette: {
...activePalette,
params: {
...activePalette.params,
stops: displayStops,
const params =
colorMode === 'dynamic'
? {
palette: {
...activePalette,
params: {
...activePalette.params,
stops: displayStops,
},
},
},
}
: {
palette: undefined,
};
setState({
...state,
color: undefined,
...params,
});
}}
/>
</EuiFormRow>
color: undefined,
}
: {
palette: undefined,
color: undefined,
};
setState({
...state,
...params,
});
}}
/>
</EuiFormRow>
)}
{!hasDynamicColoring && <StaticColorControls {...props} />}
{hasDynamicColoring && (
<EuiFormRow
@ -404,10 +408,18 @@ function PrimaryMetricEditor(props: SubProps) {
);
}
function StaticColorControls({ state, setState }: Pick<Props, 'state' | 'setState'>) {
function StaticColorControls({
state,
setState,
frame,
}: Pick<Props, 'state' | 'setState' | 'frame'>) {
const colorLabel = i18n.translate('xpack.lens.metric.color', {
defaultMessage: 'Color',
});
const currentData = frame.activeData?.[state.layerId];
const isMetricNumeric = Boolean(
state.metricAccessor && isNumericFieldForDatatable(currentData, state.metricAccessor)
);
const setColor = useCallback(
(color: string) => {
@ -420,7 +432,7 @@ function StaticColorControls({ state, setState }: Pick<Props, 'state' | 'setStat
useDebouncedValue<string>(
{
onChange: setColor,
value: state.color || getDefaultColor(state),
value: state.color || getDefaultColor(state, isMetricNumeric),
},
{ allowFalsyValue: true }
);
@ -448,10 +460,12 @@ export function DimensionEditorAdditionalSection({
addLayer,
removeLayer,
accessor,
frame,
}: VisualizationDimensionEditorProps<MetricVisualizationState>) {
const { euiTheme } = useEuiTheme();
if (accessor !== state.metricAccessor) {
const currentData = frame.activeData?.[state.layerId];
if (accessor !== state.metricAccessor || !isNumericFieldForDatatable(currentData, accessor)) {
return null;
}
@ -566,7 +580,6 @@ export function DimensionEditorAdditionalSection({
}`}
onChange={(id) => {
const supportingVisualizationType = id.split('--')[1] as SupportingVisType;
switch (supportingVisualizationType) {
case 'trendline':
setState({

View file

@ -90,6 +90,10 @@ export const toExpression = (
const datasource = datasourceLayers[state.layerId];
const datasourceExpression = datasourceExpressionsByLayers[state.layerId];
const isMetricNumeric = Boolean(
state.metricAccessor &&
datasource?.getOperationForColumnId(state.metricAccessor)?.dataType === 'number'
);
const maxPossibleTiles =
// if there's a collapse function, no need to calculate since we're dealing with a single tile
state.breakdownByAccessor && !state.collapseFn
@ -142,15 +146,16 @@ export const toExpression = (
trendline: trendlineExpression ? [trendlineExpression] : [],
subtitle: state.subtitle ?? undefined,
progressDirection: state.progressDirection as LayoutDirection,
color: state.color || getDefaultColor(state),
color: state.color || getDefaultColor(state, isMetricNumeric),
icon: state.icon,
palette: state.palette?.params
? [
paletteService
.get(CUSTOM_PALETTE)
.toExpression(computePaletteParams(state.palette.params as CustomPaletteParams)),
]
: [],
palette:
isMetricNumeric && state.palette?.params
? [
paletteService
.get(CUSTOM_PALETTE)
.toExpression(computePaletteParams(state.palette.params as CustomPaletteParams)),
]
: [],
maxCols: state.maxCols ?? DEFAULT_MAX_COLUMNS,
minTiles: maxPossibleTiles ?? undefined,
inspectorTableId: state.layerId,

View file

@ -10,7 +10,7 @@ import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { ExpressionAstExpression, ExpressionAstFunction } from '@kbn/expressions-plugin/common';
import { euiLightVars, euiThemeVars } from '@kbn/ui-theme';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { createMockDatasource, createMockFramePublicAPI } from '../../mocks';
import { createMockDatasource, createMockFramePublicAPI, generateActiveData } from '../../mocks';
import {
DatasourceLayers,
DatasourcePublicAPI,
@ -82,7 +82,14 @@ describe('metric visualization', () => {
...trendlineProps,
};
const mockFrameApi = createMockFramePublicAPI();
const mockFrameApi = createMockFramePublicAPI({
activeData: generateActiveData([
{
id: 'first',
rows: Array(3).fill({ 'metric-col-id': 20, 'max-metric-col-id': 100 }),
},
]),
});
describe('initialization', () => {
test('returns a default state', () => {
@ -268,6 +275,7 @@ describe('metric visualization', () => {
mockDatasource.publicAPIMock.getMaxPossibleNumValues.mockReturnValue(maxPossibleNumValues);
mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
isStaticValue: false,
dataType: 'number',
} as OperationDescriptor);
datasourceLayers = {
@ -616,7 +624,7 @@ describe('metric visualization', () => {
it('always applies max function to static max dimensions', () => {
(
datasourceLayers.first as jest.Mocked<DatasourcePublicAPI>
).getOperationForColumnId.mockReturnValueOnce({
).getOperationForColumnId.mockReturnValue({
isStaticValue: true,
} as OperationDescriptor);
@ -648,6 +656,9 @@ describe('metric visualization', () => {
"type": "function",
}
`);
(
datasourceLayers.first as jest.Mocked<DatasourcePublicAPI>
).getOperationForColumnId.mockClear();
});
});
@ -1109,4 +1120,28 @@ describe('metric visualization', () => {
noPadding: true,
});
});
describe('#getUserMessages', () => {
it('returns error for non numeric primary metric if maxAccessor exists', () => {
const frame = createMockFramePublicAPI({
activeData: generateActiveData([
{
id: 'first',
rows: Array(3).fill({ 'metric-col-id': '100', 'max-metric-col-id': 100 }),
},
]),
});
expect(visualization.getUserMessages!(fullState, { frame })).toHaveLength(1);
const frameNoErrors = createMockFramePublicAPI({
activeData: generateActiveData([
{
id: 'first',
rows: Array(3).fill({ 'metric-col-id': 30, 'max-metric-col-id': 100 }),
},
]),
});
expect(visualization.getUserMessages!(fullState, { frame: frameNoErrors })).toHaveLength(0);
});
});
});

View file

@ -14,6 +14,7 @@ import { LayoutDirection } from '@elastic/charts';
import { euiLightVars, euiThemeVars } from '@kbn/ui-theme';
import { IconChartMetric } from '@kbn/chart-icons';
import { AccessorConfig } from '@kbn/visualization-ui-components';
import { isNumericFieldForDatatable } from '../../../common/expressions/datatable/utils';
import { CollapseFunction } from '../../../common/expressions';
import type { LayerType } from '../../../common/types';
import { layerTypes } from '../../../common/layer_types';
@ -25,6 +26,7 @@ import {
VisualizationConfigProps,
VisualizationDimensionGroupConfig,
Suggestion,
UserMessage,
} from '../../types';
import { GROUP_ID, LENS_METRIC_ID } from './constants';
import { DimensionEditor, DimensionEditorAdditionalSection } from './dimension_editor';
@ -40,8 +42,11 @@ export const showingBar = (
): state is MetricVisualizationState & { showBar: true; maxAccessor: string } =>
Boolean(state.showBar && state.maxAccessor);
export const getDefaultColor = (state: MetricVisualizationState) =>
showingBar(state) ? euiLightVars.euiColorPrimary : euiThemeVars.euiColorLightestShade;
export const getDefaultColor = (state: MetricVisualizationState, isMetricNumeric?: boolean) => {
return showingBar(state) && isMetricNumeric
? euiLightVars.euiColorPrimary
: euiThemeVars.euiColorLightestShade;
};
export interface MetricVisualizationState {
layerId: string;
@ -70,7 +75,13 @@ export interface MetricVisualizationState {
trendlineBreakdownByAccessor?: string;
}
export const supportedDataTypes = new Set(['number']);
export const supportedDataTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
const isSupportedMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType);
const isSupportedDynamicMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue;
export const metricLabel = i18n.translate('xpack.lens.metric.label', {
defaultMessage: 'Metric',
@ -84,29 +95,25 @@ const getMetricLayerConfiguration = (
): {
groups: VisualizationDimensionGroupConfig[];
} => {
const isSupportedMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType);
const currentData = props.frame.activeData?.[props.state.layerId];
const isSupportedDynamicMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue;
const isMetricNumeric = Boolean(
props.state.metricAccessor &&
isNumericFieldForDatatable(currentData, props.state.metricAccessor)
);
const getPrimaryAccessorDisplayConfig = (): Partial<AccessorConfig> => {
const hasDynamicColoring = Boolean(isMetricNumeric && props.state.palette);
const stops = props.state.palette?.params?.stops || [];
const hasStaticColoring = !!props.state.color;
const hasDynamicColoring = !!props.state.palette;
return hasDynamicColoring
? {
triggerIconType: 'colorBy',
palette: stops.map(({ color }) => color),
}
: hasStaticColoring
? {
triggerIconType: 'color',
color: props.state.color,
}
: {
triggerIconType: 'color',
color: getDefaultColor(props.state),
color: props.state.color ?? getDefaultColor(props.state, isMetricNumeric),
};
};
@ -180,6 +187,7 @@ const getMetricLayerConfiguration = (
},
]
: [],
isHidden: !props.state.maxAccessor && !isMetricNumeric,
supportsMoreColumns: !props.state.maxAccessor,
filterOperations: isSupportedMetric,
enableDimensionEditor: true,
@ -632,7 +640,7 @@ export const getMetricVisualization = ({
return suggestion;
},
getVisualizationInfo(state) {
getVisualizationInfo(state, frame) {
const dimensions = [];
if (state.metricAccessor) {
dimensions.push({
@ -676,6 +684,11 @@ export const getMetricVisualization = ({
const hasStaticColoring = !!state.color;
const hasDynamicColoring = !!state.palette;
const currentData = frame?.activeData?.[state.layerId];
const isMetricNumeric = Boolean(
state.metricAccessor && isNumericFieldForDatatable(currentData, state.metricAccessor)
);
return {
layers: [
{
@ -688,10 +701,34 @@ export const getMetricVisualization = ({
? stops.map(({ color }) => color)
: hasStaticColoring
? [state.color]
: [getDefaultColor(state)]
: [getDefaultColor(state, isMetricNumeric)]
).filter(nonNullable),
},
],
};
},
getUserMessages(state, { frame }) {
const currentData = frame.activeData?.[state.layerId];
const errors: UserMessage[] = [];
if (state.maxAccessor) {
const isMetricNonNumeric = Boolean(
state.metricAccessor && !isNumericFieldForDatatable(currentData, state.metricAccessor)
);
if (isMetricNonNumeric) {
errors.push({
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionButton', dimensionId: state.maxAccessor }],
shortMessage: i18n.translate('xpack.lens.lnsMetric_maxDimensionPanel.nonNumericError', {
defaultMessage: 'Primary metric must be numeric to set a maximum value.',
}),
longMessage: '',
});
}
}
return errors;
},
});

View file

@ -5,25 +5,9 @@
* 2.0.
*/
import { FramePublicAPI } from '../../types';
import { computeOverallDataDomain, getStaticValue } from './reference_line_helpers';
import { XYDataLayerConfig } from './types';
function getActiveData(json: Array<{ id: string; rows: Array<Record<string, number | null>> }>) {
return json.reduce((memo, { id, rows }) => {
const columns = Object.keys(rows[0]).map((columnId) => ({
id: columnId,
name: columnId,
meta: { type: 'number' as const },
}));
memo[id] = {
type: 'datatable' as const,
columns,
rows,
};
return memo;
}, {} as NonNullable<FramePublicAPI['activeData']>);
}
import { generateActiveData } from '../../mocks';
describe('reference_line helpers', () => {
describe('getStaticValue', () => {
@ -41,7 +25,7 @@ describe('reference_line helpers', () => {
[],
'x',
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -54,7 +38,7 @@ describe('reference_line helpers', () => {
[{ layerId: 'id-a', seriesType: 'area' } as XYDataLayerConfig], // missing xAccessor for groupId == x
'x',
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -73,7 +57,7 @@ describe('reference_line helpers', () => {
], // missing hit of accessor "d" in data
'yLeft',
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -92,7 +76,7 @@ describe('reference_line helpers', () => {
], // missing yConfig fallbacks to left axis, but the requested group is yRight
'yRight',
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -111,7 +95,7 @@ describe('reference_line helpers', () => {
], // same as above with x groupId
'x',
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -134,7 +118,7 @@ describe('reference_line helpers', () => {
],
'yRight',
{
activeData: getActiveData([
activeData: generateActiveData([
{
id: 'id-a',
rows: [{ a: -30 }, { a: 10 }],
@ -159,7 +143,7 @@ describe('reference_line helpers', () => {
],
'yLeft',
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -182,7 +166,7 @@ describe('reference_line helpers', () => {
],
'yRight',
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -192,7 +176,7 @@ describe('reference_line helpers', () => {
});
it('should correctly distribute axis on left and right with different formatters when in auto', () => {
const tables = getActiveData([
const tables = generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 200, c: 100 }) },
]);
tables['id-a'].columns[0].meta.params = { id: 'number' }; // a: number formatter
@ -230,7 +214,7 @@ describe('reference_line helpers', () => {
});
it('should ignore hasHistogram for left or right axis', () => {
const tables = getActiveData([
const tables = generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 200, c: 100 }) },
]);
tables['id-a'].columns[0].meta.params = { id: 'number' }; // a: number formatter
@ -285,7 +269,7 @@ describe('reference_line helpers', () => {
],
'x', // this is influenced by the callback
{
activeData: getActiveData([
activeData: generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
]),
},
@ -312,7 +296,7 @@ describe('reference_line helpers', () => {
],
'x',
{
activeData: getActiveData([
activeData: generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -334,7 +318,7 @@ describe('reference_line helpers', () => {
computeOverallDataDomain(
[{ layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] } as XYDataLayerConfig],
['a', 'b', 'c'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -360,7 +344,7 @@ describe('reference_line helpers', () => {
computeOverallDataDomain(
[{ layerId: 'id-a', seriesType, accessors: ['a', 'b', 'c'] } as XYDataLayerConfig],
['a', 'b', 'c'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -385,7 +369,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType, accessors: ['d', 'e', 'f'] },
] as XYDataLayerConfig[],
['a', 'b', 'c', 'd', 'e', 'f'],
getActiveData([
generateActiveData([
{ id: 'id-a', rows: [{ a: 25, b: 100, c: 100 }] },
{ id: 'id-b', rows: [{ d: 50, e: 50, f: 50 }] },
])
@ -399,7 +383,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType, accessors: ['d', 'e', 'f'] },
] as XYDataLayerConfig[],
['a', 'b', 'c', 'd', 'e', 'f'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -435,7 +419,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType, accessors: ['d', 'e', 'f'] },
] as XYDataLayerConfig[],
['a', 'b', 'c', 'd', 'e', 'f'],
getActiveData([
generateActiveData([
{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) },
{ id: 'id-b', rows: Array(3).fill({ d: 50, e: 50, f: 50 }) },
])
@ -453,7 +437,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType: stackedSeries, accessors: ['d', 'e', 'f'] },
] as XYDataLayerConfig[],
['a', 'b', 'c', 'd', 'e', 'f'],
getActiveData([
generateActiveData([
{ id: 'id-a', rows: [{ a: 100, b: 100, c: 100 }] },
{ id: 'id-b', rows: [{ d: 50, e: 50, f: 50 }] },
])
@ -475,7 +459,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType, xAccessor: 'f', accessors: ['d', 'e'] },
] as XYDataLayerConfig[],
['a', 'b', 'd', 'e'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -502,7 +486,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType, accessors: ['f'] },
] as XYDataLayerConfig[],
['c', 'f'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -530,7 +514,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType, xAccessor: 'f', accessors: ['d', 'e'] },
] as XYDataLayerConfig[],
['a', 'b', 'd', 'e'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -560,7 +544,7 @@ describe('reference_line helpers', () => {
} as XYDataLayerConfig,
],
['a', 'b', 'c'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -584,7 +568,7 @@ describe('reference_line helpers', () => {
} as XYDataLayerConfig,
],
['a', 'b', 'c'],
getActiveData([
generateActiveData([
{
id: 'id-a',
rows: Array(3)
@ -605,7 +589,7 @@ describe('reference_line helpers', () => {
computeOverallDataDomain(
[],
['a', 'b', 'c'],
getActiveData([{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) }])
generateActiveData([{ id: 'id-a', rows: Array(3).fill({ a: 100, b: 100, c: 100 }) }])
)
).toEqual({ min: undefined, max: undefined });
});
@ -618,7 +602,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType: 'line', accessors: ['d', 'e', 'f'] },
] as XYDataLayerConfig[],
['a', 'b'],
getActiveData([{ id: 'id-c', rows: [{ a: 100, b: 100 }] }]) // mind the layer id here
generateActiveData([{ id: 'id-c', rows: [{ a: 100, b: 100 }] }]) // mind the layer id here
)
).toEqual({ min: undefined, max: undefined });
@ -629,7 +613,7 @@ describe('reference_line helpers', () => {
{ layerId: 'id-b', seriesType: 'bar_stacked' },
] as XYDataLayerConfig[],
['a', 'b'],
getActiveData([])
generateActiveData([])
)
).toEqual({ min: undefined, max: undefined });
});