[Maps] add attribution to layer editor (#98328)

* [Maps] add attribution to layer editor

* update sources to getAttributionProvider

* remove attribution UI from EMS_XYZ source

* remove attribution UI from WMS source editor

* clean up

* tslint

* AttributionFormRow jest test

* add migration

* tslint

* i18n fixes

* attribution

* [Maps] Improving design and a11y for attribution layer settings (#38)

* Design and a11y improvements

* Buttons aria labels

* Addressing PR review

* tslint and i18n fixes

* update jest snapshots

* remove placeholder for url

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elizabet Oliveira <elizabet.oliveira@elastic.co>
This commit is contained in:
Nathan Reese 2021-05-03 10:42:07 -06:00 committed by GitHub
parent 24fd3a1e18
commit 85b78711c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 712 additions and 443 deletions

View file

@ -16,6 +16,11 @@ import {
import { DataRequestDescriptor } from './data_request_descriptor_types';
import { AbstractSourceDescriptor, TermJoinSourceDescriptor } from './source_descriptor_types';
export type Attribution = {
label: string;
url: string;
};
export type JoinDescriptor = {
leftField?: string;
right: TermJoinSourceDescriptor;
@ -29,6 +34,7 @@ export type LayerDescriptor = {
__trackedLayerDescriptor?: LayerDescriptor;
__areTilesLoaded?: boolean;
alpha?: number;
attribution?: Attribution;
id: string;
joins?: JoinDescriptor[];
label?: string | null;

View file

@ -19,11 +19,6 @@ import {
SOURCE_TYPES,
} from '../constants';
export type AttributionDescriptor = {
attributionText?: string;
attributionUrl?: string;
};
export type AbstractSourceDescriptor = {
id?: string;
type: string;
@ -129,14 +124,11 @@ export type WMSSourceDescriptor = AbstractSourceDescriptor & {
serviceUrl: string;
layers: string;
styles: string;
attributionText: string;
attributionUrl: string;
};
export type XYZTMSSourceDescriptor = AbstractSourceDescriptor &
AttributionDescriptor & {
urlTemplate: string;
};
export type XYZTMSSourceDescriptor = AbstractSourceDescriptor & {
urlTemplate: string;
};
export type MVTFieldDescriptor = {
name: string;

View file

@ -0,0 +1,67 @@
/*
* 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 { moveAttribution } from './move_attribution';
import { LayerDescriptor } from '../descriptor_types';
test('Should handle missing layerListJSON attribute', () => {
const attributes = {
title: 'my map',
};
expect(moveAttribution({ attributes })).toEqual({
title: 'my map',
});
});
test('Should migrate source attribution to layer attribution', () => {
const layerListJSON = JSON.stringify(([
{
sourceDescriptor: {
attributionText: 'myLabel',
attributionUrl: 'myUrl',
id: 'mySourceId',
},
},
] as unknown) as LayerDescriptor[]);
const attributes = {
title: 'my map',
layerListJSON,
};
const { layerListJSON: migratedLayerListJSON } = moveAttribution({ attributes });
const migratedLayerList = JSON.parse(migratedLayerListJSON!);
expect(migratedLayerList).toEqual([
{
attribution: { label: 'myLabel', url: 'myUrl' },
sourceDescriptor: { id: 'mySourceId' },
},
]);
});
test('Should not add attribution to layer when source does not provide attribution', () => {
const layerListJSON = JSON.stringify(([
{
sourceDescriptor: {
id: 'mySourceId',
},
},
] as unknown) as LayerDescriptor[]);
const attributes = {
title: 'my map',
layerListJSON,
};
const { layerListJSON: migratedLayerListJSON } = moveAttribution({ attributes });
const migratedLayerList = JSON.parse(migratedLayerListJSON!);
expect(migratedLayerList).toEqual([
{
sourceDescriptor: { id: 'mySourceId' },
},
]);
});

View file

@ -0,0 +1,42 @@
/*
* 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 { MapSavedObjectAttributes } from '../map_saved_object_type';
import { LayerDescriptor } from '../descriptor_types';
// In 7.14, attribution added to the layer_descriptor. Prior to 7.14, 2 sources, WMS and TMS, had attribution on source descriptor.
export function moveAttribution({
attributes,
}: {
attributes: MapSavedObjectAttributes;
}): MapSavedObjectAttributes {
if (!attributes || !attributes.layerListJSON) {
return attributes;
}
const layerList: LayerDescriptor[] = JSON.parse(attributes.layerListJSON);
layerList.forEach((layer: LayerDescriptor) => {
const sourceDescriptor = layer.sourceDescriptor as {
attributionText?: string;
attributionUrl?: string;
};
if (sourceDescriptor.attributionText && sourceDescriptor.attributionUrl) {
layer.attribution = {
label: sourceDescriptor.attributionText,
url: sourceDescriptor.attributionUrl,
};
delete sourceDescriptor.attributionText;
delete sourceDescriptor.attributionUrl;
}
});
return {
...attributes,
layerListJSON: JSON.stringify(layerList),
};
}

View file

@ -24,6 +24,7 @@ import { updateFlyout } from './ui_actions';
import {
ADD_LAYER,
ADD_WAITING_FOR_MAP_READY_LAYER,
CLEAR_LAYER_PROP,
CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST,
REMOVE_LAYER,
REMOVE_TRACKED_LAYER_STATE,
@ -40,7 +41,12 @@ import {
} from './map_action_constants';
import { clearDataRequests, syncDataForLayerId, updateStyleMeta } from './data_request_actions';
import { cleanTooltipStateForLayer } from './tooltip_actions';
import { JoinDescriptor, LayerDescriptor, StyleDescriptor } from '../../common/descriptor_types';
import {
Attribution,
JoinDescriptor,
LayerDescriptor,
StyleDescriptor,
} from '../../common/descriptor_types';
import { ILayer } from '../classes/layers/layer';
import { IVectorLayer } from '../classes/layers/vector_layer';
import { LAYER_STYLE_TYPE, LAYER_TYPE } from '../../common/constants';
@ -349,6 +355,23 @@ export function updateLayerLabel(id: string, newLabel: string) {
};
}
export function setLayerAttribution(id: string, attribution: Attribution) {
return {
type: UPDATE_LAYER_PROP,
id,
propName: 'attribution',
newValue: attribution,
};
}
export function clearLayerAttribution(id: string) {
return {
type: CLEAR_LAYER_PROP,
id,
propName: 'attribution',
};
}
export function updateLayerMinZoom(id: string, minZoom: number) {
return {
type: UPDATE_LAYER_PROP,

View file

@ -10,6 +10,7 @@ export const UPDATE_LAYER_ORDER = 'UPDATE_LAYER_ORDER';
export const ADD_LAYER = 'ADD_LAYER';
export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS';
export const ADD_WAITING_FOR_MAP_READY_LAYER = 'ADD_WAITING_FOR_MAP_READY_LAYER';
export const CLEAR_LAYER_PROP = 'CLEAR_LAYER_PROP';
export const CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST = 'CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST';
export const REMOVE_LAYER = 'REMOVE_LAYER';
export const SET_LAYER_VISIBILITY = 'SET_LAYER_VISIBILITY';

View file

@ -30,13 +30,14 @@ import {
import { copyPersistentState } from '../../reducers/copy_persistent_state';
import {
AggDescriptor,
Attribution,
ESTermSourceDescriptor,
JoinDescriptor,
LayerDescriptor,
MapExtent,
StyleDescriptor,
} from '../../../common/descriptor_types';
import { Attribution, ImmutableSourceProperty, ISource, SourceEditorArgs } from '../sources/source';
import { ImmutableSourceProperty, ISource, SourceEditorArgs } from '../sources/source';
import { DataRequestContext } from '../../actions';
import { IStyle } from '../styles/style';
import { getJoinAggKey } from '../../../common/get_agg_key';
@ -99,6 +100,7 @@ export interface ILayer {
isFittable(): Promise<boolean>;
getLicensedFeatures(): Promise<LICENSED_FEATURES[]>;
getCustomIconAndTooltipContent(): CustomIconAndTooltipContent;
getDescriptor(): LayerDescriptor;
}
export type CustomIconAndTooltipContent = {
@ -156,6 +158,10 @@ export class AbstractLayer implements ILayer {
return mbStyle.sources[sourceId].data;
}
getDescriptor(): LayerDescriptor {
return this._descriptor;
}
async cloneDescriptor(): Promise<LayerDescriptor> {
const clonedDescriptor = copyPersistentState(this._descriptor);
// layer id is uuid used to track styles/layers in mapbox
@ -259,10 +265,16 @@ export class AbstractLayer implements ILayer {
}
async getAttributions(): Promise<Attribution[]> {
if (!this.hasErrors()) {
return await this.getSource().getAttributions();
if (this.hasErrors() || !this.isVisible()) {
return [];
}
return [];
const attributionProvider = this.getSource().getAttributionProvider();
if (attributionProvider) {
return attributionProvider();
}
return this._descriptor.attribution !== undefined ? [this._descriptor.attribution] : [];
}
getStyleForEditing(): IStyle {

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
import { Feature } from 'geojson';
import { Adapters } from 'src/plugins/inspector/public';
import { FileLayer } from '@elastic/ems-client';
import { Attribution, ImmutableSourceProperty, SourceEditorArgs } from '../source';
import { ImmutableSourceProperty, SourceEditorArgs } from '../source';
import { AbstractVectorSource, GeoJsonWithMeta, IVectorSource } from '../vector_source';
import { SOURCE_TYPES, FIELD_ORIGIN, VECTOR_SHAPE_TYPE } from '../../../../common/constants';
import { getEmsFileLayers } from '../../../util';
@ -183,9 +183,11 @@ export class EMSFileSource extends AbstractVectorSource implements IEmsFileSourc
}
}
async getAttributions(): Promise<Attribution[]> {
const emsFileLayer = await this.getEMSFileLayer();
return emsFileLayer.getAttributions();
getAttributionProvider() {
return async () => {
const emsFileLayer = await this.getEMSFileLayer();
return emsFileLayer.getAttributions();
};
}
async getLeftJoinFields() {

View file

@ -113,13 +113,15 @@ export class EMSTMSSource extends AbstractTMSSource {
}
}
async getAttributions() {
const emsTMSService = await this._getEMSTMSService();
const markdown = emsTMSService.getMarkdownAttribution();
if (!markdown) {
return [];
}
return this.convertMarkdownLinkToObjectArr(markdown);
getAttributionProvider() {
return async () => {
const emsTMSService = await this._getEMSTMSService();
const markdown = emsTMSService.getMarkdownAttribution();
if (!markdown) {
return [];
}
return this.convertMarkdownLinkToObjectArr(markdown);
};
}
async getUrlTemplate() {

View file

@ -42,7 +42,8 @@ describe('EMSTMSSource', () => {
id: 'road_map',
});
const attributions = await emsTmsSource.getAttributions();
const attributionProvider = emsTmsSource.getAttributionProvider();
const attributions = await attributionProvider();
expect(attributions).toEqual([
{
label: 'foobar',

View file

@ -53,11 +53,13 @@ export class KibanaTilemapSource extends AbstractTMSSource {
return tilemap.url;
}
async getAttributions() {
const tilemap = getKibanaTileMap();
const markdown = _.get(tilemap, 'options.attribution', '');
const objArr = this.convertMarkdownLinkToObjectArr(markdown);
return objArr;
getAttributionProvider() {
return async () => {
const tilemap = getKibanaTileMap();
const markdown = _.get(tilemap, 'options.attribution', '');
const objArr = this.convertMarkdownLinkToObjectArr(markdown);
return objArr;
};
}
async getDisplayName() {

View file

@ -15,7 +15,7 @@ import { copyPersistentState } from '../../reducers/copy_persistent_state';
import { IField } from '../fields/field';
import { FieldFormatter, MAX_ZOOM, MIN_ZOOM } from '../../../common/constants';
import { AbstractSourceDescriptor } from '../../../common/descriptor_types';
import { AbstractSourceDescriptor, Attribution } from '../../../common/descriptor_types';
import { OnSourceChangeArgs } from '../../connected_components/layer_panel/view';
import { LICENSED_FEATURES } from '../../licensed_features';
import { PreIndexedShape } from '../../../common/elasticsearch_util';
@ -31,11 +31,6 @@ export type ImmutableSourceProperty = {
link?: string;
};
export type Attribution = {
url: string;
label: string;
};
export interface ISource {
destroy(): void;
getDisplayName(): Promise<string>;
@ -47,7 +42,7 @@ export interface ISource {
isRefreshTimerAware(): boolean;
isTimeAware(): Promise<boolean>;
getImmutableProperties(): Promise<ImmutableSourceProperty[]>;
getAttributions(): Promise<Attribution[]>;
getAttributionProvider(): (() => Promise<Attribution[]>) | null;
isESSource(): boolean;
renderSourceSettingsEditor(sourceEditorArgs: SourceEditorArgs): ReactElement<any> | null;
supportsFitToBounds(): Promise<boolean>;
@ -103,8 +98,8 @@ export class AbstractSource implements ISource {
return '';
}
async getAttributions(): Promise<Attribution[]> {
return [];
getAttributionProvider(): (() => Promise<Attribution[]>) | null {
return null;
}
isFieldAware(): boolean {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { AbstractSource, Attribution, ISource } from '../source';
import { AbstractSource, ISource } from '../source';
export interface ITMSSource extends ISource {
getUrlTemplate(): Promise<string>;
@ -13,5 +13,4 @@ export interface ITMSSource extends ISource {
export class AbstractTMSSource extends AbstractSource implements ITMSSource {
getUrlTemplate(): Promise<string>;
getAttributions(): Promise<Attribution[]>;
}

View file

@ -39,8 +39,6 @@ export class WMSCreateSourceEditor extends Component {
styleOptions: [],
selectedLayerOptions: [],
selectedStyleOptions: [],
attributionText: '',
attributionUrl: '',
};
componentDidMount() {
this._isMounted = true;
@ -51,7 +49,7 @@ export class WMSCreateSourceEditor extends Component {
}
_previewIfPossible = _.debounce(() => {
const { serviceUrl, layers, styles, attributionText, attributionUrl } = this.state;
const { serviceUrl, layers, styles } = this.state;
const sourceConfig =
serviceUrl && layers
@ -59,8 +57,6 @@ export class WMSCreateSourceEditor extends Component {
serviceUrl,
layers,
styles,
attributionText,
attributionUrl,
}
: null;
this.props.onSourceConfigChange(sourceConfig);
@ -155,15 +151,6 @@ export class WMSCreateSourceEditor extends Component {
);
};
_handleWMSAttributionChange(attributionUpdate) {
const { attributionText, attributionUrl } = this.state;
this.setState(attributionUpdate, () => {
if (attributionText && attributionUrl) {
this._previewIfPossible();
}
});
}
_renderLayerAndStyleInputs() {
if (!this.state.hasAttemptedToLoadCapabilities || this.state.isLoadingCapabilities) {
return null;
@ -245,49 +232,6 @@ export class WMSCreateSourceEditor extends Component {
);
}
_renderAttributionInputs() {
if (!this.state.layers) {
return;
}
const { attributionText, attributionUrl } = this.state;
return (
<Fragment>
<EuiFormRow
label="Attribution text"
isInvalid={attributionUrl !== '' && attributionText === ''}
error={[
i18n.translate('xpack.maps.source.wms.attributionText', {
defaultMessage: 'Attribution url must have accompanying text',
}),
]}
>
<EuiFieldText
onChange={({ target }) =>
this._handleWMSAttributionChange({ attributionText: target.value })
}
/>
</EuiFormRow>
<EuiFormRow
label="Attribution link"
isInvalid={attributionText !== '' && attributionUrl === ''}
error={[
i18n.translate('xpack.maps.source.wms.attributionLink', {
defaultMessage: 'Attribution text must have an accompanying link',
}),
]}
>
<EuiFieldText
onChange={({ target }) =>
this._handleWMSAttributionChange({ attributionUrl: target.value })
}
/>
</EuiFormRow>
</Fragment>
);
}
render() {
return (
<EuiPanel>
@ -302,8 +246,6 @@ export class WMSCreateSourceEditor extends Component {
{this._renderGetCapabilitiesButton()}
{this._renderLayerAndStyleInputs()}
{this._renderAttributionInputs()}
</EuiPanel>
);
}

View file

@ -19,14 +19,12 @@ export const sourceTitle = i18n.translate('xpack.maps.source.wmsTitle', {
export class WMSSource extends AbstractTMSSource {
static type = SOURCE_TYPES.WMS;
static createDescriptor({ serviceUrl, layers, styles, attributionText, attributionUrl }) {
static createDescriptor({ serviceUrl, layers, styles }) {
return {
type: WMSSource.type,
serviceUrl,
layers,
styles,
attributionText,
attributionUrl,
};
}
@ -53,20 +51,6 @@ export class WMSSource extends AbstractTMSSource {
return this._descriptor.serviceUrl;
}
getAttributions() {
const { attributionText, attributionUrl } = this._descriptor;
const attributionComplete = !!attributionText && !!attributionUrl;
return attributionComplete
? [
{
url: attributionUrl,
label: attributionText,
},
]
: [];
}
getUrlTemplate() {
const client = new WmsClient({ serviceUrl: this._descriptor.serviceUrl });
return client.getUrlTemplate(this._descriptor.layers, this._descriptor.styles || '');

View file

@ -1,182 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`attribution validation should provide no validation errors when attribution text and attribution url are provided 1`] = `
<EuiPanel>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label="Url"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
placeholder="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution url must have accompanying text",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Attribution text"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value="myAttribtionLabel"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution text must have an accompanying link",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Attribution link"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value="http://mySource"
/>
</EuiFormRow>
</EuiPanel>
`;
exports[`attribution validation should provide validation error when attribution text is provided without attribution url 1`] = `
<EuiPanel>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label="Url"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
placeholder="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution url must have accompanying text",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Attribution text"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value="myAttribtionLabel"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution text must have an accompanying link",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={true}
label="Attribution link"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value=""
/>
</EuiFormRow>
</EuiPanel>
`;
exports[`attribution validation should provide validation error when attribution url is provided without attribution text 1`] = `
<EuiPanel>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label="Url"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
placeholder="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution url must have accompanying text",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={true}
label="Attribution text"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution text must have an accompanying link",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Attribution link"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value="http://mySource"
/>
</EuiFormRow>
</EuiPanel>
`;
exports[`should render 1`] = `
<EuiPanel>
<EuiFormRow
@ -193,45 +16,5 @@ exports[`should render 1`] = `
placeholder="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution url must have accompanying text",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Attribution text"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={
Array [
"Attribution text must have an accompanying link",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Attribution link"
labelType="label"
>
<EuiFieldText
onChange={[Function]}
value=""
/>
</EuiFormRow>
</EuiPanel>
`;

View file

@ -15,24 +15,3 @@ test('should render', () => {
const component = shallow(<XYZTMSEditor onSourceConfigChange={onSourceConfigChange} />);
expect(component).toMatchSnapshot();
});
describe('attribution validation', () => {
test('should provide validation error when attribution text is provided without attribution url', () => {
const component = shallow(<XYZTMSEditor onSourceConfigChange={onSourceConfigChange} />);
component.setState({ attributionText: 'myAttribtionLabel' });
expect(component).toMatchSnapshot();
});
test('should provide validation error when attribution url is provided without attribution text', () => {
const component = shallow(<XYZTMSEditor onSourceConfigChange={onSourceConfigChange} />);
component.setState({ attributionUrl: 'http://mySource' });
expect(component).toMatchSnapshot();
});
test('should provide no validation errors when attribution text and attribution url are provided', () => {
const component = shallow(<XYZTMSEditor onSourceConfigChange={onSourceConfigChange} />);
component.setState({ attributionText: 'myAttribtionLabel' });
component.setState({ attributionUrl: 'http://mySource' });
expect(component).toMatchSnapshot();
});
});

View file

@ -10,12 +10,9 @@
import React, { Component, ChangeEvent } from 'react';
import _ from 'lodash';
import { EuiFormRow, EuiFieldText, EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export type XYZTMSSourceConfig = {
urlTemplate: string;
attributionText: string;
attributionUrl: string;
};
interface Props {
@ -24,29 +21,19 @@ interface Props {
interface State {
url: string;
attributionText: string;
attributionUrl: string;
}
export class XYZTMSEditor extends Component<Props, State> {
state = {
url: '',
attributionText: '',
attributionUrl: '',
};
_previewLayer = _.debounce(() => {
const { url, attributionText, attributionUrl } = this.state;
const { url } = this.state;
const isUrlValid =
url.indexOf('{x}') >= 0 && url.indexOf('{y}') >= 0 && url.indexOf('{z}') >= 0;
const sourceConfig = isUrlValid
? {
urlTemplate: url,
attributionText,
attributionUrl,
}
: null;
const sourceConfig = isUrlValid ? { urlTemplate: url } : null;
this.props.onSourceConfigChange(sourceConfig);
}, 500);
@ -54,16 +41,7 @@ export class XYZTMSEditor extends Component<Props, State> {
this.setState({ url: event.target.value }, this._previewLayer);
};
_onAttributionTextChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ attributionText: event.target.value }, this._previewLayer);
};
_onAttributionUrlChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ attributionUrl: event.target.value }, this._previewLayer);
};
render() {
const { attributionText, attributionUrl } = this.state;
return (
<EuiPanel>
<EuiFormRow label="Url">
@ -72,32 +50,6 @@ export class XYZTMSEditor extends Component<Props, State> {
onChange={this._onUrlChange}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.xyztmssource.attributionTextLabel', {
defaultMessage: 'Attribution text',
})}
isInvalid={attributionUrl !== '' && attributionText === ''}
error={[
i18n.translate('xpack.maps.xyztmssource.attributionText', {
defaultMessage: 'Attribution url must have accompanying text',
}),
]}
>
<EuiFieldText value={attributionText} onChange={this._onAttributionTextChange} />
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.xyztmssource.attributionLinkLabel', {
defaultMessage: 'Attribution link',
})}
isInvalid={attributionText !== '' && attributionUrl === ''}
error={[
i18n.translate('xpack.maps.xyztmssource.attributionLink', {
defaultMessage: 'Attribution text must have an accompanying link',
}),
]}
>
<EuiFieldText value={attributionUrl} onChange={this._onAttributionUrlChange} />
</EuiFormRow>
</EuiPanel>
);
}

View file

@ -11,7 +11,7 @@ import { SOURCE_TYPES } from '../../../../common/constants';
import { registerSource } from '../source_registry';
import { AbstractTMSSource } from '../tms_source';
import { XYZTMSSourceDescriptor } from '../../../../common/descriptor_types';
import { Attribution, ImmutableSourceProperty } from '../source';
import { ImmutableSourceProperty } from '../source';
import { XYZTMSSourceConfig } from './xyz_tms_editor';
export const sourceTitle = i18n.translate('xpack.maps.source.ems_xyzTitle', {
@ -23,16 +23,10 @@ export class XYZTMSSource extends AbstractTMSSource {
readonly _descriptor: XYZTMSSourceDescriptor;
static createDescriptor({
urlTemplate,
attributionText,
attributionUrl,
}: XYZTMSSourceConfig): XYZTMSSourceDescriptor {
static createDescriptor({ urlTemplate }: XYZTMSSourceConfig): XYZTMSSourceDescriptor {
return {
type: XYZTMSSource.type,
urlTemplate,
attributionText,
attributionUrl,
};
}
@ -52,19 +46,6 @@ export class XYZTMSSource extends AbstractTMSSource {
return this._descriptor.urlTemplate;
}
async getAttributions(): Promise<Attribution[]> {
const { attributionText, attributionUrl } = this._descriptor;
const attributionComplete = !!attributionText && !!attributionUrl;
return attributionComplete
? [
{
url: attributionUrl as string,
label: attributionText as string,
},
]
: [];
}
async getUrlTemplate(): Promise<string> {
return this._descriptor.urlTemplate;
}

View file

@ -1,4 +1,5 @@
@import 'layer_panel';
@import 'layer_settings/index';
@import 'filter_editor/filter_editor';
@import 'join_editor/resources/join';
@import 'style_settings/style_settings';

View file

@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Should render add form row when attribution not provided 1`] = `
<fieldset
aria-labelledby="mapsLayerSettingsAttributionLegend"
>
<div
className="mapAttributionFormRow"
>
<legend
className="mapAttributionFormRow__legend"
id="mapsLayerSettingsAttributionLegend"
>
Attribution
</legend>
<div
className="mapAttributionFormRow__field"
>
<AttributionPopover
label=""
onChange={[Function]}
popoverButtonAriaLabel="Add attribution"
popoverButtonClassName="mapAttributionFormRow__addButton"
popoverButtonIcon="plusInCircleFilled"
popoverButtonLabel="Add attribution"
url=""
/>
</div>
</div>
</fieldset>
`;
exports[`Should render edit form row when attribution not provided 1`] = `
<fieldset
aria-labelledby="mapsLayerSettingsAttributionLegend"
>
<div
className="mapAttributionFormRow"
>
<legend
className="mapAttributionFormRow__legend"
id="mapsLayerSettingsAttributionLegend"
>
Attribution
</legend>
<div
className="mapAttributionFormRow__field"
>
<EuiPanel
color="subdued"
paddingSize="s"
>
<EuiLink
color="text"
href="url1"
target="_blank"
>
label1
</EuiLink>
</EuiPanel>
<div
className="mapAttributionFormRow__buttons"
>
<AttributionPopover
label="label1"
onChange={[Function]}
popoverButtonAriaLabel="Edit attribution"
popoverButtonIcon="pencil"
popoverButtonLabel="Edit"
url="url1"
/>
<EuiButtonEmpty
aria-label="Clear attribution"
color="danger"
iconType="trash"
onClick={[Function]}
size="xs"
>
<FormattedMessage
defaultMessage="Clear"
id="xpack.maps.attribution.clearBtnLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</div>
</div>
</div>
</fieldset>
`;
exports[`Should render null when layer source has attribution provider 1`] = `""`;

View file

@ -0,0 +1,27 @@
.mapAttributionFormRow {
display: flex;
margin: $euiSizeS 0;
}
.mapAttributionFormRow__legend {
@include euiFormLabel;
display: inline-flex;
width: calc(33% - #{$euiSizeS});
height: $euiSizeXL;
align-items: center;
margin-right: $euiSizeS;
}
.mapAttributionFormRow__field {
width: 67%;
}
.mapAttributionFormRow__addButton {
margin-top: $euiSizeXS;
}
.mapAttributionFormRow__buttons {
display: flex;
justify-content: flex-end;
margin-top: $euiSizeXS;
}

View file

@ -0,0 +1,3 @@
.mapAttributionPopover {
width: $euiSizeXXL * 12;
}

View file

@ -0,0 +1,2 @@
@import 'attribution_form_row';
@import 'attribution_popover';

View file

@ -0,0 +1,78 @@
/*
* 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 { shallow } from 'enzyme';
import { LayerDescriptor } from '../../../../common/descriptor_types';
import { ILayer } from '../../../classes/layers/layer';
import { ISource } from '../../../classes/sources/source';
import { AttributionFormRow } from './attribution_form_row';
const defaultProps = {
onChange: () => {},
};
test('Should render null when layer source has attribution provider', () => {
const sourceMock = ({
getAttributionProvider: () => {
return async () => {
return [{ url: 'url1', label: 'label1' }];
};
},
} as unknown) as ISource;
const layerMock = ({
getSource: () => {
return sourceMock;
},
} as unknown) as ILayer;
const component = shallow(<AttributionFormRow {...defaultProps} layer={layerMock} />);
expect(component).toMatchSnapshot();
});
test('Should render add form row when attribution not provided', () => {
const sourceMock = ({
getAttributionProvider: () => {
return null;
},
} as unknown) as ISource;
const layerMock = ({
getSource: () => {
return sourceMock;
},
getDescriptor: () => {
return ({} as unknown) as LayerDescriptor;
},
} as unknown) as ILayer;
const component = shallow(<AttributionFormRow {...defaultProps} layer={layerMock} />);
expect(component).toMatchSnapshot();
});
test('Should render edit form row when attribution not provided', () => {
const sourceMock = ({
getAttributionProvider: () => {
return null;
},
} as unknown) as ISource;
const layerMock = ({
getSource: () => {
return sourceMock;
},
getDescriptor: () => {
return ({
attribution: {
url: 'url1',
label: 'label1',
},
} as unknown) as LayerDescriptor;
},
} as unknown) as ILayer;
const component = shallow(<AttributionFormRow {...defaultProps} layer={layerMock} />);
expect(component).toMatchSnapshot();
});

View file

@ -0,0 +1,100 @@
/*
* 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 { EuiButtonEmpty, EuiLink, EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Attribution } from '../../../../common/descriptor_types';
import { ILayer } from '../../../classes/layers/layer';
import { AttributionPopover } from './attribution_popover';
interface Props {
layer: ILayer;
onChange: (attribution?: Attribution) => void;
}
export function AttributionFormRow(props: Props) {
function renderAttribution() {
const layerDescriptor = props.layer.getDescriptor();
return (
<fieldset aria-labelledby="mapsLayerSettingsAttributionLegend">
<div className="mapAttributionFormRow">
<legend id="mapsLayerSettingsAttributionLegend" className="mapAttributionFormRow__legend">
{i18n.translate('xpack.maps.layerSettings.attributionLegend', {
defaultMessage: 'Attribution',
})}
</legend>
{layerDescriptor.attribution === undefined ? (
<div className="mapAttributionFormRow__field">
<AttributionPopover
onChange={props.onChange}
popoverButtonIcon="plusInCircleFilled"
popoverButtonLabel={i18n.translate('xpack.maps.attribution.addBtnLabel', {
defaultMessage: 'Add attribution',
})}
popoverButtonAriaLabel={i18n.translate('xpack.maps.attribution.addBtnAriaLabel', {
defaultMessage: 'Add attribution',
})}
popoverButtonClassName="mapAttributionFormRow__addButton"
label={''}
url={''}
/>
</div>
) : (
<div className="mapAttributionFormRow__field">
<EuiPanel color="subdued" paddingSize="s">
<EuiLink color="text" href={layerDescriptor.attribution.url} target="_blank">
{layerDescriptor.attribution.label}
</EuiLink>
</EuiPanel>
<div className="mapAttributionFormRow__buttons">
<AttributionPopover
onChange={props.onChange}
popoverButtonIcon="pencil"
popoverButtonAriaLabel={i18n.translate(
'xpack.maps.attribution.editBtnAriaLabel',
{
defaultMessage: 'Edit attribution',
}
)}
popoverButtonLabel={i18n.translate('xpack.maps.attribution.editBtnLabel', {
defaultMessage: 'Edit',
})}
label={layerDescriptor.attribution.label}
url={layerDescriptor.attribution.url}
/>
<EuiButtonEmpty
onClick={() => {
props.onChange();
}}
size="xs"
iconType="trash"
color="danger"
aria-label={i18n.translate('xpack.maps.attribution.clearBtnAriaLabel', {
defaultMessage: 'Clear attribution',
})}
>
<FormattedMessage
id="xpack.maps.attribution.clearBtnLabel"
defaultMessage="Clear"
/>
</EuiButtonEmpty>
</div>
</div>
)}
</div>
</fieldset>
);
}
return props.layer.getSource().getAttributionProvider() ? null : renderAttribution();
}

View file

@ -0,0 +1,154 @@
/*
* 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, { ChangeEvent, Component } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFieldText,
EuiFormRow,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiSpacer,
EuiTextAlign,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Attribution } from '../../../../common/descriptor_types';
interface Props {
onChange: (attribution: Attribution) => void;
popoverButtonLabel: string;
popoverButtonAriaLabel: string;
popoverButtonIcon: string;
popoverButtonClassName?: string;
label: string;
url: string;
}
interface State {
isPopoverOpen: boolean;
label: string;
url: string;
}
export class AttributionPopover extends Component<Props, State> {
state: State = {
isPopoverOpen: false,
label: this.props.label,
url: this.props.url,
};
_togglePopover = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
};
_closePopover = () => {
this.setState({
isPopoverOpen: false,
});
};
_onApply = () => {
this.props.onChange({
label: this.state.label,
url: this.state.url,
});
this._closePopover();
};
_onLabelChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ label: event.target.value });
};
_onUrlChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ url: event.target.value });
};
_renderPopoverButton() {
return (
<EuiButtonEmpty
className={this.props.popoverButtonClassName}
aria-label={this.props.popoverButtonAriaLabel}
onClick={this._togglePopover}
size="xs"
iconType={this.props.popoverButtonIcon}
flush="left"
>
{this.props.popoverButtonLabel}
</EuiButtonEmpty>
);
}
_renderContent() {
const isComplete = this.state.label.length !== 0 && this.state.url.length !== 0;
const hasChanges = this.state.label !== this.props.label || this.state.url !== this.props.url;
return (
<div className="mapAttributionPopover">
<EuiPopoverTitle>
<FormattedMessage
id="xpack.maps.attribution.attributionFormLabel"
defaultMessage="Attribution"
/>
</EuiPopoverTitle>
<EuiFormRow
label={i18n.translate('xpack.maps.attribution.labelFieldLabel', {
defaultMessage: 'Label',
})}
fullWidth
>
<EuiFieldText
compressed
fullWidth
value={this.state.label}
onChange={this._onLabelChange}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.attribution.urlLabel', {
defaultMessage: 'Link',
})}
fullWidth
>
<EuiFieldText compressed fullWidth value={this.state.url} onChange={this._onUrlChange} />
</EuiFormRow>
<EuiSpacer size="xs" />
<EuiPopoverFooter>
<EuiTextAlign textAlign="right">
<EuiButton
fill
isDisabled={!isComplete || !hasChanges}
onClick={this._onApply}
size="s"
>
<FormattedMessage id="xpack.maps.attribution.applyBtnLabel" defaultMessage="Apply" />
</EuiButton>
</EuiTextAlign>
</EuiPopoverFooter>
</div>
);
}
render() {
return (
<EuiPopover
id="attributionPopover"
panelPaddingSize="s"
anchorPosition="leftCenter"
button={this._renderPopoverButton()}
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
ownFocus
>
{this._renderContent()}
</EuiPopover>
);
}
}

View file

@ -9,15 +9,21 @@ import { AnyAction, Dispatch } from 'redux';
import { connect } from 'react-redux';
import { LayerSettings } from './layer_settings';
import {
clearLayerAttribution,
setLayerAttribution,
updateLayerLabel,
updateLayerMaxZoom,
updateLayerMinZoom,
updateLayerAlpha,
updateLabelsOnTop,
} from '../../../actions';
import { Attribution } from '../../../../common/descriptor_types';
function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
return {
clearLayerAttribution: (id: string) => dispatch(clearLayerAttribution(id)),
setLayerAttribution: (id: string, attribution: Attribution) =>
dispatch(setLayerAttribution(id, attribution)),
updateLabel: (id: string, label: string) => dispatch(updateLayerLabel(id, label)),
updateMinZoom: (id: string, minZoom: number) => dispatch(updateLayerMinZoom(id, minZoom)),
updateMaxZoom: (id: string, maxZoom: number) => dispatch(updateLayerMaxZoom(id, maxZoom)),

View file

@ -17,13 +17,17 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Attribution } from '../../../../common/descriptor_types';
import { MAX_ZOOM } from '../../../../common/constants';
import { AlphaSlider } from '../../../components/alpha_slider';
import { ValidatedDualRange } from '../../../../../../../src/plugins/kibana_react/public';
import { ILayer } from '../../../classes/layers/layer';
import { AttributionFormRow } from './attribution_form_row';
export interface Props {
layer: ILayer;
clearLayerAttribution: (layerId: string) => void;
setLayerAttribution: (id: string, attribution: Attribution) => void;
updateLabel: (layerId: string, label: string) => void;
updateMinZoom: (layerId: string, minZoom: number) => void;
updateMaxZoom: (layerId: string, maxZoom: number) => void;
@ -54,6 +58,14 @@ export function LayerSettings(props: Props) {
props.updateLabelsOnTop(layerId, event.target.checked);
};
const onAttributionChange = (attribution?: Attribution) => {
if (attribution) {
props.setLayerAttribution(layerId, attribution);
} else {
props.clearLayerAttribution(layerId);
}
};
const renderZoomSliders = () => {
return (
<ValidatedDualRange
@ -127,6 +139,7 @@ export function LayerSettings(props: Props) {
{renderZoomSliders()}
<AlphaSlider alpha={props.layer.getAlpha()} onChange={onAlphaChange} />
{renderShowLabelsOnTop()}
<AttributionFormRow layer={props.layer} onChange={onAttributionChange} />
</EuiPanel>
<EuiSpacer size="s" />

View file

@ -9,7 +9,7 @@ import React, { Component, Fragment } from 'react';
import _ from 'lodash';
import { EuiText, EuiLink } from '@elastic/eui';
import classNames from 'classnames';
import { Attribution } from '../../../classes/sources/source';
import { Attribution } from '../../../../common/descriptor_types';
import { ILayer } from '../../../classes/layers/layer';
export interface Props {
@ -65,6 +65,7 @@ export class AttributionControl extends Component<Props, State> {
}
}
}
// Reflect top-to-bottom layer order as left-to-right in attribs
uniqueAttributions.reverse();
if (!_.isEqual(this.state.uniqueAttributions, uniqueAttributions)) {

View file

@ -17,6 +17,33 @@ export function findLayerById(state: MapState, layerId: string): LayerDescriptor
return state.layerList.find(({ id }) => layerId === id);
}
export function clearLayerProp(
state: MapState,
layerId: string,
propName: keyof LayerDescriptor
): MapState {
if (!layerId) {
return state;
}
const { layerList } = state;
const layerIdx = getLayerIndex(layerList, layerId);
if (layerIdx === -1) {
return state;
}
const updatedLayer = {
...layerList[layerIdx],
};
delete updatedLayer[propName];
const updatedList = [
...layerList.slice(0, layerIdx),
updatedLayer,
...layerList.slice(layerIdx + 1),
];
return { ...state, layerList: updatedList };
}
export function updateLayerInList(
state: MapState,
layerId: string,

View file

@ -14,6 +14,7 @@ import {
ADD_LAYER,
SET_LAYER_ERROR_STATUS,
ADD_WAITING_FOR_MAP_READY_LAYER,
CLEAR_LAYER_PROP,
CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST,
REMOVE_LAYER,
SET_LAYER_VISIBILITY,
@ -49,6 +50,7 @@ import {
import { getDefaultMapSettings } from './default_map_settings';
import {
clearLayerProp,
getLayerIndex,
removeTrackedLayerState,
rollbackTrackedLayerState,
@ -258,6 +260,8 @@ export function map(state: MapState = DEFAULT_MAP_STATE, action: any) {
};
case UPDATE_LAYER_PROP:
return updateLayerInList(state, action.id, action.propName, action.newValue);
case CLEAR_LAYER_PROP:
return clearLayerProp(state, action.id, action.propName);
case UPDATE_SOURCE_PROP:
return updateLayerSourceDescriptorProp(state, action.layerId, action.propName, action.value);
case SET_JOINS:

View file

@ -16,6 +16,7 @@ import { migrateJoinAggKey } from '../../common/migrations/join_agg_key';
import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds';
import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds';
import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin';
import { moveAttribution } from '../../common/migrations/move_attribution';
export const migrations = {
map: {
@ -89,6 +90,14 @@ export const migrations = {
'7.12.0': (doc) => {
const attributes = addTypeToTermJoin(doc);
return {
...doc,
attributes,
};
},
'7.14.0': (doc) => {
const attributes = moveAttribution(doc);
return {
...doc,
attributes,

View file

@ -12711,8 +12711,6 @@
"xpack.maps.source.pewPewDescription": "ソースとデスティネーションの間の集約データパスです。",
"xpack.maps.source.pewPewTitle": "ソースとデスティネーションの接続",
"xpack.maps.source.urlLabel": "Url",
"xpack.maps.source.wms.attributionLink": "属性テキストにはリンクが必要です",
"xpack.maps.source.wms.attributionText": "属性 URL にはテキストが必要です",
"xpack.maps.source.wms.getCapabilitiesButtonText": "負荷容量",
"xpack.maps.source.wms.getCapabilitiesErrorCalloutTitle": "サービスメタデータを読み込めません",
"xpack.maps.source.wms.layersHelpText": "レイヤー名のコンマ区切りのリストを使用します",
@ -12858,10 +12856,6 @@
"xpack.maps.viewControl.zoomLabel": "ズーム:",
"xpack.maps.visTypeAlias.description": "マップを作成し、複数のレイヤーとインデックスを使用して、スタイルを設定します。",
"xpack.maps.visTypeAlias.title": "マップ",
"xpack.maps.xyztmssource.attributionLink": "属性テキストにはリンクが必要です",
"xpack.maps.xyztmssource.attributionLinkLabel": "属性リンク",
"xpack.maps.xyztmssource.attributionText": "属性 URL にはテキストが必要です",
"xpack.maps.xyztmssource.attributionTextLabel": "属性テキスト",
"xpack.ml.accessDenied.description": "ML プラグインへのアクセスパーミッションがありません",
"xpack.ml.accessDenied.label": "パーミッションがありません",
"xpack.ml.accessDeniedLabel": "アクセスが拒否されました",

View file

@ -12880,8 +12880,6 @@
"xpack.maps.source.pewPewDescription": "源和目标之间的聚合数据路径",
"xpack.maps.source.pewPewTitle": "源-目标连接",
"xpack.maps.source.urlLabel": "URL",
"xpack.maps.source.wms.attributionLink": "属性文本必须附带链接",
"xpack.maps.source.wms.attributionText": "属性 url 必须附带文本",
"xpack.maps.source.wms.getCapabilitiesButtonText": "加载功能",
"xpack.maps.source.wms.getCapabilitiesErrorCalloutTitle": "无法加载服务元数据",
"xpack.maps.source.wms.layersHelpText": "使用图层名称逗号分隔列表",
@ -13027,10 +13025,6 @@
"xpack.maps.viewControl.zoomLabel": "缩放:",
"xpack.maps.visTypeAlias.description": "使用多个图层和索引创建地图并提供样式。",
"xpack.maps.visTypeAlias.title": "Maps",
"xpack.maps.xyztmssource.attributionLink": "属性文本必须附带链接",
"xpack.maps.xyztmssource.attributionLinkLabel": "属性链接",
"xpack.maps.xyztmssource.attributionText": "属性 url 必须附带文本",
"xpack.maps.xyztmssource.attributionTextLabel": "属性文本",
"xpack.ml.accessDenied.description": "您无权访问 ML 插件",
"xpack.ml.accessDenied.label": "权限不足",
"xpack.ml.accessDeniedLabel": "访问被拒绝",

View file

@ -42,7 +42,7 @@ export default function ({ getService }) {
type: 'index-pattern',
},
]);
expect(resp.body.migrationVersion).to.eql({ map: '7.12.0' });
expect(resp.body.migrationVersion).to.eql({ map: '7.14.0' });
expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true);
});
});