mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
3bae4cdc06
commit
3f7c461cd5
65 changed files with 2521 additions and 1606 deletions
|
@ -21,6 +21,7 @@
|
|||
*/
|
||||
|
||||
.gphNoUserSelect {
|
||||
padding-right: $euiSizeXS;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
@import './graph';
|
||||
@import './sidebar';
|
||||
@import './inspect';
|
|
@ -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 },
|
||||
} }}"
|
||||
> ({{mc.overlap}}) </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>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
})}
|
152
x-pack/plugins/graph/public/apps/workspace_route.tsx
Normal file
152
x-pack/plugins/graph/public/apps/workspace_route.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
};
|
||||
}
|
|
@ -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
|
|
@ -7,3 +7,6 @@
|
|||
@import './settings/index';
|
||||
@import './legacy_icon/index';
|
||||
@import './field_manager/index';
|
||||
@import './graph';
|
||||
@import './sidebar';
|
||||
@import './inspect';
|
||||
|
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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"> ({mc.overlap}) </small>
|
||||
</EuiToolTip>
|
||||
<EuiToolTip content={rightTermCountMsg}>
|
||||
<small className="gphLinkSummary__term--2">{mc.v2}</small>
|
||||
</EuiToolTip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1,3 +1,11 @@
|
|||
@mixin gphSvgText() {
|
||||
font-family: $euiFontFamily;
|
||||
font-size: $euiSizeS;
|
||||
line-height: $euiSizeM;
|
||||
fill: $euiColorDarkShade;
|
||||
color: $euiColorDarkShade;
|
||||
}
|
||||
|
||||
.gphVisualization {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
99
x-pack/plugins/graph/public/components/inspect_panel.tsx
Normal file
99
x-pack/plugins/graph/public/components/inspect_panel.tsx
Normal 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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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);
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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());
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
108
x-pack/plugins/graph/public/helpers/use_graph_loader.ts
Normal file
108
x-pack/plugins/graph/public/helpers/use_graph_loader.ts
Normal 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,
|
||||
};
|
||||
};
|
120
x-pack/plugins/graph/public/helpers/use_workspace_loader.ts
Normal file
120
x-pack/plugins/graph/public/helpers/use_workspace_loader.ts
Normal 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 };
|
||||
};
|
|
@ -10,5 +10,4 @@
|
|||
@import './mixins';
|
||||
|
||||
@import './main';
|
||||
@import './angular/templates/index';
|
||||
@import './components/index';
|
||||
|
|
|
@ -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,
|
||||
|
|
33
x-pack/plugins/graph/public/router.tsx
Normal file
33
x-pack/plugins/graph/public/router.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}) {
|
||||
|
|
|
@ -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 = [];
|
|
@ -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* () {
|
||||
|
|
|
@ -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' }));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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* () {
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue