[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:
![Screenshot 2025-02-05 at 14 25
15](https://github.com/user-attachments/assets/64d23f00-82f7-4e90-bcef-29a18ae7116a)
And the panel is shown with the `read only` callout with no edit
buttons:
![Screenshot 2025-02-05 at 14 25
23](https://github.com/user-attachments/assets/39782a01-5d61-4498-9f50-4a3c7a6bf35d)

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):
![Screenshot 2025-02-05 at 14 25
34](https://github.com/user-attachments/assets/0b9aebd5-96db-4140-8e85-b08a9720ae33)
![Screenshot 2025-02-05 at 14 25
41](https://github.com/user-attachments/assets/d3487aa8-af9c-4b73-80fc-8ee2489f2f90)


### 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:
Marco Liberati 2025-03-14 12:55:21 +01:00 committed by GitHub
parent 2105648730
commit 07c7450095
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 688 additions and 95 deletions

View file

@ -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,

View file

@ -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'
);
};

View file

@ -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(),
},

View file

@ -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);
};

View file

@ -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';

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", 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');
});
});

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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';

View file

@ -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');

View file

@ -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');

View file

@ -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",

View file

@ -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": "テクニカルプレビュー",

View file

@ -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": "技术预览",

View file

@ -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();
});
});
});

View file

@ -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>
)}

View file

@ -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,
};

View file

@ -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();
});
});

View file

@ -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>

View file

@ -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;
}

View file

@ -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);
});
});
});

View file

@ -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;

View file

@ -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);

View file

@ -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}
/>
);

View file

@ -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);

View file

@ -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 },

View file

@ -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();
});
});
}