[Graph] Switch to SavedObjectClient.resolve (#109617)

This commit is contained in:
Marta Bondyra 2021-09-10 15:47:06 +02:00 committed by GitHub
parent 3c17704cfc
commit 7fff05f4e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 325 additions and 57 deletions

View file

@ -4,12 +4,29 @@
"kibanaVersion": "kibana",
"server": true,
"ui": true,
"requiredPlugins": ["licensing", "data", "navigation", "savedObjects", "kibanaLegacy"],
"optionalPlugins": ["home", "features"],
"configPath": ["xpack", "graph"],
"requiredBundles": ["kibanaUtils", "kibanaReact", "home"],
"requiredPlugins": [
"licensing",
"data",
"navigation",
"savedObjects",
"kibanaLegacy"
],
"optionalPlugins": [
"home",
"features",
"spaces"
],
"configPath": [
"xpack",
"graph"
],
"requiredBundles": [
"kibanaUtils",
"kibanaReact",
"home"
],
"owner": {
"name": "Data Discovery",
"githubTeam": "kibana-data-discovery"
}
}
}

View file

@ -31,6 +31,7 @@ import './index.scss';
import { SavedObjectsStart } from '../../../../src/plugins/saved_objects/public';
import { GraphSavePolicy } from './types';
import { graphRouter } from './router';
import { SpacesApi } from '../../spaces/public';
/**
* These are dependencies of the Graph app besides the base dependencies
@ -63,6 +64,7 @@ export interface GraphDependencies {
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
uiSettings: IUiSettingsClient;
history: ScopedHistory<unknown>;
spaces?: SpacesApi;
}
export type GraphServices = Omit<GraphDependencies, 'kibanaLegacy' | 'element' | 'history'>;

View file

@ -8,7 +8,7 @@
import React, { useMemo, useRef, useState } from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { Provider } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { showSaveModal } from '../../../../../src/plugins/saved_objects/public';
import { Workspace } from '../types';
@ -40,6 +40,7 @@ export const WorkspaceRoute = ({
getBasePath,
addBasePath,
setHeaderActionMenu,
spaces,
indexPatterns: getIndexPatternProvider,
},
}: WorkspaceRouteProps) => {
@ -56,7 +57,6 @@ export const WorkspaceRoute = ({
*/
const [renderCounter, setRenderCounter] = useState(0);
const history = useHistory();
const urlQuery = new URLSearchParams(useLocation().search).get('query');
const indexPatternProvider = useMemo(
() => createCachedIndexPatternProvider(getIndexPatternProvider.get),
@ -114,22 +114,27 @@ export const WorkspaceRoute = ({
})
);
const { savedWorkspace, indexPatterns } = useWorkspaceLoader({
const loaded = useWorkspaceLoader({
workspaceRef,
store,
savedObjectsClient,
toastNotifications,
spaces,
coreStart,
});
if (!savedWorkspace || !indexPatterns) {
if (!loaded) {
return null;
}
const { savedWorkspace, indexPatterns, sharingSavedObjectProps } = loaded;
return (
<I18nProvider>
<KibanaContextProvider services={services}>
<Provider store={store}>
<WorkspaceLayout
spaces={spaces}
sharingSavedObjectProps={sharingSavedObjectProps}
renderCounter={renderCounter}
workspace={workspaceRef.current}
loading={loading}
@ -143,7 +148,6 @@ export const WorkspaceRoute = ({
indexPatterns={indexPatterns}
savedWorkspace={savedWorkspace}
indexPatternProvider={indexPatternProvider}
urlQuery={urlQuery}
/>
</Provider>
</KibanaContextProvider>

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { WorkspaceLayoutComponent } from '.';
import { coreMock } from 'src/core/public/mocks';
import { spacesPluginMock } from '../../../../spaces/public/mocks';
import { NavigationPublicPluginStart as NavigationStart } from '../../../../../../src/plugins/navigation/public';
import { GraphSavePolicy, GraphWorkspaceSavedObject, IndexPatternProvider } from '../../types';
import { OverlayStart, Capabilities } from 'kibana/public';
import { SharingSavedObjectProps } from '../../helpers/use_workspace_loader';
jest.mock('react-router-dom', () => {
const useLocation = () => ({
search: '?query={}',
});
return {
useLocation,
};
});
describe('workspace_layout', () => {
const defaultProps = {
renderCounter: 1,
loading: false,
savedWorkspace: { id: 'test' } as GraphWorkspaceSavedObject,
hasFields: true,
overlays: {} as OverlayStart,
workspaceInitialized: true,
indexPatterns: [],
indexPatternProvider: {} as IndexPatternProvider,
capabilities: {} as Capabilities,
coreStart: coreMock.createStart(),
graphSavePolicy: 'configAndDataWithConsent' as GraphSavePolicy,
navigation: {} as NavigationStart,
canEditDrillDownUrls: true,
setHeaderActionMenu: jest.fn(),
sharingSavedObjectProps: {
outcome: 'exactMatch',
aliasTargetId: '',
} as SharingSavedObjectProps,
spaces: spacesPluginMock.createStartContract(),
};
it('should display conflict notification if outcome is conflict', () => {
shallow(
<WorkspaceLayoutComponent
{...defaultProps}
sharingSavedObjectProps={{ outcome: 'conflict', aliasTargetId: 'conflictId' }}
/>
);
expect(defaultProps.spaces.ui.components.getLegacyUrlConflict).toHaveBeenCalledWith({
currentObjectId: 'test',
objectNoun: 'Graph',
otherObjectId: 'conflictId',
otherObjectPath: '#/workspace/conflictId?query={}',
});
});
});

View file

@ -9,6 +9,7 @@ import React, { Fragment, memo, useCallback, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer } from '@elastic/eui';
import { connect } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { SearchBar } from '../search_bar';
import {
GraphState,
@ -33,6 +34,8 @@ import { GraphServices } from '../../application';
import { ControlPanel } from '../control_panel';
import { GraphVisualization } from '../graph_visualization';
import { colorChoices } from '../../helpers/style_choices';
import { SharingSavedObjectProps } from '../../helpers/use_workspace_loader';
import { getEditUrl } from '../../services/url';
/**
* Each component, which depends on `worksapce`
@ -51,6 +54,7 @@ type WorkspaceLayoutProps = Pick<
| 'coreStart'
| 'canEditDrillDownUrls'
| 'overlays'
| 'spaces'
> & {
renderCounter: number;
workspace?: Workspace;
@ -58,7 +62,7 @@ type WorkspaceLayoutProps = Pick<
indexPatterns: IndexPatternSavedObject[];
savedWorkspace: GraphWorkspaceSavedObject;
indexPatternProvider: IndexPatternProvider;
urlQuery: string | null;
sharingSavedObjectProps?: SharingSavedObjectProps;
};
interface WorkspaceLayoutStateProps {
@ -66,7 +70,7 @@ interface WorkspaceLayoutStateProps {
hasFields: boolean;
}
const WorkspaceLayoutComponent = ({
export const WorkspaceLayoutComponent = ({
renderCounter,
workspace,
loading,
@ -81,8 +85,9 @@ const WorkspaceLayoutComponent = ({
graphSavePolicy,
navigation,
canEditDrillDownUrls,
urlQuery,
setHeaderActionMenu,
sharingSavedObjectProps,
spaces,
}: WorkspaceLayoutProps & WorkspaceLayoutStateProps) => {
const [currentIndexPattern, setCurrentIndexPattern] = useState<IndexPattern>();
const [showInspect, setShowInspect] = useState(false);
@ -90,6 +95,10 @@ const WorkspaceLayoutComponent = ({
const [mergeCandidates, setMergeCandidates] = useState<TermIntersect[]>([]);
const [control, setControl] = useState<ControlType>('none');
const selectedNode = useRef<WorkspaceNode | undefined>(undefined);
const search = useLocation().search;
const urlQuery = new URLSearchParams(search).get('query');
const isInitialized = Boolean(workspaceInitialized || savedWorkspace.id);
const selectSelected = useCallback((node: WorkspaceNode) => {
@ -154,6 +163,27 @@ const WorkspaceLayoutComponent = ({
[]
);
const getLegacyUrlConflictCallout = useCallback(() => {
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
const currentObjectId = savedWorkspace.id;
if (spaces && sharingSavedObjectProps?.outcome === 'conflict' && currentObjectId) {
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
// callout with a warning for the user, and provide a way for them to navigate to the other object.
const otherObjectId = sharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'conflict'
const otherObjectPath =
getEditUrl(coreStart.http.basePath.prepend, { id: otherObjectId }) + search;
return spaces.ui.components.getLegacyUrlConflict({
objectNoun: i18n.translate('xpack.graph.legacyUrlConflict.objectNoun', {
defaultMessage: 'Graph',
}),
currentObjectId,
otherObjectId,
otherObjectPath,
});
}
return null;
}, [savedWorkspace.id, sharingSavedObjectProps, spaces, coreStart.http, search]);
return (
<Fragment>
<WorkspaceTopNavMenu
@ -176,7 +206,6 @@ const WorkspaceLayoutComponent = ({
lastResponse={workspace?.lastResponse}
indexPattern={currentIndexPattern}
/>
{isInitialized && <GraphTitle />}
<div className="gphGraph__bar">
<SearchBar
@ -190,6 +219,7 @@ const WorkspaceLayoutComponent = ({
<EuiSpacer size="s" />
<FieldManagerMemoized pickerOpen={pickerOpen} setPickerOpen={setPickerOpen} />
</div>
{getLegacyUrlConflictCallout()}
{!isInitialized && (
<div>
<GuidancePanelMemoized

View file

@ -82,28 +82,38 @@ export function findSavedWorkspace(
});
}
export function getEmptyWorkspace() {
return {
savedObject: {
displayName: 'graph workspace',
getEsType: () => savedWorkspaceType,
...defaultsProps,
} as GraphWorkspaceSavedObject,
};
}
export async function getSavedWorkspace(
savedObjectsClient: SavedObjectsClientContract,
id?: string
id: string
) {
const savedObject = {
id,
displayName: 'graph workspace',
getEsType: () => savedWorkspaceType,
} as { [key: string]: any };
const resolveResult = await savedObjectsClient.resolve<Record<string, unknown>>(
savedWorkspaceType,
id
);
if (!id) {
assign(savedObject, defaultsProps);
return Promise.resolve(savedObject);
}
const resp = await savedObjectsClient.get<Record<string, unknown>>(savedWorkspaceType, id);
savedObject._source = cloneDeep(resp.attributes);
const resp = resolveResult.saved_object;
if (!resp._version) {
throw new SavedObjectNotFound(savedWorkspaceType, id || '');
}
const savedObject = {
id,
displayName: 'graph workspace',
getEsType: () => savedWorkspaceType,
_source: cloneDeep(resp.attributes),
} as GraphWorkspaceSavedObject;
// assign the defaults to the response
defaults(savedObject._source, defaultsProps);
@ -120,7 +130,15 @@ export async function getSavedWorkspace(
injectReferences(savedObject, resp.references);
}
return savedObject as GraphWorkspaceSavedObject;
const sharingSavedObjectProps = {
outcome: resolveResult.outcome,
aliasTargetId: resolveResult.alias_target_id,
};
return {
savedObject,
sharingSavedObjectProps,
};
}
export function deleteSavedWorkspace(

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { useWorkspaceLoader, UseWorkspaceLoaderProps } from './use_workspace_loader';
import { coreMock } from 'src/core/public/mocks';
import { spacesPluginMock } from '../../../spaces/public/mocks';
import { createMockGraphStore } from '../state_management/mocks';
import { Workspace } from '../types';
import { SavedObjectsClientCommon } from 'src/plugins/data/common';
import { act } from 'react-dom/test-utils';
jest.mock('react-router-dom', () => {
const useLocation = () => ({
search: '?query={}',
});
const replaceFn = jest.fn();
const useHistory = () => ({
replace: replaceFn,
});
return {
useHistory,
useLocation,
useParams: () => ({
id: '1',
}),
};
});
const mockSavedObjectsClient = ({
resolve: jest.fn().mockResolvedValue({
saved_object: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } },
outcome: 'exactMatch',
}),
find: jest.fn().mockResolvedValue({ title: 'test' }),
} as unknown) as SavedObjectsClientCommon;
async function setup(props: UseWorkspaceLoaderProps) {
const returnVal = {};
function TestComponent() {
Object.assign(returnVal, useWorkspaceLoader(props));
return null;
}
await act(async () => {
const promise = Promise.resolve();
mount(<TestComponent />);
await act(() => promise);
});
return returnVal;
}
describe('use_workspace_loader', () => {
const defaultProps = {
workspaceRef: { current: {} as Workspace },
store: createMockGraphStore({}).store,
savedObjectsClient: mockSavedObjectsClient,
coreStart: coreMock.createStart(),
spaces: spacesPluginMock.createStartContract(),
};
it('should not redirect if outcome is exactMatch', async () => {
await act(async () => {
await setup((defaultProps as unknown) as UseWorkspaceLoaderProps);
});
expect(defaultProps.spaces.ui.redirectLegacyUrl).not.toHaveBeenCalled();
expect(defaultProps.store.dispatch).toHaveBeenCalled();
});
it('should redirect if outcome is aliasMatch', async () => {
const props = {
...defaultProps,
spaces: spacesPluginMock.createStartContract(),
savedObjectsClient: {
...mockSavedObjectsClient,
resolve: jest.fn().mockResolvedValue({
saved_object: { id: 10, _version: '7.15.0', attributes: { wsState: '{}' } },
outcome: 'aliasMatch',
alias_target_id: 'aliasTargetId',
}),
},
};
await act(async () => {
await setup((props as unknown) as UseWorkspaceLoaderProps);
});
expect(props.spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith(
'#/workspace/aliasTargetId?query={}',
'Graph'
);
});
});

View file

@ -5,35 +5,48 @@
* 2.0.
*/
import { SavedObjectsClientContract, ToastsStart } from 'kibana/public';
import { SavedObjectsClientContract } from 'kibana/public';
import { useEffect, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { CoreStart } from 'kibana/public';
import { GraphStore } from '../state_management';
import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types';
import { getSavedWorkspace } from './saved_workspace_utils';
interface UseWorkspaceLoaderProps {
import { getEmptyWorkspace, getSavedWorkspace } from './saved_workspace_utils';
import { getEditUrl } from '../services/url';
import { SpacesApi } from '../../../spaces/public';
export interface UseWorkspaceLoaderProps {
store: GraphStore;
workspaceRef: React.MutableRefObject<Workspace | undefined>;
savedObjectsClient: SavedObjectsClientContract;
toastNotifications: ToastsStart;
coreStart: CoreStart;
spaces?: SpacesApi;
}
interface WorkspaceUrlParams {
id?: string;
}
export interface SharingSavedObjectProps {
outcome?: 'aliasMatch' | 'exactMatch' | 'conflict';
aliasTargetId?: string;
}
interface WorkspaceLoadedState {
savedWorkspace: GraphWorkspaceSavedObject;
indexPatterns: IndexPatternSavedObject[];
sharingSavedObjectProps?: SharingSavedObjectProps;
}
export const useWorkspaceLoader = ({
coreStart,
spaces,
workspaceRef,
store,
savedObjectsClient,
toastNotifications,
}: UseWorkspaceLoaderProps) => {
const [indexPatterns, setIndexPatterns] = useState<IndexPatternSavedObject[]>();
const [savedWorkspace, setSavedWorkspace] = useState<GraphWorkspaceSavedObject>();
const history = useHistory();
const location = useLocation();
const [state, setState] = useState<WorkspaceLoadedState>();
const { replace: historyReplace } = useHistory();
const { search } = useLocation();
const { id } = useParams<WorkspaceUrlParams>();
/**
@ -41,7 +54,7 @@ export const useWorkspaceLoader = ({
* on changes in id parameter and URL query only.
*/
useEffect(() => {
const urlQuery = new URLSearchParams(location.search).get('query');
const urlQuery = new URLSearchParams(search).get('query');
function loadWorkspace(
fetchedSavedWorkspace: GraphWorkspaceSavedObject,
@ -71,24 +84,43 @@ export const useWorkspaceLoader = ({
.then((response) => response.savedObjects);
}
async function fetchSavedWorkspace() {
return (id
async function fetchSavedWorkspace(): Promise<{
savedObject: GraphWorkspaceSavedObject;
sharingSavedObjectProps?: SharingSavedObjectProps;
}> {
return id
? await getSavedWorkspace(savedObjectsClient, id).catch(function (e) {
toastNotifications.addError(e, {
coreStart.notifications.toasts.addError(e, {
title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', {
defaultMessage: "Couldn't load graph with ID",
}),
});
history.replace('/home');
historyReplace('/home');
// return promise that never returns to prevent the controller from loading
return new Promise(() => {});
})
: await getSavedWorkspace(savedObjectsClient)) as GraphWorkspaceSavedObject;
: getEmptyWorkspace();
}
async function initializeWorkspace() {
const fetchedIndexPatterns = await fetchIndexPatterns();
const fetchedSavedWorkspace = await fetchSavedWorkspace();
const {
savedObject: fetchedSavedWorkspace,
sharingSavedObjectProps: fetchedSharingSavedObjectProps,
} = await fetchSavedWorkspace();
if (spaces && fetchedSharingSavedObjectProps?.outcome === 'aliasMatch') {
// We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash
const newObjectId = fetchedSharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'aliasMatch'
const newPath = getEditUrl(coreStart.http.basePath.prepend, { id: newObjectId }) + search;
spaces.ui.redirectLegacyUrl(
newPath,
i18n.translate('xpack.graph.legacyUrlConflict.objectNoun', {
defaultMessage: 'Graph',
})
);
return null;
}
/**
* Deal with situation of request to open saved workspace. Otherwise clean up store,
@ -99,22 +131,25 @@ export const useWorkspaceLoader = ({
} else if (workspaceRef.current) {
clearStore();
}
setIndexPatterns(fetchedIndexPatterns);
setSavedWorkspace(fetchedSavedWorkspace);
setState({
savedWorkspace: fetchedSavedWorkspace,
indexPatterns: fetchedIndexPatterns,
sharingSavedObjectProps: fetchedSharingSavedObjectProps,
});
}
initializeWorkspace();
}, [
id,
location,
search,
store,
history,
historyReplace,
savedObjectsClient,
setSavedWorkspace,
toastNotifications,
setState,
coreStart,
workspaceRef,
spaces,
]);
return { savedWorkspace, indexPatterns };
return state;
};

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { BehaviorSubject } from 'rxjs';
import { SpacesApi } from '../../spaces/public';
import {
AppNavLinkStatus,
AppUpdater,
@ -44,6 +45,7 @@ export interface GraphPluginStartDependencies {
savedObjects: SavedObjectsStart;
kibanaLegacy: KibanaLegacyStart;
home?: HomePublicPluginStart;
spaces?: SpacesApi;
}
export class GraphPlugin
@ -110,6 +112,7 @@ export class GraphPlugin
overlays: coreStart.overlays,
savedObjects: pluginsStart.savedObjects,
uiSettings: core.uiSettings,
spaces: pluginsStart.spaces,
});
},
});

View file

@ -18,13 +18,13 @@ export function getNewPath() {
return '/workspace';
}
export function getEditPath({ id }: GraphWorkspaceSavedObject) {
export function getEditPath({ id }: Pick<GraphWorkspaceSavedObject, 'id'>) {
return `/workspace/${id}`;
}
export function getEditUrl(
addBasePath: (url: string) => string,
workspace: GraphWorkspaceSavedObject
workspace: Pick<GraphWorkspaceSavedObject, 'id'>
) {
return addBasePath(`#${getEditPath(workspace)}`);
}

View file

@ -24,6 +24,7 @@
{ "path": "../../../src/plugins/kibana_legacy/tsconfig.json"},
{ "path": "../../../src/plugins/home/tsconfig.json"},
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" }
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../spaces/tsconfig.json" }
]
}