[Lens] Disable auto apply design updates (#128412)

This commit is contained in:
Andrew Tate 2022-03-29 16:05:57 -05:00 committed by GitHub
parent 02a146f7e4
commit fefb24b717
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 510 additions and 265 deletions

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { Subject } from 'rxjs';
import { Observable, Subject } from 'rxjs';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { App } from './app';
@ -83,6 +83,7 @@ describe('Lens App', () => {
datasourceMap,
visualizationMap,
topNavMenuEntryGenerators: [],
theme$: new Observable(),
};
}

View file

@ -58,6 +58,7 @@ export function App({
contextOriginatingApp,
topNavMenuEntryGenerators,
initialContext,
theme$,
}: LensAppProps) {
const lensAppServices = useKibana<LensAppServices>().services;
@ -402,6 +403,7 @@ export function App({
initialContextIsEmbedded={initialContextIsEmbedded}
topNavMenuEntryGenerators={topNavMenuEntryGenerators}
initialContext={initialContext}
theme$={theme$}
/>
{getLegacyUrlConflictCallout()}
{(!isLoading || persistedDoc) && (

View file

@ -8,6 +8,7 @@
import { isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useStore } from 'react-redux';
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
import {
LensAppServices,
@ -21,6 +22,7 @@ import { tableHasFormulas } from '../../../../../src/plugins/data/common';
import { exporters } from '../../../../../src/plugins/data/public';
import type { DataView } from '../../../../../src/plugins/data_views/public';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { toggleSettingsMenuOpen } from './settings_menu';
import {
setState,
useLensSelector,
@ -140,6 +142,17 @@ function getLensTopNavConfig(options: {
tooltip: tooltips.showExportWarning,
});
topNavMenu.push({
label: i18n.translate('xpack.lens.app.settings', {
defaultMessage: 'Settings',
}),
run: actions.openSettings,
testId: 'lnsApp_settingsButton',
description: i18n.translate('xpack.lens.app.settingsAriaLabel', {
defaultMessage: 'Open the Lens settings menu',
}),
});
if (showCancel) {
topNavMenu.push({
label: i18n.translate('xpack.lens.app.cancel', {
@ -200,6 +213,7 @@ export const LensTopNavMenu = ({
initialContextIsEmbedded,
topNavMenuEntryGenerators,
initialContext,
theme$,
}: LensTopNavMenuProps) => {
const {
data,
@ -233,6 +247,7 @@ export const LensTopNavMenu = ({
visualization,
filters,
} = useLensSelector((state) => state.lens);
const allLoaded = Object.values(datasourceStates).every(({ isLoading }) => isLoading === false);
useEffect(() => {
@ -337,6 +352,8 @@ export const LensTopNavMenu = ({
application.capabilities,
]);
const lensStore = useStore();
const topNavConfig = useMemo(() => {
const baseMenuEntries = getLensTopNavConfig({
showSaveAndReturn:
@ -465,6 +482,12 @@ export const LensTopNavMenu = ({
columns: meta.columns,
});
},
openSettings: (anchorElement: HTMLElement) =>
toggleSettingsMenuOpen({
lensStore,
anchorElement,
theme$,
}),
},
});
return [...(additionalMenuEntries || []), ...baseMenuEntries];
@ -497,6 +520,8 @@ export const LensTopNavMenu = ({
filters,
indexPatterns,
data.query.timefilter.timefilter,
lensStore,
theme$,
]);
const onQuerySubmitWrapped = useCallback(

View file

@ -249,6 +249,7 @@ export async function mountApp(
initialContext={initialContext}
contextOriginatingApp={historyLocationState?.originatingApp}
topNavMenuEntryGenerators={topNavMenuEntryGenerators}
theme$={core.theme.theme$}
/>
</Provider>
);

View file

@ -0,0 +1,108 @@
/*
* 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, { useCallback } from 'react';
import ReactDOM from 'react-dom';
import type { CoreTheme } from 'kibana/public';
import { EuiPopoverTitle, EuiSwitch, EuiWrappingPopover } from '@elastic/eui';
import { Observable } from 'rxjs';
import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { Store } from 'redux';
import { Provider } from 'react-redux';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public';
import {
disableAutoApply,
enableAutoApply,
LensAppState,
selectAutoApplyEnabled,
useLensDispatch,
useLensSelector,
} from '../state_management';
import { trackUiEvent } from '../lens_ui_telemetry';
import { writeToStorage } from '../settings_storage';
import { AUTO_APPLY_DISABLED_STORAGE_KEY } from '../editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper';
const container = document.createElement('div');
let isOpen = false;
function SettingsMenu({
anchorElement,
onClose,
}: {
anchorElement: HTMLElement;
onClose: () => void;
}) {
const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled);
const dispatch = useLensDispatch();
const toggleAutoApply = useCallback(() => {
trackUiEvent('toggle_autoapply');
writeToStorage(
new Storage(localStorage),
AUTO_APPLY_DISABLED_STORAGE_KEY,
String(autoApplyEnabled)
);
dispatch(autoApplyEnabled ? disableAutoApply() : enableAutoApply());
}, [dispatch, autoApplyEnabled]);
return (
<EuiWrappingPopover
data-test-subj="lnsApp__settingsMenu"
ownFocus
button={anchorElement}
closePopover={onClose}
isOpen
>
<EuiPopoverTitle>
<FormattedMessage id="xpack.lens.settings.title" defaultMessage="Lens settings" />
</EuiPopoverTitle>
<EuiSwitch
label={i18n.translate('xpack.lens.settings.autoApply', {
defaultMessage: 'Auto-apply visualization changes',
})}
checked={autoApplyEnabled}
onChange={() => toggleAutoApply()}
data-test-subj="lnsToggleAutoApply"
/>
</EuiWrappingPopover>
);
}
function closeSettingsMenu() {
ReactDOM.unmountComponentAtNode(container);
document.body.removeChild(container);
isOpen = false;
}
export function toggleSettingsMenuOpen(props: {
lensStore: Store<LensAppState>;
anchorElement: HTMLElement;
theme$: Observable<CoreTheme>;
}) {
if (isOpen) {
closeSettingsMenu();
return;
}
isOpen = true;
document.body.appendChild(container);
const element = (
<Provider store={props.lensStore}>
<KibanaThemeProvider theme$={props.theme$}>
<I18nProvider>
<SettingsMenu {...props} onClose={closeSettingsMenu} />
</I18nProvider>
</KibanaThemeProvider>
</Provider>
);
ReactDOM.render(element, container);
}

View file

@ -8,11 +8,13 @@
import type { History } from 'history';
import type { OnSaveProps } from 'src/plugins/saved_objects/public';
import { DiscoverStart } from 'src/plugins/discover/public';
import { Observable } from 'rxjs';
import { SpacesApi } from '../../../spaces/public';
import type {
ApplicationStart,
AppMountParameters,
ChromeStart,
CoreTheme,
ExecutionContextStart,
HttpStart,
IUiSettingsClient,
@ -73,6 +75,7 @@ export interface LensAppProps {
initialContext?: VisualizeEditorContext | VisualizeFieldContext;
contextOriginatingApp?: string;
topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[];
theme$: Observable<CoreTheme>;
}
export type RunSave = (
@ -107,6 +110,7 @@ export interface LensTopNavMenuProps {
initialContextIsEmbedded?: boolean;
topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[];
initialContext?: VisualizeFieldContext | VisualizeEditorContext;
theme$: Observable<CoreTheme>;
}
export interface HistoryLocationState {
@ -157,4 +161,5 @@ export interface LensTopNavActions {
cancel: () => void;
exportToCSV: () => void;
getUnderlyingDataUrl: () => string | undefined;
openSettings: (anchorElement: HTMLElement) => void;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -12,6 +12,8 @@
// Padding / negative margins to make room for overflow shadow
padding-left: $euiSizeXS;
margin-left: -$euiSizeXS;
padding-right: $euiSizeXS;
margin-right: -$euiSizeXS;
}
.lnsSuggestionPanel {
@ -91,4 +93,10 @@
.lnsSuggestionPanel__applyChangesPrompt {
height: $lnsSuggestionHeight;
background-color: $euiColorLightestShade !important;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

View file

@ -32,7 +32,7 @@ import {
import { setChangesApplied } from '../../state_management/lens_slice';
const SELECTORS = {
APPLY_CHANGES_BUTTON: 'button[data-test-subj="lnsSuggestionApplyChanges"]',
APPLY_CHANGES_BUTTON: 'button[data-test-subj="lnsApplyChanges__suggestions"]',
SUGGESTIONS_PANEL: '[data-test-subj="lnsSuggestionsPanel"]',
SUGGESTION_TILE_BUTTON: 'button[data-test-subj="lnsSuggestion"]',
};

View file

@ -19,10 +19,7 @@ import {
EuiToolTip,
EuiButtonEmpty,
EuiAccordion,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Ast, toExpression } from '@kbn/interpreter';
@ -337,41 +334,33 @@ export function SuggestionPanel({
}
}
const applyChangesPrompt = (
<EuiPanel
hasBorder
hasShadow={false}
className="lnsSuggestionPanel__applyChangesPrompt"
paddingSize="m"
>
<EuiFlexGroup alignItems="center" justifyContent="center" gutterSize="s">
<EuiFlexItem grow={false}>
<h3>
<FormattedMessage
id="xpack.lens.suggestions.applyChangesPrompt"
defaultMessage="Apply your changes to see suggestions."
/>
</h3>
<EuiSpacer size="s" />
<EuiButton
fill
iconType="play"
size="s"
className={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
onClick={() => dispatchLens(applyChanges())}
data-test-subj="lnsSuggestionApplyChanges"
>
<FormattedMessage
id="xpack.lens.suggestions.applyChangesLabel"
defaultMessage="Apply"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
const renderApplyChangesPrompt = () => (
<EuiPanel hasShadow={false} className="lnsSuggestionPanel__applyChangesPrompt" paddingSize="m">
<EuiText size="s" color="subdued" className="lnsSuggestionPanel__applyChangesMessage">
<p>
<FormattedMessage
id="xpack.lens.suggestions.applyChangesPrompt"
defaultMessage="Latest changes must be applied to view suggestions."
/>
</p>
</EuiText>
<EuiButtonEmpty
iconType="checkInCircleFilled"
size="s"
className={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
onClick={() => dispatchLens(applyChanges())}
data-test-subj="lnsApplyChanges__suggestions"
>
<FormattedMessage
id="xpack.lens.suggestions.applyChangesLabel"
defaultMessage="Apply changes"
/>
</EuiButtonEmpty>
</EuiPanel>
);
const suggestionsUI = (
const renderSuggestionsUI = () => (
<>
{currentVisualization.activeId && !hideSuggestions && (
<SuggestionPreview
@ -461,7 +450,7 @@ export function SuggestionPanel({
}
>
<div className="lnsSuggestionPanel__suggestions" data-test-subj="lnsSuggestionsPanel">
{changesApplied ? suggestionsUI : applyChangesPrompt}
{changesApplied ? renderSuggestionsUI() : renderApplyChangesPrompt()}
</div>
</EuiAccordion>
</div>

View file

@ -52,7 +52,7 @@ export function GeoFieldWorkspacePanel(props: Props) {
<h2>
<strong>{getVisualizeGeoFieldMessage(props.fieldType)}</strong>
</h2>
<GlobeIllustration aria-hidden={true} className="lnsWorkspacePanel__dropIllustration" />
<GlobeIllustration aria-hidden={true} className="lnsWorkspacePanel__promptIllustration" />
<DragDrop
className="lnsVisualizeGeoFieldWorkspacePanel__dragDrop"
dataTestSubj="lnsGeoFieldWorkspace"

View file

@ -73,6 +73,12 @@ const defaultProps = {
toggleFullscreen: jest.fn(),
};
const SELECTORS = {
applyChangesButton: 'button[data-test-subj="lnsApplyChanges__toolbar"]',
dragDropPrompt: '[data-test-subj="workspace-drag-drop-prompt"]',
applyChangesPrompt: '[data-test-subj="workspace-apply-changes-prompt"]',
};
describe('workspace_panel', () => {
let mockVisualization: jest.Mocked<Visualization>;
let mockVisualization2: jest.Mocked<Visualization>;
@ -115,7 +121,7 @@ describe('workspace_panel', () => {
instance = mounted.instance;
instance.update();
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find('[data-test-subj="workspace-drag-drop-prompt"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
@ -133,7 +139,7 @@ describe('workspace_panel', () => {
instance = mounted.instance;
instance.update();
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find('[data-test-subj="workspace-drag-drop-prompt"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
@ -151,7 +157,7 @@ describe('workspace_panel', () => {
instance = mounted.instance;
instance.update();
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find('[data-test-subj="workspace-drag-drop-prompt"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
@ -218,8 +224,10 @@ describe('workspace_panel', () => {
instance = mounted.instance;
instance.update();
const getExpression = () => instance.find(expressionRendererMock).prop('expression');
// allows initial render
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
expect(getExpression()).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={datasource}
| testVis"
@ -233,9 +241,9 @@ describe('workspace_panel', () => {
},
});
});
instance.update();
// nothing should change
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
expect(getExpression()).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={datasource}
| testVis"
@ -247,7 +255,7 @@ describe('workspace_panel', () => {
instance.update();
// should update
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
expect(getExpression()).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={new-datasource}
| new-vis"
@ -260,22 +268,12 @@ describe('workspace_panel', () => {
testVis: { ...mockVisualization, toExpression: () => 'other-new-vis' },
},
});
});
// should not update
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={new-datasource}
| new-vis"
`);
act(() => {
mounted.lensStore.dispatch(enableAutoApply());
});
instance.update();
// reenabling auto-apply triggers an update as well
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
expect(getExpression()).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={other-new-datasource}
| other-new-vis"
@ -339,13 +337,41 @@ describe('workspace_panel', () => {
expect(isSaveable()).toBe(false);
});
it('should allow empty workspace as initial render when auto-apply disabled', async () => {
mockVisualization.toExpression.mockReturnValue('testVis');
it('should show proper workspace prompts when auto-apply disabled', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const configureValidVisualization = () => {
mockVisualization.toExpression.mockReturnValue('testVis');
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
act(() => {
instance.setProps({
visualizationMap: {
testVis: { ...mockVisualization, toExpression: () => 'new-vis' },
},
});
});
};
const deleteVisualization = () => {
act(() => {
instance.setProps({
visualizationMap: {
testVis: { ...mockVisualization, toExpression: () => null },
},
});
});
};
const dragDropPromptShowing = () => instance.exists(SELECTORS.dragDropPrompt);
const applyChangesPromptShowing = () => instance.exists(SELECTORS.applyChangesPrompt);
const visualizationShowing = () => instance.exists(expressionRendererMock);
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
@ -356,6 +382,7 @@ describe('workspace_panel', () => {
visualizationMap={{
testVis: mockVisualization,
}}
ExpressionRenderer={expressionRendererMock}
/>,
{
preloadedState: {
@ -367,7 +394,37 @@ describe('workspace_panel', () => {
instance = mounted.instance;
instance.update();
expect(instance.exists('[data-test-subj="empty-workspace"]')).toBeTruthy();
expect(dragDropPromptShowing()).toBeTruthy();
configureValidVisualization();
instance.update();
expect(dragDropPromptShowing()).toBeFalsy();
expect(applyChangesPromptShowing()).toBeTruthy();
instance.find(SELECTORS.applyChangesButton).simulate('click');
instance.update();
expect(visualizationShowing()).toBeTruthy();
deleteVisualization();
instance.update();
expect(visualizationShowing()).toBeTruthy();
act(() => {
mounted.lensStore.dispatch(applyChanges());
});
instance.update();
expect(visualizationShowing()).toBeFalsy();
expect(dragDropPromptShowing()).toBeTruthy();
configureValidVisualization();
instance.update();
expect(dragDropPromptShowing()).toBeFalsy();
expect(applyChangesPromptShowing()).toBeTruthy();
});
it('should execute a trigger on expression event', async () => {
@ -921,9 +978,7 @@ describe('workspace_panel', () => {
expect(showingErrors()).toBeFalsy();
// errors should appear when problem changes are applied
act(() => {
lensStore.dispatch(applyChanges());
});
instance.find(SELECTORS.applyChangesButton).simulate('click');
instance.update();
expect(showingErrors()).toBeTruthy();

View file

@ -20,6 +20,7 @@ import {
EuiPageContentBody,
EuiButton,
EuiSpacer,
EuiTextColor,
} from '@elastic/eui';
import type { CoreStart, ApplicationStart } from 'kibana/public';
import type { DataPublicPluginStart, ExecutionContextSearch } from 'src/plugins/data/public';
@ -47,6 +48,8 @@ import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/publ
import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public';
import { WorkspacePanelWrapper } from './workspace_panel_wrapper';
import { DropIllustration } from '../../../assets/drop_illustration';
import applyChangesIllustrationDark from '../../../assets/render_dark@2x.png';
import applyChangesIllustrationLight from '../../../assets/render_light@2x.png';
import {
getOriginalRequestErrorMessages,
getUnknownVisualizationTypeError,
@ -69,11 +72,14 @@ import {
selectAutoApplyEnabled,
selectTriggerApplyChanges,
selectDatasourceLayers,
applyChanges,
selectChangesApplied,
} from '../../../state_management';
import type { LensInspector } from '../../../lens_inspector_service';
import { inferTimeField } from '../../../utils';
import { setChangesApplied } from '../../../state_management/lens_slice';
import type { Datatable } from '../../../../../../../src/plugins/expressions/public';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../config_panel/dimension_container';
export interface WorkspacePanelProps {
visualizationMap: VisualizationMap;
@ -143,6 +149,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const datasourceStates = useLensSelector(selectDatasourceStates);
const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled);
const changesApplied = useLensSelector(selectChangesApplied);
const triggerApply = useLensSelector(selectTriggerApplyChanges);
const [localState, setLocalState] = useState<WorkspaceState>({
@ -201,7 +208,8 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
[activeVisualization, visualization.state, activeDatasourceId, datasourceMap, datasourceStates]
);
const _expression = useMemo(() => {
// if the expression is undefined, it means we hit an error that should be displayed to the user
const unappliedExpression = useMemo(() => {
if (!configurationValidationError?.length && !missingRefsErrors.length && !unknownVisError) {
try {
const ast = buildExpression({
@ -254,20 +262,23 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
]);
useEffect(() => {
dispatchLens(setSaveable(Boolean(_expression)));
}, [_expression, dispatchLens]);
dispatchLens(setSaveable(Boolean(unappliedExpression)));
}, [unappliedExpression, dispatchLens]);
useEffect(() => {
if (!autoApplyEnabled) {
dispatchLens(setChangesApplied(_expression === localState.expressionToRender));
dispatchLens(setChangesApplied(unappliedExpression === localState.expressionToRender));
}
});
useEffect(() => {
if (shouldApplyExpression) {
setLocalState((s) => ({ ...s, expressionToRender: _expression }));
setLocalState((s) => ({
...s,
expressionToRender: unappliedExpression,
}));
}
}, [_expression, shouldApplyExpression]);
}, [unappliedExpression, shouldApplyExpression]);
const expressionExists = Boolean(localState.expressionToRender);
useEffect(() => {
@ -332,15 +343,23 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
}
}, [suggestionForDraggedField, expressionExists, dispatchLens]);
const renderEmptyWorkspace = () => {
const IS_DARK_THEME = core.uiSettings.get('theme:darkMode');
const renderDragDropPrompt = () => {
return (
<EuiText
className={classNames('lnsWorkspacePanel__emptyContent')}
textAlign="center"
color="subdued"
data-test-subj="empty-workspace"
data-test-subj="workspace-drag-drop-prompt"
size="s"
>
<DropIllustration
aria-hidden={true}
className={classNames(
'lnsWorkspacePanel__promptIllustration',
'lnsWorkspacePanel__dropIllustration'
)}
/>
<h2>
<strong>
{!expressionExists
@ -352,26 +371,25 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
})}
</strong>
</h2>
<DropIllustration aria-hidden={true} className="lnsWorkspacePanel__dropIllustration" />
{!expressionExists && (
<>
<p>
{i18n.translate('xpack.lens.editorFrame.emptyWorkspaceHeading', {
defaultMessage: 'Lens is the recommended editor for creating visualizations',
})}
</p>
<p>
<small>
<EuiLink
href="https://www.elastic.co/products/kibana/feedback"
target="_blank"
external
>
{i18n.translate('xpack.lens.editorFrame.goToForums', {
defaultMessage: 'Make requests and give feedback',
})}
</EuiLink>
</small>
<EuiTextColor color="subdued" component="div">
<p>
{i18n.translate('xpack.lens.editorFrame.emptyWorkspaceHeading', {
defaultMessage: 'Lens is the recommended editor for creating visualizations',
})}
</p>
</EuiTextColor>
<p className="lnsWorkspacePanel__actions">
<EuiLink
href="https://www.elastic.co/products/kibana/feedback"
target="_blank"
external
>
{i18n.translate('xpack.lens.editorFrame.goToForums', {
defaultMessage: 'Make requests and give feedback',
})}
</EuiLink>
</p>
</>
)}
@ -379,11 +397,47 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
);
};
const renderVisualization = () => {
if (localState.expressionToRender === null) {
return renderEmptyWorkspace();
}
const renderApplyChangesPrompt = () => {
const applyChangesString = i18n.translate('xpack.lens.editorFrame.applyChanges', {
defaultMessage: 'Apply changes',
});
return (
<EuiText
className={classNames('lnsWorkspacePanel__emptyContent')}
textAlign="center"
data-test-subj="workspace-apply-changes-prompt"
size="s"
>
<img
aria-hidden={true}
src={IS_DARK_THEME ? applyChangesIllustrationDark : applyChangesIllustrationLight}
alt={applyChangesString}
className="lnsWorkspacePanel__promptIllustration"
/>
<h2>
<strong>
{i18n.translate('xpack.lens.editorFrame.applyChangesWorkspacePrompt', {
defaultMessage: 'Apply changes to render visualization',
})}
</strong>
</h2>
<p className="lnsWorkspacePanel__actions">
<EuiButtonEmpty
size="s"
className={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
iconType="checkInCircleFilled"
onClick={() => dispatchLens(applyChanges())}
data-test-subj="lnsApplyChanges__workspace"
>
{applyChangesString}
</EuiButtonEmpty>
</p>
</EuiText>
);
};
const renderVisualization = () => {
return (
<VisualizationWrapper
expression={localState.expressionToRender}
@ -402,7 +456,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
const dragDropContext = useContext(DragContext);
const renderDragDrop = () => {
const renderWorkspace = () => {
const customWorkspaceRenderer =
activeDatasourceId &&
datasourceMap[activeDatasourceId]?.getCustomWorkspaceRenderer &&
@ -413,9 +467,19 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
)
: undefined;
return customWorkspaceRenderer ? (
customWorkspaceRenderer()
) : (
if (customWorkspaceRenderer) {
return customWorkspaceRenderer();
}
const hasSomethingToRender = localState.expressionToRender !== null;
const renderWorkspaceContents = hasSomethingToRender
? renderVisualization
: !changesApplied
? renderApplyChangesPrompt
: renderDragDropPrompt;
return (
<DragDrop
className={classNames('lnsWorkspacePanel__dragDrop', {
'lnsWorkspacePanel__dragDrop--fullscreen': isFullscreen,
@ -428,7 +492,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
order={dropProps.order}
>
<EuiPageContentBody className="lnsWorkspacePanelWrapper__pageContentBody">
{renderVisualization()}
{renderWorkspaceContents()}
</EuiPageContentBody>
</DragDrop>
);
@ -444,7 +508,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
visualizationMap={visualizationMap}
isFullscreen={isFullscreen}
>
{renderDragDrop()}
{renderWorkspace()}
</WorkspacePanelWrapper>
);
});

View file

@ -31,6 +31,9 @@
&.lnsWorkspacePanelWrapper--fullscreen {
margin-bottom: 0;
.lnsWorkspacePanelWrapper__pageContentBody {
box-shadow: none;
}
}
}
@ -77,6 +80,10 @@
justify-content: center;
align-items: center;
transition: background-color $euiAnimSpeedFast ease-in-out;
.lnsWorkspacePanel__actions {
margin-top: $euiSizeL;
}
}
.lnsWorkspacePanelWrapper__toolbar {
@ -84,6 +91,7 @@
&.lnsWorkspacePanelWrapper__toolbar--fullscreen {
padding: $euiSizeS $euiSizeS 0 $euiSizeS;
background-color: #FFF;
}
& > .euiFlexItem {
@ -91,14 +99,18 @@
}
}
.lnsDropIllustration__adjustFill {
fill: $euiColorFullShade;
.lnsWorkspacePanel__promptIllustration {
overflow: visible; // Shows arrow animation when it gets out of bounds
margin-top: 0;
margin-bottom: -$euiSize;
margin-right: auto;
margin-left: auto;
max-width: 176px;
max-height: 176px;
}
.lnsWorkspacePanel__dropIllustration {
overflow: visible; // Shows arrow animation when it gets out of bounds
margin-top: $euiSizeL;
margin-bottom: $euiSizeXXL;
// Drop shadow values is a dupe of @euiBottomShadowMedium but used as a filter
// Hard-coded px values OK (@cchaos)
// sass-lint:disable-block indentation
@ -108,6 +120,10 @@
drop-shadow(0 2px 2px transparentize($euiShadowColor, .8));
}
.lnsDropIllustration__adjustFill {
fill: $euiColorFullShade;
}
.lnsDropIllustration__hand {
animation: lnsWorkspacePanel__illustrationPulseArrow 5s ease-in-out 0s infinite normal forwards;
}

View file

@ -17,7 +17,7 @@ import {
disableAutoApply,
selectTriggerApplyChanges,
} from '../../../state_management';
import { setChangesApplied } from '../../../state_management/lens_slice';
import { enableAutoApply, setChangesApplied } from '../../../state_management/lens_slice';
describe('workspace_panel_wrapper', () => {
let mockVisualization: jest.Mocked<Visualization>;
@ -83,19 +83,7 @@ describe('workspace_panel_wrapper', () => {
}
private get applyChangesButton() {
return this._instance.find('button[data-test-subj="lensApplyChanges"]');
}
private get autoApplyToggleSwitch() {
return this._instance.find('button[data-test-subj="lensToggleAutoApply"]');
}
toggleAutoApply() {
this.autoApplyToggleSwitch.simulate('click');
}
public get autoApplySwitchOn() {
return this.autoApplyToggleSwitch.prop('aria-checked');
return this._instance.find('button[data-test-subj="lnsApplyChanges__toolbar"]');
}
applyChanges() {
@ -135,28 +123,24 @@ describe('workspace_panel_wrapper', () => {
harness = new Harness(instance);
});
it('toggles auto-apply', async () => {
it('shows and hides apply-changes button depending on whether auto-apply is enabled', async () => {
store.dispatch(disableAutoApply());
harness.update();
expect(selectAutoApplyEnabled(store.getState())).toBeFalsy();
expect(harness.autoApplySwitchOn).toBeFalsy();
expect(harness.applyChangesExists).toBeTruthy();
harness.toggleAutoApply();
store.dispatch(enableAutoApply());
harness.update();
expect(selectAutoApplyEnabled(store.getState())).toBeTruthy();
expect(harness.autoApplySwitchOn).toBeTruthy();
expect(harness.applyChangesExists).toBeFalsy();
harness.toggleAutoApply();
store.dispatch(disableAutoApply());
harness.update();
expect(selectAutoApplyEnabled(store.getState())).toBeFalsy();
expect(harness.autoApplySwitchOn).toBeFalsy();
expect(harness.applyChangesExists).toBeTruthy();
});
it('apply-changes button works', () => {
it('apply-changes button applies changes', () => {
store.dispatch(disableAutoApply());
harness.update();
@ -199,13 +183,11 @@ describe('workspace_panel_wrapper', () => {
harness.update();
expect(harness.applyChangesDisabled).toBeFalsy();
expect(harness.autoApplySwitchOn).toBeFalsy();
expect(harness.applyChangesExists).toBeTruthy();
// enable auto apply
harness.toggleAutoApply();
store.dispatch(enableAutoApply());
harness.update();
expect(harness.autoApplySwitchOn).toBeTruthy();
expect(harness.applyChangesExists).toBeFalsy();
});
});

View file

@ -8,12 +8,9 @@
import './workspace_panel_wrapper.scss';
import React, { useCallback } from 'react';
import { EuiPageContent, EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiButton } from '@elastic/eui';
import { EuiPageContent, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import classNames from 'classnames';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { trackUiEvent } from '../../../lens_ui_telemetry';
import { Storage } from '../../../../../../../src/plugins/kibana_utils/public';
import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../../types';
import { NativeRenderer } from '../../../native_renderer';
import { ChartSwitch } from './chart_switch';
@ -27,13 +24,10 @@ import {
useLensSelector,
selectChangesApplied,
applyChanges,
enableAutoApply,
disableAutoApply,
selectAutoApplyEnabled,
} from '../../../state_management';
import { WorkspaceTitle } from './title';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../config_panel/dimension_container';
import { writeToStorage } from '../../../settings_storage';
export const AUTO_APPLY_DISABLED_STORAGE_KEY = 'autoApplyDisabled';
@ -90,17 +84,6 @@ export function WorkspacePanelWrapper({
[dispatchLens]
);
const toggleAutoApply = useCallback(() => {
trackUiEvent('toggle_autoapply');
writeToStorage(
new Storage(localStorage),
AUTO_APPLY_DISABLED_STORAGE_KEY,
String(autoApplyEnabled)
);
dispatchLens(autoApplyEnabled ? disableAutoApply() : enableAutoApply());
}, [dispatchLens, autoApplyEnabled]);
const warningMessages: React.ReactNode[] = [];
if (activeVisualization?.getWarningMessages) {
warningMessages.push(
@ -119,102 +102,82 @@ export function WorkspacePanelWrapper({
});
return (
<>
<div>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
direction="row"
responsive={false}
wrap={true}
justifyContent="spaceBetween"
>
<EuiFlexItem grow={true}>
<EuiFlexGroup
gutterSize="m"
direction="row"
responsive={false}
wrap={true}
justifyContent="spaceBetween"
className={classNames('lnsWorkspacePanelWrapper__toolbar', {
'lnsWorkspacePanelWrapper__toolbar--fullscreen': isFullscreen,
})}
>
{!isFullscreen && (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m" justifyContent="flexStart">
<EuiFlexItem grow={false}>
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={visualizationMap}
datasourceMap={datasourceMap}
framePublicAPI={framePublicAPI}
/>
</EuiFlexItem>
{activeVisualization && activeVisualization.renderToolbar && (
<EuiFlexItem grow={false}>
<NativeRenderer
render={activeVisualization.renderToolbar}
nativeProps={{
frame: framePublicAPI,
state: visualizationState,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFlexGroup
alignItems="center"
justifyContent="flexStart"
gutterSize="s"
responsive={false}
>
{!(isFullscreen && (autoApplyEnabled || warningMessages?.length)) && (
<div>
<EuiFlexGroup
alignItems="center"
gutterSize="m"
direction="row"
responsive={false}
wrap={true}
justifyContent="spaceBetween"
className={classNames('lnsWorkspacePanelWrapper__toolbar', {
'lnsWorkspacePanelWrapper__toolbar--fullscreen': isFullscreen,
})}
>
{!isFullscreen && (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m" justifyContent="flexStart">
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate('xpack.lens.editorFrame.autoApply', {
defaultMessage: 'Auto-apply',
})}
checked={autoApplyEnabled}
onChange={toggleAutoApply}
compressed={true}
className={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
data-test-subj="lensToggleAutoApply"
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={visualizationMap}
datasourceMap={datasourceMap}
framePublicAPI={framePublicAPI}
/>
</EuiFlexItem>
{!autoApplyEnabled && (
{activeVisualization && activeVisualization.renderToolbar && (
<EuiFlexItem grow={false}>
<div>
<EuiButton
disabled={autoApplyEnabled || changesApplied}
fill
className={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
iconType="play"
onClick={() => dispatchLens(applyChanges())}
size="s"
data-test-subj="lensApplyChanges"
>
<FormattedMessage
id="xpack.lens.editorFrame.applyChangesLabel"
defaultMessage="Apply"
/>
</EuiButton>
</div>
<NativeRenderer
render={activeVisualization.renderToolbar}
nativeProps={{
frame: framePublicAPI,
state: visualizationState,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{warningMessages && warningMessages.length ? (
<WarningsPopover>{warningMessages}</WarningsPopover>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</div>
)}
<EuiFlexItem>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="s"
responsive={false}
>
<EuiFlexItem grow={false}>
{warningMessages?.length ? (
<WarningsPopover>{warningMessages}</WarningsPopover>
) : null}
</EuiFlexItem>
{!autoApplyEnabled && (
<EuiFlexItem grow={false}>
<div>
<EuiButton
disabled={autoApplyEnabled || changesApplied}
fill
className={DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS}
iconType="checkInCircleFilled"
onClick={() => dispatchLens(applyChanges())}
size="m"
data-test-subj="lnsApplyChanges__toolbar"
>
<FormattedMessage
id="xpack.lens.editorFrame.applyChangesLabel"
defaultMessage="Apply changes"
/>
</EuiButton>
</div>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</div>
)}
<EuiPageContent
className={classNames('lnsWorkspacePanelWrapper', {

View file

@ -42,11 +42,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.disableAutoApply();
expect(await PageObjects.lens.getAutoApplyEnabled()).not.to.be.ok();
await PageObjects.lens.closeSettingsMenu();
});
it('should preserve auto-apply controls with full-screen datasource', async () => {
it('should preserve apply-changes button with full-screen datasource', async () => {
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.disableAutoApply();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'formula',
@ -56,7 +60,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
PageObjects.lens.toggleFullscreen();
expect(await PageObjects.lens.getAutoApplyToggleExists()).to.be.ok();
expect(await PageObjects.lens.applyChangesExists('toolbar')).to.be.ok();
PageObjects.lens.toggleFullscreen();
@ -79,9 +83,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
// assert that changes haven't been applied
await PageObjects.lens.waitForEmptyWorkspace();
await PageObjects.lens.waitForWorkspaceWithApplyChangesPrompt();
await PageObjects.lens.applyChanges();
await PageObjects.lens.applyChanges('workspace');
await PageObjects.lens.waitForVisualization('xyVisChart');
});
@ -89,11 +93,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should hide suggestions when a change is made', async () => {
await PageObjects.lens.switchToVisualization('lnsDatatable');
expect(await PageObjects.lens.getAreSuggestionsPromptingToApply()).to.be.ok();
expect(await PageObjects.lens.applyChangesExists('suggestions')).to.be.ok();
await PageObjects.lens.applyChanges(true);
await PageObjects.lens.applyChanges('suggestions');
expect(await PageObjects.lens.getAreSuggestionsPromptingToApply()).not.to.be.ok();
expect(await PageObjects.lens.applyChangesExists('suggestions')).not.to.be.ok();
});
});
}

View file

@ -145,7 +145,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.lens.getLayerCount()).to.eql(2);
await PageObjects.lens.removeLayer();
await PageObjects.lens.removeLayer();
await testSubjects.existOrFail('empty-workspace');
await testSubjects.existOrFail('workspace-drag-drop-prompt');
});
it('should edit settings of xy line chart', async () => {

View file

@ -276,7 +276,13 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
async waitForEmptyWorkspace() {
await retry.try(async () => {
await testSubjects.existOrFail(`empty-workspace`);
await testSubjects.existOrFail(`workspace-drag-drop-prompt`);
});
},
async waitForWorkspaceWithApplyChangesPrompt() {
await retry.try(async () => {
await testSubjects.existOrFail(`workspace-apply-changes-prompt`);
});
},
@ -1336,32 +1342,48 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
return testSubjects.exists('lnsEmptySizeRatioButtonGroup');
},
getAutoApplyToggleExists() {
return testSubjects.exists('lensToggleAutoApply');
settingsMenuOpen() {
return testSubjects.exists('lnsApp__settingsMenu');
},
enableAutoApply() {
return testSubjects.setEuiSwitch('lensToggleAutoApply', 'check');
async openSettingsMenu() {
if (await this.settingsMenuOpen()) return;
await testSubjects.click('lnsApp_settingsButton');
},
disableAutoApply() {
return testSubjects.setEuiSwitch('lensToggleAutoApply', 'uncheck');
async closeSettingsMenu() {
if (!(await this.settingsMenuOpen())) return;
await testSubjects.click('lnsApp_settingsButton');
},
getAutoApplyEnabled() {
return testSubjects.isEuiSwitchChecked('lensToggleAutoApply');
async enableAutoApply() {
await this.openSettingsMenu();
return testSubjects.setEuiSwitch('lnsToggleAutoApply', 'check');
},
async applyChanges(throughSuggestions = false) {
const applyButtonSelector = throughSuggestions
? 'lnsSuggestionApplyChanges'
: 'lensApplyChanges';
async disableAutoApply() {
await this.openSettingsMenu();
return testSubjects.setEuiSwitch('lnsToggleAutoApply', 'uncheck');
},
async getAutoApplyEnabled() {
await this.openSettingsMenu();
return testSubjects.isEuiSwitchChecked('lnsToggleAutoApply');
},
applyChangesExists(whichButton: 'toolbar' | 'suggestions' | 'workspace') {
return testSubjects.exists(`lnsApplyChanges__${whichButton}`);
},
async applyChanges(whichButton: 'toolbar' | 'suggestions' | 'workspace') {
const applyButtonSelector = `lnsApplyChanges__${whichButton}`;
await testSubjects.waitForEnabled(applyButtonSelector);
await testSubjects.click(applyButtonSelector);
},
async getAreSuggestionsPromptingToApply() {
return testSubjects.exists('lnsSuggestionApplyChanges');
},
});
}