[Time to Visualize] Allow By Value Flow Without Visualize Save Permissions (#95951)

* Made sure users can use by value workflow without visualize save permissions
This commit is contained in:
Devon Thomson 2021-03-31 15:30:50 -04:00 committed by GitHub
parent b531d28364
commit fe17879ae3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 484 additions and 69 deletions

View file

@ -41,8 +41,15 @@ const start = doStart();
let container: DashboardContainer;
let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
let coreStart: CoreStart;
let capabilities: CoreStart['application']['capabilities'];
beforeEach(async () => {
coreStart = coreMock.createStart();
capabilities = {
...coreStart.application.capabilities,
visualize: { save: true },
maps: { save: true },
};
const containerOptions = {
ExitFullScreenButton: () => null,
@ -83,7 +90,10 @@ beforeEach(async () => {
});
test('Add to library is incompatible with Error Embeddables', async () => {
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities,
});
const errorEmbeddable = new ErrorEmbeddable(
'Wow what an awful error',
{ id: ' 404' },
@ -92,20 +102,37 @@ test('Add to library is incompatible with Error Embeddables', async () => {
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
});
test('Add to library is incompatible on visualize embeddable without visualize save permissions', async () => {
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities: { ...capabilities, visualize: { save: false } },
});
expect(await action.isCompatible({ embeddable })).toBe(false);
});
test('Add to library is compatible when embeddable on dashboard has value type input', async () => {
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities,
});
embeddable.updateInput(await embeddable.getInputAsValueType());
expect(await action.isCompatible({ embeddable })).toBe(true);
});
test('Add to library is not compatible when embeddable input is by reference', async () => {
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities,
});
embeddable.updateInput(await embeddable.getInputAsRefType());
expect(await action.isCompatible({ embeddable })).toBe(false);
});
test('Add to library is not compatible when view mode is set to view', async () => {
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities,
});
embeddable.updateInput(await embeddable.getInputAsRefType());
embeddable.updateInput({ viewMode: ViewMode.VIEW });
expect(await action.isCompatible({ embeddable })).toBe(false);
@ -126,7 +153,10 @@ test('Add to library is not compatible when embeddable is not in a dashboard con
mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id },
mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id },
});
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities,
});
expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false);
});
@ -135,7 +165,10 @@ test('Add to library replaces embeddableId and retains panel count', async () =>
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities,
});
await action.execute({ embeddable });
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount);
@ -161,7 +194,10 @@ test('Add to library returns reference type input', async () => {
});
const dashboard = embeddable.getRoot() as IContainer;
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts });
const action = new AddToLibraryAction({
toasts: coreStart.notifications.toasts,
capabilities,
});
await action.execute({ embeddable });
const newPanelId = Object.keys(container.getInput().panels).find(
(key) => !originalPanelKeySet.has(key)

View file

@ -18,7 +18,7 @@ import {
isReferenceOrValueEmbeddable,
isErrorEmbeddable,
} from '../../services/embeddable';
import { NotificationsStart } from '../../services/core';
import { ApplicationStart, NotificationsStart } from '../../services/core';
import { dashboardAddToLibraryAction } from '../../dashboard_strings';
import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..';
@ -33,7 +33,12 @@ export class AddToLibraryAction implements Action<AddToLibraryActionContext> {
public readonly id = ACTION_ADD_TO_LIBRARY;
public order = 15;
constructor(private deps: { toasts: NotificationsStart['toasts'] }) {}
constructor(
private deps: {
toasts: NotificationsStart['toasts'];
capabilities: ApplicationStart['capabilities'];
}
) {}
public getDisplayName({ embeddable }: AddToLibraryActionContext) {
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
@ -50,8 +55,15 @@ export class AddToLibraryAction implements Action<AddToLibraryActionContext> {
}
public async isCompatible({ embeddable }: AddToLibraryActionContext) {
// TODO: Fix this, potentially by adding a 'canSave' function to embeddable interface
const canSave =
embeddable.type === 'map'
? this.deps.capabilities.maps?.save
: this.deps.capabilities.visualize.save;
return Boolean(
!isErrorEmbeddable(embeddable) &&
canSave &&
!isErrorEmbeddable(embeddable) &&
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
embeddable.getRoot() &&
embeddable.getRoot().isContainer &&

View file

@ -61,7 +61,8 @@ export class ClonePanelAction implements Action<ClonePanelActionContext> {
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
embeddable.getRoot() &&
embeddable.getRoot().isContainer &&
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE &&
embeddable.getOutput().editable
);
}

View file

@ -342,7 +342,7 @@ export class DashboardPlugin
}
public start(core: CoreStart, plugins: DashboardStartDependencies): DashboardStart {
const { notifications, overlays } = core;
const { notifications, overlays, application } = core;
const { uiActions, data, share, presentationUtil, embeddable } = plugins;
const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings);
@ -370,7 +370,10 @@ export class DashboardPlugin
}
if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) {
const addToLibraryAction = new AddToLibraryAction({ toasts: notifications.toasts });
const addToLibraryAction = new AddToLibraryAction({
toasts: notifications.toasts,
capabilities: application.capabilities,
});
uiActions.registerAction(addToLibraryAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, addToLibraryAction.id);
@ -386,8 +389,8 @@ export class DashboardPlugin
overlays,
embeddable.getStateTransfer(),
{
canCreateNew: Boolean(core.application.capabilities.dashboard.createNew),
canEditExisting: !Boolean(core.application.capabilities.dashboard.hideWriteControls),
canCreateNew: Boolean(application.capabilities.dashboard.createNew),
canEditExisting: !Boolean(application.capabilities.dashboard.hideWriteControls),
},
presentationUtil.ContextProvider
);

View file

@ -12,4 +12,5 @@ export {
PluginInitializerContext,
ScopedHistory,
NotificationsStart,
ApplicationStart,
} from '../../../../core/public';

View file

@ -28,6 +28,7 @@ interface SaveModalDocumentInfo {
export interface SaveModalDashboardProps {
documentInfo: SaveModalDocumentInfo;
canSaveByReference: boolean;
objectType: string;
onClose: () => void;
onSave: (props: OnSaveProps & { dashboardId: string | null; addToLibrary: boolean }) => void;
@ -35,7 +36,7 @@ export interface SaveModalDashboardProps {
}
export function SavedObjectSaveModalDashboard(props: SaveModalDashboardProps) {
const { documentInfo, tagOptions, objectType, onClose } = props;
const { documentInfo, tagOptions, objectType, onClose, canSaveByReference } = props;
const { id: documentId } = documentInfo;
const initialCopyOnSave = !Boolean(documentId);
@ -49,7 +50,7 @@ export function SavedObjectSaveModalDashboard(props: SaveModalDashboardProps) {
documentId || disableDashboardOptions ? null : 'existing'
);
const [isAddToLibrarySelected, setAddToLibrary] = useState<boolean>(
!initialCopyOnSave || disableDashboardOptions
canSaveByReference && (!initialCopyOnSave || disableDashboardOptions)
);
const [selectedDashboard, setSelectedDashboard] = useState<{ id: string; name: string } | null>(
null
@ -65,13 +66,16 @@ export function SavedObjectSaveModalDashboard(props: SaveModalDashboardProps) {
onChange={(option) => {
setDashboardOption(option);
}}
canSaveByReference={canSaveByReference}
{...{ copyOnSave, documentId, dashboardOption, setAddToLibrary, isAddToLibrarySelected }}
/>
)
: null;
const onCopyOnSaveChange = (newCopyOnSave: boolean) => {
setAddToLibrary(true);
if (canSaveByReference) {
setAddToLibrary(true);
}
setDashboardOption(null);
setCopyOnSave(newCopyOnSave);
};

View file

@ -33,15 +33,21 @@ export default {
control: 'boolean',
defaultValue: true,
},
canSaveVisualizations: {
control: 'boolean',
defaultValue: true,
},
},
};
export function Example({
copyOnSave,
hasDocumentId,
canSaveVisualizations,
}: {
copyOnSave: boolean;
hasDocumentId: boolean;
canSaveVisualizations: boolean;
} & StorybookParams) {
const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>('existing');
const [isAddToLibrarySelected, setAddToLibrary] = useState(false);
@ -52,6 +58,7 @@ export function Example({
onChange={setDashboardOption}
dashboardOption={dashboardOption}
copyOnSave={copyOnSave}
canSaveByReference={canSaveVisualizations}
documentId={hasDocumentId ? 'abc' : undefined}
isAddToLibrarySelected={isAddToLibrarySelected}
setAddToLibrary={setAddToLibrary}

View file

@ -30,6 +30,7 @@ export interface SaveModalDashboardSelectorProps {
copyOnSave: boolean;
documentId?: string;
onSelectDashboard: DashboardPickerProps['onChange'];
canSaveByReference: boolean;
setAddToLibrary: (selected: boolean) => void;
isAddToLibrarySelected: boolean;
dashboardOption: 'new' | 'existing' | null;
@ -40,6 +41,7 @@ export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProp
const {
documentId,
onSelectDashboard,
canSaveByReference,
setAddToLibrary,
isAddToLibrarySelected,
dashboardOption,
@ -114,7 +116,7 @@ export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProp
setAddToLibrary(true);
onChange(null);
}}
disabled={isDisabled}
disabled={isDisabled || !canSaveByReference}
/>
</div>
</EuiPanel>
@ -127,7 +129,7 @@ export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProp
defaultMessage: 'Add to library',
})}
checked={isAddToLibrarySelected}
disabled={dashboardOption === null || isDisabled}
disabled={dashboardOption === null || isDisabled || !canSaveByReference}
onChange={(event) => setAddToLibrary(event.target.checked)}
/>
</EuiFlexItem>

View file

@ -23,6 +23,7 @@ export interface PresentationDashboardsService {
export interface PresentationCapabilitiesService {
canAccessDashboards: () => boolean;
canCreateNewDashboards: () => boolean;
canSaveVisualizations: () => boolean;
}
export interface PresentationUtilServices {

View file

@ -16,10 +16,11 @@ export type CapabilitiesServiceFactory = KibanaPluginServiceFactory<
>;
export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ coreStart }) => {
const { dashboard } = coreStart.application.capabilities;
const { dashboard, visualize } = coreStart.application.capabilities;
return {
canAccessDashboards: () => Boolean(dashboard.show),
canCreateNewDashboards: () => Boolean(dashboard.createNew),
canSaveVisualizations: () => Boolean(visualize.save),
};
};

View file

@ -19,11 +19,13 @@ export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({
canAccessDashboards,
canCreateNewDashboards,
canEditDashboards,
canSaveVisualizations,
}) => {
const check = (value: boolean = true) => value;
return {
canAccessDashboards: () => check(canAccessDashboards),
canCreateNewDashboards: () => check(canCreateNewDashboards),
canEditDashboards: () => check(canEditDashboards),
canSaveVisualizations: () => check(canSaveVisualizations),
};
};

View file

@ -18,6 +18,7 @@ export interface StorybookParams {
canAccessDashboards?: boolean;
canCreateNewDashboards?: boolean;
canEditDashboards?: boolean;
canSaveVisualizations?: boolean;
}
export const providers: PluginServiceProviders<PresentationUtilServices, StorybookParams> = {

View file

@ -15,4 +15,5 @@ export const capabilitiesServiceFactory: CapabilitiesServiceFactory = () => ({
canAccessDashboards: () => true,
canCreateNewDashboards: () => true,
canEditDashboards: () => true,
canSaveVisualizations: () => true,
});

View file

@ -67,7 +67,10 @@ export const createVisEmbeddableFromObject = (deps: VisualizeEmbeddableFactoryDe
indexPatterns = [vis.data.indexPattern];
}
const editable = getCapabilities().visualize.save as boolean;
const capabilities = {
visualizeSave: Boolean(getCapabilities().visualize.save),
dashboardSave: Boolean(getCapabilities().dashboard?.showWriteControls),
};
return new VisualizeEmbeddable(
getTimeFilter(),
@ -76,8 +79,8 @@ export const createVisEmbeddableFromObject = (deps: VisualizeEmbeddableFactoryDe
indexPatterns,
editPath,
editUrl,
editable,
deps,
capabilities,
},
input,
attributeService,

View file

@ -50,7 +50,7 @@ export interface VisualizeEmbeddableConfiguration {
indexPatterns?: IIndexPattern[];
editPath: string;
editUrl: string;
editable: boolean;
capabilities: { visualizeSave: boolean; dashboardSave: boolean };
deps: VisualizeEmbeddableFactoryDeps;
}
@ -111,7 +111,7 @@ export class VisualizeEmbeddable
constructor(
timefilter: TimefilterContract,
{ vis, editPath, editUrl, indexPatterns, editable, deps }: VisualizeEmbeddableConfiguration,
{ vis, editPath, editUrl, indexPatterns, deps, capabilities }: VisualizeEmbeddableConfiguration,
initialInput: VisualizeInput,
attributeService?: AttributeService<
VisualizeSavedObjectAttributes,
@ -129,7 +129,6 @@ export class VisualizeEmbeddable
editApp: 'visualize',
editUrl,
indexPatterns,
editable,
visTypeName: vis.type.name,
},
parent
@ -143,6 +142,12 @@ export class VisualizeEmbeddable
this.attributeService = attributeService;
this.savedVisualizationsLoader = savedVisualizationsLoader;
if (this.attributeService) {
const isByValue = !this.inputIsRefType(initialInput);
const editable = capabilities.visualizeSave || (isByValue && capabilities.dashboardSave);
this.updateOutput({ ...this.getOutput(), editable });
}
this.subscriptions.push(
this.getUpdated$().subscribe(() => {
const isDirty = this.handleChanges();

View file

@ -82,6 +82,7 @@ export const getTopNavConfig = (
setActiveUrl,
toastNotifications,
visualizeCapabilities,
dashboardCapabilities,
i18n: { Context: I18nContext },
dashboard,
savedObjectsTagging,
@ -205,9 +206,9 @@ export const getTopNavConfig = (
}
};
const allowByValue = dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables;
const saveButtonLabel =
embeddableId ||
(!savedVis.id && dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables && originatingApp)
embeddableId || (!savedVis.id && allowByValue && originatingApp)
? i18n.translate('visualize.topNavMenu.saveVisualizationToLibraryButtonLabel', {
defaultMessage: 'Save to library',
})
@ -219,9 +220,11 @@ export const getTopNavConfig = (
defaultMessage: 'Save',
});
const showSaveAndReturn =
originatingApp &&
(savedVis?.id || dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables);
const showSaveAndReturn = originatingApp && (savedVis?.id || allowByValue);
const showSaveButton =
visualizeCapabilities.save ||
(allowByValue && !showSaveAndReturn && dashboardCapabilities.showWriteControls);
const topNavMenu: TopNavMenuData[] = [
{
@ -300,7 +303,7 @@ export const getTopNavConfig = (
},
]
: []),
...(visualizeCapabilities.save
...(showSaveButton
? [
{
id: 'save',
@ -439,7 +442,12 @@ export const getTopNavConfig = (
/>
) : (
<SavedObjectSaveModalDashboard
documentInfo={savedVis || { title: '' }}
documentInfo={{
id: visualizeCapabilities.save ? savedVis?.id : undefined,
title: savedVis?.title || '',
description: savedVis?.description || '',
}}
canSaveByReference={Boolean(visualizeCapabilities.save)}
onSave={onSave}
tagOptions={tagOptions}
objectType={'visualization'}
@ -455,7 +463,7 @@ export const getTopNavConfig = (
},
]
: []),
...(visualizeCapabilities.save && showSaveAndReturn
...(showSaveAndReturn
? [
{
id: 'saveAndReturn',
@ -471,7 +479,7 @@ export const getTopNavConfig = (
}
),
testId: 'visualizesaveAndReturnButton',
disableButton: hasUnappliedChanges,
disableButton: hasUnappliedChanges || !dashboardCapabilities.showWriteControls,
tooltip() {
if (hasUnappliedChanges) {
return i18n.translate(

View file

@ -32,7 +32,7 @@ export const addBadgeToAppChrome = (chrome: ChromeStart) => {
defaultMessage: 'Read only',
}),
tooltip: i18n.translate('visualize.badge.readOnly.tooltip', {
defaultMessage: 'Unable to save visualizations',
defaultMessage: 'Unable to save visualizations to the library',
}),
iconType: 'glasses',
});

View file

@ -531,7 +531,13 @@ export function App({
const { TopNavMenu } = navigation.ui;
const savingPermitted = Boolean(state.isSaveable && application.capabilities.visualize.save);
const savingToLibraryPermitted = Boolean(
state.isSaveable && application.capabilities.visualize.save
);
const savingToDashboardPermitted = Boolean(
state.isSaveable && application.capabilities.dashboard?.showWriteControls
);
const unsavedTitle = i18n.translate('xpack.lens.app.unsavedFilename', {
defaultMessage: 'unsaved',
});
@ -545,8 +551,10 @@ export function App({
state.isSaveable && state.activeData && Object.keys(state.activeData).length
),
isByValueMode: getIsByValueMode(),
allowByValue: dashboardFeatureFlag.allowByValueEmbeddables,
showCancel: Boolean(state.isLinkedToOriginatingApp),
savingPermitted,
savingToLibraryPermitted,
savingToDashboardPermitted,
actions: {
exportToCSV: () => {
if (!state.activeData) {
@ -577,7 +585,7 @@ export function App({
}
},
saveAndReturn: () => {
if (savingPermitted && lastKnownDoc) {
if (savingToDashboardPermitted && lastKnownDoc) {
// disabling the validation on app leave because the document has been saved.
onAppLeave((actions) => {
return actions.default();
@ -597,7 +605,7 @@ export function App({
}
},
showSaveModal: () => {
if (savingPermitted) {
if (savingToDashboardPermitted || savingToLibraryPermitted) {
setState((s) => ({ ...s, isSaveModalVisible: true }));
}
},
@ -697,6 +705,7 @@ export function App({
<SaveModal
isVisible={state.isSaveModalVisible}
originatingApp={state.isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined}
savingToLibraryPermitted={savingToLibraryPermitted}
allowByValueEmbeddables={dashboardFeatureFlag.allowByValueEmbeddables}
savedObjectsTagging={savedObjectsTagging}
tagsIds={tagsIds}

View file

@ -14,12 +14,29 @@ export function getLensTopNavConfig(options: {
enableExportToCSV: boolean;
showCancel: boolean;
isByValueMode: boolean;
allowByValue: boolean;
actions: LensTopNavActions;
savingPermitted: boolean;
savingToLibraryPermitted: boolean;
savingToDashboardPermitted: boolean;
}): TopNavMenuData[] {
const { showSaveAndReturn, showCancel, actions, savingPermitted, enableExportToCSV } = options;
const {
actions,
showCancel,
allowByValue,
enableExportToCSV,
showSaveAndReturn,
savingToLibraryPermitted,
savingToDashboardPermitted,
} = options;
const topNavMenu: TopNavMenuData[] = [];
const enableSaveButton =
savingToLibraryPermitted ||
(allowByValue &&
savingToDashboardPermitted &&
!options.isByValueMode &&
!options.showSaveAndReturn);
const saveButtonLabel = options.isByValueMode
? i18n.translate('xpack.lens.app.addToLibrary', {
defaultMessage: 'Save to library',
@ -66,7 +83,7 @@ export function getLensTopNavConfig(options: {
description: i18n.translate('xpack.lens.app.saveButtonAriaLabel', {
defaultMessage: 'Save the current lens visualization',
}),
disableButton: !savingPermitted,
disableButton: !enableSaveButton,
});
if (showSaveAndReturn) {
@ -78,7 +95,7 @@ export function getLensTopNavConfig(options: {
iconType: 'checkInCircleFilled',
run: actions.saveAndReturn,
testId: 'lnsApp_saveAndReturnButton',
disableButton: !savingPermitted,
disableButton: !savingToDashboardPermitted,
description: i18n.translate('xpack.lens.app.saveAndReturnButtonAriaLabel', {
defaultMessage: 'Save the current lens visualization and return to the last app',
}),

View file

@ -24,6 +24,7 @@ export type SaveProps = OriginSaveProps | DashboardSaveProps;
export interface Props {
isVisible: boolean;
savingToLibraryPermitted?: boolean;
originatingApp?: string;
allowByValueEmbeddables: boolean;
@ -47,6 +48,7 @@ export const SaveModal = (props: Props) => {
const {
originatingApp,
savingToLibraryPermitted,
savedObjectsTagging,
tagsIds,
lastKnownDoc,
@ -85,13 +87,15 @@ export const SaveModal = (props: Props) => {
<TagEnhancedSavedObjectSaveModalDashboard
savedObjectsTagging={savedObjectsTagging}
initialTags={tagsIds}
canSaveByReference={Boolean(savingToLibraryPermitted)}
onSave={(saveProps) => {
const saveToLibrary = Boolean(saveProps.addToLibrary);
onSave(saveProps, { saveToLibrary });
}}
onClose={onClose}
documentInfo={{
id: lastKnownDoc.savedObjectId,
// if the user cannot save to the library - treat this as a new document.
id: savingToLibraryPermitted ? lastKnownDoc.savedObjectId : undefined,
title: lastKnownDoc.title || '',
description: lastKnownDoc.description || '',
}}

View file

@ -112,7 +112,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -151,7 +154,7 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: { canSaveDashboards: true, canSaveVisualizations: true },
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -191,7 +194,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -231,7 +237,10 @@ describe('embeddable', () => {
indexPatternService: ({
get: (id: string) => Promise.resolve({ id }),
} as unknown) as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -266,7 +275,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -307,7 +319,7 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: { canSaveDashboards: true, canSaveVisualizations: true },
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -352,7 +364,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -395,7 +410,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -445,7 +463,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -495,7 +516,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -544,7 +568,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: ({ get: jest.fn() } as unknown) as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -582,7 +609,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -620,7 +650,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -658,7 +691,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -711,7 +747,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -780,7 +819,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -824,7 +866,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({
@ -868,7 +913,10 @@ describe('embeddable', () => {
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
},
getTrigger,
documentToExpression: () =>
Promise.resolve({

View file

@ -88,13 +88,13 @@ export interface LensEmbeddableDeps {
documentToExpression: (
doc: Document
) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>;
editable: boolean;
indexPatternService: IndexPatternsContract;
expressionRenderer: ReactExpressionRendererType;
timefilter: TimefilterContract;
basePath: IBasePath;
getTrigger?: UiActionsStart['getTrigger'] | undefined;
getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions'];
capabilities: { canSaveVisualizations: boolean; canSaveDashboards: boolean };
}
export class Embeddable
@ -129,7 +129,6 @@ export class Embeddable
initialInput,
{
editApp: 'lens',
editable: deps.editable,
},
parent
);
@ -326,7 +325,7 @@ export class Embeddable
hasCompatibleActions={this.hasCompatibleActions}
className={input.className}
style={input.style}
canEdit={this.deps.editable && input.viewMode === 'edit'}
canEdit={this.getIsEditable() && input.viewMode === 'edit'}
/>,
domNode
);
@ -451,6 +450,7 @@ export class Embeddable
this.updateOutput({
...this.getOutput(),
defaultTitle: this.savedVis.title,
editable: this.getIsEditable(),
title,
editPath: getEditPath(savedObjectId),
editUrl: this.deps.basePath.prepend(`/app/lens${getEditPath(savedObjectId)}`),
@ -458,6 +458,13 @@ export class Embeddable
});
}
private getIsEditable() {
return (
this.deps.capabilities.canSaveVisualizations ||
(!this.inputIsRefType(this.getInput()) && this.deps.capabilities.canSaveDashboards)
);
}
public inputIsRefType = (
input: LensByValueInput | LensByReferenceInput
): input is LensByReferenceInput => {

View file

@ -53,7 +53,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
public isEditable = async () => {
const { capabilities } = await this.getStartServices();
return capabilities.visualize.save as boolean;
return Boolean(capabilities.visualize.save || capabilities.dashboard?.showWriteControls);
};
canCreateNew() {
@ -86,6 +86,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
coreHttp,
attributeService,
indexPatternService,
capabilities,
} = await this.getStartServices();
const { Embeddable } = await import('../../async_services');
@ -96,11 +97,14 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
indexPatternService,
timefilter,
expressionRenderer,
editable: await this.isEditable(),
basePath: coreHttp.basePath,
getTrigger: uiActions?.getTrigger,
getTriggerCompatibleActions: uiActions?.getTriggerCompatibleActions,
documentToExpression,
capabilities: {
canSaveDashboards: Boolean(capabilities.dashboard?.showWriteControls),
canSaveVisualizations: Boolean(capabilities.visualize.save),
},
},
input,
parent

View file

@ -201,7 +201,11 @@ export function getTopNavConfig({
options={tagSelector}
/>
) : (
<SavedObjectSaveModalDashboard {...saveModalProps} tagOptions={tagSelector} />
<SavedObjectSaveModalDashboard
{...saveModalProps}
canSaveByReference={true} // we know here that we have save capabilities.
tagOptions={tagSelector}
/>
);
showSaveModal(saveModal, getCoreI18n().Context, PresentationUtilContext);

View file

@ -11,6 +11,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('feature controls', function () {
this.tags(['skipFirefox']);
loadTestFile(require.resolve('./dashboard_security'));
loadTestFile(require.resolve('./time_to_visualize_security'));
loadTestFile(require.resolve('./dashboard_spaces'));
});
}

View file

@ -0,0 +1,233 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const PageObjects = getPageObjects([
'timeToVisualize',
'timePicker',
'dashboard',
'visEditor',
'visualize',
'security',
'common',
'header',
'lens',
]);
const dashboardVisualizations = getService('dashboardVisualizations');
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardExpect = getService('dashboardExpect');
const testSubjects = getService('testSubjects');
const esArchiver = getService('esArchiver');
const security = getService('security');
const find = getService('find');
describe('dashboard time to visualize security', () => {
before(async () => {
await esArchiver.load('dashboard/feature_controls/security');
await esArchiver.loadIfNeeded('logstash_functional');
// ensure we're logged out so we can login as the appropriate users
await PageObjects.security.forceLogout();
await security.role.create('dashboard_write_vis_read', {
elasticsearch: {
indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
},
kibana: [
{
feature: {
dashboard: ['all'],
visualize: ['read'],
},
spaces: ['*'],
},
],
});
await security.user.create('dashboard_write_vis_read_user', {
password: 'dashboard_write_vis_read_user-password',
roles: ['dashboard_write_vis_read'],
full_name: 'test user',
});
await PageObjects.security.login(
'dashboard_write_vis_read_user',
'dashboard_write_vis_read_user-password',
{
expectSpaceSelector: false,
}
);
});
after(async () => {
await security.role.delete('dashboard_write_vis_read');
await security.user.delete('dashboard_write_vis_read_user');
await esArchiver.unload('dashboard/feature_controls/security');
// logout, so the other tests don't accidentally run as the custom users we're testing below
await PageObjects.security.forceLogout();
});
describe('lens by value works without library save permissions', () => {
before(async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.clickNewDashboard();
});
it('can add a lens panel by value', async () => {
await dashboardVisualizations.ensureNewVisualizationDialogIsShowing();
await PageObjects.lens.createAndAddLensFromDashboard({});
const newPanelCount = await PageObjects.dashboard.getPanelCount();
expect(newPanelCount).to.eql(1);
});
it('edits to a by value lens panel are properly applied', async () => {
await PageObjects.dashboard.waitForRenderComplete();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await PageObjects.lens.switchToVisualization('donut');
await PageObjects.lens.saveAndReturn();
await PageObjects.dashboard.waitForRenderComplete();
const pieExists = await find.existsByCssSelector('.lnsPieExpression__container');
expect(pieExists).to.be(true);
});
it('disables save to library button without visualize save permissions', async () => {
await PageObjects.dashboard.waitForRenderComplete();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
const saveButton = await testSubjects.find('lnsApp_saveButton');
expect(await saveButton.getAttribute('disabled')).to.equal('true');
await PageObjects.lens.saveAndReturn();
await PageObjects.timeToVisualize.resetNewDashboard();
});
it('should allow new lens to be added by value, but not by reference', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'average',
field: 'bytes',
});
await PageObjects.lens.switchToVisualization('lnsMetric');
await PageObjects.lens.waitForVisualization();
await PageObjects.lens.assertMetric('Average of bytes', '5,727.322');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('lnsApp_saveButton');
const libraryCheckbox = await find.byCssSelector('#add-to-library-checkbox');
expect(await libraryCheckbox.getAttribute('disabled')).to.equal('true');
await PageObjects.timeToVisualize.saveFromModal('New Lens from Modal', {
addToDashboard: 'new',
saveAsNew: true,
saveToLibrary: false,
});
await PageObjects.dashboard.waitForRenderComplete();
await PageObjects.lens.assertMetric('Average of bytes', '5,727.322');
const isLinked = await PageObjects.timeToVisualize.libraryNotificationExists(
'New Lens from Modal'
);
expect(isLinked).to.be(false);
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.eql(1);
await PageObjects.timeToVisualize.resetNewDashboard();
});
});
describe('visualize by value works without library save permissions', () => {
const originalMarkdownText = 'Original markdown text';
const modifiedMarkdownText = 'Modified markdown text';
before(async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.clickNewDashboard();
});
it('can add a markdown panel by value', async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.dashboard.waitForRenderComplete();
await testSubjects.click('dashboardAddNewPanelButton');
await dashboardVisualizations.ensureNewVisualizationDialogIsShowing();
await PageObjects.visualize.clickMarkdownWidget();
await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText);
await PageObjects.visEditor.clickGo();
await PageObjects.visualize.saveVisualizationAndReturn();
const newPanelCount = await PageObjects.dashboard.getPanelCount();
expect(newPanelCount).to.eql(1);
});
it('edits to a by value visualize panel are properly applied', async () => {
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.visEditor.setMarkdownTxt(modifiedMarkdownText);
await PageObjects.visEditor.clickGo();
await PageObjects.visualize.saveVisualizationAndReturn();
await PageObjects.dashboard.waitForRenderComplete();
const markdownText = await testSubjects.find('markdownBody');
expect(await markdownText.getVisibleText()).to.eql(modifiedMarkdownText);
const newPanelCount = PageObjects.dashboard.getPanelCount();
expect(newPanelCount).to.eql(1);
});
it('disables save to library button without visualize save permissions', async () => {
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.missingOrFail('visualizeSaveButton');
await PageObjects.visualize.saveVisualizationAndReturn();
await PageObjects.timeToVisualize.resetNewDashboard();
});
it('should allow new visualization to be added by value, but not by reference', async function () {
await PageObjects.visualize.navigateToNewAggBasedVisualization();
await PageObjects.visualize.clickMetric();
await PageObjects.visualize.clickNewSearch();
await PageObjects.timePicker.setDefaultAbsoluteRange();
await testSubjects.click('visualizeSaveButton');
await PageObjects.visualize.ensureSavePanelOpen();
const libraryCheckbox = await find.byCssSelector('#add-to-library-checkbox');
expect(await libraryCheckbox.getAttribute('disabled')).to.equal('true');
await PageObjects.timeToVisualize.saveFromModal('My New Vis 1', {
addToDashboard: 'new',
});
await PageObjects.dashboard.waitForRenderComplete();
await dashboardExpect.metricValuesExist(['14,005']);
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.eql(1);
});
});
});
}