mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Lens] Enable read only editor mode to inspect panel's configuration (#208554)
## Summary Fixes #106553 This PR enables the Read Only editor feature for Lens panels, who will let users in read mode (no matter broader permissions) to explore the visualization configuration. Short list of changes: * Edit action tooltip now changed from `Edit {name}` into `Edit {name} configuration` * `isEditingEnabled` takes into account now also `Managed` state of both visualization and `parentApi` * A new `showConfigAction` has been created to show users without write capabilities the current Lens chart configuration * Edit inline flyout title changed to `Configuration` no matter the context (this has impact also on creation, i.e. ES|QL new panel) * Within the configuration panel the `Visualization configuration` section title has changed to `Visualization layers` * When the panel is in read-only mode a callout is shown and no editing/saving action is shown ## UX guidance Here's some guidance [inherited by @MichaelMarcialis comment](https://github.com/elastic/kibana/pull/208554#issuecomment-2666551818) about the different flows based on user permissions. **Read/write UX** * No change **Read-only UX** * The glasses icon's tooltip shows as "View visualization configuration"? * Flyout title should simply be "Configuration" * On second read, "Read only panel changes will revert after closing" sounds a bit odd. Can we change to "Read-only: Changes will be reverted on close"? Also, can we change the callout icon to glasses? * Change "Visualization configuration" accordion title to "Visualization layers". ### Screenshots **Read-only UX** If user has no write permissions the `glasses` icon is shown for the action:  And the panel is shown with the `read only` callout with no edit buttons:  For a `Managed` dashboard the behaviour is the same as above (for the user there's no difference between regular or managed dashboard, just wanted to report here both cases):   ### Checklist - [x] [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 --------- Co-authored-by: Marco Vettorello <vettorello.marco@gmail.com> Co-authored-by: Michael Marcialis <michael.l.marcialis@gmail.com>
This commit is contained in:
parent
2105648730
commit
07c7450095
27 changed files with 688 additions and 95 deletions
|
@ -59,6 +59,10 @@ export {
|
|||
type HasDisableTriggers,
|
||||
} from './interfaces/has_disable_triggers';
|
||||
export { hasEditCapabilities, type HasEditCapabilities } from './interfaces/has_edit_capabilities';
|
||||
export {
|
||||
hasReadOnlyCapabilities,
|
||||
type HasReadOnlyCapabilities,
|
||||
} from './interfaces/has_read_only_capabilities';
|
||||
export {
|
||||
apiHasExecutionContext,
|
||||
type HasExecutionContext,
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { HasTypeDisplayName } from './has_type';
|
||||
|
||||
/**
|
||||
* An interface which determines whether or not a given API offers to show the config for read only permissions.
|
||||
* In order to be read only, the api requires a show config function to execute the action
|
||||
* a getTypeDisplayName function to display to the user which type of chart is being
|
||||
* shown, and an isReadOnlyEnabled function.
|
||||
*/
|
||||
export interface HasReadOnlyCapabilities extends HasTypeDisplayName {
|
||||
onShowConfig: () => Promise<void>;
|
||||
isReadOnlyEnabled: () => { read: boolean; write: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard which determines whether or not a given API is editable.
|
||||
*/
|
||||
export const hasReadOnlyCapabilities = (root: unknown): root is HasReadOnlyCapabilities => {
|
||||
return Boolean(
|
||||
root &&
|
||||
typeof (root as HasReadOnlyCapabilities).onShowConfig === 'function' &&
|
||||
typeof (root as HasReadOnlyCapabilities).getTypeDisplayName === 'function' &&
|
||||
typeof (root as HasReadOnlyCapabilities).isReadOnlyEnabled === 'function'
|
||||
);
|
||||
};
|
|
@ -44,7 +44,7 @@ export class EditPanelAction
|
|||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return i18n.translate('presentationPanel.action.editPanel.displayName', {
|
||||
defaultMessage: 'Edit {value}',
|
||||
defaultMessage: 'Edit {value} configuration',
|
||||
values: {
|
||||
value: embeddable.getTypeDisplayName(),
|
||||
},
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
CUSTOM_TIME_RANGE_BADGE,
|
||||
} from './customize_panel_action/constants';
|
||||
import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER } from './triggers';
|
||||
import { ACTION_SHOW_CONFIG_PANEL } from './show_config_panel_action/constants';
|
||||
|
||||
export const registerActions = () => {
|
||||
uiActions.registerActionAsync(ACTION_REMOVE_PANEL, async () => {
|
||||
|
@ -47,4 +48,10 @@ export const registerActions = () => {
|
|||
return new CustomizePanelAction();
|
||||
});
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, ACTION_CUSTOMIZE_PANEL);
|
||||
|
||||
uiActions.registerActionAsync(ACTION_SHOW_CONFIG_PANEL, async () => {
|
||||
const { ShowConfigPanelAction } = await import('../panel_component/panel_module');
|
||||
return new ShowConfigPanelAction();
|
||||
});
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, ACTION_SHOW_CONFIG_PANEL);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export const ACTION_SHOW_CONFIG_PANEL = 'ACTION_SHOW_CONFIG_PANEL';
|
|
@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { PublishesViewMode, ViewMode } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject, take } from 'rxjs';
|
||||
import { ShowConfigPanelAction, ShowConfigPanelActionApi } from './show_config_panel_action';
|
||||
|
||||
describe('Show config panel action', () => {
|
||||
let action: ShowConfigPanelAction;
|
||||
let context: { embeddable: ShowConfigPanelActionApi };
|
||||
let updateViewMode: (viewMode: ViewMode) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
const viewModeSubject = new BehaviorSubject<ViewMode>('view');
|
||||
updateViewMode = jest.fn((viewMode) => viewModeSubject.next(viewMode));
|
||||
|
||||
action = new ShowConfigPanelAction();
|
||||
context = {
|
||||
embeddable: {
|
||||
viewMode$: viewModeSubject,
|
||||
onShowConfig: jest.fn(),
|
||||
isReadOnlyEnabled: jest.fn().mockReturnValue({ read: true, write: false }),
|
||||
getTypeDisplayName: jest.fn().mockReturnValue('A very fun panel type'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('is incompatible when context lacks necessary functions', async () => {
|
||||
const emptyContext = {
|
||||
embeddable: {},
|
||||
};
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('is incompatible when view mode is edit', async () => {
|
||||
(context.embeddable as PublishesViewMode).viewMode$ = new BehaviorSubject<ViewMode>('edit');
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('is incompatible when view is not enabled', async () => {
|
||||
context.embeddable.isReadOnlyEnabled = jest.fn().mockReturnValue({ read: false, write: false });
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('is incompatible when view mode is view but user has write permissions', async () => {
|
||||
context.embeddable.isReadOnlyEnabled = jest.fn().mockReturnValue({ read: true, write: true });
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('should trigger a change ont he subject when changing viewMode', (done) => {
|
||||
const subject$ = action.getCompatibilityChangesSubject(context);
|
||||
subject$?.pipe(take(1)).subscribe(() => {
|
||||
done();
|
||||
});
|
||||
updateViewMode('edit');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import {
|
||||
EmbeddableApiContext,
|
||||
CanAccessViewMode,
|
||||
apiCanAccessViewMode,
|
||||
getInheritedViewMode,
|
||||
getViewModeSubject,
|
||||
HasReadOnlyCapabilities,
|
||||
hasReadOnlyCapabilities,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import {
|
||||
Action,
|
||||
FrequentCompatibilityChangeAction,
|
||||
IncompatibleActionError,
|
||||
} from '@kbn/ui-actions-plugin/public';
|
||||
import { map } from 'rxjs';
|
||||
import { ACTION_SHOW_CONFIG_PANEL } from './constants';
|
||||
|
||||
export type ShowConfigPanelActionApi = CanAccessViewMode & HasReadOnlyCapabilities;
|
||||
|
||||
const isApiCompatible = (api: unknown | null): api is ShowConfigPanelActionApi => {
|
||||
return hasReadOnlyCapabilities(api) && apiCanAccessViewMode(api);
|
||||
};
|
||||
|
||||
export class ShowConfigPanelAction
|
||||
implements Action<EmbeddableApiContext>, FrequentCompatibilityChangeAction<EmbeddableApiContext>
|
||||
{
|
||||
public readonly type = ACTION_SHOW_CONFIG_PANEL;
|
||||
public readonly id = ACTION_SHOW_CONFIG_PANEL;
|
||||
public order = 50;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return i18n.translate('presentationPanel.action.showConfigPanel.displayName', {
|
||||
defaultMessage: 'Show {value} configuration',
|
||||
values: {
|
||||
value: embeddable.getTypeDisplayName(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public getCompatibilityChangesSubject({ embeddable }: EmbeddableApiContext) {
|
||||
return apiCanAccessViewMode(embeddable)
|
||||
? getViewModeSubject(embeddable)?.pipe(map(() => undefined))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
public couldBecomeCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return isApiCompatible(embeddable);
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'glasses';
|
||||
}
|
||||
|
||||
/**
|
||||
* The compatible check is scoped to the read only capabilities
|
||||
* Note: it does not take into account write permissions
|
||||
*/
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable) || getInheritedViewMode(embeddable) !== 'view') {
|
||||
return false;
|
||||
}
|
||||
const { read: canRead, write: canWrite } = embeddable.isReadOnlyEnabled();
|
||||
return Boolean(
|
||||
// No option to view or edit the configuration is offered for users with write permission.
|
||||
canRead && !canWrite
|
||||
);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
await embeddable.onShowConfig();
|
||||
}
|
||||
}
|
|
@ -79,7 +79,13 @@ const QUICK_ACTION_IDS = {
|
|||
'ACTION_OPEN_IN_DISCOVER',
|
||||
'ACTION_VIEW_SAVED_SEARCH',
|
||||
],
|
||||
view: ['ACTION_OPEN_IN_DISCOVER', 'ACTION_VIEW_SAVED_SEARCH', 'openInspector', 'togglePanel'],
|
||||
view: [
|
||||
'ACTION_SHOW_CONFIG_PANEL',
|
||||
'ACTION_OPEN_IN_DISCOVER',
|
||||
'ACTION_VIEW_SAVED_SEARCH',
|
||||
'openInspector',
|
||||
'togglePanel',
|
||||
],
|
||||
} as const;
|
||||
|
||||
const ALLOWED_NOTIFICATIONS = ['ACTION_FILTERS_NOTIFICATION'] as const;
|
||||
|
|
|
@ -13,4 +13,5 @@ export { RemovePanelAction } from '../panel_actions/remove_panel_action/remove_p
|
|||
export { CustomTimeRangeBadge } from '../panel_actions/customize_panel_action';
|
||||
export { CustomizePanelAction } from '../panel_actions/customize_panel_action';
|
||||
export { EditPanelAction } from '../panel_actions/edit_panel_action/edit_panel_action';
|
||||
export { ShowConfigPanelAction } from '../panel_actions/show_config_panel_action/show_config_panel_action';
|
||||
export { InspectPanelAction } from '../panel_actions/inspect_panel_action/inspect_panel_action';
|
||||
|
|
|
@ -294,6 +294,16 @@ export class DashboardPageObject extends FtrService {
|
|||
return panels.length === dragHandles.length;
|
||||
});
|
||||
}
|
||||
public async switchToViewMode() {
|
||||
this.log.debug('Switching to view mode');
|
||||
if (await this.testSubjects.exists('dashboardViewOnlyMode')) {
|
||||
await this.testSubjects.click('dashboardViewOnlyMode');
|
||||
}
|
||||
// wait until edit button appears
|
||||
await this.retry.waitFor('in view mode', async () => {
|
||||
return this.testSubjects.exists('dashboardEditMode');
|
||||
});
|
||||
}
|
||||
|
||||
public async getIsInViewMode() {
|
||||
this.log.debug('getIsInViewMode');
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services';
|
||||
import { FtrService } from '../../ftr_provider_context';
|
||||
|
||||
const ACTION_SHOW_CONFIG_PANEL_SUBJ = 'embeddablePanelAction-ACTION_SHOW_CONFIG_PANEL';
|
||||
const REMOVE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-deletePanel';
|
||||
const EDIT_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-editPanel';
|
||||
const EDIT_IN_LENS_EDITOR_DATA_TEST_SUBJ = 'navigateToLensEditorLink';
|
||||
|
@ -303,6 +304,11 @@ export class DashboardPanelActionsService extends FtrService {
|
|||
await this.expectExistsPanelAction(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ, title);
|
||||
}
|
||||
|
||||
async expectExistsShowConfigPanelAction(title = '') {
|
||||
this.log.debug('expectExistsShowConfigPanelAction');
|
||||
await this.expectExistsPanelAction(ACTION_SHOW_CONFIG_PANEL_SUBJ, title);
|
||||
}
|
||||
|
||||
async expectMissingPanelAction(testSubject: string, title = '') {
|
||||
this.log.debug('expectMissingPanelAction', testSubject, title);
|
||||
const wrapper = await this.getPanelWrapper(title);
|
||||
|
@ -331,6 +337,11 @@ export class DashboardPanelActionsService extends FtrService {
|
|||
await this.expectMissingPanelAction(REMOVE_PANEL_DATA_TEST_SUBJ, title);
|
||||
}
|
||||
|
||||
async expectMissingShowConfigPanelAction(title = '') {
|
||||
this.log.debug('expectMissingShowConfigPanelAction');
|
||||
await this.expectMissingPanelAction(ACTION_SHOW_CONFIG_PANEL_SUBJ, title);
|
||||
}
|
||||
|
||||
async getPanelHeading(title = '') {
|
||||
this.log.debug(`getPanelHeading(${title})`);
|
||||
if (!title) return await this.find.byClassName('embPanel__wrapper');
|
||||
|
|
|
@ -24516,15 +24516,12 @@
|
|||
"xpack.lens.colorMapping.techPreviewLabel": "Préversion technique",
|
||||
"xpack.lens.colorMapping.tryLabel": "Utiliser la nouvelle fonctionnalité de mapping des couleurs",
|
||||
"xpack.lens.colorSiblingFlyoutTitle": "Couleur",
|
||||
"xpack.lens.config.applyFlyoutAriaLabel": "Appliquer les modifications",
|
||||
"xpack.lens.config.applyFlyoutLabel": "Appliquer et fermer",
|
||||
"xpack.lens.config.cancelFlyoutAriaLabel": "Annuler les changements appliqués",
|
||||
"xpack.lens.config.cancelFlyoutLabel": "Annuler",
|
||||
"xpack.lens.config.configFlyoutCallout": "Affichage d'une partie limitée des champs disponibles. Ajoutez-en plus depuis le panneau de configuration.",
|
||||
"xpack.lens.config.createVisualizationLabel": "Créer la visualisation {lang}",
|
||||
"xpack.lens.config.editLabel": "Modifier la configuration",
|
||||
"xpack.lens.config.editLinkLabel": "Modifier dans Lens",
|
||||
"xpack.lens.config.editVisualizationLabel": "Modifier la visualisation {lang}",
|
||||
"xpack.lens.config.ESQLQueryResultsTitle": "Résultats de la requête ES|QL",
|
||||
"xpack.lens.config.experimentalLabelDataview.content": "L'édition en ligne offre actuellement des options de configuration limitées.",
|
||||
"xpack.lens.config.experimentalLabelDataview.title": "Version d'évaluation technique",
|
||||
|
|
|
@ -24495,15 +24495,12 @@
|
|||
"xpack.lens.colorMapping.techPreviewLabel": "テクニカルプレビュー",
|
||||
"xpack.lens.colorMapping.tryLabel": "新しい色マッピング機能を使用",
|
||||
"xpack.lens.colorSiblingFlyoutTitle": "色",
|
||||
"xpack.lens.config.applyFlyoutAriaLabel": "変更を適用",
|
||||
"xpack.lens.config.applyFlyoutLabel": "適用して閉じる",
|
||||
"xpack.lens.config.cancelFlyoutAriaLabel": "適用された変更をキャンセル",
|
||||
"xpack.lens.config.cancelFlyoutLabel": "キャンセル",
|
||||
"xpack.lens.config.configFlyoutCallout": "使用可能なフィールドの一部を表示します。構成パネルからその他の項目を追加します。",
|
||||
"xpack.lens.config.createVisualizationLabel": "{lang}ビジュアライゼーションを作成",
|
||||
"xpack.lens.config.editLabel": "構成の編集",
|
||||
"xpack.lens.config.editLinkLabel": "Lensで編集",
|
||||
"xpack.lens.config.editVisualizationLabel": "{lang}ビジュアライゼーションを編集",
|
||||
"xpack.lens.config.ESQLQueryResultsTitle": "ES|QLクエリ結果",
|
||||
"xpack.lens.config.experimentalLabelDataview.content": "現在、インライン編集では、構成オプションは限られています。",
|
||||
"xpack.lens.config.experimentalLabelDataview.title": "テクニカルプレビュー",
|
||||
|
|
|
@ -24543,15 +24543,12 @@
|
|||
"xpack.lens.colorMapping.techPreviewLabel": "技术预览",
|
||||
"xpack.lens.colorMapping.tryLabel": "使用新的颜色映射功能",
|
||||
"xpack.lens.colorSiblingFlyoutTitle": "颜色",
|
||||
"xpack.lens.config.applyFlyoutAriaLabel": "应用更改",
|
||||
"xpack.lens.config.applyFlyoutLabel": "应用并关闭",
|
||||
"xpack.lens.config.cancelFlyoutAriaLabel": "取消应用的更改",
|
||||
"xpack.lens.config.cancelFlyoutLabel": "取消",
|
||||
"xpack.lens.config.configFlyoutCallout": "正在显示有限数量的可用字段。从配置面板添加更多字段。",
|
||||
"xpack.lens.config.createVisualizationLabel": "创建 {lang} 可视化",
|
||||
"xpack.lens.config.editLabel": "编辑配置",
|
||||
"xpack.lens.config.editLinkLabel": "在 Lens 中编辑",
|
||||
"xpack.lens.config.editVisualizationLabel": "编辑 {lang} 可视化",
|
||||
"xpack.lens.config.ESQLQueryResultsTitle": "ES|QL 查询结果",
|
||||
"xpack.lens.config.experimentalLabelDataview.content": "内联编辑当前提供的配置选项数量有限。",
|
||||
"xpack.lens.config.experimentalLabelDataview.title": "技术预览",
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* 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 { screen } from '@testing-library/react';
|
||||
import { renderWithReduxStore } from '../../../mocks';
|
||||
import { FlyoutWrapper } from './flyout_wrapper';
|
||||
import type { FlyoutWrapperProps } from './types';
|
||||
|
||||
function mountFlyoutWrapper(propsOverrides: Partial<FlyoutWrapperProps> = {}) {
|
||||
const result = renderWithReduxStore(
|
||||
<FlyoutWrapper
|
||||
isInlineFlyoutVisible
|
||||
displayFlyoutHeader
|
||||
isScrollable
|
||||
isNewPanel
|
||||
isSaveable
|
||||
language={'Lens'}
|
||||
onCancel={jest.fn()}
|
||||
navigateToLensEditor={jest.fn()}
|
||||
onApply={jest.fn()}
|
||||
{...propsOverrides}
|
||||
>
|
||||
<div>Test</div>
|
||||
</FlyoutWrapper>
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
// rewrite the rerender function to work with the store wrapper
|
||||
rerender: (props: Partial<FlyoutWrapperProps>) =>
|
||||
result.rerender(
|
||||
<FlyoutWrapper
|
||||
isInlineFlyoutVisible
|
||||
displayFlyoutHeader
|
||||
isScrollable
|
||||
isNewPanel
|
||||
isSaveable
|
||||
language={'Lens'}
|
||||
onCancel={jest.fn()}
|
||||
navigateToLensEditor={jest.fn()}
|
||||
onApply={jest.fn()}
|
||||
{...propsOverrides}
|
||||
{...props}
|
||||
>
|
||||
<div>Test</div>
|
||||
</FlyoutWrapper>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Flyout wrapper', () => {
|
||||
describe('inline mode', () => {
|
||||
it('should enable edit actions if the panel is not in read only mode', async () => {
|
||||
mountFlyoutWrapper();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit in Lens' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Apply and close' })).toBeInTheDocument();
|
||||
// make sure the read only warning is not shown
|
||||
expect(
|
||||
screen.queryByText('Read-only: Changes will be reverted on close')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
it('should show a warning and avoid any edit action when in read mode', async () => {
|
||||
mountFlyoutWrapper({ isReadOnly: true });
|
||||
|
||||
expect(
|
||||
screen.queryByText('Read-only: Changes will be reverted on close')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// make sure edit actions are not shown
|
||||
expect(screen.queryByRole('button', { name: 'Edit in Lens' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Apply and close' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the only a single and consistent title no matter the context', async () => {
|
||||
const component = mountFlyoutWrapper();
|
||||
expect(screen.getByText('Configuration')).toBeInTheDocument();
|
||||
component.rerender({ isNewPanel: true });
|
||||
expect(screen.getByText('Configuration')).toBeInTheDocument();
|
||||
component.rerender({ isNewPanel: false, isReadOnly: true });
|
||||
expect(screen.getByText('Configuration')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('not inline mode', () => {
|
||||
it('should not show a warning even in read mode when not inline', async () => {
|
||||
mountFlyoutWrapper({ isReadOnly: true, isInlineFlyoutVisible: false });
|
||||
|
||||
expect(
|
||||
screen.queryByText('Read only panel changes will revert after closing')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -19,6 +19,7 @@ import {
|
|||
EuiLink,
|
||||
EuiBetaBadge,
|
||||
EuiText,
|
||||
EuiCallOut,
|
||||
} from '@elastic/eui';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import { css } from '@emotion/react';
|
||||
|
@ -37,6 +38,7 @@ export const FlyoutWrapper = ({
|
|||
onCancel,
|
||||
navigateToLensEditor,
|
||||
onApply,
|
||||
isReadOnly,
|
||||
}: FlyoutWrapperProps) => {
|
||||
return (
|
||||
<>
|
||||
|
@ -55,15 +57,9 @@ export const FlyoutWrapper = ({
|
|||
<h2>
|
||||
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
{isNewPanel
|
||||
? i18n.translate('xpack.lens.config.createVisualizationLabel', {
|
||||
defaultMessage: 'Create {lang} visualization',
|
||||
values: { lang: language },
|
||||
})
|
||||
: i18n.translate('xpack.lens.config.editVisualizationLabel', {
|
||||
defaultMessage: 'Edit {lang} visualization',
|
||||
values: { lang: language },
|
||||
})}
|
||||
{i18n.translate('xpack.lens.config.showVisualizationLabel', {
|
||||
defaultMessage: 'Configuration',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
|
@ -92,7 +88,7 @@ export const FlyoutWrapper = ({
|
|||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{navigateToLensEditor && (
|
||||
{navigateToLensEditor && !isReadOnly && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs">
|
||||
<EuiLink onClick={navigateToLensEditor} data-test-subj="navigateToLensEditorLink">
|
||||
|
@ -106,6 +102,16 @@ export const FlyoutWrapper = ({
|
|||
</EuiFlexGroup>
|
||||
</EuiFlyoutHeader>
|
||||
)}
|
||||
{isInlineFlyoutVisible && isReadOnly ? (
|
||||
<EuiCallOut
|
||||
title={i18n.translate('xpack.lens.config.readOnly', {
|
||||
defaultMessage: 'Read-only: Changes will be reverted on close',
|
||||
})}
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<EuiFlyoutBody
|
||||
className="lnsEditFlyoutBody"
|
||||
|
@ -154,23 +160,22 @@ export const FlyoutWrapper = ({
|
|||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={onApply}
|
||||
fill
|
||||
aria-label={i18n.translate('xpack.lens.config.applyFlyoutAriaLabel', {
|
||||
defaultMessage: 'Apply changes',
|
||||
})}
|
||||
disabled={Boolean(isNewPanel) ? false : !isSaveable}
|
||||
iconType="check"
|
||||
data-test-subj="applyFlyoutButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.lens.config.applyFlyoutLabel"
|
||||
defaultMessage="Apply and close"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
{isReadOnly ? null : (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={onApply}
|
||||
fill
|
||||
disabled={Boolean(isNewPanel) ? false : !isSaveable}
|
||||
iconType="check"
|
||||
data-test-subj="applyFlyoutButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.lens.config.applyFlyoutLabel"
|
||||
defaultMessage="Apply and close"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
)}
|
||||
|
|
|
@ -155,6 +155,7 @@ export async function getEditLensConfiguration(
|
|||
onApply,
|
||||
onCancel,
|
||||
hideTimeFilterInfo,
|
||||
isReadOnly,
|
||||
parentApi,
|
||||
}: EditLensConfigurationProps) => {
|
||||
if (!lensServices || !datasourceMap || !visualizationMap) {
|
||||
|
@ -226,6 +227,7 @@ export async function getEditLensConfiguration(
|
|||
onApply,
|
||||
onCancel,
|
||||
hideTimeFilterInfo,
|
||||
isReadOnly,
|
||||
parentApi,
|
||||
panelId,
|
||||
};
|
||||
|
|
|
@ -177,9 +177,7 @@ describe('LensEditConfigurationFlyout', () => {
|
|||
displayFlyoutHeader: true,
|
||||
isNewPanel: true,
|
||||
});
|
||||
expect(screen.getByTestId('inlineEditingFlyoutLabel').textContent).toBe(
|
||||
'Create ES|QL visualization'
|
||||
);
|
||||
expect(screen.getByTestId('inlineEditingFlyoutLabel').textContent).toBe('Configuration');
|
||||
});
|
||||
|
||||
it('should call the closeFlyout callback if cancel button is clicked', async () => {
|
||||
|
@ -321,7 +319,7 @@ describe('LensEditConfigurationFlyout', () => {
|
|||
// @ts-ignore
|
||||
newProps.attributes.state.datasourceStates.testDatasource = 'state';
|
||||
await renderConfigFlyout(newProps);
|
||||
expect(screen.getByRole('button', { name: /apply changes/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /apply and close/i })).toBeDisabled();
|
||||
});
|
||||
it('save button should be disabled if expression cannot be generated', async () => {
|
||||
const updateByRefInputSpy = jest.fn();
|
||||
|
@ -341,6 +339,6 @@ describe('LensEditConfigurationFlyout', () => {
|
|||
};
|
||||
|
||||
await renderConfigFlyout(newProps);
|
||||
expect(screen.getByRole('button', { name: /apply changes/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /apply and close/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -58,6 +58,7 @@ export function LensEditConfigurationFlyout({
|
|||
onApply: onApplyCallback,
|
||||
onCancel: onCancelCallback,
|
||||
hideTimeFilterInfo,
|
||||
isReadOnly,
|
||||
parentApi,
|
||||
panelId,
|
||||
}: EditConfigPanelProps) {
|
||||
|
@ -280,6 +281,7 @@ export function LensEditConfigurationFlyout({
|
|||
isScrollable
|
||||
isNewPanel={isNewPanel}
|
||||
isSaveable={isSaveable}
|
||||
isReadOnly={isReadOnly}
|
||||
>
|
||||
<LayerConfiguration
|
||||
// TODO: remove this once we support switching to any chart in Discover
|
||||
|
@ -319,6 +321,7 @@ export function LensEditConfigurationFlyout({
|
|||
isScrollable={false}
|
||||
language={textBasedMode ? getLanguageDisplayName('esql') : ''}
|
||||
isNewPanel={isNewPanel}
|
||||
isReadOnly={isReadOnly}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
css={css`
|
||||
|
@ -381,7 +384,7 @@ export function LensEditConfigurationFlyout({
|
|||
>
|
||||
<h5>
|
||||
{i18n.translate('xpack.lens.config.visualizationConfigurationLabel', {
|
||||
defaultMessage: 'Visualization configuration',
|
||||
defaultMessage: 'Visualization parameters',
|
||||
})}
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
|
|
|
@ -28,6 +28,7 @@ export interface FlyoutWrapperProps {
|
|||
onCancel?: () => void;
|
||||
onApply?: () => void;
|
||||
navigateToLensEditor?: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface EditConfigPanelProps {
|
||||
|
@ -86,6 +87,9 @@ export interface EditConfigPanelProps {
|
|||
// in cases where the embeddable is not filtered by time
|
||||
// (e.g. through unified search) set this property to true
|
||||
hideTimeFilterInfo?: boolean;
|
||||
// Lens panels allow read-only "edit" where the user can look and tweak the existing chart, without
|
||||
// persisting the changes. This is useful for dashboards where the user wants to see the configuration behind
|
||||
isReadOnly?: boolean;
|
||||
/** The dashboard api, important for creating controls from the ES|QL editor */
|
||||
parentApi?: unknown;
|
||||
}
|
||||
|
|
|
@ -16,8 +16,12 @@ import {
|
|||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ApplicationStart } from '@kbn/core/public';
|
||||
import { LensEmbeddableStartServices } from '../types';
|
||||
import { PublishesViewMode, ViewMode } from '@kbn/presentation-publishing';
|
||||
|
||||
function createEditApi(servicesOverrides: Partial<LensEmbeddableStartServices> = {}) {
|
||||
function createEditApi(
|
||||
servicesOverrides: Partial<LensEmbeddableStartServices> = {},
|
||||
parentApiOverrides: Partial<PublishesViewMode | { isManaged: boolean }> = {}
|
||||
) {
|
||||
const internalApi = getLensInternalApiMock();
|
||||
const runtimeState = getLensRuntimeStateMock();
|
||||
const api = getLensApiMock();
|
||||
|
@ -28,6 +32,11 @@ function createEditApi(servicesOverrides: Partial<LensEmbeddableStartServices> =
|
|||
}),
|
||||
...servicesOverrides,
|
||||
};
|
||||
const parentApi = {
|
||||
getAppContext: () => ({ currentAppId: 'lens' }),
|
||||
viewMode$: new BehaviorSubject('edit'),
|
||||
...parentApiOverrides,
|
||||
};
|
||||
return initializeEditApi(
|
||||
faker.string.uuid(),
|
||||
runtimeState,
|
||||
|
@ -38,34 +47,165 @@ function createEditApi(servicesOverrides: Partial<LensEmbeddableStartServices> =
|
|||
api,
|
||||
() => false, // DSL based
|
||||
services,
|
||||
{ getAppContext: () => ({ currentAppId: 'lens' }), viewMode$: new BehaviorSubject('edit') }
|
||||
parentApi
|
||||
);
|
||||
}
|
||||
|
||||
describe('edit features', () => {
|
||||
it('should be editable if visualize library privileges allow it', () => {
|
||||
const editApi = createEditApi();
|
||||
expect(editApi.api.isEditingEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be editable if visualize library privileges do not allow it', () => {
|
||||
const editApi = createEditApi({
|
||||
capabilities: {
|
||||
visualize_v2: {
|
||||
// cannot save
|
||||
save: false,
|
||||
saveQuery: true,
|
||||
// cannot see the visualization
|
||||
show: true,
|
||||
createShortUrl: true,
|
||||
},
|
||||
dashboard_v2: {
|
||||
// cannot edit in dashboard
|
||||
showWriteControls: false,
|
||||
},
|
||||
} as unknown as ApplicationStart['capabilities'],
|
||||
describe('isEditingEnabled()', () => {
|
||||
it('should be editable if visualize library privileges allow it', () => {
|
||||
const editApi = createEditApi();
|
||||
expect(editApi.api.isEditingEnabled()).toBe(true);
|
||||
// { read: false } here is expected the environment is in edit mode
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: false, write: true });
|
||||
});
|
||||
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
it('should not be editable if visualize library privileges do not allow it', () => {
|
||||
const editApi = createEditApi({
|
||||
capabilities: {
|
||||
visualize_v2: {
|
||||
// cannot save
|
||||
save: false,
|
||||
saveQuery: true,
|
||||
// can see the visualization
|
||||
show: true,
|
||||
createShortUrl: true,
|
||||
},
|
||||
dashboard_v2: {
|
||||
// cannot edit in dashboard
|
||||
showWriteControls: false,
|
||||
},
|
||||
} as unknown as ApplicationStart['capabilities'],
|
||||
});
|
||||
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
// { read: false } here is expected the environment is in edit mode
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: false, write: false });
|
||||
});
|
||||
|
||||
it("should return false if it's a managed context", () => {
|
||||
const editApi = createEditApi(undefined, {
|
||||
isManaged: true,
|
||||
});
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
// { read: false } here is expected the environment is in edit mode
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: false, write: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReadOnlyEnabled()', () => {
|
||||
it('should be read only enabled if user has edit permissions', () => {
|
||||
const editApi = createEditApi(undefined, {
|
||||
viewMode$: new BehaviorSubject('view' as ViewMode),
|
||||
});
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
// now it's in view mode, read should be true and write should be true too
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: true, write: true });
|
||||
});
|
||||
|
||||
it('should be read only enabled if edit capabilities are off', () => {
|
||||
const editApi = createEditApi(
|
||||
{
|
||||
capabilities: {
|
||||
visualize_v2: {
|
||||
// cannot save
|
||||
save: false,
|
||||
saveQuery: true,
|
||||
// can see the visualization
|
||||
show: true,
|
||||
createShortUrl: true,
|
||||
},
|
||||
dashboard_v2: {
|
||||
// cannot edit in dashboard
|
||||
showWriteControls: false,
|
||||
},
|
||||
} as unknown as ApplicationStart['capabilities'],
|
||||
},
|
||||
{
|
||||
viewMode$: new BehaviorSubject('view' as ViewMode),
|
||||
}
|
||||
);
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: true, write: false });
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be read only disabled if show capabilities are off', () => {
|
||||
const editApi = createEditApi(
|
||||
{
|
||||
capabilities: {
|
||||
visualize_v2: {
|
||||
// cannot save
|
||||
save: false,
|
||||
saveQuery: true,
|
||||
// cannot see the visualization
|
||||
show: false,
|
||||
createShortUrl: true,
|
||||
},
|
||||
dashboard_v2: {
|
||||
// cannot edit in dashboard
|
||||
showWriteControls: false,
|
||||
},
|
||||
} as unknown as ApplicationStart['capabilities'],
|
||||
},
|
||||
{
|
||||
viewMode$: new BehaviorSubject('view' as ViewMode),
|
||||
}
|
||||
);
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: false, write: false });
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be read only enabled but with no write flag is dashboard write is disabled', () => {
|
||||
const editApi = createEditApi(
|
||||
{
|
||||
capabilities: {
|
||||
visualize_v2: {
|
||||
// can save a visualization but not edit in dashboard (see below)
|
||||
save: true,
|
||||
saveQuery: true,
|
||||
// can see the visualization
|
||||
show: true,
|
||||
createShortUrl: true,
|
||||
},
|
||||
dashboard_v2: {
|
||||
// cannot edit in dashboard
|
||||
showWriteControls: false,
|
||||
},
|
||||
} as unknown as ApplicationStart['capabilities'],
|
||||
},
|
||||
{
|
||||
viewMode$: new BehaviorSubject('view' as ViewMode),
|
||||
}
|
||||
);
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: true, write: false });
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should enable read only mode on a managed context even if user has write permissions', () => {
|
||||
const editApi = createEditApi(
|
||||
{
|
||||
capabilities: {
|
||||
visualize_v2: {
|
||||
// can save a visualization but not edit in dashboard (see below)
|
||||
save: true,
|
||||
saveQuery: true,
|
||||
// can see the visualization
|
||||
show: true,
|
||||
createShortUrl: true,
|
||||
},
|
||||
dashboard_v2: {
|
||||
// can edit in dashboard
|
||||
showWriteControls: true,
|
||||
},
|
||||
} as unknown as ApplicationStart['capabilities'],
|
||||
},
|
||||
{
|
||||
viewMode$: new BehaviorSubject('view' as ViewMode),
|
||||
isManaged: true,
|
||||
}
|
||||
);
|
||||
expect(editApi.api.isReadOnlyEnabled()).toEqual({ read: true, write: false });
|
||||
expect(editApi.api.isEditingEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,9 +7,11 @@
|
|||
|
||||
import {
|
||||
HasEditCapabilities,
|
||||
HasReadOnlyCapabilities,
|
||||
HasSupportedTriggers,
|
||||
PublishesDisabledActionIds,
|
||||
PublishesViewMode,
|
||||
PublishingSubject,
|
||||
ViewMode,
|
||||
apiHasAppContext,
|
||||
apiPublishesDisabledActionIds,
|
||||
|
@ -53,6 +55,18 @@ function getSupportedTriggers(
|
|||
};
|
||||
}
|
||||
|
||||
function isReadOnly(viewMode$: PublishingSubject<ViewMode>) {
|
||||
return viewMode$.getValue() === 'view';
|
||||
}
|
||||
|
||||
function isEditMode(viewMode$: PublishingSubject<ViewMode>) {
|
||||
return viewMode$.getValue() === 'edit';
|
||||
}
|
||||
|
||||
function hasManagedApi(api: unknown): api is { isManaged: boolean } {
|
||||
return Boolean(api && typeof (api as { isManaged?: boolean }).isManaged === 'boolean');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the edit API for the embeddable
|
||||
**/
|
||||
|
@ -71,12 +85,16 @@ export function initializeEditApi(
|
|||
api: HasSupportedTriggers &
|
||||
PublishesDisabledActionIds &
|
||||
HasEditCapabilities &
|
||||
HasReadOnlyCapabilities &
|
||||
PublishesViewMode & { uuid: string };
|
||||
comparators: {};
|
||||
serialize: () => {};
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const supportedTriggers = getSupportedTriggers(getState, startDependencies.visualizationMap);
|
||||
const isManaged = (currentState: LensRuntimeState) => {
|
||||
return currentState.managed || (hasManagedApi(parentApi) ? parentApi.isManaged : false);
|
||||
};
|
||||
|
||||
const isESQLModeEnabled = () => uiSettings.get(ENABLE_ESQL);
|
||||
|
||||
|
@ -122,6 +140,8 @@ export function initializeEditApi(
|
|||
const panelManagementApi = setupPanelManagement(uuid, parentApi, {
|
||||
isNewlyCreated$: internalApi.isNewlyCreated$,
|
||||
setAsCreated: internalApi.setAsCreated,
|
||||
isReadOnly: () => isReadOnly(viewMode$),
|
||||
canEdit: () => isEditMode(viewMode$),
|
||||
});
|
||||
|
||||
const updateState = (newState: Pick<LensRuntimeState, 'attributes' | 'savedObjectId'>) => {
|
||||
|
@ -154,6 +174,7 @@ export function initializeEditApi(
|
|||
};
|
||||
};
|
||||
|
||||
// This will handle both edit and read only mode based on the view mode
|
||||
const openInlineEditor = prepareInlineEditPanel(
|
||||
initialState,
|
||||
getStateWithInjectedFilters,
|
||||
|
@ -173,11 +194,15 @@ export function initializeEditApi(
|
|||
const { uiSettings, capabilities, data } = startDependencies;
|
||||
|
||||
const canEdit = () => {
|
||||
if (viewMode$.getValue() !== 'edit') {
|
||||
if (!isEditMode(viewMode$)) {
|
||||
return false;
|
||||
}
|
||||
const currentState = getState();
|
||||
// check if it's in ES|QL mode
|
||||
if (isTextBasedLanguage(getState()) && !isESQLModeEnabled()) {
|
||||
if (isTextBasedLanguage(currentState) && !isESQLModeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
if (isManaged(currentState)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
|
@ -188,11 +213,39 @@ export function initializeEditApi(
|
|||
);
|
||||
};
|
||||
|
||||
const canShowConfig = () => {
|
||||
return isReadOnly(viewMode$) && Boolean(capabilities.visualize_v2.show);
|
||||
};
|
||||
|
||||
// this will force the embeddable to toggle the inline editing feature
|
||||
const canEditInline = apiPublishesInlineEditingCapabilities(parentApi)
|
||||
? parentApi.canEditInline
|
||||
: true;
|
||||
|
||||
const openConfigurationPanel = async (
|
||||
{ showOnly }: { showOnly: boolean } = { showOnly: false }
|
||||
) => {
|
||||
// save the initial state in case it needs to revert later on
|
||||
const firstState = getState();
|
||||
|
||||
const rootEmbeddable = parentApi;
|
||||
const overlayTracker = tracksOverlays(rootEmbeddable) ? rootEmbeddable : undefined;
|
||||
const ConfigPanel = await openInlineEditor({
|
||||
// restore the first state found when the panel opened
|
||||
onCancel: () => updateState({ ...firstState }),
|
||||
// the getState() here contains the wrong filters references
|
||||
// but the input attributes are correct as openInlineEditor() handler is using
|
||||
// the getStateWithInjectedFilters() function
|
||||
onApply: showOnly
|
||||
? noop
|
||||
: (attributes: LensRuntimeState['attributes']) =>
|
||||
updateState({ ...getState(), attributes }),
|
||||
});
|
||||
if (ConfigPanel) {
|
||||
mountInlineEditPanel(ConfigPanel, startDependencies.coreStart, overlayTracker, uuid);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
comparators: { disabledActionIds$: [disabledActionIds$, setDisabledActionIds] },
|
||||
serialize: emptySerializer,
|
||||
|
@ -202,7 +255,7 @@ export function initializeEditApi(
|
|||
viewMode$,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('xpack.lens.embeddableDisplayName', {
|
||||
defaultMessage: 'Lens',
|
||||
defaultMessage: 'visualization',
|
||||
}),
|
||||
supportedTriggers,
|
||||
disabledActionIds$,
|
||||
|
@ -228,23 +281,7 @@ export function initializeEditApi(
|
|||
return navigateFn();
|
||||
}
|
||||
|
||||
// save the initial state in case it needs to revert later on
|
||||
const firstState = getState();
|
||||
|
||||
const rootEmbeddable = parentApi;
|
||||
const overlayTracker = tracksOverlays(rootEmbeddable) ? rootEmbeddable : undefined;
|
||||
const ConfigPanel = await openInlineEditor({
|
||||
// the getState() here contains the wrong filters references
|
||||
// but the input attributes are correct as openInlineEditor() handler is using
|
||||
// the getStateWithInjectedFilters() function
|
||||
onApply: (attributes: LensRuntimeState['attributes']) =>
|
||||
updateState({ ...getState(), attributes }),
|
||||
// restore the first state found when the panel opened
|
||||
onCancel: () => updateState({ ...firstState }),
|
||||
});
|
||||
if (ConfigPanel) {
|
||||
mountInlineEditPanel(ConfigPanel, startDependencies.coreStart, overlayTracker, uuid);
|
||||
}
|
||||
openConfigurationPanel({ showOnly: false });
|
||||
},
|
||||
/**
|
||||
* Check everything here: user/app permissions and the current inline editing state
|
||||
|
@ -257,6 +294,18 @@ export function initializeEditApi(
|
|||
panelManagementApi.isEditingEnabled()
|
||||
);
|
||||
},
|
||||
isReadOnlyEnabled: () => {
|
||||
return {
|
||||
read: Boolean(parentApi && apiHasAppContext(parentApi) && canShowConfig()),
|
||||
write: Boolean(capabilities.dashboard_v2?.showWriteControls && !isManaged(getState())),
|
||||
};
|
||||
},
|
||||
onShowConfig: async () => {
|
||||
if (!parentApi || !apiHasAppContext(parentApi)) {
|
||||
return;
|
||||
}
|
||||
openConfigurationPanel({ showOnly: true });
|
||||
},
|
||||
getEditHref: async () => {
|
||||
if (!parentApi || !apiHasAppContext(parentApi)) {
|
||||
return;
|
||||
|
|
|
@ -11,6 +11,8 @@ import { PublishingSubject } from '@kbn/presentation-publishing';
|
|||
import { LensRuntimeState } from '../types';
|
||||
|
||||
export interface PanelManagementApi {
|
||||
// distinguish show and edit capabilities for read only mode
|
||||
canShowConfig: () => boolean;
|
||||
isEditingEnabled: () => boolean;
|
||||
isNewPanel: () => boolean;
|
||||
onStopEditing: (isCancel: boolean, state: LensRuntimeState | undefined) => void;
|
||||
|
@ -22,15 +24,20 @@ export function setupPanelManagement(
|
|||
{
|
||||
isNewlyCreated$,
|
||||
setAsCreated,
|
||||
isReadOnly,
|
||||
canEdit,
|
||||
}: {
|
||||
isNewlyCreated$: PublishingSubject<boolean>;
|
||||
setAsCreated: () => void;
|
||||
isReadOnly: () => boolean;
|
||||
canEdit: () => boolean;
|
||||
}
|
||||
): PanelManagementApi {
|
||||
const isEditing$ = new BehaviorSubject(false);
|
||||
|
||||
return {
|
||||
isEditingEnabled: () => true,
|
||||
canShowConfig: isReadOnly,
|
||||
isEditingEnabled: canEdit,
|
||||
isNewPanel: () => isNewlyCreated$.getValue(),
|
||||
onStopEditing: (isCancel: boolean = false, state: LensRuntimeState | undefined) => {
|
||||
isEditing$.next(false);
|
||||
|
|
|
@ -92,6 +92,11 @@ export function prepareInlineEditPanel(
|
|||
if (attributes?.visualizationType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canNavigateToFullEditor =
|
||||
!isTextBasedLanguage(currentState) &&
|
||||
panelManagementApi.isEditingEnabled() &&
|
||||
navigateToLensEditor;
|
||||
return (
|
||||
<Component
|
||||
attributes={attributes}
|
||||
|
@ -104,7 +109,7 @@ export function prepareInlineEditPanel(
|
|||
panelId={uuid}
|
||||
savedObjectId={currentState.savedObjectId}
|
||||
navigateToLensEditor={
|
||||
!isTextBasedLanguage(currentState) && navigateToLensEditor
|
||||
canNavigateToFullEditor
|
||||
? navigateToLensEditor(
|
||||
new EmbeddableStateTransfer(
|
||||
coreStart.application.navigateToApp,
|
||||
|
@ -134,6 +139,7 @@ export function prepareInlineEditPanel(
|
|||
}
|
||||
}}
|
||||
hideTimeFilterInfo={hideTimeFilterInfo}
|
||||
isReadOnly={panelManagementApi.canShowConfig() && !panelManagementApi.isEditingEnabled()}
|
||||
parentApi={parentApi}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -12,7 +12,6 @@ import { ExpressionWrapper } from '../expression_wrapper';
|
|||
import { LensInternalApi } from '../types';
|
||||
import { UserMessages } from '../user_messages/container';
|
||||
import { useMessages, useDispatcher } from './hooks';
|
||||
import { getViewMode } from '../helper';
|
||||
import { addLog } from '../logger';
|
||||
|
||||
export function LensEmbeddableComponent({
|
||||
|
@ -34,16 +33,16 @@ export function LensEmbeddableComponent({
|
|||
blockingErrors,
|
||||
// has the render completed?
|
||||
hasRendered,
|
||||
// has view mode changed?
|
||||
latestViewMode,
|
||||
] = useBatchedPublishingSubjects(
|
||||
internalApi.expressionParams$,
|
||||
internalApi.renderCount$,
|
||||
internalApi.validationMessages$,
|
||||
api.rendered$,
|
||||
// listen to view change mode but do not use its actual value
|
||||
// just call the Lens API to know whether it's in edit mode
|
||||
api.viewMode$
|
||||
);
|
||||
const canEdit = Boolean(api.isEditingEnabled?.() && getViewMode(latestViewMode) === 'edit');
|
||||
const canEdit = Boolean(api.isEditingEnabled?.());
|
||||
|
||||
const [warningOrErrors, infoMessages] = useMessages(internalApi);
|
||||
|
||||
|
|
|
@ -63,6 +63,8 @@ export async function executeEditEmbeddableAction({
|
|||
const panelManagementApi = setupPanelManagement(uuid, container, {
|
||||
isNewlyCreated$,
|
||||
setAsCreated: () => isNewlyCreated$.next(false),
|
||||
isReadOnly: () => false,
|
||||
canEdit: () => true,
|
||||
});
|
||||
const openInlineEditor = prepareInlineEditPanel(
|
||||
{ attributes },
|
||||
|
|
|
@ -7,20 +7,45 @@
|
|||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const { dashboard, visualize, lens, timeToVisualize } = getPageObjects([
|
||||
const { dashboard, visualize, lens, timeToVisualize, security } = getPageObjects([
|
||||
'dashboard',
|
||||
'visualize',
|
||||
'lens',
|
||||
'timeToVisualize',
|
||||
'security',
|
||||
]);
|
||||
const find = getService('find');
|
||||
const log = getService('log');
|
||||
const securityService = getService('security');
|
||||
const listingTable = getService('listingTable');
|
||||
const dashboardPanelActions = getService('dashboardPanelActions');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const elasticChart = getService('elasticChart');
|
||||
const toastsService = getService('toasts');
|
||||
|
||||
const loginWithReadOnlyUser = async () => {
|
||||
await securityService.user.create('global_dashboard_read_privileges_user', {
|
||||
password: 'global_dashboard_read_privileges_user-password',
|
||||
roles: ['viewer'],
|
||||
full_name: 'test user',
|
||||
});
|
||||
|
||||
await security.forceLogout();
|
||||
|
||||
await security.login(
|
||||
'global_dashboard_read_privileges_user',
|
||||
'global_dashboard_read_privileges_user-password',
|
||||
{
|
||||
expectSpaceSelector: false,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const logoutAndDeleteReadOnlyUser = async () => {
|
||||
await security.forceLogout();
|
||||
await securityService.user.delete('global_dashboard_read_privileges_user');
|
||||
};
|
||||
|
||||
const createNewLens = async () => {
|
||||
await visualize.navigateToNewVisualization();
|
||||
await visualize.clickVisType('lens');
|
||||
|
@ -306,5 +331,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
await timeToVisualize.resetNewDashboard();
|
||||
});
|
||||
|
||||
it('should not allow the show config action for a user with write permissions', async () => {
|
||||
// create a chart and save the dashboard
|
||||
await createNewLens();
|
||||
// save it and add to a new dashboard
|
||||
await lens.save('New Lens from Modal', false, false, false, 'new');
|
||||
// now save the dashboard
|
||||
await dashboard.saveDashboard('My read only testing dashboard', { saveAsNew: true });
|
||||
|
||||
await dashboardPanelActions.expectMissingShowConfigPanelAction();
|
||||
// switch to view and check again
|
||||
await dashboard.switchToViewMode();
|
||||
await dashboardPanelActions.expectMissingShowConfigPanelAction();
|
||||
});
|
||||
|
||||
it('should allow the show config action for a user without write permissions', async () => {
|
||||
// setup a read only user and login with it
|
||||
await loginWithReadOnlyUser();
|
||||
|
||||
// open the previous dashboard
|
||||
await dashboard.navigateToApp();
|
||||
await dashboard.loadSavedDashboard('My read only testing dashboard');
|
||||
// now check the action is there
|
||||
await dashboardPanelActions.expectExistsShowConfigPanelAction();
|
||||
// clean up
|
||||
await logoutAndDeleteReadOnlyUser();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue