From 7e77e0bdb42ba35ac4ecbd4889a54b2b54bf7750 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Oct 2019 10:39:51 +0200 Subject: [PATCH 1/4] Update dependency @elastic/charts to ^12.1.0 (#47267) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index fb65efdde6912..e60819102eb91 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "dependencies": { "@babel/core": "^7.5.5", "@babel/register": "^7.5.5", - "@elastic/charts": "^12.0.2", + "@elastic/charts": "^12.1.0", "@elastic/datemath": "5.0.2", "@elastic/eui": "14.4.0", "@elastic/filesaver": "1.1.2", diff --git a/yarn.lock b/yarn.lock index 620acf64598a0..b96aa7f3b32d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1064,10 +1064,10 @@ debug "^3.1.0" lodash.once "^4.1.1" -"@elastic/charts@^12.0.2": - version "12.0.2" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-12.0.2.tgz#576fafccd9e9f6ca751b6e846be3a5c954e8865b" - integrity sha512-BxdJVXUkYE11X+n5QWfu6ntDCm6wbkvLRNWrJG30pgGv9QEDhEbraQ8ql9Vx1454EuEjgXP6xOM0X+3rCO4Nqw== +"@elastic/charts@^12.1.0": + version "12.1.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-12.1.0.tgz#a1ad871cf1d90efc2450d29aa41bc9872434616a" + integrity sha512-sXZ0KHN29icVbeCU0x6LsiYI++w55kUJA0bpH7vpGExny70UBHNqjnLcBui0DBhlS3Bsz64xW5c4i9TKWeqXLA== dependencies: "@types/d3-shape" "^1.3.1" "@types/luxon" "^1.11.1" From 9cf13c9a58e9147be2155ccfd95905e247410496 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 4 Oct 2019 11:16:04 +0200 Subject: [PATCH 2/4] [Graph] App state management (#46133) --- .../graph/public/angular/templates/index.html | 10 +- x-pack/legacy/plugins/graph/public/app.js | 598 +++++------------- .../plugins/graph/public/components/app.tsx | 57 +- .../components/field_manager/field_editor.tsx | 3 +- .../field_manager/field_manager.test.tsx | 84 +-- .../field_manager/field_manager.tsx | 67 +- .../guidance_panel/guidance_panel.tsx | 46 +- .../public/components/search_bar.test.tsx | 97 +-- .../graph/public/components/search_bar.tsx | 76 ++- .../settings/advanced_settings_form.tsx | 6 +- .../components/settings/settings.test.tsx | 220 ++++--- .../public/components/settings/settings.tsx | 69 +- .../components/settings/url_template_list.tsx | 12 +- .../graph/public/helpers/url_template.ts | 6 +- .../public/services/index_pattern_cache.ts | 23 + .../services/persistence/deserialize.test.ts | 13 +- .../services/persistence/deserialize.ts | 16 +- .../services/persistence/serialize.test.ts | 46 +- .../public/services/persistence/serialize.ts | 18 +- .../graph/public/services/save_modal.tsx | 18 +- .../plugins/graph/public/services/url.ts | 11 +- .../state_management/advanced_settings.ts | 54 ++ .../public/state_management/datasource.ts | 110 ++++ .../graph/public/state_management/fields.ts | 93 ++- .../graph/public/state_management/global.ts | 10 + .../graph/public/state_management/helpers.ts | 28 + .../graph/public/state_management/index.ts | 7 + .../public/state_management/meta_data.ts | 61 ++ .../graph/public/state_management/mocks.ts | 57 ++ .../public/state_management/persistence.ts | 203 ++++++ .../graph/public/state_management/store.ts | 87 ++- .../public/state_management/url_templates.ts | 92 +++ .../plugins/graph/public/types/app_state.ts | 10 +- .../graph/public/types/workspace_state.ts | 4 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 36 files changed, 1527 insertions(+), 787 deletions(-) create mode 100644 x-pack/legacy/plugins/graph/public/services/index_pattern_cache.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/advanced_settings.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/datasource.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/global.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/helpers.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/meta_data.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/mocks.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/persistence.ts create mode 100644 x-pack/legacy/plugins/graph/public/state_management/url_templates.ts diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 07b57ee322548..9e9356f30642e 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -9,20 +9,20 @@ -
+
{ - // TODO this should be wrapped into canWipeWorkspace, - // but the check is too simple right now. Change this - // once actual state-diffing is in place. - $scope.$evalAsync(() => { - kbnUrl.changePath(getHomePath()); - }); - } - }); - } - - const store = createGraphStore(); - - $scope.title = 'Graph'; - $scope.spymode = 'request'; - - $scope.iconChoices = iconChoices; - $scope.drillDownIconChoices = urlTemplateIconChoices; - $scope.colors = colorChoices; - $scope.iconChoicesByClass = iconChoicesByClass; - - $scope.outlinkEncoders = outlinkEncoders; - - $scope.fields = []; - $scope.canEditDrillDownUrls = chrome.getInjected('canEditDrillDownUrls'); - - $scope.graphSavePolicy = chrome.getInjected('graphSavePolicy'); - $scope.allSavingDisabled = $scope.graphSavePolicy === 'none'; - $scope.searchTerm = ''; - - $scope.reduxDispatch = (action) => { - store.dispatch(action); - - // patch updated icons and fields on the nodes in the workspace state - // this workaround is necessary because the nodes are still managed by - // angular - once they are moved over to redux, this can be handled in - // the reducer - if (action.type === 'x-pack/graph/fields/UPDATE_FIELD_PROPERTIES' && - action.payload.fieldProperties.color && $scope.workspace) { - $scope.workspace.nodes.forEach(function (node) { - if (node.data.field === action.payload.fieldName) { - node.color = action.payload.fieldProperties.color; + // Replacement function for graphClientWorkspace's comms so + // that it works with Kibana. + function callNodeProxy(indexName, query, responseHandler) { + const request = { + index: indexName, + query: query + }; + $scope.loading = true; + return $http.post('../api/graph/graphExplore', request) + .then(function (resp) { + if (resp.data.resp.timed_out) { + toastNotifications.addWarning( + i18n.translate('xpack.graph.exploreGraph.timedOutWarningText', { + defaultMessage: 'Exploration timed out', + }) + ); } + responseHandler(resp.data.resp); + }) + .catch(handleHttpError) + .finally(() => { + $scope.loading = false; }); - } + } - if (action.type === 'x-pack/graph/fields/UPDATE_FIELD_PROPERTIES' && - action.payload.fieldProperties.icon && $scope.workspace) { - $scope.workspace.nodes.forEach(function (node) { - if (node.data.field === action.payload.fieldName) { - node.icon = action.payload.fieldProperties.icon; - } + + //Helper function for the graphClientWorkspace to perform a query + const callSearchNodeProxy = function (indexName, query, responseHandler) { + const request = { + index: indexName, + body: query + }; + $scope.loading = true; + $http.post('../api/graph/searchProxy', request) + .then(function (resp) { + responseHandler(resp.data.resp); + }) + .catch(handleHttpError) + .finally(() => { + $scope.loading = false; }); - } }; + $scope.indexPatternProvider = createCachedIndexPatternProvider($route.current.locals.GetIndexPatternProvider.get); + + const store = createGraphStore({ + basePath: chrome.getBasePath(), + 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 = gws.createWorkspace(options); + }, + setLiveResponseFields: (fields) => { + $scope.liveResponseFields = fields; + }, + getWorkspace: () => { + return $scope.workspace; + }, + getSavedWorkspace: () => { + return $route.current.locals.savedWorkspace; + }, + notifications: npStart.core.notifications, + showSaveModal, + savePolicy: chrome.getInjected('graphSavePolicy'), + changeUrl: (newUrl) => { + $scope.$evalAsync(() => { + kbnUrl.change(newUrl, {}); + }); + }, + notifyAngular: () => { + $scope.$digest(); + }, + chrome, + }); $scope.store = new Storage(window.localStorage); $scope.coreStart = npStart.core; $scope.autocompleteStart = npStart.plugins.data.autocomplete; $scope.loading = false; - const updateScope = () => { - const newState = store.getState(); - $scope.reduxState = newState; - $scope.allFields = fieldsSelector(newState); - $scope.selectedFields = selectedFieldsSelector(newState); - $scope.liveResponseFields = liveResponseFieldsSelector(newState); - if ($scope.workspace) { - $scope.workspace.options.vertex_fields = $scope.selectedFields; - } - }; - store.subscribe(updateScope); - updateScope(); + $scope.spymode = 'request'; - //So scope properties can be used consistently with ng-model - $scope.grr = $scope; + const allSavingDisabled = chrome.getInjected('graphSavePolicy') === 'none'; - $scope.toggleDrillDownIcon = function (urlTemplate, icon) { - urlTemplate.icon === icon ? urlTemplate.icon = null : urlTemplate.icon = icon; - }; + $scope.reduxStore = store; $scope.nodeClick = function (n, $event) { @@ -350,14 +351,14 @@ app.controller('graphuiPlugin', function ( } }; - function canWipeWorkspace(yesFn, noFn) { - if ($scope.selectedFields.length === 0 && $scope.workspace === null) { - yesFn(); + function canWipeWorkspace(callback) { + if (!hasFieldsSelector(store.getState())) { + callback(); return; } const confirmModalOptions = { - onConfirm: yesFn, - onCancel: noFn || (() => {}), + onConfirm: callback, + onCancel: (() => {}), confirmButtonText: i18n.translate('xpack.graph.clearWorkspace.confirmButtonLabel', { defaultMessage: 'Continue', }), @@ -369,30 +370,7 @@ app.controller('graphuiPlugin', function ( defaultMessage: 'Once you discard changes made to a workspace, there is no getting them back.', }), confirmModalOptions); } - - $scope.uiSelectIndex = function (proposedIndex) { - canWipeWorkspace(function () { - $scope.indexSelected(proposedIndex); - }); - }; - - $scope.indexSelected = function (selectedIndex) { - $scope.clearWorkspace(); - $scope.allFields = []; - $scope.selectedFields = []; - $scope.basicModeSelectedSingleField = null; - $scope.selectedField = null; - $scope.selectedFieldConfig = null; - - return $route.current.locals.GetIndexPatternProvider.get(selectedIndex.id) - .then(handleSuccess) - .then(function (indexPattern) { - $scope.selectedIndex = indexPattern; - store.dispatch(loadFields(mapFields(indexPattern))); - $scope.$digest(); - }, handleError); - }; - + $scope.confirmWipeWorkspace = canWipeWorkspace; $scope.clickEdge = function (edge) { if (edge.inferred) { @@ -402,62 +380,20 @@ app.controller('graphuiPlugin', function ( } }; - // Replacement function for graphClientWorkspace's comms so - // that it works with Kibana. - function callNodeProxy(indexName, query, responseHandler) { - const request = { - index: indexName, - query: query - }; - $scope.loading = true; - return $http.post('../api/graph/graphExplore', request) - .then(function (resp) { - if (resp.data.resp.timed_out) { - toastNotifications.addWarning( - i18n.translate('xpack.graph.exploreGraph.timedOutWarningText', { - defaultMessage: 'Exploration timed out', - }) - ); - } - responseHandler(resp.data.resp); - }) - .catch(handleHttpError) - .finally(() => { - $scope.loading = false; - }); - } - - - //Helper function for the graphClientWorkspace to perform a query - const callSearchNodeProxy = function (indexName, query, responseHandler) { - const request = { - index: indexName, - body: query - }; - $scope.loading = true; - $http.post('../api/graph/searchProxy', request) - .then(function (resp) { - responseHandler(resp.data.resp); - }) - .catch(handleHttpError) - .finally(() => { - $scope.loading = false; - }); - }; $scope.fillWorkspace = async () => { try { const fields = selectedFieldsSelector(store.getState()); const topTermNodes = await fetchTopNodes( npStart.core.http.post, - $scope.selectedIndex.title, + datasourceSelector(store.getState()).current.title, fields ); - initWorkspaceIfRequired(); $scope.workspace.mergeGraph({ nodes: topTermNodes, edges: [] }); + $scope.workspaceInitialized = true; $scope.workspace.fillInGraph(fields.length * 10); } catch (e) { toastNotifications.addDanger({ @@ -470,7 +406,7 @@ app.controller('graphuiPlugin', function ( }; $scope.submit = function (searchTerm) { - initWorkspaceIfRequired(); + $scope.workspaceInitialized = true; const numHops = 2; if (searchTerm.startsWith('{')) { try { @@ -509,121 +445,12 @@ app.controller('graphuiPlugin', function ( return $scope.selectedSelectedVertex === node; }; - $scope.saveUrlTemplate = function (index, urlTemplate) { - const newTemplatesList = [...$scope.urlTemplates]; - if (index !== -1) { - newTemplatesList[index] = urlTemplate; - } else { - newTemplatesList.push(urlTemplate); - } - - $scope.urlTemplates = newTemplatesList; - }; - - $scope.removeUrlTemplate = function (urlTemplate) { - const newTemplatesList = [...$scope.urlTemplates]; - const i = newTemplatesList.indexOf(urlTemplate); - newTemplatesList.splice(i, 1); - $scope.urlTemplates = newTemplatesList; - }; - $scope.openUrlTemplate = function (template) { const url = template.url; const newUrl = url.replace(urlTemplateRegex, template.encoder.encode($scope.workspace)); window.open(newUrl, '_blank'); }; - - //============================ - - $scope.resetWorkspace = function () { - $scope.clearWorkspace(); - $scope.selectedIndex = null; - $scope.proposedIndex = null; - $scope.detail = null; - $scope.selectedSelectedVertex = null; - $scope.selectedField = null; - $scope.description = null; - $scope.allFields = []; - $scope.urlTemplates = []; - - $scope.fieldNamesFilterString = null; - $scope.filteredFields = []; - - $scope.selectedFields = []; - $scope.liveResponseFields = []; - - $scope.exploreControls = { - useSignificance: true, - sampleSize: 2000, - timeoutMillis: 5000, - sampleDiversityField: null, - maxValuesPerDoc: 1, - minDocCount: 3 - }; - }; - - - function initWorkspaceIfRequired() { - if ($scope.workspace) { - return; - } - const options = { - indexName: $scope.selectedIndex.title, - vertex_fields: $scope.selectedFields, - // 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.exploreControls - }; - $scope.workspace = gws.createWorkspace(options); - $scope.detail = null; - - // filter out default url templates because they will get re-added - $scope.urlTemplates = $scope.urlTemplates.filter(template => !template.isDefault); - - if ($scope.urlTemplates.length === 0) { - // url templates specified by users can include the `{{gquery}}` tag and - // will have the elasticsearch query for the graph nodes injected there - const tag = '{{gquery}}'; - - const kUrl = new KibanaParsedUrl({ - appId: 'kibana', - basePath: chrome.getBasePath(), - appPath: '/discover' - }); - - kUrl.addQueryParameter('_a', rison.encode({ - columns: ['_source'], - index: $scope.selectedIndex.id, - interval: 'auto', - query: { language: 'kuery', query: tag }, - sort: ['_score', 'desc'] - })); - - const discoverUrl = kUrl.getRootRelativePath() - // replace the URI encoded version of the tag with the unescaped version - // so it can be found with String.replace, regexp, etc. - .replace(encodeURIComponent(tag), tag); - - $scope.urlTemplates.push({ - url: discoverUrl, - description: i18n.translate('xpack.graph.settings.drillDowns.defaultUrlTemplateTitle', { - defaultMessage: 'Raw documents', - }), - encoder: $scope.outlinkEncoders[0], - isDefault: true - }); - } - } - $scope.aceLoaded = (editor) => { editor.$blockScrolling = Infinity; }; @@ -668,39 +495,6 @@ app.controller('graphuiPlugin', function ( $scope.detail = { mergeCandidates }; }; - //initialize all the state - $scope.resetWorkspace(); - - const managementUrl = npStart.core.chrome.navLinks.get('kibana:management').url; - const url = `${managementUrl}/kibana/index_patterns`; - - if ($route.current.locals.indexPatterns.length === 0) { - toastNotifications.addWarning({ - title: i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { - defaultMessage: 'No data source', - }), - text: ( -

- - - - ) - }} - /> -

- ), - }); - } - - // ===== Menubar configuration ========= $scope.topNavMenu = []; $scope.topNavMenu.push({ @@ -737,7 +531,7 @@ app.controller('graphuiPlugin', function ( defaultMessage: 'Save workspace', }), tooltip: () => { - if ($scope.allSavingDisabled) { + if (allSavingDisabled) { return i18n.translate('xpack.graph.topNavMenu.saveWorkspace.disabledTooltip', { defaultMessage: 'No changes to saved workspaces are permitted by the current save policy', }); @@ -748,15 +542,12 @@ app.controller('graphuiPlugin', function ( } }, disableButton: function () { - return $scope.allSavingDisabled || $scope.selectedFields.length === 0; + return allSavingDisabled || !hasFieldsSelector(store.getState()); }, run: () => { - openSaveModal({ - savePolicy: $scope.graphSavePolicy, - hasData: $scope.workspace && ($scope.workspace.nodes.length > 0 || $scope.workspace.blacklistedNodes.length > 0), - workspace: $scope.savedWorkspace, - saveWorkspace: $scope.saveWorkspace, - showSaveModal + store.dispatch({ + type: 'x-pack/graph/SAVE_WORKSPACE', + payload: $route.current.locals.savedWorkspace, }); }, testId: 'graphSaveButton', @@ -780,10 +571,9 @@ app.controller('graphuiPlugin', function ( }, }); - let currentSettingsFlyout; $scope.topNavMenu.push({ key: 'settings', - disableButton: function () { return $scope.selectedIndex === null; }, + disableButton: function () { return datasourceSelector(store.getState()).type === 'none'; }, label: i18n.translate('xpack.graph.topNavMenu.settingsLabel', { defaultMessage: 'Settings', }), @@ -791,40 +581,25 @@ app.controller('graphuiPlugin', function ( defaultMessage: 'Settings', }), run: () => { - if (currentSettingsFlyout) { - currentSettingsFlyout.close(); - return; - } const settingsObservable = asAngularSyncedObservable(() => ({ - advancedSettings: { ...$scope.exploreControls }, - updateAdvancedSettings: (updatedSettings) => { - $scope.exploreControls = updatedSettings; - if ($scope.workspace) { - $scope.workspace.options.exploreControls = updatedSettings; - } - }, blacklistedNodes: $scope.workspace ? [...$scope.workspace.blacklistedNodes] : undefined, unblacklistNode: $scope.workspace ? $scope.workspace.unblacklist : undefined, - urlTemplates: [...$scope.urlTemplates], - removeUrlTemplate: $scope.removeUrlTemplate, - saveUrlTemplate: $scope.saveUrlTemplate, - allFields: [...$scope.allFields], - canEditDrillDownUrls: $scope.canEditDrillDownUrls + canEditDrillDownUrls: chrome.getInjected('canEditDrillDownUrls') }), $scope.$digest.bind($scope)); - currentSettingsFlyout = npStart.core.overlays.openFlyout(, { - size: 'm', - closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', { defaultMessage: 'Close' }), - 'data-test-subj': 'graphSettingsFlyout', - ownFocus: true, - className: 'gphSettingsFlyout', - maxWidth: 520, - }); - currentSettingsFlyout.onClose.then(() => { currentSettingsFlyout = null; }); + npStart.core.overlays.openFlyout( + + + , { + size: 'm', + closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', { defaultMessage: 'Close' }), + 'data-test-subj': 'graphSettingsFlyout', + ownFocus: true, + className: 'gphSettingsFlyout', + maxWidth: 520, + }); }, }); - updateBreadcrumbs(); - $scope.menus = { showSettings: false, }; @@ -836,99 +611,42 @@ app.controller('graphuiPlugin', function ( }; // Deal with situation of request to open saved workspace - if ($route.current.locals.savedWorkspace) { - $scope.savedWorkspace = $route.current.locals.savedWorkspace; - const selectedIndex = lookupIndexPattern($scope.savedWorkspace, $route.current.locals.indexPatterns); - if(!selectedIndex) { - toastNotifications.addDanger( - i18n.translate('xpack.graph.loadWorkspace.missingIndexPatternErrorMessage', { - defaultMessage: 'Index pattern not found', - }) - ); - return; - } - $route.current.locals.GetIndexPatternProvider.get(selectedIndex.id).then(indexPattern => { - $scope.selectedIndex = indexPattern; - initWorkspaceIfRequired(); - const { - urlTemplates, - advancedSettings, - allFields, - } = savedWorkspaceToAppState($scope.savedWorkspace, indexPattern, $scope.workspace); - - // wire up stuff to angular - store.dispatch(loadFields(allFields)); - $scope.exploreControls = advancedSettings; - $scope.workspace.options.exploreControls = advancedSettings; - $scope.urlTemplates = urlTemplates; - $scope.workspace.runLayout(); - // Allow URLs to include a user-defined text query - if ($route.current.params.query) { - $scope.initialQuery = $route.current.params.query; - $scope.submit($route.current.params.query); - } - - $scope.$digest(); + if ($route.current.locals.savedWorkspace.id) { + store.dispatch({ + type: 'x-pack/graph/LOAD_WORKSPACE', + payload: $route.current.locals.savedWorkspace, }); } else { - $route.current.locals.SavedWorkspacesProvider.get().then(function (newWorkspace) { - $scope.savedWorkspace = newWorkspace; - }); - } + const managementUrl = npStart.core.chrome.navLinks.get('kibana:management').url; + const url = `${managementUrl}/kibana/index_patterns`; - $scope.saveWorkspace = function (saveOptions, userHasConfirmedSaveWorkspaceData) { - if ($scope.allSavingDisabled) { - // It should not be possible to navigate to this function if allSavingDisabled is set - // but adding check here as a safeguard. - toastNotifications.addWarning( - i18n.translate('xpack.graph.saveWorkspace.disabledWarning', { defaultMessage: 'Saving is disabled' }) - ); - return; + if ($route.current.locals.indexPatterns.length === 0) { + toastNotifications.addWarning({ + title: i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { + defaultMessage: 'No data source', + }), + text: ( +

+ + + + ) + }} + /> +

+ ), + }); } - initWorkspaceIfRequired(); - const canSaveData = $scope.graphSavePolicy === 'configAndData' || - ($scope.graphSavePolicy === 'configAndDataWithConsent' && userHasConfirmedSaveWorkspaceData); - - appStateToSavedWorkspace( - $scope.savedWorkspace, - { - workspace: $scope.workspace, - urlTemplates: $scope.urlTemplates, - advancedSettings: $scope.exploreControls, - selectedIndex: $scope.selectedIndex, - selectedFields: $scope.selectedFields - }, - canSaveData - ); - - return $scope.savedWorkspace.save(saveOptions).then(function (id) { - if (id) { - const title = i18n.translate('xpack.graph.saveWorkspace.successNotificationTitle', { - defaultMessage: 'Saved "{workspaceTitle}"', - values: { workspaceTitle: $scope.savedWorkspace.title }, - }); - let text; - if (!canSaveData && $scope.workspace.nodes.length > 0) { - text = i18n.translate('xpack.graph.saveWorkspace.successNotification.noDataSavedText', { - defaultMessage: 'The configuration was saved, but the data was not saved', - }); - } - - toastNotifications.addSuccess({ - title, - text, - 'data-test-subj': 'saveGraphSuccess', - }); - if ($scope.savedWorkspace.id !== $route.current.params.id) { - kbnUrl.change(getEditPath($scope.savedWorkspace)); - } - } - return { id }; - }, fatalError); - - }; - - + } + $scope.savedWorkspace = $route.current.locals.savedWorkspace; }); -//End controller + diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 894c6b9ef45ac..efc8c1bfcc00a 100644 --- a/x-pack/legacy/plugins/graph/public/components/app.tsx +++ b/x-pack/legacy/plugins/graph/public/components/app.tsx @@ -5,29 +5,31 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Provider } from 'react-redux'; import React, { useState } from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { Storage } from 'ui/storage'; import { CoreStart } from 'kibana/public'; import { AutocompletePublicPluginStart } from 'src/plugins/data/public'; -import { FieldManagerProps, FieldManager } from './field_manager'; +import { FieldManager } from './field_manager'; import { SearchBarProps, SearchBar } from './search_bar'; +import { GraphStore } from '../state_management'; import { GuidancePanel } from './guidance_panel'; -import { selectedFieldsSelector } from '../state_management'; -import { openSourceModal } from '../services/source_modal'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; -export interface GraphAppProps extends FieldManagerProps, SearchBarProps { +export interface GraphAppProps extends SearchBarProps { coreStart: CoreStart; autocompleteStart: AutocompletePublicPluginStart; store: Storage; - onFillWorkspace: () => void; + reduxStore: GraphStore; isInitialized: boolean; + onFillWorkspace: () => void; } export function GraphApp(props: GraphAppProps) { const [pickerOpen, setPickerOpen] = useState(false); + const { coreStart, autocompleteStart, store, reduxStore, ...searchBarProps } = props; return ( @@ -39,29 +41,28 @@ export function GraphApp(props: GraphAppProps) { ...props.coreStart, }} > -
- - - - - - - - -
- {!props.isInitialized && ( - 0} - onFillWorkspace={props.onFillWorkspace} - onOpenFieldPicker={() => { - setPickerOpen(true); - }} - onOpenDatasourcePicker={() => { - openSourceModal(props.coreStart, props.onIndexPatternSelected); - }} - /> - )} + + <> +
+ + + + + + + + +
+ {!props.isInitialized && ( + { + setPickerOpen(true); + }} + /> + )} + +
); diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx index b9de9b9c628a0..519e41e846051 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_editor.tsx @@ -30,9 +30,10 @@ import { WorkspaceField } from '../../types'; import { iconChoices } from '../../helpers/style_choices'; import { LegacyIcon } from '../legacy_icon'; import { FieldIcon } from './field_icon'; +import { UpdateableFieldProperties } from './field_manager'; + import { isEqual } from '../helpers'; -type UpdateableFieldProperties = 'hopSize' | 'lastValidHopSize' | 'color' | 'icon'; export interface FieldPickerProps { field: WorkspaceField; allFields: WorkspaceField[]; diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx index fb715e759c62d..e9d3f3738ea6e 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx @@ -5,23 +5,25 @@ */ import React, { ReactElement } from 'react'; -import { EuiColorPicker, EuiSelectable, EuiContextMenu, EuiPopover, EuiButton } from '@elastic/eui'; +import { EuiColorPicker, EuiSelectable, EuiContextMenu, EuiButton } from '@elastic/eui'; import { FieldPicker } from './field_picker'; import { FieldEditor } from './field_editor'; -import { GraphStore, createGraphStore, loadFields } from '../../state_management'; +import { GraphStore, loadFields } from '../../state_management'; import { getSuitableIcon } from '../../helpers/style_choices'; import { shallow, ShallowWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { FieldManager } from './field_manager'; +import { Provider } from 'react-redux'; +import { createMockGraphStore } from '../../state_management/mocks'; describe('field_manager', () => { let store: GraphStore; let instance: ShallowWrapper; + let getInstance: () => ShallowWrapper; let dispatchSpy: jest.Mock; - let openSpy: jest.Mock; beforeEach(() => { - store = createGraphStore(); + store = createMockGraphStore({ includeSagas: false }).store; store.dispatch( loadFields([ { @@ -53,35 +55,31 @@ describe('field_manager', () => { ); dispatchSpy = jest.fn(store.dispatch); - openSpy = jest.fn(); + store.dispatch = dispatchSpy; instance = shallow( - + + {}} /> + ); - }); - function update() { - instance.setProps({ - state: store.getState(), - dispatch: dispatchSpy, - }); - } + getInstance = () => + instance + .find(FieldManager) + .dive() + .dive(); + }); it('should list editors for all selected fields', () => { - expect(instance.find(FieldEditor).length).toEqual(2); + expect(getInstance().find(FieldEditor).length).toEqual(2); expect( - instance + getInstance() .find(FieldEditor) .at(0) .prop('field').name ).toEqual('field1'); expect( - instance + getInstance() .find(FieldEditor) .at(1) .prop('field').name @@ -89,29 +87,21 @@ describe('field_manager', () => { }); it('should select fields from picker', () => { - act(() => { - (instance + expect( + getInstance() .find(FieldPicker) .dive() - .find(EuiPopover) - .prop('button')! as ReactElement).props.onClick(); - }); - - expect(openSpy).toHaveBeenCalled(); - - instance.setProps({ pickerOpen: true }); - - const fieldPicker = instance.find(FieldPicker).dive(); - - expect( - fieldPicker .find(EuiSelectable) .prop('options') .map((option: { label: string }) => option.label) ).toEqual(['field1', 'field2', 'field3']); act(() => { - fieldPicker.find(EuiSelectable).prop('onChange')([{ checked: 'on', label: 'field3' }]); + getInstance() + .find(FieldPicker) + .dive() + .find(EuiSelectable) + .prop('onChange')([{ checked: 'on', label: 'field3' }]); }); expect(dispatchSpy).toHaveBeenCalledWith({ @@ -119,13 +109,12 @@ describe('field_manager', () => { payload: 'field3', }); - update(); - expect(instance.find(FieldEditor).length).toEqual(3); + expect(getInstance().find(FieldEditor).length).toEqual(3); }); it('should deselect field', () => { act(() => { - instance + getInstance() .find(FieldEditor) .at(0) .dive() @@ -138,12 +127,11 @@ describe('field_manager', () => { payload: 'field1', }); - update(); - expect(instance.find(FieldEditor).length).toEqual(1); + expect(getInstance().find(FieldEditor).length).toEqual(1); }); it('should disable field', () => { - const toggleItem = instance + const toggleItem = getInstance() .find(FieldEditor) .at(0) .dive() @@ -165,10 +153,8 @@ describe('field_manager', () => { }, }); - update(); - expect( - instance + getInstance() .find(FieldEditor) .at(0) .dive() @@ -178,7 +164,7 @@ describe('field_manager', () => { }); it('should enable field', () => { - const toggleItem = instance + const toggleItem = getInstance() .find(FieldEditor) .at(1) .dive() @@ -200,10 +186,8 @@ describe('field_manager', () => { }, }); - update(); - expect( - instance + getInstance() .find(FieldEditor) .at(1) .dive() @@ -213,7 +197,7 @@ describe('field_manager', () => { }); it('should change color', () => { - const fieldEditor = instance + const fieldEditor = getInstance() .find(FieldEditor) .at(1) .dive(); diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx index e44ad248e279d..89b325d737bd5 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx @@ -7,55 +7,62 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; import { FieldPicker } from './field_picker'; import { FieldEditor } from './field_editor'; import { selectedFieldsSelector, fieldsSelector, + fieldMapSelector, updateFieldProperties, selectField, deselectField, - GraphDispatch, GraphState, - fieldMapSelector, } from '../../state_management'; +import { WorkspaceField } from '../../types'; + +export type UpdateableFieldProperties = 'hopSize' | 'lastValidHopSize' | 'color' | 'icon'; -export interface FieldManagerProps { - state: GraphState; - dispatch: GraphDispatch; +export function FieldManagerComponent(props: { + allFields: WorkspaceField[]; + fieldMap: Record; + selectedFields: WorkspaceField[]; + updateFieldProperties: (props: { + fieldName: string; + fieldProperties: Partial>; + }) => void; + selectField: (fieldName: string) => void; + deselectField: (fieldName: string) => void; pickerOpen: boolean; setPickerOpen: (open: boolean) => void; -} - -export function FieldManager({ state, dispatch, pickerOpen, setPickerOpen }: FieldManagerProps) { - const fieldMap = fieldMapSelector(state); - const allFields = fieldsSelector(state); - const selectedFields = selectedFieldsSelector(state); - - const actionCreators = bindActionCreators( - { - updateFieldProperties, - selectField, - deselectField, - }, - dispatch - ); - +}) { return ( - - {selectedFields.map(field => ( + + {props.selectedFields.map(field => ( - + ))} - + ); } + +export const FieldManager = connect( + (state: GraphState) => ({ + fieldMap: fieldMapSelector(state), + allFields: fieldsSelector(state), + selectedFields: selectedFieldsSelector(state), + }), + dispatch => + bindActionCreators( + { + updateFieldProperties, + selectField, + deselectField, + }, + dispatch + ) +)(FieldManagerComponent); diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx index 62d8bbb03bc3f..840280a754154 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx @@ -9,13 +9,25 @@ import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiLink } from ' import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n/react'; +import { connect } from 'react-redux'; +import { IDataPluginServices } from 'src/legacy/core_plugins/data/public/types'; +import { + GraphState, + hasDatasourceSelector, + hasFieldsSelector, + requestDatasource, +} from '../../state_management'; +import { IndexPatternSavedObject } from '../../types'; +import { openSourceModal } from '../../services/source_modal'; + +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; export interface GuidancePanelProps { onFillWorkspace: () => void; onOpenFieldPicker: () => void; - onOpenDatasourcePicker: () => void; hasDatasource: boolean; hasFields: boolean; + onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void; } function ListItem({ @@ -47,15 +59,23 @@ function ListItem({ ); } -export function GuidancePanel(props: GuidancePanelProps) { +function GuidancePanelComponent(props: GuidancePanelProps) { const { onFillWorkspace, onOpenFieldPicker, - onOpenDatasourcePicker, + onIndexPatternSelected, hasDatasource, hasFields, } = props; + const kibana = useKibana(); + const { overlays, savedObjects, uiSettings } = kibana.services; + if (!overlays) return null; + + const onOpenDatasourcePicker = () => { + openSourceModal({ overlays, savedObjects, uiSettings }, onIndexPatternSelected); + }; + return ( @@ -141,3 +161,23 @@ export function GuidancePanel(props: GuidancePanelProps) { ); } + +export const GuidancePanel = connect( + (state: GraphState) => { + return { + hasDatasource: hasDatasourceSelector(state), + hasFields: hasFieldsSelector(state), + }; + }, + dispatch => ({ + onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => { + dispatch( + requestDatasource({ + type: 'indexpattern', + id: indexPattern.id, + title: indexPattern.attributes.title, + }) + ); + }, + }) +)(GuidancePanelComponent); diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx index dbad0e01078fd..3c37c77e6d450 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchBar, SearchBarProps } from './search_bar'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { SearchBar, OuterSearchBarProps } from './search_bar'; import React, { ReactElement } from 'react'; import { CoreStart } from 'src/core/public'; import { act } from 'react-dom/test-utils'; -import { IndexPattern, QueryBarInput } from 'src/legacy/core_plugins/data/public'; +import { QueryBarInput, IndexPattern } from 'src/legacy/core_plugins/data/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { I18nProvider } from '@kbn/i18n/react'; @@ -16,11 +17,19 @@ import { I18nProvider } from '@kbn/i18n/react'; jest.mock('ui/new_platform'); import { openSourceModal } from '../services/source_modal'; -import { mount } from 'enzyme'; +import { GraphStore, setDatasource } from '../state_management'; +import { ReactWrapper } from 'enzyme'; +import { createMockGraphStore } from '../state_management/mocks'; +import { Provider } from 'react-redux'; jest.mock('../services/source_modal', () => ({ openSourceModal: jest.fn() })); +jest.mock('../../../../../../src/legacy/core_plugins/data/public', () => ({ + QueryBarInput: () => null, +})); -function wrapSearchBarInContext(testProps: SearchBarProps) { +const waitForIndexPatternFetch = () => new Promise(r => setTimeout(r)); + +function wrapSearchBarInContext(testProps: OuterSearchBarProps) { const services = { uiSettings: { get: (key: string) => { @@ -43,17 +52,47 @@ function wrapSearchBarInContext(testProps: SearchBarProps) { } describe('search_bar', () => { - it('should render search bar and submit queryies', () => { - const querySubmit = jest.fn(); - const instance = mount( - wrapSearchBarInContext({ - isLoading: false, - onIndexPatternSelected: () => {}, - onQuerySubmit: querySubmit, - currentIndexPattern: { title: 'Testpattern' } as IndexPattern, - coreStart: {} as CoreStart, + const defaultProps = { + isLoading: false, + onQuerySubmit: jest.fn(), + indexPatternProvider: { + get: jest.fn(() => Promise.resolve(({ fields: [] } as unknown) as IndexPattern)), + }, + confirmWipeWorkspace: (callback: () => void) => { + callback(); + }, + }; + let instance: ReactWrapper; + let store: GraphStore; + + beforeEach(() => { + store = createMockGraphStore({ includeSagas: false }).store; + store.dispatch( + setDatasource({ + type: 'indexpattern', + id: '123', + title: 'test-index', }) ); + }); + + function mountSearchBar() { + jest.clearAllMocks(); + const wrappedSearchBar = wrapSearchBarInContext({ ...defaultProps }); + instance = mountWithIntl({wrappedSearchBar}); + } + + it('should render search bar and fetch index pattern', () => { + mountSearchBar(); + + expect(defaultProps.indexPatternProvider.get).toHaveBeenCalledWith('123'); + }); + + it('should render search bar and submit queries', async () => { + mountSearchBar(); + + await waitForIndexPatternFetch(); + act(() => { instance.find(QueryBarInput).prop('onChange')!({ language: 'lucene', query: 'testQuery' }); }); @@ -62,20 +101,14 @@ describe('search_bar', () => { instance.find('form').simulate('submit', { preventDefault: () => {} }); }); - expect(querySubmit).toHaveBeenCalledWith('testQuery'); + expect(defaultProps.onQuerySubmit).toHaveBeenCalledWith('testQuery'); }); - it('should translate kql query into JSON dsl', () => { - const querySubmit = jest.fn(); - const instance = mount( - wrapSearchBarInContext({ - isLoading: false, - onIndexPatternSelected: () => {}, - onQuerySubmit: querySubmit, - currentIndexPattern: { title: 'Testpattern', fields: [{ name: 'test' }] } as IndexPattern, - coreStart: {} as CoreStart, - }) - ); + it('should translate kql query into JSON dsl', async () => { + mountSearchBar(); + + await waitForIndexPatternFetch(); + act(() => { instance.find(QueryBarInput).prop('onChange')!({ language: 'kuery', query: 'test: abc' }); }); @@ -84,24 +117,14 @@ describe('search_bar', () => { instance.find('form').simulate('submit', { preventDefault: () => {} }); }); - const parsedQuery = JSON.parse(querySubmit.mock.calls[0][0]); + const parsedQuery = JSON.parse(defaultProps.onQuerySubmit.mock.calls[0][0]); expect(parsedQuery).toEqual({ bool: { should: [{ match: { test: 'abc' } }], minimum_should_match: 1 }, }); }); it('should open index pattern picker', () => { - const indexPatternSelected = jest.fn(); - - const instance = mount( - wrapSearchBarInContext({ - isLoading: false, - onIndexPatternSelected: indexPatternSelected, - onQuerySubmit: () => {}, - currentIndexPattern: { title: 'Testpattern' } as IndexPattern, - coreStart: {} as CoreStart, - }) - ); + mountSearchBar(); // pick the button component out of the tree because // it's part of a popover and thus not covered by enzyme diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx index 18eca326776f5..ae0d32cd5f686 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx @@ -5,28 +5,39 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; +import { connect } from 'react-redux'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { IDataPluginServices } from 'src/legacy/core_plugins/data/public/types'; -import { CoreStart } from 'kibana/public'; +import { IndexPatternSavedObject, IndexPatternProvider } from '../types'; import { QueryBarInput, Query, IndexPattern, } from '../../../../../../src/legacy/core_plugins/data/public'; -import { IndexPatternSavedObject } from '../types/app_state'; import { openSourceModal } from '../services/source_modal'; +import { + GraphState, + datasourceSelector, + requestDatasource, + IndexpatternDatasource, +} from '../state_management'; + import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -export interface SearchBarProps { - coreStart: CoreStart; +export interface OuterSearchBarProps { isLoading: boolean; - currentIndexPattern?: IndexPattern; initialQuery?: string; - onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void; onQuerySubmit: (query: string) => void; + confirmWipeWorkspace: (onConfirm: () => void) => void; + indexPatternProvider: IndexPatternProvider; +} + +export interface SearchBarProps extends OuterSearchBarProps { + currentDatasource?: IndexpatternDatasource; + onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => void; } function queryToString(query: Query, indexPattern: IndexPattern) { @@ -45,17 +56,34 @@ function queryToString(query: Query, indexPattern: IndexPattern) { return JSON.stringify(query.query); } -export function SearchBar(props: SearchBarProps) { +export function SearchBarComponent(props: SearchBarProps) { const { - currentIndexPattern, + currentDatasource, onQuerySubmit, isLoading, onIndexPatternSelected, initialQuery, + indexPatternProvider, + confirmWipeWorkspace, } = props; const [query, setQuery] = useState({ language: 'kuery', query: initialQuery || '' }); + const [currentIndexPattern, setCurrentIndexPattern] = useState( + undefined + ); + + useEffect(() => { + async function fetchPattern() { + if (currentDatasource) { + setCurrentIndexPattern(await indexPatternProvider.get(currentDatasource.id)); + } else { + setCurrentIndexPattern(undefined); + } + } + fetchPattern(); + }, [currentDatasource]); + const kibana = useKibana(); - const { overlays } = kibana.services; + const { overlays, savedObjects, uiSettings } = kibana.services; if (!overlays) return null; return ( @@ -88,7 +116,12 @@ export function SearchBar(props: SearchBarProps) { className="gphSearchBar__datasourceButton" data-test-subj="graphDatasourceButton" onClick={() => { - openSourceModal(props.coreStart, onIndexPatternSelected); + confirmWipeWorkspace(() => + openSourceModal( + { overlays, savedObjects, uiSettings }, + onIndexPatternSelected + ) + ); }} > {currentIndexPattern @@ -113,3 +146,24 @@ export function SearchBar(props: SearchBarProps) { ); } + +export const SearchBar = connect( + (state: GraphState) => { + const datasource = datasourceSelector(state); + return { + currentDatasource: + datasource.current.type === 'indexpattern' ? datasource.current : undefined, + }; + }, + dispatch => ({ + onIndexPatternSelected: (indexPattern: IndexPatternSavedObject) => { + dispatch( + requestDatasource({ + type: 'indexpattern', + id: indexPattern.id, + title: indexPattern.attributes.title, + }) + ); + }, + }) +)(SearchBarComponent); diff --git a/x-pack/legacy/plugins/graph/public/components/settings/advanced_settings_form.tsx b/x-pack/legacy/plugins/graph/public/components/settings/advanced_settings_form.tsx index f963d53c639c4..e6a99e909c69e 100644 --- a/x-pack/legacy/plugins/graph/public/components/settings/advanced_settings_form.tsx +++ b/x-pack/legacy/plugins/graph/public/components/settings/advanced_settings_form.tsx @@ -23,11 +23,11 @@ type NumberKeys = Exclude< export function AdvancedSettingsForm({ advancedSettings, - updateAdvancedSettings, + updateSettings, allFields, -}: Pick) { +}: Pick) { function updateSetting(key: K, value: AdvancedSettings[K]) { - updateAdvancedSettings({ ...advancedSettings, [key]: value }); + updateSettings({ ...advancedSettings, [key]: value }); } function getNumberUpdater>(key: K) { diff --git a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx index a0c95a894ee84..782b26a558249 100644 --- a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx @@ -8,21 +8,44 @@ import React from 'react'; import { EuiTab, EuiListGroupItem, EuiButton, EuiAccordion, EuiFieldText } from '@elastic/eui'; import * as Rx from 'rxjs'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { Settings, SettingsProps } from './settings'; +import { Settings, AngularProps } from './settings'; import { act } from 'react-testing-library'; import { ReactWrapper } from 'enzyme'; import { UrlTemplateForm } from './url_template_form'; +import { + GraphStore, + updateSettings, + loadFields, + saveTemplate, + removeTemplate, +} from '../../state_management'; +import { createMockGraphStore } from '../../state_management/mocks'; +import { Provider } from 'react-redux'; +import { UrlTemplate } from '../../types'; describe('settings', () => { - const props: jest.Mocked = { - advancedSettings: { - maxValuesPerDoc: 5, - minDocCount: 10, - sampleSize: 12, - useSignificance: true, - timeoutMillis: 10000, + let store: GraphStore; + let dispatchSpy: jest.Mock; + + const initialTemplate: UrlTemplate = { + description: 'template', + encoder: { + description: 'test encoder description', + encode: jest.fn(), + id: 'test', + title: 'test encoder', + type: 'esq', + }, + url: 'http://example.org', + icon: { + class: 'test', + code: '1', + label: 'test', }, - updateAdvancedSettings: jest.fn(), + isDefault: false, + }; + + const angularProps: jest.Mocked = { blacklistedNodes: [ { x: 0, @@ -60,59 +83,63 @@ describe('settings', () => { }, ], unblacklistNode: jest.fn(), - urlTemplates: [ - { - description: 'template', - encoder: { - description: 'test encoder description', - encode: jest.fn(), - id: 'test', - title: 'test encoder', - type: 'esq', - }, - url: 'http://example.org', - icon: { - class: 'test', - code: '1', - label: 'test', - }, - }, - ], - removeUrlTemplate: jest.fn(), - saveUrlTemplate: jest.fn(), - allFields: [ - { - selected: false, - color: 'black', - name: 'B', - type: 'string', - icon: { - class: 'test', - code: '1', - label: 'test', - }, - }, - { - selected: false, - color: 'red', - name: 'C', - type: 'string', - icon: { - class: 'test', - code: '1', - label: 'test', - }, - }, - ], canEditDrillDownUrls: true, }; - let subject: Rx.BehaviorSubject>; + let subject: Rx.BehaviorSubject>; let instance: ReactWrapper; beforeEach(() => { - subject = new Rx.BehaviorSubject(props); - instance = mountWithIntl(); + store = createMockGraphStore({ includeSagas: false }).store; + store.dispatch( + updateSettings({ + maxValuesPerDoc: 5, + minDocCount: 10, + sampleSize: 12, + useSignificance: true, + timeoutMillis: 10000, + }) + ); + store.dispatch( + loadFields([ + { + selected: false, + color: 'black', + name: 'B', + type: 'string', + icon: { + class: 'test', + code: '1', + label: 'test', + }, + }, + { + selected: false, + color: 'red', + name: 'C', + type: 'string', + icon: { + class: 'test', + code: '1', + label: 'test', + }, + }, + ]) + ); + store.dispatch( + saveTemplate({ + index: -1, + template: initialTemplate, + }) + ); + dispatchSpy = jest.fn(store.dispatch); + store.dispatch = dispatchSpy; + subject = new Rx.BehaviorSubject(angularProps); + instance = mountWithIntl( + + + + ); }); function toTab(tab: string) { @@ -139,25 +166,14 @@ describe('settings', () => { HTMLInputElement >); - expect(props.updateAdvancedSettings).toHaveBeenCalledWith( - expect.objectContaining({ sampleSize: 13 }) - ); - }); - - it('should update on new data', () => { - act(() => { - subject.next({ - ...props, - advancedSettings: { - ...props.advancedSettings, + expect(dispatchSpy).toHaveBeenCalledWith( + updateSettings( + expect.objectContaining({ + timeoutMillis: 10000, sampleSize: 13, - }, - }); - }); - - instance.update(); - - expect(input('Sample size').prop('value')).toEqual(13); + }) + ) + ); }); }); @@ -173,13 +189,46 @@ describe('settings', () => { ]); }); + it('should update on new data', () => { + act(() => { + subject.next({ + ...angularProps, + blacklistedNodes: [ + { + x: 0, + y: 0, + scaledSize: 10, + parent: null, + color: 'black', + data: { + field: 'A', + term: '1', + }, + label: 'blacklisted node 3', + icon: { + class: 'test', + code: '1', + label: 'test', + }, + }, + ], + }); + }); + + instance.update(); + + expect(instance.find(EuiListGroupItem).map(item => item.prop('label'))).toEqual([ + 'blacklisted node 3', + ]); + }); + it('should delete node', () => { instance .find(EuiListGroupItem) .at(0) .prop('extraAction')!.onClick!({} as any); - expect(props.unblacklistNode).toHaveBeenCalledWith(props.blacklistedNodes![0]); + expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![0]); }); it('should delete all nodes', () => { @@ -188,8 +237,8 @@ describe('settings', () => { .find(EuiButton) .simulate('click'); - expect(props.unblacklistNode).toHaveBeenCalledWith(props.blacklistedNodes![0]); - expect(props.unblacklistNode).toHaveBeenCalledWith(props.blacklistedNodes![1]); + expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![0]); + expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![1]); }); }); @@ -226,7 +275,7 @@ describe('settings', () => { templateForm(0) .find('EuiButtonEmpty[data-test-subj="graphRemoveUrlTemplate"]') .simulate('click'); - expect(props.removeUrlTemplate).toHaveBeenCalledWith(props.urlTemplates[0]); + expect(dispatchSpy).toHaveBeenCalledWith(removeTemplate(initialTemplate)); }); it('should update url template', () => { @@ -236,10 +285,9 @@ describe('settings', () => { .find('form') .simulate('submit'); }); - expect(props.saveUrlTemplate).toHaveBeenCalledWith(0, { - ...props.urlTemplates[0], - description: 'Updated title', - }); + expect(dispatchSpy).toHaveBeenCalledWith( + saveTemplate({ index: 0, template: { ...initialTemplate, description: 'Updated title' } }) + ); }); it('should add url template', async () => { @@ -256,9 +304,11 @@ describe('settings', () => { .find('form') .simulate('submit'); }); - expect(props.saveUrlTemplate).toHaveBeenCalledWith( - -1, - expect.objectContaining({ description: 'Title', url: 'test-url' }) + expect(dispatchSpy).toHaveBeenCalledWith( + saveTemplate({ + index: -1, + template: expect.objectContaining({ description: 'Title', url: 'test-url' }), + }) ); }); }); diff --git a/x-pack/legacy/plugins/graph/public/components/settings/settings.tsx b/x-pack/legacy/plugins/graph/public/components/settings/settings.tsx index 4cb4b473f7372..4dab0ece5b52d 100644 --- a/x-pack/legacy/plugins/graph/public/components/settings/settings.tsx +++ b/x-pack/legacy/plugins/graph/public/components/settings/settings.tsx @@ -8,10 +8,21 @@ import { i18n } from '@kbn/i18n'; import React, { useState, useEffect } from 'react'; import { EuiFlyoutHeader, EuiTitle, EuiTabs, EuiFlyoutBody, EuiTab } from '@elastic/eui'; import * as Rx from 'rxjs'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import { AdvancedSettingsForm } from './advanced_settings_form'; import { BlacklistForm } from './blacklist_form'; import { UrlTemplateList } from './url_template_list'; import { WorkspaceNode, AdvancedSettings, UrlTemplate, WorkspaceField } from '../../types'; +import { + GraphState, + settingsSelector, + templatesSelector, + fieldsSelector, + updateSettings, + saveTemplate, + removeTemplate, +} from '../../state_management'; const tabs = [ { @@ -35,31 +46,46 @@ const tabs = [ }, ]; -export interface SettingsProps { +/** + * These props are wired in the angular scope and are passed in via observable + * to catch update outside updates + */ +export interface AngularProps { + blacklistedNodes: WorkspaceNode[]; + unblacklistNode: (node: WorkspaceNode) => void; + canEditDrillDownUrls: boolean; +} + +export interface StateProps { advancedSettings: AdvancedSettings; - updateAdvancedSettings: (advancedSettings: AdvancedSettings) => void; - blacklistedNodes?: WorkspaceNode[]; - unblacklistNode?: (node: WorkspaceNode) => void; urlTemplates: UrlTemplate[]; - removeUrlTemplate: (urlTemplate: UrlTemplate) => void; - saveUrlTemplate: (index: number, urlTemplate: UrlTemplate) => void; allFields: WorkspaceField[]; - canEditDrillDownUrls: boolean; +} + +export interface DispatchProps { + updateSettings: (advancedSettings: AdvancedSettings) => void; + removeTemplate: (urlTemplate: UrlTemplate) => void; + saveTemplate: (props: { index: number; template: UrlTemplate }) => void; } interface AsObservable

{ observable: Readonly>; } -export function Settings({ observable }: AsObservable) { - const [currentProps, setCurrentProps] = useState(undefined); +export interface SettingsProps extends AngularProps, StateProps, DispatchProps {} + +export function SettingsComponent({ + observable, + ...props +}: AsObservable & StateProps & DispatchProps) { + const [angularProps, setAngularProps] = useState(undefined); const [activeTab, setActiveTab] = useState(0); useEffect(() => { - observable.subscribe(setCurrentProps); + observable.subscribe(setAngularProps); }, [observable]); - if (!currentProps) return null; + if (!angularProps) return null; const ActiveTabContent = tabs[activeTab].component; return ( @@ -70,7 +96,7 @@ export function Settings({ observable }: AsObservable) { {tabs - .filter(({ id }) => id !== 'drillDowns' || currentProps.canEditDrillDownUrls) + .filter(({ id }) => id !== 'drillDowns' || angularProps.canEditDrillDownUrls) .map(({ title }, index) => ( ) { - + ); } + +export const Settings = connect, GraphState>( + (state: GraphState) => ({ + advancedSettings: settingsSelector(state), + urlTemplates: templatesSelector(state), + allFields: fieldsSelector(state), + }), + dispatch => + bindActionCreators( + { + updateSettings, + saveTemplate, + removeTemplate, + }, + dispatch + ) +)(SettingsComponent); diff --git a/x-pack/legacy/plugins/graph/public/components/settings/url_template_list.tsx b/x-pack/legacy/plugins/graph/public/components/settings/url_template_list.tsx index 2e41f78bb9403..1946c18b41425 100644 --- a/x-pack/legacy/plugins/graph/public/components/settings/url_template_list.tsx +++ b/x-pack/legacy/plugins/graph/public/components/settings/url_template_list.tsx @@ -14,10 +14,10 @@ import { useListKeys } from './use_list_keys'; const generateId = htmlIdGenerator(); export function UrlTemplateList({ - removeUrlTemplate, - saveUrlTemplate, + removeTemplate, + saveTemplate, urlTemplates, -}: Pick) { +}: Pick) { const [uncommittedForms, setUncommittedForms] = useState([]); const getListKey = useListKeys(urlTemplates); @@ -40,10 +40,10 @@ export function UrlTemplateList({ id={getListKey(template)} initialTemplate={template} onSubmit={newTemplate => { - saveUrlTemplate(index, newTemplate); + saveTemplate({ index, template: newTemplate }); }} onRemove={() => { - removeUrlTemplate(template); + removeTemplate(template); }} /> ))} @@ -53,7 +53,7 @@ export function UrlTemplateList({ id={`accordion-new-${id}`} key={id} onSubmit={newTemplate => { - saveUrlTemplate(-1, newTemplate); + saveTemplate({ index: -1, template: newTemplate }); removeUncommittedForm(id); }} onRemove={removeUncommittedForm.bind(undefined, id)} diff --git a/x-pack/legacy/plugins/graph/public/helpers/url_template.ts b/x-pack/legacy/plugins/graph/public/helpers/url_template.ts index bb85d2e8c5836..f982d0443c39a 100644 --- a/x-pack/legacy/plugins/graph/public/helpers/url_template.ts +++ b/x-pack/legacy/plugins/graph/public/helpers/url_template.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export const urlTemplatePlaceholder = '{{gquery}}'; export const urlTemplateRegex = /\{\{gquery\}\}/g; const defaultKibanaQuery = /,query:\(language:kuery,query:'.*?'\)/g; @@ -34,5 +35,8 @@ export function isKibanaUrl(url: string) { * @param url The url to turn into an url template */ export function replaceKibanaUrlParam(url: string) { - return url.replace(defaultKibanaQuery, ',query:(language:kuery,query:{{gquery}})'); + return url.replace( + defaultKibanaQuery, + `,query:(language:kuery,query:{{${urlTemplatePlaceholder}}})` + ); } diff --git a/x-pack/legacy/plugins/graph/public/services/index_pattern_cache.ts b/x-pack/legacy/plugins/graph/public/services/index_pattern_cache.ts new file mode 100644 index 0000000000000..e4e02f860db14 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/index_pattern_cache.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPattern } from 'src/legacy/core_plugins/data/public'; +import { IndexPatternProvider } from '../types'; + +export function createCachedIndexPatternProvider( + indexPatternGetter: (id: string) => Promise +): IndexPatternProvider { + const cache = new Map(); + + return { + get: async (id: string) => { + if (!cache.has(id)) { + cache.set(id, await indexPatternGetter(id)); + } + return Promise.resolve(cache.get(id)!); + }, + }; +} diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts index 4575b596d65d9..6aa9fd671ffcb 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { GraphWorkspaceSavedObject } from '../../types'; +import { GraphWorkspaceSavedObject, Workspace } from '../../types'; import { savedWorkspaceToAppState } from './deserialize'; import { IndexPattern } from 'src/legacy/core_plugins/data/public'; import { createWorkspace } from '../../angular/graph_client_workspace'; @@ -12,6 +12,7 @@ import { outlinkEncoders } from '../../helpers/outlink_encoders'; describe('deserialize', () => { let savedWorkspace: GraphWorkspaceSavedObject; + let workspace: Workspace; beforeEach(() => { savedWorkspace = { @@ -110,6 +111,7 @@ describe('deserialize', () => { }, }), } as GraphWorkspaceSavedObject; + workspace = createWorkspace({}); }); function callSavedWorkspaceToAppState() { @@ -122,7 +124,7 @@ describe('deserialize', () => { { name: 'field3', type: 'string' }, ], } as IndexPattern, - createWorkspace({}) + workspace ); } @@ -133,7 +135,7 @@ describe('deserialize', () => { }); it('should deserialize fields', () => { - const { allFields, selectedFields } = callSavedWorkspaceToAppState(); + const { allFields } = callSavedWorkspaceToAppState(); expect(allFields).toMatchInlineSnapshot(` Array [ @@ -175,9 +177,6 @@ describe('deserialize', () => { }, ] `); - - expect(selectedFields.length).toEqual(2); - selectedFields.forEach(field => expect(allFields.includes(field)).toEqual(true)); }); it('should deserialize url templates', () => { @@ -188,7 +187,7 @@ describe('deserialize', () => { }); it('should deserialize nodes and edges', () => { - const { workspace } = callSavedWorkspaceToAppState(); + callSavedWorkspaceToAppState(); expect(workspace.blacklistedNodes.length).toEqual(1); expect(workspace.nodes.length).toEqual(5); diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts index 39c7ef841dcb6..b1879ec92c131 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts @@ -6,7 +6,6 @@ import { IndexPattern } from 'src/legacy/core_plugins/data/public/index_patterns/index_patterns'; import { - AppState, SerializedNode, UrlTemplate, SerializedUrlTemplate, @@ -188,10 +187,11 @@ export function savedWorkspaceToAppState( savedWorkspace: GraphWorkspaceSavedObject, indexPattern: IndexPattern, workspaceInstance: Workspace -): Pick< - AppState, - 'urlTemplates' | 'advancedSettings' | 'workspace' | 'allFields' | 'selectedFields' -> { +): { + urlTemplates: UrlTemplate[]; + advancedSettings: AdvancedSettings; + allFields: WorkspaceField[]; +} { const persistedWorkspaceState: SerializedWorkspaceState = JSON.parse(savedWorkspace.wsState); // ================== url templates ============================= @@ -205,9 +205,11 @@ export function savedWorkspaceToAppState( persistedWorkspaceState.selectedFields ); const selectedFields = allFields.filter(field => field.selected); + workspaceInstance.options.vertex_fields = selectedFields; // ================== advanced settings ============================= const advancedSettings = Object.assign( + {}, defaultAdvancedSettings, persistedWorkspaceState.exploreControls ); @@ -220,6 +222,8 @@ export function savedWorkspaceToAppState( ); } + workspaceInstance.options.exploreControls = advancedSettings; + // ================== nodes and edges ============================= const graph = getNodesAndEdges(persistedWorkspaceState, allFields); workspaceInstance.mergeGraph(graph); @@ -232,8 +236,6 @@ export function savedWorkspaceToAppState( return { urlTemplates, advancedSettings, - workspace: workspaceInstance, allFields, - selectedFields, }; } diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts index b3450cce05c0b..95f55bcc87eb7 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts @@ -5,12 +5,25 @@ */ import { appStateToSavedWorkspace } from './serialize'; -import { GraphWorkspaceSavedObject, Workspace, WorkspaceEdge, AppState } from '../../types'; +import { + GraphWorkspaceSavedObject, + Workspace, + WorkspaceEdge, + UrlTemplate, + AdvancedSettings, + WorkspaceField, +} from '../../types'; import { outlinkEncoders } from '../../helpers/outlink_encoders'; -import { IndexPattern } from 'src/legacy/core_plugins/data/public'; +import { IndexpatternDatasource } from '../../state_management'; describe('serialize', () => { - let appState: AppState; + let appState: { + workspace: Workspace; + urlTemplates: UrlTemplate[]; + advancedSettings: AdvancedSettings; + selectedIndex: IndexpatternDatasource; + selectedFields: WorkspaceField[]; + }; beforeEach(() => { appState = { @@ -21,29 +34,6 @@ describe('serialize', () => { maxValuesPerDoc: 1, minDocCount: 3, }, - allFields: [ - { - color: 'black', - icon: { class: 'a', code: '', label: '' }, - name: 'field1', - selected: true, - type: 'string', - }, - { - color: 'black', - icon: { class: 'b', code: '', label: '' }, - name: 'field2', - selected: true, - type: 'string', - }, - { - color: 'black', - icon: { class: 'c', code: '', label: '' }, - name: 'field3', - selected: false, - type: 'string', - }, - ], selectedFields: [ { color: 'black', @@ -61,8 +51,10 @@ describe('serialize', () => { }, ], selectedIndex: { + type: 'indexpattern', + id: '123', title: 'Testindexpattern', - } as IndexPattern, + }, urlTemplates: [ { description: 'Template', diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts index 21583781cb14b..3a94136d26e7c 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts @@ -5,7 +5,6 @@ */ import { - AppState, SerializedNode, WorkspaceNode, WorkspaceEdge, @@ -15,7 +14,10 @@ import { WorkspaceField, GraphWorkspaceSavedObject, SerializedWorkspaceState, + Workspace, + AdvancedSettings, } from '../../types'; +import { IndexpatternDatasource } from '../../state_management'; function serializeNode( { data, scaledSize, parent, x, y, label, color }: WorkspaceNode, @@ -80,7 +82,19 @@ function serializeField({ export function appStateToSavedWorkspace( currentSavedWorkspace: GraphWorkspaceSavedObject, - { workspace, urlTemplates, advancedSettings, selectedIndex, selectedFields }: AppState, + { + workspace, + urlTemplates, + advancedSettings, + selectedIndex, + selectedFields, + }: { + workspace: Workspace; + urlTemplates: UrlTemplate[]; + advancedSettings: AdvancedSettings; + selectedIndex: IndexpatternDatasource; + selectedFields: WorkspaceField[]; + }, canSaveData: boolean ) { const blacklist: SerializedNode[] = canSaveData diff --git a/x-pack/legacy/plugins/graph/public/services/save_modal.tsx b/x-pack/legacy/plugins/graph/public/services/save_modal.tsx index bb453bd95df5e..5930d2283b7c0 100644 --- a/x-pack/legacy/plugins/graph/public/services/save_modal.tsx +++ b/x-pack/legacy/plugins/graph/public/services/save_modal.tsx @@ -9,6 +9,15 @@ import { SaveResult } from 'ui/saved_objects/show_saved_object_save_modal'; import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types'; import { SaveModal, OnSaveGraphProps } from '../components/save_modal'; +export type SaveWorkspaceHandler = ( + saveOptions: { + confirmOverwrite: boolean; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: () => void; + }, + dataConsent: boolean +) => Promise; + export function openSaveModal({ savePolicy, hasData, @@ -19,14 +28,7 @@ export function openSaveModal({ savePolicy: GraphSavePolicy; hasData: boolean; workspace: GraphWorkspaceSavedObject; - saveWorkspace: ( - saveOptions: { - confirmOverwrite: boolean; - isTitleDuplicateConfirmed: boolean; - onTitleDuplicate: () => void; - }, - dataConsent: boolean - ) => Promise; + saveWorkspace: SaveWorkspaceHandler; showSaveModal: (el: React.ReactNode) => void; }) { const currentTitle = workspace.title; diff --git a/x-pack/legacy/plugins/graph/public/services/url.ts b/x-pack/legacy/plugins/graph/public/services/url.ts index 97a30e26c25f3..ff14faf3e350c 100644 --- a/x-pack/legacy/plugins/graph/public/services/url.ts +++ b/x-pack/legacy/plugins/graph/public/services/url.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { Chrome } from 'ui/chrome'; import { GraphWorkspaceSavedObject } from '../types'; +import { MetaDataState } from '../state_management'; export function getHomePath() { return '/home'; @@ -30,12 +31,12 @@ export type SetBreadcrumbOptions = } | { chrome: Chrome; - savedWorkspace?: GraphWorkspaceSavedObject; + metaData: MetaDataState; navigateTo: (path: string) => void; }; export function setBreadcrumbs(options: SetBreadcrumbOptions) { - if ('savedWorkspace' in options) { + if ('metaData' in options) { options.chrome.breadcrumbs.set([ { text: i18n.translate('xpack.graph.home.breadcrumb', { @@ -47,11 +48,7 @@ export function setBreadcrumbs(options: SetBreadcrumbOptions) { 'data-test-subj': 'graphHomeBreadcrumb', }, { - text: options.savedWorkspace - ? options.savedWorkspace.title - : i18n.translate('xpack.graph.newWorkspaceTitle', { - defaultMessage: 'Unsaved workspace', - }), + text: options.metaData.title, 'data-test-subj': 'graphCurrentWorkspaceBreadcrumb', }, ]); diff --git a/x-pack/legacy/plugins/graph/public/state_management/advanced_settings.ts b/x-pack/legacy/plugins/graph/public/state_management/advanced_settings.ts new file mode 100644 index 0000000000000..44950f8a45f85 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/advanced_settings.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import actionCreatorFactory, { Action } from 'typescript-fsa'; +import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; +import { takeLatest } from 'redux-saga/effects'; +import { GraphState, GraphStoreDependencies } from './store'; +import { AdvancedSettings } from '../types'; +import { reset } from './global'; + +const actionCreator = actionCreatorFactory('x-pack/graph/advancedSettings'); + +export type AdvancedSettingsState = AdvancedSettings; + +export const updateSettings = actionCreator('UPDATE_SETTINGS'); + +const initialSettings: AdvancedSettingsState = { + useSignificance: true, + sampleSize: 2000, + timeoutMillis: 5000, + sampleDiversityField: undefined, + maxValuesPerDoc: 1, + minDocCount: 3, +}; + +export const advancedSettingsReducer = reducerWithInitialState(initialSettings) + .case(reset, () => initialSettings) + .case(updateSettings, (_oldSettings, newSettings) => newSettings) + .build(); + +export const settingsSelector = (state: GraphState) => state.advancedSettings; + +/** + * Saga making sure the advanced settings are always synced up to the workspace instance. + * + * Won't be necessary once the workspace is moved to redux + */ +export const syncSettingsSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => { + function* syncSettings(action: Action): IterableIterator { + const workspace = getWorkspace(); + if (!workspace) { + return; + } + workspace.options.exploreControls = action.payload; + notifyAngular(); + } + + return function*() { + yield takeLatest(updateSettings.match, syncSettings); + }; +}; diff --git a/x-pack/legacy/plugins/graph/public/state_management/datasource.ts b/x-pack/legacy/plugins/graph/public/state_management/datasource.ts new file mode 100644 index 0000000000000..801dd315df30e --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/datasource.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import actionCreatorFactory, { Action } from 'typescript-fsa'; +import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; +import { takeLatest, put, call, select } from 'redux-saga/effects'; +import { i18n } from '@kbn/i18n'; +import { IndexPattern } from 'src/legacy/core_plugins/data/public'; +import { createSelector } from 'reselect'; +import { GraphState, GraphStoreDependencies } from './store'; +import { reset } from './global'; +import { loadFields } from './fields'; +import { mapFields } from '../services/persistence'; +import { settingsSelector } from './advanced_settings'; + +const actionCreator = actionCreatorFactory('x-pack/graph/datasource'); + +export interface NoDatasource { + type: 'none'; +} +export interface IndexpatternDatasource { + type: 'indexpattern'; + id: string; + title: string; +} + +export interface DatasourceState { + current: NoDatasource | IndexpatternDatasource; + loading: boolean; +} + +/** + * Sets the current datasource. This will not trigger a load of fields + */ +export const setDatasource = actionCreator('SET_DATASOURCE'); + +/** + * Sets the current datasource. This will trigger a load of fields and overwrite the current + * fields configuration + */ +export const requestDatasource = actionCreator('SET_DATASOURCE_REQUEST'); + +/** + * Datasource loading finished successfully. + */ +export const datasourceLoaded = actionCreator('SET_DATASOURCE_SUCCESS'); + +const initialDatasource: DatasourceState = { + current: { type: 'none' }, + loading: false, +}; + +export const datasourceReducer = reducerWithInitialState(initialDatasource) + .case(reset, () => initialDatasource) + .case(setDatasource, (_oldDatasource, newDatasource) => ({ + current: newDatasource, + loading: false, + })) + .case(requestDatasource, (_oldDatasource, newDatasource) => ({ + current: newDatasource, + loading: true, + })) + .case(datasourceLoaded, datasource => ({ + ...datasource, + loading: false, + })) + .build(); + +export const datasourceSelector = (state: GraphState) => state.datasource; +export const hasDatasourceSelector = createSelector( + datasourceSelector, + datasource => datasource.current.type !== 'none' +); + +/** + * Saga loading field information when the datasource is switched. This will overwrite current settings + * in fields. + * + * TODO: Carry over fields than can be carried over because they also exist in the target index pattern + */ +export const datasourceSaga = ({ + indexPatternProvider, + notifications, + createWorkspace, +}: GraphStoreDependencies) => { + function* fetchFields(action: Action) { + try { + const indexPattern: IndexPattern = yield call(indexPatternProvider.get, action.payload.id); + yield put(loadFields(mapFields(indexPattern))); + yield put(datasourceLoaded()); + const advancedSettings = settingsSelector(yield select()); + createWorkspace(indexPattern.title, advancedSettings); + } catch (e) { + // in case of errors, reset the datasource and show notification + yield put(setDatasource({ type: 'none' })); + notifications.toasts.addDanger( + i18n.translate('xpack.graph.loadWorkspace.missingIndexPatternErrorMessage', { + defaultMessage: 'Index pattern not found', + }) + ); + } + } + + return function*() { + yield takeLatest(requestDatasource.match, fetchFields); + }; +}; diff --git a/x-pack/legacy/plugins/graph/public/state_management/fields.ts b/x-pack/legacy/plugins/graph/public/state_management/fields.ts index 4708d17734ad0..62bb62c1f6125 100644 --- a/x-pack/legacy/plugins/graph/public/state_management/fields.ts +++ b/x-pack/legacy/plugins/graph/public/state_management/fields.ts @@ -4,11 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import actionCreatorFactory from 'typescript-fsa'; +import actionCreatorFactory, { Action } from 'typescript-fsa'; import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; import { createSelector } from 'reselect'; +import { select, takeLatest, takeEvery } from 'redux-saga/effects'; import { WorkspaceField } from '../types'; -import { GraphState } from './store'; +import { GraphState, GraphStoreDependencies } from './store'; +import { reset } from './global'; +import { setDatasource } from './datasource'; +import { matchesOne, InferActionType } from './helpers'; const actionCreator = actionCreatorFactory('x-pack/graph/fields'); @@ -25,6 +29,8 @@ export type FieldsState = Record; const initialFields: FieldsState = {}; export const fieldsReducer = reducerWithInitialState(initialFields) + .case(reset, () => initialFields) + .case(setDatasource, () => initialFields) .case(loadFields, (_currentFields, newFields) => { const newFieldMap: Record = {}; newFields.forEach(field => { @@ -57,3 +63,86 @@ export const liveResponseFieldsSelector = createSelector( selectedFieldsSelector, fields => fields.filter(field => field.hopSize && field.hopSize > 0) ); +export const hasFieldsSelector = createSelector( + selectedFieldsSelector, + fields => fields.length > 0 +); + +/** + * Saga making notifying angular when fields are selected to re-calculate the state of the save button. + * + * Won't be necessary once the workspace is moved to redux + */ +export const updateSaveButtonSaga = ({ notifyAngular }: GraphStoreDependencies) => { + function* notify(): IterableIterator { + notifyAngular(); + } + return function*() { + yield takeLatest(matchesOne(selectField, deselectField), notify); + }; +}; + +/** + * Saga making sure the fields in the store are always synced with the fields + * known to the workspace. + * + * Won't be necessary once the workspace is moved to redux + */ +export const syncFieldsSaga = ({ getWorkspace, setLiveResponseFields }: GraphStoreDependencies) => { + function* syncFields() { + const workspace = getWorkspace(); + if (!workspace) { + return; + } + + const currentState = yield select(); + workspace.options.vertex_fields = selectedFieldsSelector(currentState); + setLiveResponseFields(liveResponseFieldsSelector(currentState)); + } + return function*() { + yield takeEvery( + matchesOne(loadFields, selectField, deselectField, updateFieldProperties), + syncFields + ); + }; +}; + +/** + * Saga making sure the field styles (icons and colors) are applied to nodes currently active + * in the workspace. + * + * Won't be necessary once the workspace is moved to redux + */ +export const syncNodeStyleSaga = ({ getWorkspace, notifyAngular }: GraphStoreDependencies) => { + function* syncNodeStyle(action: Action>) { + const workspace = getWorkspace(); + if (!workspace) { + return; + } + const newColor = action.payload.fieldProperties.color; + if (newColor) { + workspace.nodes.forEach(function(node) { + if (node.data.field === action.payload.fieldName) { + node.color = newColor; + } + }); + } + const newIcon = action.payload.fieldProperties.icon; + + if (newIcon) { + workspace.nodes.forEach(function(node) { + if (node.data.field === action.payload.fieldName) { + node.icon = newIcon; + } + }); + } + notifyAngular(); + + const selectedFields = selectedFieldsSelector(yield select()); + workspace.options.vertex_fields = selectedFields; + } + + return function*() { + yield takeLatest(updateFieldProperties.match, syncNodeStyle); + }; +}; diff --git a/x-pack/legacy/plugins/graph/public/state_management/global.ts b/x-pack/legacy/plugins/graph/public/state_management/global.ts new file mode 100644 index 0000000000000..cfd1493a7e353 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/global.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import actionCreatorFactory from 'typescript-fsa'; +const actionCreator = actionCreatorFactory('x-pack/graph'); + +export const reset = actionCreator('RESET'); diff --git a/x-pack/legacy/plugins/graph/public/state_management/helpers.ts b/x-pack/legacy/plugins/graph/public/state_management/helpers.ts new file mode 100644 index 0000000000000..215691d454484 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionCreator, AnyAction } from 'typescript-fsa'; + +/** + * Infers the type of an action out of a given action type. + * This makes it easier to distribute the action types because they come + * along with the creators: `type MyAction = InferActionType`. + * + * This isn't expected to be used in a lot of places - if it is, naming the individual + * action types might make more sense. + */ +export type InferActionType = X extends ActionCreator ? T : never; + +/** + * Helper to create a matcher that matches all passed in action creators. + * + * This is helpful to create a saga that takes multiple actions: + * `yield takeEvery(matchesOne(actionCreator1, actionCreator2), handler);` + * + * @param actionCreators The action creators to create a unified matcher for + */ +export const matchesOne = (...actionCreators: Array>) => (action: AnyAction) => + actionCreators.some(actionCreator => actionCreator.match(action)); diff --git a/x-pack/legacy/plugins/graph/public/state_management/index.ts b/x-pack/legacy/plugins/graph/public/state_management/index.ts index 546af1673ea92..e577fed63a504 100644 --- a/x-pack/legacy/plugins/graph/public/state_management/index.ts +++ b/x-pack/legacy/plugins/graph/public/state_management/index.ts @@ -5,4 +5,11 @@ */ export * from './fields'; +export * from './url_templates'; +export * from './advanced_settings'; +export * from './datasource'; +export * from './meta_data'; +export * from './persistence'; + +export * from './global'; export * from './store'; diff --git a/x-pack/legacy/plugins/graph/public/state_management/meta_data.ts b/x-pack/legacy/plugins/graph/public/state_management/meta_data.ts new file mode 100644 index 0000000000000..7d4ad4814081d --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/meta_data.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import actionCreatorFactory from 'typescript-fsa'; +import { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { select, takeLatest, call } from 'redux-saga/effects'; +import { i18n } from '@kbn/i18n'; +import { GraphState, GraphStoreDependencies } from './store'; +import { reset } from './global'; +import { setBreadcrumbs } from '../services/url'; + +const actionCreator = actionCreatorFactory('x-pack/graph/metaData'); + +export interface MetaDataState { + title: string; + description: string; + savedObjectId?: string; +} + +export const updateMetaData = actionCreator>('UPDATE_META_DATA'); + +const initialMetaData: MetaDataState = { + title: i18n.translate('xpack.graph.newWorkspaceTitle', { + defaultMessage: 'Unsaved workspace', + }), + description: '', +}; + +export const metaDataReducer = reducerWithInitialState(initialMetaData) + .case(reset, () => initialMetaData) + .case(updateMetaData, (oldMetaData, newMetaData) => ({ ...oldMetaData, ...newMetaData })) + .build(); + +export const metaDataSelector = (state: GraphState) => state.metaData; + +/** + * Saga updating the breadcrumb when the shown workspace changes. + */ +export const syncBreadcrumbSaga = ({ chrome, changeUrl }: GraphStoreDependencies) => { + function* syncBreadcrumb() { + const metaData = metaDataSelector(yield select()); + setBreadcrumbs({ + chrome, + metaData, + navigateTo: (path: string) => { + // TODO this should be wrapped into canWipeWorkspace, + // but the check is too simple right now. Change this + // once actual state-diffing is in place. + changeUrl(path); + }, + }); + } + return function*() { + // initial sync + yield call(syncBreadcrumb); + yield takeLatest(updateMetaData.match, syncBreadcrumb); + }; +}; diff --git a/x-pack/legacy/plugins/graph/public/state_management/mocks.ts b/x-pack/legacy/plugins/graph/public/state_management/mocks.ts new file mode 100644 index 0000000000000..9d22fd521a96f --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/mocks.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Chrome } from 'ui/chrome'; +import { IndexPattern } from 'src/legacy/core_plugins/data/public'; +import { NotificationsStart } from 'kibana/public'; +import createSagaMiddleware from 'redux-saga'; +import { createStore, applyMiddleware } from 'redux'; +import { GraphStoreDependencies, createRootReducer, registerSagas } from './store'; +import { Workspace, GraphWorkspaceSavedObject, IndexPatternSavedObject } from '../types'; + +jest.mock('ui/new_platform'); + +export function createMockGraphStore({ includeSagas }: { includeSagas: boolean }) { + const mockedDeps: jest.Mocked = { + basePath: '', + changeUrl: jest.fn(), + chrome: ({ + breadcrumbs: { + set: jest.fn(), + }, + } as unknown) as Chrome, + createWorkspace: jest.fn(), + getWorkspace: jest.fn(() => (({} as unknown) as Workspace)), + getSavedWorkspace: jest.fn(() => (({} as unknown) as GraphWorkspaceSavedObject)), + indexPatternProvider: { + get: jest.fn(() => Promise.resolve(({} as unknown) as IndexPattern)), + }, + indexPatterns: [ + ({ id: '123', attributes: { title: 'test-pattern' } } as unknown) as IndexPatternSavedObject, + ], + notifications: ({ + toasts: { + addDanger: jest.fn(), + addSuccess: jest.fn(), + }, + } as unknown) as NotificationsStart, + notifyAngular: jest.fn(), + savePolicy: 'configAndDataWithConsent', + showSaveModal: jest.fn(), + setLiveResponseFields: jest.fn(), + }; + const sagaMiddleware = createSagaMiddleware(); + + const rootReducer = createRootReducer(''); + + const store = createStore(rootReducer, applyMiddleware(sagaMiddleware)); + + if (includeSagas) { + registerSagas(sagaMiddleware, mockedDeps); + } + + return { store, mockedDeps }; +} diff --git a/x-pack/legacy/plugins/graph/public/state_management/persistence.ts b/x-pack/legacy/plugins/graph/public/state_management/persistence.ts new file mode 100644 index 0000000000000..0b8130a766cd3 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/persistence.ts @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +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 { setDatasource, datasourceSelector, IndexpatternDatasource } from './datasource'; +import { loadFields, selectedFieldsSelector } from './fields'; +import { updateSettings, settingsSelector } from './advanced_settings'; +import { loadTemplates, templatesSelector } from './url_templates'; +import { + lookupIndexPattern, + savedWorkspaceToAppState, + appStateToSavedWorkspace, +} from '../services/persistence'; +import { updateMetaData, metaDataSelector } from './meta_data'; +import { openSaveModal, SaveWorkspaceHandler } from '../services/save_modal'; +import { getEditPath } from '../services/url'; +const actionCreator = actionCreatorFactory('x-pack/graph'); + +export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE'); +export const saveWorkspace = actionCreator('SAVE_WORKSPACE'); + +/** + * Saga handling loading of a saved workspace. + * + * It will load the index pattern associated with the saved object and deserialize all properties + * into the store. Existing state will be overwritten. + */ +export const loadingSaga = ({ + createWorkspace, + getWorkspace, + indexPatterns, + notifications, + indexPatternProvider, +}: GraphStoreDependencies) => { + function* deserializeWorkspace(action: Action) { + const selectedIndex = lookupIndexPattern(action.payload, indexPatterns); + if (!selectedIndex) { + notifications.toasts.addDanger( + i18n.translate('xpack.graph.loadWorkspace.missingIndexPatternErrorMessage', { + defaultMessage: 'Index pattern not found', + }) + ); + return; + } + + const indexPattern = yield call(indexPatternProvider.get, selectedIndex.id); + const initialSettings = settingsSelector(yield select()); + + createWorkspace(selectedIndex.attributes.title, initialSettings); + + const { urlTemplates, advancedSettings, allFields } = savedWorkspaceToAppState( + action.payload, + indexPattern, + // workspace won't be null because it's created in the same call stack + getWorkspace()! + ); + + // put everything in the store + yield put( + updateMetaData({ + title: action.payload.title, + description: action.payload.description, + savedObjectId: action.payload.id, + }) + ); + yield put( + setDatasource({ + type: 'indexpattern', + id: selectedIndex.id, + title: selectedIndex.attributes.title, + }) + ); + yield put(loadFields(allFields)); + yield put(updateSettings(advancedSettings)); + yield put(loadTemplates(urlTemplates)); + + // workspace won't be null because it's created in the same call stack + getWorkspace()!.runLayout(); + } + + return function*() { + yield takeLatest(loadSavedWorkspace.match, deserializeWorkspace); + }; +}; + +/** + * Saga handling saving of current state. + * + * It will serialize everything and save it using the saved objects client + */ +export const savingSaga = (deps: GraphStoreDependencies) => { + function* persistWorkspace() { + const savedWorkspace = deps.getSavedWorkspace(); + const state: GraphState = yield select(); + const workspace = deps.getWorkspace(); + const selectedDatasource = datasourceSelector(state).current; + if (!workspace || selectedDatasource.type === 'none') { + return; + } + + const savedObjectId = yield cps(showModal, { + deps, + workspace, + savedWorkspace, + state, + selectedDatasource, + }); + if (savedObjectId) { + yield put(updateMetaData({ savedObjectId })); + } + } + + return function*() { + yield takeLatest(saveWorkspace.match, persistWorkspace); + }; +}; + +function showModal( + { + deps, + workspace, + savedWorkspace, + state, + selectedDatasource, + }: { + deps: GraphStoreDependencies; + workspace: Workspace; + savedWorkspace: GraphWorkspaceSavedObject; + state: GraphState; + selectedDatasource: IndexpatternDatasource; + }, + savingCallback: (error: unknown, id?: string) => void +) { + const saveWorkspaceHandler: SaveWorkspaceHandler = async ( + saveOptions, + userHasConfirmedSaveWorkspaceData + ) => { + const canSaveData = + deps.savePolicy === 'configAndData' || + (deps.savePolicy === 'configAndDataWithConsent' && userHasConfirmedSaveWorkspaceData); + appStateToSavedWorkspace( + savedWorkspace, + { + workspace, + urlTemplates: templatesSelector(state), + advancedSettings: settingsSelector(state), + selectedIndex: selectedDatasource, + selectedFields: selectedFieldsSelector(state), + }, + canSaveData + ); + try { + const id = await savedWorkspace.save(saveOptions); + if (id) { + const title = i18n.translate('xpack.graph.saveWorkspace.successNotificationTitle', { + defaultMessage: 'Saved "{workspaceTitle}"', + values: { workspaceTitle: savedWorkspace.title }, + }); + let text; + if (!canSaveData && workspace.nodes.length > 0) { + text = i18n.translate('xpack.graph.saveWorkspace.successNotification.noDataSavedText', { + defaultMessage: 'The configuration was saved, but the data was not saved', + }); + } + deps.notifications.toasts.addSuccess({ + title, + text, + 'data-test-subj': 'saveGraphSuccess', + }); + if (savedWorkspace.id !== metaDataSelector(state).savedObjectId) { + deps.changeUrl(getEditPath(savedWorkspace)); + } + } + savingCallback(null, id); + return { id }; + } catch (error) { + deps.notifications.toasts.addDanger( + i18n.translate('xpack.graph.saveWorkspace.savingErrorMessage', { + defaultMessage: 'Failed to save workspace: {message}', + values: { + message: error, + }, + }) + ); + return { error }; + } + }; + + openSaveModal({ + savePolicy: deps.savePolicy, + hasData: workspace.nodes.length > 0 || workspace.blacklistedNodes.length > 0, + workspace: savedWorkspace, + showSaveModal: deps.showSaveModal, + saveWorkspace: saveWorkspaceHandler, + }); +} diff --git a/x-pack/legacy/plugins/graph/public/state_management/store.ts b/x-pack/legacy/plugins/graph/public/state_management/store.ts index f5317e9c5b6ab..4161a3c48593e 100644 --- a/x-pack/legacy/plugins/graph/public/state_management/store.ts +++ b/x-pack/legacy/plugins/graph/public/state_management/store.ts @@ -4,16 +4,95 @@ * you may not use this file except in compliance with the Elastic License. */ -import { combineReducers, createStore, Store, AnyAction, Dispatch } from 'redux'; -import { fieldsReducer, FieldsState } from './fields'; +import createSagaMiddleware, { SagaMiddleware } from 'redux-saga'; +import { combineReducers, createStore, Store, AnyAction, Dispatch, applyMiddleware } from 'redux'; +import { CoreStart } from 'src/core/public'; +import { Chrome } from 'ui/chrome'; +import { + fieldsReducer, + FieldsState, + syncNodeStyleSaga, + syncFieldsSaga, + updateSaveButtonSaga, +} from './fields'; +import { UrlTemplatesState, urlTemplatesReducer } from './url_templates'; +import { + AdvancedSettingsState, + advancedSettingsReducer, + syncSettingsSaga, +} from './advanced_settings'; +import { DatasourceState, datasourceReducer, datasourceSaga } from './datasource'; +import { + IndexPatternProvider, + Workspace, + IndexPatternSavedObject, + GraphSavePolicy, + GraphWorkspaceSavedObject, + AdvancedSettings, + WorkspaceField, +} from '../types'; +import { loadingSaga, savingSaga } from './persistence'; +import { metaDataReducer, MetaDataState, syncBreadcrumbSaga } from './meta_data'; export interface GraphState { fields: FieldsState; + urlTemplates: UrlTemplatesState; + advancedSettings: AdvancedSettingsState; + datasource: DatasourceState; + metaData: MetaDataState; } -const rootReducer = combineReducers({ fields: fieldsReducer }); +export interface GraphStoreDependencies { + basePath: string; + indexPatternProvider: IndexPatternProvider; + indexPatterns: IndexPatternSavedObject[]; + createWorkspace: (index: string, advancedSettings: AdvancedSettings) => void; + getWorkspace: () => Workspace | null; + getSavedWorkspace: () => GraphWorkspaceSavedObject; + notifications: CoreStart['notifications']; + showSaveModal: (el: React.ReactNode) => void; + savePolicy: GraphSavePolicy; + changeUrl: (newUrl: string) => void; + notifyAngular: () => void; + setLiveResponseFields: (fields: WorkspaceField[]) => void; + chrome: Chrome; +} + +export function createRootReducer(basePath: string) { + return combineReducers({ + fields: fieldsReducer, + urlTemplates: urlTemplatesReducer(basePath), + advancedSettings: advancedSettingsReducer, + datasource: datasourceReducer, + metaData: metaDataReducer, + }); +} + +export function registerSagas( + sagaMiddleware: SagaMiddleware, + deps: GraphStoreDependencies +) { + sagaMiddleware.run(datasourceSaga(deps)); + sagaMiddleware.run(loadingSaga(deps)); + sagaMiddleware.run(savingSaga(deps)); + sagaMiddleware.run(syncFieldsSaga(deps)); + sagaMiddleware.run(syncNodeStyleSaga(deps)); + sagaMiddleware.run(syncSettingsSaga(deps)); + sagaMiddleware.run(updateSaveButtonSaga(deps)); + sagaMiddleware.run(syncBreadcrumbSaga(deps)); +} + +export const createGraphStore = (deps: GraphStoreDependencies) => { + const sagaMiddleware = createSagaMiddleware(); + + const rootReducer = createRootReducer(deps.basePath); + + const store = createStore(rootReducer, applyMiddleware(sagaMiddleware)); + + registerSagas(sagaMiddleware, deps); -export const createGraphStore = () => createStore(rootReducer); + return store; +}; export type GraphStore = Store; export type GraphDispatch = Dispatch; diff --git a/x-pack/legacy/plugins/graph/public/state_management/url_templates.ts b/x-pack/legacy/plugins/graph/public/state_management/url_templates.ts new file mode 100644 index 0000000000000..3bb97c343ba34 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/state_management/url_templates.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import actionCreatorFactory from 'typescript-fsa'; +import { reducerWithInitialState } from 'typescript-fsa-reducers/dist'; +import { KibanaParsedUrl } from 'ui/url/kibana_parsed_url'; +import { i18n } from '@kbn/i18n'; +import rison from 'rison-node'; +import { GraphState } from './store'; +import { UrlTemplate } from '../types'; +import { reset } from './global'; +import { setDatasource, IndexpatternDatasource, requestDatasource } from './datasource'; +import { outlinkEncoders } from '../helpers/outlink_encoders'; +import { urlTemplatePlaceholder } from '../helpers/url_template'; + +const actionCreator = actionCreatorFactory('x-pack/graph/urlTemplates'); + +export const loadTemplates = actionCreator('LOAD_TEMPLATES'); +export const saveTemplate = actionCreator<{ index: number; template: UrlTemplate }>( + 'SAVE_TEMPLATE' +); +export const removeTemplate = actionCreator('REMOVE_TEMPLATE'); + +export type UrlTemplatesState = UrlTemplate[]; + +const initialTemplates: UrlTemplatesState = []; + +function generateDefaultTemplate( + datasource: IndexpatternDatasource, + basePath: string +): UrlTemplate { + const kUrl = new KibanaParsedUrl({ + appId: 'kibana', + basePath, + appPath: '/discover', + }); + + kUrl.addQueryParameter( + '_a', + rison.encode({ + columns: ['_source'], + index: datasource.title, + interval: 'auto', + query: { language: 'kuery', query: urlTemplatePlaceholder }, + sort: ['_score', 'desc'], + }) + ); + + // replace the URI encoded version of the tag with the unescaped version + // so it can be found with String.replace, regexp, etc. + const discoverUrl = kUrl + .getRootRelativePath() + .replace(encodeURIComponent(urlTemplatePlaceholder), urlTemplatePlaceholder); + + return { + url: discoverUrl, + description: i18n.translate('xpack.graph.settings.drillDowns.defaultUrlTemplateTitle', { + defaultMessage: 'Raw documents', + }), + encoder: outlinkEncoders[0], + isDefault: true, + icon: null, + }; +} + +export const urlTemplatesReducer = (basePath: string) => + reducerWithInitialState(initialTemplates) + .case(reset, () => initialTemplates) + .cases([requestDatasource, setDatasource], (templates, datasource) => { + if (datasource.type === 'none') { + return initialTemplates; + } + const customTemplates = templates.filter(template => !template.isDefault); + return [...customTemplates, generateDefaultTemplate(datasource, basePath)]; + }) + .case(loadTemplates, (_currentTemplates, newTemplates) => newTemplates) + .case(saveTemplate, (templates, { index: indexToUpdate, template: updatedTemplate }) => { + // set default flag to false as soon as template is overwritten. + const newTemplate = { ...updatedTemplate, isDefault: false }; + return indexToUpdate === -1 + ? [...templates, newTemplate] + : templates.map((template, index) => (index === indexToUpdate ? newTemplate : template)); + }) + .case(removeTemplate, (templates, templateToDelete) => + templates.filter(template => template !== templateToDelete) + ) + .build(); + +export const templatesSelector = (state: GraphState) => state.urlTemplates; diff --git a/x-pack/legacy/plugins/graph/public/types/app_state.ts b/x-pack/legacy/plugins/graph/public/types/app_state.ts index f72c4057f4c2f..24b1876bb713f 100644 --- a/x-pack/legacy/plugins/graph/public/types/app_state.ts +++ b/x-pack/legacy/plugins/graph/public/types/app_state.ts @@ -6,7 +6,6 @@ import { SimpleSavedObject } from 'src/core/public'; import { IndexPattern } from 'src/legacy/core_plugins/data/public'; -import { Workspace } from './workspace_state'; import { FontawesomeIcon } from '../helpers/style_choices'; import { OutlinkEncoder } from '../helpers/outlink_encoders'; @@ -39,11 +38,6 @@ export interface AdvancedSettings { export type IndexPatternSavedObject = SimpleSavedObject<{ title: string }>; -export interface AppState { - urlTemplates: UrlTemplate[]; - advancedSettings: AdvancedSettings; - workspace: Workspace; - allFields: WorkspaceField[]; - selectedFields: WorkspaceField[]; - selectedIndex: IndexPattern; +export interface IndexPatternProvider { + get(id: string): Promise; } diff --git a/x-pack/legacy/plugins/graph/public/types/workspace_state.ts b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts index fab093535cb63..c23ab49de496d 100644 --- a/x-pack/legacy/plugins/graph/public/types/workspace_state.ts +++ b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts @@ -69,6 +69,7 @@ export interface GraphData { } export interface Workspace { + options: WorkspaceOptions; nodesMap: Record; nodes: WorkspaceNode[]; edges: WorkspaceEdge[]; @@ -89,6 +90,9 @@ export interface Workspace { * @param newData */ mergeGraph(newData: GraphData): void; + + runLayout(): void; + stopLayout(): void; } export type ExploreRequest = any; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 676cf78de9ff9..b7a6b24242924 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4517,7 +4517,6 @@ "xpack.graph.savedWorkspace.workspaceNameTitle": "新規グラフワークスペース", "xpack.graph.savedWorkspaces.graphWorkspaceLabel": "グラフワークスペース", "xpack.graph.savedWorkspaces.graphWorkspacesLabel": "グラフワークスペース", - "xpack.graph.saveWorkspace.disabledWarning": "保存が無効になっています", "xpack.graph.saveWorkspace.successNotification.noDataSavedText": "構成が保存されましたが、データは保存されませんでした", "xpack.graph.saveWorkspace.successNotificationTitle": "「{workspaceTitle}」が保存されました", "xpack.graph.serverSideErrors.expiredLicenseErrorMessage": "グラフを利用できません。ライセンスが期限切れです。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c2054497c96f6..79cba3ee696be 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4520,7 +4520,6 @@ "xpack.graph.savedWorkspace.workspaceNameTitle": "新建 Graph 工作空间", "xpack.graph.savedWorkspaces.graphWorkspaceLabel": "Graph 工作空间", "xpack.graph.savedWorkspaces.graphWorkspacesLabel": "Graph 工作空间", - "xpack.graph.saveWorkspace.disabledWarning": "已禁用保存", "xpack.graph.saveWorkspace.successNotification.noDataSavedText": "配置会被保存,但不保存数据", "xpack.graph.saveWorkspace.successNotificationTitle": "已保存“{workspaceTitle}”", "xpack.graph.serverSideErrors.expiredLicenseErrorMessage": "Graph 不可用 - 许可已过期。", From ba8e8fb1bea7c958eebae200362b97865b226f5b Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Fri, 4 Oct 2019 11:00:26 +0100 Subject: [PATCH 3/4] [SIEM] Chart enhancement (#47130) * chart styling * rename variable * styling for bar chart * add unit test * clean up * fix for code review --- .../components/charts/areachart.test.tsx | 293 +++++++++--------- .../public/components/charts/areachart.tsx | 61 ++-- .../components/charts/barchart.test.tsx | 262 +++++++--------- .../public/components/charts/barchart.tsx | 63 ++-- .../charts/chart_place_holder.test.tsx | 92 ++++++ .../components/charts/chart_place_holder.tsx | 40 +++ .../public/components/charts/common.test.tsx | 83 +++-- .../siem/public/components/charts/common.tsx | 51 +-- .../public/components/charts/translation.ts | 15 + .../components/matrix_over_time/index.tsx | 2 +- .../page/network/kpi_network/mock.ts | 11 +- .../__snapshots__/index.test.tsx.snap | 3 + .../public/components/stat_items/index.tsx | 2 + 13 files changed, 545 insertions(+), 433 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/charts/translation.ts diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx index 7338a959495f8..910e576e6e1e7 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.test.tsx @@ -7,13 +7,135 @@ import { ShallowWrapper, shallow } from 'enzyme'; import * as React from 'react'; -import { AreaChartBaseComponent, AreaChartWithCustomPrompt, AreaChart } from './areachart'; -import { ChartHolder, ChartSeriesData } from './common'; +import { AreaChartBaseComponent, AreaChart } from './areachart'; +import { ChartSeriesData } from './common'; import { ScaleType, AreaSeries, Axis } from '@elastic/charts'; -jest.mock('@elastic/charts'); const customHeight = '100px'; const customWidth = '120px'; +const chartDataSets = [ + [ + [ + { + key: 'uniqueSourceIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 580213 }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: null }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12382 }, + ], + color: '#DB1374', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12280 }, + ], + color: '#490092', + }, + ], + ], + [ + [ + { + key: 'uniqueSourceIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 580213 }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1096175 }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12382 }, + ], + color: '#DB1374', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12280 }, + ], + color: '#490092', + }, + ], + ], + [ + [ + { + key: 'uniqueSourceIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 580213 }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: {} }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12382 }, + ], + color: '#DB1374', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12280 }, + ], + color: '#490092', + }, + ], + ], + [ + [ + { + key: 'uniqueSourceIpsHistogram', + value: [], + color: '#DB1374', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12280 }, + ], + color: '#490092', + }, + ], + ], +]; + +const chartHolderDataSets = [ + [null], + [[]], + [ + { + key: 'uniqueSourceIpsHistogram', + value: null, + color: '#DB1374', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: null, + color: '#490092', + }, + ], + [ + { + key: 'uniqueSourceIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf() }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf() }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf() }, + ], + color: '#DB1374', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').valueOf() }, + { x: new Date('2019-05-04T01:00:00.000Z').valueOf() }, + { x: new Date('2019-05-04T13:00:00.000Z').valueOf() }, + ], + color: '#490092', + }, + ], +]; describe('AreaChartBaseComponent', () => { let shallowWrapper: ShallowWrapper; const mockAreaChartData: ChartSeriesData[] = [ @@ -186,137 +308,6 @@ describe('AreaChartBaseComponent', () => { }); }); -describe('AreaChartWithCustomPrompt', () => { - let shallowWrapper: ShallowWrapper; - describe.each([ - [ - [ - { - key: 'uniqueSourceIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 580213 }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1096175 }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12382 }, - ], - color: '#DB1374', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12280 }, - ], - color: '#490092', - }, - ], - ], - ] as Array<[ChartSeriesData[]]>)('renders areachart', data => { - beforeAll(() => { - shallowWrapper = shallow( - - ); - }); - - it('render AreaChartBaseComponent', () => { - expect(shallowWrapper.find(AreaChartBaseComponent)).toHaveLength(1); - expect(shallowWrapper.find(ChartHolder)).toHaveLength(0); - }); - }); - - describe.each([ - null, - [], - [ - { - key: 'uniqueSourceIpsHistogram', - value: null, - color: '#DB1374', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: null, - color: '#490092', - }, - ], - [ - { - key: 'uniqueSourceIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf() }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf() }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf() }, - ], - color: '#DB1374', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf() }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf() }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf() }, - ], - color: '#490092', - }, - ], - [ - [ - { - key: 'uniqueSourceIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 580213 }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: null }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12382 }, - ], - color: '#DB1374', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12280 }, - ], - color: '#490092', - }, - ], - ], - [ - [ - { - key: 'uniqueSourceIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 580213 }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: {} }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12382 }, - ], - color: '#DB1374', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').valueOf(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').valueOf(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').valueOf(), y: 12280 }, - ], - color: '#490092', - }, - ], - ], - ] as Array<[ChartSeriesData[] | null | undefined]>)('renders prompt', data => { - beforeAll(() => { - shallowWrapper = shallow( - - ); - }); - - it('render Chart Holder', () => { - expect(shallowWrapper.find(AreaChartBaseComponent)).toHaveLength(0); - expect(shallowWrapper.find(ChartHolder)).toHaveLength(1); - }); - }); -}); - describe('AreaChart', () => { let shallowWrapper: ShallowWrapper; const mockConfig = { @@ -332,20 +323,28 @@ describe('AreaChart', () => { }, customHeight: 324, }; + describe.each(chartDataSets as Array<[ChartSeriesData[]]>)('with valid data [%o]', data => { + beforeAll(() => { + shallowWrapper = shallow(); + }); - it('should render if data exist', () => { - const mockData = [ - { key: 'uniqueSourceIps', value: [{ y: 100, x: 100, g: 'group' }], color: '#DB1374' }, - ]; - shallowWrapper = shallow(); - expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); - expect(shallowWrapper.find('ChartHolder')).toHaveLength(0); + it(`should render area chart`, () => { + expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0); + }); }); - it('should render a chartHolder if no data given', () => { - const mockData = [{ key: 'uniqueSourceIps', value: [], color: '#DB1374' }]; - shallowWrapper = shallow(); - expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); - expect(shallowWrapper.find('ChartHolder')).toHaveLength(1); - }); + describe.each(chartHolderDataSets as Array<[ChartSeriesData[] | null | undefined]>)( + 'with invalid data [%o]', + data => { + beforeAll(() => { + shallowWrapper = shallow(); + }); + + it(`should render a chart place holder`, () => { + expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); + expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(1); + }); + } + ); }); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx index c4bb01a66753b..6347b93772b4e 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/areachart.tsx @@ -17,19 +17,19 @@ import { AreaSeriesStyle, RecursivePartial, } from '@elastic/charts'; -import { getOr, get } from 'lodash/fp'; +import { getOr, get, isNull, isNumber } from 'lodash/fp'; +import { AutoSizer } from '../auto_sizer'; +import { ChartPlaceHolder } from './chart_place_holder'; import { - ChartSeriesData, - ChartHolder, - getSeriesStyle, - WrappedByAutoSizer, - ChartSeriesConfigs, browserTimezone, chartDefaultSettings, + ChartSeriesConfigs, + ChartSeriesData, getChartHeight, getChartWidth, + getSeriesStyle, + WrappedByAutoSizer, } from './common'; -import { AutoSizer } from '../auto_sizer'; // custom series styles: https://ela.st/areachart-styling const getSeriesLineStyle = (): RecursivePartial => { @@ -51,6 +51,17 @@ const getSeriesLineStyle = (): RecursivePartial => { }; }; +const checkIfAllTheDataInTheSeriesAreValid = (series: unknown): series is ChartSeriesData => + !!get('value.length', series) && + get('value', series).every( + ({ x, y }: { x: unknown; y: unknown }) => !isNull(x) && isNumber(y) && y > 0 + ); + +const checkIfAnyValidSeriesExist = ( + data: ChartSeriesData[] | null | undefined +): data is ChartSeriesData[] => + Array.isArray(data) && data.some(checkIfAllTheDataInTheSeriesAreValid); + // https://ela.st/multi-areaseries export const AreaChartBaseComponent = React.memo<{ data: ChartSeriesData[]; @@ -73,12 +84,12 @@ export const AreaChartBaseComponent = React.memo<{ {data.map(series => { const seriesKey = series.key; const seriesSpecId = getSpecId(seriesKey); - return series.value != null ? ( + return checkIfAllTheDataInTheSeriesAreValid(series) ? ( (({ data, height, width, configs }) => { - return data != null && - data.length && - data.every( - ({ value }) => - value != null && - value.length > 0 && - value.every(chart => chart.x != null && chart.y != null) - ) ? ( - - ) : ( - - ); -}); - -AreaChartWithCustomPrompt.displayName = 'AreaChartWithCustomPrompt'; - export const AreaChart = React.memo<{ areaChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; @@ -135,11 +124,11 @@ export const AreaChart = React.memo<{ const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); - return get(`0.value.length`, areaChart) ? ( + return checkIfAnyValidSeriesExist(areaChart) ? ( {({ measureRef, content: { height, width } }) => ( - ) : ( - + ); }); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx index 527556842126c..4b3ec577e6488 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.test.tsx @@ -7,13 +7,113 @@ import { shallow, ShallowWrapper } from 'enzyme'; import * as React from 'react'; -import { BarChartBaseComponent, BarChartWithCustomPrompt, BarChart } from './barchart'; -import { ChartSeriesData, ChartHolder } from './common'; +import { BarChartBaseComponent, BarChart } from './barchart'; +import { ChartSeriesData } from './common'; import { BarSeries, ScaleType, Axis } from '@elastic/charts'; -jest.mock('@elastic/charts'); const customHeight = '100px'; const customWidth = '120px'; +const chartDataSets = [ + [ + [ + { key: 'uniqueSourceIps', value: [{ y: 1714, x: 'uniqueSourceIps' }], color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + value: [{ y: 2354, x: 'uniqueDestinationIps' }], + color: '#490092', + }, + ], + ], + [ + [ + { key: 'uniqueSourceIps', value: [{ y: 1714, x: '' }], color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + value: [{ y: 2354, x: '' }], + color: '#490092', + }, + ], + ], + [ + [ + { key: 'uniqueSourceIps', value: [{ y: 1714, x: 'uniqueSourceIps' }], color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + value: [{ y: 0, x: 'uniqueDestinationIps' }], + color: '#490092', + }, + ], + ], + [ + [ + { key: 'uniqueSourceIps', value: [{ y: null, x: 'uniqueSourceIps' }], color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + value: [{ y: 2354, x: 'uniqueDestinationIps' }], + color: '#490092', + }, + ], + ], +]; + +const chartHolderDataSets: Array<[ChartSeriesData[] | undefined | null]> = [ + [[]], + [null], + [ + [ + { key: 'uniqueSourceIps', color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + color: '#490092', + }, + ], + ], + [ + [ + { key: 'uniqueSourceIps', value: [], color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + value: [], + color: '#490092', + }, + ], + ], + [ + [ + { key: 'uniqueSourceIps', value: [{}], color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + value: [{}], + color: '#490092', + }, + ], + ], + [ + [ + { key: 'uniqueSourceIps', value: [{ y: 0, x: 'uniqueSourceIps' }], color: '#DB1374' }, + { + key: 'uniqueDestinationIps', + value: [{ y: 0, x: 'uniqueDestinationIps' }], + color: '#490092', + }, + ], + ], +] as any; // eslint-disable-line @typescript-eslint/no-explicit-any + +const mockConfig = { + series: { + xScaleType: ScaleType.Time, + yScaleType: ScaleType.Linear, + stackAccessors: ['g'], + }, + axis: { + xTickFormatter: jest.fn(), + yTickFormatter: jest.fn(), + tickSize: 8, + }, + customHeight: 324, +}; + describe('BarChartBaseComponent', () => { let shallowWrapper: ShallowWrapper; const mockBarChartData: ChartSeriesData[] = [ @@ -168,164 +268,28 @@ describe('BarChartBaseComponent', () => { }); }); -describe.each([ - [ - [ - { key: 'uniqueSourceIps', value: [{ y: 1714, x: 'uniqueSourceIps' }], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [{ y: 2354, x: 'uniqueDestinationIps' }], - color: '#490092', - }, - ], - ], - [ - [ - { key: 'uniqueSourceIps', value: [{ y: 1714, x: '' }], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [{ y: 2354, x: '' }], - color: '#490092', - }, - ], - ], - [ - [ - { key: 'uniqueSourceIps', value: [{ y: 1714, x: 'uniqueSourceIps' }], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [{ y: 0, x: 'uniqueDestinationIps' }], - color: '#490092', - }, - ], - ], -])('BarChartWithCustomPrompt', mockBarChartData => { +describe.each(chartDataSets)('BarChart with valid data [%o]', data => { let shallowWrapper: ShallowWrapper; - describe('renders barchart', () => { - beforeAll(() => { - shallowWrapper = shallow( - - ); - }); - - it('render BarChartBaseComponent', () => { - expect(shallowWrapper.find(BarChartBaseComponent)).toHaveLength(1); - expect(shallowWrapper.find(ChartHolder)).toHaveLength(0); - }); - }); -}); -const table: Array<[ChartSeriesData[] | undefined | null]> = [ - [], - null, - [ - [ - { key: 'uniqueSourceIps', color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - color: '#490092', - }, - ], - ], - [ - [ - { key: 'uniqueSourceIps', value: [], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [], - color: '#490092', - }, - ], - ], - [ - [ - { key: 'uniqueSourceIps', value: [{}], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [{}], - color: '#490092', - }, - ], - ], - [ - [ - { key: 'uniqueSourceIps', value: [{ y: 0, x: 'uniqueSourceIps' }], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [{ y: 0, x: 'uniqueDestinationIps' }], - color: '#490092', - }, - ], - ], - [ - [ - { key: 'uniqueSourceIps', value: [{ y: null, x: 'uniqueSourceIps' }], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [{ y: 2354, x: 'uniqueDestinationIps' }], - color: '#490092', - }, - ], - ], - [ - [ - { key: 'uniqueSourceIps', value: [{ y: null, x: 'uniqueSourceIps' }], color: '#DB1374' }, - { - key: 'uniqueDestinationIps', - value: [{ y: null, x: 'uniqueDestinationIps' }], - color: '#490092', - }, - ], - ], -] as any; // eslint-disable-line @typescript-eslint/no-explicit-any - -describe.each(table)('renders prompt', data => { - let shallowWrapper: ShallowWrapper; beforeAll(() => { - shallowWrapper = shallow( - - ); + shallowWrapper = shallow(); }); - it('render Chart Holder', () => { - expect(shallowWrapper.find(BarChartBaseComponent)).toHaveLength(0); - expect(shallowWrapper.find(ChartHolder)).toHaveLength(1); + it(`should render chart`, () => { + expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); + expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(0); }); }); -describe('BarChart', () => { +describe.each(chartHolderDataSets)('BarChart with invalid data [%o]', data => { let shallowWrapper: ShallowWrapper; - const mockConfig = { - series: { - xScaleType: ScaleType.Time, - yScaleType: ScaleType.Linear, - stackAccessors: ['g'], - }, - axis: { - xTickFormatter: jest.fn(), - yTickFormatter: jest.fn(), - tickSize: 8, - }, - customHeight: 324, - }; - it('should render if data exist', () => { - const mockData = [ - { key: 'uniqueSourceIps', value: [{ y: 100, x: 100, g: 'group' }], color: '#DB1374' }, - ]; - shallowWrapper = shallow(); - expect(shallowWrapper.find('AutoSizer')).toHaveLength(1); - expect(shallowWrapper.find('ChartHolder')).toHaveLength(0); + beforeAll(() => { + shallowWrapper = shallow(); }); - it('should render a chartHolder if no data given', () => { - const mockData = [{ key: 'uniqueSourceIps', value: [], color: '#DB1374' }]; - shallowWrapper = shallow(); + it(`should render chart holder`, () => { expect(shallowWrapper.find('AutoSizer')).toHaveLength(0); - expect(shallowWrapper.find('ChartHolder')).toHaveLength(1); + expect(shallowWrapper.find('ChartPlaceHolder')).toHaveLength(1); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx index 02345fc149c2a..9ef26c690c56b 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/barchart.tsx @@ -16,20 +16,33 @@ import { ScaleType, Settings, } from '@elastic/charts'; -import { getOr, get } from 'lodash/fp'; +import { getOr, get, isNumber } from 'lodash/fp'; +import { AutoSizer } from '../auto_sizer'; +import { ChartPlaceHolder } from './chart_place_holder'; import { - ChartSeriesData, - WrappedByAutoSizer, - ChartHolder, - SeriesType, - getSeriesStyle, - ChartSeriesConfigs, browserTimezone, chartDefaultSettings, + ChartSeriesConfigs, + ChartSeriesData, + checkIfAllValuesAreZero, + getSeriesStyle, getChartHeight, getChartWidth, + SeriesType, + WrappedByAutoSizer, } from './common'; -import { AutoSizer } from '../auto_sizer'; + +const checkIfAllTheDataInTheSeriesAreValid = (series: ChartSeriesData): series is ChartSeriesData => + series != null && + !!get('value.length', series) && + (series.value || []).every(({ x, y }) => isNumber(y) && y >= 0); + +const checkIfAnyValidSeriesExist = ( + data: ChartSeriesData[] | null | undefined +): data is ChartSeriesData[] => + Array.isArray(data) && + !checkIfAllValuesAreZero(data) && + data.some(checkIfAllTheDataInTheSeriesAreValid); // Bar chart rotation: https://ela.st/chart-rotations export const BarChartBaseComponent = React.memo<{ @@ -54,7 +67,7 @@ export const BarChartBaseComponent = React.memo<{ const barSeriesKey = series.key; const barSeriesSpecId = getSpecId(barSeriesKey); const seriesType = SeriesType.BAR; - return ( + return checkIfAllTheDataInTheSeriesAreValid ? ( - ); + ) : null; })} (({ data, height, width, configs }) => { - return data && - data.length && - data.some( - ({ value }) => - value != null && value.length > 0 && value.every(chart => chart.y != null && chart.y >= 0) - ) ? ( - - ) : ( - - ); -}); - -BarChartWithCustomPrompt.displayName = 'BarChartWithCustomPrompt'; - export const BarChart = React.memo<{ barChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; }>(({ barChart, configs }) => { const customHeight = get('customHeight', configs); const customWidth = get('customWidth', configs); - return get(`0.value.length`, barChart) ? ( + return checkIfAnyValidSeriesExist(barChart) ? ( {({ measureRef, content: { height, width } }) => ( - ) : ( - + ); }); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.test.tsx new file mode 100644 index 0000000000000..7674fd09739f5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow, ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { ChartPlaceHolder } from './chart_place_holder'; +import { ChartSeriesData } from './common'; + +describe('ChartPlaceHolder', () => { + let shallowWrapper: ShallowWrapper; + const mockDataAllZeros = [ + { + key: 'mockKeyA', + color: 'mockColor', + value: [{ x: 'a', y: 0 }, { x: 'b', y: 0 }], + }, + { + key: 'mockKeyB', + color: 'mockColor', + value: [{ x: 'a', y: 0 }, { x: 'b', y: 0 }], + }, + ]; + const mockDataUnexpectedValue = [ + { + key: 'mockKeyA', + color: 'mockColor', + value: [{ x: 'a', y: '' }, { x: 'b', y: 0 }], + }, + { + key: 'mockKeyB', + color: 'mockColor', + value: [{ x: 'a', y: {} }, { x: 'b', y: 0 }], + }, + ]; + + it('should render with default props', () => { + const height = `100%`; + const width = `100%`; + shallowWrapper = shallow(); + expect(shallowWrapper.props()).toMatchObject({ + height, + width, + }); + }); + + it('should render with given props', () => { + const height = `100px`; + const width = `100px`; + shallowWrapper = shallow( + + ); + expect(shallowWrapper.props()).toMatchObject({ + height, + width, + }); + }); + + it('should render correct wording when all values returned zero', () => { + const height = `100px`; + const width = `100px`; + shallowWrapper = shallow( + + ); + expect( + shallowWrapper + .find(`[data-test-subj="chartHolderText"]`) + .childAt(0) + .text() + ).toEqual('All values returned zero'); + }); + + it('should render correct wording when unexpected value exists', () => { + const height = `100px`; + const width = `100px`; + shallowWrapper = shallow( + + ); + expect( + shallowWrapper + .find(`[data-test-subj="chartHolderText"]`) + .childAt(0) + .text() + ).toEqual('Chart Data Not Available'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.tsx b/x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.tsx new file mode 100644 index 0000000000000..22122b5fad74a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/chart_place_holder.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexItem, EuiText, EuiFlexGroup } from '@elastic/eui'; +import styled from 'styled-components'; +import { ChartSeriesData, checkIfAllValuesAreZero } from './common'; +import * as i18n from './translation'; + +const FlexGroup = styled(EuiFlexGroup)<{ height?: string | null; width?: string | null }>` + height: ${({ height }) => (height ? height : '100%')}; + width: ${({ width }) => (width ? width : '100%')}; + position: relative; + margin: 0; +`; + +FlexGroup.displayName = 'FlexGroup'; + +export const ChartPlaceHolder = ({ + height = '100%', + width = '100%', + data, +}: { + height?: string | null; + width?: string | null; + data: ChartSeriesData[] | null | undefined; +}) => ( + + + + {checkIfAllValuesAreZero(data) + ? i18n.ALL_VALUES_ZEROS_TITLE + : i18n.DATA_NOT_AVAILABLE_TITLE} + + + +); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx b/x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx index f23b97d8cd5ff..0fc7bc6afc216 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/common.test.tsx @@ -3,18 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { shallow, ShallowWrapper } from 'enzyme'; -import * as React from 'react'; +import { shallow } from 'enzyme'; +import React from 'react'; import { - ChartHolder, + checkIfAllValuesAreZero, + defaultChartHeight, getChartHeight, getChartWidth, - WrappedByAutoSizer, - defaultChartHeight, getSeriesStyle, - SeriesType, getTheme, + SeriesType, + WrappedByAutoSizer, + ChartSeriesData, } from './common'; import 'jest-styled-components'; import { mergeWithDefaultTheme, LIGHT_THEME } from '@elastic/charts'; @@ -26,30 +26,6 @@ jest.mock('@elastic/charts', () => { }; }); -describe('ChartHolder', () => { - let shallowWrapper: ShallowWrapper; - - it('should render with default props', () => { - const height = `100%`; - const width = `100%`; - shallowWrapper = shallow(); - expect(shallowWrapper.props()).toMatchObject({ - height, - width, - }); - }); - - it('should render with given props', () => { - const height = `100px`; - const width = `100px`; - shallowWrapper = shallow(); - expect(shallowWrapper.props()).toMatchObject({ - height, - width, - }); - }); -}); - describe('WrappedByAutoSizer', () => { it('should render correct default height', () => { const wrapper = shallow(); @@ -88,7 +64,7 @@ describe('getTheme', () => { chartMargins: { bottom: 0, left: 0, right: 0, top: 4 }, chartPaddings: { bottom: 0, left: 0, right: 0, top: 0 }, scales: { - barsPadding: 0.5, + barsPadding: 0.05, }, }; getTheme(); @@ -130,3 +106,46 @@ describe('getChartWidth', () => { expect(height).toEqual(defaultChartHeight); }); }); + +describe('checkIfAllValuesAreZero', () => { + const mockInvalidDataSets: Array<[ChartSeriesData[]]> = [ + [[{ key: 'mockKey', color: 'mockColor', value: [{ x: 1, y: 0 }, { x: 1, y: 1 }] }]], + [ + [ + { key: 'mockKeyA', color: 'mockColor', value: [{ x: 1, y: 0 }, { x: 1, y: 1 }] }, + { key: 'mockKeyB', color: 'mockColor', value: [{ x: 1, y: 0 }, { x: 1, y: 0 }] }, + ], + ], + ]; + const mockValidDataSets: Array<[ChartSeriesData[]]> = [ + [[{ key: 'mockKey', color: 'mockColor', value: [{ x: 0, y: 0 }, { x: 1, y: 0 }] }]], + [ + [ + { key: 'mockKeyA', color: 'mockColor', value: [{ x: 1, y: 0 }, { x: 3, y: 0 }] }, + { key: 'mockKeyB', color: 'mockColor', value: [{ x: 2, y: 0 }, { x: 4, y: 0 }] }, + ], + ], + ]; + + describe.each(mockInvalidDataSets)('with data [%o]', data => { + let result: boolean; + beforeAll(() => { + result = checkIfAllValuesAreZero(data); + }); + + it(`should return false`, () => { + expect(result).toBeFalsy(); + }); + }); + + describe.each(mockValidDataSets)('with data [%o]', data => { + let result: boolean; + beforeAll(() => { + result = checkIfAllValuesAreZero(data); + }); + + it(`should return true`, () => { + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/common.tsx b/x-pack/legacy/plugins/siem/public/components/charts/common.tsx index 59873b2cd6a31..7ac91437920e5 100644 --- a/x-pack/legacy/plugins/siem/public/components/charts/common.tsx +++ b/x-pack/legacy/plugins/siem/public/components/charts/common.tsx @@ -3,58 +3,33 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiText, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; + +import chrome from 'ui/chrome'; import { CustomSeriesColorsMap, + DARK_THEME, DataSeriesColorsValues, getSpecId, + LIGHT_THEME, mergeWithDefaultTheme, PartialTheme, - LIGHT_THEME, - DARK_THEME, + Rendering, + Rotation, ScaleType, - TickFormatter, SettingSpecProps, - Rotation, - Rendering, + TickFormatter, } from '@elastic/charts'; -import { i18n } from '@kbn/i18n'; -import chrome from 'ui/chrome'; import moment from 'moment-timezone'; +import styled from 'styled-components'; import { DEFAULT_DATE_FORMAT_TZ, DEFAULT_DARK_MODE } from '../../../common/constants'; + export const defaultChartHeight = '100%'; export const defaultChartWidth = '100%'; const chartDefaultRotation: Rotation = 0; const chartDefaultRendering: Rendering = 'canvas'; -const FlexGroup = styled(EuiFlexGroup)<{ height?: string | null; width?: string | null }>` - height: ${({ height }) => (height ? height : '100%')}; - width: ${({ width }) => (width ? width : '100%')}; -`; - -FlexGroup.displayName = 'FlexGroup'; export type UpdateDateRange = (min: number, max: number) => void; -export const ChartHolder = ({ - height = '100%', - width = '100%', -}: { - height?: string | null; - width?: string | null; -}) => ( - - - - {i18n.translate('xpack.siem.chart.dataNotAvailableTitle', { - defaultMessage: 'Chart Data Not Available', - })} - - - -); - export interface ChartData { x: number | string | null; y: number | string | null; @@ -136,7 +111,7 @@ export const getTheme = () => { bottom: 0, }, scales: { - barsPadding: 0.5, + barsPadding: 0.05, }, }; const isDarkMode: boolean = chrome.getUiSettingsClient().get(DEFAULT_DARK_MODE); @@ -166,3 +141,9 @@ export const getChartWidth = (customWidth?: number, autoSizerWidth?: number): st const height = customWidth || autoSizerWidth; return height ? `${height}px` : defaultChartWidth; }; + +export const checkIfAllValuesAreZero = (data: ChartSeriesData[] | null | undefined): boolean => + Array.isArray(data) && + data.every(series => { + return Array.isArray(series.value) && (series.value as ChartData[]).every(({ y }) => y === 0); + }); diff --git a/x-pack/legacy/plugins/siem/public/components/charts/translation.ts b/x-pack/legacy/plugins/siem/public/components/charts/translation.ts new file mode 100644 index 0000000000000..341cb7782f87c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/charts/translation.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALL_VALUES_ZEROS_TITLE = i18n.translate('xpack.siem.chart.dataAllValuesZerosTitle', { + defaultMessage: 'All values returned zero', +}); + +export const DATA_NOT_AVAILABLE_TITLE = i18n.translate('xpack.siem.chart.dataNotAvailableTitle', { + defaultMessage: 'Chart Data Not Available', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx b/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx index f95b9e6b3ecf5..2898541a4a3d1 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/matrix_over_time/index.tsx @@ -53,7 +53,7 @@ const getBarchartConfigs = (from: number, to: number, onBrushEnd: UpdateDateRang showLegend: true, theme: { scales: { - barsPadding: 0.05, + barsPadding: 0.08, }, chartMargins: { left: 0, diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/kpi_network/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/network/kpi_network/mock.ts index bb06926ec08f4..e06bb1477bc7f 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/kpi_network/mock.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/kpi_network/mock.ts @@ -184,12 +184,19 @@ export const mockEnableChartsData = { { key: 'uniqueSourcePrivateIps', color: '#DB1374', - value: [{ x: 'Src.', y: 383, g: 'uniqueSourcePrivateIps' }], + value: [ + { + x: 'Src.', + y: 383, + g: 'uniqueSourcePrivateIps', + y0: 0, + }, + ], }, { key: 'uniqueDestinationPrivateIps', color: '#490092', - value: [{ x: 'Dest.', y: 18, g: 'uniqueDestinationPrivateIps' }], + value: [{ x: 'Dest.', y: 18, g: 'uniqueDestinationPrivateIps', y0: 0 }], }, ], description: 'Unique private IPs', diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 9541ad4de043b..7475220b56e77 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -994,6 +994,9 @@ exports[`Stat Items Component rendering kpis with charts it renders the default }, "customHeight": 74, "series": Object { + "stackAccessors": Array [ + "y0", + ], "xScaleType": "ordinal", "yScaleType": "linear", }, diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx index 110d146381709..c206a4d33270b 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx @@ -103,6 +103,7 @@ export const barchartConfigs = (config?: { onElementClick?: ElementClickListener series: { xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, + stackAccessors: ['y0'], }, axis: { xTickFormatter: numberFormatter, @@ -145,6 +146,7 @@ export const addValueToBarChart = ( x, y, g: key, + y0: 0, }, ]; From d1a99ea6eec614d7edf6441b5b79c0b1bbda0123 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Fri, 4 Oct 2019 12:48:05 +0200 Subject: [PATCH 4/4] Convert uiSettings service to TypeScript (#47018) * tsify is_config_version_upgradeable * tsify get_upgradeable_config * tsify create_or_upgrade_saved_config * tsify ui_settings_service * tsify ui_settings_service_factory * tsify ui_settings_service_for_request * declare types on server object * tsify set route * tsify set_many route * tsify get route * tsify delete route * tsify logWithMetadata * improve ui_settings_service typings * introduce uiService mocks * remove private methods from public contract * add types for server.uiSettingsServiceFactory * rename IUiSettingsService --> IUiSettingsClient --- src/legacy/server/kbn_server.d.ts | 7 +- .../ui_settings/create_objects_client_stub.ts | 8 + .../create_or_upgrade_saved_config.test.ts | 25 ++- ...g.js => create_or_upgrade_saved_config.ts} | 49 ++--- ...le_config.js => get_upgradeable_config.ts} | 16 +- .../{index.js => index.ts} | 0 .../create_or_upgrade.test.ts | 1 - .../is_config_version_upgradeable.test.ts | 1 - ...le.js => is_config_version_upgradeable.ts} | 8 +- .../ui_settings_mixin.test.ts | 12 +- .../routes/{delete.js => delete.ts} | 11 +- .../ui/ui_settings/routes/{get.js => get.ts} | 9 +- .../ui_settings/routes/{index.js => index.ts} | 0 .../routes/integration_tests/lib/servers.ts | 3 +- .../ui/ui_settings/routes/{set.js => set.ts} | 30 +-- .../routes/{set_many.js => set_many.ts} | 24 ++- .../ui_settings/ui_settings_service.mock.ts | 40 ++++ .../ui_settings/ui_settings_service.test.ts | 8 +- ...ings_service.js => ui_settings_service.ts} | 180 ++++++++++++------ ...tory.js => ui_settings_service_factory.ts} | 35 ++-- ....js => ui_settings_service_for_request.ts} | 21 +- .../server/lib/helpers/setup_request.test.ts | 9 +- 22 files changed, 325 insertions(+), 172 deletions(-) rename src/legacy/ui/ui_settings/create_or_upgrade_saved_config/{create_or_upgrade_saved_config.js => create_or_upgrade_saved_config.ts} (55%) rename src/legacy/ui/ui_settings/create_or_upgrade_saved_config/{get_upgradeable_config.js => get_upgradeable_config.ts} (80%) rename src/legacy/ui/ui_settings/create_or_upgrade_saved_config/{index.js => index.ts} (100%) rename src/legacy/ui/ui_settings/create_or_upgrade_saved_config/{is_config_version_upgradeable.js => is_config_version_upgradeable.ts} (89%) rename src/legacy/ui/ui_settings/routes/{delete.js => delete.ts} (81%) rename src/legacy/ui/ui_settings/routes/{get.js => get.ts} (84%) rename src/legacy/ui/ui_settings/routes/{index.js => index.ts} (100%) rename src/legacy/ui/ui_settings/routes/{set.js => set.ts} (70%) rename src/legacy/ui/ui_settings/routes/{set_many.js => set_many.ts} (73%) create mode 100644 src/legacy/ui/ui_settings/ui_settings_service.mock.ts rename src/legacy/ui/ui_settings/{ui_settings_service.js => ui_settings_service.ts} (52%) rename src/legacy/ui/ui_settings/{ui_settings_service_factory.js => ui_settings_service_factory.ts} (59%) rename src/legacy/ui/ui_settings/{ui_settings_service_for_request.js => ui_settings_service_for_request.ts} (74%) diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 69bf95e57cab9..b3e7078d8b5a9 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -39,6 +39,8 @@ import { CallClusterWithRequest, ElasticsearchPlugin } from '../core_plugins/ela import { CapabilitiesModifier } from './capabilities'; import { IndexPatternsServiceFactory } from './index_patterns'; import { Capabilities } from '../../core/public'; +import { IUiSettingsClient } from '../../legacy/ui/ui_settings/ui_settings_service'; +import { UiSettingsServiceFactoryOptions } from '../../legacy/ui/ui_settings/ui_settings_service_factory'; export interface KibanaConfig { get(key: string): T; @@ -77,14 +79,15 @@ declare module 'hapi' { name: string, factoryFn: (request: Request) => Record ) => void; - uiSettingsServiceFactory: (options: any) => any; + uiSettingsServiceFactory: (options?: UiSettingsServiceFactoryOptions) => IUiSettingsClient; + logWithMetadata: (tags: string[], message: string, meta: Record) => void; } interface Request { getSavedObjectsClient(options?: SavedObjectsClientProviderOptions): SavedObjectsClientContract; getBasePath(): string; getDefaultRoute(): Promise; - getUiSettingsService(): any; + getUiSettingsService(): IUiSettingsClient; getCapabilities(): Promise; } diff --git a/src/legacy/ui/ui_settings/create_objects_client_stub.ts b/src/legacy/ui/ui_settings/create_objects_client_stub.ts index ebbedb761fae9..ad19b5c8bc7cf 100644 --- a/src/legacy/ui/ui_settings/create_objects_client_stub.ts +++ b/src/legacy/ui/ui_settings/create_objects_client_stub.ts @@ -26,6 +26,10 @@ export interface SavedObjectsClientStub { update: sinon.SinonStub; get: sinon.SinonStub; create: sinon.SinonStub; + bulkCreate: sinon.SinonStub; + bulkGet: sinon.SinonStub; + delete: sinon.SinonStub; + find: sinon.SinonStub; errors: typeof savedObjectsClientErrors; } @@ -35,6 +39,10 @@ export function createObjectsClientStub(esDocSource = {}): SavedObjectsClientStu get: sinon.stub().returns({ attributes: esDocSource }), create: sinon.stub(), errors: savedObjectsClientErrors, + bulkCreate: sinon.stub(), + bulkGet: sinon.stub(), + delete: sinon.stub(), + find: sinon.stub(), }; return savedObjectsClient; } diff --git a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts index 9b9a2fad39aca..654c0fbb66c8b 100644 --- a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts +++ b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts @@ -21,9 +21,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; import Chance from 'chance'; -// @ts-ignore import * as getUpgradeableConfigNS from './get_upgradeable_config'; -// @ts-ignore import { createOrUpgradeSavedConfig } from './create_or_upgrade_saved_config'; const chance = new Chance(); @@ -45,7 +43,7 @@ describe('uiSettings/createOrUpgradeSavedConfig', function() { id: options.id, version: 'foo', })), - }; + } as any; // mute until we have savedObjects mocks async function run(options = {}) { const resp = await createOrUpgradeSavedConfig({ @@ -103,7 +101,12 @@ describe('uiSettings/createOrUpgradeSavedConfig', function() { [chance.word()]: chance.sentence(), }; - getUpgradeableConfig.returns({ id: prevVersion, attributes: savedAttributes }); + getUpgradeableConfig.resolves({ + id: prevVersion, + attributes: savedAttributes, + type: '', + references: [], + }); await run(); @@ -125,7 +128,12 @@ describe('uiSettings/createOrUpgradeSavedConfig', function() { it('should log a message for upgrades', async () => { const { getUpgradeableConfig, logWithMetadata, run } = setup(); - getUpgradeableConfig.returns({ id: prevVersion, attributes: { buildNum: buildNum - 100 } }); + getUpgradeableConfig.resolves({ + id: prevVersion, + attributes: { buildNum: buildNum - 100 }, + type: '', + references: [], + }); await run(); sinon.assert.calledOnce(logWithMetadata); @@ -143,7 +151,12 @@ describe('uiSettings/createOrUpgradeSavedConfig', function() { it('does not log when upgrade fails', async () => { const { getUpgradeableConfig, logWithMetadata, run, savedObjectsClient } = setup(); - getUpgradeableConfig.returns({ id: prevVersion, attributes: { buildNum: buildNum - 100 } }); + getUpgradeableConfig.resolves({ + id: prevVersion, + attributes: { buildNum: buildNum - 100 }, + type: '', + references: [], + }); savedObjectsClient.create.callsFake(async () => { throw new Error('foo'); diff --git a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts similarity index 55% rename from src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js rename to src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts index c175e583ee916..0dc3d5f50e97e 100644 --- a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js +++ b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts @@ -18,37 +18,38 @@ */ import { defaults } from 'lodash'; +import { SavedObjectsClientContract, SavedObjectAttribute } from 'src/core/server'; +import { Legacy } from 'kibana'; import { getUpgradeableConfig } from './get_upgradeable_config'; -export async function createOrUpgradeSavedConfig(options) { - const { - savedObjectsClient, - version, - buildNum, - logWithMetadata, - onWriteError, - } = options; +interface Options { + savedObjectsClient: SavedObjectsClientContract; + version: string; + buildNum: number; + logWithMetadata: Legacy.Server['logWithMetadata']; + onWriteError?: ( + error: Error, + attributes: Record + ) => Record | undefined; +} +export async function createOrUpgradeSavedConfig( + options: Options +): Promise | undefined> { + const { savedObjectsClient, version, buildNum, logWithMetadata, onWriteError } = options; // try to find an older config we can upgrade const upgradeableConfig = await getUpgradeableConfig({ savedObjectsClient, - version + version, }); // default to the attributes of the upgradeableConfig if available - const attributes = defaults( - { buildNum }, - upgradeableConfig ? upgradeableConfig.attributes : {} - ); + const attributes = defaults({ buildNum }, upgradeableConfig ? upgradeableConfig.attributes : {}); try { // create the new SavedConfig - await savedObjectsClient.create( - 'config', - attributes, - { id: version } - ); + await savedObjectsClient.create('config', attributes, { id: version }); } catch (error) { if (onWriteError) { return onWriteError(error, attributes); @@ -58,9 +59,13 @@ export async function createOrUpgradeSavedConfig(options) { } if (upgradeableConfig) { - logWithMetadata(['plugin', 'elasticsearch'], `Upgrade config from ${upgradeableConfig.id} to ${version}`, { - prevVersion: upgradeableConfig.id, - newVersion: version - }); + logWithMetadata( + ['plugin', 'elasticsearch'], + `Upgrade config from ${upgradeableConfig.id} to ${version}`, + { + prevVersion: upgradeableConfig.id, + newVersion: version, + } + ); } } diff --git a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.js b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.ts similarity index 80% rename from src/legacy/ui/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.js rename to src/legacy/ui/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.ts index 1108a01167580..350137a81a49b 100644 --- a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.js +++ b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/get_upgradeable_config.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - +import { SavedObjectsClientContract } from 'src/core/server'; import { isConfigVersionUpgradeable } from './is_config_version_upgradeable'; /** @@ -26,18 +26,22 @@ import { isConfigVersionUpgradeable } from './is_config_version_upgradeable'; * @property {string} version * @return {Promise} */ -export async function getUpgradeableConfig({ savedObjectsClient, version }) { +export async function getUpgradeableConfig({ + savedObjectsClient, + version, +}: { + savedObjectsClient: SavedObjectsClientContract; + version: string; +}) { // attempt to find a config we can upgrade const { saved_objects: savedConfigs } = await savedObjectsClient.find({ type: 'config', page: 1, perPage: 1000, sortField: 'buildNum', - sortOrder: 'desc' + sortOrder: 'desc', }); // try to find a config that we can upgrade - return savedConfigs.find(savedConfig => ( - isConfigVersionUpgradeable(savedConfig.id, version) - )); + return savedConfigs.find(savedConfig => isConfigVersionUpgradeable(savedConfig.id, version)); } diff --git a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/index.js b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/index.ts similarity index 100% rename from src/legacy/ui/ui_settings/create_or_upgrade_saved_config/index.js rename to src/legacy/ui/ui_settings/create_or_upgrade_saved_config/index.ts diff --git a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts index 7d5f4e970638d..753e73058af2f 100644 --- a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts +++ b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts @@ -24,7 +24,6 @@ import { SavedObjectsClientContract } from 'src/core/server'; import KbnServer from '../../../../server/kbn_server'; import { createTestServers } from '../../../../../test_utils/kbn_server'; -// @ts-ignore import { createOrUpgradeSavedConfig } from '../create_or_upgrade_saved_config'; describe('createOrUpgradeSavedConfig()', () => { diff --git a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.test.ts b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.test.ts index 91231da968227..6bb2cb3b87850 100644 --- a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.test.ts +++ b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.test.ts @@ -19,7 +19,6 @@ import expect from '@kbn/expect'; -// @ts-ignore import { isConfigVersionUpgradeable } from './is_config_version_upgradeable'; // @ts-ignore import { pkg } from '../../../utils'; diff --git a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.js b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.ts similarity index 89% rename from src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.js rename to src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.ts index beeba6717f24a..8359f02ffee74 100644 --- a/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.js +++ b/src/legacy/ui/ui_settings/create_or_upgrade_saved_config/is_config_version_upgradeable.ts @@ -20,14 +20,12 @@ import semver from 'semver'; const rcVersionRegex = /^(\d+\.\d+\.\d+)\-rc(\d+)$/i; -function extractRcNumber(version) { +function extractRcNumber(version: string): [string, number] { const match = version.match(rcVersionRegex); - return match - ? [match[1], parseInt(match[2], 10)] - : [version, Infinity]; + return match ? [match[1], parseInt(match[2], 10)] : [version, Infinity]; } -export function isConfigVersionUpgradeable(savedVersion, kibanaVersion) { +export function isConfigVersionUpgradeable(savedVersion: string, kibanaVersion: string): boolean { if ( typeof savedVersion !== 'string' || typeof kibanaVersion !== 'string' || diff --git a/src/legacy/ui/ui_settings/integration_tests/ui_settings_mixin.test.ts b/src/legacy/ui/ui_settings/integration_tests/ui_settings_mixin.test.ts index f522f119a26cc..f43c6436d1c33 100644 --- a/src/legacy/ui/ui_settings/integration_tests/ui_settings_mixin.test.ts +++ b/src/legacy/ui/ui_settings/integration_tests/ui_settings_mixin.test.ts @@ -23,9 +23,7 @@ import expect from '@kbn/expect'; // @ts-ignore import { Config } from '../../../server/config'; -// @ts-ignore import * as uiSettingsServiceFactoryNS from '../ui_settings_service_factory'; -// @ts-ignore import * as getUiSettingsServiceForRequestNS from '../ui_settings_service_for_request'; // @ts-ignore import { uiSettingsMixin } from '../ui_settings_mixin'; @@ -123,7 +121,8 @@ describe('uiSettingsMixin()', () => { foo: 'bar', }); sinon.assert.calledOnce(uiSettingsServiceFactoryStub); - sinon.assert.calledWithExactly(uiSettingsServiceFactoryStub, server, { + sinon.assert.calledWithExactly(uiSettingsServiceFactoryStub, server as any, { + // @ts-ignore foo doesn't exist on Hapi.Server foo: 'bar', overrides: { foo: 'bar', @@ -162,7 +161,12 @@ describe('uiSettingsMixin()', () => { sinon.assert.notCalled(getUiSettingsServiceForRequestStub); const request = {}; decorations.request.getUiSettingsService.call(request); - sinon.assert.calledWith(getUiSettingsServiceForRequestStub, server, request); + sinon.assert.calledWith(getUiSettingsServiceForRequestStub, server as any, request as any, { + overrides: { + foo: 'bar', + }, + getDefaults: sinon.match.func, + }); }); }); diff --git a/src/legacy/ui/ui_settings/routes/delete.js b/src/legacy/ui/ui_settings/routes/delete.ts similarity index 81% rename from src/legacy/ui/ui_settings/routes/delete.js rename to src/legacy/ui/ui_settings/routes/delete.ts index 78e07bbceab01..7825204e6b99b 100644 --- a/src/legacy/ui/ui_settings/routes/delete.js +++ b/src/legacy/ui/ui_settings/routes/delete.ts @@ -16,21 +16,22 @@ * specific language governing permissions and limitations * under the License. */ +import { Legacy } from 'kibana'; -async function handleRequest(request) { +async function handleRequest(request: Legacy.Request) { const { key } = request.params; const uiSettings = request.getUiSettingsService(); await uiSettings.remove(key); return { - settings: await uiSettings.getUserProvided() + settings: await uiSettings.getUserProvided(), }; } export const deleteRoute = { path: '/api/kibana/settings/{key}', method: 'DELETE', - handler: async (request, h) => { - return h.response(await handleRequest(request)); - } + handler: async (request: Legacy.Request) => { + return await handleRequest(request); + }, }; diff --git a/src/legacy/ui/ui_settings/routes/get.js b/src/legacy/ui/ui_settings/routes/get.ts similarity index 84% rename from src/legacy/ui/ui_settings/routes/get.js rename to src/legacy/ui/ui_settings/routes/get.ts index 7e91bc46596b5..3e165a12522bb 100644 --- a/src/legacy/ui/ui_settings/routes/get.js +++ b/src/legacy/ui/ui_settings/routes/get.ts @@ -16,18 +16,19 @@ * specific language governing permissions and limitations * under the License. */ +import { Legacy } from 'kibana'; -async function handleRequest(request) { +async function handleRequest(request: Legacy.Request) { const uiSettings = request.getUiSettingsService(); return { - settings: await uiSettings.getUserProvided() + settings: await uiSettings.getUserProvided(), }; } export const getRoute = { path: '/api/kibana/settings', method: 'GET', - handler: function (request) { + handler(request: Legacy.Request) { return handleRequest(request); - } + }, }; diff --git a/src/legacy/ui/ui_settings/routes/index.js b/src/legacy/ui/ui_settings/routes/index.ts similarity index 100% rename from src/legacy/ui/ui_settings/routes/index.js rename to src/legacy/ui/ui_settings/routes/index.ts diff --git a/src/legacy/ui/ui_settings/routes/integration_tests/lib/servers.ts b/src/legacy/ui/ui_settings/routes/integration_tests/lib/servers.ts index 5b0fbf5a5f256..b076a2a86e166 100644 --- a/src/legacy/ui/ui_settings/routes/integration_tests/lib/servers.ts +++ b/src/legacy/ui/ui_settings/routes/integration_tests/lib/servers.ts @@ -23,6 +23,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import KbnServer from '../../../../../server/kbn_server'; import { createTestServers } from '../../../../../../test_utils/kbn_server'; import { CallCluster } from '../../../../../../legacy/core_plugins/elasticsearch'; +import { IUiSettingsClient } from '../../../ui_settings_service'; let kbnServer: KbnServer; let servers: ReturnType; @@ -33,7 +34,7 @@ interface AllServices { kbnServer: KbnServer; savedObjectsClient: SavedObjectsClientContract; callCluster: CallCluster; - uiSettings: any; + uiSettings: IUiSettingsClient; deleteKibanaIndex: typeof deleteKibanaIndex; } diff --git a/src/legacy/ui/ui_settings/routes/set.js b/src/legacy/ui/ui_settings/routes/set.ts similarity index 70% rename from src/legacy/ui/ui_settings/routes/set.js rename to src/legacy/ui/ui_settings/routes/set.ts index e50c9bf08de3e..1f1ab17a0daf7 100644 --- a/src/legacy/ui/ui_settings/routes/set.js +++ b/src/legacy/ui/ui_settings/routes/set.ts @@ -16,18 +16,18 @@ * specific language governing permissions and limitations * under the License. */ - +import { Legacy } from 'kibana'; import Joi from 'joi'; -async function handleRequest(request) { +async function handleRequest(request: Legacy.Request) { const { key } = request.params; - const { value } = request.payload; + const { value } = request.payload as any; const uiSettings = request.getUiSettingsService(); await uiSettings.set(key, value); return { - settings: await uiSettings.getUserProvided() + settings: await uiSettings.getUserProvided(), }; } @@ -36,16 +36,20 @@ export const setRoute = { method: 'POST', config: { validate: { - params: Joi.object().keys({ - key: Joi.string().required(), - }).default(), + params: Joi.object() + .keys({ + key: Joi.string().required(), + }) + .default(), - payload: Joi.object().keys({ - value: Joi.any().required() - }).required() + payload: Joi.object() + .keys({ + value: Joi.any().required(), + }) + .required(), }, - handler(request) { + handler(request: Legacy.Request) { return handleRequest(request); - } - } + }, + }, }; diff --git a/src/legacy/ui/ui_settings/routes/set_many.js b/src/legacy/ui/ui_settings/routes/set_many.ts similarity index 73% rename from src/legacy/ui/ui_settings/routes/set_many.js rename to src/legacy/ui/ui_settings/routes/set_many.ts index 8e7882f48ef70..18b1046417fec 100644 --- a/src/legacy/ui/ui_settings/routes/set_many.js +++ b/src/legacy/ui/ui_settings/routes/set_many.ts @@ -16,17 +16,17 @@ * specific language governing permissions and limitations * under the License. */ - +import { Legacy } from 'kibana'; import Joi from 'joi'; -async function handleRequest(request) { - const { changes } = request.payload; +async function handleRequest(request: Legacy.Request) { + const { changes } = request.payload as any; const uiSettings = request.getUiSettingsService(); await uiSettings.setMany(changes); return { - settings: await uiSettings.getUserProvided() + settings: await uiSettings.getUserProvided(), }; } @@ -35,12 +35,16 @@ export const setManyRoute = { method: 'POST', config: { validate: { - payload: Joi.object().keys({ - changes: Joi.object().unknown(true).required() - }).required() + payload: Joi.object() + .keys({ + changes: Joi.object() + .unknown(true) + .required(), + }) + .required(), }, - handler(request) { + handler(request: Legacy.Request) { return handleRequest(request); - } - } + }, + }, }; diff --git a/src/legacy/ui/ui_settings/ui_settings_service.mock.ts b/src/legacy/ui/ui_settings/ui_settings_service.mock.ts new file mode 100644 index 0000000000000..7c1a17ebd447c --- /dev/null +++ b/src/legacy/ui/ui_settings/ui_settings_service.mock.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IUiSettingsClient } from './ui_settings_service'; + +const createServiceMock = () => { + const mocked: jest.Mocked = { + getDefaults: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + getUserProvided: jest.fn(), + setMany: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + removeMany: jest.fn(), + isOverridden: jest.fn(), + }; + mocked.get.mockResolvedValue(false); + return mocked; +}; + +export const uiSettingsServiceMock = { + create: createServiceMock, +}; diff --git a/src/legacy/ui/ui_settings/ui_settings_service.test.ts b/src/legacy/ui/ui_settings/ui_settings_service.test.ts index bb407d7b3a91b..f37076b27ad6f 100644 --- a/src/legacy/ui/ui_settings/ui_settings_service.test.ts +++ b/src/legacy/ui/ui_settings/ui_settings_service.test.ts @@ -21,9 +21,7 @@ import expect from '@kbn/expect'; import Chance from 'chance'; import sinon from 'sinon'; -// @ts-ignore import { UiSettingsService } from './ui_settings_service'; -// @ts-ignore import * as createOrUpgradeSavedConfigNS from './create_or_upgrade_saved_config/create_or_upgrade_saved_config'; import { createObjectsClientStub, savedObjectsClientErrors } from './create_objects_client_stub'; @@ -43,7 +41,7 @@ describe('ui settings', () => { const sandbox = sinon.createSandbox(); function setup(options: SetupOptions = {}) { - const { getDefaults, defaults = {}, overrides, esDocSource = {} } = options; + const { getDefaults, defaults = {}, overrides = {}, esDocSource = {} } = options; const savedObjectsClient = createObjectsClientStub(esDocSource); @@ -233,7 +231,7 @@ describe('ui settings', () => { }); try { - await uiSettings.setMany(['bar', 'foo']); + await uiSettings.setMany({ baz: 'baz', foo: 'foo' }); } catch (error) { expect(error.message).to.be('Unable to update "foo" because it is overridden'); } @@ -489,7 +487,7 @@ describe('ui settings', () => { it('pulls user configuration from ES', async () => { const esDocSource = {}; const { uiSettings, assertGetQuery } = setup({ esDocSource }); - await uiSettings.get(); + await uiSettings.get('any'); assertGetQuery(); }); diff --git a/src/legacy/ui/ui_settings/ui_settings_service.js b/src/legacy/ui/ui_settings/ui_settings_service.ts similarity index 52% rename from src/legacy/ui/ui_settings/ui_settings_service.js rename to src/legacy/ui/ui_settings/ui_settings_service.ts index 9f79ed2dbe168..57312140b16b3 100644 --- a/src/legacy/ui/ui_settings/ui_settings_service.js +++ b/src/legacy/ui/ui_settings/ui_settings_service.ts @@ -16,28 +16,77 @@ * specific language governing permissions and limitations * under the License. */ - +import { Legacy } from 'kibana'; import { defaultsDeep } from 'lodash'; import Boom from 'boom'; +import { SavedObjectsClientContract, SavedObjectAttribute } from 'src/core/server'; import { createOrUpgradeSavedConfig } from './create_or_upgrade_saved_config'; +export interface UiSettingsServiceOptions { + type: string; + id: string; + buildNum: number; + savedObjectsClient: SavedObjectsClientContract; + overrides?: Record; + getDefaults?: () => Record; + logWithMetadata?: Legacy.Server['logWithMetadata']; +} + +interface ReadOptions { + ignore401Errors?: boolean; + autoCreateOrUpgradeIfMissing?: boolean; +} + +interface UserProvidedValue { + userValue?: SavedObjectAttribute; + isOverridden?: boolean; +} + +type UiSettingsRawValue = UiSettingsParams & UserProvidedValue; + +type UserProvided = Record; +type UiSettingsRaw = Record; + +type UiSettingsType = 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string'; + +interface UiSettingsParams { + name: string; + value: SavedObjectAttribute; + description: string; + category: string[]; + options?: string[]; + optionLabels?: Record; + requiresPageReload?: boolean; + readonly?: boolean; + type?: UiSettingsType; +} + +export interface IUiSettingsClient { + getDefaults: () => Promise>; + get: (key: string) => Promise; + getAll: () => Promise>; + getUserProvided: () => Promise; + setMany: (changes: Record) => Promise; + set: (key: string, value: T) => Promise; + remove: (key: string) => Promise; + removeMany: (keys: string[]) => Promise; + isOverridden: (key: string) => boolean; +} /** * Service that provides access to the UiSettings stored in elasticsearch. * @class UiSettingsService */ -export class UiSettingsService { - /** - * @constructor - * @param {Object} options - * @property {string} options.type type of SavedConfig object - * @property {string} options.id id of SavedConfig object - * @property {number} options.buildNum - * @property {SavedObjectsClient} options.savedObjectsClient - * @property {Function} [options.getDefaults] - * @property {Function} [options.log] - */ - constructor(options) { +export class UiSettingsService implements IUiSettingsClient { + private readonly _type: UiSettingsServiceOptions['type']; + private readonly _id: UiSettingsServiceOptions['id']; + private readonly _buildNum: UiSettingsServiceOptions['buildNum']; + private readonly _savedObjectsClient: UiSettingsServiceOptions['savedObjectsClient']; + private readonly _overrides: NonNullable; + private readonly _getDefaults: NonNullable; + private readonly _logWithMetadata: NonNullable; + + constructor(options: UiSettingsServiceOptions) { const { type, id, @@ -65,36 +114,38 @@ export class UiSettingsService { } // returns a Promise for the value of the requested setting - async get(key) { + async get(key: string): Promise { const all = await this.getAll(); return all[key]; } - async getAll() { + async getAll() { const raw = await this.getRaw(); - return Object.keys(raw) - .reduce((all, key) => { + return Object.keys(raw).reduce( + (all, key) => { const item = raw[key]; - const hasUserValue = 'userValue' in item; - all[key] = hasUserValue ? item.userValue : item.value; + all[key] = ('userValue' in item ? item.userValue : item.value) as T; return all; - }, {}); + }, + {} as Record + ); } - async getRaw() { + // NOTE: should be a private method + async getRaw(): Promise { const userProvided = await this.getUserProvided(); return defaultsDeep(userProvided, await this.getDefaults()); } - async getUserProvided(options) { - const userProvided = {}; + async getUserProvided(options: ReadOptions = {}): Promise { + const userProvided: UserProvided = {}; // write the userValue for each key stored in the saved object that is not overridden for (const [key, userValue] of Object.entries(await this._read(options))) { if (userValue !== null && !this.isOverridden(key)) { userProvided[key] = { - userValue + userValue, }; } } @@ -102,45 +153,51 @@ export class UiSettingsService { // write all overridden keys, dropping the userValue is override is null and // adding keys for overrides that are not in saved object for (const [key, userValue] of Object.entries(this._overrides)) { - userProvided[key] = userValue === null - ? { isOverridden: true } - : { isOverridden: true, userValue }; + userProvided[key] = + userValue === null ? { isOverridden: true } : { isOverridden: true, userValue }; } return userProvided; } - async setMany(changes) { + async setMany(changes: Record) { await this._write({ changes }); } - async set(key, value) { + async set(key: string, value: T) { await this.setMany({ [key]: value }); } - async remove(key) { + async remove(key: string) { await this.set(key, null); } - async removeMany(keys) { - const changes = {}; + async removeMany(keys: string[]) { + const changes: Record = {}; keys.forEach(key => { changes[key] = null; }); await this.setMany(changes); } - isOverridden(key) { + isOverridden(key: string) { return this._overrides.hasOwnProperty(key); } - assertUpdateAllowed(key) { + // NOTE: should be private method + assertUpdateAllowed(key: string) { if (this.isOverridden(key)) { throw Boom.badRequest(`Unable to update "${key}" because it is overridden`); } } - async _write({ changes, autoCreateOrUpgradeIfMissing = true }) { + private async _write({ + changes, + autoCreateOrUpgradeIfMissing = true, + }: { + changes: Record; + autoCreateOrUpgradeIfMissing?: boolean; + }) { for (const key of Object.keys(changes)) { this.assertUpdateAllowed(key); } @@ -162,72 +219,77 @@ export class UiSettingsService { await this._write({ changes, - autoCreateOrUpgradeIfMissing: false + autoCreateOrUpgradeIfMissing: false, }); } } - async _read(options = {}) { - const { - ignore401Errors = false, - autoCreateOrUpgradeIfMissing = true - } = options; - + private async _read({ + ignore401Errors = false, + autoCreateOrUpgradeIfMissing = true, + }: ReadOptions = {}): Promise> { const { isConflictError, isNotFoundError, isForbiddenError, - isEsUnavailableError, isNotAuthorizedError, } = this._savedObjectsClient.errors; - const isIgnorableError = error => ( - isForbiddenError(error) || - isEsUnavailableError(error) || - (ignore401Errors && isNotAuthorizedError(error)) - ); - try { const resp = await this._savedObjectsClient.get(this._type, this._id); return resp.attributes; } catch (error) { if (isNotFoundError(error) && autoCreateOrUpgradeIfMissing) { - const failedUpgradeAttributes = await createOrUpgradeSavedConfig({ + const failedUpgradeAttributes = await createOrUpgradeSavedConfig({ savedObjectsClient: this._savedObjectsClient, version: this._id, buildNum: this._buildNum, logWithMetadata: this._logWithMetadata, - async onWriteError(error, attributes) { - if (isConflictError(error)) { + onWriteError(writeError, attributes) { + if (isConflictError(writeError)) { // trigger `!failedUpgradeAttributes` check below, since another // request caused the uiSettings object to be created so we can // just re-read - return false; + return; } - if (isNotAuthorizedError(error) || isForbiddenError(error)) { + if (isNotAuthorizedError(writeError) || isForbiddenError(writeError)) { return attributes; } - throw error; - } + throw writeError; + }, }); if (!failedUpgradeAttributes) { return await this._read({ - ...options, - autoCreateOrUpgradeIfMissing: false + ignore401Errors, + autoCreateOrUpgradeIfMissing: false, }); } return failedUpgradeAttributes; } - if (isIgnorableError(error)) { + if (this.isIgnorableError(error, ignore401Errors)) { return {}; } throw error; } } + + private isIgnorableError(error: Error, ignore401Errors: boolean) { + const { + isForbiddenError, + isEsUnavailableError, + isNotAuthorizedError, + } = this._savedObjectsClient.errors; + + return ( + isForbiddenError(error) || + isEsUnavailableError(error) || + (ignore401Errors && isNotAuthorizedError(error)) + ); + } } diff --git a/src/legacy/ui/ui_settings/ui_settings_service_factory.js b/src/legacy/ui/ui_settings/ui_settings_service_factory.ts similarity index 59% rename from src/legacy/ui/ui_settings/ui_settings_service_factory.js rename to src/legacy/ui/ui_settings/ui_settings_service_factory.ts index f83a0d9825557..9e1384494161c 100644 --- a/src/legacy/ui/ui_settings/ui_settings_service_factory.js +++ b/src/legacy/ui/ui_settings/ui_settings_service_factory.ts @@ -16,29 +16,30 @@ * specific language governing permissions and limitations * under the License. */ +import { Legacy } from 'kibana'; +import { + IUiSettingsClient, + UiSettingsService, + UiSettingsServiceOptions, +} from './ui_settings_service'; -import { UiSettingsService } from './ui_settings_service'; - +export type UiSettingsServiceFactoryOptions = Pick< + UiSettingsServiceOptions, + 'savedObjectsClient' | 'getDefaults' | 'overrides' +>; /** * Create an instance of UiSettingsService that will use the - * passed `callCluster` function to communicate with elasticsearch + * passed `savedObjectsClient` to communicate with elasticsearch * - * @param {Hapi.Server} server - * @param {Object} options - * @property {AsyncFunction} options.callCluster function that accepts a method name and - * param object which causes a request via some elasticsearch client - * @property {AsyncFunction} [options.getDefaults] async function that returns defaults/details about - * the uiSettings. - * @return {UiSettingsService} + * @return {IUiSettingsClient} */ -export function uiSettingsServiceFactory(server, options) { +export function uiSettingsServiceFactory( + server: Legacy.Server, + options: UiSettingsServiceFactoryOptions +): IUiSettingsClient { const config = server.config(); - const { - savedObjectsClient, - getDefaults, - overrides, - } = options; + const { savedObjectsClient, getDefaults, overrides } = options; return new UiSettingsService({ type: 'config', @@ -47,6 +48,6 @@ export function uiSettingsServiceFactory(server, options) { savedObjectsClient, getDefaults, overrides, - logWithMetadata: (...args) => server.logWithMetadata(...args), + logWithMetadata: server.logWithMetadata, }); } diff --git a/src/legacy/ui/ui_settings/ui_settings_service_for_request.js b/src/legacy/ui/ui_settings/ui_settings_service_for_request.ts similarity index 74% rename from src/legacy/ui/ui_settings/ui_settings_service_for_request.js rename to src/legacy/ui/ui_settings/ui_settings_service_for_request.ts index 422c9cc14f833..e265ad5f1e115 100644 --- a/src/legacy/ui/ui_settings/ui_settings_service_for_request.js +++ b/src/legacy/ui/ui_settings/ui_settings_service_for_request.ts @@ -17,8 +17,11 @@ * under the License. */ +import { Legacy } from 'kibana'; import { uiSettingsServiceFactory } from './ui_settings_service_factory'; +import { IUiSettingsClient, UiSettingsServiceOptions } from './ui_settings_service'; +type Options = Pick; /** * Get/create an instance of UiSettingsService bound to a specific request. * Each call is cached (keyed on the request object itself) and subsequent @@ -28,20 +31,20 @@ import { uiSettingsServiceFactory } from './ui_settings_service_factory'; * @param {Hapi.Server} server * @param {Hapi.Request} request * @param {Object} [options={}] - * @property {AsyncFunction} [options.getDefaults] async function that returns defaults/details about - * the uiSettings. - * @return {UiSettingsService} + + * @return {IUiSettingsClient} */ -export function getUiSettingsServiceForRequest(server, request, options = {}) { - const { - getDefaults, - overrides, - } = options; +export function getUiSettingsServiceForRequest( + server: Legacy.Server, + request: Legacy.Request, + options: Options +): IUiSettingsClient { + const { getDefaults, overrides } = options; const uiSettingsService = uiSettingsServiceFactory(server, { getDefaults, overrides, - savedObjectsClient: request.getSavedObjectsClient() + savedObjectsClient: request.getSavedObjectsClient(), }); return uiSettingsService; diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts index bd45cec316dfc..91809fbaede03 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -6,6 +6,7 @@ import { Legacy } from 'kibana'; import { setupRequest } from './setup_request'; +import { uiSettingsServiceMock } from '../../../../../../../src/legacy/ui/ui_settings/ui_settings_service.mock'; function getMockRequest() { const callWithRequestSpy = jest.fn(); @@ -125,8 +126,10 @@ describe('setupRequest', () => { it('should set `ignore_throttled=true` if `includeFrozen=false`', async () => { const { mockRequest, callWithRequestSpy } = getMockRequest(); + const uiSettingsService = uiSettingsServiceMock.create(); // mock includeFrozen to return false - mockRequest.getUiSettingsService = () => ({ get: async () => false }); + uiSettingsService.get.mockResolvedValue(false); + mockRequest.getUiSettingsService = () => uiSettingsService; const { client } = await setupRequest(mockRequest); await client.search({}); const params = callWithRequestSpy.mock.calls[0][2]; @@ -136,8 +139,10 @@ describe('setupRequest', () => { it('should set `ignore_throttled=false` if `includeFrozen=true`', async () => { const { mockRequest, callWithRequestSpy } = getMockRequest(); + const uiSettingsService = uiSettingsServiceMock.create(); // mock includeFrozen to return true - mockRequest.getUiSettingsService = () => ({ get: async () => true }); + uiSettingsService.get.mockResolvedValue(true); + mockRequest.getUiSettingsService = () => uiSettingsService; const { client } = await setupRequest(mockRequest); await client.search({}); const params = callWithRequestSpy.mock.calls[0][2];