From 8a900bfe099851e4ba29946301232b35fcebff8e Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Wed, 11 Sep 2019 16:10:13 +0200 Subject: [PATCH 1/5] Functional tests: convert more test/services to TS (#45176) * convert more test/services to TS * Update test/functional/services/combo_box.ts Co-Authored-By: Tre' * Update test/functional/services/combo_box.ts Co-Authored-By: Tre' * Update test/functional/services/combo_box.ts Co-Authored-By: Tre' * fix lint error --- test/functional/apps/visualize/_tag_cloud.js | 2 +- test/functional/services/combo_box.ts | 111 ++++++++------ .../services/{embedding.js => embedding.ts} | 11 +- .../services/{filter_bar.js => filter_bar.ts} | 89 ++++++++--- test/functional/services/find.ts | 2 +- .../services/{flyout.js => flyout.ts} | 37 +++-- .../services/{global_nav.js => global_nav.ts} | 23 +-- test/functional/services/index.ts | 10 -- .../services/{inspector.js => inspector.ts} | 145 +++++++++++++----- .../services/{query_bar.js => query_bar.ts} | 18 ++- .../services/{renderable.js => renderable.ts} | 19 ++- .../services/{table.js => table.ts} | 33 ++-- ...ng_table.js => visualize_listing_table.ts} | 20 ++- .../apps/visualize/hybrid_visualization.ts | 2 +- 14 files changed, 346 insertions(+), 176 deletions(-) rename test/functional/services/{embedding.js => embedding.ts} (86%) rename test/functional/services/{filter_bar.js => filter_bar.ts} (71%) rename test/functional/services/{flyout.js => flyout.ts} (56%) rename test/functional/services/{global_nav.js => global_nav.ts} (73%) rename test/functional/services/{inspector.js => inspector.ts} (60%) rename test/functional/services/{query_bar.js => query_bar.ts} (81%) rename test/functional/services/{renderable.js => renderable.ts} (83%) rename test/functional/services/{table.js => table.ts} (51%) rename test/functional/services/{visualize_listing_table.js => visualize_listing_table.ts} (72%) diff --git a/test/functional/apps/visualize/_tag_cloud.js b/test/functional/apps/visualize/_tag_cloud.js index 998019c98c1d8..4ed95214550c1 100644 --- a/test/functional/apps/visualize/_tag_cloud.js +++ b/test/functional/apps/visualize/_tag_cloud.js @@ -124,7 +124,7 @@ export default function ({ getService, getPageObjects }) { ]; await inspector.open(); - await await inspector.setTablePageSize('50'); + await await inspector.setTablePageSize(50); await inspector.expectTableData(expectedTableData); }); diff --git a/test/functional/services/combo_box.ts b/test/functional/services/combo_box.ts index 5ecd8cd883c8a..c845c4f1ddbaf 100644 --- a/test/functional/services/combo_box.ts +++ b/test/functional/services/combo_box.ts @@ -35,25 +35,32 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont // wrapper around EuiComboBox interactions class ComboBox { /** - * set value inside combobox + * Finds combobox element and sets specified value * - * @param comboBoxSelector test subject selector - * @param value + * @param comboBoxSelector data-test-subj selector + * @param value option text */ + public async set(comboBoxSelector: string, value: string): Promise { log.debug(`comboBox.set, comboBoxSelector: ${comboBoxSelector}`); const comboBox = await testSubjects.find(comboBoxSelector); await this.setElement(comboBox, value); } - private async clickOption(isMouseClick: boolean, element: WebElementWrapper) { + /** + * Clicks option in combobox dropdown + * + * @param isMouseClick if 'true', click will be done with mouse + * @param element element that wraps up option + */ + private async clickOption(isMouseClick: boolean, element: WebElementWrapper): Promise { return isMouseClick ? await element.clickMouseButton() : await element.click(); } /** - * set value inside combobox element + * Sets value for specified combobox element * - * @param comboBoxElement + * @param comboBoxElement element that wraps up EuiComboBox * @param value */ public async setElement( @@ -69,7 +76,7 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont } comboBoxElement.scrollIntoViewIfNecessary(); - await this._filterOptionsList(comboBoxElement, value); + await this.setFilterValue(comboBoxElement, value); await this.openOptionsList(comboBoxElement); if (value !== undefined) { @@ -93,30 +100,42 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont } /** - * This method set custom value to comboBox. + * Finds combobox element and sets custom value * It applies changes by pressing Enter key. Sometimes it may lead to auto-submitting a form. * - * @param comboBoxSelector test subject selector - * @param value + * @param comboBoxSelector data-test-subj selector + * @param value option text */ - async setCustom(comboBoxSelector: string, value: string) { + public async setCustom(comboBoxSelector: string, value: string): Promise { log.debug(`comboBox.setCustom, comboBoxSelector: ${comboBoxSelector}, value: ${value}`); const comboBoxElement = await testSubjects.find(comboBoxSelector); - await this._filterOptionsList(comboBoxElement, value); + await this.setFilterValue(comboBoxElement, value); await PageObjects.common.pressEnterKey(); await this.closeOptionsList(comboBoxElement); } - async filterOptionsList(comboBoxSelector: string, filterValue: string) { + /** + * Finds combobox element and sets filter value + * + * @param comboBoxSelector data-test-subj selector + * @param filterValue text + */ + public async filterOptionsList(comboBoxSelector: string, filterValue: string): Promise { log.debug( `comboBox.filterOptionsList, comboBoxSelector: ${comboBoxSelector}, filter: ${filterValue}` ); const comboBox = await testSubjects.find(comboBoxSelector); - await this._filterOptionsList(comboBox, filterValue); + await this.setFilterValue(comboBox, filterValue); await this.closeOptionsList(comboBox); } - private async _filterOptionsList( + /** + * Sets new filter value in specified combobox element + * + * @param comboBoxElement element that wraps up EuiComboBox + * @param filterValue text + */ + private async setFilterValue( comboBoxElement: WebElementWrapper, filterValue: string ): Promise { @@ -127,10 +146,20 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont await this.waitForOptionsListLoading(comboBoxElement); } + /** + * Waits options list to be loaded + * + * @param comboBoxElement element that wraps up EuiComboBox + */ private async waitForOptionsListLoading(comboBoxElement: WebElementWrapper): Promise { await comboBoxElement.waitForDeletedByCssSelector('.euiLoadingSpinner'); } + /** + * Returns options list as a single string + * + * @param comboBoxSelector data-test-subj selector + */ public async getOptionsList(comboBoxSelector: string): Promise { log.debug(`comboBox.getOptionsList, comboBoxSelector: ${comboBoxSelector}`); const comboBox = await testSubjects.find(comboBoxSelector); @@ -148,37 +177,33 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont return optionsText; } + /** + * Finds combobox element and checks if it has selected options + * + * @param comboBoxSelector data-test-subj selector + */ public async doesComboBoxHaveSelectedOptions(comboBoxSelector: string): Promise { log.debug(`comboBox.doesComboBoxHaveSelectedOptions, comboBoxSelector: ${comboBoxSelector}`); const comboBox = await testSubjects.find(comboBoxSelector); - const selectedOptions = await comboBox.findAllByClassName( - 'euiComboBoxPill', - WAIT_FOR_EXISTS_TIME - ); - return selectedOptions.length > 0; + const $ = await comboBox.parseDomContent(); + return $('.euiComboBoxPill').toArray().length > 0; } + /** + * Returns selected options + * @param comboBoxSelector data-test-subj selector + */ public async getComboBoxSelectedOptions(comboBoxSelector: string): Promise { log.debug(`comboBox.getComboBoxSelectedOptions, comboBoxSelector: ${comboBoxSelector}`); - return await retry.try(async () => { - const comboBox = await testSubjects.find(comboBoxSelector); - const selectedOptions = await comboBox.findAllByClassName( - 'euiComboBoxPill', - WAIT_FOR_EXISTS_TIME - ); - if (selectedOptions.length === 0) { - return []; - } - return Promise.all( - selectedOptions.map(async optionElement => { - return await optionElement.getVisibleText(); - }) - ); - }); + const comboBox = await testSubjects.find(comboBoxSelector); + const $ = await comboBox.parseDomContent(); + return $('.euiComboBoxPill') + .toArray() + .map(option => $(option).text()); } /** - * clearing value from combobox + * Finds combobox element and clears value in the input field by clicking clear button * * @param comboBoxSelector data-test-subj selector */ @@ -212,9 +237,9 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont } /** - * closing option list for combobox + * Closes options list * - * @param comboBoxElement + * @param comboBoxElement element that wraps up EuiComboBox */ public async closeOptionsList(comboBoxElement: WebElementWrapper): Promise { const isOptionsListOpen = await testSubjects.exists('comboBoxOptionsList'); @@ -225,9 +250,9 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont } /** - * opened list of options for combobox + * Opens options list * - * @param comboBoxElement + * @param comboBoxElement element that wraps up EuiComboBox */ public async openOptionsList(comboBoxElement: WebElementWrapper): Promise { const isOptionsListOpen = await testSubjects.exists('comboBoxOptionsList'); @@ -240,10 +265,10 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont } /** - * check if option is already selected + * Checks if specified option is already selected * - * @param comboBoxElement - * @param value + * @param comboBoxElement element that wraps up EuiComboBox + * @param value option text */ public async isOptionSelected( comboBoxElement: WebElementWrapper, diff --git a/test/functional/services/embedding.js b/test/functional/services/embedding.ts similarity index 86% rename from test/functional/services/embedding.js rename to test/functional/services/embedding.ts index 2242be2923c15..9a6bb3ba9b4e2 100644 --- a/test/functional/services/embedding.js +++ b/test/functional/services/embedding.ts @@ -17,20 +17,23 @@ * under the License. */ -export function EmbeddingProvider({ getService, getPageObjects }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export function EmbeddingProvider({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const log = getService('log'); const PageObjects = getPageObjects(['header']); class Embedding { - - async openInEmbeddedMode() { + /** + * Opens current page in embeded mode + */ + public async openInEmbeddedMode(): Promise { const currentUrl = await browser.getCurrentUrl(); log.debug(`Opening in embedded mode: ${currentUrl}`); await browser.get(`${currentUrl}&embed=true`); await PageObjects.header.waitUntilLoadingHasFinished(); } - } return new Embedding(); diff --git a/test/functional/services/filter_bar.js b/test/functional/services/filter_bar.ts similarity index 71% rename from test/functional/services/filter_bar.js rename to test/functional/services/filter_bar.ts index 51b3de354bc82..987fa1b2df1d1 100644 --- a/test/functional/services/filter_bar.js +++ b/test/functional/services/filter_bar.ts @@ -17,48 +17,70 @@ * under the License. */ -export function FilterBarProvider({ getService, getPageObjects }) { - const testSubjects = getService('testSubjects'); +import { FtrProviderContext } from '../ftr_provider_context'; + +export function FilterBarProvider({ getService, getPageObjects }: FtrProviderContext) { const comboBox = getService('comboBox'); + const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common', 'header']); class FilterBar { - hasFilter(key, value, enabled = true) { + /** + * Checks if specified filter exists + * + * @param key field name + * @param value filter value + * @param enabled filter status + */ + public async hasFilter(key: string, value: string, enabled: boolean = true): Promise { const filterActivationState = enabled ? 'enabled' : 'disabled'; - return testSubjects.exists( + return await testSubjects.exists( `filter & filter-key-${key} & filter-value-${value} & filter-${filterActivationState}`, { - allowHidden: true + allowHidden: true, } ); } - async removeFilter(key) { + /** + * Removes specified filter + * + * @param key field name + */ + public async removeFilter(key: string): Promise { await testSubjects.click(`filter & filter-key-${key}`); await testSubjects.click(`deleteFilter`); await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); } - async removeAllFilters() { + /** + * Removes all filters + */ + public async removeAllFilters(): Promise { await testSubjects.click('showFilterActions'); await testSubjects.click('removeAllFilters'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.common.waitUntilUrlIncludes('filters:!()'); } - async toggleFilterEnabled(key) { + /** + * Changes filter active status + * + * @param key field name + */ + public async toggleFilterEnabled(key: string): Promise { await testSubjects.click(`filter & filter-key-${key}`); await testSubjects.click(`disableFilter`); await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); } - async toggleFilterPinned(key) { + public async toggleFilterPinned(key: string): Promise { await testSubjects.click(`filter & filter-key-${key}`); await testSubjects.click(`pinFilter`); await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); } - async getFilterCount() { + public async getFilterCount(): Promise { const filters = await testSubjects.findAll('filter'); return filters.length; } @@ -81,12 +103,14 @@ export function FilterBarProvider({ getService, getPageObjects }) { * // Add a filter containing multiple values * filterBar.addFilter('extension', 'is one of', ['jpg', 'png']); */ - async addFilter(field, operator, ...values) { + public async addFilter(field: string, operator: string, ...values: any): Promise { await testSubjects.click('addFilter'); await comboBox.set('filterFieldSuggestionList', field); await comboBox.set('filterOperatorList', operator); const params = await testSubjects.find('filterParams'); - const paramsComboBoxes = await params.findAllByCssSelector('[data-test-subj~="filterParamsComboBox"]'); + const paramsComboBoxes = await params.findAllByCssSelector( + '[data-test-subj~="filterParamsComboBox"]' + ); const paramFields = await params.findAllByTagName('input'); for (let i = 0; i < values.length; i++) { let fieldValues = values[i]; @@ -98,8 +122,7 @@ export function FilterBarProvider({ getService, getPageObjects }) { for (let j = 0; j < fieldValues.length; j++) { await comboBox.setElement(paramsComboBoxes[i], fieldValues[j]); } - } - else if (paramFields && paramFields.length > 0) { + } else if (paramFields && paramFields.length > 0) { for (let j = 0; j < fieldValues.length; j++) { await paramFields[i].type(fieldValues[j]); } @@ -109,36 +132,60 @@ export function FilterBarProvider({ getService, getPageObjects }) { await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); } - async clickEditFilter(key, value) { + /** + * Activates filter editing + * @param key field name + * @param value field value + */ + public async clickEditFilter(key: string, value: string): Promise { await testSubjects.click(`filter & filter-key-${key} & filter-value-${value}`); await testSubjects.click(`editFilter`); await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); } - async getFilterEditorSelectedPhrases() { + /** + * Returns available phrases in the filter + */ + public async getFilterEditorSelectedPhrases(): Promise { return await comboBox.getComboBoxSelectedOptions('filterParamsComboBox'); } - async getFilterEditorFields() { + /** + * Returns available fields in the filter + */ + public async getFilterEditorFields(): Promise { const optionsString = await comboBox.getOptionsList('filterFieldSuggestionList'); return optionsString.split('\n'); } - async ensureFieldEditorModalIsClosed() { + /** + * Closes field editor modal window + */ + public async ensureFieldEditorModalIsClosed(): Promise { const cancelSaveFilterModalButtonExists = await testSubjects.exists('cancelSaveFilter'); if (cancelSaveFilterModalButtonExists) { await testSubjects.click('cancelSaveFilter'); } } - async getIndexPatterns() { + /** + * Returns comma-separated list of index patterns + */ + public async getIndexPatterns(): Promise { await testSubjects.click('addFilter'); const indexPatterns = await comboBox.getOptionsList('filterIndexPatternsSelect'); await this.ensureFieldEditorModalIsClosed(); - return indexPatterns.trim().split('\n').join(','); + return indexPatterns + .trim() + .split('\n') + .join(','); } - async selectIndexPattern(indexPatternTitle) { + /** + * Adds new index pattern filter + * @param indexPatternTitle + */ + public async selectIndexPattern(indexPatternTitle: string): Promise { await testSubjects.click('addFilter'); await comboBox.set('filterIndexPatternsSelect', indexPatternTitle); } diff --git a/test/functional/services/find.ts b/test/functional/services/find.ts index 4fdc26619d8be..29713304cd5cb 100644 --- a/test/functional/services/find.ts +++ b/test/functional/services/find.ts @@ -364,7 +364,7 @@ export async function FindProvider({ getService }: FtrProviderContext) { public async clickByButtonText( buttonText: string, - element = driver, + element: WebDriver | WebElement | WebElementWrapper = driver, timeout: number = defaultFindTimeout ): Promise { log.debug(`Find.clickByButtonText('${buttonText}') with timeout=${timeout}`); diff --git a/test/functional/services/flyout.js b/test/functional/services/flyout.ts similarity index 56% rename from test/functional/services/flyout.js rename to test/functional/services/flyout.ts index f0395e1cdd410..1ac07fd4e2e05 100644 --- a/test/functional/services/flyout.js +++ b/test/functional/services/flyout.ts @@ -17,42 +17,47 @@ * under the License. */ -export function FlyoutProvider({ getService }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export function FlyoutProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const find = getService('find'); const log = getService('log'); const retry = getService('retry'); class Flyout { - async close(testSubj) { - log.debug('Closing flyout', testSubj); - const flyoutElement = await testSubjects.find(testSubj); + public async close(dataTestSubj: string): Promise { + log.debug('Closing flyout', dataTestSubj); + const flyoutElement = await testSubjects.find(dataTestSubj); const closeBtn = await flyoutElement.findByCssSelector('[aria-label*="Close"]'); await closeBtn.click(); - await retry.waitFor('flyout closed', async () => !await testSubjects.exists(testSubj)); + await retry.waitFor('flyout closed', async () => !(await testSubjects.exists(dataTestSubj))); } - async ensureClosed(testSubj) { - if (await testSubjects.exists(testSubj)) { - await this.close(testSubj); + public async ensureClosed(dataTestSubj: string): Promise { + if (await testSubjects.exists(dataTestSubj)) { + await this.close(dataTestSubj); } } - async ensureAllClosed() { + public async ensureAllClosed(): Promise { const flyoutElements = await find.allByCssSelector('.euiFlyout'); if (!flyoutElements.length) { return; } - await Promise.all(flyoutElements.map(async (flyoutElement) => { - const closeBtn = await flyoutElement.findByCssSelector('[aria-label*="Close"]'); - await closeBtn.click(); - })); + await Promise.all( + flyoutElements.map(async flyoutElement => { + const closeBtn = await flyoutElement.findByCssSelector('[aria-label*="Close"]'); + await closeBtn.click(); + }) + ); - await retry.waitFor('all flyouts to be closed', async () => ( - (await find.allByCssSelector('.euiFlyout')).length === 0 - )); + await retry.waitFor( + 'all flyouts to be closed', + async () => (await find.allByCssSelector('.euiFlyout')).length === 0 + ); } } diff --git a/test/functional/services/global_nav.js b/test/functional/services/global_nav.ts similarity index 73% rename from test/functional/services/global_nav.js rename to test/functional/services/global_nav.ts index 332efdbd2bd7b..9622d29800930 100644 --- a/test/functional/services/global_nav.js +++ b/test/functional/services/global_nav.ts @@ -18,39 +18,42 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; -export function GlobalNavProvider({ getService }) { +export function GlobalNavProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); - return new class GlobalNav { - async moveMouseToLogo() { + class GlobalNav { + public async moveMouseToLogo(): Promise { await testSubjects.moveMouseTo('headerGlobalNav logo'); } - async clickLogo() { + public async clickLogo(): Promise { return await testSubjects.click('headerGlobalNav logo'); } - async exists() { + public async exists(): Promise { return await testSubjects.exists('headerGlobalNav'); } - async getFirstBreadcrumb() { + public async getFirstBreadcrumb(): Promise { return await testSubjects.getVisibleText('headerGlobalNav breadcrumbs first&breadcrumb'); } - async getLastBreadcrumb() { + public async getLastBreadcrumb(): Promise { return await testSubjects.getVisibleText('headerGlobalNav breadcrumbs last&breadcrumb'); } - async badgeExistsOrFail(expectedLabel) { + public async badgeExistsOrFail(expectedLabel: string): Promise { await testSubjects.existOrFail('headerBadge'); const actualLabel = await testSubjects.getAttribute('headerBadge', 'data-test-badge-label'); expect(actualLabel.toUpperCase()).to.equal(expectedLabel.toUpperCase()); } - async badgeMissingOrFail() { + public async badgeMissingOrFail(): Promise { await testSubjects.missingOrFail('headerBadge'); } - }; + } + + return new GlobalNav(); } diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 60ab726bafd64..e7b02a89b4b2c 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -29,36 +29,26 @@ import { DashboardVisualizationProvider, // @ts-ignore not TS yet } from './dashboard'; -// @ts-ignore not TS yet import { DocTableProvider } from './doc_table'; -// @ts-ignore not TS yet import { EmbeddingProvider } from './embedding'; // @ts-ignore not TS yet import { FailureDebuggingProvider } from './failure_debugging'; -// @ts-ignore not TS yet import { FilterBarProvider } from './filter_bar'; import { FindProvider } from './find'; -// @ts-ignore not TS yet import { FlyoutProvider } from './flyout'; -// @ts-ignore not TS yet import { GlobalNavProvider } from './global_nav'; -// @ts-ignore not TS yet import { InspectorProvider } from './inspector'; -// @ts-ignore not TS yet import { QueryBarProvider } from './query_bar'; import { RemoteProvider } from './remote'; -// @ts-ignore not TS yet import { RenderableProvider } from './renderable'; import { ScreenshotsProvider } from './screenshots'; // @ts-ignore not TS yet import { SnapshotsProvider } from './snapshots'; -// @ts-ignore not TS yet import { TableProvider } from './table'; import { TestSubjectsProvider } from './test_subjects'; import { ToastsProvider } from './toasts'; // @ts-ignore not TS yet import { PieChartProvider } from './visualizations'; -// @ts-ignore not TS yet import { VisualizeListingTableProvider } from './visualize_listing_table'; // @ts-ignore not TS yet import { SavedQueryManagementComponentProvider } from './saved_query_management_component'; diff --git a/test/functional/services/inspector.js b/test/functional/services/inspector.ts similarity index 60% rename from test/functional/services/inspector.js rename to test/functional/services/inspector.ts index d7c3109251aaf..74fc250438635 100644 --- a/test/functional/services/inspector.js +++ b/test/functional/services/inspector.ts @@ -18,8 +18,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; -export function InspectorProvider({ getService }) { +export function InspectorProvider({ getService }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); const renderable = getService('renderable'); @@ -27,27 +28,36 @@ export function InspectorProvider({ getService }) { const testSubjects = getService('testSubjects'); const find = getService('find'); - return new class Inspector { - async getIsEnabled() { + class Inspector { + private async getIsEnabled(): Promise { const ariaDisabled = await testSubjects.getAttribute('openInspectorButton', 'disabled'); return ariaDisabled !== 'true'; } - async expectIsEnabled() { + /** + * Asserts that inspector is enabled + */ + public async expectIsEnabled(): Promise { await retry.try(async () => { const isEnabled = await this.getIsEnabled(); expect(isEnabled).to.be(true); }); } - async expectIsNotEnabled() { + /** + * Asserts that inspector is disabled + */ + public async expectIsNotEnabled(): Promise { await retry.try(async () => { const isEnabled = await this.getIsEnabled(); expect(isEnabled).to.be(false); }); } - async open() { + /** + * Opens inspector panel + */ + public async open(): Promise { log.debug('Inspector.open'); const isOpen = await testSubjects.exists('inspectorPanel'); if (!isOpen) { @@ -58,7 +68,10 @@ export function InspectorProvider({ getService }) { } } - async close() { + /** + * Closes inspector panel + */ + public async close(): Promise { log.debug('Close Inspector'); let isOpen = await testSubjects.exists('inspectorPanel'); if (isOpen) { @@ -72,13 +85,21 @@ export function InspectorProvider({ getService }) { } } - async expectTableData(expectedData) { + /** + * Asserts data on inspector panel + * @param expectedData + */ + public async expectTableData(expectedData: string[][]): Promise { await log.debug(`Inspector.expectTableData(${expectedData.join(',')})`); const data = await this.getTableData(); expect(data).to.eql(expectedData); } - async setTablePageSize(size) { + /** + * Sets table page size + * @param size rows count + */ + public async setTablePageSize(size: number): Promise { const panel = await testSubjects.find('inspectorPanel'); await find.clickByButtonText('Rows per page: 20', panel); // The buttons for setting table page size are in a popover element. This popover @@ -88,27 +109,43 @@ export function InspectorProvider({ getService }) { await find.clickByButtonText(`${size} rows`, tableSizesPopover); } - async getTableData() { + /** + * Returns table data in nested array format + */ + public async getTableData(): Promise { // TODO: we should use datat-test-subj=inspectorTable as soon as EUI supports it const inspectorPanel = await testSubjects.find('inspectorPanel'); const tableBody = await retry.try(async () => inspectorPanel.findByTagName('tbody')); const $ = await tableBody.parseDomContent(); - return $('tr').toArray().map(tr => { - return $(tr).find('td').toArray().map(cell => { - // if this is an EUI table, filter down to the specific cell content - // otherwise this will include mobile-specific header information - const euiTableCellContent = $(cell).find('.euiTableCellContent'); - - if (euiTableCellContent.length > 0) { - return $(cell).find('.euiTableCellContent').text().trim(); - } else { - return $(cell).text().trim(); - } + return $('tr') + .toArray() + .map(tr => { + return $(tr) + .find('td') + .toArray() + .map(cell => { + // if this is an EUI table, filter down to the specific cell content + // otherwise this will include mobile-specific header information + const euiTableCellContent = $(cell).find('.euiTableCellContent'); + + if (euiTableCellContent.length > 0) { + return $(cell) + .find('.euiTableCellContent') + .text() + .trim(); + } else { + return $(cell) + .text() + .trim(); + } + }); }); - }); } - async getTableHeaders() { + /** + * Returns table headers + */ + public async getTableHeaders(): Promise { log.debug('Inspector.getTableHeaders'); // TODO: we should use datat-test-subj=inspectorTable as soon as EUI supports it const dataTableHeader = await retry.try(async () => { @@ -116,21 +153,37 @@ export function InspectorProvider({ getService }) { return await inspectorPanel.findByTagName('thead'); }); const $ = await dataTableHeader.parseDomContent(); - return $('th span.euiTableCellContent__text').toArray() - .map(cell => $(cell).text().trim()); + return $('th span.euiTableCellContent__text') + .toArray() + .map(cell => + $(cell) + .text() + .trim() + ); } - async expectTableHeaders(expected) { + /** + * Asserts table headers + * @param expected expected headers + */ + public async expectTableHeaders(expected: string[]): Promise { await retry.try(async () => { const headers = await this.getTableHeaders(); expect(headers).to.eql(expected); }); } - async filterForTableCell(column, row) { + /** + * Filters table for value by clicking specified cell + * @param column column index + * @param row row index + */ + public async filterForTableCell(column: string, row: string): Promise { await retry.try(async () => { const table = await testSubjects.find('inspectorTable'); - const cell = await table.findByCssSelector(`tbody tr:nth-child(${row}) td:nth-child(${column})`); + const cell = await table.findByCssSelector( + `tbody tr:nth-child(${row}) td:nth-child(${column})` + ); await cell.moveMouseTo(); const filterBtn = await testSubjects.findDescendant('filterForInspectorCellValue', cell); await filterBtn.click(); @@ -138,10 +191,17 @@ export function InspectorProvider({ getService }) { await renderable.waitForRender(); } - async filterOutTableCell(column, row) { + /** + * Filters out table by clicking specified cell + * @param column column index + * @param row row index + */ + public async filterOutTableCell(column: string, row: string): Promise { await retry.try(async () => { const table = await testSubjects.find('inspectorTable'); - const cell = await table.findByCssSelector(`tbody tr:nth-child(${row}) td:nth-child(${column})`); + const cell = await table.findByCssSelector( + `tbody tr:nth-child(${row}) td:nth-child(${column})` + ); await cell.moveMouseTo(); const filterBtn = await testSubjects.findDescendant('filterOutInspectorCellValue', cell); await filterBtn.click(); @@ -149,28 +209,43 @@ export function InspectorProvider({ getService }) { await renderable.waitForRender(); } - async openInspectorView(viewId) { + /** + * Opens inspector view + * @param viewId + */ + public async openInspectorView(viewId: string): Promise { log.debug(`Open Inspector view ${viewId}`); await testSubjects.click('inspectorViewChooser'); await testSubjects.click(viewId); } - async openInspectorRequestsView() { + /** + * Opens inspector requests view + */ + public async openInspectorRequestsView(): Promise { await this.openInspectorView('inspectorViewChooserRequests'); } - async getRequestNames() { + /** + * Returns request name as the comma-separated string + */ + public async getRequestNames(): Promise { await this.openInspectorRequestsView(); const requestChooserExists = await testSubjects.exists('inspectorRequestChooser'); if (requestChooserExists) { await testSubjects.click('inspectorRequestChooser'); const menu = await testSubjects.find('inspectorRequestChooserMenuPanel'); const requestNames = await menu.getVisibleText(); - return requestNames.trim().split('\n').join(','); + return requestNames + .trim() + .split('\n') + .join(','); } const singleRequest = await testSubjects.find('inspectorRequestName'); return await singleRequest.getVisibleText(); } - }; + } + + return new Inspector(); } diff --git a/test/functional/services/query_bar.js b/test/functional/services/query_bar.ts similarity index 81% rename from test/functional/services/query_bar.js rename to test/functional/services/query_bar.ts index eb11ff10c8ed8..ace8b97155c09 100644 --- a/test/functional/services/query_bar.js +++ b/test/functional/services/query_bar.ts @@ -17,7 +17,9 @@ * under the License. */ -export function QueryBarProvider({ getService, getPageObjects }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export function QueryBarProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); const log = getService('log'); @@ -25,12 +27,11 @@ export function QueryBarProvider({ getService, getPageObjects }) { const find = getService('find'); class QueryBar { - - async getQueryString() { + async getQueryString(): Promise { return await testSubjects.getAttribute('queryInput', 'value'); } - async setQuery(query) { + public async setQuery(query: string): Promise { log.debug(`QueryBar.setQuery(${query})`); // Extra caution used because of flaky test here: https://github.com/elastic/kibana/issues/16978 doesn't seem // to be actually setting the query in the query input based off @@ -44,22 +45,23 @@ export function QueryBarProvider({ getService, getPageObjects }) { await input.type(query); const currentQuery = await this.getQueryString(); if (currentQuery !== query) { - throw new Error(`Failed to set query input to ${query}, instead query is ${currentQuery}`); + throw new Error( + `Failed to set query input to ${query}, instead query is ${currentQuery}` + ); } }); } - async submitQuery() { + public async submitQuery(): Promise { log.debug('QueryBar.submitQuery'); await testSubjects.click('queryInput'); await PageObjects.common.pressEnterKey(); await PageObjects.header.waitUntilLoadingHasFinished(); } - async clickQuerySubmitButton() { + public async clickQuerySubmitButton(): Promise { await testSubjects.click('querySubmitButton'); } - } return new QueryBar(); diff --git a/test/functional/services/renderable.js b/test/functional/services/renderable.ts similarity index 83% rename from test/functional/services/renderable.js rename to test/functional/services/renderable.ts index d602ae059637c..e5a0b22fef7fb 100644 --- a/test/functional/services/renderable.js +++ b/test/functional/services/renderable.ts @@ -17,17 +17,18 @@ * under the License. */ +import { FtrProviderContext } from '../ftr_provider_context'; + const RENDER_COMPLETE_SELECTOR = '[data-render-complete="true"]'; const RENDER_COMPLETE_PENDING_SELECTOR = '[data-render-complete="false"]'; const DATA_LOADING_SELECTOR = '[data-loading]'; -export function RenderableProvider({ getService }) { +export function RenderableProvider({ getService }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); const find = getService('find'); - return new class Renderable { - + class Renderable { /** * This method waits for a certain number of objects to finish rendering and loading, which is indicated * by a couple tags. The RENDER_COMPLETE_SELECTOR indicates that it's done initially loading up. Some @@ -35,7 +36,7 @@ export function RenderableProvider({ getService }) { * return if any of those tags are found. * @param count {Number} Number of RENDER_COMPLETE_SELECTORs to wait for. */ - async waitForRender(count = 1) { + public async waitForRender(count: number = 1): Promise { log.debug(`Renderable.waitForRender for ${count} elements`); await retry.try(async () => { const completedElements = await find.allByCssSelector(RENDER_COMPLETE_SELECTOR); @@ -46,8 +47,10 @@ export function RenderableProvider({ getService }) { const title = await pendingElement.getAttribute('data-title'); pendingElementNames.push(title); } - throw new Error(`${completedElements.length} elements completed rendering, still waiting on a total of ${count} - specifically:\n${pendingElementNames.join('\n')}`); + throw new Error(`${ + completedElements.length + } elements completed rendering, still waiting on a total of ${count} + specifically:\n${pendingElementNames.join('\n')}`); } const stillLoadingElements = await find.allByCssSelector(DATA_LOADING_SELECTOR, 1000); @@ -56,5 +59,7 @@ export function RenderableProvider({ getService }) { } }); } - }; + } + + return new Renderable(); } diff --git a/test/functional/services/table.js b/test/functional/services/table.ts similarity index 51% rename from test/functional/services/table.js rename to test/functional/services/table.ts index a881699eeda0d..6298221ab8db3 100644 --- a/test/functional/services/table.js +++ b/test/functional/services/table.ts @@ -17,25 +17,36 @@ * under the License. */ -export function TableProvider({ getService }) { +import { FtrProviderContext } from '../ftr_provider_context'; +import { WebElementWrapper } from './lib/web_element_wrapper'; + +export function TableProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); - // const retry = getService('retry'); class Table { + /** + * Finds table and returns data in the nested array format + * @param dataTestSubj data-test-subj selector + */ - async getDataFromTestSubj(testSubj) { - const table = await testSubjects.find(testSubj); + public async getDataFromTestSubj(dataTestSubj: string): Promise { + const table = await testSubjects.find(dataTestSubj); return await this.getDataFromElement(table); } - async getDataFromElement(table) { - // Convert the data into a nested array format: - // [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + /** + * Converts the table data into nested array + * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + * @param table + */ + public async getDataFromElement(table: WebElementWrapper): Promise { const rows = await table.findAllByTagName('tr'); - return await Promise.all(rows.map(async row => { - const cells = await row.findAllByTagName('td'); - return await Promise.all(cells.map(async cell => await cell.getVisibleText())); - })); + return await Promise.all( + rows.map(async row => { + const cells = await row.findAllByTagName('td'); + return await Promise.all(cells.map(async cell => await cell.getVisibleText())); + }) + ); } } diff --git a/test/functional/services/visualize_listing_table.js b/test/functional/services/visualize_listing_table.ts similarity index 72% rename from test/functional/services/visualize_listing_table.js rename to test/functional/services/visualize_listing_table.ts index de9a095430afd..8c4640ada1c05 100644 --- a/test/functional/services/visualize_listing_table.js +++ b/test/functional/services/visualize_listing_table.ts @@ -17,14 +17,16 @@ * under the License. */ -export function VisualizeListingTableProvider({ getService, getPageObjects }) { +import { FtrProviderContext } from '../ftr_provider_context'; + +export function VisualizeListingTableProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const find = getService('find'); const log = getService('log'); - const PageObjects = getPageObjects(['dashboard', 'visualize', 'header', 'discover']); + const { header } = getPageObjects(['header']); class VisualizeListingTable { - async getAllVisualizationNamesOnCurrentPage() { + public async getAllVisualizationNamesOnCurrentPage(): Promise { const visualizationNames = []; const links = await find.allByCssSelector('.kuiLink'); for (let i = 0; i < links.length; i++) { @@ -34,16 +36,18 @@ export function VisualizeListingTableProvider({ getService, getPageObjects }) { return visualizationNames; } - async getAllVisualizationNames() { + public async getAllVisualizationNames(): Promise { log.debug('VisualizeListingTable.getAllVisualizationNames'); let morePages = true; - let visualizationNames = []; + let visualizationNames: string[] = []; while (morePages) { - visualizationNames = visualizationNames.concat(await this.getAllVisualizationNamesOnCurrentPage()); - morePages = !(await testSubjects.getAttribute('pagerNextButton', 'disabled') === 'true'); + visualizationNames = visualizationNames.concat( + await this.getAllVisualizationNamesOnCurrentPage() + ); + morePages = !((await testSubjects.getAttribute('pagerNextButton', 'disabled')) === 'true'); if (morePages) { await testSubjects.click('pagerNextButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); + await header.waitUntilLoadingHasFinished(); } } return visualizationNames; diff --git a/x-pack/test/functional/apps/visualize/hybrid_visualization.ts b/x-pack/test/functional/apps/visualize/hybrid_visualization.ts index adb3ae5308be9..dacd0b75b126c 100644 --- a/x-pack/test/functional/apps/visualize/hybrid_visualization.ts +++ b/x-pack/test/functional/apps/visualize/hybrid_visualization.ts @@ -83,7 +83,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.visualize.waitForVisualizationRenderingStabilized(); await inspector.open(); - await inspector.setTablePageSize('50'); + await inspector.setTablePageSize(50); await inspector.expectTableData(expectedData); }); }); From 8b97cafc0f516818d60dd66b01d25aefbcf055ff Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 11 Sep 2019 16:40:59 +0200 Subject: [PATCH 2/5] [Graph] Type persistence (#44985) --- .../angular/graph_client_workspace.d.ts | 9 + .../angular/services/outlink_encoders.js | 101 ------ x-pack/legacy/plugins/graph/public/app.js | 309 +++--------------- .../graph/public/components/graph_listing.tsx | 12 +- .../graph/public/services/outlink_encoders.ts | 134 ++++++++ .../services/persistence/deserialize.test.ts | 201 ++++++++++++ .../services/persistence/deserialize.ts | 238 ++++++++++++++ .../public/services/persistence/index.ts | 8 + .../services/persistence/serialize.test.ts | 278 ++++++++++++++++ .../public/services/persistence/serialize.ts | 111 +++++++ .../services/{save.tsx => save_modal.tsx} | 9 +- .../graph/public/services/style_choices.ts | 227 +++++++++++++ .../plugins/graph/public/services/url.ts | 8 +- .../plugins/graph/public/style_choices.js | 168 ---------- .../plugins/graph/public/types/app_state.ts | 47 +++ .../plugins/graph/public/types/index.ts | 10 + .../plugins/graph/public/types/persistence.ts | 37 ++- .../graph/public/types/workspace_state.ts | 109 ++++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 20 files changed, 1472 insertions(+), 546 deletions(-) create mode 100644 x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.d.ts delete mode 100644 x-pack/legacy/plugins/graph/public/angular/services/outlink_encoders.js create mode 100644 x-pack/legacy/plugins/graph/public/services/outlink_encoders.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/persistence/index.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts rename x-pack/legacy/plugins/graph/public/services/{save.tsx => save_modal.tsx} (86%) create mode 100644 x-pack/legacy/plugins/graph/public/services/style_choices.ts delete mode 100644 x-pack/legacy/plugins/graph/public/style_choices.js create mode 100644 x-pack/legacy/plugins/graph/public/types/app_state.ts create mode 100644 x-pack/legacy/plugins/graph/public/types/index.ts create mode 100644 x-pack/legacy/plugins/graph/public/types/workspace_state.ts diff --git a/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.d.ts b/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.d.ts new file mode 100644 index 0000000000000..de9763815b09f --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.d.ts @@ -0,0 +1,9 @@ +/* + * 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 { Workspace, WorkspaceOptions } from '../types'; + +declare function createWorkspace(options: WorkspaceOptions): Workspace; diff --git a/x-pack/legacy/plugins/graph/public/angular/services/outlink_encoders.js b/x-pack/legacy/plugins/graph/public/angular/services/outlink_encoders.js deleted file mode 100644 index eed43a4c0f712..0000000000000 --- a/x-pack/legacy/plugins/graph/public/angular/services/outlink_encoders.js +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import rison from 'rison-node'; - -import { i18n } from '@kbn/i18n'; - -export const getOutlinkEncoders = () => [{ - id: 'esq-rison-loose', - title: i18n.translate('xpack.graph.outlinkEncoders.esqRisonLooseTitle', { - defaultMessage: 'elasticsearch OR query (rison encoded)', - }), - description: i18n.translate('xpack.graph.outlinkEncoders.esqRisonLooseDescription', { - defaultMessage: 'rison-encoded JSON, minimum_should_match=1, compatible with most Kibana URLs', - }), - encode: function (workspace) { - return encodeURIComponent(rison.encode(workspace.getQuery(workspace.getSelectedOrAllNodes(), true))); - } -}, { - id: 'esq-rison', - title: i18n.translate('xpack.graph.outlinkEncoders.esqRisonTitle', { - defaultMessage: 'elasticsearch AND query (rison encoded)', - }), - description: i18n.translate('xpack.graph.outlinkEncoders.esqRisonDescription', { - defaultMessage: 'rison-encoded JSON, minimum_should_match=2, compatible with most Kibana URLs', - }), - encode: function (workspace) { - return encodeURIComponent(rison.encode(workspace.getQuery(workspace.getSelectedOrAllNodes()))); - } -}, { - id: 'esq-similar-rison', - title: i18n.translate('xpack.graph.outlinkEncoders.esqSimilarRisonTitle', { - defaultMessage: 'elasticsearch more like this query (rison encoded)', - }), - description: i18n.translate('xpack.graph.outlinkEncoders.esqSimilarRisonDescription', { - defaultMessage: 'rison-encoded JSON, "like this but not this" type query to find missing docs', - }), - encode: function (workspace) { - return encodeURIComponent(rison.encode(workspace.getLikeThisButNotThisQuery(workspace.getSelectedOrAllNodes()))); - } -}, { - id: 'esq-plain', - title: i18n.translate('xpack.graph.outlinkEncoders.esqPlainTitle', { - defaultMessage: 'elasticsearch query (plain encoding)', - }), - description: i18n.translate('xpack.graph.outlinkEncoders.esqPlainDescription', { - defaultMessage: 'JSON encoded using standard url encoding', - }), - encode: function (workspace) { - return encodeURIComponent(JSON.stringify(workspace.getQuery(workspace.getSelectedOrAllNodes()))); - } -}, { - id: 'text-plain', - title: i18n.translate('xpack.graph.outlinkEncoders.textPlainTitle', { - defaultMessage: 'plain text', - }), - description: i18n.translate('xpack.graph.outlinkEncoders.textPlainDescription', { - defaultMessage: 'Text of selected vertex labels as a plain url-encoded string', - }), - encode: function (workspace) { - let q = ''; - const nodes = workspace.getSelectedOrAllNodes(); - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (i > 0) { - q += ' '; - } - q += node.label; - } - return encodeURIComponent(q); - } -}, { - id: 'text-lucene', - title: i18n.translate('xpack.graph.outlinkEncoders.textLuceneTitle', { - defaultMessage: 'Lucene-escaped text', - }), - description: i18n.translate('xpack.graph.outlinkEncoders.textLuceneDescription', { - defaultMessage: 'Text of selected vertex labels with any Lucene special characters encoded', - }), - encode: function (workspace) { - let q = ''; - const nodes = workspace.getSelectedOrAllNodes(); - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (i > 0) { - q += ' '; - } - q += node.label; - } - //escape the Lucene special characters https://lucene.apache.org/core/2_9_4/queryparsersyntax.html#Escaping Special Characters - const luceneChars = '+-&|!(){}[]^"~*?:\\'; - q = q - .split('') - .map(char => luceneChars.includes(char) ? `\\${char}` : char) - .join(''); - return encodeURIComponent(q); - } -}]; diff --git a/x-pack/legacy/plugins/graph/public/app.js b/x-pack/legacy/plugins/graph/public/app.js index dd9b326127380..f5acaa42eab1b 100644 --- a/x-pack/legacy/plugins/graph/public/app.js +++ b/x-pack/legacy/plugins/graph/public/app.js @@ -50,14 +50,15 @@ import { iconChoices, colorChoices, iconChoicesByClass, - drillDownIconChoices, - drillDownIconChoicesByClass -} from './style_choices'; + urlTemplateIconChoices, + urlTemplateIconChoicesByClass +} from './services/style_choices'; import { - getOutlinkEncoders, -} from './angular/services/outlink_encoders'; + outlinkEncoders, +} from './services/outlink_encoders'; import { getEditUrl, getNewPath, getEditPath, setBreadcrumbs, getHomePath } from './services/url'; -import { save } from './services/save'; +import { appStateToSavedWorkspace, savedWorkspaceToAppState, lookupIndexPattern, mapFields } from './services/persistence'; +import { save } from './services/save_modal'; import settingsTemplate from './angular/templates/settings.html'; @@ -225,11 +226,11 @@ app.controller('graphuiPlugin', function ( $scope.spymode = 'request'; $scope.iconChoices = iconChoices; - $scope.drillDownIconChoices = drillDownIconChoices; + $scope.drillDownIconChoices = urlTemplateIconChoices; $scope.colors = colorChoices; $scope.iconChoicesByClass = iconChoicesByClass; - $scope.outlinkEncoders = getOutlinkEncoders(i18n); + $scope.outlinkEncoders = outlinkEncoders; $scope.fields = []; $scope.canEditDrillDownUrls = chrome.getInjected('canEditDrillDownUrls'); @@ -382,7 +383,7 @@ app.controller('graphuiPlugin', function ( }); }; - $scope.indexSelected = function (selectedIndex, postInitHandler) { + $scope.indexSelected = function (selectedIndex) { $scope.clearWorkspace(); $scope.allFields = []; $scope.selectedFields = []; @@ -392,59 +393,15 @@ app.controller('graphuiPlugin', function ( $scope.selectedIndex = selectedIndex; $scope.proposedIndex = selectedIndex; - const promise = $route.current.locals.GetIndexPatternProvider.get(selectedIndex.id); - promise + return $route.current.locals.GetIndexPatternProvider.get(selectedIndex.id) .then(handleSuccess) .then(function (indexPattern) { - const patternFields = indexPattern.getNonScriptedFields(); - const blockedFieldNames = ['_id', '_index', '_score', '_source', '_type']; - patternFields.forEach(function (field, index) { - if (blockedFieldNames.indexOf(field.name) >= 0) { - return; - } - const graphFieldDef = { - 'name': field.name - }; - $scope.allFields.push(graphFieldDef); - graphFieldDef.hopSize = 5; //Default the number of results returned per hop - graphFieldDef.lastValidHopSize = graphFieldDef.hopSize; - graphFieldDef.icon = $scope.iconChoices[0]; - for (let i = 0; i < $scope.iconChoices.length; i++) { - const icon = $scope.iconChoices[i]; - for (let p = 0; p < icon.patterns.length; p++) { - const pattern = icon.patterns[p]; - if (pattern.test(graphFieldDef.name)) { - graphFieldDef.icon = icon; - break; - } - } - } - graphFieldDef.color = $scope.colors[index % $scope.colors.length]; - }); - $scope.setAllFieldStatesToDefault(); - - $scope.allFields.sort(function (a, b) { - // TODO - should we use "popularity" setting from index pattern definition? - // What is its intended use? Couldn't see it on the patternField objects - if (a.name < b.name) { - return -1; - } else if (a.name > b.name) { - return 1; - } - return 0; - }); + $scope.allFields = mapFields(indexPattern); $scope.filteredFields = $scope.allFields; if ($scope.allFields.length > 0) { $scope.selectedField = $scope.allFields[0]; } - - - if (postInitHandler) { - postInitHandler(); - } - }, handleError); - }; @@ -935,151 +892,47 @@ app.controller('graphuiPlugin', function ( // Deal with situation of request to open saved workspace if ($route.current.locals.savedWorkspace) { - - const wsObj = JSON.parse($route.current.locals.savedWorkspace.wsState); $scope.savedWorkspace = $route.current.locals.savedWorkspace; - $scope.description = $route.current.locals.savedWorkspace.description; - - // Load any saved drill-down templates - wsObj.urlTemplates.forEach(urlTemplate => { - const encoder = $scope.outlinkEncoders.find(outlinkEncoder => outlinkEncoder.id === urlTemplate.encoderID); - if (encoder) { - const template = { - url: urlTemplate.url, - description: urlTemplate.description, - encoder: encoder, - }; - if (urlTemplate.iconClass) { - template.icon = drillDownIconChoicesByClass[urlTemplate.iconClass]; - } - $scope.urlTemplates.push(template); - } - }); - - //Lookup the saved index pattern title - let savedObjectIndexPattern = null; - $scope.indices.forEach(function (savedObject) { - // wsObj.indexPattern is the title string of an indexPattern which - // we attempt here to look up in the list of currently saved objects - // that contain index pattern definitions - if(savedObject.attributes.title === wsObj.indexPattern) { - savedObjectIndexPattern = savedObject; - } - }); - if(!savedObjectIndexPattern) { + const selectedIndex = lookupIndexPattern($scope.savedWorkspace, $scope.indices); + if(!selectedIndex) { toastNotifications.addDanger( i18n.translate('xpack.graph.loadWorkspace.missingIndexPatternErrorMessage', { - defaultMessage: 'Missing index pattern {indexPattern}', - values: { indexPattern: wsObj.indexPattern }, + defaultMessage: 'Index pattern not found', }) ); return; } - - $scope.indexSelected(savedObjectIndexPattern, function () { - Object.assign($scope.exploreControls, wsObj.exploreControls); - - if ($scope.exploreControls.sampleDiversityField) { - $scope.exploreControls.sampleDiversityField = $scope.allFields.find(field => - $scope.exploreControls.sampleDiversityField.name === field.name); - } - - for (const i in wsObj.selectedFields) { - const savedField = wsObj.selectedFields[i]; - for (const f in $scope.allFields) { - const field = $scope.allFields[f]; - if (savedField.name === field.name) { - field.hopSize = savedField.hopSize; - field.lastValidHopSize = savedField.lastValidHopSize; - field.color = savedField.color; - field.icon = $scope.iconChoicesByClass[savedField.iconClass]; - field.selected = true; - $scope.selectedFields.push(field); - break; - } - } - //TODO what if field name no longer exists as part of the index-pattern definition? - } - - $scope.updateLiveResponseFields(); + $scope.selectedIndex = selectedIndex; + $scope.proposedIndex = selectedIndex; + $route.current.locals.GetIndexPatternProvider.get(selectedIndex.id).then(indexPattern => { initWorkspaceIfRequired(); - const graph = { - nodes: [], - edges: [] - }; - for (const i in wsObj.vertices) { - var vertex = wsObj.vertices[i]; // eslint-disable-line no-var - const node = { - field: vertex.field, - term: vertex.term, - label: vertex.label, - color: vertex.color, - icon: $scope.allFields.filter(function (fieldDef) { - return vertex.field === fieldDef.name; - })[0].icon, - data: {} - }; - graph.nodes.push(node); - } - for (const i in wsObj.blacklist) { - var vertex = wsObj.vertices[i]; // eslint-disable-line no-var - const fieldDef = $scope.allFields.filter(function (fieldDef) { - return vertex.field === fieldDef.name; - })[0]; - if (fieldDef) { - const node = { - field: vertex.field, - term: vertex.term, - label: vertex.label, - color: vertex.color, - icon: fieldDef.icon, - data: { - field: vertex.field, - term: vertex.term - } - }; - $scope.workspace.blacklistedNodes.push(node); - } - } - for (const i in wsObj.links) { - const link = wsObj.links[i]; - graph.edges.push({ - source: link.source, - target: link.target, - inferred: link.inferred, - label: link.label, - term: vertex.term, - width: link.width, - weight: link.weight - }); - } - - $scope.workspace.mergeGraph(graph); - - // Wire up parents and children - for (const i in wsObj.vertices) { - const vertex = wsObj.vertices[i]; - const vId = $scope.workspace.makeNodeId(vertex.field, vertex.term); - const visNode = $scope.workspace.nodesMap[vId]; - // Default the positions. - visNode.x = vertex.x; - visNode.y = vertex.y; - if (vertex.parent !== null) { - const parentSavedObj = graph.nodes[vertex.parent]; - const parentId = $scope.workspace.makeNodeId(parentSavedObj.field, parentSavedObj.term); - visNode.parent = $scope.workspace.nodesMap[parentId]; - } - } + const { + urlTemplates, + advancedSettings, + workspace, + allFields, + selectedFields, + } = savedWorkspaceToAppState($scope.savedWorkspace, indexPattern, $scope.workspace); + + // wire up stuff to angular + $scope.allFields = allFields; + $scope.selectedFields = selectedFields; + $scope.workspace = workspace; + $scope.exploreControls = advancedSettings; + $scope.urlTemplates = urlTemplates; + $scope.updateLiveResponseFields(); $scope.workspace.runLayout(); - + $scope.filteredFields = $scope.allFields; + if ($scope.allFields.length > 0) { + $scope.selectedField = $scope.allFields[0]; + } // Allow URLs to include a user-defined text query if ($route.current.params.query) { $scope.searchTerm = $route.current.params.query; $scope.submit(); } - }); - }else { + } else { $route.current.locals.SavedWorkspacesProvider.get().then(function (newWorkspace) { $scope.savedWorkspace = newWorkspace; }); @@ -1098,80 +951,18 @@ app.controller('graphuiPlugin', function ( const canSaveData = $scope.graphSavePolicy === 'configAndData' || ($scope.graphSavePolicy === 'configAndDataWithConsent' && userHasConfirmedSaveWorkspaceData); - - let blacklist = []; - let vertices = []; - let links = []; - if (canSaveData) { - blacklist = $scope.workspace.blacklistedNodes.map(function (node) { - return { - x: node.x, - y: node.y, - field: node.data.field, - term: node.data.term, - label: node.label, - color: node.color, - parent: null, - weight: node.weight, - size: node.scaledSize, - }; - }); - vertices = $scope.workspace.nodes.map(function (node) { - return { - x: node.x, - y: node.y, - field: node.data.field, - term: node.data.term, - label: node.label, - color: node.color, - parent: node.parent ? $scope.workspace.nodes.indexOf(node.parent) : null, - weight: node.weight, - size: node.scaledSize, - }; - }); - links = $scope.workspace.edges.map(function (edge) { - return { - 'weight': edge.weight, - 'width': edge.width, - 'inferred': edge.inferred, - 'label': edge.label, - 'source': $scope.workspace.nodes.indexOf(edge.source), - 'target': $scope.workspace.nodes.indexOf(edge.target) - }; - }); - } - - const urlTemplates = $scope.urlTemplates.map(function (template) { - const result = { - 'url': template.url, - 'description': template.description, - 'encoderID': template.encoder.id - }; - if (template.icon) { - result.iconClass = template.icon.class; - } - return result; - }); - - $scope.savedWorkspace.wsState = JSON.stringify({ - 'indexPattern': $scope.selectedIndex.attributes.title, - 'selectedFields': $scope.selectedFields.map(function (field) { - return { - 'name': field.name, - 'lastValidHopSize': field.lastValidHopSize, - 'color': field.color, - 'iconClass': field.icon.class, - 'hopSize': field.hopSize - }; - }), - blacklist, - vertices, - links, - urlTemplates, - exploreControls: $scope.exploreControls - }); - $scope.savedWorkspace.numVertices = vertices.length; - $scope.savedWorkspace.numLinks = links.length; + appStateToSavedWorkspace( + $scope.savedWorkspace, + { + workspace: $scope.workspace, + urlTemplates: $scope.urlTemplates, + advancedSettings: $scope.exploreControls, + selectedIndex: $scope.selectedIndex, + selectedFields: $scope.selectedFields + }, + $scope.graphSavePolicy === 'configAndData' || + ($scope.graphSavePolicy === 'configAndDataWithConsent' && userHasConfirmedSaveWorkspaceData) + ); return $scope.savedWorkspace.save(saveOptions).then(function (id) { if (id) { diff --git a/x-pack/legacy/plugins/graph/public/components/graph_listing.tsx b/x-pack/legacy/plugins/graph/public/components/graph_listing.tsx index 75dcc61017c7a..65394bdfe9086 100644 --- a/x-pack/legacy/plugins/graph/public/components/graph_listing.tsx +++ b/x-pack/legacy/plugins/graph/public/components/graph_listing.tsx @@ -11,14 +11,14 @@ import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui'; // @ts-ignore import { TableListView } from '../../../../../../src/legacy/core_plugins/kibana/public/table_list_view/table_list_view'; -import { SavedGraphWorkspace } from '../types/persistence'; +import { GraphWorkspaceSavedObject } from '../types'; export interface GraphListingProps { createItem: () => void; - findItems: (query: string, limit: number) => Promise; + findItems: (query: string, limit: number) => Promise; deleteItems: (ids: string[]) => Promise; - editItem: (record: SavedGraphWorkspace) => void; - getViewUrl: (record: SavedGraphWorkspace) => string; + editItem: (record: GraphWorkspaceSavedObject) => void; + getViewUrl: (record: GraphWorkspaceSavedObject) => string; listingLimit: number; hideWriteControls: boolean; capabilities: { save: boolean; delete: boolean }; @@ -131,11 +131,11 @@ interface DataColumn { field: string; name: string; sortable?: boolean; - render?: (value: string, item: SavedGraphWorkspace) => React.ReactNode; + render?: (value: string, item: GraphWorkspaceSavedObject) => React.ReactNode; dataType?: 'auto' | 'string' | 'number' | 'date' | 'boolean'; } -function getTableColumns(getViewUrl: (record: SavedGraphWorkspace) => string): DataColumn[] { +function getTableColumns(getViewUrl: (record: GraphWorkspaceSavedObject) => string): DataColumn[] { return [ { field: 'title', diff --git a/x-pack/legacy/plugins/graph/public/services/outlink_encoders.ts b/x-pack/legacy/plugins/graph/public/services/outlink_encoders.ts new file mode 100644 index 0000000000000..5b7ea38f078d7 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/outlink_encoders.ts @@ -0,0 +1,134 @@ +/* + * 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 rison from 'rison-node'; + +import { i18n } from '@kbn/i18n'; +import { Workspace } from '../types'; + +export interface OutlinkEncoder { + id: string; + title: string; + description: string; + encode: (workspace: Workspace) => string; + type: 'kql' | 'lucene' | 'plain' | 'esq'; +} + +export const outlinkEncoders: OutlinkEncoder[] = [ + { + id: 'esq-rison-loose', + title: i18n.translate('xpack.graph.outlinkEncoders.esqRisonLooseTitle', { + defaultMessage: 'elasticsearch OR query (rison encoded)', + }), + description: i18n.translate('xpack.graph.outlinkEncoders.esqRisonLooseDescription', { + defaultMessage: + 'rison-encoded JSON, minimum_should_match=1, compatible with most Kibana URLs', + }), + encode(workspace) { + return encodeURIComponent( + rison.encode(workspace.getQuery(workspace.getSelectedOrAllNodes(), true)) + ); + }, + type: 'esq', + }, + { + id: 'esq-rison', + title: i18n.translate('xpack.graph.outlinkEncoders.esqRisonTitle', { + defaultMessage: 'elasticsearch AND query (rison encoded)', + }), + description: i18n.translate('xpack.graph.outlinkEncoders.esqRisonDescription', { + defaultMessage: + 'rison-encoded JSON, minimum_should_match=2, compatible with most Kibana URLs', + }), + encode(workspace) { + return encodeURIComponent( + rison.encode(workspace.getQuery(workspace.getSelectedOrAllNodes())) + ); + }, + type: 'esq', + }, + { + id: 'esq-similar-rison', + title: i18n.translate('xpack.graph.outlinkEncoders.esqSimilarRisonTitle', { + defaultMessage: 'elasticsearch more like this query (rison encoded)', + }), + description: i18n.translate('xpack.graph.outlinkEncoders.esqSimilarRisonDescription', { + defaultMessage: + 'rison-encoded JSON, "like this but not this" type query to find missing docs', + }), + encode(workspace) { + return encodeURIComponent( + rison.encode(workspace.getLikeThisButNotThisQuery(workspace.getSelectedOrAllNodes())) + ); + }, + type: 'esq', + }, + { + id: 'esq-plain', + title: i18n.translate('xpack.graph.outlinkEncoders.esqPlainTitle', { + defaultMessage: 'elasticsearch query (plain encoding)', + }), + description: i18n.translate('xpack.graph.outlinkEncoders.esqPlainDescription', { + defaultMessage: 'JSON encoded using standard url encoding', + }), + encode(workspace) { + return encodeURIComponent( + JSON.stringify(workspace.getQuery(workspace.getSelectedOrAllNodes())) + ); + }, + type: 'esq', + }, + { + id: 'text-plain', + title: i18n.translate('xpack.graph.outlinkEncoders.textPlainTitle', { + defaultMessage: 'plain text', + }), + description: i18n.translate('xpack.graph.outlinkEncoders.textPlainDescription', { + defaultMessage: 'Text of selected vertex labels as a plain url-encoded string', + }), + encode(workspace) { + let q = ''; + const nodes = workspace.getSelectedOrAllNodes(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (i > 0) { + q += ' '; + } + q += node.label; + } + return encodeURIComponent(q); + }, + type: 'plain', + }, + { + id: 'text-lucene', + title: i18n.translate('xpack.graph.outlinkEncoders.textLuceneTitle', { + defaultMessage: 'Lucene-escaped text', + }), + description: i18n.translate('xpack.graph.outlinkEncoders.textLuceneDescription', { + defaultMessage: 'Text of selected vertex labels with any Lucene special characters encoded', + }), + encode(workspace) { + let q = ''; + const nodes = workspace.getSelectedOrAllNodes(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (i > 0) { + q += ' '; + } + q += node.label; + } + // escape the Lucene special characters https://lucene.apache.org/core/2_9_4/queryparsersyntax.html#Escaping Special Characters + const luceneChars = '+-&|!(){}[]^"~*?:\\'; + q = q + .split('') + .map(char => (luceneChars.includes(char) ? `\\${char}` : char)) + .join(''); + return encodeURIComponent(q); + }, + type: 'lucene', + }, +]; 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 new file mode 100644 index 0000000000000..fa620e5ad4cae --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts @@ -0,0 +1,201 @@ +/* + * 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 { GraphWorkspaceSavedObject } from '../../types'; +import { savedWorkspaceToAppState } from './deserialize'; +import { IndexPattern } from 'src/legacy/core_plugins/data/public'; +import { createWorkspace } from '../../angular/graph_client_workspace'; +import { outlinkEncoders } from '../outlink_encoders'; + +describe('deserialize', () => { + let savedWorkspace: GraphWorkspaceSavedObject; + + beforeEach(() => { + savedWorkspace = { + title: '', + description: '', + numLinks: 2, + numVertices: 4, + wsState: JSON.stringify({ + indexPattern: 'Testindexpattern', + selectedFields: [ + { color: 'black', name: 'field1', selected: true, iconClass: 'a' }, + { color: 'black', name: 'field2', selected: true, iconClass: 'b' }, + ], + blacklist: [ + { + color: 'black', + label: 'Z', + x: 1, + y: 2, + field: 'field1', + term: 'Z', + parent: null, + size: 10, + }, + ], + vertices: [ + { + color: 'black', + label: 'A', + x: 1, + y: 2, + field: 'field1', + term: 'A', + parent: null, + size: 10, + }, + { + color: 'black', + label: 'B', + x: 3, + y: 4, + field: 'field1', + term: 'B', + parent: 2, + size: 10, + }, + { + color: 'black', + label: 'B', + x: 5, + y: 6, + field: 'field1', + term: 'C', + parent: null, + size: 10, + }, + { + color: 'black', + label: 'D', + x: 7, + y: 8, + field: 'field2', + term: 'D', + parent: 2, + size: 10, + }, + { + color: 'black', + label: 'E', + x: 9, + y: 10, + field: 'field2', + term: 'E', + parent: null, + size: 10, + }, + ], + links: [ + { inferred: false, label: '', weight: 5, width: 5, source: 2, target: 0 }, + { inferred: false, label: '', weight: 5, width: 5, source: 2, target: 4 }, + ], + urlTemplates: [ + { + description: 'Template', + url: 'test-url', + encoderID: 'esq-rison-loose', + iconClass: 'd', + }, + ], + exploreControls: { + useSignificance: true, + sampleSize: 1000, + timeoutMillis: 5000, + maxValuesPerDoc: 1, + minDocCount: 3, + }, + }), + } as GraphWorkspaceSavedObject; + }); + + function callSavedWorkspaceToAppState() { + return savedWorkspaceToAppState( + savedWorkspace, + { + getNonScriptedFields: () => [{ name: 'field1' }, { name: 'field2' }, { name: 'field3' }], + } as IndexPattern, + createWorkspace({}) + ); + } + + it('should deserialize settings', () => { + const { advancedSettings } = callSavedWorkspaceToAppState(); + + expect(advancedSettings.sampleSize).toEqual(1000); + }); + + it('should deserialize fields', () => { + const { allFields, selectedFields } = callSavedWorkspaceToAppState(); + + expect(allFields).toMatchInlineSnapshot(` + Array [ + Object { + "color": "black", + "hopSize": undefined, + "icon": undefined, + "lastValidHopSize": undefined, + "name": "field1", + "selected": true, + }, + Object { + "color": "black", + "hopSize": undefined, + "icon": undefined, + "lastValidHopSize": undefined, + "name": "field2", + "selected": true, + }, + Object { + "color": "#8ee684", + "hopSize": 5, + "icon": Object { + "class": "fa-folder-open-o", + "code": "", + "patterns": Array [ + /category/i, + /folder/i, + /group/i, + ], + }, + "lastValidHopSize": 5, + "name": "field3", + "selected": false, + }, + ] + `); + + expect(selectedFields.length).toEqual(2); + selectedFields.forEach(field => expect(allFields.includes(field)).toEqual(true)); + }); + + it('should deserialize url templates', () => { + const { urlTemplates } = callSavedWorkspaceToAppState(); + + expect(urlTemplates[0].description).toBe('Template'); + expect(urlTemplates[0].encoder).toBe(outlinkEncoders[0]); + }); + + it('should deserialize nodes and edges', () => { + const { workspace } = callSavedWorkspaceToAppState(); + + expect(workspace.blacklistedNodes.length).toEqual(1); + expect(workspace.nodes.length).toEqual(5); + expect(workspace.edges.length).toEqual(2); + + // C is parent of B and D + expect(workspace.nodes[3].parent).toBe(workspace.nodes[2]); + expect(workspace.nodes[1].parent).toBe(workspace.nodes[2]); + + // A <-> C + expect(workspace.edges[0].source).toBe(workspace.nodes[2]); + expect(workspace.edges[0].target).toBe(workspace.nodes[0]); + + // C <-> E + expect(workspace.edges[1].source).toBe(workspace.nodes[2]); + expect(workspace.edges[1].target).toBe(workspace.nodes[4]); + }); +}); diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts new file mode 100644 index 0000000000000..4a30f21cef335 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts @@ -0,0 +1,238 @@ +/* + * 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/index_patterns/index_patterns'; +import { + AppState, + SerializedNode, + UrlTemplate, + SerializedUrlTemplate, + WorkspaceField, + GraphWorkspaceSavedObject, + SerializedWorkspaceState, + IndexPatternSavedObject, + AdvancedSettings, + GraphData, + Workspace, + SerializedField, +} from '../../types'; +import { outlinkEncoders } from '../outlink_encoders'; +import { + urlTemplateIconChoicesByClass, + getSuitableIcon, + colorChoices, + iconChoicesByClass, +} from '../style_choices'; + +const defaultAdvancedSettings: AdvancedSettings = { + useSignificance: true, + sampleSize: 2000, + timeoutMillis: 5000, + maxValuesPerDoc: 1, + minDocCount: 3, +}; + +function deserializeUrlTemplate({ + encoderID, + iconClass, + ...serializableProps +}: SerializedUrlTemplate) { + const encoder = outlinkEncoders.find(outlinkEncoder => outlinkEncoder.id === encoderID); + if (!encoder) { + return; + } + + const template: UrlTemplate = { + ...serializableProps, + encoder, + icon: null, + }; + + if (iconClass) { + const iconCandidate = urlTemplateIconChoicesByClass[iconClass]; + template.icon = iconCandidate ? iconCandidate : null; + } + + return template; +} + +// returns the id of the index pattern, lookup is done in app.js +export function lookupIndexPattern( + savedWorkspace: GraphWorkspaceSavedObject, + indexPatterns: IndexPatternSavedObject[] +) { + const serializedWorkspaceState: SerializedWorkspaceState = JSON.parse(savedWorkspace.wsState); + const indexPattern = indexPatterns.find( + pattern => pattern.attributes.title === serializedWorkspaceState.indexPattern + ); + + if (indexPattern) { + return indexPattern; + } +} + +// returns all graph fields mapped out of the index pattern +export function mapFields(indexPattern: IndexPattern): WorkspaceField[] { + const blockedFieldNames = ['_id', '_index', '_score', '_source', '_type']; + const defaultHopSize = 5; + + return indexPattern + .getNonScriptedFields() + .filter(field => !blockedFieldNames.includes(field.name)) + .map((field, index) => ({ + name: field.name, + hopSize: defaultHopSize, + lastValidHopSize: defaultHopSize, + icon: getSuitableIcon(field.name), + color: colorChoices[index % colorChoices.length], + selected: false, + })) + .sort((a, b) => { + if (a.name < b.name) { + return -1; + } else if (a.name > b.name) { + return 1; + } + return 0; + }); +} + +function getFieldsWithWorkspaceSettings( + indexPattern: IndexPattern, + selectedFields: SerializedField[] +) { + const allFields = mapFields(indexPattern); + + // merge in selected information into all fields + selectedFields.forEach(serializedField => { + const workspaceField = allFields.find(field => field.name === serializedField.name); + if (!workspaceField) { + return; + } + workspaceField.hopSize = serializedField.hopSize; + workspaceField.lastValidHopSize = serializedField.lastValidHopSize; + workspaceField.color = serializedField.color; + workspaceField.icon = iconChoicesByClass[serializedField.iconClass]!; + workspaceField.selected = true; + }); + + return allFields; +} + +function getBlacklistedNodes( + serializedWorkspaceState: SerializedWorkspaceState, + allFields: WorkspaceField[] +) { + return serializedWorkspaceState.blacklist.map(serializedNode => { + const currentField = allFields.find(field => field.name === serializedNode.field)!; + return { + x: 0, + y: 0, + label: serializedNode.label, + color: serializedNode.color, + icon: currentField.icon, + parent: null, + scaledSize: 0, + data: { + field: serializedNode.field, + term: serializedNode.term, + }, + }; + }); +} + +function resolveGroups(nodes: SerializedNode[], workspaceInstance: Workspace) { + nodes.forEach(({ field, term, x, y, parent }) => { + const nodeId = makeNodeId(field, term); + const workspaceNode = workspaceInstance.nodesMap[nodeId]; + workspaceNode.x = x; + workspaceNode.y = y; + if (parent !== null) { + const { field: parentField, term: parentTerm } = nodes[parent]; + const parentId = makeNodeId(parentField, parentTerm); + workspaceNode.parent = workspaceInstance.nodesMap[parentId]; + } + }); +} + +function getNodesAndEdges( + persistedWorkspaceState: SerializedWorkspaceState, + allFields: WorkspaceField[] +): GraphData { + return { + nodes: persistedWorkspaceState.vertices.map(serializedNode => ({ + ...serializedNode, + id: '', + icon: allFields.find(field => field.name === serializedNode.field)!.icon, + data: { + field: serializedNode.field, + term: serializedNode.term, + }, + })), + edges: persistedWorkspaceState.links.map(serializedEdge => ({ + ...serializedEdge, + id: '', + })), + }; +} + +export function makeNodeId(field: string, term: string) { + return field + '..' + term; +} + +export function savedWorkspaceToAppState( + savedWorkspace: GraphWorkspaceSavedObject, + indexPattern: IndexPattern, + workspaceInstance: Workspace +): Pick< + AppState, + 'urlTemplates' | 'advancedSettings' | 'workspace' | 'allFields' | 'selectedFields' +> { + const persistedWorkspaceState: SerializedWorkspaceState = JSON.parse(savedWorkspace.wsState); + + // ================== url templates ============================= + const urlTemplates = persistedWorkspaceState.urlTemplates + .map(deserializeUrlTemplate) + .filter((template: UrlTemplate | undefined): template is UrlTemplate => Boolean(template)); + + // ================== fields ============================= + const allFields = getFieldsWithWorkspaceSettings( + indexPattern, + persistedWorkspaceState.selectedFields + ); + const selectedFields = allFields.filter(field => field.selected); + + // ================== advanced settings ============================= + const advancedSettings = Object.assign( + defaultAdvancedSettings, + persistedWorkspaceState.exploreControls + ); + + if (advancedSettings.sampleDiversityField) { + // restore reference to sample diversity field + const serializedField = advancedSettings.sampleDiversityField; + advancedSettings.sampleDiversityField = allFields.find( + field => field.name === serializedField.name + ); + } + + // ================== nodes and edges ============================= + const graph = getNodesAndEdges(persistedWorkspaceState, allFields); + workspaceInstance.mergeGraph(graph); + resolveGroups(persistedWorkspaceState.vertices, workspaceInstance); + + // ================== blacklist ============================= + const blacklistedNodes = getBlacklistedNodes(persistedWorkspaceState, allFields); + workspaceInstance.blacklistedNodes.push(...blacklistedNodes); + + return { + urlTemplates, + advancedSettings, + workspace: workspaceInstance, + allFields, + selectedFields, + }; +} diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/index.ts b/x-pack/legacy/plugins/graph/public/services/persistence/index.ts new file mode 100644 index 0000000000000..c4be224f847a5 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/persistence/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './serialize'; +export * from './deserialize'; 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 new file mode 100644 index 0000000000000..f5f0ac43bcdaa --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts @@ -0,0 +1,278 @@ +/* + * 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 { appStateToSavedWorkspace } from './serialize'; +import { + GraphWorkspaceSavedObject, + IndexPatternSavedObject, + Workspace, + WorkspaceEdge, + AppState, +} from '../../types'; +import { outlinkEncoders } from '../outlink_encoders'; + +describe('serialize', () => { + let appState: AppState; + + beforeEach(() => { + appState = { + advancedSettings: { + useSignificance: true, + sampleSize: 2000, + timeoutMillis: 5000, + maxValuesPerDoc: 1, + minDocCount: 3, + }, + allFields: [ + { color: 'black', icon: { class: 'a', code: '' }, name: 'field1', selected: true }, + { color: 'black', icon: { class: 'b', code: '' }, name: 'field2', selected: true }, + { color: 'black', icon: { class: 'c', code: '' }, name: 'field3', selected: false }, + ], + selectedFields: [ + { color: 'black', icon: { class: 'a', code: '' }, name: 'field1', selected: true }, + { color: 'black', icon: { class: 'b', code: '' }, name: 'field2', selected: true }, + ], + selectedIndex: { + attributes: { + title: 'Testindexpattern', + }, + } as IndexPatternSavedObject, + urlTemplates: [ + { + description: 'Template', + encoder: outlinkEncoders[0], + icon: { class: 'd', code: '' }, + url: 'test-url', + }, + ], + workspace: { + nodes: [ + { + color: 'black', + data: { field: 'field1', term: 'A' }, + icon: { class: 'a', code: '' }, + label: 'A', + x: 1, + y: 2, + scaledSize: 10, + parent: null, + }, + { + color: 'black', + data: { field: 'field1', term: 'B' }, + icon: { class: 'a', code: '' }, + label: 'B', + x: 3, + y: 4, + scaledSize: 10, + parent: null, + }, + { + color: 'black', + data: { field: 'field1', term: 'C' }, + icon: { class: 'a', code: '' }, + label: 'B', + x: 5, + y: 6, + scaledSize: 10, + parent: null, + }, + { + color: 'black', + data: { field: 'field2', term: 'D' }, + icon: { class: 'a', code: '' }, + label: 'D', + x: 7, + y: 8, + scaledSize: 10, + parent: null, + }, + { + color: 'black', + data: { field: 'field2', term: 'E' }, + icon: { class: 'a', code: '' }, + label: 'E', + x: 9, + y: 10, + scaledSize: 10, + parent: null, + }, + ], + blacklistedNodes: [ + { + color: 'black', + data: { field: 'field1', term: 'Z' }, + icon: { class: 'a', code: '' }, + label: 'Z', + x: 1, + y: 2, + scaledSize: 10, + parent: null, + }, + ], + edges: [] as WorkspaceEdge[], + } as Workspace, + }; + + // C is parent of B and D + appState.workspace.nodes[3].parent = appState.workspace.nodes[2]; + appState.workspace.nodes[1].parent = appState.workspace.nodes[2]; + + // A <-> C + appState.workspace.edges.push({ + inferred: false, + label: '', + source: appState.workspace.nodes[2], + target: appState.workspace.nodes[0], + weight: 5, + width: 5, + }); + + // C <-> E + appState.workspace.edges.push({ + inferred: false, + label: '', + source: appState.workspace.nodes[2], + target: appState.workspace.nodes[4], + weight: 5, + width: 5, + }); + }); + + it('should serialize given workspace', () => { + const savedWorkspace = ({} as unknown) as GraphWorkspaceSavedObject; + + appStateToSavedWorkspace(savedWorkspace, appState, true); + + const workspaceState = JSON.parse(savedWorkspace.wsState); + expect(workspaceState).toMatchInlineSnapshot(` + Object { + "blacklist": Array [ + Object { + "color": "black", + "field": "field1", + "label": "Z", + "parent": null, + "size": 10, + "term": "Z", + "x": 1, + "y": 2, + }, + ], + "exploreControls": Object { + "maxValuesPerDoc": 1, + "minDocCount": 3, + "sampleSize": 2000, + "timeoutMillis": 5000, + "useSignificance": true, + }, + "indexPattern": "Testindexpattern", + "links": Array [ + Object { + "inferred": false, + "label": "", + "source": 2, + "target": 0, + "weight": 5, + "width": 5, + }, + Object { + "inferred": false, + "label": "", + "source": 2, + "target": 4, + "weight": 5, + "width": 5, + }, + ], + "selectedFields": Array [ + Object { + "color": "black", + "iconClass": "a", + "name": "field1", + "selected": true, + }, + Object { + "color": "black", + "iconClass": "b", + "name": "field2", + "selected": true, + }, + ], + "urlTemplates": Array [ + Object { + "description": "Template", + "encoderID": "esq-rison-loose", + "iconClass": "d", + "url": "test-url", + }, + ], + "vertices": Array [ + Object { + "color": "black", + "field": "field1", + "label": "A", + "parent": null, + "size": 10, + "term": "A", + "x": 1, + "y": 2, + }, + Object { + "color": "black", + "field": "field1", + "label": "B", + "parent": 2, + "size": 10, + "term": "B", + "x": 3, + "y": 4, + }, + Object { + "color": "black", + "field": "field1", + "label": "B", + "parent": null, + "size": 10, + "term": "C", + "x": 5, + "y": 6, + }, + Object { + "color": "black", + "field": "field2", + "label": "D", + "parent": 2, + "size": 10, + "term": "D", + "x": 7, + "y": 8, + }, + Object { + "color": "black", + "field": "field2", + "label": "E", + "parent": null, + "size": 10, + "term": "E", + "x": 9, + "y": 10, + }, + ], + } + `); + }); + + it('should not save data if set to false', () => { + const savedWorkspace = ({} as unknown) as GraphWorkspaceSavedObject; + + appStateToSavedWorkspace(savedWorkspace, appState, false); + + const workspaceState = JSON.parse(savedWorkspace.wsState); + expect(workspaceState.vertices.length).toEqual(0); + expect(workspaceState.links.length).toEqual(0); + }); +}); diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts new file mode 100644 index 0000000000000..d7423d300affb --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.ts @@ -0,0 +1,111 @@ +/* + * 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 { + AppState, + SerializedNode, + WorkspaceNode, + WorkspaceEdge, + SerializedEdge, + UrlTemplate, + SerializedUrlTemplate, + WorkspaceField, + GraphWorkspaceSavedObject, + SerializedWorkspaceState, +} from '../../types'; + +function serializeNode( + { data, scaledSize, parent, x, y, label, color }: WorkspaceNode, + allNodes: WorkspaceNode[] = [] +): SerializedNode { + return { + x, + y, + label, + color, + field: data.field, + term: data.term, + parent: parent ? allNodes.indexOf(parent) : null, + size: scaledSize, + }; +} + +function serializeEdge( + { source, target, weight, width, inferred, label }: WorkspaceEdge, + allNodes: WorkspaceNode[] = [] +): SerializedEdge { + return { + weight, + width, + inferred, + label, + source: allNodes.indexOf(source), + target: allNodes.indexOf(target), + }; +} + +function serializeUrlTemplate({ encoder, icon, url, description, isDefault }: UrlTemplate) { + const serializedTemplate: SerializedUrlTemplate = { + url, + description, + isDefault, + encoderID: encoder.id, + }; + if (icon) { + serializedTemplate.iconClass = icon.class; + } + return serializedTemplate; +} + +function serializeField({ + name, + icon, + hopSize, + lastValidHopSize, + color, + selected, +}: WorkspaceField) { + return { + name, + hopSize, + lastValidHopSize, + color, + selected, + iconClass: icon.class, + }; +} + +export function appStateToSavedWorkspace( + currentSavedWorkspace: GraphWorkspaceSavedObject, + { workspace, urlTemplates, advancedSettings, selectedIndex, selectedFields }: AppState, + canSaveData: boolean +) { + const blacklist: SerializedNode[] = canSaveData + ? workspace.blacklistedNodes.map(node => serializeNode(node)) + : []; + const vertices: SerializedNode[] = canSaveData + ? workspace.nodes.map(node => serializeNode(node, workspace.nodes)) + : []; + const links: SerializedEdge[] = canSaveData + ? workspace.edges.map(edge => serializeEdge(edge, workspace.nodes)) + : []; + + const mappedUrlTemplates = urlTemplates.map(serializeUrlTemplate); + + const persistedWorkspaceState: SerializedWorkspaceState = { + indexPattern: selectedIndex.attributes.title, + selectedFields: selectedFields.map(serializeField), + blacklist, + vertices, + links, + urlTemplates: mappedUrlTemplates, + exploreControls: advancedSettings, + }; + + currentSavedWorkspace.wsState = JSON.stringify(persistedWorkspaceState); + currentSavedWorkspace.numVertices = vertices.length; + currentSavedWorkspace.numLinks = links.length; +} diff --git a/x-pack/legacy/plugins/graph/public/services/save.tsx b/x-pack/legacy/plugins/graph/public/services/save_modal.tsx similarity index 86% rename from x-pack/legacy/plugins/graph/public/services/save.tsx rename to x-pack/legacy/plugins/graph/public/services/save_modal.tsx index 4903fb4913a3f..7680cdaa085e9 100644 --- a/x-pack/legacy/plugins/graph/public/services/save.tsx +++ b/x-pack/legacy/plugins/graph/public/services/save_modal.tsx @@ -5,10 +5,9 @@ */ import React from 'react'; -import { showSaveModal } from 'ui/saved_objects/show_saved_object_save_modal'; -import { SavedGraphWorkspace } from '../types/persistence'; +import { showSaveModal, SaveResult } from 'ui/saved_objects/show_saved_object_save_modal'; +import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types'; import { GraphSaveModal, OnSaveGraphProps } from '../components/graph_save_modal'; -import { GraphSavePolicy } from '../types/config'; export function save({ savePolicy, @@ -18,7 +17,7 @@ export function save({ }: { savePolicy: GraphSavePolicy; hasData: boolean; - workspace: SavedGraphWorkspace; + workspace: GraphWorkspaceSavedObject; saveWorkspace: ( saveOptions: { confirmOverwrite: boolean; @@ -26,7 +25,7 @@ export function save({ onTitleDuplicate: () => void; }, dataConsent: boolean - ) => Promise<{ id?: string } | { error: string }>; + ) => Promise; }) { const currentTitle = workspace.title; const currentDescription = workspace.description; diff --git a/x-pack/legacy/plugins/graph/public/services/style_choices.ts b/x-pack/legacy/plugins/graph/public/services/style_choices.ts new file mode 100644 index 0000000000000..5528f5faab14f --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/style_choices.ts @@ -0,0 +1,227 @@ +/* + * 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. + */ + +export interface FontawesomeIcon { + class: string; + code: string; + patterns?: RegExp[]; +} + +export const iconChoices = [ + // Patterns are used to help default icon choices for common field names + { + class: 'fa-folder-open-o', + code: '\uf115', + patterns: [/category/i, /folder/i, /group/i], + }, + { + class: 'fa-cube', + code: '\uf1b2', + patterns: [/prod/i, /sku/i], + }, + { + class: 'fa-key', + code: '\uf084', + patterns: [/key/i], + }, + { + class: 'fa-bank', + code: '\uf19c', + patterns: [/bank/i, /account/i], + }, + { + class: 'fa-automobile', + code: '\uf1b9', + patterns: [/car/i, /veh/i], + }, + { + class: 'fa-home', + code: '\uf015', + patterns: [/address/i, /home/i], + }, + { + class: 'fa-question', + code: '\uf128', + patterns: [/query/i, /search/i], + }, + { + class: 'fa-plane', + code: '\uf072', + patterns: [/flight/i, /plane/i], + }, + { + class: 'fa-file-o', + code: '\uf016', + patterns: [/file/i, /doc/i], + }, + { + class: 'fa-user', + code: '\uf007', + patterns: [ + /user/i, + /person/i, + /people/i, + /owner/i, + /cust/i, + /participant/i, + /party/i, + /member/i, + ], + }, + { + class: 'fa-users', + code: '\uf0c0', + patterns: [/group/i, /team/i, /meeting/i], + }, + { + class: 'fa-music', + code: '\uf001', + patterns: [/artist/i, /sound/i, /music/i], + }, + { + class: 'fa-flag', + code: '\uf024', + patterns: [/country/i, /warn/i, /flag/i], + }, + { + class: 'fa-tag', + code: '\uf02b', + patterns: [/tag/i, /label/i], + }, + { + class: 'fa-phone', + code: '\uf095', + patterns: [/phone/i], + }, + { + class: 'fa-desktop', + code: '\uf108', + patterns: [/host/i, /server/i], + }, + { + class: 'fa-font', + code: '\uf031', + patterns: [/text/i, /title/i, /body/i, /desc/i], + }, + { + class: 'fa-at', + code: '\uf1fa', + patterns: [/account/i, /email/i], + }, + { + class: 'fa-heart', + code: '\uf004', + patterns: [/like/i, /favourite/i, /favorite/i], + }, + { + class: 'fa-bolt', + code: '\uf0e7', + patterns: [/action/i], + }, + { + class: 'fa-map-marker', + code: '\uf041', + patterns: [/location/i, /geo/i, /position/i], + }, + { + class: 'fa-exclamation', + code: '\uf12a', + patterns: [/risk/i, /error/i, /warn/i], + }, + { + class: 'fa-industry', + code: '\uf275', + patterns: [/business/i, /company/i, /industry/i, /organisation/i], + }, +]; + +export const getSuitableIcon = (fieldName: string) => + iconChoices.find(choice => choice.patterns.some(pattern => pattern.test(fieldName))) || + iconChoices[0]; + +export const iconChoicesByClass: Partial> = {}; + +iconChoices.forEach(icon => { + iconChoicesByClass[icon.class] = icon; +}); + +export const urlTemplateIconChoices = [ + // Patterns are used to help default icon choices for common field names + { + class: 'fa-line-chart', + code: '\uf201', + }, + { + class: 'fa-pie-chart', + code: '\uf200', + }, + { + class: 'fa-area-chart', + code: '\uf1fe', + }, + { + class: 'fa-bar-chart', + code: '\uf080', + }, + { + class: 'fa-globe', + code: '\uf0ac', + }, + { + class: 'fa-file-text-o', + code: '\uf0f6', + }, + { + class: 'fa-google', + code: '\uf1a0', + }, + { + class: 'fa-eye', + code: '\uf06e', + }, + { + class: 'fa-tachometer', + code: '\uf0e4', + }, + { + class: 'fa-info', + code: '\uf129', + }, + { + class: 'fa-external-link', + code: '\uf08e', + }, + { + class: 'fa-table', + code: '\uf0ce', + }, + { + class: 'fa-list', + code: '\uf03a', + }, + { + class: 'fa-share-alt', + code: '\uf1e0', + }, +]; +export const urlTemplateIconChoicesByClass: Partial> = {}; + +urlTemplateIconChoices.forEach(icon => { + urlTemplateIconChoicesByClass[icon.class] = icon; +}); + +export const colorChoices = [ + '#99bde7', + '#e3d754', + '#8ee684', + '#e7974c', + '#e4878d', + '#67adab', + '#43ebcc', + '#e4b4ea', + '#a1a655', + '#78b36e', +]; diff --git a/x-pack/legacy/plugins/graph/public/services/url.ts b/x-pack/legacy/plugins/graph/public/services/url.ts index dd9db8fa1f07a..97a30e26c25f3 100644 --- a/x-pack/legacy/plugins/graph/public/services/url.ts +++ b/x-pack/legacy/plugins/graph/public/services/url.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { Chrome } from 'ui/chrome'; -import { SavedGraphWorkspace } from '../types/persistence'; +import { GraphWorkspaceSavedObject } from '../types'; export function getHomePath() { return '/home'; @@ -16,11 +16,11 @@ export function getNewPath() { return '/workspace'; } -export function getEditPath({ id }: SavedGraphWorkspace) { +export function getEditPath({ id }: GraphWorkspaceSavedObject) { return `/workspace/${id}`; } -export function getEditUrl(chrome: Chrome, workspace: SavedGraphWorkspace) { +export function getEditUrl(chrome: Chrome, workspace: GraphWorkspaceSavedObject) { return chrome.addBasePath(`#${getEditPath(workspace)}`); } @@ -30,7 +30,7 @@ export type SetBreadcrumbOptions = } | { chrome: Chrome; - savedWorkspace?: SavedGraphWorkspace; + savedWorkspace?: GraphWorkspaceSavedObject; navigateTo: (path: string) => void; }; diff --git a/x-pack/legacy/plugins/graph/public/style_choices.js b/x-pack/legacy/plugins/graph/public/style_choices.js deleted file mode 100644 index 6bc95971f5443..0000000000000 --- a/x-pack/legacy/plugins/graph/public/style_choices.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const iconChoices = [ - //Patterns are used to help default icon choices for common field names - { - class: 'fa-folder-open-o', - code: '\uf115', - 'patterns': [/category/i, /folder/i, /group/i] - }, { - class: 'fa-cube', - code: '\uf1b2', - 'patterns': [/prod/i, /sku/i] - }, { - class: 'fa-key', - code: '\uf084', - 'patterns': [/key/i] - }, { - class: 'fa-bank', - code: '\uf19c', - 'patterns': [/bank/i, /account/i] - }, { - class: 'fa-automobile', - code: '\uf1b9', - 'patterns': [/car/i, /veh/i] - }, { - class: 'fa-home', - code: '\uf015', - 'patterns': [/address/i, /home/i] - }, { - class: 'fa-question', - code: '\uf128', - 'patterns': [/query/i, /search/i] - }, { - class: 'fa-plane', - code: '\uf072', - 'patterns': [/flight/i, /plane/i] - }, { - class: 'fa-file-o', - code: '\uf016', - 'patterns': [/file/i, /doc/i] - }, { - class: 'fa-user', - code: '\uf007', - 'patterns': [/user/i, /person/i, /people/i, /owner/i, /cust/i, /participant/i, /party/i, /member/i] - }, { - class: 'fa-users', - code: '\uf0c0', - 'patterns': [/group/i, /team/i, /meeting/i] - }, { - class: 'fa-music', - code: '\uf001', - 'patterns': [/artist/i, /sound/i, /music/i] - }, { - class: 'fa-flag', - code: '\uf024', - 'patterns': [/country/i, /warn/i, /flag/i] - }, { - class: 'fa-tag', - code: '\uf02b', - 'patterns': [/tag/i, /label/i] - }, { - class: 'fa-phone', - code: '\uf095', - 'patterns': [/phone/i] - }, { - class: 'fa-desktop', - code: '\uf108', - 'patterns': [/host/i, /server/i] - }, { - class: 'fa-font', - code: '\uf031', - 'patterns': [/text/i, /title/i, /body/i, /desc/i] - }, { - class: 'fa-at', - code: '\uf1fa', - 'patterns': [/account/i, /email/i] - }, { - class: 'fa-heart', - code: '\uf004', - 'patterns': [/like/i, /favourite/i, /favorite/i] - }, { - class: 'fa-bolt', - code: '\uf0e7', - 'patterns': [/action/i] - }, { - class: 'fa-map-marker', - code: '\uf041', - 'patterns': [/location/i, /geo/i, /position/i] - }, { - class: 'fa-exclamation', - code: '\uf12a', - 'patterns': [/risk/i, /error/i, /warn/i] - }, { - class: 'fa-industry', - code: '\uf275', - 'patterns': [/business/i, /company/i, /industry/i, /organisation/i] - } -]; - -export const iconChoicesByClass = {}; - -iconChoices.forEach(icon => { - iconChoicesByClass[icon.class] = icon; -}); - - -export const drillDownIconChoices = [ - //Patterns are used to help default icon choices for common field names - { - class: 'fa-line-chart', - code: '\uf201' - }, { - class: 'fa-pie-chart', - code: '\uf200' - }, { - class: 'fa-area-chart', - code: '\uf1fe' - }, { - class: 'fa-bar-chart', - code: '\uf080' - }, { - class: 'fa-globe', - code: '\uf0ac' - }, { - class: 'fa-file-text-o', - code: '\uf0f6' - }, { - class: 'fa-google', - code: '\uf1a0' - }, { - class: 'fa-eye', - code: '\uf06e' - }, { - class: 'fa-tachometer', - code: '\uf0e4' - }, { - class: 'fa-info', - code: '\uf129' - }, { - class: 'fa-external-link', - code: '\uf08e' - }, { - class: 'fa-table', - code: '\uf0ce' - }, { - class: 'fa-list', - code: '\uf03a' - }, { - class: 'fa-share-alt', - code: '\uf1e0' - } -]; -export const drillDownIconChoicesByClass = {}; - -drillDownIconChoices.forEach(icon => { - drillDownIconChoicesByClass[icon.class] = icon; -}); - - - - - -export const colorChoices = ['#99bde7', '#e3d754', '#8ee684', '#e7974c', '#e4878d', '#67adab', - '#43ebcc', '#e4b4ea', '#a1a655', '#78b36e']; diff --git a/x-pack/legacy/plugins/graph/public/types/app_state.ts b/x-pack/legacy/plugins/graph/public/types/app_state.ts new file mode 100644 index 0000000000000..ad0f80c2fabb7 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/types/app_state.ts @@ -0,0 +1,47 @@ +/* + * 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 { SimpleSavedObject } from 'src/core/public'; +import { Workspace } from './workspace_state'; +import { FontawesomeIcon } from '../services/style_choices'; +import { OutlinkEncoder } from '../services/outlink_encoders'; + +export interface UrlTemplate { + url: string; + description: string; + icon: FontawesomeIcon | null; + encoder: OutlinkEncoder; + isDefault?: boolean; +} + +export interface WorkspaceField { + name: string; + hopSize?: number; + lastValidHopSize?: number; // TODO handle this by an "active" flag + color: string; + icon: FontawesomeIcon; + selected: boolean; +} + +export interface AdvancedSettings { + sampleSize: number; + useSignificance: boolean; + minDocCount: number; + sampleDiversityField?: WorkspaceField; + maxValuesPerDoc: number; + timeoutMillis: number; +} + +export type IndexPatternSavedObject = SimpleSavedObject<{ title: string }>; + +export interface AppState { + urlTemplates: UrlTemplate[]; + advancedSettings: AdvancedSettings; + workspace: Workspace; + allFields: WorkspaceField[]; + selectedFields: WorkspaceField[]; + selectedIndex: IndexPatternSavedObject; +} diff --git a/x-pack/legacy/plugins/graph/public/types/index.ts b/x-pack/legacy/plugins/graph/public/types/index.ts new file mode 100644 index 0000000000000..da02e3ab7d5d2 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/types/index.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. + */ + +export * from './config'; +export * from './app_state'; +export * from './workspace_state'; +export * from './persistence'; diff --git a/x-pack/legacy/plugins/graph/public/types/persistence.ts b/x-pack/legacy/plugins/graph/public/types/persistence.ts index 56040aa30db4c..e0a37b62db6b7 100644 --- a/x-pack/legacy/plugins/graph/public/types/persistence.ts +++ b/x-pack/legacy/plugins/graph/public/types/persistence.ts @@ -5,12 +5,16 @@ */ import { SavedObject } from 'ui/saved_objects/saved_object'; +import { AdvancedSettings, UrlTemplate, WorkspaceField } from './app_state'; +import { WorkspaceNode, WorkspaceEdge } from './workspace_state'; + +type Omit = Pick>; /** * Workspace fetched from server. * This type is returned by `SavedWorkspacesProvider#get`. */ -export interface SavedGraphWorkspace extends SavedObject { +export interface GraphWorkspaceSavedObject extends SavedObject { title: string; description: string; numLinks: number; @@ -18,3 +22,34 @@ export interface SavedGraphWorkspace extends SavedObject { version: number; wsState: string; } + +export interface SerializedWorkspaceState { + indexPattern: string; + selectedFields: SerializedField[]; + blacklist: SerializedNode[]; + vertices: SerializedNode[]; + links: SerializedEdge[]; + urlTemplates: SerializedUrlTemplate[]; + exploreControls: AdvancedSettings; +} + +export interface SerializedUrlTemplate extends Omit { + encoderID: string; + iconClass?: string; +} +export interface SerializedField extends Omit { + iconClass: string; +} + +export interface SerializedNode + extends Omit { + field: string; + term: string; + parent: number | null; + size: number; +} + +export interface SerializedEdge extends Omit { + source: number; + target: number; +} diff --git a/x-pack/legacy/plugins/graph/public/types/workspace_state.ts b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts new file mode 100644 index 0000000000000..caa6bee07408d --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts @@ -0,0 +1,109 @@ +/* + * 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 { FontawesomeIcon } from '../services/style_choices'; +import { WorkspaceField, AdvancedSettings } from './app_state'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface JsonArray extends Array {} + +type JsonValue = null | boolean | number | string | JsonObject | JsonArray; + +interface JsonObject { + [key: string]: JsonValue; +} + +export interface WorkspaceNode { + x: number; + y: number; + label: string; + icon: FontawesomeIcon; + data: { + field: string; + term: string; + }; + scaledSize: number; + parent: WorkspaceNode | null; + color: string; +} + +export interface WorkspaceEdge { + weight: number; + width: number; + inferred: boolean; + label: string; + source: WorkspaceNode; + target: WorkspaceNode; +} + +export interface GraphData { + nodes: Array<{ + field: string; + term: string; + id: string; + label: string; + color: string; + icon: FontawesomeIcon; + data: { + field: string; + term: string; + }; + }>; + edges: Array<{ + source: number; + target: number; + weight: number; + width: number; + doc_count?: number; + inferred: boolean; + }>; +} + +export interface Workspace { + nodesMap: Record; + nodes: WorkspaceNode[]; + edges: WorkspaceEdge[]; + blacklistedNodes: WorkspaceNode[]; + + getQuery(startNodes?: WorkspaceNode[], loose?: boolean): JsonObject; + getSelectedOrAllNodes(): WorkspaceNode[]; + getLikeThisButNotThisQuery(startNodes?: WorkspaceNode[]): JsonObject; + + /** + * Flatten grouped nodes and return a flat array of nodes + * @param nodes List of nodes probably containing grouped nodes + */ + returnUnpackedGroupeds(nodes: WorkspaceNode[]): WorkspaceNode[]; + + /** + * Adds new nodes retrieved from an elasticsearch search + * @param newData + */ + mergeGraph(newData: GraphData): void; +} + +export type ExploreRequest = any; +export type SearchRequest = any; +export type ExploreResults = any; +export type SearchResults = any; + +export type WorkspaceOptions = Partial<{ + indexName: string; + vertex_fields: WorkspaceField[]; + nodeLabeller: (newNodes: WorkspaceNode[]) => void; + changeHandler: () => void; + graphExploreProxy: ( + indexPattern: string, + request: ExploreRequest, + callback: (data: ExploreResults) => void + ) => void; + searchProxy: ( + indexPattern: string, + request: SearchRequest, + callback: (data: SearchResults) => void + ) => void; + exploreControls: AdvancedSettings; +}>; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0d890c29f2897..e86118e176638 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4515,7 +4515,6 @@ "xpack.graph.exploreGraph.timedOutWarningText": "閲覧がタイムアウトしました", "xpack.graph.featureRegistry.graphFeatureName": "Graph", "xpack.graph.home.breadcrumb": "Graph", - "xpack.graph.loadWorkspace.missingIndexPatternErrorMessage": "インデックスパターン {indexPattern} がありません", "xpack.graph.missingWorkspaceErrorMessage": "ワークスペースがありません", "xpack.graph.noDataSourceNotificationMessageText": "{managementIndexPatternsLink} に移動してインデックスパターンを作成してください", "xpack.graph.noDataSourceNotificationMessageText.managementIndexPatternLinkText": "管理 > インデックスパターン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 44a1009707d89..915f8cd23ebb9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4659,7 +4659,6 @@ "xpack.graph.exploreGraph.timedOutWarningText": "浏览超时", "xpack.graph.featureRegistry.graphFeatureName": "Graph", "xpack.graph.home.breadcrumb": "Graph", - "xpack.graph.loadWorkspace.missingIndexPatternErrorMessage": "缺少索引模式 {indexPattern}", "xpack.graph.missingWorkspaceErrorMessage": "缺少工作空间", "xpack.graph.noDataSourceNotificationMessageText": "前往 “{managementIndexPatternsLink}” 并创建索引模式", "xpack.graph.noDataSourceNotificationMessageText.managementIndexPatternLinkText": "管理 > 索引模式", From 7cf69fdc99a005cec10f93766137417cb069fa56 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 11 Sep 2019 10:42:55 -0400 Subject: [PATCH 3/5] [SIEM] Inject/apply KQL changed in refresh button (#45065) * wip to apply kql when refresh on hosts page * refactor to have less re-render * add network and timeline page * fix/add unit testing * from review remove any effect from render * clean up + review II * review II + bug fixes * review III --- .../event_details/event_details.tsx | 6 +- .../events_viewer/events_viewer.tsx | 66 +++--- .../components/notes/note_cards/index.tsx | 5 +- .../super_date_picker/index.test.tsx | 3 +- .../components/super_date_picker/index.tsx | 197 +++++++++++------- .../super_date_picker/selectors.test.ts | 18 +- .../components/super_date_picker/selectors.ts | 10 +- .../body/column_headers/actions/index.tsx | 5 +- .../body/column_headers/header/index.tsx | 5 +- .../timeline/body/renderers/row_renderer.tsx | 6 +- .../data_providers/providers.test.tsx | 15 +- .../timeline/expandable_event/index.tsx | 5 +- .../timeline/fetch_kql_timeline.tsx | 79 +++++++ .../components/timeline/refetch_timeline.tsx | 42 ++-- .../public/components/timeline/timeline.tsx | 78 +++---- .../components/timeline/timeline_context.tsx | 39 +++- .../public/containers/global_time/index.tsx | 2 +- .../siem/public/containers/hosts/filter.tsx | 127 +++++++---- .../siem/public/containers/network/filter.tsx | 129 ++++++++---- .../siem/public/pages/hosts/details/index.tsx | 2 +- .../plugins/siem/public/pages/hosts/hosts.tsx | 6 +- .../plugins/siem/public/pages/hosts/kql.tsx | 12 +- .../siem/public/pages/network/ip_details.tsx | 30 +-- .../plugins/siem/public/pages/network/kql.tsx | 12 +- .../siem/public/pages/network/network.tsx | 23 +- .../plugins/siem/public/store/inputs/model.ts | 17 +- .../siem/public/store/timeline/selectors.ts | 12 ++ .../public/utils/kql/use_update_kql.test.tsx | 156 ++++++++++++++ .../siem/public/utils/kql/use_update_kql.tsx | 97 +++++++++ 29 files changed, 876 insertions(+), 328 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/fetch_kql_timeline.tsx create mode 100644 x-pack/legacy/plugins/siem/public/utils/kql/use_update_kql.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/utils/kql/use_update_kql.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx index cc4d5969ab69e..821ec2048d5ad 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx @@ -5,7 +5,7 @@ */ import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; -import React, { useContext } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -16,7 +16,7 @@ import { OnUpdateColumns } from '../timeline/events'; import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; -import { TimelineWidthContext } from '../timeline/timeline_context'; +import { useTimelineWidthContext } from '../timeline/timeline_context'; export type View = 'table-view' | 'json-view'; @@ -51,7 +51,7 @@ export const EventDetails = React.memo( timelineId, toggleColumn, }) => { - const width = useContext(TimelineWidthContext); + const width = useTimelineWidthContext(); const tabs: EuiTabbedContentTab[] = [ { id: 'table-view', diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index 881713cca1a57..dfb6af1984261 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -23,9 +23,8 @@ import { DataProvider } from '../timeline/data_providers/data_provider'; import { OnChangeItemsPerPage } from '../timeline/events'; import { Footer, footerHeight } from '../timeline/footer'; import { combineQueries } from '../timeline/helpers'; -import { TimelineRefetch } from '../timeline/refetch_timeline'; import { isCompactFooter } from '../timeline/timeline'; -import { TimelineContext, TimelineWidthContext } from '../timeline/timeline_context'; +import { ManageTimelineContext } from '../timeline/timeline_context'; import { EventsViewerHeader } from './events_viewer_header'; @@ -115,7 +114,6 @@ export const EventsViewer = React.memo( style={{ height: '0px', width: '100%' }} /> - {combinedQueries != null ? ( c.id)} @@ -138,7 +136,7 @@ export const EventsViewer = React.memo( refetch, totalCount = 0, }) => ( - + <> ( />
- - - -
- - + + +
+
-
+ )}
) : null} diff --git a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx index 3b76f6c9540f9..8ea0f8ee8bb21 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx @@ -8,12 +8,11 @@ import { EuiFlexGroup, EuiPanel } from '@elastic/eui'; import * as React from 'react'; import styled from 'styled-components'; -import { useContext } from 'react'; import { Note } from '../../../lib/note'; import { AddNote } from '../add_note'; import { AssociateNote, GetNewNoteId, UpdateNote } from '../helpers'; import { NoteCard } from '../note_card'; -import { TimelineWidthContext } from '../../timeline/timeline_context'; +import { useTimelineWidthContext } from '../../timeline/timeline_context'; const AddNoteContainer = styled.div``; @@ -30,7 +29,7 @@ interface NoteCardsCompProps { } const NoteCardsComp = React.memo(({ children }) => { - const width = useContext(TimelineWidthContext); + const width = useTimelineWidthContext(); // Passing the styles directly to the component because the width is // being calculated and is recommended by Styled Components for performance diff --git a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.test.tsx index 4cf5b4e9026ec..5c2ae38ed4b62 100644 --- a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.test.tsx @@ -265,6 +265,7 @@ describe('SIEM Super Date Picker', () => { const wrapperFixedEuiFieldSearch = wrapper.find( 'input[data-test-subj="superDatePickerRefreshIntervalInput"]' ); + wrapperFixedEuiFieldSearch.simulate('change', { target: { value: '2' } }); wrapper.update(); @@ -456,7 +457,7 @@ describe('SIEM Super Date Picker', () => { }, ]; const props2 = mapStateToProps(clone, { id: 'global' }); - expect(props1.refetch).not.toBe(props2.refetch); + expect(props1.queries).not.toBe(props2.queries); }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx index d73b0268c4d8d..c68306c8368f7 100644 --- a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx @@ -15,8 +15,8 @@ import { import { getOr, take } from 'lodash/fp'; import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { ActionCreator } from 'typescript-fsa'; +import { Dispatch } from 'redux'; import { inputsModel, State } from '../../store'; import { inputsActions, timelineActions } from '../../store/actions'; import { InputsModelId } from '../../store/inputs/constants'; @@ -29,7 +29,8 @@ import { fromStrSelector, toStrSelector, isLoadingSelector, - refetchSelector, + queriesSelector, + kqlQuerySelector, } from './selectors'; import { InputsRange, Policy } from '../../store/inputs/model'; @@ -60,29 +61,36 @@ interface SuperDatePickerStateRedux { start: number; end: number; isLoading: boolean; - refetch: inputsModel.Refetch[]; + queries: inputsModel.GlobalGraphqlQuery[]; + kqlQuery: inputsModel.GlobalKqlQuery; } +interface UpdateReduxTime extends OnTimeChangeProps { + id: InputsModelId; + kql?: inputsModel.GlobalKqlQuery | undefined; + timelineId?: string; +} + +interface ReturnUpdateReduxTime { + kqlHaveBeenUpdated: boolean; +} + +type DispatchUpdateReduxTime = ({ + end, + id, + isQuickSelection, + kql, + start, + timelineId, +}: UpdateReduxTime) => ReturnUpdateReduxTime; + interface SuperDatePickerDispatchProps { - setAbsoluteSuperDatePicker: ActionCreator<{ - id: InputsModelId; - from: number; - to: number; - timelineId?: string; - }>; - setRelativeSuperDatePicker: ActionCreator<{ - id: InputsModelId; - fromStr: string; - from: number; - to: number; - toStr: string; - timelineId?: string; - }>; - startAutoReload: ActionCreator<{ id: InputsModelId }>; - stopAutoReload: ActionCreator<{ id: InputsModelId }>; - setDuration: ActionCreator<{ id: InputsModelId; duration: number }>; - updateTimelineRange: ActionCreator<{ id: string; start: number; end: number }>; + startAutoReload: ({ id }: { id: InputsModelId }) => void; + stopAutoReload: ({ id }: { id: InputsModelId }) => void; + setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => void; + updateReduxTime: DispatchUpdateReduxTime; } + interface OwnProps { id: InputsModelId; disabled?: boolean; @@ -139,27 +147,30 @@ export const SuperDatePickerComponent = class extends Component< ); } private onRefresh = ({ start, end, refreshInterval }: OnRefreshProps): void => { - this.updateReduxTime({ - start, + const { kqlHaveBeenUpdated } = this.props.updateReduxTime({ end, - isQuickSelection: this.state.isQuickSelection, + id: this.props.id, isInvalid: false, + isQuickSelection: this.state.isQuickSelection, + kql: this.props.kqlQuery, + start, + timelineId: this.props.timelineId, }); - const currentStart = this.formatDate(start); + const currentStart = formatDate(start); const currentEnd = this.state.isQuickSelection - ? this.formatDate(end, { roundUp: true }) - : this.formatDate(end); + ? formatDate(end, { roundUp: true }) + : formatDate(end); if ( - !this.state.isQuickSelection || - (this.props.start === currentStart && this.props.end === currentEnd) + !kqlHaveBeenUpdated && + (!this.state.isQuickSelection || + (this.props.start === currentStart && this.props.end === currentEnd)) ) { - this.refetchQuery(this.props.refetch); + this.refetchQuery(this.props.queries); } }; private onRefreshChange = ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { const { id, duration, policy, stopAutoReload, startAutoReload } = this.props; - if (duration !== refreshInterval) { this.props.setDuration({ id, duration: refreshInterval }); } @@ -174,27 +185,25 @@ export const SuperDatePickerComponent = class extends Component< !isPaused && (!this.state.isQuickSelection || (this.state.isQuickSelection && this.props.toStr !== 'now')) ) { - this.refetchQuery(this.props.refetch); + this.refetchQuery(this.props.queries); } }; - private refetchQuery = (query: inputsModel.Refetch[]) => { - query.forEach((refetch: inputsModel.Refetch) => refetch()); - }; - - private formatDate = ( - date: string, - options?: { - roundUp?: boolean; - } - ) => { - const momentDate = dateMath.parse(date, options); - return momentDate != null && momentDate.isValid() ? momentDate.valueOf() : 0; + private refetchQuery = (queries: inputsModel.GlobalGraphqlQuery[]) => { + queries.forEach(q => q.refetch && (q.refetch as inputsModel.Refetch)()); }; private onTimeChange = ({ start, end, isQuickSelection, isInvalid }: OnTimeChangeProps) => { if (!isInvalid) { - this.updateReduxTime({ start, end, isQuickSelection, isInvalid }); + this.props.updateReduxTime({ + end, + id: this.props.id, + isInvalid, + isQuickSelection, + kql: this.props.kqlQuery, + start, + timelineId: this.props.timelineId, + }); this.setState((prevState: SuperDatePickerState) => { const recentlyUsedRanges = [ { start, end }, @@ -214,40 +223,66 @@ export const SuperDatePickerComponent = class extends Component< }); } }; +}; + +const formatDate = ( + date: string, + options?: { + roundUp?: boolean; + } +) => { + const momentDate = dateMath.parse(date, options); + return momentDate != null && momentDate.isValid() ? momentDate.valueOf() : 0; +}; - private updateReduxTime = ({ start, end, isQuickSelection }: OnTimeChangeProps) => { - const { - id, - setAbsoluteSuperDatePicker, - setRelativeSuperDatePicker, - timelineId, - updateTimelineRange, - } = this.props; - const fromDate = this.formatDate(start); - let toDate = this.formatDate(end, { roundUp: true }); - if (isQuickSelection) { - setRelativeSuperDatePicker({ +const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({ + end, + id, + isQuickSelection, + kql, + start, + timelineId, +}: UpdateReduxTime): ReturnUpdateReduxTime => { + const fromDate = formatDate(start); + let toDate = formatDate(end, { roundUp: true }); + if (isQuickSelection) { + dispatch( + inputsActions.setRelativeRangeDatePicker({ id, fromStr: start, toStr: end, from: fromDate, to: toDate, - }); - } else { - toDate = this.formatDate(end); - setAbsoluteSuperDatePicker({ + }) + ); + } else { + toDate = formatDate(end); + dispatch( + inputsActions.setAbsoluteRangeDatePicker({ id, - from: this.formatDate(start), - to: this.formatDate(end), - }); - } - if (timelineId != null) { - updateTimelineRange({ + from: formatDate(start), + to: formatDate(end), + }) + ); + } + if (timelineId != null) { + dispatch( + timelineActions.updateRange({ id: timelineId, start: fromDate, end: toDate, - }); - } + }) + ); + } + + if (kql) { + return { + kqlHaveBeenUpdated: kql.refetch(dispatch), + }; + } + + return { + kqlHaveBeenUpdated: false, }; }; @@ -260,7 +295,8 @@ export const makeMapStateToProps = () => { const getFromStrSelector = fromStrSelector(); const getToStrSelector = toStrSelector(); const getIsLoadingSelector = isLoadingSelector(); - const getRefetchQuerySelector = refetchSelector(); + const getQueriesSelector = queriesSelector(); + const getKqlQuerySelector = kqlQuerySelector(); return (state: State, { id }: OwnProps) => { const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state); return { @@ -272,19 +308,22 @@ export const makeMapStateToProps = () => { fromStr: getFromStrSelector(inputsRange), toStr: getToStrSelector(inputsRange), isLoading: getIsLoadingSelector(inputsRange), - refetch: getRefetchQuerySelector(inputsRange), + queries: getQueriesSelector(inputsRange), + kqlQuery: getKqlQuerySelector(inputsRange), }; }; }; +const mapDispatchToProps = (dispatch: Dispatch) => ({ + startAutoReload: ({ id }: { id: InputsModelId }) => + dispatch(inputsActions.startAutoReload({ id })), + stopAutoReload: ({ id }: { id: InputsModelId }) => dispatch(inputsActions.stopAutoReload({ id })), + setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => + dispatch(inputsActions.setDuration({ id, duration })), + updateReduxTime: dispatchUpdateReduxTime(dispatch), +}); + export const SuperDatePicker = connect( makeMapStateToProps, - { - setAbsoluteSuperDatePicker: inputsActions.setAbsoluteRangeDatePicker, - setRelativeSuperDatePicker: inputsActions.setRelativeRangeDatePicker, - startAutoReload: inputsActions.startAutoReload, - stopAutoReload: inputsActions.stopAutoReload, - setDuration: inputsActions.setDuration, - updateTimelineRange: timelineActions.updateRange, - } + mapDispatchToProps )(SuperDatePickerComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.test.ts b/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.test.ts index 194e77075fb55..2e42ed791f3d8 100644 --- a/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.test.ts @@ -13,7 +13,7 @@ import { fromStrSelector, toStrSelector, isLoadingSelector, - refetchSelector, + queriesSelector, } from './selectors'; import { InputsRange, AbsoluteTimeRange, RelativeTimeRange } from '../../store/inputs/model'; import { cloneDeep } from 'lodash/fp'; @@ -45,7 +45,7 @@ describe('selectors', () => { const getFromStrSelector = fromStrSelector(); const getToStrSelector = toStrSelector(); const getIsLoadingSelector = isLoadingSelector(); - const getRefetchSelector = refetchSelector(); + const getQueriesSelector = queriesSelector(); beforeEach(() => { absoluteTime = { @@ -363,22 +363,22 @@ describe('selectors', () => { }); }); - describe('#refetchSelector', () => { + describe('#queriesSelector', () => { test('returns the same reference given the same identical input twice', () => { - const result1 = getRefetchSelector(inputState); - const result2 = getRefetchSelector(inputState); + const result1 = getQueriesSelector(inputState); + const result2 = getQueriesSelector(inputState); expect(result1).toBe(result2); }); test('DOES NOT return the same reference given different input twice but with different deep copies since the query is not a primitive', () => { const clone = cloneDeep(inputState); - const result1 = getRefetchSelector(inputState); - const result2 = getRefetchSelector(clone); + const result1 = getQueriesSelector(inputState); + const result2 = getQueriesSelector(clone); expect(result1).not.toBe(result2); }); test('returns a different reference even if the contents are the same since query is an array and not a primitive', () => { - const result1 = getRefetchSelector(inputState); + const result1 = getQueriesSelector(inputState); const change: InputsRange = { ...inputState, query: [ @@ -392,7 +392,7 @@ describe('selectors', () => { }, ], }; - const result2 = getRefetchSelector(change); + const result2 = getQueriesSelector(change); expect(result1).not.toBe(result2); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.ts b/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.ts index b0ebefb98ea30..7f2acd17ce799 100644 --- a/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.ts +++ b/x-pack/legacy/plugins/siem/public/components/super_date_picker/selectors.ts @@ -61,8 +61,14 @@ export const isLoadingSelector = () => query => query.some(i => i.loading === true) ); -export const refetchSelector = () => +export const queriesSelector = () => createSelector( getQuery, - query => query.map(i => i.refetch) + query => query.filter(q => q.id !== 'kql') + ); + +export const kqlQuerySelector = () => + createSelector( + getQuery, + query => query.find(q => q.id === 'kql') ); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/actions/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/actions/index.tsx index f35f5c922f070..6e3409c46e4f2 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/actions/index.tsx @@ -9,14 +9,13 @@ import * as React from 'react'; import { pure } from 'recompose'; import styled from 'styled-components'; -import { useContext } from 'react'; import { OnColumnRemoved } from '../../../events'; import { Sort } from '../../sort'; import { SortIndicator } from '../../sort/sort_indicator'; import { ColumnHeader } from '../column_header'; import { getSortDirection } from '../header/helpers'; import * as i18n from '../translations'; -import { TimelineContext } from '../../../timeline_context'; +import { useTimelineContext } from '../../../timeline_context'; const CLOSE_BUTTON_SIZE = 25; // px const SORT_INDICATOR_SIZE = 25; // px @@ -68,7 +67,7 @@ export const CloseButton = pure<{ CloseButton.displayName = 'CloseButton'; export const Actions = React.memo(({ header, onColumnRemoved, show, sort }) => { - const isLoading = useContext(TimelineContext); + const isLoading = useTimelineContext(); return ( (({ children, onClick, isResizing }) => { - const isLoading = useContext(TimelineContext); + const isLoading = useTimelineContext(); return ( (({ children }) => { - const width = useContext(TimelineWidthContext); + const width = useTimelineWidthContext(); // Passing the styles directly to the component because the width is // being calculated and is recommended by Styled Components for performance diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/providers.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/providers.test.tsx index c2c4c30db3c53..8a99ed7417a63 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/providers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/providers.test.tsx @@ -17,6 +17,7 @@ import { getDraggableId, Providers } from './providers'; import { DELETE_CLASS_NAME, ENABLE_CLASS_NAME, EXCLUDE_CLASS_NAME } from './provider_item_actions'; describe('Providers', () => { + const mockTimelineContext: boolean = true; describe('rendering', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( @@ -95,7 +96,7 @@ describe('Providers', () => { const mockOnDataProviderRemoved = jest.fn(); const wrapper = mount( - + { const mockOnDataProviderRemoved = jest.fn(); const wrapper = mount( - + { const mockOnToggleDataProviderEnabled = jest.fn(); const wrapper = mount( - + { const wrapper = mount( - + { const wrapper = mount( - + { const wrapper = mount( - + { const wrapper = mount( - + ` ${({ hideExpandButton }) => @@ -52,7 +51,7 @@ export const ExpandableEvent = React.memo( toggleColumn, onUpdateColumns, }) => { - const width = useContext(TimelineWidthContext); + const width = useTimelineWidthContext(); // Passing the styles directly to the component of LazyAccordion because the width is // being calculated and is recommended by Styled Components for performance // https://github.com/styled-components/styled-components/issues/134#issuecomment-312415291 diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/fetch_kql_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/fetch_kql_timeline.tsx new file mode 100644 index 0000000000000..e5f50c332a7c2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/fetch_kql_timeline.tsx @@ -0,0 +1,79 @@ +/* + * 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 { memo, useEffect } from 'react'; +import { connect } from 'react-redux'; +import { ActionCreator } from 'typescript-fsa'; +import { StaticIndexPattern } from 'ui/index_patterns'; + +import { inputsModel, KueryFilterQuery, timelineSelectors, State } from '../../store'; +import { inputsActions } from '../../store/actions'; +import { InputsModelId } from '../../store/inputs/constants'; +import { useUpdateKql } from '../../utils/kql/use_update_kql'; + +interface TimelineKqlFetchRedux { + kueryFilterQuery: KueryFilterQuery | null; + kueryFilterQueryDraft: KueryFilterQuery | null; +} + +interface TimelineKqlFetchDispatch { + setTimelineQuery: ActionCreator<{ + id: string; + inputId: InputsModelId; + inspect: inputsModel.InspectQuery | null; + loading: boolean; + refetch: inputsModel.Refetch | inputsModel.RefetchKql | null; + }>; +} + +export interface TimelineKqlFetchProps { + id: string; + indexPattern: StaticIndexPattern; + inputId: InputsModelId; +} + +type OwnProps = TimelineKqlFetchProps & TimelineKqlFetchRedux & TimelineKqlFetchDispatch; + +const TimelineKqlFetchComponent = memo( + ({ id, indexPattern, inputId, kueryFilterQuery, kueryFilterQueryDraft, setTimelineQuery }) => { + useEffect(() => { + setTimelineQuery({ + id: 'kql', + inputId, + inspect: null, + loading: false, + refetch: useUpdateKql({ + indexPattern, + kueryFilterQuery, + kueryFilterQueryDraft, + storeType: 'timelineType', + type: null, + timelineId: id, + }), + }); + }, [kueryFilterQueryDraft, kueryFilterQuery, id]); + return null; + } +); + +const makeMapStateToProps = () => { + const getTimelineKueryFilterQueryDraft = timelineSelectors.getKqlFilterQueryDraftSelector(); + const getTimelineKueryFilterQuery = timelineSelectors.getKqlFilterKuerySelector(); + const mapStateToProps = (state: State, { id }: TimelineKqlFetchProps) => { + return { + kueryFilterQuery: getTimelineKueryFilterQuery(state, id), + kueryFilterQueryDraft: getTimelineKueryFilterQueryDraft(state, id), + }; + }; + return mapStateToProps; +}; + +export const TimelineKqlFetch = connect( + makeMapStateToProps, + { + setTimelineQuery: inputsActions.setQuery, + } +)(TimelineKqlFetchComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/refetch_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/refetch_timeline.tsx index 658ae0bb3ffd1..ee818597268c5 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/refetch_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/refetch_timeline.tsx @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { memo, useEffect } from 'react'; import { connect } from 'react-redux'; +import { compose } from 'redux'; import { ActionCreator } from 'typescript-fsa'; import { inputsModel } from '../../store'; @@ -18,36 +19,35 @@ interface TimelineRefetchDispatch { inputId: InputsModelId; inspect: inputsModel.InspectQuery | null; loading: boolean; - refetch: inputsModel.Refetch; + refetch: inputsModel.Refetch | inputsModel.RefetchKql | null; }>; } -interface TimelineRefetchProps { - children: React.ReactNode; +export interface TimelineRefetchProps { id: string; + inputId: InputsModelId; inspect: inputsModel.InspectQuery | null; loading: boolean; - refetch: inputsModel.Refetch; + refetch: inputsModel.Refetch | null; } -type OwnProps = TimelineRefetchDispatch & TimelineRefetchProps; +type OwnProps = TimelineRefetchProps & TimelineRefetchDispatch; -class TimelineRefetchComponent extends React.PureComponent { - public componentDidUpdate(prevProps: OwnProps) { - const { loading, id, inspect, refetch } = this.props; - if (prevProps.loading !== loading) { - this.props.setTimelineQuery({ id, inputId: 'timeline', inspect, loading, refetch }); - } - } +const TimelineRefetchComponent = memo( + ({ children, id, inputId, inspect, loading, refetch, setTimelineQuery }) => { + useEffect(() => { + setTimelineQuery({ id, inputId, inspect, loading, refetch }); + }, [loading, refetch, inspect]); - public render() { - return <>{this.props.children}; + return null; } -} +); -export const TimelineRefetch = connect( - null, - { - setTimelineQuery: inputsActions.setQuery, - } +export const TimelineRefetch = compose>( + connect( + null, + { + setTimelineQuery: inputsActions.setQuery, + } + ) )(TimelineRefetchComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx index 2a31402fbd32b..ded0209cef35d 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx @@ -34,7 +34,8 @@ import { Footer, footerHeight } from './footer'; import { TimelineHeader } from './header'; import { calculateBodyHeight, combineQueries } from './helpers'; import { TimelineRefetch } from './refetch_timeline'; -import { TimelineContext, TimelineWidthContext } from './timeline_context'; +import { ManageTimelineContext } from './timeline_context'; +import { TimelineKqlFetch } from './fetch_kql_timeline'; const WrappedByAutoSizer = styled.div` width: 100%; @@ -147,7 +148,7 @@ export const Timeline = React.memo( sort={sort} /> - + {combinedQueries != null ? ( ( getUpdatedAt, refetch, }) => ( - - - - -