[Lens] Enhance visualization modifier popup with layer palette (#155280)

## Summary

Fix #154349 

This PR introduces some chart info API enhancements, used within the
visualization modifier popup in a embeddable context.

<img width="596" alt="Screenshot 2023-04-19 at 15 56 01"
src="https://user-images.githubusercontent.com/924948/233101058-9227511e-ee45-458f-93f7-249fb4c01ee8.png">

For the specific scenario with a table with multiple color by value
columns, the palette is omitted:

<img width="538" alt="Screenshot 2023-04-19 at 16 14 22"
src="https://user-images.githubusercontent.com/924948/233103086-8908bd58-6632-4982-b33c-a6beb990fa75.png">


Sampling dashboard with many combinations:

[sampling_testing_dashboard.ndjson.txt](11274000/sampling_testing_dashboard.ndjson.txt)


Additional fixes:
* 💄 Fixes bottom padding in case of single layer info in the popup

Before:
<img width="266" alt="Screenshot 2023-04-19 at 15 53 07 copy"
src="https://user-images.githubusercontent.com/924948/233101455-7b34437c-1f2c-461e-98b3-8e245eacaa9a.png">

* 💄 Fixes some styling issues of the visualization modifier icon with
some visualization types (treemaps, metrics, tables)

Before:
<img width="464" alt="Screenshot 2023-04-19 at 15 53 31"
src="https://user-images.githubusercontent.com/924948/233102676-a988a186-ba0d-4237-b759-406c55f83c0d.png">
<img width="1176" alt="Screenshot 2023-04-19 at 15 53 17"
src="https://user-images.githubusercontent.com/924948/233101680-5c1bcf7b-d369-4df4-9a5b-4c22a56fe06a.png">
<img width="266" alt="Screenshot 2023-04-19 at 15 53 07"
src="https://user-images.githubusercontent.com/924948/233101686-414e0b31-1c9f-48da-8bfb-94567292cd98.png">
<img width="159" alt="Screenshot 2023-04-19 at 15 53 00"
src="https://user-images.githubusercontent.com/924948/233101692-c76b7a0e-730a-4145-8688-720bd2602875.png">
<img width="222" alt="Screenshot 2023-04-19 at 15 52 44"
src="https://user-images.githubusercontent.com/924948/233101694-66bdb977-37a1-421f-8ae4-5a7033c8d64a.png">
<img width="155" alt="Screenshot 2023-04-19 at 15 52 38"
src="https://user-images.githubusercontent.com/924948/233101696-7d4152c1-5470-43ab-8120-27b0681b5078.png">

After:
<img width="934" alt="Screenshot 2023-04-19 at 15 55 22"
src="https://user-images.githubusercontent.com/924948/233101771-aaa25384-157d-488e-89d9-a8b138204408.png">
<img width="469" alt="Screenshot 2023-04-19 at 15 55 52"
src="https://user-images.githubusercontent.com/924948/233101870-b8886e95-1d54-4467-aa32-6ef12aeb165e.png">
<img width="1184" alt="Screenshot 2023-04-19 at 15 55 45"
src="https://user-images.githubusercontent.com/924948/233101874-418e56f7-098a-47a8-a4c1-9cf7fc7bd87a.png">
<img width="1184" alt="Screenshot 2023-04-19 at 15 55 38"
src="https://user-images.githubusercontent.com/924948/233101877-35a02094-b749-4a82-85cf-ec573f911399.png">
<img width="934" alt="Screenshot 2023-04-19 at 15 55 31"
src="https://user-images.githubusercontent.com/924948/233101878-7a9fcf41-f340-4beb-824e-e377771cfe54.png">


### 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)

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Marco Liberati 2023-04-26 12:06:03 +02:00 committed by GitHub
parent 24712d4860
commit d8a1706bfd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 497 additions and 165 deletions

View file

@ -70,7 +70,7 @@ export const getMaxValue = (
if (isRespectRanges && paletteParams?.rangeMax) {
const metricValue = accessors?.metric ? getValueFromAccessor(accessors.metric, row) : undefined;
return !metricValue || metricValue < paletteParams?.rangeMax
? paletteParams?.rangeMax
? paletteParams.rangeMax
: metricValue;
}
@ -93,16 +93,16 @@ export const getMinValue = (
accessors?: Accessors,
paletteParams?: CustomPaletteParams,
isRespectRanges?: boolean
) => {
): number => {
const currentValue = accessors?.min ? getValueFromAccessor(accessors.min, row) : undefined;
if (currentValue !== undefined && currentValue !== null) {
if (currentValue != null) {
return currentValue;
}
if (isRespectRanges && paletteParams?.rangeMin) {
const metricValue = accessors?.metric ? getValueFromAccessor(accessors.metric, row) : undefined;
return !metricValue || metricValue > paletteParams?.rangeMin
? paletteParams?.rangeMin
? paletteParams.rangeMin
: metricValue;
}
@ -121,7 +121,7 @@ export const getMinValue = (
export const getGoalValue = (row?: DatatableRow, accessors?: Accessors) => {
const currentValue = accessors?.goal ? getValueFromAccessor(accessors.goal, row) : undefined;
if (currentValue !== undefined && currentValue !== null) {
if (currentValue != null) {
return currentValue;
}

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { FormBasedLayer } from '../..';
import { InfoBadge } from '../../shared_components/info_badges/info_badge';
import { FramePublicAPI, VisualizationInfo } from '../../types';
import { getSamplingValue } from './utils';
@ -22,38 +22,27 @@ export function ReducedSamplingSectionEntries({
visualizationInfo: VisualizationInfo;
dataViews: FramePublicAPI['dataViews'];
}) {
const { euiTheme } = useEuiTheme();
return (
<>
{layers.map(([id, layer], layerIndex) => {
const dataView = dataViews.indexPatterns[layer.indexPatternId];
const layerInfo = visualizationInfo.layers.find(({ layerId, label }) => layerId === id);
const layerTitle =
visualizationInfo.layers.find(({ layerId }) => layerId === id)?.label ||
layerInfo?.label ||
i18n.translate('xpack.lens.indexPattern.samplingPerLayer.fallbackLayerName', {
defaultMessage: 'Data layer',
});
const layerPalette = layerInfo?.palette;
return (
<li
key={`${layerTitle}-${dataView}-${layerIndex}`}
data-test-subj={`lns-feature-badges-reducedSampling-${layerIndex}`}
css={css`
margin: ${euiTheme.size.base} 0 0;
`}
<InfoBadge
title={layerTitle}
index={layerIndex}
dataView={dataView.id}
palette={layerPalette}
data-test-subj-prefix="lns-feature-badges-reducedSampling"
>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiText size="s">{layerTitle}</EuiText>
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
padding-right: 0;
`}
>
<EuiText size="s">{`${Number(getSamplingValue(layer)) * 100}%`}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</li>
<EuiText size="s">{`${Number(getSamplingValue(layer)) * 100}%`}</EuiText>
</InfoBadge>
);
})}
</>

View file

@ -53,9 +53,10 @@ import { LANGUAGE_ID } from './math_tokenization';
import './formula.scss';
import { FormulaIndexPatternColumn } from '../formula';
import { insertOrReplaceFormulaColumn } from '../parse';
import { filterByVisibleOperation, nonNullable } from '../util';
import { filterByVisibleOperation } from '../util';
import { getColumnTimeShiftWarnings, getDateHistogramInterval } from '../../../../time_shift_utils';
import { getDocumentationSections } from './formula_help';
import { nonNullable } from '../../../../../../utils';
function tableHasData(
activeData: ParamEditorProps<FormulaIndexPatternColumn>['activeData'],

View file

@ -23,10 +23,11 @@ import type {
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { parseTimeShift } from '@kbn/data-plugin/common';
import moment from 'moment';
import { nonNullable } from '../../../../../../utils';
import { DateRange } from '../../../../../../../common/types';
import type { IndexPattern } from '../../../../../../types';
import { memoizedGetAvailableOperationsByMetadata } from '../../../operations';
import { tinymathFunctions, groupArgsByType, unquotedStringRegex, nonNullable } from '../util';
import { tinymathFunctions, groupArgsByType, unquotedStringRegex } from '../util';
import type { GenericOperationDefinition } from '../..';
import { getFunctionSignatureLabel, getHelpTextContent } from './formula_help';
import { hasFunctionFieldArgument } from '../validation';

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { uniqBy } from 'lodash';
import { nonNullable } from '../../../../../utils';
import type {
BaseIndexPatternColumn,
FieldBasedOperationErrorMessage,
@ -18,7 +19,7 @@ import { runASTValidation, tryToParse } from './validation';
import { WrappedFormulaEditor } from './editor';
import { insertOrReplaceFormulaColumn } from './parse';
import { generateFormula } from './generate';
import { filterByVisibleOperation, nonNullable } from './util';
import { filterByVisibleOperation } from './util';
import { getManagedColumnsFrom } from '../../layer_helpers';
import { generateMissingFieldMessage, getFilter, isColumnFormatted } from '../helpers';

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import { isObject } from 'lodash';
import type { TinymathAST, TinymathVariable, TinymathLocation } from '@kbn/tinymath';
import { nonNullable } from '../../../../../utils';
import type { DateRange } from '../../../../../../common/types';
import type { IndexPattern } from '../../../../../types';
import {
@ -26,7 +27,6 @@ import {
getOperationParams,
groupArgsByType,
mergeWithGlobalFilters,
nonNullable,
} from './util';
import { FormulaIndexPatternColumn, isFormulaIndexPatternColumn } from './formula';
import { getColumnOrder } from '../../layer_helpers';

View file

@ -14,6 +14,7 @@ import type {
TinymathVariable,
} from '@kbn/tinymath';
import type { Query } from '@kbn/es-query';
import { nonNullable } from '../../../../../utils';
import type {
OperationDefinition,
GenericIndexPatternColumn,
@ -736,10 +737,6 @@ Example: Average revenue per customer but in some cases customer id is not provi
},
};
export function nonNullable<T>(v: T): v is NonNullable<T> {
return v != null;
}
export function isMathNode(node: TinymathAST | string) {
return isObject(node) && node.type === 'function' && tinymathFunctions[node.name];
}

View file

@ -18,6 +18,7 @@ import {
REASON_ID_TYPES,
validateAbsoluteTimeShift,
} from '@kbn/data-plugin/common';
import { nonNullable } from '../../../../../utils';
import { DateRange } from '../../../../../../common/types';
import {
findMathNodes,
@ -27,7 +28,6 @@ import {
getValueOrName,
groupArgsByType,
isMathNode,
nonNullable,
tinymathFunctions,
} from './util';

View file

@ -7,16 +7,14 @@
import { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public';
import { Ast } from '@kbn/interpreter';
import memoizeOne from 'memoize-one';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { difference } from 'lodash';
import type { DataViewsContract, DataViewSpec } from '@kbn/data-views-plugin/public';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import type { TimefilterContract } from '@kbn/data-plugin/public';
import {
import type {
Datasource,
DatasourceLayers,
DatasourceMap,
IndexPattern,
IndexPatternMap,
@ -28,9 +26,10 @@ import {
import { buildExpression } from './expression_helpers';
import { Document } from '../../persistence/saved_object_store';
import { getActiveDatasourceIdFromDoc, sortDataViewRefs } from '../../utils';
import type { DatasourceStates, DataViewsState, VisualizationState } from '../../state_management';
import type { DatasourceStates, VisualizationState } from '../../state_management';
import { readFromStorage } from '../../settings_storage';
import { loadIndexPatternRefs, loadIndexPatterns } from '../../data_views_service/loader';
import { getDatasourceLayers } from '../../state_management/utils';
function getIndexPatterns(
references?: SavedObjectReference[],
@ -283,30 +282,6 @@ export function initializeDatasources({
return states;
}
export const getDatasourceLayers = memoizeOne(function getDatasourceLayers(
datasourceStates: DatasourceStates,
datasourceMap: DatasourceMap,
indexPatterns: DataViewsState['indexPatterns']
) {
const datasourceLayers: DatasourceLayers = {};
Object.keys(datasourceMap)
.filter((id) => datasourceStates[id] && !datasourceStates[id].isLoading)
.forEach((id) => {
const datasourceState = datasourceStates[id].state;
const datasource = datasourceMap[id];
const layers = datasource.getLayers(datasourceState);
layers.forEach((layer) => {
datasourceLayers[layer] = datasourceMap[id].getPublicAPI({
state: datasourceState,
layerId: layer,
indexPatterns,
});
});
});
return datasourceLayers;
});
export async function persistedStateToExpression(
datasourceMap: DatasourceMap,
visualizations: VisualizationMap,

View file

@ -126,6 +126,7 @@ import {
} from '../app_plugin/get_application_user_messages';
import { MessageList } from '../editor_frame_service/editor_frame/workspace_panel/message_list';
import { EmbeddableFeatureBadge } from './embeddable_info_badges';
import { getDatasourceLayers } from '../state_management/utils';
export type LensSavedObjectAttributes = Omit<Document, 'savedObjectId' | 'type'>;
@ -610,7 +611,7 @@ export class Embeddable
visualizationMap: this.deps.visualizationMap,
activeDatasource: this.activeDatasource,
activeDatasourceState: {
isLoading: Boolean(this.activeDatasourceState),
isLoading: !this.activeDatasourceState,
state: this.activeDatasourceState,
},
dataViews: {
@ -631,7 +632,16 @@ export class Embeddable
indexPatterns: this.indexPatterns,
indexPatternRefs: this.indexPatternRefs,
},
datasourceLayers: {}, // TODO
datasourceLayers: getDatasourceLayers(
{
[this.activeDatasourceId!]: {
isLoading: !this.activeDatasourceState,
state: this.activeDatasourceState,
},
},
this.deps.datasourceMap,
this.indexPatterns
),
query: this.savedVis.state.query,
filters: mergedSearchContext.filters ?? [],
dateRange: {
@ -646,7 +656,8 @@ export class Embeddable
setState: () => {},
frame: frameDatasourceAPI,
visualizationInfo: this.activeVisualization?.getVisualizationInfo?.(
this.activeVisualizationState
this.activeVisualizationState,
frameDatasourceAPI
),
}) ?? []),
...(this.activeVisualization?.getUserMessages?.(this.activeVisualizationState, {
@ -802,6 +813,13 @@ export class Embeddable
);
this.activeData = newActiveData;
// Refresh messanges if info type is found as with active data
// these messages can be enriched
if (this._userMessages.some(({ severity }) => severity === 'info')) {
this.loadUserMessages();
this.renderUserMessages();
}
};
private onRender: ExpressionWrapperProps['onRender$'] = () => {

View file

@ -49,7 +49,7 @@ export const EmbeddableFeatureBadge = ({ messages }: { messages: UserMessage[] }
title={iconTitle}
size="s"
css={css`
color: ${euiTheme.colors.emptyShade};
color: transparent;
font-size: ${xsFontSize};
height: ${euiTheme.size.l} !important;
.euiButtonEmpty__content {
@ -68,22 +68,29 @@ export const EmbeddableFeatureBadge = ({ messages }: { messages: UserMessage[] }
isOpen={isPopoverOpen}
closePopover={closePopover}
>
<div>
{messages.map(({ shortMessage, longMessage }, index) => (
<aside
key={`${shortMessage}-${index}`}
css={css`
padding: ${index > 0 ? 0 : euiTheme.size.base} ${euiTheme.size.base}
${index > 0 ? euiTheme.size.s : 0};
`}
>
{index ? <EuiHorizontalRule margin="s" /> : null}
<EuiTitle size="xxs" css={css`color=${euiTheme.colors.title}`}>
<h3>{shortMessage}</h3>
</EuiTitle>
<ul className="lnsEmbeddablePanelFeatureList">{longMessage}</ul>
</aside>
))}
<div
css={css`
max-width: 280px;
`}
>
{messages.map(({ shortMessage, longMessage }, index) => {
return (
<>
{index ? <EuiHorizontalRule margin="none" /> : null}
<aside
key={`${shortMessage}-${index}`}
css={css`
padding: ${euiTheme.size.base};
`}
>
<EuiTitle size="xxs" css={css`color=${euiTheme.colors.title}`}>
<h3>{shortMessage}</h3>
</EuiTitle>
<ul className="lnsEmbeddablePanelFeatureList">{longMessage}</ul>
</aside>
</>
);
})}
</div>
</EuiPopover>
);

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { InfoBadge } from './info_badge';
describe('Info badge', () => {
it('should render no icon if no palette is passed', () => {
const res = render(
<InfoBadge title="my Title" dataView="dataView" index={0} data-test-subj-prefix="prefix" />
);
expect(res.queryByTestId('prefix-0-icon')).not.toBeInTheDocument();
expect(res.queryByTestId('prefix-0-palette')).not.toBeInTheDocument();
});
it('should render an icon if a single palette color is passed over', () => {
const res = render(
<InfoBadge
title="my Title"
dataView="dataView"
index={0}
data-test-subj-prefix="prefix"
palette={['red']}
/>
);
expect(res.queryByTestId('prefix-0-icon')).toBeInTheDocument();
expect(res.queryByTestId('prefix-0-palette')).not.toBeInTheDocument();
});
it('should render both an icon an a palette indicator if multiple colors are passed over', () => {
const res = render(
<InfoBadge
title="my Title"
dataView="dataView"
index={0}
data-test-subj-prefix="prefix"
palette={['red', 'blue']}
/>
);
expect(res.queryByTestId('prefix-0-icon')).toBeInTheDocument();
expect(res.queryByTestId('prefix-0-palette')).toBeInTheDocument();
});
it('should render children as value when passed', () => {
const res = render(
<InfoBadge title="my Title" dataView="dataView" index={0} data-test-subj-prefix="prefix">
<div>100%</div>
</InfoBadge>
);
expect(res.getByText('100%')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiColorPaletteDisplay,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import React, { type ReactChildren, type ReactChild } from 'react';
export function InfoBadge({
title,
dataView,
index,
palette,
children,
'data-test-subj-prefix': dataTestSubjPrefix,
}: {
title: string;
dataView: string;
index: number;
palette?: string[];
children?: ReactChild | ReactChildren;
'data-test-subj-prefix': string;
}) {
const { euiTheme } = useEuiTheme();
const hasColor = Boolean(palette);
const hasSingleColor = palette && palette.length === 1;
const hasMultipleColors = palette && palette.length > 1;
const iconType = hasSingleColor ? 'stopFilled' : 'color';
return (
<li
key={`${title}-${dataView}-${index}`}
data-test-subj={`${dataTestSubjPrefix}-${index}`}
css={css`
margin: ${euiTheme.size.base} 0 0;
`}
>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
{hasColor ? (
<EuiFlexItem grow={false}>
<EuiIcon
color={hasSingleColor ? palette[0] : undefined}
type={iconType}
data-test-subj={`${dataTestSubjPrefix}-${index}-icon`}
/>
</EuiFlexItem>
) : null}
<EuiFlexItem>
<EuiText size="s" data-test-subj={`${dataTestSubjPrefix}-${index}-title`}>
{title}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
</EuiFlexGroup>
{hasMultipleColors ? (
<div
css={css`
margin-top: ${euiTheme.size.xs};
overflow-y: hidden;
height: ${euiTheme.size.xs};
margin-left: ${euiTheme.size.l};
`}
>
<EuiColorPaletteDisplay
size="xs"
palette={palette}
data-test-subj={`${dataTestSubjPrefix}-${index}-palette`}
/>
</div>
) : null}
</li>
);
}

View file

@ -11,7 +11,7 @@ import { SavedObjectReference } from '@kbn/core/public';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import { LensState } from './types';
import { Datasource, DatasourceMap, VisualizationMap } from '../types';
import { getDatasourceLayers } from '../editor_frame_service/editor_frame';
import { getDatasourceLayers } from './utils';
export const selectPersistedDoc = (state: LensState) => state.lens.persistedDoc;
export const selectQuery = (state: LensState) => state.lens.query;

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import memoizeOne from 'memoize-one';
import type { DatasourceMap, DatasourceLayers } from '../types';
import type { DatasourceStates, DataViewsState } from './types';
export const getDatasourceLayers = memoizeOne(function getDatasourceLayers(
datasourceStates: DatasourceStates,
datasourceMap: DatasourceMap,
indexPatterns: DataViewsState['indexPatterns']
) {
const datasourceLayers: DatasourceLayers = {};
Object.keys(datasourceMap)
.filter((id) => datasourceStates[id] && !datasourceStates[id].isLoading)
.forEach((id) => {
const datasourceState = datasourceStates[id].state;
const datasource = datasourceMap[id];
const layers = datasource.getLayers(datasourceState);
layers.forEach((layer) => {
datasourceLayers[layer] = datasourceMap[id].getPublicAPI({
state: datasourceState,
layerId: layer,
indexPatterns,
});
});
});
return datasourceLayers;
});

View file

@ -164,6 +164,7 @@ export interface VisualizationInfo {
icon?: IconType;
label?: string;
dimensions: Array<{ name: string; id: string; dimensionType: string }>;
palette?: string[];
}>;
}
@ -1291,7 +1292,7 @@ export interface Visualization<T = unknown, P = T> {
props: VisualizationStateFromContextChangeProps
) => Suggestion<T> | undefined;
getVisualizationInfo?: (state: T) => VisualizationInfo;
getVisualizationInfo?: (state: T, frame?: FramePublicAPI) => VisualizationInfo;
/**
* A visualization can return custom dimensions for the reporting tool
*/

View file

@ -361,3 +361,7 @@ export const getSearchWarningMessages = (
return [...warningsMap.values()].flat();
};
export function nonNullable<T>(v: T): v is NonNullable<T> {
return v != null;
}

View file

@ -30,5 +30,6 @@
// Make the visualization modifiers icon appear only on panel hover
.embPanel__content:hover .lnsEmbeddablePanelFeatureList_button {
color: $euiTextColor;
transition: color $euiAnimSpeedSlow;
background: $euiColorEmptyShade;
transition: color $euiAnimSpeedSlow, background $euiAnimSpeedSlow;
}

View file

@ -610,7 +610,11 @@ export const getDatatableVisualization = ({
return suggestion;
},
getVisualizationInfo(state: DatatableVisualizationState) {
getVisualizationInfo(state) {
const visibleMetricColumns = state.columns.filter(
(c) => !c.hidden && c.colorMode && c.colorMode !== 'none'
);
return {
layers: [
{
@ -618,6 +622,11 @@ export const getDatatableVisualization = ({
layerType: state.layerType,
chartType: 'table',
...this.getDescription(state),
palette:
// if multiple columns have color by value, do not show the palette for now: see #154349
visibleMetricColumns.length > 1
? undefined
: visibleMetricColumns[0]?.palette?.params?.stops?.map(({ color }) => color),
dimensions: state.columns.map((column) => {
let name = i18n.translate('xpack.lens.datatable.metric', {
defaultMessage: 'Metric',

View file

@ -27,6 +27,7 @@ import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import type {
DatasourceLayers,
FramePublicAPI,
OperationMetadata,
Suggestion,
UserMessage,
@ -234,23 +235,12 @@ export const getGaugeVisualization = ({
getSuggestions,
getConfiguration({ state, frame }) {
const hasColoring = Boolean(state.colorMode !== 'none' && state.palette?.params?.stops);
const row = state?.layerId ? frame?.activeData?.[state?.layerId]?.rows?.[0] : undefined;
const { metricAccessor } = state ?? {};
const accessors = getAccessorsFromState(state);
let palette;
if (!(row == null || metricAccessor == null || state?.palette == null || !hasColoring)) {
const currentMinMax = {
min: getMinValue(row, accessors),
max: getMaxValue(row, accessors),
};
const displayStops = applyPaletteParams(paletteService, state?.palette, currentMinMax);
palette = displayStops.map(({ color }) => color);
}
const { palette, metricAccessor, accessors } = getConfigurationAccessorsAndPalette(
state,
paletteService,
frame.activeData
);
return {
groups: [
@ -602,11 +592,16 @@ export const getGaugeVisualization = ({
return suggestion;
},
getVisualizationInfo(state: GaugeVisualizationState) {
getVisualizationInfo(state, frame) {
const { palette, accessors } = getConfigurationAccessorsAndPalette(
state,
paletteService,
frame?.activeData
);
const dimensions = [];
if (state.metricAccessor) {
if (accessors?.metric) {
dimensions.push({
id: state.metricAccessor,
id: accessors.metric,
name: i18n.translate('xpack.lens.gauge.metricLabel', {
defaultMessage: 'Metric',
}),
@ -614,9 +609,9 @@ export const getGaugeVisualization = ({
});
}
if (state.maxAccessor) {
if (accessors?.max) {
dimensions.push({
id: state.maxAccessor,
id: accessors.max,
name: i18n.translate('xpack.lens.gauge.maxValueLabel', {
defaultMessage: 'Maximum value',
}),
@ -624,9 +619,9 @@ export const getGaugeVisualization = ({
});
}
if (state.minAccessor) {
if (accessors?.min) {
dimensions.push({
id: state.minAccessor,
id: accessors.min,
name: i18n.translate('xpack.lens.gauge.minValueLabel', {
defaultMessage: 'Minimum value',
}),
@ -634,9 +629,9 @@ export const getGaugeVisualization = ({
});
}
if (state.goalAccessor) {
if (accessors?.goal) {
dimensions.push({
id: state.goalAccessor,
id: accessors.goal,
name: i18n.translate('xpack.lens.gauge.goalValueLabel', {
defaultMessage: 'Goal value',
}),
@ -651,8 +646,44 @@ export const getGaugeVisualization = ({
chartType: state.shape,
...this.getDescription(state),
dimensions,
palette,
},
],
};
},
});
// When the active data comes from the embeddable side it might not have been indexed by layerId
// rather using a "default" key
function getActiveDataForLayer(
layerId: string | undefined,
activeData: FramePublicAPI['activeData'] | undefined
) {
if (activeData && layerId) {
return activeData[layerId] || activeData.default;
}
}
function getConfigurationAccessorsAndPalette(
state: GaugeVisualizationState,
paletteService: PaletteRegistry,
activeData?: FramePublicAPI['activeData']
) {
const hasColoring = Boolean(state.colorMode !== 'none' && state.palette?.params?.stops);
const row = getActiveDataForLayer(state?.layerId, activeData)?.rows?.[0];
const { metricAccessor } = state ?? {};
const accessors = getAccessorsFromState(state);
let palette;
if (row != null && metricAccessor != null && state?.palette != null && hasColoring) {
const currentMinMax = {
min: getMinValue(row, accessors),
max: getMaxValue(row, accessors),
};
const displayStops = applyPaletteParams(paletteService, state?.palette, currentMinMax);
palette = displayStops.map(({ color }) => color);
}
return { metricAccessor, accessors, palette };
}

View file

@ -515,7 +515,7 @@ export const getHeatmapVisualization = ({
return suggestion;
},
getVisualizationInfo(state: HeatmapVisualizationState) {
getVisualizationInfo(state, frame) {
const dimensions = [];
if (state.xAccessor) {
dimensions.push({
@ -543,6 +543,15 @@ export const getHeatmapVisualization = ({
});
}
const { displayStops } = getSafePaletteParams(
paletteService,
// When the active data comes from the embeddable side it might not have been indexed by layerId
// rather using a "default" key
frame?.activeData?.[state.layerId] || frame?.activeData?.default,
state.valueAccessor,
state?.palette && state.palette.accessor === state.valueAccessor ? state.palette : undefined
);
return {
layers: [
{
@ -551,6 +560,7 @@ export const getHeatmapVisualization = ({
chartType: state.shape,
...this.getDescription(state),
dimensions,
palette: displayStops.length ? displayStops.map(({ color }) => color) : undefined,
},
],
};

View file

@ -324,6 +324,9 @@ export const getLegacyMetricVisualization = ({
});
}
const hasColoring = state.palette != null;
const stops = state.palette?.params?.stops || [];
return {
layers: [
{
@ -332,6 +335,7 @@ export const getLegacyMetricVisualization = ({
chartType: 'metric',
...this.getDescription(state),
dimensions,
palette: hasColoring ? stops.map(({ color }) => color) : undefined,
},
],
};

View file

@ -35,6 +35,7 @@ import { Toolbar } from './toolbar';
import { generateId } from '../../id_generator';
import { FormatSelectorOptions } from '../../datasources/form_based/dimension_panel/format_selector';
import { toExpression } from './to_expression';
import { nonNullable } from '../../utils';
export const DEFAULT_MAX_COLUMNS = 3;
@ -666,7 +667,7 @@ export const getMetricVisualization = ({
return suggestion;
},
getVisualizationInfo(state: MetricVisualizationState) {
getVisualizationInfo(state) {
const dimensions = [];
if (state.metricAccessor) {
dimensions.push({
@ -706,6 +707,10 @@ export const getMetricVisualization = ({
});
}
const stops = state.palette?.params?.stops || [];
const hasStaticColoring = !!state.color;
const hasDynamicColoring = !!state.palette;
return {
layers: [
{
@ -714,6 +719,12 @@ export const getMetricVisualization = ({
chartType: 'metric',
...this.getDescription(state),
dimensions,
palette: (hasDynamicColoring
? stops.map(({ color }) => color)
: hasStaticColoring
? [state.color]
: [getDefaultColor(state)]
).filter(nonNullable),
},
],
};

View file

@ -46,6 +46,7 @@ import { DimensionDataExtraEditor, DimensionEditor, PieToolbar } from './toolbar
import { LayerSettings } from './layer_settings';
import { checkTableForContainsSmallValues } from './render_helpers';
import { DatasourcePublicAPI } from '../..';
import { nonNullable } from '../../utils';
const metricLabel = i18n.translate('xpack.lens.pie.groupMetricLabelSingular', {
defaultMessage: 'Metric',
@ -202,7 +203,7 @@ export const getPieVisualization = ({
// count multiple metrics as a bucket dimension so that the rest of the dimension
// groups UI behaves correctly.
const multiMetricsBucketDimensionCount =
layer.metrics.length > 1 && state.shape !== 'mosaic' ? 1 : 0;
layer.metrics.length > 1 && state.shape !== PieChartTypes.MOSAIC ? 1 : 0;
const totalNonCollapsedAccessors =
accessors.reduce(
@ -223,8 +224,8 @@ export const getPieVisualization = ({
: undefined;
switch (state.shape) {
case 'donut':
case 'pie':
case PieChartTypes.DONUT:
case PieChartTypes.PIE:
return {
...primaryGroupConfigBaseProps,
groupLabel: i18n.translate('xpack.lens.pie.sliceGroupLabel', {
@ -239,7 +240,7 @@ export const getPieVisualization = ({
dataTestSubj: 'lnsPie_sliceByDimensionPanel',
hideGrouping: true,
};
case 'mosaic':
case PieChartTypes.MOSAIC:
return {
...primaryGroupConfigBaseProps,
groupLabel: i18n.translate('xpack.lens.pie.verticalAxisLabel', {
@ -267,7 +268,7 @@ export const getPieVisualization = ({
dimensionsTooMany:
totalNonCollapsedAccessors - PartitionChartsMeta[state.shape].maxBuckets,
dataTestSubj: 'lnsPie_groupByDimensionPanel',
hideGrouping: state.shape === 'treemap',
hideGrouping: state.shape === PieChartTypes.TREEMAP,
};
}
};
@ -297,7 +298,7 @@ export const getPieVisualization = ({
);
switch (state.shape) {
case 'mosaic':
case PieChartTypes.MOSAIC:
return {
...secondaryGroupConfigBaseProps,
groupLabel: i18n.translate('xpack.lens.pie.horizontalAxisLabel', {
@ -374,8 +375,8 @@ export const getPieVisualization = ({
return {
groups: [getPrimaryGroupConfig(), getSecondaryGroupConfig(), getMetricGroupConfig()].filter(
Boolean
) as VisualizationDimensionGroupConfig[],
nonNullable
),
};
},
@ -595,7 +596,7 @@ export const getPieVisualization = ({
if (
numericColumn &&
state.shape === 'waffle' &&
state.shape === PieChartTypes.WAFFLE &&
layer.primaryGroups.length &&
checkTableForContainsSmallValues(frame.activeData[layerId], numericColumn.id, 1)
) {
@ -619,7 +620,7 @@ export const getPieVisualization = ({
return metricColId;
}
})
.filter(Boolean) as string[];
.filter(nonNullable);
if (metricsWithArrayValues.length) {
const labels = metricsWithArrayValues.map(
@ -650,10 +651,42 @@ export const getPieVisualization = ({
return [...errors, ...warningMessages];
},
getVisualizationInfo(state: PieVisualizationState) {
getVisualizationInfo(state, frame) {
const layer = state.layers[0];
const dimensions: VisualizationInfo['layers'][number]['dimensions'] = [];
const datasource = frame?.datasourceLayers[layer.layerId];
const hasSliceBy = layer.primaryGroups.length + (layer.secondaryGroups?.length || 0);
const hasMultipleMetrics = layer.allowMultipleMetrics;
const palette = [];
if (!hasSliceBy && datasource) {
if (hasMultipleMetrics) {
palette.push(
...layer.metrics.map(
(columnId) =>
layer.colorsByDimension?.[columnId] ??
getDefaultColorForMultiMetricDimension({
layer,
columnId,
paletteService,
datasource,
})
)
);
} else if (!layer.primaryGroups?.length) {
// This is a logic integrated in the renderer, here simulated
// In the particular case of no color assigned (as no sliceBy dimension defined)
// the color is generated on the fly from the default palette
palette.push(
...paletteService
.get(state.palette?.name || 'default')
.getCategoricalColors(Math.max(10, layer.metrics.length))
.slice(0, layer.metrics.length)
);
}
}
layer.metrics.forEach((metric) => {
dimensions.push({
id: metric,
@ -662,7 +695,7 @@ export const getPieVisualization = ({
});
});
if (state.shape === 'mosaic' && layer.secondaryGroups && layer.secondaryGroups.length) {
if (state.shape === PieChartTypes.MOSAIC && layer.secondaryGroups?.length) {
layer.secondaryGroups.forEach((accessor) => {
dimensions.push({
name: i18n.translate('xpack.lens.pie.horizontalAxisLabel', {
@ -674,18 +707,19 @@ export const getPieVisualization = ({
});
}
if (layer.primaryGroups && layer.primaryGroups.length) {
if (layer.primaryGroups?.length) {
let name = i18n.translate('xpack.lens.pie.treemapGroupLabel', {
defaultMessage: 'Group by',
});
let dimensionType = 'group_by';
if (state.shape === 'mosaic') {
if (state.shape === PieChartTypes.MOSAIC) {
name = i18n.translate('xpack.lens.pie.verticalAxisLabel', {
defaultMessage: 'Vertical axis',
});
dimensionType = 'vertical_axis';
}
if (state.shape === 'donut' || state.shape === 'pie') {
if (state.shape === PieChartTypes.DONUT || state.shape === PieChartTypes.PIE) {
name = i18n.translate('xpack.lens.pie.sliceGroupLabel', {
defaultMessage: 'Slice by',
});
@ -698,8 +732,18 @@ export const getPieVisualization = ({
id: accessor,
});
});
if (layer.primaryGroups.some((id) => !isCollapsed(id, layer))) {
palette.push(
...paletteService
.get(state.palette?.name || 'default')
.getCategoricalColors(10, state.palette?.params)
);
}
}
const finalPalette = palette.filter(nonNullable);
return {
layers: [
{
@ -708,6 +752,7 @@ export const getPieVisualization = ({
chartType: state.shape,
...this.getDescription(state),
dimensions,
palette: finalPalette.length ? finalPalette : undefined,
},
],
};

View file

@ -44,15 +44,13 @@ export function getColorAssignments(
): ColorAssignments {
const layersPerPalette: Record<string, XYDataLayerConfig[]> = {};
layers
.filter((layer): layer is XYDataLayerConfig => isDataLayer(layer))
.forEach((layer) => {
const palette = layer.palette?.name || 'default';
if (!layersPerPalette[palette]) {
layersPerPalette[palette] = [];
}
layersPerPalette[palette].push(layer);
});
layers.filter(isDataLayer).forEach((layer) => {
const palette = layer.palette?.name || 'default';
if (!layersPerPalette[palette]) {
layersPerPalette[palette] = [];
}
layersPerPalette[palette].push(layer);
});
return mapValues(layersPerPalette, (paletteLayers) => {
const seriesPerLayer = paletteLayers.map((layer, layerIndex) => {

View file

@ -5,10 +5,9 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { InfoBadge } from '../../shared_components/info_badges/info_badge';
import { FramePublicAPI, VisualizationInfo } from '../../types';
import { XYAnnotationLayerConfig } from './types';
@ -21,30 +20,25 @@ export function IgnoredGlobalFiltersEntries({
visualizationInfo: VisualizationInfo;
dataViews: FramePublicAPI['dataViews'];
}) {
const { euiTheme } = useEuiTheme();
return (
<>
{layers.map((layer, layerIndex) => {
const dataView = dataViews.indexPatterns[layer.indexPatternId];
const layerInfo = visualizationInfo.layers.find(({ layerId }) => layerId === layer.layerId);
const layerTitle =
visualizationInfo.layers.find(({ layerId, label }) => layerId === layer.layerId)?.label ||
layerInfo?.label ||
i18n.translate('xpack.lens.xyChart.layerAnnotationsLabel', {
defaultMessage: 'Annotations',
});
const layerPalette = layerInfo?.palette;
return (
<li
key={`${layerTitle}-${dataView}-${layerIndex}`}
data-test-subj={`lns-feature-badges-ignoreGlobalFilters-${layerIndex}`}
css={css`
margin: ${euiTheme.size.base} 0 0;
`}
>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiText size="s">{layerTitle}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</li>
<InfoBadge
title={layerTitle}
index={layerIndex}
dataView={dataView.id}
palette={layerPalette}
data-test-subj-prefix="lns-feature-badges-ignoreGlobalFilters"
/>
);
})}
</>

View file

@ -2242,7 +2242,8 @@ describe('xy_visualization', () => {
expect(yConfigs?.accessors[1].columnId).toEqual('b');
expect(yConfigs?.accessors[1].color).toEqual('green');
paletteGetter.mockClear();
// This call restores the initial state of the paletteGetter
paletteGetter.mockRestore();
});
});
});

View file

@ -26,6 +26,7 @@ import {
isDraggedDataViewField,
isOperationFromCompatibleGroup,
isOperationFromTheSameGroup,
nonNullable,
renewIDs,
} from '../../utils';
import { getSuggestions } from './xy_suggestions';
@ -74,6 +75,7 @@ import {
getUniqueLabels,
onAnnotationDrop,
isDateHistogram,
getSingleColorAnnotationConfig,
} from './annotations/helpers';
import {
checkXAccessorCompatibility,
@ -868,7 +870,7 @@ export const getXyVisualization = ({
);
}
const info = getNotifiableFeatures(state, frame.dataViews);
const info = getNotifiableFeatures(state, frame, paletteService, fieldFormats);
return errors.concat(warnings, info);
},
@ -913,7 +915,9 @@ export const getXyVisualization = ({
return suggestion;
},
getVisualizationInfo,
getVisualizationInfo(state, frame) {
return getVisualizationInfo(state, frame, paletteService, fieldFormats);
},
});
const getMappedAccessors = ({
@ -954,18 +958,26 @@ const getMappedAccessors = ({
return mappedAccessors;
};
function getVisualizationInfo(state: XYState) {
function getVisualizationInfo(
state: XYState,
frame: Partial<FramePublicAPI> | undefined,
paletteService: PaletteRegistry,
fieldFormats: FieldFormatsStart
) {
const isHorizontal = isHorizontalChart(state.layers);
const visualizationLayersInfo = state.layers.map((layer) => {
const palette = [];
const dimensions = [];
let chartType: SeriesType | undefined;
let icon;
let label;
if (isDataLayer(layer)) {
chartType = layer.seriesType;
const layerVisType = visualizationTypes.find((visType) => visType.id === chartType);
icon = layerVisType?.icon;
label = layerVisType?.fullLabel || layerVisType?.label;
if (layer.xAccessor) {
dimensions.push({
name: getAxisName('x', { isHorizontal }),
@ -981,6 +993,21 @@ function getVisualizationInfo(state: XYState) {
dimensionType: 'y',
});
});
if (frame?.datasourceLayers && frame.activeData) {
const sortedAccessors: string[] = getSortedAccessors(
frame.datasourceLayers[layer.layerId],
layer
);
const mappedAccessors = getMappedAccessors({
state,
frame: frame as Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>,
layer,
fieldFormats,
paletteService,
accessors: sortedAccessors,
});
palette.push(...mappedAccessors.flatMap(({ color }) => color));
}
}
if (layer.splitAccessor) {
dimensions.push({
@ -990,6 +1017,13 @@ function getVisualizationInfo(state: XYState) {
dimensionType: 'breakdown',
id: layer.splitAccessor,
});
if (!layer.collapseFn) {
palette.push(
...paletteService
.get(layer.palette?.name || 'default')
.getCategoricalColors(10, layer.palette?.params)
);
}
}
}
if (isReferenceLayer(layer) && layer.accessors && layer.accessors.length) {
@ -1006,6 +1040,20 @@ function getVisualizationInfo(state: XYState) {
defaultMessage: 'Reference lines',
});
icon = IconChartBarReferenceLine;
if (frame?.datasourceLayers && frame.activeData) {
const sortedAccessors: string[] = getSortedAccessors(
frame.datasourceLayers[layer.layerId],
layer
);
palette.push(
...getReferenceConfiguration({
state,
frame: frame as Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>,
layer,
sortedAccessors,
}).groups.flatMap(({ accessors }) => accessors.map(({ color }) => color))
);
}
}
if (isAnnotationsLayer(layer) && layer.annotations && layer.annotations.length) {
layer.annotations.forEach((annotation) => {
@ -1021,8 +1069,15 @@ function getVisualizationInfo(state: XYState) {
defaultMessage: 'Annotations',
});
icon = IconChartBarAnnotations;
palette.push(
...layer.annotations
.filter(({ isHidden }) => !isHidden)
.map((annotation) => getSingleColorAnnotationConfig(annotation).color)
);
}
const finalPalette = palette?.filter(nonNullable);
return {
layerId: layer.layerId,
layerType: layer.layerType,
@ -1030,6 +1085,7 @@ function getVisualizationInfo(state: XYState) {
icon,
label,
dimensions,
palette: finalPalette.length ? finalPalette : undefined,
};
});
return {
@ -1039,7 +1095,9 @@ function getVisualizationInfo(state: XYState) {
function getNotifiableFeatures(
state: XYState,
dataViews: FramePublicAPI['dataViews']
frame: Pick<FramePublicAPI, 'dataViews'> & Partial<FramePublicAPI>,
paletteService: PaletteRegistry,
fieldFormats: FieldFormatsStart
): UserMessage[] {
const annotationsWithIgnoreFlag = getAnnotationsLayers(state.layers).filter(
(layer) => layer.ignoreGlobalFilters
@ -1047,7 +1105,7 @@ function getNotifiableFeatures(
if (!annotationsWithIgnoreFlag.length) {
return [];
}
const visualizationInfo = getVisualizationInfo(state);
const visualizationInfo = getVisualizationInfo(state, frame, paletteService, fieldFormats);
return [
{
@ -1061,7 +1119,7 @@ function getNotifiableFeatures(
<IgnoredGlobalFiltersEntries
layers={annotationsWithIgnoreFlag}
visualizationInfo={visualizationInfo}
dataViews={dataViews}
dataViews={frame.dataViews}
/>
),
displayLocations: [{ id: 'embeddableBadge' }],