@@ -217,7 +218,7 @@ describe('settings', () => {
it('should update on new data', () => {
act(() => {
subject.next({
- ...angularProps,
+ ...workspaceProps,
blocklistedNodes: [
{
x: 0,
@@ -250,14 +251,13 @@ describe('settings', () => {
it('should delete node', () => {
instance.find(EuiListGroupItem).at(0).prop('extraAction')!.onClick!({} as any);
- expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]);
+ expect(workspaceProps.unblockNode).toHaveBeenCalledWith(workspaceProps.blocklistedNodes![0]);
});
it('should delete all nodes', () => {
instance.find('[data-test-subj="graphUnblocklistAll"]').find(EuiButton).simulate('click');
- expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]);
- expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![1]);
+ expect(workspaceProps.unblockAll).toHaveBeenCalled();
});
});
diff --git a/x-pack/plugins/graph/public/components/settings/settings.tsx b/x-pack/plugins/graph/public/components/settings/settings.tsx
index ab9cfdfe38072..d8f18add4f375 100644
--- a/x-pack/plugins/graph/public/components/settings/settings.tsx
+++ b/x-pack/plugins/graph/public/components/settings/settings.tsx
@@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
-import React, { useState, useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
import { EuiFlyoutHeader, EuiTitle, EuiTabs, EuiFlyoutBody, EuiTab } from '@elastic/eui';
import * as Rx from 'rxjs';
import { connect } from 'react-redux';
@@ -14,7 +14,7 @@ import { bindActionCreators } from 'redux';
import { AdvancedSettingsForm } from './advanced_settings_form';
import { BlocklistForm } from './blocklist_form';
import { UrlTemplateList } from './url_template_list';
-import { WorkspaceNode, AdvancedSettings, UrlTemplate, WorkspaceField } from '../../types';
+import { AdvancedSettings, BlockListedNode, UrlTemplate, WorkspaceField } from '../../types';
import {
GraphState,
settingsSelector,
@@ -47,16 +47,6 @@ const tabs = [
},
];
-/**
- * These props are wired in the angular scope and are passed in via observable
- * to catch update outside updates
- */
-export interface AngularProps {
- blocklistedNodes: WorkspaceNode[];
- unblocklistNode: (node: WorkspaceNode) => void;
- canEditDrillDownUrls: boolean;
-}
-
export interface StateProps {
advancedSettings: AdvancedSettings;
urlTemplates: UrlTemplate[];
@@ -69,26 +59,43 @@ export interface DispatchProps {
saveTemplate: (props: { index: number; template: UrlTemplate }) => void;
}
-interface AsObservable {
+export interface SettingsWorkspaceProps {
+ blocklistedNodes: BlockListedNode[];
+ unblockNode: (node: BlockListedNode) => void;
+ unblockAll: () => void;
+ canEditDrillDownUrls: boolean;
+}
+
+export interface AsObservable
{
observable: Readonly>;
}
-export interface SettingsProps extends AngularProps, StateProps, DispatchProps {}
+export interface SettingsStateProps extends StateProps, DispatchProps {}
export function SettingsComponent({
observable,
- ...props
-}: AsObservable & StateProps & DispatchProps) {
- const [angularProps, setAngularProps] = useState(undefined);
+ advancedSettings,
+ urlTemplates,
+ allFields,
+ saveTemplate: saveTemplateAction,
+ updateSettings: updateSettingsAction,
+ removeTemplate: removeTemplateAction,
+}: AsObservable & SettingsStateProps) {
+ const [workspaceProps, setWorkspaceProps] = useState(
+ undefined
+ );
const [activeTab, setActiveTab] = useState(0);
useEffect(() => {
- observable.subscribe(setAngularProps);
+ observable.subscribe(setWorkspaceProps);
}, [observable]);
- if (!angularProps) return null;
+ if (!workspaceProps) {
+ return null;
+ }
const ActiveTabContent = tabs[activeTab].component;
+
return (
<>
@@ -97,7 +104,7 @@ export function SettingsComponent({
{tabs
- .filter(({ id }) => id !== 'drillDowns' || angularProps.canEditDrillDownUrls)
+ .filter(({ id }) => id !== 'drillDowns' || workspaceProps.canEditDrillDownUrls)
.map(({ title }, index) => (
-
+
>
);
}
-export const Settings = connect, GraphState>(
+export const Settings = connect<
+ StateProps,
+ DispatchProps,
+ AsObservable,
+ GraphState
+>(
(state: GraphState) => ({
advancedSettings: settingsSelector(state),
urlTemplates: templatesSelector(state),
diff --git a/x-pack/plugins/graph/public/components/settings/url_template_list.tsx b/x-pack/plugins/graph/public/components/settings/url_template_list.tsx
index 24ce9dd267ad0..d18a9adb9bc0d 100644
--- a/x-pack/plugins/graph/public/components/settings/url_template_list.tsx
+++ b/x-pack/plugins/graph/public/components/settings/url_template_list.tsx
@@ -8,7 +8,7 @@
import React, { useState } from 'react';
import { EuiText, EuiSpacer, EuiTextAlign, EuiButton, htmlIdGenerator } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { SettingsProps } from './settings';
+import { SettingsStateProps } from './settings';
import { UrlTemplateForm } from './url_template_form';
import { useListKeys } from './use_list_keys';
@@ -18,7 +18,7 @@ export function UrlTemplateList({
removeTemplate,
saveTemplate,
urlTemplates,
-}: Pick) {
+}: Pick) {
const [uncommittedForms, setUncommittedForms] = useState([]);
const getListKey = useListKeys(urlTemplates);
diff --git a/x-pack/plugins/graph/public/components/workspace_layout/index.ts b/x-pack/plugins/graph/public/components/workspace_layout/index.ts
new file mode 100644
index 0000000000000..9f753a5bad576
--- /dev/null
+++ b/x-pack/plugins/graph/public/components/workspace_layout/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export * from './workspace_layout';
diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx
new file mode 100644
index 0000000000000..70e5b82ec6526
--- /dev/null
+++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_layout.tsx
@@ -0,0 +1,234 @@
+/*
+ * 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, { Fragment, memo, useCallback, useRef, useState } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiSpacer } from '@elastic/eui';
+import { connect } from 'react-redux';
+import { SearchBar } from '../search_bar';
+import {
+ GraphState,
+ hasFieldsSelector,
+ workspaceInitializedSelector,
+} from '../../state_management';
+import { FieldManager } from '../field_manager';
+import { IndexPattern } from '../../../../../../src/plugins/data/public';
+import {
+ ControlType,
+ IndexPatternProvider,
+ IndexPatternSavedObject,
+ TermIntersect,
+ WorkspaceNode,
+} from '../../types';
+import { WorkspaceTopNavMenu } from './workspace_top_nav_menu';
+import { InspectPanel } from '../inspect_panel';
+import { GuidancePanel } from '../guidance_panel';
+import { GraphTitle } from '../graph_title';
+import { GraphWorkspaceSavedObject, Workspace } from '../../types';
+import { GraphServices } from '../../application';
+import { ControlPanel } from '../control_panel';
+import { GraphVisualization } from '../graph_visualization';
+import { colorChoices } from '../../helpers/style_choices';
+
+/**
+ * Each component, which depends on `worksapce`
+ * should not be memoized, since it will not get updates.
+ * This behaviour should be changed after migrating `worksapce` to redux
+ */
+const FieldManagerMemoized = memo(FieldManager);
+const GuidancePanelMemoized = memo(GuidancePanel);
+
+type WorkspaceLayoutProps = Pick<
+ GraphServices,
+ | 'setHeaderActionMenu'
+ | 'graphSavePolicy'
+ | 'navigation'
+ | 'capabilities'
+ | 'coreStart'
+ | 'canEditDrillDownUrls'
+ | 'overlays'
+> & {
+ renderCounter: number;
+ workspace?: Workspace;
+ loading: boolean;
+ indexPatterns: IndexPatternSavedObject[];
+ savedWorkspace: GraphWorkspaceSavedObject;
+ indexPatternProvider: IndexPatternProvider;
+ urlQuery: string | null;
+};
+
+interface WorkspaceLayoutStateProps {
+ workspaceInitialized: boolean;
+ hasFields: boolean;
+}
+
+const WorkspaceLayoutComponent = ({
+ renderCounter,
+ workspace,
+ loading,
+ savedWorkspace,
+ hasFields,
+ overlays,
+ workspaceInitialized,
+ indexPatterns,
+ indexPatternProvider,
+ capabilities,
+ coreStart,
+ graphSavePolicy,
+ navigation,
+ canEditDrillDownUrls,
+ urlQuery,
+ setHeaderActionMenu,
+}: WorkspaceLayoutProps & WorkspaceLayoutStateProps) => {
+ const [currentIndexPattern, setCurrentIndexPattern] = useState();
+ const [showInspect, setShowInspect] = useState(false);
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [mergeCandidates, setMergeCandidates] = useState([]);
+ const [control, setControl] = useState('none');
+ const selectedNode = useRef(undefined);
+ const isInitialized = Boolean(workspaceInitialized || savedWorkspace.id);
+
+ const selectSelected = useCallback((node: WorkspaceNode) => {
+ selectedNode.current = node;
+ setControl('editLabel');
+ }, []);
+
+ const onSetControl = useCallback((newControl: ControlType) => {
+ selectedNode.current = undefined;
+ setControl(newControl);
+ }, []);
+
+ const onIndexPatternChange = useCallback(
+ (indexPattern?: IndexPattern) => setCurrentIndexPattern(indexPattern),
+ []
+ );
+
+ const onOpenFieldPicker = useCallback(() => {
+ setPickerOpen(true);
+ }, []);
+
+ const confirmWipeWorkspace = useCallback(
+ (
+ onConfirm: () => void,
+ text?: string,
+ options?: { confirmButtonText: string; title: string }
+ ) => {
+ if (!hasFields) {
+ onConfirm();
+ return;
+ }
+ const confirmModalOptions = {
+ confirmButtonText: i18n.translate('xpack.graph.leaveWorkspace.confirmButtonLabel', {
+ defaultMessage: 'Leave anyway',
+ }),
+ title: i18n.translate('xpack.graph.leaveWorkspace.modalTitle', {
+ defaultMessage: 'Unsaved changes',
+ }),
+ 'data-test-subj': 'confirmModal',
+ ...options,
+ };
+
+ overlays
+ .openConfirm(
+ text ||
+ i18n.translate('xpack.graph.leaveWorkspace.confirmText', {
+ defaultMessage: 'If you leave now, you will lose unsaved changes.',
+ }),
+ confirmModalOptions
+ )
+ .then((isConfirmed) => {
+ if (isConfirmed) {
+ onConfirm();
+ }
+ });
+ },
+ [hasFields, overlays]
+ );
+
+ const onSetMergeCandidates = useCallback(
+ (terms: TermIntersect[]) => setMergeCandidates(terms),
+ []
+ );
+
+ return (
+
+
+
+
+
+ {isInitialized && }
+
+
+
+
+
+ {!isInitialized && (
+
+
+
+ )}
+
+ {isInitialized && workspace && (
+
+ )}
+
+ );
+};
+
+export const WorkspaceLayout = connect(
+ (state: GraphState) => ({
+ workspaceInitialized: workspaceInitializedSelector(state),
+ hasFields: hasFieldsSelector(state),
+ })
+)(WorkspaceLayoutComponent);
diff --git a/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx
new file mode 100644
index 0000000000000..c5b10b9d92120
--- /dev/null
+++ b/x-pack/plugins/graph/public/components/workspace_layout/workspace_top_nav_menu.tsx
@@ -0,0 +1,175 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { Provider, useStore } from 'react-redux';
+import { AppMountParameters, Capabilities, CoreStart } from 'kibana/public';
+import { useHistory, useLocation } from 'react-router-dom';
+import { NavigationPublicPluginStart as NavigationStart } from '../../../../../../src/plugins/navigation/public';
+import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
+import { datasourceSelector, hasFieldsSelector } from '../../state_management';
+import { GraphSavePolicy, GraphWorkspaceSavedObject, Workspace } from '../../types';
+import { AsObservable, Settings, SettingsWorkspaceProps } from '../settings';
+import { asSyncedObservable } from '../../helpers/as_observable';
+
+interface WorkspaceTopNavMenuProps {
+ workspace: Workspace | undefined;
+ setShowInspect: React.Dispatch>;
+ confirmWipeWorkspace: (
+ onConfirm: () => void,
+ text?: string,
+ options?: { confirmButtonText: string; title: string }
+ ) => void;
+ savedWorkspace: GraphWorkspaceSavedObject;
+ setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
+ graphSavePolicy: GraphSavePolicy;
+ navigation: NavigationStart;
+ capabilities: Capabilities;
+ coreStart: CoreStart;
+ canEditDrillDownUrls: boolean;
+ isInitialized: boolean;
+}
+
+export const WorkspaceTopNavMenu = (props: WorkspaceTopNavMenuProps) => {
+ const store = useStore();
+ const location = useLocation();
+ const history = useHistory();
+
+ // register things for legacy angular UI
+ const allSavingDisabled = props.graphSavePolicy === 'none';
+
+ // ===== Menubar configuration =========
+ const { TopNavMenu } = props.navigation.ui;
+ const topNavMenu = [];
+ topNavMenu.push({
+ key: 'new',
+ label: i18n.translate('xpack.graph.topNavMenu.newWorkspaceLabel', {
+ defaultMessage: 'New',
+ }),
+ description: i18n.translate('xpack.graph.topNavMenu.newWorkspaceAriaLabel', {
+ defaultMessage: 'New Workspace',
+ }),
+ tooltip: i18n.translate('xpack.graph.topNavMenu.newWorkspaceTooltip', {
+ defaultMessage: 'Create a new workspace',
+ }),
+ disableButton() {
+ return !props.isInitialized;
+ },
+ run() {
+ props.confirmWipeWorkspace(() => {
+ if (location.pathname === '/workspace/') {
+ history.go(0);
+ } else {
+ history.push('/workspace/');
+ }
+ });
+ },
+ testId: 'graphNewButton',
+ });
+
+ // if saving is disabled using uiCapabilities, we don't want to render the save
+ // button so it's consistent with all of the other applications
+ if (props.capabilities.graph.save) {
+ // allSavingDisabled is based on the xpack.graph.savePolicy, we'll maintain this functionality
+
+ topNavMenu.push({
+ key: 'save',
+ label: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledLabel', {
+ defaultMessage: 'Save',
+ }),
+ description: i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledAriaLabel', {
+ defaultMessage: 'Save workspace',
+ }),
+ tooltip: () => {
+ if (allSavingDisabled) {
+ return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.disabledTooltip', {
+ defaultMessage:
+ 'No changes to saved workspaces are permitted by the current save policy',
+ });
+ } else {
+ return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.enabledTooltip', {
+ defaultMessage: 'Save this workspace',
+ });
+ }
+ },
+ disableButton() {
+ return allSavingDisabled || !hasFieldsSelector(store.getState());
+ },
+ run: () => {
+ store.dispatch({ type: 'x-pack/graph/SAVE_WORKSPACE', payload: props.savedWorkspace });
+ },
+ testId: 'graphSaveButton',
+ });
+ }
+ topNavMenu.push({
+ key: 'inspect',
+ disableButton() {
+ return props.workspace === null;
+ },
+ label: i18n.translate('xpack.graph.topNavMenu.inspectLabel', {
+ defaultMessage: 'Inspect',
+ }),
+ description: i18n.translate('xpack.graph.topNavMenu.inspectAriaLabel', {
+ defaultMessage: 'Inspect',
+ }),
+ run: () => {
+ props.setShowInspect((prevShowInspect) => !prevShowInspect);
+ },
+ });
+
+ topNavMenu.push({
+ key: 'settings',
+ disableButton() {
+ return datasourceSelector(store.getState()).current.type === 'none';
+ },
+ label: i18n.translate('xpack.graph.topNavMenu.settingsLabel', {
+ defaultMessage: 'Settings',
+ }),
+ description: i18n.translate('xpack.graph.topNavMenu.settingsAriaLabel', {
+ defaultMessage: 'Settings',
+ }),
+ run: () => {
+ // At this point workspace should be initialized,
+ // since settings button will be disabled only if workspace was set
+ const workspace = props.workspace as Workspace;
+
+ const settingsObservable = (asSyncedObservable(() => ({
+ blocklistedNodes: workspace.blocklistedNodes,
+ unblockNode: workspace.unblockNode,
+ unblockAll: workspace.unblockAll,
+ canEditDrillDownUrls: props.canEditDrillDownUrls,
+ })) as unknown) as AsObservable['observable'];
+
+ props.coreStart.overlays.openFlyout(
+ toMountPoint(
+
+
+
+ ),
+ {
+ size: 'm',
+ closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', {
+ defaultMessage: 'Close',
+ }),
+ 'data-test-subj': 'graphSettingsFlyout',
+ ownFocus: true,
+ className: 'gphSettingsFlyout',
+ maxWidth: 520,
+ }
+ );
+ },
+ });
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/graph/public/helpers/as_observable.ts b/x-pack/plugins/graph/public/helpers/as_observable.ts
index c1fa963641366..146161cceb46d 100644
--- a/x-pack/plugins/graph/public/helpers/as_observable.ts
+++ b/x-pack/plugins/graph/public/helpers/as_observable.ts
@@ -12,19 +12,20 @@ interface Props {
}
/**
- * This is a helper to tie state updates that happen somewhere else back to an angular scope.
+ * This is a helper to tie state updates that happen somewhere else back to an react state.
* It is roughly comparable to `reactDirective`, but does not have to be used from within a
* template.
*
- * This is a temporary solution until the state management is moved outside of Angular.
+ * This is a temporary solution until the state of Workspace internals is moved outside
+ * of mutable object to the redux state (at least blocklistedNodes, canEditDrillDownUrls and
+ * unblocklist action in this case).
*
* @param collectProps Function that collects properties from the scope that should be passed
- * into the observable. All functions passed along will be wrapped to cause an angular digest cycle
- * and refresh the observable afterwards with a new call to `collectProps`. By doing so, angular
- * can react to changes made outside of it and the results are passed back via the observable
- * @param angularDigest The `$digest` function of the scope.
+ * into the observable. All functions passed along will be wrapped to cause a react render
+ * and refresh the observable afterwards with a new call to `collectProps`. By doing so, react
+ * will receive an update outside of it local state and the results are passed back via the observable.
*/
-export function asAngularSyncedObservable(collectProps: () => Props, angularDigest: () => void) {
+export function asSyncedObservable(collectProps: () => Props) {
const boundCollectProps = () => {
const collectedProps = collectProps();
Object.keys(collectedProps).forEach((key) => {
@@ -32,7 +33,6 @@ export function asAngularSyncedObservable(collectProps: () => Props, angularDige
if (typeof currentValue === 'function') {
collectedProps[key] = (...args: unknown[]) => {
currentValue(...args);
- angularDigest();
subject$.next(boundCollectProps());
};
}
diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts
index 1d8be0fe86b97..336708173d321 100644
--- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts
+++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts
@@ -49,7 +49,7 @@ const defaultsProps = {
const urlFor = (basePath: IBasePath, id: string) =>
basePath.prepend(`/app/graph#/workspace/${encodeURIComponent(id)}`);
-function mapHits(hit: { id: string; attributes: Record }, url: string) {
+function mapHits(hit: any, url: string): GraphWorkspaceSavedObject {
const source = hit.attributes;
source.id = hit.id;
source.url = url;
diff --git a/x-pack/plugins/graph/public/helpers/use_graph_loader.ts b/x-pack/plugins/graph/public/helpers/use_graph_loader.ts
new file mode 100644
index 0000000000000..c133f6bf260cd
--- /dev/null
+++ b/x-pack/plugins/graph/public/helpers/use_graph_loader.ts
@@ -0,0 +1,108 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useCallback, useState } from 'react';
+import { ToastsStart } from 'kibana/public';
+import { IHttpFetchError, CoreStart } from 'kibana/public';
+import { i18n } from '@kbn/i18n';
+import { ExploreRequest, GraphExploreCallback, GraphSearchCallback, SearchRequest } from '../types';
+import { formatHttpError } from './format_http_error';
+
+interface UseGraphLoaderProps {
+ toastNotifications: ToastsStart;
+ coreStart: CoreStart;
+}
+
+export const useGraphLoader = ({ toastNotifications, coreStart }: UseGraphLoaderProps) => {
+ const [loading, setLoading] = useState(false);
+
+ const handleHttpError = useCallback(
+ (error: IHttpFetchError) => {
+ toastNotifications.addDanger(formatHttpError(error));
+ },
+ [toastNotifications]
+ );
+
+ const handleSearchQueryError = useCallback(
+ (err: Error | string) => {
+ const toastTitle = i18n.translate('xpack.graph.errorToastTitle', {
+ defaultMessage: 'Graph Error',
+ description: '"Graph" is a product name and should not be translated.',
+ });
+ if (err instanceof Error) {
+ toastNotifications.addError(err, {
+ title: toastTitle,
+ });
+ } else {
+ toastNotifications.addDanger({
+ title: toastTitle,
+ text: String(err),
+ });
+ }
+ },
+ [toastNotifications]
+ );
+
+ // Replacement function for graphClientWorkspace's comms so
+ // that it works with Kibana.
+ const callNodeProxy = useCallback(
+ (indexName: string, query: ExploreRequest, responseHandler: GraphExploreCallback) => {
+ const request = {
+ body: JSON.stringify({
+ index: indexName,
+ query,
+ }),
+ };
+ setLoading(true);
+ return coreStart.http
+ .post('../api/graph/graphExplore', request)
+ .then(function (data) {
+ const response = data.resp;
+ if (response.timed_out) {
+ toastNotifications.addWarning(
+ i18n.translate('xpack.graph.exploreGraph.timedOutWarningText', {
+ defaultMessage: 'Exploration timed out',
+ })
+ );
+ }
+ responseHandler(response);
+ })
+ .catch(handleHttpError)
+ .finally(() => setLoading(false));
+ },
+ [coreStart.http, handleHttpError, toastNotifications]
+ );
+
+ // Helper function for the graphClientWorkspace to perform a query
+ const callSearchNodeProxy = useCallback(
+ (indexName: string, query: SearchRequest, responseHandler: GraphSearchCallback) => {
+ const request = {
+ body: JSON.stringify({
+ index: indexName,
+ body: query,
+ }),
+ };
+ setLoading(true);
+ coreStart.http
+ .post('../api/graph/searchProxy', request)
+ .then(function (data) {
+ const response = data.resp;
+ responseHandler(response);
+ })
+ .catch(handleHttpError)
+ .finally(() => setLoading(false));
+ },
+ [coreStart.http, handleHttpError]
+ );
+
+ return {
+ loading,
+ callNodeProxy,
+ callSearchNodeProxy,
+ handleSearchQueryError,
+ };
+};
diff --git a/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts
new file mode 100644
index 0000000000000..8b91546d52446
--- /dev/null
+++ b/x-pack/plugins/graph/public/helpers/use_workspace_loader.ts
@@ -0,0 +1,120 @@
+/*
+ * 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 { SavedObjectsClientContract, ToastsStart } from 'kibana/public';
+import { useEffect, useState } from 'react';
+import { useHistory, useLocation, useParams } from 'react-router-dom';
+import { i18n } from '@kbn/i18n';
+import { GraphStore } from '../state_management';
+import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types';
+import { getSavedWorkspace } from './saved_workspace_utils';
+
+interface UseWorkspaceLoaderProps {
+ store: GraphStore;
+ workspaceRef: React.MutableRefObject;
+ savedObjectsClient: SavedObjectsClientContract;
+ toastNotifications: ToastsStart;
+}
+
+interface WorkspaceUrlParams {
+ id?: string;
+}
+
+export const useWorkspaceLoader = ({
+ workspaceRef,
+ store,
+ savedObjectsClient,
+ toastNotifications,
+}: UseWorkspaceLoaderProps) => {
+ const [indexPatterns, setIndexPatterns] = useState();
+ const [savedWorkspace, setSavedWorkspace] = useState();
+ const history = useHistory();
+ const location = useLocation();
+ const { id } = useParams();
+
+ /**
+ * The following effect initializes workspace initially and reacts
+ * on changes in id parameter and URL query only.
+ */
+ useEffect(() => {
+ const urlQuery = new URLSearchParams(location.search).get('query');
+
+ function loadWorkspace(
+ fetchedSavedWorkspace: GraphWorkspaceSavedObject,
+ fetchedIndexPatterns: IndexPatternSavedObject[]
+ ) {
+ store.dispatch({
+ type: 'x-pack/graph/LOAD_WORKSPACE',
+ payload: {
+ savedWorkspace: fetchedSavedWorkspace,
+ indexPatterns: fetchedIndexPatterns,
+ urlQuery,
+ },
+ });
+ }
+
+ function clearStore() {
+ store.dispatch({ type: 'x-pack/graph/RESET' });
+ }
+
+ async function fetchIndexPatterns() {
+ return await savedObjectsClient
+ .find<{ title: string }>({
+ type: 'index-pattern',
+ fields: ['title', 'type'],
+ perPage: 10000,
+ })
+ .then((response) => response.savedObjects);
+ }
+
+ async function fetchSavedWorkspace() {
+ return (id
+ ? await getSavedWorkspace(savedObjectsClient, id).catch(function (e) {
+ toastNotifications.addError(e, {
+ title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', {
+ defaultMessage: "Couldn't load graph with ID",
+ }),
+ });
+ history.replace('/home');
+ // return promise that never returns to prevent the controller from loading
+ return new Promise(() => {});
+ })
+ : await getSavedWorkspace(savedObjectsClient)) as GraphWorkspaceSavedObject;
+ }
+
+ async function initializeWorkspace() {
+ const fetchedIndexPatterns = await fetchIndexPatterns();
+ const fetchedSavedWorkspace = await fetchSavedWorkspace();
+
+ /**
+ * Deal with situation of request to open saved workspace. Otherwise clean up store,
+ * when navigating to a new workspace from existing one.
+ */
+ if (fetchedSavedWorkspace.id) {
+ loadWorkspace(fetchedSavedWorkspace, fetchedIndexPatterns);
+ } else if (workspaceRef.current) {
+ clearStore();
+ }
+
+ setIndexPatterns(fetchedIndexPatterns);
+ setSavedWorkspace(fetchedSavedWorkspace);
+ }
+
+ initializeWorkspace();
+ }, [
+ id,
+ location,
+ store,
+ history,
+ savedObjectsClient,
+ setSavedWorkspace,
+ toastNotifications,
+ workspaceRef,
+ ]);
+
+ return { savedWorkspace, indexPatterns };
+};
diff --git a/x-pack/plugins/graph/public/index.scss b/x-pack/plugins/graph/public/index.scss
index f4e38de3e93a4..4062864dd41e0 100644
--- a/x-pack/plugins/graph/public/index.scss
+++ b/x-pack/plugins/graph/public/index.scss
@@ -10,5 +10,4 @@
@import './mixins';
@import './main';
-@import './angular/templates/index';
@import './components/index';
diff --git a/x-pack/plugins/graph/public/plugin.ts b/x-pack/plugins/graph/public/plugin.ts
index 70671260ce5b9..1ff9afe505a3b 100644
--- a/x-pack/plugins/graph/public/plugin.ts
+++ b/x-pack/plugins/graph/public/plugin.ts
@@ -84,7 +84,6 @@ export class GraphPlugin
updater$: this.appUpdater$,
mount: async (params: AppMountParameters) => {
const [coreStart, pluginsStart] = await core.getStartServices();
- await pluginsStart.kibanaLegacy.loadAngularBootstrap();
coreStart.chrome.docTitle.change(
i18n.translate('xpack.graph.pageTitle', { defaultMessage: 'Graph' })
);
@@ -104,7 +103,7 @@ export class GraphPlugin
canEditDrillDownUrls: config.canEditDrillDownUrls,
graphSavePolicy: config.savePolicy,
storage: new Storage(window.localStorage),
- capabilities: coreStart.application.capabilities.graph,
+ capabilities: coreStart.application.capabilities,
chrome: coreStart.chrome,
toastNotifications: coreStart.notifications.toasts,
indexPatterns: pluginsStart.data!.indexPatterns,
diff --git a/x-pack/plugins/graph/public/router.tsx b/x-pack/plugins/graph/public/router.tsx
new file mode 100644
index 0000000000000..61a39bbbf63dd
--- /dev/null
+++ b/x-pack/plugins/graph/public/router.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { createHashHistory } from 'history';
+import { Redirect, Route, Router, Switch } from 'react-router-dom';
+import { ListingRoute } from './apps/listing_route';
+import { GraphServices } from './application';
+import { WorkspaceRoute } from './apps/workspace_route';
+
+export const graphRouter = (deps: GraphServices) => {
+ const history = createHashHistory();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts
index 443d8581c435d..31826c3b3a747 100644
--- a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts
+++ b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts
@@ -7,7 +7,7 @@
import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../../types';
import { migrateLegacyIndexPatternRef, savedWorkspaceToAppState, mapFields } from './deserialize';
-import { createWorkspace } from '../../angular/graph_client_workspace';
+import { createWorkspace } from '../../services/workspace/graph_client_workspace';
import { outlinkEncoders } from '../../helpers/outlink_encoders';
import { IndexPattern } from '../../../../../../src/plugins/data/public';
diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts
index 8213aac3fd62e..2466582bc7b25 100644
--- a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts
+++ b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts
@@ -146,7 +146,7 @@ describe('serialize', () => {
target: appState.workspace.nodes[0],
weight: 5,
width: 5,
- });
+ } as WorkspaceEdge);
// C <-> E
appState.workspace.edges.push({
@@ -155,7 +155,7 @@ describe('serialize', () => {
target: appState.workspace.nodes[4],
weight: 5,
width: 5,
- });
+ } as WorkspaceEdge);
});
it('should serialize given workspace', () => {
diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.ts b/x-pack/plugins/graph/public/services/persistence/serialize.ts
index 65392b69b5a6e..e1ec8db19a4c4 100644
--- a/x-pack/plugins/graph/public/services/persistence/serialize.ts
+++ b/x-pack/plugins/graph/public/services/persistence/serialize.ts
@@ -6,7 +6,6 @@
*/
import {
- SerializedNode,
WorkspaceNode,
WorkspaceEdge,
SerializedEdge,
@@ -17,13 +16,15 @@ import {
SerializedWorkspaceState,
Workspace,
AdvancedSettings,
+ SerializedNode,
+ BlockListedNode,
} from '../../types';
import { IndexpatternDatasource } from '../../state_management';
function serializeNode(
- { data, scaledSize, parent, x, y, label, color }: WorkspaceNode,
+ { data, scaledSize, parent, x, y, label, color }: BlockListedNode,
allNodes: WorkspaceNode[] = []
-): SerializedNode {
+) {
return {
x,
y,
diff --git a/x-pack/plugins/graph/public/services/save_modal.tsx b/x-pack/plugins/graph/public/services/save_modal.tsx
index eff98ebeded47..f1603ed790d3a 100644
--- a/x-pack/plugins/graph/public/services/save_modal.tsx
+++ b/x-pack/plugins/graph/public/services/save_modal.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React from 'react';
+import React, { ReactElement } from 'react';
import { I18nStart, OverlayStart, SavedObjectsClientContract } from 'src/core/public';
import { SaveResult } from 'src/plugins/saved_objects/public';
import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types';
@@ -39,7 +39,7 @@ export function openSaveModal({
hasData: boolean;
workspace: GraphWorkspaceSavedObject;
saveWorkspace: SaveWorkspaceHandler;
- showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void;
+ showSaveModal: (el: ReactElement, I18nContext: I18nStart['Context']) => void;
I18nContext: I18nStart['Context'];
services: SaveWorkspaceServices;
}) {
diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.d.ts b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.d.ts
similarity index 100%
rename from x-pack/plugins/graph/public/angular/graph_client_workspace.d.ts
rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.d.ts
diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js
similarity index 99%
rename from x-pack/plugins/graph/public/angular/graph_client_workspace.js
rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js
index 07e4dfc2e874a..c849a25cb19bb 100644
--- a/x-pack/plugins/graph/public/angular/graph_client_workspace.js
+++ b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js
@@ -631,10 +631,14 @@ function GraphWorkspace(options) {
self.runLayout();
};
- this.unblocklist = function (node) {
+ this.unblockNode = function (node) {
self.arrRemove(self.blocklistedNodes, node);
};
+ this.unblockAll = function () {
+ self.arrRemoveAll(self.blocklistedNodes, self.blocklistedNodes);
+ };
+
this.blocklistSelection = function () {
const selection = self.getAllSelectedNodes();
const danglingEdges = [];
diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.test.js
similarity index 100%
rename from x-pack/plugins/graph/public/angular/graph_client_workspace.test.js
rename to x-pack/plugins/graph/public/services/workspace/graph_client_workspace.test.js
diff --git a/x-pack/plugins/graph/public/state_management/advanced_settings.ts b/x-pack/plugins/graph/public/state_management/advanced_settings.ts
index 82f1358dd4164..68b9e002766e3 100644
--- a/x-pack/plugins/graph/public/state_management/advanced_settings.ts
+++ b/x-pack/plugins/graph/public/state_management/advanced_settings.ts
@@ -43,14 +43,14 @@ export const settingsSelector = (state: GraphState) => state.advancedSettings;
*
* Won't be necessary once the workspace is moved to redux
*/
-export const syncSettingsSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => {
+export const syncSettingsSaga = ({ getWorkspace, notifyReact }: GraphStoreDependencies) => {
function* syncSettings(action: Action): IterableIterator {
const workspace = getWorkspace();
if (!workspace) {
return;
}
workspace.options.exploreControls = action.payload;
- notifyAngular();
+ notifyReact();
}
return function* () {
diff --git a/x-pack/plugins/graph/public/state_management/datasource.sagas.ts b/x-pack/plugins/graph/public/state_management/datasource.sagas.ts
index b185af28c3481..9bfc7b3da0f91 100644
--- a/x-pack/plugins/graph/public/state_management/datasource.sagas.ts
+++ b/x-pack/plugins/graph/public/state_management/datasource.sagas.ts
@@ -30,7 +30,7 @@ export const datasourceSaga = ({
indexPatternProvider,
notifications,
createWorkspace,
- notifyAngular,
+ notifyReact,
}: GraphStoreDependencies) => {
function* fetchFields(action: Action) {
try {
@@ -39,7 +39,7 @@ export const datasourceSaga = ({
yield put(datasourceLoaded());
const advancedSettings = settingsSelector(yield select());
createWorkspace(indexPattern.title, advancedSettings);
- notifyAngular();
+ notifyReact();
} catch (e) {
// in case of errors, reset the datasource and show notification
yield put(setDatasource({ type: 'none' }));
diff --git a/x-pack/plugins/graph/public/state_management/fields.ts b/x-pack/plugins/graph/public/state_management/fields.ts
index 051f5328091e1..3a117fa6fe50a 100644
--- a/x-pack/plugins/graph/public/state_management/fields.ts
+++ b/x-pack/plugins/graph/public/state_management/fields.ts
@@ -69,9 +69,9 @@ export const hasFieldsSelector = createSelector(
*
* Won't be necessary once the workspace is moved to redux
*/
-export const updateSaveButtonSaga = ({ notifyAngular }: GraphStoreDependencies) => {
+export const updateSaveButtonSaga = ({ notifyReact }: GraphStoreDependencies) => {
function* notify(): IterableIterator {
- notifyAngular();
+ notifyReact();
}
return function* () {
yield takeLatest(matchesOne(selectField, deselectField), notify);
@@ -84,7 +84,7 @@ export const updateSaveButtonSaga = ({ notifyAngular }: GraphStoreDependencies)
*
* Won't be necessary once the workspace is moved to redux
*/
-export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphStoreDependencies) => {
+export const syncFieldsSaga = ({ getWorkspace }: GraphStoreDependencies) => {
function* syncFields() {
const workspace = getWorkspace();
if (!workspace) {
@@ -93,7 +93,6 @@ export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphSto
const currentState = yield select();
workspace.options.vertex_fields = selectedFieldsSelector(currentState);
- setLiveResponseFields(liveResponseFieldsSelector(currentState));
}
return function* () {
yield takeEvery(
@@ -109,7 +108,7 @@ export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphSto
*
* Won't be necessary once the workspace is moved to redux
*/
-export const syncNodeStyleSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => {
+export const syncNodeStyleSaga = ({ getWorkspace, notifyReact }: GraphStoreDependencies) => {
function* syncNodeStyle(action: Action>) {
const workspace = getWorkspace();
if (!workspace) {
@@ -132,7 +131,7 @@ export const syncNodeStyleSaga = ({ getWorkspace, notifyAngular }: GraphStoreDep
}
});
}
- notifyAngular();
+ notifyReact();
const selectedFields = selectedFieldsSelector(yield select());
workspace.options.vertex_fields = selectedFields;
diff --git a/x-pack/plugins/graph/public/state_management/legacy.test.ts b/x-pack/plugins/graph/public/state_management/legacy.test.ts
index 1dbad39a918a5..5a05efdc478fc 100644
--- a/x-pack/plugins/graph/public/state_management/legacy.test.ts
+++ b/x-pack/plugins/graph/public/state_management/legacy.test.ts
@@ -77,13 +77,12 @@ describe('legacy sync sagas', () => {
it('syncs templates with workspace', () => {
env.store.dispatch(loadTemplates([]));
- expect(env.mockedDeps.setUrlTemplates).toHaveBeenCalledWith([]);
- expect(env.mockedDeps.notifyAngular).toHaveBeenCalled();
+ expect(env.mockedDeps.notifyReact).toHaveBeenCalled();
});
it('notifies angular when fields are selected', () => {
env.store.dispatch(selectField('field1'));
- expect(env.mockedDeps.notifyAngular).toHaveBeenCalled();
+ expect(env.mockedDeps.notifyReact).toHaveBeenCalled();
});
it('syncs field list with workspace', () => {
@@ -99,9 +98,6 @@ describe('legacy sync sagas', () => {
const workspace = env.mockedDeps.getWorkspace()!;
expect(workspace.options.vertex_fields![0].name).toEqual('field1');
expect(workspace.options.vertex_fields![0].hopSize).toEqual(22);
- expect(env.mockedDeps.setLiveResponseFields).toHaveBeenCalledWith([
- expect.objectContaining({ hopSize: 22 }),
- ]);
});
it('syncs styles with nodes', () => {
diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts
index 74d980753a09a..189875d04b015 100644
--- a/x-pack/plugins/graph/public/state_management/mocks.ts
+++ b/x-pack/plugins/graph/public/state_management/mocks.ts
@@ -15,7 +15,7 @@ import createSagaMiddleware from 'redux-saga';
import { createStore, applyMiddleware, AnyAction } from 'redux';
import { ChromeStart } from 'kibana/public';
import { GraphStoreDependencies, createRootReducer, GraphStore, GraphState } from './store';
-import { Workspace, GraphWorkspaceSavedObject, IndexPatternSavedObject } from '../types';
+import { Workspace } from '../types';
import { IndexPattern } from '../../../../../src/plugins/data/public';
export interface MockedGraphEnvironment {
@@ -48,11 +48,8 @@ export function createMockGraphStore({
blocklistedNodes: [],
} as unknown) as Workspace;
- const savedWorkspace = ({
- save: jest.fn(),
- } as unknown) as GraphWorkspaceSavedObject;
-
const mockedDeps: jest.Mocked = {
+ basePath: '',
addBasePath: jest.fn((url: string) => url),
changeUrl: jest.fn(),
chrome: ({
@@ -60,15 +57,11 @@ export function createMockGraphStore({
} as unknown) as ChromeStart,
createWorkspace: jest.fn(),
getWorkspace: jest.fn(() => workspaceMock),
- getSavedWorkspace: jest.fn(() => savedWorkspace),
indexPatternProvider: {
get: jest.fn(() =>
Promise.resolve(({ id: '123', title: 'test-pattern' } as unknown) as IndexPattern)
),
},
- indexPatterns: [
- ({ id: '123', attributes: { title: 'test-pattern' } } as unknown) as IndexPatternSavedObject,
- ],
I18nContext: jest
.fn()
.mockImplementation(({ children }: { children: React.ReactNode }) => children),
@@ -79,12 +72,9 @@ export function createMockGraphStore({
},
} as unknown) as NotificationsStart,
http: {} as HttpStart,
- notifyAngular: jest.fn(),
+ notifyReact: jest.fn(),
savePolicy: 'configAndData',
showSaveModal: jest.fn(),
- setLiveResponseFields: jest.fn(),
- setUrlTemplates: jest.fn(),
- setWorkspaceInitialized: jest.fn(),
overlays: ({
openModal: jest.fn(),
} as unknown) as OverlayStart,
@@ -92,6 +82,7 @@ export function createMockGraphStore({
find: jest.fn(),
get: jest.fn(),
} as unknown) as SavedObjectsClientContract,
+ handleSearchQueryError: jest.fn(),
...mockedDepsOverwrites,
};
const sagaMiddleware = createSagaMiddleware();
diff --git a/x-pack/plugins/graph/public/state_management/persistence.test.ts b/x-pack/plugins/graph/public/state_management/persistence.test.ts
index b0932c92c2d1e..dc59869fafd4c 100644
--- a/x-pack/plugins/graph/public/state_management/persistence.test.ts
+++ b/x-pack/plugins/graph/public/state_management/persistence.test.ts
@@ -6,8 +6,14 @@
*/
import { createMockGraphStore, MockedGraphEnvironment } from './mocks';
-import { loadSavedWorkspace, loadingSaga, saveWorkspace, savingSaga } from './persistence';
-import { GraphWorkspaceSavedObject, UrlTemplate, AdvancedSettings, WorkspaceField } from '../types';
+import {
+ loadSavedWorkspace,
+ loadingSaga,
+ saveWorkspace,
+ savingSaga,
+ LoadSavedWorkspacePayload,
+} from './persistence';
+import { UrlTemplate, AdvancedSettings, WorkspaceField, GraphWorkspaceSavedObject } from '../types';
import { IndexpatternDatasource, datasourceSelector } from './datasource';
import { fieldsSelector } from './fields';
import { metaDataSelector, updateMetaData } from './meta_data';
@@ -55,7 +61,9 @@ describe('persistence sagas', () => {
});
it('should deserialize saved object and populate state', async () => {
env.store.dispatch(
- loadSavedWorkspace({ title: 'my workspace' } as GraphWorkspaceSavedObject)
+ loadSavedWorkspace({
+ savedWorkspace: { title: 'my workspace' },
+ } as LoadSavedWorkspacePayload)
);
await waitForPromise();
const resultingState = env.store.getState();
@@ -70,7 +78,7 @@ describe('persistence sagas', () => {
it('should warn with a toast and abort if index pattern is not found', async () => {
(migrateLegacyIndexPatternRef as jest.Mock).mockReturnValueOnce({ success: false });
- env.store.dispatch(loadSavedWorkspace({} as GraphWorkspaceSavedObject));
+ env.store.dispatch(loadSavedWorkspace({ savedWorkspace: {} } as LoadSavedWorkspacePayload));
await waitForPromise();
expect(env.mockedDeps.notifications.toasts.addDanger).toHaveBeenCalled();
const resultingState = env.store.getState();
@@ -96,11 +104,10 @@ describe('persistence sagas', () => {
savePolicy: 'configAndDataWithConsent',
},
});
- env.mockedDeps.getSavedWorkspace().id = '123';
});
it('should serialize saved object and save after confirmation', async () => {
- env.store.dispatch(saveWorkspace());
+ env.store.dispatch(saveWorkspace({ id: '123' } as GraphWorkspaceSavedObject));
(openSaveModal as jest.Mock).mock.calls[0][0].saveWorkspace({}, true);
expect(appStateToSavedWorkspace).toHaveBeenCalled();
await waitForPromise();
@@ -112,7 +119,7 @@ describe('persistence sagas', () => {
});
it('should not save data if user does not give consent in the modal', async () => {
- env.store.dispatch(saveWorkspace());
+ env.store.dispatch(saveWorkspace({} as GraphWorkspaceSavedObject));
(openSaveModal as jest.Mock).mock.calls[0][0].saveWorkspace({}, false);
// serialize function is called with `canSaveData` set to false
expect(appStateToSavedWorkspace).toHaveBeenCalledWith(
@@ -123,9 +130,8 @@ describe('persistence sagas', () => {
});
it('should not change url if it was just updating existing workspace', async () => {
- env.mockedDeps.getSavedWorkspace().id = '123';
env.store.dispatch(updateMetaData({ savedObjectId: '123' }));
- env.store.dispatch(saveWorkspace());
+ env.store.dispatch(saveWorkspace({} as GraphWorkspaceSavedObject));
await waitForPromise();
expect(env.mockedDeps.changeUrl).not.toHaveBeenCalled();
});
diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts
index f815474fa6e51..6a99eaddb32e3 100644
--- a/x-pack/plugins/graph/public/state_management/persistence.ts
+++ b/x-pack/plugins/graph/public/state_management/persistence.ts
@@ -8,8 +8,8 @@
import actionCreatorFactory, { Action } from 'typescript-fsa';
import { i18n } from '@kbn/i18n';
import { takeLatest, call, put, select, cps } from 'redux-saga/effects';
-import { GraphWorkspaceSavedObject, Workspace } from '../types';
-import { GraphStoreDependencies, GraphState } from '.';
+import { GraphWorkspaceSavedObject, IndexPatternSavedObject, Workspace } from '../types';
+import { GraphStoreDependencies, GraphState, submitSearch } from '.';
import { datasourceSelector } from './datasource';
import { setDatasource, IndexpatternDatasource } from './datasource';
import { loadFields, selectedFieldsSelector } from './fields';
@@ -26,10 +26,17 @@ import { openSaveModal, SaveWorkspaceHandler } from '../services/save_modal';
import { getEditPath } from '../services/url';
import { saveSavedWorkspace } from '../helpers/saved_workspace_utils';
+export interface LoadSavedWorkspacePayload {
+ indexPatterns: IndexPatternSavedObject[];
+ savedWorkspace: GraphWorkspaceSavedObject;
+ urlQuery: string | null;
+}
+
const actionCreator = actionCreatorFactory('x-pack/graph');
-export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE');
-export const saveWorkspace = actionCreator('SAVE_WORKSPACE');
+export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE');
+export const saveWorkspace = actionCreator('SAVE_WORKSPACE');
+export const fillWorkspace = actionCreator('FILL_WORKSPACE');
/**
* Saga handling loading of a saved workspace.
@@ -39,14 +46,12 @@ export const saveWorkspace = actionCreator('SAVE_WORKSPACE');
*/
export const loadingSaga = ({
createWorkspace,
- getWorkspace,
- indexPatterns,
notifications,
indexPatternProvider,
}: GraphStoreDependencies) => {
- function* deserializeWorkspace(action: Action) {
- const workspacePayload = action.payload;
- const migrationStatus = migrateLegacyIndexPatternRef(workspacePayload, indexPatterns);
+ function* deserializeWorkspace(action: Action) {
+ const { indexPatterns, savedWorkspace, urlQuery } = action.payload;
+ const migrationStatus = migrateLegacyIndexPatternRef(savedWorkspace, indexPatterns);
if (!migrationStatus.success) {
notifications.toasts.addDanger(
i18n.translate('xpack.graph.loadWorkspace.missingIndexPatternErrorMessage', {
@@ -59,25 +64,24 @@ export const loadingSaga = ({
return;
}
- const selectedIndexPatternId = lookupIndexPatternId(workspacePayload);
+ const selectedIndexPatternId = lookupIndexPatternId(savedWorkspace);
const indexPattern = yield call(indexPatternProvider.get, selectedIndexPatternId);
const initialSettings = settingsSelector(yield select());
- createWorkspace(indexPattern.title, initialSettings);
+ const createdWorkspace = createWorkspace(indexPattern.title, initialSettings);
const { urlTemplates, advancedSettings, allFields } = savedWorkspaceToAppState(
- workspacePayload,
+ savedWorkspace,
indexPattern,
- // workspace won't be null because it's created in the same call stack
- getWorkspace()!
+ createdWorkspace
);
// put everything in the store
yield put(
updateMetaData({
- title: workspacePayload.title,
- description: workspacePayload.description,
- savedObjectId: workspacePayload.id,
+ title: savedWorkspace.title,
+ description: savedWorkspace.description,
+ savedObjectId: savedWorkspace.id,
})
);
yield put(
@@ -91,7 +95,11 @@ export const loadingSaga = ({
yield put(updateSettings(advancedSettings));
yield put(loadTemplates(urlTemplates));
- getWorkspace()!.runLayout();
+ if (urlQuery) {
+ yield put(submitSearch(urlQuery));
+ }
+
+ createdWorkspace.runLayout();
}
return function* () {
@@ -105,8 +113,8 @@ export const loadingSaga = ({
* It will serialize everything and save it using the saved objects client
*/
export const savingSaga = (deps: GraphStoreDependencies) => {
- function* persistWorkspace() {
- const savedWorkspace = deps.getSavedWorkspace();
+ function* persistWorkspace(action: Action) {
+ const savedWorkspace = action.payload;
const state: GraphState = yield select();
const workspace = deps.getWorkspace();
const selectedDatasource = datasourceSelector(state).current;
diff --git a/x-pack/plugins/graph/public/state_management/store.ts b/x-pack/plugins/graph/public/state_management/store.ts
index 400736f7534b6..ba9bff98b0ca9 100644
--- a/x-pack/plugins/graph/public/state_management/store.ts
+++ b/x-pack/plugins/graph/public/state_management/store.ts
@@ -9,6 +9,7 @@ import createSagaMiddleware, { SagaMiddleware } from 'redux-saga';
import { combineReducers, createStore, Store, AnyAction, Dispatch, applyMiddleware } from 'redux';
import { ChromeStart, I18nStart, OverlayStart, SavedObjectsClientContract } from 'kibana/public';
import { CoreStart } from 'src/core/public';
+import { ReactElement } from 'react';
import {
fieldsReducer,
FieldsState,
@@ -24,19 +25,10 @@ import {
} from './advanced_settings';
import { DatasourceState, datasourceReducer } from './datasource';
import { datasourceSaga } from './datasource.sagas';
-import {
- IndexPatternProvider,
- Workspace,
- IndexPatternSavedObject,
- GraphSavePolicy,
- GraphWorkspaceSavedObject,
- AdvancedSettings,
- WorkspaceField,
- UrlTemplate,
-} from '../types';
+import { IndexPatternProvider, Workspace, GraphSavePolicy, AdvancedSettings } from '../types';
import { loadingSaga, savingSaga } from './persistence';
import { metaDataReducer, MetaDataState, syncBreadcrumbSaga } from './meta_data';
-import { fillWorkspaceSaga } from './workspace';
+import { fillWorkspaceSaga, submitSearchSaga, workspaceReducer, WorkspaceState } from './workspace';
export interface GraphState {
fields: FieldsState;
@@ -44,28 +36,26 @@ export interface GraphState {
advancedSettings: AdvancedSettingsState;
datasource: DatasourceState;
metaData: MetaDataState;
+ workspace: WorkspaceState;
}
export interface GraphStoreDependencies {
addBasePath: (url: string) => string;
indexPatternProvider: IndexPatternProvider;
- indexPatterns: IndexPatternSavedObject[];
- createWorkspace: (index: string, advancedSettings: AdvancedSettings) => void;
- getWorkspace: () => Workspace | null;
- getSavedWorkspace: () => GraphWorkspaceSavedObject;
+ createWorkspace: (index: string, advancedSettings: AdvancedSettings) => Workspace;
+ getWorkspace: () => Workspace | undefined;
notifications: CoreStart['notifications'];
http: CoreStart['http'];
overlays: OverlayStart;
savedObjectsClient: SavedObjectsClientContract;
- showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void;
+ showSaveModal: (el: ReactElement, I18nContext: I18nStart['Context']) => void;
savePolicy: GraphSavePolicy;
changeUrl: (newUrl: string) => void;
- notifyAngular: () => void;
- setLiveResponseFields: (fields: WorkspaceField[]) => void;
- setUrlTemplates: (templates: UrlTemplate[]) => void;
- setWorkspaceInitialized: () => void;
+ notifyReact: () => void;
chrome: ChromeStart;
I18nContext: I18nStart['Context'];
+ basePath: string;
+ handleSearchQueryError: (err: Error | string) => void;
}
export function createRootReducer(addBasePath: (url: string) => string) {
@@ -75,6 +65,7 @@ export function createRootReducer(addBasePath: (url: string) => string) {
advancedSettings: advancedSettingsReducer,
datasource: datasourceReducer,
metaData: metaDataReducer,
+ workspace: workspaceReducer,
});
}
@@ -89,6 +80,7 @@ function registerSagas(sagaMiddleware: SagaMiddleware