[Dashboard] Add Readonly State For Managed Dashboards (#166204)

Adds a new `managed` mode to Dashboards. When a user with write permissions opens a managed Dashboard they will be unable to hit the `edit` button - and will instead be prompted to clone the Dashboard first.
This commit is contained in:
Devon Thomson 2023-09-21 11:04:23 -04:00 committed by GitHub
parent 4f0a43d145
commit 8bd25152bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 185 additions and 64 deletions

View file

@ -36,7 +36,7 @@ export type TableListViewProps<T extends UserContentCommonSchema = UserContentCo
| 'contentEditor' | 'contentEditor'
| 'titleColumnName' | 'titleColumnName'
| 'withoutPageTemplateWrapper' | 'withoutPageTemplateWrapper'
| 'showEditActionForItem' | 'itemIsEditable'
> & { > & {
title: string; title: string;
description?: string; description?: string;
@ -73,6 +73,7 @@ export const TableListView = <T extends UserContentCommonSchema>({
titleColumnName, titleColumnName,
additionalRightSideActions, additionalRightSideActions,
withoutPageTemplateWrapper, withoutPageTemplateWrapper,
itemIsEditable,
}: TableListViewProps<T>) => { }: TableListViewProps<T>) => {
const PageTemplate = withoutPageTemplateWrapper const PageTemplate = withoutPageTemplateWrapper
? (React.Fragment as unknown as typeof KibanaPageTemplate) ? (React.Fragment as unknown as typeof KibanaPageTemplate)
@ -118,6 +119,7 @@ export const TableListView = <T extends UserContentCommonSchema>({
id={listingId} id={listingId}
contentEditor={contentEditor} contentEditor={contentEditor}
titleColumnName={titleColumnName} titleColumnName={titleColumnName}
itemIsEditable={itemIsEditable}
withoutPageTemplateWrapper={withoutPageTemplateWrapper} withoutPageTemplateWrapper={withoutPageTemplateWrapper}
onFetchSuccess={onFetchSuccess} onFetchSuccess={onFetchSuccess}
setPageDataTestSubject={setPageDataTestSubject} setPageDataTestSubject={setPageDataTestSubject}

View file

@ -90,10 +90,12 @@ export interface TableListViewTableProps<
* Edit action onClick handler. Edit action not provided when property is not provided * Edit action onClick handler. Edit action not provided when property is not provided
*/ */
editItem?(item: T): void; editItem?(item: T): void;
/** /**
* Handler to set edit action visiblity per item. * Handler to set edit action visiblity, and content editor readonly state per item. If not provided all non-managed items are considered editable. Note: Items with the managed property set to true will always be non-editable.
*/ */
showEditActionForItem?(item: T): boolean; itemIsEditable?(item: T): boolean;
/** /**
* Name for the column containing the "title" value. * Name for the column containing the "title" value.
*/ */
@ -144,6 +146,7 @@ export interface State<T extends UserContentCommonSchema = UserContentCommonSche
export interface UserContentCommonSchema { export interface UserContentCommonSchema {
id: string; id: string;
updatedAt: string; updatedAt: string;
managed?: boolean;
references: SavedObjectsReference[]; references: SavedObjectsReference[];
type: string; type: string;
attributes: { attributes: {
@ -264,7 +267,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
findItems, findItems,
createItem, createItem,
editItem, editItem,
showEditActionForItem, itemIsEditable,
deleteItems, deleteItems,
getDetailViewLink, getDetailViewLink,
onClickTitle, onClickTitle,
@ -451,6 +454,15 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
items, items,
}); });
const isEditable = useCallback(
(item: T) => {
// If the So is `managed` it is never editable.
if (item.managed) return false;
return itemIsEditable?.(item) ?? true;
},
[itemIsEditable]
);
const inspectItem = useCallback( const inspectItem = useCallback(
(item: T) => { (item: T) => {
const tags = getTagIdsFromReferences(item.references).map((_id) => { const tags = getTagIdsFromReferences(item.references).map((_id) => {
@ -466,6 +478,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
}, },
entityName, entityName,
...contentEditor, ...contentEditor,
isReadonly: contentEditor.isReadonly || !isEditable(item),
onSave: onSave:
contentEditor.onSave && contentEditor.onSave &&
(async (args) => { (async (args) => {
@ -476,7 +489,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
}), }),
}); });
}, },
[getTagIdsFromReferences, openContentEditor, entityName, contentEditor, fetchItems] [getTagIdsFromReferences, openContentEditor, entityName, contentEditor, isEditable, fetchItems]
); );
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
@ -550,7 +563,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
), ),
icon: 'pencil', icon: 'pencil',
type: 'icon', type: 'icon',
available: (v) => (showEditActionForItem ? showEditActionForItem(v) : true), available: (item) => isEditable(item),
enabled: (v) => !(v as unknown as { error: string })?.error, enabled: (v) => !(v as unknown as { error: string })?.error,
onClick: editItem, onClick: editItem,
'data-test-subj': `edit-action`, 'data-test-subj': `edit-action`,
@ -598,16 +611,16 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
customTableColumn, customTableColumn,
hasUpdatedAtMetadata, hasUpdatedAtMetadata,
editItem, editItem,
contentEditor.enabled,
listingId, listingId,
getDetailViewLink, getDetailViewLink,
onClickTitle, onClickTitle,
searchQuery.text, searchQuery.text,
addOrRemoveIncludeTagFilter,
addOrRemoveExcludeTagFilter, addOrRemoveExcludeTagFilter,
addOrRemoveIncludeTagFilter,
DateFormatterComp, DateFormatterComp,
contentEditor, isEditable,
inspectItem, inspectItem,
showEditActionForItem,
]); ]);
const itemsById = useMemo(() => { const itemsById = useMemo(() => {

View file

@ -43,11 +43,13 @@ function savedObjectToItem<Attributes extends object>(
error, error,
namespaces, namespaces,
version, version,
managed,
} = savedObject; } = savedObject;
return { return {
id, id,
type, type,
managed,
updatedAt, updatedAt,
createdAt, createdAt,
attributes: pick(attributes, allowedSavedObjectAttributes), attributes: pick(attributes, allowedSavedObjectAttributes),

View file

@ -69,11 +69,13 @@ function savedObjectToItem<Attributes extends object>(
error, error,
namespaces, namespaces,
version, version,
managed,
} = savedObject; } = savedObject;
return { return {
id, id,
type, type,
managed,
updatedAt, updatedAt,
createdAt, createdAt,
attributes: pick(attributes, allowedSavedObjectAttributes), attributes: pick(attributes, allowedSavedObjectAttributes),

View file

@ -200,6 +200,7 @@ export interface SOWithMetadata<Attributes extends object = object> {
statusCode: number; statusCode: number;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
managed?: boolean;
attributes: Attributes; attributes: Attributes;
references: Reference[]; references: Reference[];
namespaces?: string[]; namespaces?: string[];

View file

@ -25,6 +25,17 @@ export const dashboardReadonlyBadge = {
}), }),
}; };
export const dashboardManagedBadge = {
getText: () =>
i18n.translate('dashboard.badge.managed.text', {
defaultMessage: 'Managed',
}),
getTooltip: () =>
i18n.translate('dashboard.badge.managed.tooltip', {
defaultMessage: 'This dashboard is system managed. Clone this dashboard to make changes.',
}),
};
/** /**
* @param title {string} the current title of the dashboard * @param title {string} the current title of the dashboard
* @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title.

View file

@ -24,6 +24,7 @@ import {
leaveConfirmStrings, leaveConfirmStrings,
getDashboardBreadcrumb, getDashboardBreadcrumb,
unsavedChangesBadgeStrings, unsavedChangesBadgeStrings,
dashboardManagedBadge,
} from '../_dashboard_app_strings'; } from '../_dashboard_app_strings';
import { UI_SETTINGS } from '../../../common'; import { UI_SETTINGS } from '../../../common';
import { useDashboardAPI } from '../dashboard_app'; import { useDashboardAPI } from '../dashboard_app';
@ -67,7 +68,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
navigation: { TopNavMenu }, navigation: { TopNavMenu },
embeddable: { getStateTransfer }, embeddable: { getStateTransfer },
initializerContext: { allowByValueEmbeddables }, initializerContext: { allowByValueEmbeddables },
dashboardCapabilities: { saveQuery: showSaveQuery }, dashboardCapabilities: { saveQuery: showSaveQuery, showWriteControls },
} = pluginServices.getServices(); } = pluginServices.getServices();
const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI); const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI);
const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext(); const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext();
@ -82,6 +83,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
const fullScreenMode = dashboard.select((state) => state.componentState.fullScreenMode); const fullScreenMode = dashboard.select((state) => state.componentState.fullScreenMode);
const savedQueryId = dashboard.select((state) => state.componentState.savedQueryId); const savedQueryId = dashboard.select((state) => state.componentState.savedQueryId);
const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId); const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId);
const managed = dashboard.select((state) => state.componentState.managed);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode); const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const query = dashboard.select((state) => state.explicitInput.query); const query = dashboard.select((state) => state.explicitInput.query);
@ -237,9 +239,8 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
}); });
const badges = useMemo(() => { const badges = useMemo(() => {
if (viewMode !== ViewMode.EDIT) return;
const allBadges: TopNavMenuProps['badges'] = []; const allBadges: TopNavMenuProps['badges'] = [];
if (hasUnsavedChanges) { if (hasUnsavedChanges && viewMode === ViewMode.EDIT) {
allBadges.push({ allBadges.push({
'data-test-subj': 'dashboardUnsavedChangesBadge', 'data-test-subj': 'dashboardUnsavedChangesBadge',
badgeText: unsavedChangesBadgeStrings.getUnsavedChangedBadgeText(), badgeText: unsavedChangesBadgeStrings.getUnsavedChangedBadgeText(),
@ -251,7 +252,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
} as EuiToolTipProps, } as EuiToolTipProps,
}); });
} }
if (hasRunMigrations) { if (hasRunMigrations && viewMode === ViewMode.EDIT) {
allBadges.push({ allBadges.push({
'data-test-subj': 'dashboardSaveRecommendedBadge', 'data-test-subj': 'dashboardSaveRecommendedBadge',
badgeText: unsavedChangesBadgeStrings.getHasRunMigrationsText(), badgeText: unsavedChangesBadgeStrings.getHasRunMigrationsText(),
@ -264,8 +265,21 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
} as EuiToolTipProps, } as EuiToolTipProps,
}); });
} }
if (showWriteControls && managed) {
allBadges.push({
'data-test-subj': 'dashboardSaveRecommendedBadge',
badgeText: dashboardManagedBadge.getText(),
title: '',
color: 'primary',
iconType: 'glasses',
toolTipProps: {
content: dashboardManagedBadge.getTooltip(),
position: 'bottom',
} as EuiToolTipProps,
});
}
return allBadges; return allBadges;
}, [hasRunMigrations, hasUnsavedChanges, viewMode]); }, [hasUnsavedChanges, viewMode, hasRunMigrations, showWriteControls, managed]);
return ( return (
<div className="dashboardTopNav"> <div className="dashboardTopNav">

View file

@ -56,6 +56,7 @@ export const useDashboardMenuItems = ({
const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId); const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId);
const dashboardTitle = dashboard.select((state) => state.explicitInput.title); const dashboardTitle = dashboard.select((state) => state.explicitInput.title);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode); const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const managed = dashboard.select((state) => state.componentState.managed);
const disableTopNav = isSaveInProgress || hasOverlays; const disableTopNav = isSaveInProgress || hasOverlays;
/** /**
@ -265,7 +266,7 @@ export const useDashboardMenuItems = ({
const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; const labsMenuItem = isLabsEnabled ? [menuItems.labs] : [];
const shareMenuItem = share ? [menuItems.share] : []; const shareMenuItem = share ? [menuItems.share] : [];
const cloneMenuItem = showWriteControls ? [menuItems.clone] : []; const cloneMenuItem = showWriteControls ? [menuItems.clone] : [];
const editMenuItem = showWriteControls ? [menuItems.edit] : []; const editMenuItem = showWriteControls && !managed ? [menuItems.edit] : [];
return [ return [
...labsMenuItem, ...labsMenuItem,
menuItems.fullScreen, menuItems.fullScreen,
@ -274,7 +275,7 @@ export const useDashboardMenuItems = ({
resetChangesMenuItem, resetChangesMenuItem,
...editMenuItem, ...editMenuItem,
]; ];
}, [menuItems, share, showWriteControls, resetChangesMenuItem, isLabsEnabled]); }, [isLabsEnabled, menuItems, share, showWriteControls, managed, resetChangesMenuItem]);
const editModeTopNavConfig = useMemo(() => { const editModeTopNavConfig = useMemo(() => {
const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; const labsMenuItem = isLabsEnabled ? [menuItems.labs] : [];

View file

@ -33,10 +33,11 @@ export function runSaveAs(this: DashboardContainer) {
const { const {
explicitInput: currentState, explicitInput: currentState,
componentState: { lastSavedId }, componentState: { lastSavedId, managed },
} = this.getState(); } = this.getState();
return new Promise<SaveDashboardReturn | undefined>((resolve) => { return new Promise<SaveDashboardReturn | undefined>((resolve) => {
if (managed) resolve(undefined);
const onSave = async ({ const onSave = async ({
newTags, newTags,
newTitle, newTitle,
@ -132,9 +133,11 @@ export async function runQuickSave(this: DashboardContainer) {
const { const {
explicitInput: currentState, explicitInput: currentState,
componentState: { lastSavedId }, componentState: { lastSavedId, managed },
} = this.getState(); } = this.getState();
if (managed) return;
const saveResult = await saveDashboardState({ const saveResult = await saveDashboardState({
lastSavedId, lastSavedId,
currentState, currentState,

View file

@ -94,6 +94,21 @@ test('pulls state from dashboard saved object when given a saved object id', asy
expect(dashboard!.getState().explicitInput.description).toBe(`wow would you look at that? Wow.`); expect(dashboard!.getState().explicitInput.description).toBe(`wow would you look at that? Wow.`);
}); });
test('passes managed state from the saved object into the Dashboard component state', async () => {
pluginServices.getServices().dashboardContentManagement.loadDashboardState = jest
.fn()
.mockResolvedValue({
dashboardInput: {
...DEFAULT_DASHBOARD_INPUT,
description: 'wow this description is okay',
},
managed: true,
});
const dashboard = await createDashboard({}, 0, 'what-an-id');
expect(dashboard).toBeDefined();
expect(dashboard!.getState().componentState.managed).toBe(true);
});
test('pulls state from session storage which overrides state from saved object', async () => { test('pulls state from session storage which overrides state from saved object', async () => {
pluginServices.getServices().dashboardContentManagement.loadDashboardState = jest pluginServices.getServices().dashboardContentManagement.loadDashboardState = jest
.fn() .fn()

View file

@ -29,6 +29,7 @@ import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_
import { DEFAULT_DASHBOARD_INPUT, GLOBAL_STATE_STORAGE_KEY } from '../../../dashboard_constants'; import { DEFAULT_DASHBOARD_INPUT, GLOBAL_STATE_STORAGE_KEY } from '../../../dashboard_constants';
import { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_integration'; import { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_integration';
import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration'; import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration';
import { DashboardPublicState } from '../../types';
/** /**
* Builds a new Dashboard from scratch. * Builds a new Dashboard from scratch.
@ -86,16 +87,27 @@ export const createDashboard = async (
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
// Build and return the dashboard container. // Build and return the dashboard container.
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const initialComponentState: DashboardPublicState = {
lastSavedInput: savedObjectResult?.dashboardInput ?? {
...DEFAULT_DASHBOARD_INPUT,
id: input.id,
},
hasRunClientsideMigrations: savedObjectResult.anyMigrationRun,
isEmbeddedExternally: creationOptions?.isEmbeddedExternally,
animatePanelTransforms: false, // set panel transforms to false initially to avoid panels animating on initial render.
hasUnsavedChanges: false, // if there is initial unsaved changes, the initial diff will catch them.
managed: savedObjectResult.managed,
lastSavedId: savedObjectId,
};
const dashboardContainer = new DashboardContainer( const dashboardContainer = new DashboardContainer(
input, input,
reduxEmbeddablePackage, reduxEmbeddablePackage,
searchSessionId, searchSessionId,
savedObjectResult?.dashboardInput,
savedObjectResult.anyMigrationRun,
dashboardCreationStartTime, dashboardCreationStartTime,
undefined, undefined,
creationOptions, creationOptions,
savedObjectId initialComponentState
); );
dashboardContainerReady$.next(dashboardContainer); dashboardContainerReady$.next(dashboardContainer);
return dashboardContainer; return dashboardContainer;

View file

@ -167,10 +167,15 @@ test('Container view mode change propagates to new children', async () => {
test('searchSessionId propagates to children', async () => { test('searchSessionId propagates to children', async () => {
const searchSessionId1 = 'searchSessionId1'; const searchSessionId1 = 'searchSessionId1';
const sampleInput = getSampleDashboardInput();
const container = new DashboardContainer( const container = new DashboardContainer(
getSampleDashboardInput(), sampleInput,
mockedReduxEmbeddablePackage, mockedReduxEmbeddablePackage,
searchSessionId1 searchSessionId1,
0,
undefined,
undefined,
{ lastSavedInput: sampleInput }
); );
const embeddable = await container.addNewEmbeddable< const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput, ContactCardEmbeddableInput,

View file

@ -11,7 +11,6 @@ import { batch } from 'react-redux';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import { import {
ViewMode, ViewMode,
Container, Container,
@ -20,6 +19,10 @@ import {
type EmbeddableOutput, type EmbeddableOutput,
type EmbeddableFactory, type EmbeddableFactory,
} from '@kbn/embeddable-plugin/public'; } from '@kbn/embeddable-plugin/public';
import {
getDefaultControlGroupInput,
persistableControlGroupInputIsEqual,
} from '@kbn/controls-plugin/common';
import { I18nProvider } from '@kbn/i18n-react'; import { I18nProvider } from '@kbn/i18n-react';
import { RefreshInterval } from '@kbn/data-plugin/public'; import { RefreshInterval } from '@kbn/data-plugin/public';
import type { Filter, TimeRange, Query } from '@kbn/es-query'; import type { Filter, TimeRange, Query } from '@kbn/es-query';
@ -28,11 +31,8 @@ import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public'; import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
import {
getDefaultControlGroupInput,
persistableControlGroupInputIsEqual,
} from '@kbn/controls-plugin/common';
import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen'; import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen';
import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import { import {
runClone, runClone,
@ -44,19 +44,24 @@ import {
addOrUpdateEmbeddable, addOrUpdateEmbeddable,
} from './api'; } from './api';
import {
DashboardPublicState,
DashboardReduxState,
DashboardRenderPerformanceStats,
} from '../types';
import { DASHBOARD_CONTAINER_TYPE } from '../..'; import { DASHBOARD_CONTAINER_TYPE } from '../..';
import { createPanelState } from '../component/panel'; import { createPanelState } from '../component/panel';
import { pluginServices } from '../../services/plugin_services'; import { pluginServices } from '../../services/plugin_services';
import { initializeDashboard } from './create/create_dashboard'; import { initializeDashboard } from './create/create_dashboard';
import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants';
import { DashboardCreationOptions } from './dashboard_container_factory'; import { DashboardCreationOptions } from './dashboard_container_factory';
import { DashboardAnalyticsService } from '../../services/analytics/types'; import { DashboardAnalyticsService } from '../../services/analytics/types';
import { DashboardViewport } from '../component/viewport/dashboard_viewport'; import { DashboardViewport } from '../component/viewport/dashboard_viewport';
import { DashboardPanelState, DashboardContainerInput } from '../../../common'; import { DashboardPanelState, DashboardContainerInput } from '../../../common';
import { DashboardReduxState, DashboardRenderPerformanceStats } from '../types';
import { dashboardContainerReducers } from '../state/dashboard_container_reducers'; import { dashboardContainerReducers } from '../state/dashboard_container_reducers';
import { startDiffingDashboardState } from '../state/diffing/dashboard_diffing_integration'; import { startDiffingDashboardState } from '../state/diffing/dashboard_diffing_integration';
import { DASHBOARD_LOADED_EVENT, DEFAULT_DASHBOARD_INPUT } from '../../dashboard_constants';
import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration'; import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration';
import { DashboardCapabilitiesService } from '../../services/dashboard_capabilities/types';
export interface InheritedChildInput { export interface InheritedChildInput {
filters: Filter[]; filters: Filter[];
@ -118,6 +123,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
// Services that are used in the Dashboard container code // Services that are used in the Dashboard container code
private creationOptions?: DashboardCreationOptions; private creationOptions?: DashboardCreationOptions;
private analyticsService: DashboardAnalyticsService; private analyticsService: DashboardAnalyticsService;
private showWriteControls: DashboardCapabilitiesService['showWriteControls'];
private theme$; private theme$;
private chrome; private chrome;
private customBranding; private customBranding;
@ -126,12 +132,10 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
initialInput: DashboardContainerInput, initialInput: DashboardContainerInput,
reduxToolsPackage: ReduxToolsPackage, reduxToolsPackage: ReduxToolsPackage,
initialSessionId?: string, initialSessionId?: string,
initialLastSavedInput?: DashboardContainerInput,
anyMigrationRun?: boolean,
dashboardCreationStartTime?: number, dashboardCreationStartTime?: number,
parent?: Container, parent?: Container,
creationOptions?: DashboardCreationOptions, creationOptions?: DashboardCreationOptions,
savedObjectId?: string initialComponentState?: DashboardPublicState
) { ) {
const { const {
embeddable: { getEmbeddableFactory }, embeddable: { getEmbeddableFactory },
@ -153,6 +157,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
}, },
chrome: this.chrome, chrome: this.chrome,
customBranding: this.customBranding, customBranding: this.customBranding,
dashboardCapabilities: { showWriteControls: this.showWriteControls },
} = pluginServices.getServices()); } = pluginServices.getServices());
this.creationOptions = creationOptions; this.creationOptions = creationOptions;
@ -170,17 +175,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
embeddable: this, embeddable: this,
reducers: dashboardContainerReducers, reducers: dashboardContainerReducers,
additionalMiddleware: [diffingMiddleware], additionalMiddleware: [diffingMiddleware],
initialComponentState: { initialComponentState,
lastSavedInput: initialLastSavedInput ?? {
...DEFAULT_DASHBOARD_INPUT,
id: initialInput.id,
},
hasRunClientsideMigrations: anyMigrationRun,
isEmbeddedExternally: creationOptions?.isEmbeddedExternally,
animatePanelTransforms: false, // set panel transforms to false initially to avoid panels animating on initial render.
hasUnsavedChanges: false, // if there is initial unsaved changes, the initial diff will catch them.
lastSavedId: savedObjectId,
},
}); });
this.onStateChange = reduxTools.onStateChange; this.onStateChange = reduxTools.onStateChange;
@ -248,6 +243,19 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
); );
} }
public updateInput(changes: Partial<DashboardContainerInput>): void {
// block the Dashboard from entering edit mode if this Dashboard is managed.
if (
(this.getState().componentState.managed || !this.showWriteControls) &&
changes.viewMode?.toLowerCase() === ViewMode.EDIT?.toLowerCase()
) {
const { viewMode, ...rest } = changes;
super.updateInput(rest);
return;
}
super.updateInput(changes);
}
protected getInheritedInput(id: string): InheritedChildInput { protected getInheritedInput(id: string): InheritedChildInput {
const { const {
query, query,
@ -394,6 +402,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
this.updateInput(newInput); this.updateInput(newInput);
batch(() => { batch(() => {
this.dispatch.setLastSavedInput(loadDashboardReturn?.dashboardInput); this.dispatch.setLastSavedInput(loadDashboardReturn?.dashboardInput);
this.dispatch.setManaged(loadDashboardReturn?.managed);
this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate. this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate.
this.dispatch.setLastSavedId(newSavedObjectId); this.dispatch.setLastSavedId(newSavedObjectId);
}); });

View file

@ -7,6 +7,7 @@
*/ */
import { PayloadAction } from '@reduxjs/toolkit'; import { PayloadAction } from '@reduxjs/toolkit';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { import {
DashboardReduxState, DashboardReduxState,
@ -89,6 +90,11 @@ export const dashboardContainerReducers = {
state: DashboardReduxState, state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['viewMode']> action: PayloadAction<DashboardContainerInput['viewMode']>
) => { ) => {
// Managed Dashboards cannot be put into edit mode.
if (state.componentState.managed) {
state.explicitInput.viewMode = ViewMode.VIEW;
return;
}
state.explicitInput.viewMode = action.payload; state.explicitInput.viewMode = action.payload;
}, },
@ -103,6 +109,13 @@ export const dashboardContainerReducers = {
state.explicitInput.title = action.payload; state.explicitInput.title = action.payload;
}, },
setManaged: (
state: DashboardReduxState,
action: PayloadAction<DashboardPublicState['managed']>
) => {
state.componentState.managed = action.payload;
},
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Unsaved Changes Reducers // Unsaved Changes Reducers
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------

View file

@ -40,6 +40,7 @@ export interface DashboardPublicState {
fullScreenMode?: boolean; fullScreenMode?: boolean;
savedQueryId?: string; savedQueryId?: string;
lastSavedId?: string; lastSavedId?: string;
managed?: boolean;
scrollToPanelId?: string; scrollToPanelId?: string;
highlightPanelId?: string; highlightPanelId?: string;
} }

View file

@ -88,6 +88,7 @@ test('when showWriteControls is true, table list view is passed editing function
createItem: expect.any(Function), createItem: expect.any(Function),
deleteItems: expect.any(Function), deleteItems: expect.any(Function),
editItem: expect.any(Function), editItem: expect.any(Function),
itemIsEditable: expect.any(Function),
}), }),
expect.any(Object) // react context expect.any(Object) // react context
); );

View file

@ -149,6 +149,7 @@ describe('useDashboardListingTable', () => {
initialPageSize: 5, initialPageSize: 5,
listingLimit: 20, listingLimit: 20,
onFetchSuccess: expect.any(Function), onFetchSuccess: expect.any(Function),
itemIsEditable: expect.any(Function),
setPageDataTestSubject: expect.any(Function), setPageDataTestSubject: expect.any(Function),
title: 'Dashboard List', title: 'Dashboard List',
urlStateEnabled: false, urlStateEnabled: false,

View file

@ -7,27 +7,28 @@
*/ */
import React, { useCallback, useState, useMemo } from 'react'; import React, { useCallback, useState, useMemo } from 'react';
import type { SavedObjectsFindOptionsReference } from '@kbn/core/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { TableListViewTableProps } from '@kbn/content-management-table-list-view-table';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import type { SavedObjectsFindOptionsReference } from '@kbn/core/public';
import { OpenContentEditorParams } from '@kbn/content-management-content-editor'; import { OpenContentEditorParams } from '@kbn/content-management-content-editor';
import { DashboardContainerInput } from '../../../common'; import { TableListViewTableProps } from '@kbn/content-management-table-list-view-table';
import { DashboardListingEmptyPrompt } from '../dashboard_listing_empty_prompt';
import { pluginServices } from '../../services/plugin_services';
import { import {
DASHBOARD_CONTENT_ID, DASHBOARD_CONTENT_ID,
SAVED_OBJECT_DELETE_TIME, SAVED_OBJECT_DELETE_TIME,
SAVED_OBJECT_LOADED_TIME, SAVED_OBJECT_LOADED_TIME,
} from '../../dashboard_constants'; } from '../../dashboard_constants';
import { DashboardItem } from '../../../common/content_management';
import { import {
dashboardListingErrorStrings, dashboardListingErrorStrings,
dashboardListingTableStrings, dashboardListingTableStrings,
} from '../_dashboard_listing_strings'; } from '../_dashboard_listing_strings';
import { confirmCreateWithUnsaved } from '../confirm_overlays'; import { DashboardContainerInput } from '../../../common';
import { DashboardSavedObjectUserContent } from '../types'; import { DashboardSavedObjectUserContent } from '../types';
import { confirmCreateWithUnsaved } from '../confirm_overlays';
import { pluginServices } from '../../services/plugin_services';
import { DashboardItem } from '../../../common/content_management';
import { DashboardListingEmptyPrompt } from '../dashboard_listing_empty_prompt';
type GetDetailViewLink = type GetDetailViewLink =
TableListViewTableProps<DashboardSavedObjectUserContent>['getDetailViewLink']; TableListViewTableProps<DashboardSavedObjectUserContent>['getDetailViewLink'];
@ -42,6 +43,7 @@ const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUse
id: hit.id, id: hit.id,
updatedAt: hit.updatedAt!, updatedAt: hit.updatedAt!,
references: hit.references, references: hit.references,
managed: hit.managed,
attributes: { attributes: {
title, title,
description, description,
@ -50,14 +52,16 @@ const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUse
}; };
}; };
type DashboardListingViewTableProps = Omit<
TableListViewTableProps<DashboardSavedObjectUserContent>,
'tableCaption'
> & { title: string };
interface UseDashboardListingTableReturnType { interface UseDashboardListingTableReturnType {
hasInitialFetchReturned: boolean; hasInitialFetchReturned: boolean;
pageDataTestSubject: string | undefined; pageDataTestSubject: string | undefined;
refreshUnsavedDashboards: () => void; refreshUnsavedDashboards: () => void;
tableListViewTableProps: Omit< tableListViewTableProps: DashboardListingViewTableProps;
TableListViewTableProps<DashboardSavedObjectUserContent>,
'tableCaption'
> & { title: string };
unsavedDashboardIds: string[]; unsavedDashboardIds: string[];
} }
@ -269,7 +273,7 @@ export const useDashboardListingTable = ({
[getDashboardUrl] [getDashboardUrl]
); );
const tableListViewTableProps = useMemo( const tableListViewTableProps: DashboardListingViewTableProps = useMemo(
() => ({ () => ({
contentEditor: { contentEditor: {
isReadonly: !showWriteControls, isReadonly: !showWriteControls,
@ -279,6 +283,7 @@ export const useDashboardListingTable = ({
createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem, createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem,
deleteItems: !showWriteControls ? undefined : deleteItems, deleteItems: !showWriteControls ? undefined : deleteItems,
editItem: !showWriteControls ? undefined : editItem, editItem: !showWriteControls ? undefined : editItem,
itemIsEditable: () => showWriteControls,
emptyPrompt, emptyPrompt,
entityName, entityName,
entityNamePlural, entityNamePlural,

View file

@ -27,6 +27,7 @@ export type TableListViewApplicationService = DashboardApplicationService & {
}; };
export interface DashboardSavedObjectUserContent extends UserContentCommonSchema { export interface DashboardSavedObjectUserContent extends UserContentCommonSchema {
managed?: boolean;
attributes: { attributes: {
title: string; title: string;
description?: string; description?: string;

View file

@ -68,7 +68,15 @@ export function setupIntersectionObserverMock({
export function buildMockDashboard(overrides?: Partial<DashboardContainerInput>) { export function buildMockDashboard(overrides?: Partial<DashboardContainerInput>) {
const initialInput = getSampleDashboardInput(overrides); const initialInput = getSampleDashboardInput(overrides);
const dashboardContainer = new DashboardContainer(initialInput, mockedReduxEmbeddablePackage); const dashboardContainer = new DashboardContainer(
initialInput,
mockedReduxEmbeddablePackage,
undefined,
undefined,
undefined,
undefined,
{ lastSavedInput: initialInput }
);
return dashboardContainer; return dashboardContainer;
} }

View file

@ -80,7 +80,7 @@ export const loadDashboardState = async ({
/** /**
* Inject saved object references back into the saved object attributes * Inject saved object references back into the saved object attributes
*/ */
const { references, attributes: rawAttributes } = rawDashboardContent; const { references, attributes: rawAttributes, managed } = rawDashboardContent;
const attributes = (() => { const attributes = (() => {
if (!references || references.length === 0) return rawAttributes; if (!references || references.length === 0) return rawAttributes;
return injectReferences( return injectReferences(
@ -168,6 +168,7 @@ export const loadDashboardState = async ({
); );
return { return {
managed,
resolveMeta, resolveMeta,
dashboardInput, dashboardInput,
anyMigrationRun, anyMigrationRun,

View file

@ -64,6 +64,7 @@ type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta'];
export interface LoadDashboardReturn { export interface LoadDashboardReturn {
dashboardFound: boolean; dashboardFound: boolean;
dashboardId?: string; dashboardId?: string;
managed?: boolean;
resolveMeta?: DashboardResolveMeta; resolveMeta?: DashboardResolveMeta;
dashboardInput: DashboardContainerInput; dashboardInput: DashboardContainerInput;
anyMigrationRun?: boolean; anyMigrationRun?: boolean;

View file

@ -52,7 +52,7 @@ export function DashboardPicker(props: DashboardPickerProps) {
if (objects) { if (objects) {
setDashboardOptions( setDashboardOptions(
objects objects
.filter((d) => !props.idsToOmit || !props.idsToOmit.includes(d.id)) .filter((d) => !d.managed && !(props.idsToOmit ?? []).includes(d.id))
.map((d) => ({ .map((d) => ({
value: d.id, value: d.id,
label: d.attributes.title, label: d.attributes.title,

View file

@ -93,7 +93,7 @@ type CustomTableViewProps = Pick<
| 'editItem' | 'editItem'
| 'contentEditor' | 'contentEditor'
| 'emptyPrompt' | 'emptyPrompt'
| 'showEditActionForItem' | 'itemIsEditable'
>; >;
const useTableListViewProps = ( const useTableListViewProps = (
@ -257,8 +257,7 @@ const useTableListViewProps = (
editItem, editItem,
emptyPrompt: noItemsFragment, emptyPrompt: noItemsFragment,
createItem: createNewVis, createItem: createNewVis,
showEditActionForItem: ({ attributes: { readOnly } }) => itemIsEditable: ({ attributes: { readOnly } }) => visualizeCapabilities.save && !readOnly,
visualizeCapabilities.save && !readOnly,
}; };
return props; return props;