[Graph] Deangularize graph app controller (#106587)

* [Graph] deaungularize control panel

* [Graph] move main graph directive to react

* [Graph] refactoring

* [Graph] remove redundant memoization, update import

* [Graph] fix settings menu, clean up the code

* [Graph] fix graph settings

* [Graph] code refactoring, fixing control panel render issues

* [Graph] fix small mistake

* [Graph] rename components

* [Graph] fix imports

* [Graph] fix graph search and inspect panel

* [Graph] remove redundant types

* [Graph] fix problem with selection list

* [Graph] fix functional test which uses selection list

* [Graph] fix unit tests, update types

* [Graph] fix types

* [Discover] fix url queries

* [Graph] fix types

* [Graph] add react router, remove angular stuff

* [Graph] fix styles

* [Graph] fix i18n

* [Graph] fix navigation to a new workspace creation

* [Graph] fix issues from comments

* [Graph] add suggested changed

* Update x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx

Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>

* [Graph] remove brace lib from imports

* [Graph] fix url navigation between workspaces, fix types

* [Graph] refactoring, fixing url issue

* [Graph] update graph dependencies

* [Graph] add comments

* [Graph] fix types

* [Graph] fix new button, fix control panel styles

* [Graph] apply suggestions

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
This commit is contained in:
Dmitry Tomashevich 2021-08-31 19:26:45 +03:00 committed by GitHub
parent 3bae4cdc06
commit 3f7c461cd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 2521 additions and 1606 deletions

View file

@ -21,6 +21,7 @@
*/
.gphNoUserSelect {
padding-right: $euiSizeXS;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;

View file

@ -1,3 +0,0 @@
@import './graph';
@import './sidebar';
@import './inspect';

View file

@ -1,362 +0,0 @@
<main id="graphBasic" ng-controller="graphuiPlugin" aria-labelledby="graphHeading">
<!-- Local nav. -->
<kbn-top-nav name="workspacesTopNav" config="topNavMenu" set-menu-mount-point="setHeaderActionMenu">
</kbn-top-nav>
<inspect-panel
show-inspect="menus.showInspect"
last-request="workspace && workspace.lastRequest"
last-response="workspace && workspace.lastResponse"
index-pattern="selectedIndex">
</inspect-panel>
<div
graph-app
current-index-pattern="selectedIndex"
on-query-submit="submit"
index-pattern-provider="indexPatternProvider"
redux-store="reduxStore"
confirm-wipe-workspace="confirmWipeWorkspace"
is-loading="loading"
is-initialized="workspaceInitialized || savedWorkspace.id"
initial-query="initialQuery"
plugin-data-start="pluginDataStart"
core-start="coreStart"
storage="storage"
no-index-patterns="noIndexPatterns"
></div>
<div class="gphGraph__container" id="GraphSvgContainer" ng-if="workspaceInitialized || savedWorkspace.id">
<div
class="gphVisualization"
graph-visualization
nodes="workspace.nodes"
edges="workspace.edges"
edge-click="clickEdge"
node-click="nodeClick"
></div>
<div id="sidebar" class="gphSidebar" ng-if="workspace !== null">
<div>
<button
class="kuiButton kuiButton--basic kuiButton--small"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.undoButtonTooltip' | i18n: { defaultMessage: 'Undo' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.undoButtonTooltip' | i18n: { defaultMessage: 'Undo' } }}"
type="button"
ng-click="workspace.undo()"
ng-disabled="workspace === null||workspace.undoLog.length <1"
>
<span class="kuiIcon fa-history"></span>
</button>
<button
class="kuiButton kuiButton--basic kuiButton--small"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.redoButtonTooltip' | i18n: { defaultMessage: 'Redo' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.redoButtonTooltip' | i18n: { defaultMessage: 'Redo' } }}"
type="button"
ng-disabled="workspace === null ||workspace.redoLog.length === 0"
ng-click="workspace.redo()"
>
<span class="kuiIcon fa-repeat"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-disabled="workspace === null ||liveResponseFields.length === 0||workspace.nodes.length === 0"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip' | i18n: { defaultMessage: 'Expand selection' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip' | i18n: { defaultMessage: 'Expand selection' } }}"
ng-click="setDetail(null);workspace.expandSelecteds({toFields:liveResponseFields});">
<span class="kuiIcon fa-plus"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-disabled="workspace === null ||workspace.nodes.length === 0"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.addLinksButtonTooltip' | i18n: { defaultMessage: 'Add links between existing terms' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.addLinksButtonTooltip' | i18n: { defaultMessage: 'Add links between existing terms' } }}"
ng-click="workspace.fillInGraph();">
<span class="kuiIcon fa-link"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-disabled="workspace === null ||workspace.nodes.length === 0"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.removeVerticesButtonTooltip' | i18n: { defaultMessage: 'Remove vertices from workspace' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.removeVerticesButtonTooltip' | i18n: { defaultMessage: 'Remove vertices from workspace' } }}"
ng-click="setDetail(null);workspace.deleteSelection();" data-test-subj="graphRemoveSelection">
<span class="kuiIcon fa-trash"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-disabled="workspace === null ||workspace.selectedNodes.length === 0"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.blocklistButtonTooltip' | i18n: { defaultMessage: 'Block selection from appearing in workspace' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.blocklistButtonTooltip' | i18n: { defaultMessage: 'Block selection from appearing in workspace' } }}"
ng-click="workspace.blocklistSelection();">
<span class="kuiIcon fa-ban"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-disabled="workspace === null ||workspace.selectedNodes.length === 0"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.customStyleButtonTooltip' | i18n: { defaultMessage: 'Custom style selected vertices' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.customStyleButtonTooltip' | i18n: { defaultMessage: 'Custom style selected vertices' } }}"
ng-click="setDetail({showStyle:true})">
<span class="kuiIcon fa-paint-brush"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-disabled="workspace === null||workspace.nodes.length === 0"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.drillDownButtonTooltip' | i18n: { defaultMessage: 'Drill down' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.drillDownButtonTooltip' | i18n: { defaultMessage: 'Drill down' } }}"
ng-click="setDetail({showDrillDowns:true})">
<span class="kuiIcon fa-info"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-disabled="workspace.nodes.length === 0" ng-if="workspace.nodes.length === 0||workspace.force === null"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.runLayoutButtonTooltip' | i18n: { defaultMessage: 'Run layout' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.runLayoutButtonTooltip' | i18n: { defaultMessage: 'Run layout' } }}"
ng-click="workspace.runLayout()" data-test-subj="graphResumeLayout">
<span class="kuiIcon fa-play"></span>
</button>
<button class="kuiButton kuiButton--basic kuiButton--small" ng-if="workspace.force !== null&&workspace.nodes.length>0"
tooltip="{{ ::'xpack.graph.sidebar.topMenu.pauseLayoutButtonTooltip' | i18n: { defaultMessage: 'Pause layout' } }}"
aria-label="{{ ::'xpack.graph.sidebar.topMenu.pauseLayoutButtonTooltip' | i18n: { defaultMessage: 'Pause layout' } }}"
ng-click="workspace.stopLayout()" data-test-subj="graphPauseLayout">
<span class="kuiIcon fa-pause"></span>
</button>
</div>
<div>
<div class="gphSidebar__header">
{{ ::'xpack.graph.sidebar.selectionsTitle' | i18n: { defaultMessage: 'Selections' } }}
</div>
<div id="vertexSelectionTypesBar">
<button
tooltip="{{ ::'xpack.graph.sidebar.selections.selectAllButtonTooltip' | i18n: { defaultMessage: 'Select all' } }}"
type="button" class="kuiButton kuiButton--basic kuiButton--small gphVertexSelect__button"
ng-disabled="workspace.nodes.length === 0" ng-click="setDetail(null);workspace.selectAll()"
i18n-id="xpack.graph.sidebar.selections.selectAllButtonLabel"
i18n-default-message="all" data-test-subj="graphSelectAll"
></button>
<button
tooltip="{{ ::'xpack.graph.sidebar.selections.selectNoneButtonTooltip' | i18n: { defaultMessage: 'Select none' } }}"
type="button" class="kuiButton kuiButton--basic kuiButton--small gphVertexSelect__button"
ng-disabled="workspace.nodes.length === 0" ng-click="setDetail(null);workspace.selectNone()"
i18n-id="xpack.graph.sidebar.selections.selectNoneButtonLabel"
i18n-default-message="none"
></button>
<button
tooltip="{{ ::'xpack.graph.sidebar.selections.invertSelectionButtonTooltip' | i18n: { defaultMessage: 'Invert selection' } }}"
type="button" class="kuiButton kuiButton--basic kuiButton--small gphVertexSelect__button"
ng-disabled="workspace.nodes.length === 0" ng-click="setDetail(null);workspace.selectInvert()"
i18n-id="xpack.graph.sidebar.selections.invertSelectionButtonLabel"
i18n-default-message="invert" data-test-subj="graphInvertSelection"
></button>
<button
tooltip="{{ ::'xpack.graph.sidebar.selections.selectNeighboursButtonTooltip' | i18n: { defaultMessage: 'Select neighbours' } }}"
type="button" class="kuiButton kuiButton--basic kuiButton--small gphVertexSelect__button"
ng-disabled="workspace.selectedNodes.length === 0" ng-click="setDetail(null);workspace.selectNeighbours()"
i18n-id="xpack.graph.sidebar.selections.selectNeighboursButtonLabel"
i18n-default-message="linked" data-test-subj="graphLinkedSelection"
></button>
</div>
<div class="gphSelectionList">
<p
ng-if="workspace.selectedNodes.length === 0"
class="help-block"
i18n-id="xpack.graph.sidebar.selections.noSelectionsHelpText"
i18n-default-message="No selections. Click on vertices to add."
></p>
<div ng-repeat="n in workspace.selectedNodes" class="gphSelectionList__field" ng-class="{'gphSelectionList__field--selected': isSelectedSelected(n)}"
ng-click="selectSelected(n)">
<svg width="24" height="24">
<circle class="gphNode__circle " r="10" cx="12" cy="12" ng-attr-style="fill:{{n.color}}"
ng-click="workspace.deselectNode(n)" ></circle>
<text
ng-if="n.icon"
class="fa gphNode__text gphSelectionList__icon"
text-anchor="middle"
x="12"
y="16"
ng-click="workspace.deselectNode(n)"
ng-class="{'gphNode__text--inverse': isColorDark(n.color)}"
>{{n.icon.code}}</text>
</svg>
<span>{{n.label}}</span>
<span ng-if="n.numChildren>0"> (+{{n.numChildren}})</span>
</div>
</div>
</div>
<!-- Any drill-downs with a choice of button icon appear here for quick access -->
<div ng-if="(urlTemplates | filter:{icon: {class:''}}).length > 0">
<button ng-repeat="urlTemplate in urlTemplates | filter:{icon: {class:''}}" class="kuiButton kuiButton--basic kuiButton--small gphVertexSelect__button"
tooltip="{{urlTemplate.description}}" type="button" ng-disabled="workspace === null ||workspace.nodes.length === 0"
ng-click="openUrlTemplate(urlTemplate)">
<span class="kuiIcon" ng-class="urlTemplate.icon.class"></span>
</button>
</div>
<div ng-if="detail.showDrillDowns">
<div class="gphSidebar__header">
<span class="kuiIcon fa-info"></span>
{{ ::'xpack.graph.sidebar.drillDownsTitle' | i18n: { defaultMessage: 'Drill-downs' } }}
</div>
<div class="gphSidebar__panel">
<p
ng-if="urlTemplates.length === 0"
class="help-block"
i18n-id="xpack.graph.sidebar.drillDowns.noDrillDownsHelpText"
i18n-default-message="Configure drill-downs from the settings menu"
></p>
<ul class="list-group">
<li class="list-group-item" ng-repeat="urlTemplate in urlTemplates">
<span ng-if="urlTemplate.icon" class="kuiIcon gphNoUserSelect">
{{urlTemplate.icon.code}}</span>
<a ng-click="openUrlTemplate(urlTemplate)">{{urlTemplate.description}}</a>
</li>
</ul>
</div>
</div>
<div class="gphSidebar__panel" ng-if="(detail.showStyle)&&(workspace.selectedNodes.length>0)">
<div class="gphSidebar__header">
<span class="kuiIcon fa-paint-brush"></span>
{{ ::'xpack.graph.sidebar.styleVerticesTitle' | i18n: { defaultMessage: 'Style selected vertices' } }}
</div>
<div class="form-group form-group-sm gphFormGroup--small">
<span ng-repeat="c in colors" ng-disabled="!selectedField.selected" ng-click="workspace.colorSelected(c)"
ng-style="{color: c}" class="kuiIcon gphColorPicker__color fa-circle">
</span>
</div>
</div>
<div class="gphSidebar__panel" ng-if="detail.latestNodeSelection">
<div class="gphSidebar__header">
<span class="kuiIcon {{detail.latestNodeSelection.icon.class}}" ng-if="detail.latestNodeSelection.icon"></span>
{{detail.latestNodeSelection.data.field}} {{detail.latestNodeSelection.data.term}}
</div>
<button
class="kuiButton kuiButton--basic kuiButton--iconText kuiButton--small"
ng-if="workspace.selectedNodes.length>1||(workspace.selectedNodes.length>0&&workspace.selectedNodes[0] !== detail.latestNodeSelection)"
tooltip="{{ 'xpack.graph.sidebar.groupButtonTooltip' | i18n: {
defaultMessage: 'group the currently selected items into {latestSelectionLabel}',
values: { latestSelectionLabel: detail.latestNodeSelection.label },
} }}"
ng-click="workspace.groupSelections(detail.latestNodeSelection)"
>
<span class="kuiButton__icon kuiIcon fa-object-group"></span>
<span
i18n-id="xpack.graph.sidebar.groupButtonLabel"
i18n-default-message="group"
></span>
</button>
<button
class="kuiButton kuiButton--basic kuiButton--iconText kuiButton--small"
ng-if="detail.latestNodeSelection.numChildren>0"
tooltip="{{ 'xpack.graph.sidebar.ungroupButtonTooltip' | i18n: {
defaultMessage: 'ungroup {latestSelectionLabel}',
values: { latestSelectionLabel: detail.latestNodeSelection.label },
} }}"
ng-click="workspace.ungroup(detail.latestNodeSelection)"
>
<span class="kuiIcon fa-object-ungroup"></span>
<span
i18n-id="xpack.graph.sidebar.ungroupButtonLabel"
i18n-default-message="ungroup"
></span>
</button>
<form class="form-horizontal">
<div class="form-group form-group-sm gphFormGroup--small">
<label
for="labelEdit"
class="col-sm-3 control-label"
i18n-id="xpack.graph.sidebar.displayLabelLabel"
i18n-default-message="Display label"
></label>
<div class="col-sm-9">
<input type="text" id="labelEdit" class="form-control input-sm" ng-model="detail.latestNodeSelection.label">
<div
class="help-block"
i18n-id="xpack.graph.sidebar.displayLabelHelpText"
i18n-default-message="Change the label for this vertex."
></div>
</div>
</div>
</form>
</div>
<div ng-if="detail.mergeCandidates.length>0" class="gphSidebar__panel">
<div class="gphSidebar__header">
<span class="kuiIcon fa-link"></span>
{{ ::'xpack.graph.sidebar.linkSummaryTitle' | i18n: { defaultMessage: 'Link summary' } }}
</div>
<div ng-repeat="mc in detail.mergeCandidates">
<span>
<button
tooltip="{{ ::'xpack.graph.sidebar.linkSummary.mergeTerm1ToTerm2ButtonTooltip' | i18n: {
defaultMessage: 'Merge {term1} into {term2}',
values: { term1: mc.term1, term2: mc.term2 },
} }}"
type="button" ng-attr-style="opacity:{{0.2+(mc.overlap/mc.v1)}};"
class="kuiButton kuiButton--basic kuiButton--small" ng-click="performMerge(mc.id2, mc.id1)">
<span class="kuiIcon fa-chevron-circle-right"></span>
</button>
<span class="gphLinkSummary__term--1">{{mc.term1}}</span>
<span class="gphLinkSummary__term--2">{{mc.term2}}</span>
<button
tooltip="{{ ::'xpack.graph.sidebar.linkSummary.mergeTerm2ToTerm1ButtonTooltip' | i18n: {
defaultMessage: 'Merge {term2} into {term1}',
values: { term1: mc.term1, term2: mc.term2 },
} }}"
type="button" class="kuiButton kuiButton--basic kuiButton--small"
ng-attr-style="opacity:{{0.2+(mc.overlap/mc.v2)}};" ng-click="performMerge(mc.id1, mc.id2)">
<span class="kuiIcon fa-chevron-circle-left"></span>
</button>
</span>
<!-- Venn diagram of term/shared doc intersections -->
<venn-diagram left-value="mc.v1" right-value="mc.v2" overlap="mc.overlap"></venn-diagram>
<small
class="gphLinkSummary__term--1"
tooltip="{{ ::'xpack.graph.sidebar.linkSummary.leftTermCountTooltip' | i18n: {
defaultMessage: '{count} documents have term {term}',
values: { count: mc.v1, term: mc.term1 },
} }}"
>{{mc.v1}}</small>
<small
class="gphLinkSummary__term--1-2"
tooltip="{{ ::'xpack.graph.sidebar.linkSummary.bothTermsCountTooltip' | i18n: {
defaultMessage: '{count} documents have both terms',
values: { count: mc.overlap },
} }}"
>&nbsp;({{mc.overlap}})&nbsp;</small>
<small
class="gphLinkSummary__term--2"
tooltip="{{ ::'xpack.graph.sidebar.linkSummary.rightTermCountTooltip' | i18n: {
defaultMessage: '{count} documents have term {term}',
values: { count: mc.v2, term: mc.term2 },
} }}"
>{{mc.v2}}</small>
</div>
</div>
<!-- end edge-merge detail panel -->
</div>
<!-- end sidebar -->
</div>
<!--end svg container-->
</main>

View file

@ -1,13 +0,0 @@
<graph-listing
create-item="create"
get-view-url="getViewUrl"
edit-item="editItem"
find-items="find"
delete-items="delete"
listing-limit="listingLimit"
capabilities="capabilities"
initial-filter="initialFilter"
initialPageSize="initialPageSize"
core-start="coreStart"
class="kbnAppWrapper"
></graph-listing>

View file

@ -1,646 +0,0 @@
/*
* 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 _ from 'lodash';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Provider } from 'react-redux';
import { isColorDark, hexToRgb } from '@elastic/eui';
import { toMountPoint } from '../../../../src/plugins/kibana_react/public';
import { showSaveModal } from '../../../../src/plugins/saved_objects/public';
import appTemplate from './angular/templates/index.html';
import listingTemplate from './angular/templates/listing_ng_wrapper.html';
import { getReadonlyBadge } from './badge';
import { GraphApp } from './components/app';
import { VennDiagram } from './components/venn_diagram';
import { Listing } from './components/listing';
import { Settings } from './components/settings';
import { GraphVisualization } from './components/graph_visualization';
import { createWorkspace } from './angular/graph_client_workspace.js';
import { getEditUrl, getNewPath, getEditPath, setBreadcrumbs } from './services/url';
import { createCachedIndexPatternProvider } from './services/index_pattern_cache';
import { urlTemplateRegex } from './helpers/url_template';
import { asAngularSyncedObservable } from './helpers/as_observable';
import { colorChoices } from './helpers/style_choices';
import { createGraphStore, datasourceSelector, hasFieldsSelector } from './state_management';
import { formatHttpError } from './helpers/format_http_error';
import {
findSavedWorkspace,
getSavedWorkspace,
deleteSavedWorkspace,
} from './helpers/saved_workspace_utils';
import { InspectPanel } from './components/inspect_panel/inspect_panel';
export function initGraphApp(angularModule, deps) {
const {
chrome,
toastNotifications,
savedObjectsClient,
indexPatterns,
addBasePath,
getBasePath,
data,
capabilities,
coreStart,
storage,
canEditDrillDownUrls,
graphSavePolicy,
overlays,
savedObjects,
setHeaderActionMenu,
uiSettings,
} = deps;
const app = angularModule;
app.directive('vennDiagram', function (reactDirective) {
return reactDirective(VennDiagram);
});
app.directive('graphVisualization', function (reactDirective) {
return reactDirective(GraphVisualization);
});
app.directive('graphListing', function (reactDirective) {
return reactDirective(Listing, [
['coreStart', { watchDepth: 'reference' }],
['createItem', { watchDepth: 'reference' }],
['findItems', { watchDepth: 'reference' }],
['deleteItems', { watchDepth: 'reference' }],
['editItem', { watchDepth: 'reference' }],
['getViewUrl', { watchDepth: 'reference' }],
['listingLimit', { watchDepth: 'reference' }],
['hideWriteControls', { watchDepth: 'reference' }],
['capabilities', { watchDepth: 'reference' }],
['initialFilter', { watchDepth: 'reference' }],
['initialPageSize', { watchDepth: 'reference' }],
]);
});
app.directive('graphApp', function (reactDirective) {
return reactDirective(
GraphApp,
[
['storage', { watchDepth: 'reference' }],
['isInitialized', { watchDepth: 'reference' }],
['currentIndexPattern', { watchDepth: 'reference' }],
['indexPatternProvider', { watchDepth: 'reference' }],
['isLoading', { watchDepth: 'reference' }],
['onQuerySubmit', { watchDepth: 'reference' }],
['initialQuery', { watchDepth: 'reference' }],
['confirmWipeWorkspace', { watchDepth: 'reference' }],
['coreStart', { watchDepth: 'reference' }],
['noIndexPatterns', { watchDepth: 'reference' }],
['reduxStore', { watchDepth: 'reference' }],
['pluginDataStart', { watchDepth: 'reference' }],
],
{ restrict: 'A' }
);
});
app.directive('graphVisualization', function (reactDirective) {
return reactDirective(GraphVisualization, undefined, { restrict: 'A' });
});
app.directive('inspectPanel', function (reactDirective) {
return reactDirective(
InspectPanel,
[
['showInspect', { watchDepth: 'reference' }],
['lastRequest', { watchDepth: 'reference' }],
['lastResponse', { watchDepth: 'reference' }],
['indexPattern', { watchDepth: 'reference' }],
['uiSettings', { watchDepth: 'reference' }],
],
{ restrict: 'E' },
{
uiSettings,
}
);
});
app.config(function ($routeProvider) {
$routeProvider
.when('/home', {
template: listingTemplate,
badge: getReadonlyBadge,
controller: function ($location, $scope) {
$scope.listingLimit = savedObjects.settings.getListingLimit();
$scope.initialPageSize = savedObjects.settings.getPerPage();
$scope.create = () => {
$location.url(getNewPath());
};
$scope.find = (search) => {
return findSavedWorkspace(
{ savedObjectsClient, basePath: coreStart.http.basePath },
search,
$scope.listingLimit
);
};
$scope.editItem = (workspace) => {
$location.url(getEditPath(workspace));
};
$scope.getViewUrl = (workspace) => getEditUrl(addBasePath, workspace);
$scope.delete = (workspaces) =>
deleteSavedWorkspace(
savedObjectsClient,
workspaces.map(({ id }) => id)
);
$scope.capabilities = capabilities;
$scope.initialFilter = $location.search().filter || '';
$scope.coreStart = coreStart;
setBreadcrumbs({ chrome });
},
})
.when('/workspace/:id?', {
template: appTemplate,
badge: getReadonlyBadge,
resolve: {
savedWorkspace: function ($rootScope, $route, $location) {
return $route.current.params.id
? getSavedWorkspace(savedObjectsClient, $route.current.params.id).catch(function (e) {
toastNotifications.addError(e, {
title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', {
defaultMessage: "Couldn't load graph with ID",
}),
});
$rootScope.$eval(() => {
$location.path('/home');
$location.replace();
});
// return promise that never returns to prevent the controller from loading
return new Promise();
})
: getSavedWorkspace(savedObjectsClient);
},
indexPatterns: function () {
return savedObjectsClient
.find({
type: 'index-pattern',
fields: ['title', 'type'],
perPage: 10000,
})
.then((response) => response.savedObjects);
},
GetIndexPatternProvider: function () {
return indexPatterns;
},
},
})
.otherwise({
redirectTo: '/home',
});
});
//======== Controller for basic UI ==================
app.controller('graphuiPlugin', function ($scope, $route, $location) {
function handleError(err) {
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),
});
}
}
async function handleHttpError(error) {
toastNotifications.addDanger(formatHttpError(error));
}
// Replacement function for graphClientWorkspace's comms so
// that it works with Kibana.
function callNodeProxy(indexName, query, responseHandler) {
const request = {
body: JSON.stringify({
index: indexName,
query: query,
}),
};
$scope.loading = 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(() => {
$scope.loading = false;
$scope.$digest();
});
}
//Helper function for the graphClientWorkspace to perform a query
const callSearchNodeProxy = function (indexName, query, responseHandler) {
const request = {
body: JSON.stringify({
index: indexName,
body: query,
}),
};
$scope.loading = true;
coreStart.http
.post('../api/graph/searchProxy', request)
.then(function (data) {
const response = data.resp;
responseHandler(response);
})
.catch(handleHttpError)
.finally(() => {
$scope.loading = false;
$scope.$digest();
});
};
$scope.indexPatternProvider = createCachedIndexPatternProvider(
$route.current.locals.GetIndexPatternProvider.get
);
const store = createGraphStore({
basePath: getBasePath(),
addBasePath,
indexPatternProvider: $scope.indexPatternProvider,
indexPatterns: $route.current.locals.indexPatterns,
createWorkspace: (indexPattern, exploreControls) => {
const options = {
indexName: indexPattern,
vertex_fields: [],
// Here we have the opportunity to look up labels for nodes...
nodeLabeller: function () {
// console.log(newNodes);
},
changeHandler: function () {
//Allows DOM to update with graph layout changes.
$scope.$apply();
},
graphExploreProxy: callNodeProxy,
searchProxy: callSearchNodeProxy,
exploreControls,
};
$scope.workspace = createWorkspace(options);
},
setLiveResponseFields: (fields) => {
$scope.liveResponseFields = fields;
},
setUrlTemplates: (urlTemplates) => {
$scope.urlTemplates = urlTemplates;
},
getWorkspace: () => {
return $scope.workspace;
},
getSavedWorkspace: () => {
return $route.current.locals.savedWorkspace;
},
notifications: coreStart.notifications,
http: coreStart.http,
overlays: coreStart.overlays,
savedObjectsClient,
showSaveModal,
setWorkspaceInitialized: () => {
$scope.workspaceInitialized = true;
},
savePolicy: graphSavePolicy,
changeUrl: (newUrl) => {
$scope.$evalAsync(() => {
$location.url(newUrl);
});
},
notifyAngular: () => {
$scope.$digest();
},
chrome,
I18nContext: coreStart.i18n.Context,
});
// register things on scope passed down to react components
$scope.pluginDataStart = data;
$scope.storage = storage;
$scope.coreStart = coreStart;
$scope.loading = false;
$scope.reduxStore = store;
$scope.savedWorkspace = $route.current.locals.savedWorkspace;
// register things for legacy angular UI
const allSavingDisabled = graphSavePolicy === 'none';
$scope.spymode = 'request';
$scope.colors = colorChoices;
$scope.isColorDark = (color) => isColorDark(...hexToRgb(color));
$scope.nodeClick = function (n, $event) {
//Selection logic - shift key+click helps selects multiple nodes
// Without the shift key we deselect all prior selections (perhaps not
// a great idea for touch devices with no concept of shift key)
if (!$event.shiftKey) {
const prevSelection = n.isSelected;
$scope.workspace.selectNone();
n.isSelected = prevSelection;
}
if ($scope.workspace.toggleNodeSelection(n)) {
$scope.selectSelected(n);
} else {
$scope.detail = null;
}
};
$scope.clickEdge = function (edge) {
$scope.workspace.getAllIntersections($scope.handleMergeCandidatesCallback, [
edge.topSrc,
edge.topTarget,
]);
};
$scope.submit = function (searchTerm) {
$scope.workspaceInitialized = true;
const numHops = 2;
if (searchTerm.startsWith('{')) {
try {
const query = JSON.parse(searchTerm);
if (query.vertices) {
// Is a graph explore request
$scope.workspace.callElasticsearch(query);
} else {
// Is a regular query DSL query
$scope.workspace.search(query, $scope.liveResponseFields, numHops);
}
} catch (err) {
handleError(err);
}
return;
}
$scope.workspace.simpleSearch(searchTerm, $scope.liveResponseFields, numHops);
};
$scope.selectSelected = function (node) {
$scope.detail = {
latestNodeSelection: node,
};
return ($scope.selectedSelectedVertex = node);
};
$scope.isSelectedSelected = function (node) {
return $scope.selectedSelectedVertex === node;
};
$scope.openUrlTemplate = function (template) {
const url = template.url;
const newUrl = url.replace(urlTemplateRegex, template.encoder.encode($scope.workspace));
window.open(newUrl, '_blank');
};
$scope.aceLoaded = (editor) => {
editor.$blockScrolling = Infinity;
};
$scope.setDetail = function (data) {
$scope.detail = data;
};
function canWipeWorkspace(callback, text, options) {
if (!hasFieldsSelector(store.getState())) {
callback();
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) {
callback();
}
});
}
$scope.confirmWipeWorkspace = canWipeWorkspace;
$scope.performMerge = function (parentId, childId) {
let found = true;
while (found) {
found = false;
for (const i in $scope.detail.mergeCandidates) {
if ($scope.detail.mergeCandidates.hasOwnProperty(i)) {
const mc = $scope.detail.mergeCandidates[i];
if (mc.id1 === childId || mc.id2 === childId) {
$scope.detail.mergeCandidates.splice(i, 1);
found = true;
break;
}
}
}
}
$scope.workspace.mergeIds(parentId, childId);
$scope.detail = null;
};
$scope.handleMergeCandidatesCallback = function (termIntersects) {
const mergeCandidates = [];
termIntersects.forEach((ti) => {
mergeCandidates.push({
id1: ti.id1,
id2: ti.id2,
term1: ti.term1,
term2: ti.term2,
v1: ti.v1,
v2: ti.v2,
overlap: ti.overlap,
});
});
$scope.detail = { mergeCandidates };
};
// ===== Menubar configuration =========
$scope.setHeaderActionMenu = setHeaderActionMenu;
$scope.topNavMenu = [];
$scope.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',
}),
run: function () {
canWipeWorkspace(function () {
$scope.$evalAsync(() => {
if ($location.url() === '/workspace/') {
$route.reload();
} else {
$location.url('/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 (capabilities.save) {
// allSavingDisabled is based on the xpack.graph.savePolicy, we'll maintain this functionality
$scope.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: function () {
return allSavingDisabled || !hasFieldsSelector(store.getState());
},
run: () => {
store.dispatch({
type: 'x-pack/graph/SAVE_WORKSPACE',
payload: $route.current.locals.savedWorkspace,
});
},
testId: 'graphSaveButton',
});
}
$scope.topNavMenu.push({
key: 'inspect',
disableButton: function () {
return $scope.workspace === null;
},
label: i18n.translate('xpack.graph.topNavMenu.inspectLabel', {
defaultMessage: 'Inspect',
}),
description: i18n.translate('xpack.graph.topNavMenu.inspectAriaLabel', {
defaultMessage: 'Inspect',
}),
run: () => {
$scope.$evalAsync(() => {
const curState = $scope.menus.showInspect;
$scope.closeMenus();
$scope.menus.showInspect = !curState;
});
},
});
$scope.topNavMenu.push({
key: 'settings',
disableButton: function () {
return datasourceSelector(store.getState()).type === 'none';
},
label: i18n.translate('xpack.graph.topNavMenu.settingsLabel', {
defaultMessage: 'Settings',
}),
description: i18n.translate('xpack.graph.topNavMenu.settingsAriaLabel', {
defaultMessage: 'Settings',
}),
run: () => {
const settingsObservable = asAngularSyncedObservable(
() => ({
blocklistedNodes: $scope.workspace ? [...$scope.workspace.blocklistedNodes] : undefined,
unblocklistNode: $scope.workspace ? $scope.workspace.unblocklist : undefined,
canEditDrillDownUrls: canEditDrillDownUrls,
}),
$scope.$digest.bind($scope)
);
coreStart.overlays.openFlyout(
toMountPoint(
<Provider store={store}>
<Settings observable={settingsObservable} />
</Provider>
),
{
size: 'm',
closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', {
defaultMessage: 'Close',
}),
'data-test-subj': 'graphSettingsFlyout',
ownFocus: true,
className: 'gphSettingsFlyout',
maxWidth: 520,
}
);
},
});
// Allow URLs to include a user-defined text query
if ($route.current.params.query) {
$scope.initialQuery = $route.current.params.query;
const unbind = $scope.$watch('workspace', () => {
if (!$scope.workspace) {
return;
}
unbind();
$scope.submit($route.current.params.query);
});
}
$scope.menus = {
showSettings: false,
};
$scope.closeMenus = () => {
_.forOwn($scope.menus, function (_, key) {
$scope.menus[key] = false;
});
};
// Deal with situation of request to open saved workspace
if ($route.current.locals.savedWorkspace.id) {
store.dispatch({
type: 'x-pack/graph/LOAD_WORKSPACE',
payload: $route.current.locals.savedWorkspace,
});
} else {
$scope.noIndexPatterns = $route.current.locals.indexPatterns.length === 0;
}
});
//End controller
}

View file

@ -5,20 +5,8 @@
* 2.0.
*/
// inner angular imports
// these are necessary to bootstrap the local angular.
// They can stay even after NP cutover
import angular from 'angular';
import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular';
import { i18n } from '@kbn/i18n';
import 'brace';
import 'brace/mode/json';
// required for i18nIdDirective and `ngSanitize` angular module
import 'angular-sanitize';
// required for ngRoute
import 'angular-route';
// type imports
import {
ChromeStart,
CoreStart,
@ -28,23 +16,21 @@ import {
OverlayStart,
AppMountParameters,
IUiSettingsClient,
Capabilities,
ScopedHistory,
} from 'kibana/public';
// @ts-ignore
import { initGraphApp } from './app';
import ReactDOM from 'react-dom';
import { DataPlugin, IndexPatternsContract } from '../../../../src/plugins/data/public';
import { LicensingPluginStart } from '../../licensing/public';
import { checkLicense } from '../common/check_license';
import { NavigationPublicPluginStart as NavigationStart } from '../../../../src/plugins/navigation/public';
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import {
configureAppAngularModule,
createTopNavDirective,
createTopNavHelper,
KibanaLegacyStart,
} from '../../../../src/plugins/kibana_legacy/public';
import { KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public';
import './index.scss';
import { SavedObjectsStart } from '../../../../src/plugins/saved_objects/public';
import { GraphSavePolicy } from './types';
import { graphRouter } from './router';
/**
* These are dependencies of the Graph app besides the base dependencies
@ -58,7 +44,7 @@ export interface GraphDependencies {
coreStart: CoreStart;
element: HTMLElement;
appBasePath: string;
capabilities: Record<string, boolean | Record<string, boolean>>;
capabilities: Capabilities;
navigation: NavigationStart;
licensing: LicensingPluginStart;
chrome: ChromeStart;
@ -70,22 +56,32 @@ export interface GraphDependencies {
getBasePath: () => string;
storage: Storage;
canEditDrillDownUrls: boolean;
graphSavePolicy: string;
graphSavePolicy: GraphSavePolicy;
overlays: OverlayStart;
savedObjects: SavedObjectsStart;
kibanaLegacy: KibanaLegacyStart;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
uiSettings: IUiSettingsClient;
history: ScopedHistory<unknown>;
}
export const renderApp = ({ appBasePath, element, kibanaLegacy, ...deps }: GraphDependencies) => {
export type GraphServices = Omit<GraphDependencies, 'kibanaLegacy' | 'element' | 'history'>;
export const renderApp = ({ history, kibanaLegacy, element, ...deps }: GraphDependencies) => {
const { chrome, capabilities } = deps;
kibanaLegacy.loadFontAwesome();
const graphAngularModule = createLocalAngularModule(deps.navigation);
configureAppAngularModule(
graphAngularModule,
{ core: deps.core, env: deps.pluginInitializerContext.env },
true
);
if (!capabilities.graph.save) {
chrome.setBadge({
text: i18n.translate('xpack.graph.badge.readOnly.text', {
defaultMessage: 'Read only',
}),
tooltip: i18n.translate('xpack.graph.badge.readOnly.tooltip', {
defaultMessage: 'Unable to save Graph workspaces',
}),
iconType: 'glasses',
});
}
const licenseSubscription = deps.licensing.license$.subscribe((license) => {
const info = checkLicense(license);
@ -105,59 +101,19 @@ export const renderApp = ({ appBasePath, element, kibanaLegacy, ...deps }: Graph
}
});
initGraphApp(graphAngularModule, deps);
const $injector = mountGraphApp(appBasePath, element);
// dispatch synthetic hash change event to update hash history objects
// this is necessary because hash updates triggered by using popState won't trigger this event naturally.
const unlistenParentHistory = history.listen(() => {
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
const app = graphRouter(deps);
ReactDOM.render(app, element);
element.setAttribute('class', 'gphAppWrapper');
return () => {
licenseSubscription.unsubscribe();
$injector.get('$rootScope').$destroy();
unlistenParentHistory();
ReactDOM.unmountComponentAtNode(element);
};
};
const mainTemplate = (basePath: string) => `<div ng-view class="gphAppWrapper">
<base href="${basePath}" />
</div>
`;
const moduleName = 'app/graph';
const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react', 'ui.bootstrap'];
function mountGraphApp(appBasePath: string, element: HTMLElement) {
const mountpoint = document.createElement('div');
mountpoint.setAttribute('class', 'gphAppWrapper');
// eslint-disable-next-line no-unsanitized/property
mountpoint.innerHTML = mainTemplate(appBasePath);
// bootstrap angular into detached element and attach it later to
// make angular-within-angular possible
const $injector = angular.bootstrap(mountpoint, [moduleName]);
element.appendChild(mountpoint);
element.setAttribute('class', 'gphAppWrapper');
return $injector;
}
function createLocalAngularModule(navigation: NavigationStart) {
createLocalI18nModule();
createLocalTopNavModule(navigation);
const graphAngularModule = angular.module(moduleName, [
...thirdPartyAngularDependencies,
'graphI18n',
'graphTopNav',
]);
return graphAngularModule;
}
function createLocalTopNavModule(navigation: NavigationStart) {
angular
.module('graphTopNav', ['react'])
.directive('kbnTopNav', createTopNavDirective)
.directive('kbnTopNavHelper', createTopNavHelper(navigation.ui));
}
function createLocalI18nModule() {
angular
.module('graphI18n', [])
.provider('i18n', I18nProvider)
.filter('i18n', i18nFilter)
.directive('i18nId', i18nDirective);
}

View file

@ -5,30 +5,72 @@
* 2.0.
*/
import React, { Fragment, useCallback, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import React, { Fragment } from 'react';
import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui';
import { CoreStart, ApplicationStart } from 'kibana/public';
import { ApplicationStart } from 'kibana/public';
import { useHistory, useLocation } from 'react-router-dom';
import { TableListView } from '../../../../../src/plugins/kibana_react/public';
import { deleteSavedWorkspace, findSavedWorkspace } from '../helpers/saved_workspace_utils';
import { getEditPath, getEditUrl, getNewPath, setBreadcrumbs } from '../services/url';
import { GraphWorkspaceSavedObject } from '../types';
import { GraphServices } from '../application';
export interface ListingProps {
coreStart: CoreStart;
createItem: () => void;
findItems: (query: string) => Promise<{ total: number; hits: GraphWorkspaceSavedObject[] }>;
deleteItems: (records: GraphWorkspaceSavedObject[]) => Promise<void>;
editItem: (record: GraphWorkspaceSavedObject) => void;
getViewUrl: (record: GraphWorkspaceSavedObject) => string;
listingLimit: number;
hideWriteControls: boolean;
capabilities: { save: boolean; delete: boolean };
initialFilter: string;
initialPageSize: number;
export interface ListingRouteProps {
deps: GraphServices;
}
export function Listing(props: ListingProps) {
export function ListingRoute({
deps: { chrome, savedObjects, savedObjectsClient, coreStart, capabilities, addBasePath },
}: ListingRouteProps) {
const listingLimit = savedObjects.settings.getListingLimit();
const initialPageSize = savedObjects.settings.getPerPage();
const history = useHistory();
const query = new URLSearchParams(useLocation().search);
const initialFilter = query.get('filter') || '';
useEffect(() => {
setBreadcrumbs({ chrome });
}, [chrome]);
const createItem = useCallback(() => {
history.push(getNewPath());
}, [history]);
const findItems = useCallback(
(search: string) => {
return findSavedWorkspace(
{ savedObjectsClient, basePath: coreStart.http.basePath },
search,
listingLimit
);
},
[coreStart.http.basePath, listingLimit, savedObjectsClient]
);
const editItem = useCallback(
(savedWorkspace: GraphWorkspaceSavedObject) => {
history.push(getEditPath(savedWorkspace));
},
[history]
);
const getViewUrl = useCallback(
(savedWorkspace: GraphWorkspaceSavedObject) => getEditUrl(addBasePath, savedWorkspace),
[addBasePath]
);
const deleteItems = useCallback(
async (savedWorkspaces: GraphWorkspaceSavedObject[]) => {
await deleteSavedWorkspace(
savedObjectsClient,
savedWorkspaces.map((cur) => cur.id!)
);
},
[savedObjectsClient]
);
return (
<I18nProvider>
<TableListView
@ -37,20 +79,20 @@ export function Listing(props: ListingProps) {
})}
headingId="graphListingHeading"
rowHeader="title"
createItem={props.capabilities.save ? props.createItem : undefined}
findItems={props.findItems}
deleteItems={props.capabilities.delete ? props.deleteItems : undefined}
editItem={props.capabilities.save ? props.editItem : undefined}
tableColumns={getTableColumns(props.getViewUrl)}
listingLimit={props.listingLimit}
initialFilter={props.initialFilter}
initialPageSize={props.initialPageSize}
createItem={capabilities.graph.save ? createItem : undefined}
findItems={findItems}
deleteItems={capabilities.graph.delete ? deleteItems : undefined}
editItem={capabilities.graph.save ? editItem : undefined}
tableColumns={getTableColumns(getViewUrl)}
listingLimit={listingLimit}
initialFilter={initialFilter}
initialPageSize={initialPageSize}
emptyPrompt={getNoItemsMessage(
props.capabilities.save === false,
props.createItem,
props.coreStart.application
capabilities.graph.save === false,
createItem,
coreStart.application
)}
toastNotifications={props.coreStart.notifications.toasts}
toastNotifications={coreStart.notifications.toasts}
entityName={i18n.translate('xpack.graph.listing.table.entityName', {
defaultMessage: 'graph',
})}

View file

@ -0,0 +1,152 @@
/*
* 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, { useMemo, useRef, useState } from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { Provider } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { showSaveModal } from '../../../../../src/plugins/saved_objects/public';
import { Workspace } from '../types';
import { createGraphStore } from '../state_management';
import { createWorkspace } from '../services/workspace/graph_client_workspace';
import { WorkspaceLayout } from '../components/workspace_layout';
import { GraphServices } from '../application';
import { useWorkspaceLoader } from '../helpers/use_workspace_loader';
import { useGraphLoader } from '../helpers/use_graph_loader';
import { createCachedIndexPatternProvider } from '../services/index_pattern_cache';
export interface WorkspaceRouteProps {
deps: GraphServices;
}
export const WorkspaceRoute = ({
deps: {
toastNotifications,
coreStart,
savedObjectsClient,
graphSavePolicy,
chrome,
canEditDrillDownUrls,
overlays,
navigation,
capabilities,
storage,
data,
getBasePath,
addBasePath,
setHeaderActionMenu,
indexPatterns: getIndexPatternProvider,
},
}: WorkspaceRouteProps) => {
/**
* It's temporary workaround, which should be removed after migration `workspace` to redux.
* Ref holds mutable `workspace` object. After each `workspace.methodName(...)` call
* (which might mutate `workspace` somehow), react state needs to be updated using
* `workspace.changeHandler()`.
*/
const workspaceRef = useRef<Workspace>();
/**
* Providing `workspaceRef.current` to the hook dependencies or components itself
* will not leads to updates, therefore `renderCounter` is used to update react state.
*/
const [renderCounter, setRenderCounter] = useState(0);
const history = useHistory();
const urlQuery = new URLSearchParams(useLocation().search).get('query');
const indexPatternProvider = useMemo(
() => createCachedIndexPatternProvider(getIndexPatternProvider.get),
[getIndexPatternProvider.get]
);
const { loading, callNodeProxy, callSearchNodeProxy, handleSearchQueryError } = useGraphLoader({
toastNotifications,
coreStart,
});
const services = useMemo(
() => ({
appName: 'graph',
storage,
data,
...coreStart,
}),
[coreStart, data, storage]
);
const [store] = useState(() =>
createGraphStore({
basePath: getBasePath(),
addBasePath,
indexPatternProvider,
createWorkspace: (indexPattern, exploreControls) => {
const options = {
indexName: indexPattern,
vertex_fields: [],
// Here we have the opportunity to look up labels for nodes...
nodeLabeller() {
// console.log(newNodes);
},
changeHandler: () => setRenderCounter((cur) => cur + 1),
graphExploreProxy: callNodeProxy,
searchProxy: callSearchNodeProxy,
exploreControls,
};
const createdWorkspace = (workspaceRef.current = createWorkspace(options));
return createdWorkspace;
},
getWorkspace: () => workspaceRef.current,
notifications: coreStart.notifications,
http: coreStart.http,
overlays: coreStart.overlays,
savedObjectsClient,
showSaveModal,
savePolicy: graphSavePolicy,
changeUrl: (newUrl) => history.push(newUrl),
notifyReact: () => setRenderCounter((cur) => cur + 1),
chrome,
I18nContext: coreStart.i18n.Context,
handleSearchQueryError,
})
);
const { savedWorkspace, indexPatterns } = useWorkspaceLoader({
workspaceRef,
store,
savedObjectsClient,
toastNotifications,
});
if (!savedWorkspace || !indexPatterns) {
return null;
}
return (
<I18nProvider>
<KibanaContextProvider services={services}>
<Provider store={store}>
<WorkspaceLayout
renderCounter={renderCounter}
workspace={workspaceRef.current}
loading={loading}
setHeaderActionMenu={setHeaderActionMenu}
graphSavePolicy={graphSavePolicy}
navigation={navigation}
capabilities={capabilities}
coreStart={coreStart}
canEditDrillDownUrls={canEditDrillDownUrls}
overlays={overlays}
indexPatterns={indexPatterns}
savedWorkspace={savedWorkspace}
indexPatternProvider={indexPatternProvider}
urlQuery={urlQuery}
/>
</Provider>
</KibanaContextProvider>
</I18nProvider>
);
};

View file

@ -1,24 +0,0 @@
/*
* 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 { i18n } from '@kbn/i18n';
export function getReadonlyBadge(uiCapabilities) {
if (uiCapabilities.graph.save) {
return null;
}
return {
text: i18n.translate('xpack.graph.badge.readOnly.text', {
defaultMessage: 'Read only',
}),
tooltip: i18n.translate('xpack.graph.badge.readOnly.tooltip', {
defaultMessage: 'Unable to save Graph workspaces',
}),
iconType: 'glasses',
};
}

View file

@ -1,11 +1,3 @@
@mixin gphSvgText() {
font-family: $euiFontFamily;
font-size: $euiSizeS;
line-height: $euiSizeM;
fill: $euiColorDarkShade;
color: $euiColorDarkShade;
}
/**
* THE SVG Graph
* 1. Calculated px values come from the open/closed state of the global nav sidebar

View file

@ -7,3 +7,6 @@
@import './settings/index';
@import './legacy_icon/index';
@import './field_manager/index';
@import './graph';
@import './sidebar';
@import './inspect';

View file

@ -24,6 +24,10 @@
padding: $euiSizeXS;
border-radius: $euiBorderRadius;
margin-bottom: $euiSizeXS;
& > span {
padding-right: $euiSizeXS;
}
}
.gphSidebar__panel {
@ -35,8 +39,9 @@
* Vertex Select
*/
.gphVertexSelect__button {
margin: $euiSizeXS $euiSizeXS $euiSizeXS 0;
.vertexSelectionTypesBar {
margin-top: 0;
margin-bottom: 0;
}
/**
@ -68,15 +73,24 @@
background: $euiColorLightShade;
}
/**
* Link summary
*/
.gphDrillDownIconLinks {
margin-top: .5 * $euiSizeXS;
margin-bottom: .5 * $euiSizeXS;
}
/**
* Link summary
*/
.gphLinkSummary__term--1 {
color:$euiColorDanger;
color: $euiColorDanger;
}
.gphLinkSummary__term--2 {
color:$euiColorPrimary;
color: $euiColorPrimary;
}
.gphLinkSummary__term--1-2 {
color: mix($euiColorDanger, $euiColorPrimary);

View file

@ -1,76 +0,0 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import { DataPublicPluginStart } from 'src/plugins/data/public';
import { Provider } from 'react-redux';
import React, { useState } from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { CoreStart } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { FieldManager } from './field_manager';
import { SearchBarProps, SearchBar } from './search_bar';
import { GraphStore } from '../state_management';
import { GuidancePanel } from './guidance_panel';
import { GraphTitle } from './graph_title';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
export interface GraphAppProps extends SearchBarProps {
coreStart: CoreStart;
// This is not named dataStart because of Angular treating data- prefix differently
pluginDataStart: DataPublicPluginStart;
storage: IStorageWrapper;
reduxStore: GraphStore;
isInitialized: boolean;
noIndexPatterns: boolean;
}
export function GraphApp(props: GraphAppProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const {
coreStart,
pluginDataStart,
storage,
reduxStore,
noIndexPatterns,
...searchBarProps
} = props;
return (
<I18nProvider>
<KibanaContextProvider
services={{
appName: 'graph',
storage,
data: pluginDataStart,
...coreStart,
}}
>
<Provider store={reduxStore}>
<>
{props.isInitialized && <GraphTitle />}
<div className="gphGraph__bar">
<SearchBar {...searchBarProps} />
<EuiSpacer size="s" />
<FieldManager pickerOpen={pickerOpen} setPickerOpen={setPickerOpen} />
</div>
{!props.isInitialized && (
<GuidancePanel
noIndexPatterns={noIndexPatterns}
onOpenFieldPicker={() => {
setPickerOpen(true);
}}
/>
)}
</>
</Provider>
</KibanaContextProvider>
</I18nProvider>
);
}

View file

@ -0,0 +1,143 @@
/*
* 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 { connect } from 'react-redux';
import {
ControlType,
TermIntersect,
UrlTemplate,
Workspace,
WorkspaceField,
WorkspaceNode,
} from '../../types';
import { urlTemplateRegex } from '../../helpers/url_template';
import { SelectionToolBar } from './selection_tool_bar';
import { ControlPanelToolBar } from './control_panel_tool_bar';
import { SelectStyle } from './select_style';
import { SelectedNodeEditor } from './selected_node_editor';
import { MergeCandidates } from './merge_candidates';
import { DrillDowns } from './drill_downs';
import { DrillDownIconLinks } from './drill_down_icon_links';
import { GraphState, liveResponseFieldsSelector, templatesSelector } from '../../state_management';
import { SelectedNodeItem } from './selected_node_item';
export interface TargetOptions {
toFields: WorkspaceField[];
}
interface ControlPanelProps {
renderCounter: number;
workspace: Workspace;
control: ControlType;
selectedNode?: WorkspaceNode;
colors: string[];
mergeCandidates: TermIntersect[];
onSetControl: (control: ControlType) => void;
selectSelected: (node: WorkspaceNode) => void;
}
interface ControlPanelStateProps {
urlTemplates: UrlTemplate[];
liveResponseFields: WorkspaceField[];
}
const ControlPanelComponent = ({
workspace,
liveResponseFields,
urlTemplates,
control,
selectedNode,
colors,
mergeCandidates,
onSetControl,
selectSelected,
}: ControlPanelProps & ControlPanelStateProps) => {
const hasNodes = workspace.nodes.length === 0;
const openUrlTemplate = (template: UrlTemplate) => {
const url = template.url;
const newUrl = url.replace(urlTemplateRegex, template.encoder.encode(workspace!));
window.open(newUrl, '_blank');
};
const onSelectedFieldClick = (node: WorkspaceNode) => {
selectSelected(node);
workspace.changeHandler();
};
const onDeselectNode = (node: WorkspaceNode) => {
workspace.deselectNode(node);
workspace.changeHandler();
onSetControl('none');
};
return (
<div id="sidebar" className="gphSidebar">
<ControlPanelToolBar
workspace={workspace}
liveResponseFields={liveResponseFields}
onSetControl={onSetControl}
/>
<div>
<div className="gphSidebar__header">
{i18n.translate('xpack.graph.sidebar.selectionsTitle', {
defaultMessage: 'Selections',
})}
</div>
<SelectionToolBar workspace={workspace} onSetControl={onSetControl} />
<div className="gphSelectionList">
{workspace.selectedNodes.length === 0 && (
<p className="help-block">
{i18n.translate('xpack.graph.sidebar.selections.noSelectionsHelpText', {
defaultMessage: 'No selections. Click on vertices to add.',
})}
</p>
)}
{workspace.selectedNodes.map((node) => (
<SelectedNodeItem
key={node.id}
node={node}
isHighlighted={selectedNode === node}
onSelectedFieldClick={onSelectedFieldClick}
onDeselectNode={onDeselectNode}
/>
))}
</div>
</div>
<DrillDownIconLinks
urlTemplates={urlTemplates}
hasNodes={hasNodes}
openUrlTemplate={openUrlTemplate}
/>
{control === 'drillDowns' && (
<DrillDowns urlTemplates={urlTemplates} openUrlTemplate={openUrlTemplate} />
)}
{control === 'style' && workspace.selectedNodes.length > 0 && (
<SelectStyle workspace={workspace} colors={colors} />
)}
{control === 'editLabel' && selectedNode && (
<SelectedNodeEditor workspace={workspace} selectedNode={selectedNode} />
)}
{control === 'mergeTerms' && mergeCandidates.length > 0 && (
<MergeCandidates
workspace={workspace}
mergeCandidates={mergeCandidates}
onSetControl={onSetControl}
/>
)}
</div>
);
};
export const ControlPanel = connect((state: GraphState) => ({
urlTemplates: templatesSelector(state),
liveResponseFields: liveResponseFieldsSelector(state),
}))(ControlPanelComponent);

View file

@ -0,0 +1,230 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { ControlType, Workspace, WorkspaceField } from '../../types';
interface ControlPanelToolBarProps {
workspace: Workspace;
liveResponseFields: WorkspaceField[];
onSetControl: (action: ControlType) => void;
}
export const ControlPanelToolBar = ({
workspace,
onSetControl,
liveResponseFields,
}: ControlPanelToolBarProps) => {
const haveNodes = workspace.nodes.length === 0;
const undoButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.undoButtonTooltip', {
defaultMessage: 'Undo',
});
const redoButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.redoButtonTooltip', {
defaultMessage: 'Redo',
});
const expandButtonMsg = i18n.translate(
'xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip',
{
defaultMessage: 'Expand selection',
}
);
const addLinksButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.addLinksButtonTooltip', {
defaultMessage: 'Add links between existing terms',
});
const removeVerticesButtonMsg = i18n.translate(
'xpack.graph.sidebar.topMenu.removeVerticesButtonTooltip',
{
defaultMessage: 'Remove vertices from workspace',
}
);
const blocklistButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.blocklistButtonTooltip', {
defaultMessage: 'Block selection from appearing in workspace',
});
const customStyleButtonMsg = i18n.translate(
'xpack.graph.sidebar.topMenu.customStyleButtonTooltip',
{
defaultMessage: 'Custom style selected vertices',
}
);
const drillDownButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.drillDownButtonTooltip', {
defaultMessage: 'Drill down',
});
const runLayoutButtonMsg = i18n.translate('xpack.graph.sidebar.topMenu.runLayoutButtonTooltip', {
defaultMessage: 'Run layout',
});
const pauseLayoutButtonMsg = i18n.translate(
'xpack.graph.sidebar.topMenu.pauseLayoutButtonTooltip',
{
defaultMessage: 'Pause layout',
}
);
const onUndoClick = () => workspace.undo();
const onRedoClick = () => workspace.redo();
const onExpandButtonClick = () => {
onSetControl('none');
workspace.expandSelecteds({ toFields: liveResponseFields });
};
const onAddLinksClick = () => workspace.fillInGraph();
const onRemoveVerticesClick = () => {
onSetControl('none');
workspace.deleteSelection();
};
const onBlockListClick = () => workspace.blocklistSelection();
const onCustomStyleClick = () => onSetControl('style');
const onDrillDownClick = () => onSetControl('drillDowns');
const onRunLayoutClick = () => workspace.runLayout();
const onPauseLayoutClick = () => {
workspace.stopLayout();
workspace.changeHandler();
};
return (
<EuiFlexGroup gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiToolTip content={undoButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
aria-label={undoButtonMsg}
type="button"
onClick={onUndoClick}
disabled={workspace.undoLog.length < 1}
>
<span className="kuiIcon fa-history" />
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={redoButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
aria-label={redoButtonMsg}
type="button"
onClick={onRedoClick}
disabled={workspace.redoLog.length === 0}
>
<span className="kuiIcon fa-repeat" />
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={expandButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
aria-label={expandButtonMsg}
disabled={liveResponseFields.length === 0 || workspace.nodes.length === 0}
onClick={onExpandButtonClick}
>
<span className="kuiIcon fa-plus" />
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={addLinksButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
aria-label={addLinksButtonMsg}
disabled={haveNodes}
onClick={onAddLinksClick}
>
<span className="kuiIcon fa-link" />
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={removeVerticesButtonMsg}>
<button
data-test-subj="graphRemoveSelection"
className="kuiButton kuiButton--basic kuiButton--small"
disabled={haveNodes}
aria-label={removeVerticesButtonMsg}
onClick={onRemoveVerticesClick}
>
<span className="kuiIcon fa-trash" />
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={blocklistButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
disabled={workspace.selectedNodes.length === 0}
aria-label={blocklistButtonMsg}
onClick={onBlockListClick}
>
<span className="kuiIcon fa-ban" />
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={customStyleButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
disabled={workspace.selectedNodes.length === 0}
aria-label={customStyleButtonMsg}
onClick={onCustomStyleClick}
>
<span className="kuiIcon fa-paint-brush" />
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={drillDownButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
disabled={haveNodes}
aria-label={drillDownButtonMsg}
onClick={onDrillDownClick}
>
<span className="kuiIcon fa-info" />
</button>
</EuiToolTip>
</EuiFlexItem>
{(workspace.nodes.length === 0 || workspace.force === null) && (
<EuiFlexItem grow={false}>
<EuiToolTip content={runLayoutButtonMsg}>
<button
data-test-subj="graphResumeLayout"
className="kuiButton kuiButton--basic kuiButton--small"
disabled={workspace.nodes.length === 0}
aria-label={runLayoutButtonMsg}
onClick={onRunLayoutClick}
>
<span className="kuiIcon fa-play" />
</button>
</EuiToolTip>
</EuiFlexItem>
)}
{workspace.force !== null && workspace.nodes.length > 0 && (
<EuiFlexItem grow={false}>
<EuiToolTip content={pauseLayoutButtonMsg}>
<button
data-test-subj="graphPauseLayout"
className="kuiButton kuiButton--basic kuiButton--small"
aria-label={pauseLayoutButtonMsg}
onClick={onPauseLayoutClick}
>
<span className="kuiIcon fa-pause" />
</button>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,61 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { UrlTemplate } from '../../types';
interface UrlTemplateButtonsProps {
urlTemplates: UrlTemplate[];
hasNodes: boolean;
openUrlTemplate: (template: UrlTemplate) => void;
}
export const DrillDownIconLinks = ({
hasNodes,
urlTemplates,
openUrlTemplate,
}: UrlTemplateButtonsProps) => {
const drillDownsWithIcons = urlTemplates.filter(
({ icon }: UrlTemplate) => icon && icon.class !== ''
);
if (drillDownsWithIcons.length === 0) {
return null;
}
const drillDowns = drillDownsWithIcons.map((cur) => {
const onUrlTemplateClick = () => openUrlTemplate(cur);
return (
<EuiFlexItem grow={false}>
<EuiToolTip content={cur.description}>
<button
className="kuiButton kuiButton--basic kuiButton--small"
type="button"
disabled={hasNodes}
onClick={onUrlTemplateClick}
>
<span className={`kuiIcon ${cur.icon?.class || ''}`} />
</button>
</EuiToolTip>
</EuiFlexItem>
);
});
return (
<EuiFlexGroup
className="gphDrillDownIconLinks"
justifyContent="flexStart"
alignItems="center"
gutterSize="xs"
responsive={false}
>
{drillDowns}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,55 @@
/*
* 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 { UrlTemplate } from '../../types';
interface DrillDownsProps {
urlTemplates: UrlTemplate[];
openUrlTemplate: (template: UrlTemplate) => void;
}
export const DrillDowns = ({ urlTemplates, openUrlTemplate }: DrillDownsProps) => {
return (
<div>
<div className="gphSidebar__header">
<span className="kuiIcon fa-info" />
{i18n.translate('xpack.graph.sidebar.drillDownsTitle', {
defaultMessage: 'Drill-downs',
})}
</div>
<div className="gphSidebar__panel">
{urlTemplates.length === 0 && (
<p className="help-block">
{i18n.translate('xpack.graph.sidebar.drillDowns.noDrillDownsHelpText', {
defaultMessage: 'Configure drill-downs from the settings menu',
})}
</p>
)}
<ul className="list-group">
{urlTemplates.map((urlTemplate) => {
const onOpenUrlTemplate = () => openUrlTemplate(urlTemplate);
return (
<li className="list-group-item">
{urlTemplate.icon && (
<span className="kuiIcon gphNoUserSelect">{urlTemplate.icon?.code}</span>
)}
<a aria-hidden="true" onClick={onOpenUrlTemplate}>
{urlTemplate.description}
</a>
</li>
);
})}
</ul>
</div>
</div>
);
};

View file

@ -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 './control_panel';

View file

@ -0,0 +1,137 @@
/*
* 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 { EuiToolTip } from '@elastic/eui';
import { ControlType, TermIntersect, Workspace } from '../../types';
import { VennDiagram } from '../venn_diagram';
interface MergeCandidatesProps {
workspace: Workspace;
mergeCandidates: TermIntersect[];
onSetControl: (control: ControlType) => void;
}
export const MergeCandidates = ({
workspace,
mergeCandidates,
onSetControl,
}: MergeCandidatesProps) => {
const performMerge = (parentId: string, childId: string) => {
const tempMergeCandidates = [...mergeCandidates];
let found = true;
while (found) {
found = false;
for (let i = 0; i < tempMergeCandidates.length; i++) {
const term = tempMergeCandidates[i];
if (term.id1 === childId || term.id2 === childId) {
tempMergeCandidates.splice(i, 1);
found = true;
break;
}
}
}
workspace.mergeIds(parentId, childId);
onSetControl('none');
};
return (
<div className="gphSidebar__panel">
<div className="gphSidebar__header">
<span className="kuiIcon fa-link" />
{i18n.translate('xpack.graph.sidebar.linkSummaryTitle', {
defaultMessage: 'Link summary',
})}
</div>
{mergeCandidates.map((mc) => {
const mergeTerm1ToTerm2ButtonMsg = i18n.translate(
'xpack.graph.sidebar.linkSummary.mergeTerm1ToTerm2ButtonTooltip',
{
defaultMessage: 'Merge {term1} into {term2}',
values: { term1: mc.term1, term2: mc.term2 },
}
);
const mergeTerm2ToTerm1ButtonMsg = i18n.translate(
'xpack.graph.sidebar.linkSummary.mergeTerm2ToTerm1ButtonTooltip',
{
defaultMessage: 'Merge {term2} into {term1}',
values: { term1: mc.term1, term2: mc.term2 },
}
);
const leftTermCountMsg = i18n.translate(
'xpack.graph.sidebar.linkSummary.leftTermCountTooltip',
{
defaultMessage: '{count} documents have term {term}',
values: { count: mc.v1, term: mc.term1 },
}
);
const bothTermsCountMsg = i18n.translate(
'xpack.graph.sidebar.linkSummary.bothTermsCountTooltip',
{
defaultMessage: '{count} documents have both terms',
values: { count: mc.overlap },
}
);
const rightTermCountMsg = i18n.translate(
'xpack.graph.sidebar.linkSummary.rightTermCountTooltip',
{
defaultMessage: '{count} documents have term {term}',
values: { count: mc.v2, term: mc.term2 },
}
);
const onMergeTerm1ToTerm2Click = () => performMerge(mc.id2, mc.id1);
const onMergeTerm2ToTerm1Click = () => performMerge(mc.id1, mc.id2);
return (
<div>
<span>
<EuiToolTip content={mergeTerm1ToTerm2ButtonMsg}>
<button
type="button"
style={{ opacity: 0.2 + mc.overlap / mc.v1 }}
className="kuiButton kuiButton--basic kuiButton--small"
onClick={onMergeTerm1ToTerm2Click}
>
<span className="kuiIcon fa-chevron-circle-right" />
</button>
</EuiToolTip>
<span className="gphLinkSummary__term--1">{mc.term1}</span>
<span className="gphLinkSummary__term--2">{mc.term2}</span>
<EuiToolTip content={mergeTerm2ToTerm1ButtonMsg}>
<button
type="button"
className="kuiButton kuiButton--basic kuiButton--small"
style={{ opacity: 0.2 + mc.overlap / mc.v2 }}
onClick={onMergeTerm2ToTerm1Click}
>
<span className="kuiIcon fa-chevron-circle-left" />
</button>
</EuiToolTip>
</span>
<VennDiagram leftValue={mc.v1} rightValue={mc.v2} overlap={mc.overlap} />
<EuiToolTip content={leftTermCountMsg}>
<small className="gphLinkSummary__term--1">{mc.v1}</small>
</EuiToolTip>
<EuiToolTip content={bothTermsCountMsg}>
<small className="gphLinkSummary__term--1-2">&nbsp;({mc.overlap})&nbsp;</small>
</EuiToolTip>
<EuiToolTip content={rightTermCountMsg}>
<small className="gphLinkSummary__term--2">{mc.v2}</small>
</EuiToolTip>
</div>
);
})}
</div>
);
};

View file

@ -0,0 +1,45 @@
/*
* 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 { Workspace } from '../../types';
interface SelectStyleProps {
workspace: Workspace;
colors: string[];
}
export const SelectStyle = ({ colors, workspace }: SelectStyleProps) => {
return (
<div className="gphSidebar__panel">
<div className="gphSidebar__header">
<span className="kuiIcon fa-paint-brush" />
{i18n.translate('xpack.graph.sidebar.styleVerticesTitle', {
defaultMessage: 'Style selected vertices',
})}
</div>
<div className="form-group form-group-sm gphFormGroup--small">
{colors.map((c) => {
const onSelectColor = () => {
workspace.colorSelected(c);
workspace.changeHandler();
};
return (
<span
aria-hidden="true"
onClick={onSelectColor}
style={{ color: c }}
className="kuiIcon gphColorPicker__color fa-circle"
/>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,100 @@
/*
* 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 { EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Workspace, WorkspaceNode } from '../../types';
interface SelectedNodeEditorProps {
workspace: Workspace;
selectedNode: WorkspaceNode;
}
export const SelectedNodeEditor = ({ workspace, selectedNode }: SelectedNodeEditorProps) => {
const groupButtonMsg = i18n.translate('xpack.graph.sidebar.groupButtonTooltip', {
defaultMessage: 'group the currently selected items into {latestSelectionLabel}',
values: { latestSelectionLabel: selectedNode.label },
});
const ungroupButtonMsg = i18n.translate('xpack.graph.sidebar.ungroupButtonTooltip', {
defaultMessage: 'ungroup {latestSelectionLabel}',
values: { latestSelectionLabel: selectedNode.label },
});
const onGroupButtonClick = () => {
workspace.groupSelections(selectedNode);
};
const onClickUngroup = () => {
workspace.ungroup(selectedNode);
};
const onChangeSelectedVertexLabel = (event: React.ChangeEvent<HTMLInputElement>) => {
selectedNode.label = event.target.value;
workspace.changeHandler();
};
return (
<div className="gphSidebar__panel">
<div className="gphSidebar__header">
{selectedNode.icon && <span className={`kuiIcon ${selectedNode.icon.class}`} />}
{selectedNode.data.field} {selectedNode.data.term}
</div>
{(workspace.selectedNodes.length > 1 ||
(workspace.selectedNodes.length > 0 && workspace.selectedNodes[0] !== selectedNode)) && (
<EuiToolTip content={groupButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--iconText kuiButton--small"
onClick={onGroupButtonClick}
>
<span className="kuiButton__icon kuiIcon fa-object-group" />
<FormattedMessage id="xpack.graph.sidebar.groupButtonLabel" defaultMessage="group" />
</button>
</EuiToolTip>
)}
{selectedNode.numChildren > 0 && (
<EuiToolTip content={ungroupButtonMsg}>
<button
className="kuiButton kuiButton--basic kuiButton--iconText kuiButton--small"
onClick={onClickUngroup}
>
<span className="kuiIcon fa-object-ungroup" />
<FormattedMessage
id="xpack.graph.sidebar.ungroupButtonLabel"
defaultMessage="ungroup"
/>
</button>
</EuiToolTip>
)}
<form className="form-horizontal">
<div className="form-group form-group-sm gphFormGroup--small">
<label htmlFor="labelEdit" className="col-sm-3 control-label">
{i18n.translate('xpack.graph.sidebar.displayLabelLabel', {
defaultMessage: 'Display label',
})}
</label>
<div className="col-sm-9">
<input
ref={(element) => element && (element.value = selectedNode.label)}
type="text"
id="labelEdit"
className="form-control input-sm"
onChange={onChangeSelectedVertexLabel}
/>
<div className="help-block">
{i18n.translate('xpack.graph.sidebar.displayLabelHelpText', {
defaultMessage: 'Change the label for this vertex.',
})}
</div>
</div>
</div>
</form>
</div>
);
};

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 { hexToRgb, isColorDark } from '@elastic/eui';
import classNames from 'classnames';
import React from 'react';
import { WorkspaceNode } from '../../types';
const isHexColorDark = (color: string) => isColorDark(...hexToRgb(color));
interface SelectedNodeItemProps {
node: WorkspaceNode;
isHighlighted: boolean;
onDeselectNode: (node: WorkspaceNode) => void;
onSelectedFieldClick: (node: WorkspaceNode) => void;
}
export const SelectedNodeItem = ({
node,
isHighlighted,
onSelectedFieldClick,
onDeselectNode,
}: SelectedNodeItemProps) => {
const fieldClasses = classNames('gphSelectionList__field', {
['gphSelectionList__field--selected']: isHighlighted,
});
const fieldIconClasses = classNames('fa', 'gphNode__text', 'gphSelectionList__icon', {
['gphNode__text--inverse']: isHexColorDark(node.color),
});
return (
<div aria-hidden="true" className={fieldClasses} onClick={() => onSelectedFieldClick(node)}>
<svg width="24" height="24">
<circle
className="gphNode__circle"
r="10"
cx="12"
cy="12"
style={{ fill: node.color }}
onClick={() => onDeselectNode(node)}
/>
{node.icon && (
<text
className={fieldIconClasses}
textAnchor="middle"
x="12"
y="16"
onClick={() => onDeselectNode(node)}
>
{node.icon.code}
</text>
)}
</svg>
<span>{node.label}</span>
{node.numChildren > 0 && <span> (+{node.numChildren})</span>}
</div>
);
};

View file

@ -0,0 +1,136 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { ControlType, Workspace } from '../../types';
interface SelectionToolBarProps {
workspace: Workspace;
onSetControl: (data: ControlType) => void;
}
export const SelectionToolBar = ({ workspace, onSetControl }: SelectionToolBarProps) => {
const haveNodes = workspace.nodes.length === 0;
const selectAllButtonMsg = i18n.translate(
'xpack.graph.sidebar.selections.selectAllButtonTooltip',
{
defaultMessage: 'Select all',
}
);
const selectNoneButtonMsg = i18n.translate(
'xpack.graph.sidebar.selections.selectNoneButtonTooltip',
{
defaultMessage: 'Select none',
}
);
const invertSelectionButtonMsg = i18n.translate(
'xpack.graph.sidebar.selections.invertSelectionButtonTooltip',
{
defaultMessage: 'Invert selection',
}
);
const selectNeighboursButtonMsg = i18n.translate(
'xpack.graph.sidebar.selections.selectNeighboursButtonTooltip',
{
defaultMessage: 'Select neighbours',
}
);
const onSelectAllClick = () => {
onSetControl('none');
workspace.selectAll();
workspace.changeHandler();
};
const onSelectNoneClick = () => {
onSetControl('none');
workspace.selectNone();
workspace.changeHandler();
};
const onInvertSelectionClick = () => {
onSetControl('none');
workspace.selectInvert();
workspace.changeHandler();
};
const onSelectNeighboursClick = () => {
onSetControl('none');
workspace.selectNeighbours();
workspace.changeHandler();
};
return (
<EuiFlexGroup
className="vertexSelectionTypesBar"
justifyContent="flexStart"
gutterSize="s"
alignItems="center"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiToolTip content={selectAllButtonMsg}>
<button
data-test-subj="graphSelectAll"
type="button"
className="kuiButton kuiButton--basic kuiButton--small"
disabled={haveNodes}
onClick={onSelectAllClick}
>
{i18n.translate('xpack.graph.sidebar.selections.selectAllButtonLabel', {
defaultMessage: 'all',
})}
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={selectNoneButtonMsg}>
<button
type="button"
className="kuiButton kuiButton--basic kuiButton--small"
disabled={haveNodes}
onClick={onSelectNoneClick}
>
{i18n.translate('xpack.graph.sidebar.selections.selectNoneButtonLabel', {
defaultMessage: 'none',
})}
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={invertSelectionButtonMsg}>
<button
data-test-subj="graphInvertSelection"
type="button"
className="kuiButton kuiButton--basic kuiButton--small"
disabled={haveNodes}
onClick={onInvertSelectionClick}
>
{i18n.translate('xpack.graph.sidebar.selections.invertSelectionButtonLabel', {
defaultMessage: 'invert',
})}
</button>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={selectNeighboursButtonMsg}>
<button
type="button"
className="kuiButton kuiButton--basic kuiButton--small"
disabled={workspace.selectedNodes.length === 0}
onClick={onSelectNeighboursClick}
data-test-subj="graphLinkedSelection"
>
{i18n.translate('xpack.graph.sidebar.selections.selectNeighboursButtonLabel', {
defaultMessage: 'linked',
})}
</button>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -1,3 +1,11 @@
@mixin gphSvgText() {
font-family: $euiFontFamily;
font-size: $euiSizeS;
line-height: $euiSizeM;
fill: $euiColorDarkShade;
color: $euiColorDarkShade;
}
.gphVisualization {
flex: 1;
display: flex;

View file

@ -7,15 +7,13 @@
import React from 'react';
import { shallow } from 'enzyme';
import {
GraphVisualization,
GroupAwareWorkspaceNode,
GroupAwareWorkspaceEdge,
} from './graph_visualization';
import { GraphVisualization } from './graph_visualization';
import { Workspace, WorkspaceEdge, WorkspaceNode } from '../../types';
describe('graph_visualization', () => {
const nodes: GroupAwareWorkspaceNode[] = [
const nodes: WorkspaceNode[] = [
{
id: '1',
color: 'black',
data: {
field: 'A',
@ -37,6 +35,7 @@ describe('graph_visualization', () => {
y: 5,
},
{
id: '2',
color: 'red',
data: {
field: 'B',
@ -58,6 +57,7 @@ describe('graph_visualization', () => {
y: 9,
},
{
id: '3',
color: 'yellow',
data: {
field: 'C',
@ -79,7 +79,7 @@ describe('graph_visualization', () => {
y: 9,
},
];
const edges: GroupAwareWorkspaceEdge[] = [
const edges: WorkspaceEdge[] = [
{
isSelected: true,
label: '',
@ -101,9 +101,32 @@ describe('graph_visualization', () => {
width: 2.2,
},
];
const workspace = ({
nodes,
edges,
selectNone: () => {},
changeHandler: jest.fn(),
toggleNodeSelection: jest.fn().mockImplementation((node: WorkspaceNode) => {
return !node.isSelected;
}),
getAllIntersections: jest.fn(),
} as unknown) as jest.Mocked<Workspace>;
beforeEach(() => {
jest.clearAllMocks();
});
it('should render empty workspace without data', () => {
expect(shallow(<GraphVisualization edgeClick={() => {}} nodeClick={() => {}} />))
.toMatchInlineSnapshot(`
expect(
shallow(
<GraphVisualization
workspace={({} as unknown) as Workspace}
selectSelected={() => {}}
onSetControl={() => {}}
onSetMergeCandidates={() => {}}
/>
)
).toMatchInlineSnapshot(`
<svg
className="gphGraph"
height="100%"
@ -122,36 +145,67 @@ describe('graph_visualization', () => {
it('should render to svg elements', () => {
expect(
shallow(
<GraphVisualization edgeClick={() => {}} nodeClick={() => {}} nodes={nodes} edges={edges} />
<GraphVisualization
workspace={workspace}
selectSelected={() => {}}
onSetControl={() => {}}
onSetMergeCandidates={() => {}}
/>
)
).toMatchSnapshot();
});
it('should react to node click', () => {
const nodeClickSpy = jest.fn();
it('should react to node selection', () => {
const selectSelectedMock = jest.fn();
const instance = shallow(
<GraphVisualization
edgeClick={() => {}}
nodeClick={nodeClickSpy}
nodes={nodes}
edges={edges}
workspace={workspace}
selectSelected={selectSelectedMock}
onSetControl={() => {}}
onSetMergeCandidates={() => {}}
/>
);
instance.find('.gphNode').last().simulate('click', {});
expect(workspace.toggleNodeSelection).toHaveBeenCalledWith(nodes[2]);
expect(selectSelectedMock).toHaveBeenCalledWith(nodes[2]);
expect(workspace.changeHandler).toHaveBeenCalled();
});
it('should react to node deselection', () => {
const onSetControlMock = jest.fn();
const instance = shallow(
<GraphVisualization
workspace={workspace}
selectSelected={() => {}}
onSetControl={onSetControlMock}
onSetMergeCandidates={() => {}}
/>
);
instance.find('.gphNode').first().simulate('click', {});
expect(nodeClickSpy).toHaveBeenCalledWith(nodes[0], {});
expect(workspace.toggleNodeSelection).toHaveBeenCalledWith(nodes[0]);
expect(onSetControlMock).toHaveBeenCalledWith('none');
expect(workspace.changeHandler).toHaveBeenCalled();
});
it('should react to edge click', () => {
const edgeClickSpy = jest.fn();
const instance = shallow(
<GraphVisualization
edgeClick={edgeClickSpy}
nodeClick={() => {}}
nodes={nodes}
edges={edges}
workspace={workspace}
selectSelected={() => {}}
onSetControl={() => {}}
onSetMergeCandidates={() => {}}
/>
);
instance.find('.gphEdge').first().simulate('click');
expect(edgeClickSpy).toHaveBeenCalledWith(edges[0]);
expect(workspace.getAllIntersections).toHaveBeenCalled();
expect(edges[0].topSrc).toEqual(workspace.getAllIntersections.mock.calls[0][1][0]);
expect(edges[0].topTarget).toEqual(workspace.getAllIntersections.mock.calls[0][1][1]);
});
});

View file

@ -9,31 +9,14 @@ import React, { useRef } from 'react';
import classNames from 'classnames';
import d3, { ZoomEvent } from 'd3';
import { isColorDark, hexToRgb } from '@elastic/eui';
import { WorkspaceNode, WorkspaceEdge } from '../../types';
import { Workspace, WorkspaceNode, TermIntersect, ControlType, WorkspaceEdge } from '../../types';
import { makeNodeId } from '../../services/persistence';
/*
* The layouting algorithm sets a few extra properties on
* node objects to handle grouping. This will be moved to
* a separate data structure when the layouting is migrated
*/
export interface GroupAwareWorkspaceNode extends WorkspaceNode {
kx: number;
ky: number;
numChildren: number;
}
export interface GroupAwareWorkspaceEdge extends WorkspaceEdge {
topTarget: GroupAwareWorkspaceNode;
topSrc: GroupAwareWorkspaceNode;
}
export interface GraphVisualizationProps {
nodes?: GroupAwareWorkspaceNode[];
edges?: GroupAwareWorkspaceEdge[];
edgeClick: (edge: GroupAwareWorkspaceEdge) => void;
nodeClick: (node: GroupAwareWorkspaceNode, e: React.MouseEvent<Element, MouseEvent>) => void;
workspace: Workspace;
onSetControl: (control: ControlType) => void;
selectSelected: (node: WorkspaceNode) => void;
onSetMergeCandidates: (terms: TermIntersect[]) => void;
}
function registerZooming(element: SVGSVGElement) {
@ -55,13 +38,39 @@ function registerZooming(element: SVGSVGElement) {
}
export function GraphVisualization({
nodes,
edges,
edgeClick,
nodeClick,
workspace,
selectSelected,
onSetControl,
onSetMergeCandidates,
}: GraphVisualizationProps) {
const svgRoot = useRef<SVGSVGElement | null>(null);
const nodeClick = (n: WorkspaceNode, event: React.MouseEvent) => {
// Selection logic - shift key+click helps selects multiple nodes
// Without the shift key we deselect all prior selections (perhaps not
// a great idea for touch devices with no concept of shift key)
if (!event.shiftKey) {
const prevSelection = n.isSelected;
workspace.selectNone();
n.isSelected = prevSelection;
}
if (workspace.toggleNodeSelection(n)) {
selectSelected(n);
} else {
onSetControl('none');
}
workspace.changeHandler();
};
const handleMergeCandidatesCallback = (termIntersects: TermIntersect[]) => {
const mergeCandidates: TermIntersect[] = [...termIntersects];
onSetMergeCandidates(mergeCandidates);
onSetControl('mergeTerms');
};
const edgeClick = (edge: WorkspaceEdge) =>
workspace.getAllIntersections(handleMergeCandidatesCallback, [edge.topSrc, edge.topTarget]);
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -79,8 +88,8 @@ export function GraphVisualization({
>
<g>
<g>
{edges &&
edges.map((edge) => (
{workspace.edges &&
workspace.edges.map((edge) => (
<line
key={`${makeNodeId(edge.source.data.field, edge.source.data.term)}-${makeNodeId(
edge.target.data.field,
@ -101,8 +110,8 @@ export function GraphVisualization({
/>
))}
</g>
{nodes &&
nodes
{workspace.nodes &&
workspace.nodes
.filter((node) => !node.parent)
.map((node) => (
<g

View file

@ -0,0 +1,99 @@
/*
* 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, { useMemo, useState } from 'react';
import { EuiTab, EuiTabs, EuiText } from '@elastic/eui';
import { monaco, XJsonLang } from '@kbn/monaco';
import { FormattedMessage } from '@kbn/i18n/react';
import { IndexPattern } from '../../../../../src/plugins/data/public';
import { CodeEditor } from '../../../../../src/plugins/kibana_react/public';
interface InspectPanelProps {
showInspect: boolean;
indexPattern?: IndexPattern;
lastRequest?: string;
lastResponse?: string;
}
const CODE_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
automaticLayout: true,
fontSize: 12,
lineNumbers: 'on',
minimap: {
enabled: false,
},
overviewRulerBorder: false,
readOnly: true,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
};
const dummyCallback = () => {};
export const InspectPanel = ({
showInspect,
lastRequest,
lastResponse,
indexPattern,
}: InspectPanelProps) => {
const [selectedTabId, setSelectedTabId] = useState('request');
const onRequestClick = () => setSelectedTabId('request');
const onResponseClick = () => setSelectedTabId('response');
const editorContent = useMemo(() => (selectedTabId === 'request' ? lastRequest : lastResponse), [
lastRequest,
lastResponse,
selectedTabId,
]);
if (showInspect) {
return (
<div className="gphGraph__menus">
<div>
<div className="kuiLocalDropdownTitle">
<FormattedMessage id="xpack.graph.inspect.title" defaultMessage="Inspect" />
</div>
<div className="list-group-item">
<EuiText size="xs" className="help-block">
<span>http://host:port/{indexPattern?.id}/_graph/explore</span>
</EuiText>
<EuiTabs>
<EuiTab onClick={onRequestClick} isSelected={'request' === selectedTabId}>
<FormattedMessage
id="xpack.graph.inspect.requestTabTitle"
defaultMessage="Request"
/>
</EuiTab>
<EuiTab onClick={onResponseClick} isSelected={'response' === selectedTabId}>
<FormattedMessage
id="xpack.graph.inspect.responseTabTitle"
defaultMessage="Response"
/>
</EuiTab>
</EuiTabs>
<CodeEditor
languageId={XJsonLang.ID}
height={240}
value={editorContent || ''}
onChange={dummyCallback}
editorDidMount={dummyCallback}
options={CODE_EDITOR_OPTIONS}
/>
</div>
</div>
</div>
);
}
return null;
};

View file

@ -1,109 +0,0 @@
/*
* 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, { useMemo, useState } from 'react';
import { EuiTab, EuiTabs, EuiText } from '@elastic/eui';
import { monaco, XJsonLang } from '@kbn/monaco';
import { FormattedMessage } from '@kbn/i18n/react';
import { IUiSettingsClient } from 'kibana/public';
import { IndexPattern } from '../../../../../../src/plugins/data/public';
import {
CodeEditor,
KibanaContextProvider,
} from '../../../../../../src/plugins/kibana_react/public';
interface InspectPanelProps {
showInspect?: boolean;
indexPattern?: IndexPattern;
uiSettings: IUiSettingsClient;
lastRequest?: string;
lastResponse?: string;
}
const CODE_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
automaticLayout: true,
fontSize: 12,
lineNumbers: 'on',
minimap: {
enabled: false,
},
overviewRulerBorder: false,
readOnly: true,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
};
const dummyCallback = () => {};
export const InspectPanel = ({
showInspect,
lastRequest,
lastResponse,
indexPattern,
uiSettings,
}: InspectPanelProps) => {
const [selectedTabId, setSelectedTabId] = useState('request');
const onRequestClick = () => setSelectedTabId('request');
const onResponseClick = () => setSelectedTabId('response');
const services = useMemo(() => ({ uiSettings }), [uiSettings]);
const editorContent = useMemo(() => (selectedTabId === 'request' ? lastRequest : lastResponse), [
selectedTabId,
lastRequest,
lastResponse,
]);
if (showInspect) {
return (
<KibanaContextProvider services={services}>
<div className="gphGraph__menus">
<div>
<div className="kuiLocalDropdownTitle">
<FormattedMessage id="xpack.graph.inspect.title" defaultMessage="Inspect" />
</div>
<div className="list-group-item">
<EuiText size="xs" className="help-block">
<span>http://host:port/{indexPattern?.id}/_graph/explore</span>
</EuiText>
<EuiTabs>
<EuiTab onClick={onRequestClick} isSelected={'request' === selectedTabId}>
<FormattedMessage
id="xpack.graph.inspect.requestTabTitle"
defaultMessage="Request"
/>
</EuiTab>
<EuiTab onClick={onResponseClick} isSelected={'response' === selectedTabId}>
<FormattedMessage
id="xpack.graph.inspect.responseTabTitle"
defaultMessage="Response"
/>
</EuiTab>
</EuiTabs>
<CodeEditor
languageId={XJsonLang.ID}
height={240}
value={editorContent || ''}
onChange={dummyCallback}
editorDidMount={dummyCallback}
options={CODE_EDITOR_OPTIONS}
/>
</div>
</div>
</div>
</KibanaContextProvider>
);
}
return null;
};

View file

@ -6,18 +6,18 @@
*/
import { mountWithIntl } from '@kbn/test/jest';
import { SearchBar, OuterSearchBarProps } from './search_bar';
import React, { ReactElement } from 'react';
import { SearchBar, SearchBarProps } from './search_bar';
import React, { Component, ReactElement } from 'react';
import { CoreStart } from 'src/core/public';
import { act } from 'react-dom/test-utils';
import { IndexPattern, QueryStringInput } from '../../../../../src/plugins/data/public';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { I18nProvider } from '@kbn/i18n/react';
import { I18nProvider, InjectedIntl } from '@kbn/i18n/react';
import { openSourceModal } from '../services/source_modal';
import { GraphStore, setDatasource } from '../state_management';
import { GraphStore, setDatasource, submitSearchSaga } from '../state_management';
import { ReactWrapper } from 'enzyme';
import { createMockGraphStore } from '../state_management/mocks';
import { Provider } from 'react-redux';
@ -26,7 +26,7 @@ jest.mock('../services/source_modal', () => ({ openSourceModal: jest.fn() }));
const waitForIndexPatternFetch = () => new Promise((r) => setTimeout(r));
function wrapSearchBarInContext(testProps: OuterSearchBarProps) {
function wrapSearchBarInContext(testProps: SearchBarProps) {
const services = {
uiSettings: {
get: (key: string) => {
@ -67,21 +67,34 @@ function wrapSearchBarInContext(testProps: OuterSearchBarProps) {
}
describe('search_bar', () => {
let dispatchSpy: jest.Mock;
let instance: ReactWrapper<
SearchBarProps & { intl: InjectedIntl },
Readonly<{}>,
Component<{}, {}, any>
>;
let store: GraphStore;
const defaultProps = {
isLoading: false,
onQuerySubmit: jest.fn(),
indexPatternProvider: {
get: jest.fn(() => Promise.resolve(({ fields: [] } as unknown) as IndexPattern)),
},
confirmWipeWorkspace: (callback: () => void) => {
callback();
},
onIndexPatternChange: (indexPattern?: IndexPattern) => {
instance.setProps({
...defaultProps,
currentIndexPattern: indexPattern,
});
},
};
let instance: ReactWrapper;
let store: GraphStore;
beforeEach(() => {
store = createMockGraphStore({}).store;
store = createMockGraphStore({
sagas: [submitSearchSaga],
}).store;
store.dispatch(
setDatasource({
type: 'indexpattern',
@ -89,14 +102,21 @@ describe('search_bar', () => {
title: 'test-index',
})
);
dispatchSpy = jest.fn(store.dispatch);
store.dispatch = dispatchSpy;
});
async function mountSearchBar() {
jest.clearAllMocks();
const wrappedSearchBar = wrapSearchBarInContext({ ...defaultProps });
const searchBarTestRoot = React.createElement((updatedProps: SearchBarProps) => (
<Provider store={store}>
{wrapSearchBarInContext({ ...defaultProps, ...updatedProps })}
</Provider>
));
await act(async () => {
instance = mountWithIntl(<Provider store={store}>{wrappedSearchBar}</Provider>);
instance = mountWithIntl(searchBarTestRoot);
});
}
@ -119,7 +139,10 @@ describe('search_bar', () => {
instance.find('form').simulate('submit', { preventDefault: () => {} });
});
expect(defaultProps.onQuerySubmit).toHaveBeenCalledWith('testQuery');
expect(dispatchSpy).toHaveBeenCalledWith({
type: 'x-pack/graph/workspace/SUBMIT_SEARCH',
payload: 'testQuery',
});
});
it('should translate kql query into JSON dsl', async () => {
@ -135,7 +158,7 @@ describe('search_bar', () => {
instance.find('form').simulate('submit', { preventDefault: () => {} });
});
const parsedQuery = JSON.parse(defaultProps.onQuerySubmit.mock.calls[0][0]);
const parsedQuery = JSON.parse(dispatchSpy.mock.calls[0][0].payload);
expect(parsedQuery).toEqual({
bool: { should: [{ match: { test: 'abc' } }], minimum_should_match: 1 },
});

View file

@ -17,6 +17,7 @@ import {
datasourceSelector,
requestDatasource,
IndexpatternDatasource,
submitSearch,
} from '../state_management';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
@ -28,11 +29,11 @@ import {
esKuery,
} from '../../../../../src/plugins/data/public';
export interface OuterSearchBarProps {
export interface SearchBarProps {
isLoading: boolean;
initialQuery?: string;
onQuerySubmit: (query: string) => void;
urlQuery: string | null;
currentIndexPattern?: IndexPattern;
onIndexPatternChange: (indexPattern?: IndexPattern) => void;
confirmWipeWorkspace: (
onConfirm: () => void,
text?: string,
@ -41,9 +42,10 @@ export interface OuterSearchBarProps {
indexPatternProvider: IndexPatternProvider;
}
export interface SearchBarProps extends OuterSearchBarProps {
export interface SearchBarStateProps {
currentDatasource?: IndexpatternDatasource;
onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void;
submit: (searchTerm: string) => void;
}
function queryToString(query: Query, indexPattern: IndexPattern) {
@ -65,31 +67,34 @@ function queryToString(query: Query, indexPattern: IndexPattern) {
return JSON.stringify(query.query);
}
export function SearchBarComponent(props: SearchBarProps) {
export function SearchBarComponent(props: SearchBarStateProps & SearchBarProps) {
const {
currentDatasource,
onQuerySubmit,
isLoading,
onIndexPatternSelected,
initialQuery,
urlQuery,
currentIndexPattern,
currentDatasource,
indexPatternProvider,
submit,
onIndexPatternSelected,
confirmWipeWorkspace,
onIndexPatternChange,
} = props;
const [query, setQuery] = useState<Query>({ language: 'kuery', query: initialQuery || '' });
const [currentIndexPattern, setCurrentIndexPattern] = useState<IndexPattern | undefined>(
undefined
);
const [query, setQuery] = useState<Query>({ language: 'kuery', query: urlQuery || '' });
useEffect(() => setQuery((prev) => ({ language: prev.language, query: urlQuery || '' })), [
urlQuery,
]);
useEffect(() => {
async function fetchPattern() {
if (currentDatasource) {
setCurrentIndexPattern(await indexPatternProvider.get(currentDatasource.id));
onIndexPatternChange(await indexPatternProvider.get(currentDatasource.id));
} else {
setCurrentIndexPattern(undefined);
onIndexPatternChange(undefined);
}
}
fetchPattern();
}, [currentDatasource, indexPatternProvider]);
}, [currentDatasource, indexPatternProvider, onIndexPatternChange]);
const kibana = useKibana<IDataPluginServices>();
const { services, overlays } = kibana;
@ -101,7 +106,7 @@ export function SearchBarComponent(props: SearchBarProps) {
onSubmit={(e) => {
e.preventDefault();
if (!isLoading && currentIndexPattern) {
onQuerySubmit(queryToString(query, currentIndexPattern));
submit(queryToString(query, currentIndexPattern));
}
}}
>
@ -196,5 +201,8 @@ export const SearchBar = connect(
})
);
},
submit: (searchTerm: string) => {
dispatch(submitSearch(searchTerm));
},
})
)(SearchBarComponent);

View file

@ -8,8 +8,8 @@
import React, { useState, useEffect } from 'react';
import { EuiFormRow, EuiFieldNumber, EuiComboBox, EuiSwitch, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SettingsProps } from './settings';
import { AdvancedSettings } from '../../types';
import { SettingsStateProps } from './settings';
// Helper type to get all keys of an interface
// that are of type number.
@ -26,9 +26,10 @@ export function AdvancedSettingsForm({
advancedSettings,
updateSettings,
allFields,
}: Pick<SettingsProps, 'advancedSettings' | 'updateSettings' | 'allFields'>) {
}: Pick<SettingsStateProps, 'advancedSettings' | 'updateSettings' | 'allFields'>) {
// keep a local state during changes
const [formState, updateFormState] = useState({ ...advancedSettings });
// useEffect update localState only based on the main store
useEffect(() => {
updateFormState(advancedSettings);

View file

@ -17,14 +17,15 @@ import {
EuiCallOut,
} from '@elastic/eui';
import { SettingsProps } from './settings';
import { SettingsWorkspaceProps } from './settings';
import { LegacyIcon } from '../legacy_icon';
import { useListKeys } from './use_list_keys';
export function BlocklistForm({
blocklistedNodes,
unblocklistNode,
}: Pick<SettingsProps, 'blocklistedNodes' | 'unblocklistNode'>) {
unblockNode,
unblockAll,
}: Pick<SettingsWorkspaceProps, 'blocklistedNodes' | 'unblockNode' | 'unblockAll'>) {
const getListKey = useListKeys(blocklistedNodes || []);
return (
<>
@ -46,7 +47,7 @@ export function BlocklistForm({
/>
)}
<EuiSpacer />
{blocklistedNodes && unblocklistNode && blocklistedNodes.length > 0 && (
{blocklistedNodes && blocklistedNodes.length > 0 && (
<>
<EuiListGroup bordered maxWidth={false}>
{blocklistedNodes.map((node) => (
@ -63,9 +64,7 @@ export function BlocklistForm({
defaultMessage: 'Delete',
}),
color: 'danger',
onClick: () => {
unblocklistNode(node);
},
onClick: () => unblockNode(node),
}}
/>
))}
@ -77,11 +76,7 @@ export function BlocklistForm({
iconType="trash"
size="s"
fill
onClick={() => {
blocklistedNodes.forEach((node) => {
unblocklistNode(node);
});
}}
onClick={() => unblockAll()}
>
{i18n.translate('xpack.graph.settings.blocklist.clearButtonLabel', {
defaultMessage: 'Delete all',

View file

@ -9,7 +9,7 @@ import React from 'react';
import { EuiTab, EuiListGroupItem, EuiButton, EuiAccordion, EuiFieldText } from '@elastic/eui';
import * as Rx from 'rxjs';
import { mountWithIntl } from '@kbn/test/jest';
import { Settings, AngularProps } from './settings';
import { Settings, SettingsWorkspaceProps } from './settings';
import { act } from '@testing-library/react';
import { ReactWrapper } from 'enzyme';
import { UrlTemplateForm } from './url_template_form';
@ -46,7 +46,7 @@ describe('settings', () => {
isDefault: false,
};
const angularProps: jest.Mocked<AngularProps> = {
const workspaceProps: jest.Mocked<SettingsWorkspaceProps> = {
blocklistedNodes: [
{
x: 0,
@ -83,11 +83,12 @@ describe('settings', () => {
},
},
],
unblocklistNode: jest.fn(),
unblockNode: jest.fn(),
unblockAll: jest.fn(),
canEditDrillDownUrls: true,
};
let subject: Rx.BehaviorSubject<jest.Mocked<AngularProps>>;
let subject: Rx.BehaviorSubject<jest.Mocked<SettingsWorkspaceProps>>;
let instance: ReactWrapper;
beforeEach(() => {
@ -137,7 +138,7 @@ describe('settings', () => {
);
dispatchSpy = jest.fn(store.dispatch);
store.dispatch = dispatchSpy;
subject = new Rx.BehaviorSubject(angularProps);
subject = new Rx.BehaviorSubject(workspaceProps);
instance = mountWithIntl(
<Provider store={store}>
<Settings observable={subject.asObservable()} />
@ -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();
});
});

View file

@ -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<P> {
export interface SettingsWorkspaceProps {
blocklistedNodes: BlockListedNode[];
unblockNode: (node: BlockListedNode) => void;
unblockAll: () => void;
canEditDrillDownUrls: boolean;
}
export interface AsObservable<P> {
observable: Readonly<Rx.Observable<P>>;
}
export interface SettingsProps extends AngularProps, StateProps, DispatchProps {}
export interface SettingsStateProps extends StateProps, DispatchProps {}
export function SettingsComponent({
observable,
...props
}: AsObservable<AngularProps> & StateProps & DispatchProps) {
const [angularProps, setAngularProps] = useState<AngularProps | undefined>(undefined);
advancedSettings,
urlTemplates,
allFields,
saveTemplate: saveTemplateAction,
updateSettings: updateSettingsAction,
removeTemplate: removeTemplateAction,
}: AsObservable<SettingsWorkspaceProps> & SettingsStateProps) {
const [workspaceProps, setWorkspaceProps] = useState<SettingsWorkspaceProps | undefined>(
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 (
<>
<EuiFlyoutHeader hasBorder>
@ -97,7 +104,7 @@ export function SettingsComponent({
</EuiTitle>
<EuiTabs style={{ margin: '0 -16px -25px' }}>
{tabs
.filter(({ id }) => id !== 'drillDowns' || angularProps.canEditDrillDownUrls)
.filter(({ id }) => id !== 'drillDowns' || workspaceProps.canEditDrillDownUrls)
.map(({ title }, index) => (
<EuiTab
key={title}
@ -112,13 +119,28 @@ export function SettingsComponent({
</EuiTabs>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<ActiveTabContent {...angularProps} {...props} />
<ActiveTabContent
blocklistedNodes={workspaceProps.blocklistedNodes}
unblockNode={workspaceProps.unblockNode}
unblockAll={workspaceProps.unblockAll}
advancedSettings={advancedSettings}
urlTemplates={urlTemplates}
allFields={allFields}
updateSettings={updateSettingsAction}
removeTemplate={removeTemplateAction}
saveTemplate={saveTemplateAction}
/>
</EuiFlyoutBody>
</>
);
}
export const Settings = connect<StateProps, DispatchProps, AsObservable<AngularProps>, GraphState>(
export const Settings = connect<
StateProps,
DispatchProps,
AsObservable<SettingsWorkspaceProps>,
GraphState
>(
(state: GraphState) => ({
advancedSettings: settingsSelector(state),
urlTemplates: templatesSelector(state),

View file

@ -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<SettingsProps, 'removeTemplate' | 'saveTemplate' | 'urlTemplates'>) {
}: Pick<SettingsStateProps, 'removeTemplate' | 'saveTemplate' | 'urlTemplates'>) {
const [uncommittedForms, setUncommittedForms] = useState<string[]>([]);
const getListKey = useListKeys(urlTemplates);

View file

@ -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';

View file

@ -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<IndexPattern>();
const [showInspect, setShowInspect] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [mergeCandidates, setMergeCandidates] = useState<TermIntersect[]>([]);
const [control, setControl] = useState<ControlType>('none');
const selectedNode = useRef<WorkspaceNode | undefined>(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 (
<Fragment>
<WorkspaceTopNavMenu
workspace={workspace}
savedWorkspace={savedWorkspace}
graphSavePolicy={graphSavePolicy}
navigation={navigation}
capabilities={capabilities}
coreStart={coreStart}
canEditDrillDownUrls={canEditDrillDownUrls}
setShowInspect={setShowInspect}
confirmWipeWorkspace={confirmWipeWorkspace}
setHeaderActionMenu={setHeaderActionMenu}
isInitialized={isInitialized}
/>
<InspectPanel
showInspect={showInspect}
lastRequest={workspace?.lastRequest}
lastResponse={workspace?.lastResponse}
indexPattern={currentIndexPattern}
/>
{isInitialized && <GraphTitle />}
<div className="gphGraph__bar">
<SearchBar
isLoading={loading}
urlQuery={urlQuery}
currentIndexPattern={currentIndexPattern}
indexPatternProvider={indexPatternProvider}
confirmWipeWorkspace={confirmWipeWorkspace}
onIndexPatternChange={onIndexPatternChange}
/>
<EuiSpacer size="s" />
<FieldManagerMemoized pickerOpen={pickerOpen} setPickerOpen={setPickerOpen} />
</div>
{!isInitialized && (
<div>
<GuidancePanelMemoized
noIndexPatterns={indexPatterns.length === 0}
onOpenFieldPicker={onOpenFieldPicker}
/>
</div>
)}
{isInitialized && workspace && (
<div className="gphGraph__container" id="GraphSvgContainer">
<div className="gphVisualization">
<GraphVisualization
workspace={workspace}
selectSelected={selectSelected}
onSetControl={onSetControl}
onSetMergeCandidates={onSetMergeCandidates}
/>
</div>
<ControlPanel
renderCounter={renderCounter}
workspace={workspace}
control={control}
selectedNode={selectedNode.current}
colors={colorChoices}
mergeCandidates={mergeCandidates}
selectSelected={selectSelected}
onSetControl={onSetControl}
/>
</div>
)}
</Fragment>
);
};
export const WorkspaceLayout = connect<WorkspaceLayoutStateProps, {}, {}, GraphState>(
(state: GraphState) => ({
workspaceInitialized: workspaceInitializedSelector(state),
hasFields: hasFieldsSelector(state),
})
)(WorkspaceLayoutComponent);

View file

@ -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<React.SetStateAction<boolean>>;
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<SettingsWorkspaceProps>['observable'];
props.coreStart.overlays.openFlyout(
toMountPoint(
<Provider store={store}>
<Settings observable={settingsObservable} />
</Provider>
),
{
size: 'm',
closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', {
defaultMessage: 'Close',
}),
'data-test-subj': 'graphSettingsFlyout',
ownFocus: true,
className: 'gphSettingsFlyout',
maxWidth: 520,
}
);
},
});
return (
<TopNavMenu
appName="workspacesTopNav"
config={topNavMenu}
setMenuMountPoint={props.setHeaderActionMenu}
/>
);
};

View file

@ -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());
};
}

View file

@ -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<string, unknown> }, url: string) {
function mapHits(hit: any, url: string): GraphWorkspaceSavedObject {
const source = hit.attributes;
source.id = hit.id;
source.url = url;

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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,
};
};

View file

@ -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<Workspace | undefined>;
savedObjectsClient: SavedObjectsClientContract;
toastNotifications: ToastsStart;
}
interface WorkspaceUrlParams {
id?: string;
}
export const useWorkspaceLoader = ({
workspaceRef,
store,
savedObjectsClient,
toastNotifications,
}: UseWorkspaceLoaderProps) => {
const [indexPatterns, setIndexPatterns] = useState<IndexPatternSavedObject[]>();
const [savedWorkspace, setSavedWorkspace] = useState<GraphWorkspaceSavedObject>();
const history = useHistory();
const location = useLocation();
const { id } = useParams<WorkspaceUrlParams>();
/**
* 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 };
};

View file

@ -10,5 +10,4 @@
@import './mixins';
@import './main';
@import './angular/templates/index';
@import './components/index';

View file

@ -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,

View file

@ -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 (
<Router history={history}>
<Switch>
<Route exact path="/home">
<ListingRoute deps={deps} />
</Route>
<Route path="/workspace/:id?">
<WorkspaceRoute deps={deps} />
</Route>
<Route>
<Redirect exact to="/home" />
</Route>
</Switch>
</Router>
);
};

View file

@ -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';

View file

@ -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', () => {

View file

@ -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,

View file

@ -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;
}) {

View file

@ -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 = [];

View file

@ -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<AdvancedSettingsState>): IterableIterator<void> {
const workspace = getWorkspace();
if (!workspace) {
return;
}
workspace.options.exploreControls = action.payload;
notifyAngular();
notifyReact();
}
return function* () {

View file

@ -30,7 +30,7 @@ export const datasourceSaga = ({
indexPatternProvider,
notifications,
createWorkspace,
notifyAngular,
notifyReact,
}: GraphStoreDependencies) => {
function* fetchFields(action: Action<IndexpatternDatasource>) {
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' }));

View file

@ -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<void> {
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<InferActionType<typeof updateFieldProperties>>) {
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;

View file

@ -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', () => {

View file

@ -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<GraphStoreDependencies> = {
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();

View file

@ -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();
});

View file

@ -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<GraphWorkspaceSavedObject>('LOAD_WORKSPACE');
export const saveWorkspace = actionCreator<void>('SAVE_WORKSPACE');
export const loadSavedWorkspace = actionCreator<LoadSavedWorkspacePayload>('LOAD_WORKSPACE');
export const saveWorkspace = actionCreator<GraphWorkspaceSavedObject>('SAVE_WORKSPACE');
export const fillWorkspace = actionCreator<void>('FILL_WORKSPACE');
/**
* Saga handling loading of a saved workspace.
@ -39,14 +46,12 @@ export const saveWorkspace = actionCreator<void>('SAVE_WORKSPACE');
*/
export const loadingSaga = ({
createWorkspace,
getWorkspace,
indexPatterns,
notifications,
indexPatternProvider,
}: GraphStoreDependencies) => {
function* deserializeWorkspace(action: Action<GraphWorkspaceSavedObject>) {
const workspacePayload = action.payload;
const migrationStatus = migrateLegacyIndexPatternRef(workspacePayload, indexPatterns);
function* deserializeWorkspace(action: Action<LoadSavedWorkspacePayload>) {
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<GraphWorkspaceSavedObject>) {
const savedWorkspace = action.payload;
const state: GraphState = yield select();
const workspace = deps.getWorkspace();
const selectedDatasource = datasourceSelector(state).current;

View file

@ -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<object>, deps: GraphStoreD
sagaMiddleware.run(syncBreadcrumbSaga(deps));
sagaMiddleware.run(syncTemplatesSaga(deps));
sagaMiddleware.run(fillWorkspaceSaga(deps));
sagaMiddleware.run(submitSearchSaga(deps));
}
export const createGraphStore = (deps: GraphStoreDependencies) => {

View file

@ -10,7 +10,7 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers/dist';
import { i18n } from '@kbn/i18n';
import { modifyUrl } from '@kbn/std';
import rison from 'rison-node';
import { takeEvery, select } from 'redux-saga/effects';
import { takeEvery } from 'redux-saga/effects';
import { format, parse } from 'url';
import { GraphState, GraphStoreDependencies } from './store';
import { UrlTemplate } from '../types';
@ -102,11 +102,9 @@ export const templatesSelector = (state: GraphState) => state.urlTemplates;
*
* Won't be necessary once the side bar is moved to redux
*/
export const syncTemplatesSaga = ({ setUrlTemplates, notifyAngular }: GraphStoreDependencies) => {
export const syncTemplatesSaga = ({ notifyReact }: GraphStoreDependencies) => {
function* syncTemplates() {
const templates = templatesSelector(yield select());
setUrlTemplates(templates);
notifyAngular();
notifyReact();
}
return function* () {

View file

@ -5,16 +5,41 @@
* 2.0.
*/
import actionCreatorFactory from 'typescript-fsa';
import actionCreatorFactory, { Action } from 'typescript-fsa';
import { i18n } from '@kbn/i18n';
import { takeLatest, select, call } from 'redux-saga/effects';
import { GraphStoreDependencies, GraphState } from '.';
import { takeLatest, select, call, put } from 'redux-saga/effects';
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { createSelector } from 'reselect';
import { GraphStoreDependencies, GraphState, fillWorkspace } from '.';
import { reset } from './global';
import { datasourceSelector } from './datasource';
import { selectedFieldsSelector } from './fields';
import { liveResponseFieldsSelector, selectedFieldsSelector } from './fields';
import { fetchTopNodes } from '../services/fetch_top_nodes';
const actionCreator = actionCreatorFactory('x-pack/graph');
import { Workspace } from '../types';
export const fillWorkspace = actionCreator<void>('FILL_WORKSPACE');
const actionCreator = actionCreatorFactory('x-pack/graph/workspace');
export interface WorkspaceState {
isInitialized: boolean;
}
const initialWorkspaceState: WorkspaceState = {
isInitialized: false,
};
export const initializeWorkspace = actionCreator('INITIALIZE_WORKSPACE');
export const submitSearch = actionCreator<string>('SUBMIT_SEARCH');
export const workspaceReducer = reducerWithInitialState(initialWorkspaceState)
.case(reset, () => ({ isInitialized: false }))
.case(initializeWorkspace, () => ({ isInitialized: true }))
.build();
export const workspaceSelector = (state: GraphState) => state.workspace;
export const workspaceInitializedSelector = createSelector(
workspaceSelector,
(workspace: WorkspaceState) => workspace.isInitialized
);
/**
* Saga handling filling in top terms into workspace.
@ -23,8 +48,7 @@ export const fillWorkspace = actionCreator<void>('FILL_WORKSPACE');
*/
export const fillWorkspaceSaga = ({
getWorkspace,
setWorkspaceInitialized,
notifyAngular,
notifyReact,
http,
notifications,
}: GraphStoreDependencies) => {
@ -47,8 +71,8 @@ export const fillWorkspaceSaga = ({
nodes: topTermNodes,
edges: [],
});
setWorkspaceInitialized();
notifyAngular();
yield put(initializeWorkspace());
notifyReact();
workspace.fillInGraph(fields.length * 10);
} catch (e) {
const message = 'body' in e ? e.body.message : e.message;
@ -65,3 +89,39 @@ export const fillWorkspaceSaga = ({
yield takeLatest(fillWorkspace.match, fetchNodes);
};
};
export const submitSearchSaga = ({
getWorkspace,
handleSearchQueryError,
}: GraphStoreDependencies) => {
function* submit(action: Action<string>) {
const searchTerm = action.payload;
yield put(initializeWorkspace());
// type casting is safe, at this point workspace should be loaded
const workspace = getWorkspace() as Workspace;
const numHops = 2;
const liveResponseFields = liveResponseFieldsSelector(yield select());
if (searchTerm.startsWith('{')) {
try {
const query = JSON.parse(searchTerm);
if (query.vertices) {
// Is a graph explore request
workspace.callElasticsearch(query);
} else {
// Is a regular query DSL query
workspace.search(query, liveResponseFields, numHops);
}
} catch (err) {
handleSearchQueryError(err);
}
return;
}
workspace.simpleSearch(searchTerm, liveResponseFields, numHops);
}
return function* () {
yield takeLatest(submitSearch.match, submit);
};
};

View file

@ -53,15 +53,15 @@ export interface SerializedField extends Omit<WorkspaceField, 'icon' | 'type' |
iconClass: string;
}
export interface SerializedNode
extends Omit<WorkspaceNode, 'icon' | 'data' | 'parent' | 'scaledSize'> {
export interface SerializedNode extends Pick<WorkspaceNode, 'x' | 'y' | 'label' | 'color'> {
field: string;
term: string;
parent: number | null;
size: number;
}
export interface SerializedEdge extends Omit<WorkspaceEdge, 'source' | 'target'> {
export interface SerializedEdge
extends Omit<WorkspaceEdge, 'source' | 'target' | 'topTarget' | 'topSrc'> {
source: number;
target: number;
}

View file

@ -6,10 +6,13 @@
*/
import { JsonObject } from '@kbn/utility-types';
import d3 from 'd3';
import { TargetOptions } from '../components/control_panel';
import { FontawesomeIcon } from '../helpers/style_choices';
import { WorkspaceField, AdvancedSettings } from './app_state';
export interface WorkspaceNode {
id: string;
x: number;
y: number;
label: string;
@ -21,9 +24,14 @@ export interface WorkspaceNode {
scaledSize: number;
parent: WorkspaceNode | null;
color: string;
numChildren: number;
isSelected?: boolean;
kx: number;
ky: number;
}
export type BlockListedNode = Omit<WorkspaceNode, 'numChildren' | 'kx' | 'ky' | 'id'>;
export interface WorkspaceEdge {
weight: number;
width: number;
@ -31,6 +39,8 @@ export interface WorkspaceEdge {
source: WorkspaceNode;
target: WorkspaceNode;
isSelected?: boolean;
topTarget: WorkspaceNode;
topSrc: WorkspaceNode;
}
export interface ServerResultNode {
@ -58,13 +68,59 @@ export interface GraphData {
nodes: ServerResultNode[];
edges: ServerResultEdge[];
}
export interface TermIntersect {
id1: string;
id2: string;
term1: string;
term2: string;
v1: number;
v2: number;
overlap: number;
}
export interface Workspace {
options: WorkspaceOptions;
nodesMap: Record<string, WorkspaceNode>;
nodes: WorkspaceNode[];
selectedNodes: WorkspaceNode[];
edges: WorkspaceEdge[];
blocklistedNodes: WorkspaceNode[];
blocklistedNodes: BlockListedNode[];
undoLog: string;
redoLog: string;
force: ReturnType<typeof d3.layout.force>;
lastRequest: string;
lastResponse: string;
undo: () => void;
redo: () => void;
expandSelecteds: (targetOptions: TargetOptions) => {};
deleteSelection: () => void;
blocklistSelection: () => void;
selectAll: () => void;
selectNone: () => void;
selectInvert: () => void;
selectNeighbours: () => void;
deselectNode: (node: WorkspaceNode) => void;
colorSelected: (color: string) => void;
groupSelections: (node: WorkspaceNode | undefined) => void;
ungroup: (node: WorkspaceNode | undefined) => void;
callElasticsearch: (request: any) => void;
search: (qeury: any, fieldsChoice: WorkspaceField[] | undefined, numHops: number) => void;
simpleSearch: (
searchTerm: string,
fieldsChoice: WorkspaceField[] | undefined,
numHops: number
) => void;
getAllIntersections: (
callback: (termIntersects: TermIntersect[]) => void,
nodes: WorkspaceNode[]
) => void;
toggleNodeSelection: (node: WorkspaceNode) => boolean;
mergeIds: (term1: string, term2: string) => void;
changeHandler: () => void;
unblockNode: (node: BlockListedNode) => void;
unblockAll: () => void;
clearGraph: () => void;
getQuery(startNodes?: WorkspaceNode[], loose?: boolean): JsonObject;
getSelectedOrAllNodes(): WorkspaceNode[];
@ -96,6 +152,8 @@ export type ExploreRequest = any;
export type SearchRequest = any;
export type ExploreResults = any;
export type SearchResults = any;
export type GraphExploreCallback = (data: ExploreResults) => void;
export type GraphSearchCallback = (data: SearchResults) => void;
export type WorkspaceOptions = Partial<{
indexName: string;
@ -105,12 +163,14 @@ export type WorkspaceOptions = Partial<{
graphExploreProxy: (
indexPattern: string,
request: ExploreRequest,
callback: (data: ExploreResults) => void
callback: GraphExploreCallback
) => void;
searchProxy: (
indexPattern: string,
request: SearchRequest,
callback: (data: SearchResults) => void
callback: GraphSearchCallback
) => void;
exploreControls: AdvancedSettings;
}>;
export type ControlType = 'style' | 'drillDowns' | 'editLabel' | 'mergeTerms' | 'none';