From e98e2796d5dae3d21b23a8ae4de996ae14220922 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 5 Oct 2020 19:40:19 +0200 Subject: [PATCH 01/14] [Ingest Pipelines] Fix description field bug (#79445) * rendering optimisation of text fields * fix description disappearing bug * refactor fix to get value from form serializer --- .../inline_text_input.tsx | 16 ++++-- .../pipeline_processors_editor_item.tsx | 51 ++++++++++--------- .../processor_form/add_processor_form.tsx | 4 +- .../processor_form.container.tsx | 24 ++++++++- .../components/private_tree.tsx | 8 ++- 5 files changed, 68 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx index 833888e9062df..a9e8028fd02ee 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import classNames from 'classnames'; -import React, { FunctionComponent, useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, memo } from 'react'; import { EuiFieldText, EuiText, keys } from '@elastic/eui'; export interface Props { @@ -23,15 +23,15 @@ export interface Props { text?: string; } -export const InlineTextInput: FunctionComponent = ({ +function _InlineTextInput({ placeholder, text, ariaLabel, disabled = false, onChange, -}) => { +}: Props): React.ReactElement | null { const [isShowingTextInput, setIsShowingTextInput] = useState(false); - const [textValue, setTextValue] = useState(text ?? ''); + const [textValue, setTextValue] = useState(() => text ?? ''); const containerClasses = classNames('pipelineProcessorsEditor__item__textContainer', { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -47,6 +47,10 @@ export const InlineTextInput: FunctionComponent = ({ }); }, [setIsShowingTextInput, onChange, textValue]); + useEffect(() => { + setTextValue(text ?? ''); + }, [text]); + useEffect(() => { const keyboardListener = (event: KeyboardEvent) => { if (event.key === keys.ESCAPE || event.code === 'Escape') { @@ -91,4 +95,6 @@ export const InlineTextInput: FunctionComponent = ({ ); -}; +} + +export const InlineTextInput = memo(_InlineTextInput); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index aa76f67413306..dd7798a37dd4e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -5,7 +5,7 @@ */ import classNames from 'classnames'; -import React, { FunctionComponent, memo } from 'react'; +import React, { FunctionComponent, memo, useCallback } from 'react'; import { EuiButtonToggle, EuiFlexGroup, @@ -89,6 +89,32 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( 'pipelineProcessorsEditor__item--displayNone': isInMoveMode && !processor.options.description, }); + const onDescriptionChange = useCallback( + (nextDescription) => { + let nextOptions: Record; + if (!nextDescription) { + const { description: _description, ...restOptions } = processor.options; + nextOptions = restOptions; + } else { + nextOptions = { + ...processor.options, + description: nextDescription, + }; + } + processorsDispatch({ + type: 'updateProcessor', + payload: { + processor: { + ...processor, + options: nextOptions, + }, + selector, + }, + }); + }, + [processor, processorsDispatch, selector] + ); + const renderMoveButton = () => { const label = !isMovingThisProcessor ? i18nTexts.moveButtonLabel @@ -176,28 +202,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( { - let nextOptions: Record; - if (!nextDescription) { - const { description: _description, ...restOptions } = processor.options; - nextOptions = restOptions; - } else { - nextOptions = { - ...processor.options, - description: nextDescription, - }; - } - processorsDispatch({ - type: 'updateProcessor', - payload: { - processor: { - ...processor, - options: nextOptions, - }, - selector, - }, - }); - }} + onChange={onDescriptionChange} ariaLabel={i18nTexts.processorTypeLabel({ type: processor.type })} text={description} placeholder={i18nTexts.descriptionPlaceholder} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx index b663daedd9b9c..f663832702b1c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx @@ -24,10 +24,8 @@ import { getProcessorDescriptor } from '../shared'; import { DocumentationButton } from './documentation_button'; import { ProcessorSettingsFields } from './processor_settings_fields'; +import { Fields } from './processor_form.container'; -interface Fields { - fields: { [key: string]: any }; -} export interface Props { isOnFailure: boolean; form: FormHook; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx index 25c9579e3c48e..61a6f985340ea 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx @@ -19,6 +19,7 @@ export type OnSubmitHandler = (processor: ProcessorFormOnSubmitArg) => void; export type OnFormUpdateHandler = (form: OnFormUpdateArg) => void; export interface Fields { + type: string; fields: { [key: string]: any }; } @@ -57,8 +58,28 @@ export const ProcessorFormContainer: FunctionComponent = ({ return { ...processor, options } as ProcessorInternal; }, [processor, unsavedFormState]); + const formSerializer = useCallback( + (formState) => { + return { + type: formState.type, + fields: formState.customOptions + ? { + ...formState.customOptions, + } + : { + ...formState.fields, + // The description field is not editable in processor forms currently. We re-add it here or it will be + // stripped. + description: processor ? processor.options.description : undefined, + }, + }; + }, + [processor] + ); + const { form } = useForm({ defaultValue: { fields: getProcessor().options }, + serializer: formSerializer, }); const { subscribe } = form; @@ -67,8 +88,7 @@ export const ProcessorFormContainer: FunctionComponent = ({ const { isValid, data } = await form.submit(); if (isValid) { - const { type, customOptions, fields } = data as FormData; - const options = customOptions ? customOptions : fields; + const { type, fields: options } = data as FormData; unsavedFormState.current = options; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx index 27e612f196667..cbff02070483a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent, MutableRefObject, useEffect } from 'react'; +import React, { FunctionComponent, MutableRefObject, useEffect, useMemo } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { AutoSizer, List, WindowScroller } from 'react-virtualized'; @@ -65,6 +65,10 @@ export const PrivateTree: FunctionComponent = ({ windowScrollerRef, listRef, }) => { + const selectors: string[][] = useMemo(() => { + return processors.map((_, idx) => selector.concat(String(idx))); + }, [processors, selector]); + const renderRow = ({ idx, info, @@ -163,7 +167,7 @@ export const PrivateTree: FunctionComponent = ({ const below = processors[idx + 1]; const info: ProcessorInfo = { id: processor.id, - selector: selector.concat(String(idx)), + selector: selectors[idx], aboveId: above?.id, belowId: below?.id, }; From 8c3af56e2be083dc8ec84daa5546833eeae7b053 Mon Sep 17 00:00:00 2001 From: Sushrut Kasture Date: Mon, 5 Oct 2020 23:11:25 +0530 Subject: [PATCH 02/14] Update user table after user is deleted (#79491) --- .../management/users/users_grid/users_grid_page.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx index 4825e2b119b7b..998739a9a83af 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx @@ -276,12 +276,18 @@ export class UsersGridPage extends Component { private handleDelete = (usernames: string[], errors: string[]) => { const { users } = this.state; + const filteredUsers = users.filter(({ username }) => { + return !usernames.includes(username) || errors.includes(username); + }); this.setState({ selection: [], showDeleteConfirmation: false, - users: users.filter(({ username }) => { - return !usernames.includes(username) || errors.includes(username); - }), + users: filteredUsers, + visibleUsers: this.getVisibleUsers( + filteredUsers, + this.state.filter, + this.state.includeReservedUsers + ), }); }; From ead4ebc9f6be9b107ac31e017aab23d045c167cd Mon Sep 17 00:00:00 2001 From: Poff Poffenberger Date: Mon, 5 Oct 2020 12:52:12 -0500 Subject: [PATCH 03/14] [Canvas] Disable datasource UI when expression contains an expression argument (#79369) * Disable datasource UI when expression contains an expression argument * Removing unnecessary type coercion --- .../uis/datasources/demodata.js | 10 +- x-pack/plugins/canvas/i18n/components.ts | 5 + .../datasource_component.stories.tsx | 93 +++++++++++++++++++ .../datasource/datasource_component.js | 66 +++++++------ .../canvas/storybook/webpack.config.js | 4 + .../plugins/canvas/tasks/mocks/esService.ts | 9 ++ 6 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 x-pack/plugins/canvas/public/components/datasource/__stories__/datasource_component.stories.tsx create mode 100644 x-pack/plugins/canvas/tasks/mocks/esService.ts diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js index faadfd4bb26d7..43999e9bc7fac 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js @@ -5,16 +5,18 @@ */ import React from 'react'; -import { EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiText } from '@elastic/eui'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; import { DataSourceStrings } from '../../../i18n'; const { DemoData: strings } = DataSourceStrings; const DemodataDatasource = () => ( - -

{strings.getDescription()}

-
+ + +

{strings.getDescription()}

+
+
); export const demodata = () => ({ diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index 71e3386d821f1..51c86f6604330 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -235,6 +235,11 @@ export const ComponentStrings = { i18n.translate('xpack.canvas.datasourceDatasourceComponent.changeButtonLabel', { defaultMessage: 'Change element data source', }), + getExpressionArgDescription: () => + i18n.translate('xpack.canvas.datasourceDatasourceComponent.expressionArgDescription', { + defaultMessage: + 'The datasource has an argument controlled by an expression. Use the expression editor to modify the datasource.', + }), getPreviewButtonLabel: () => i18n.translate('xpack.canvas.datasourceDatasourceComponent.previewButtonLabel', { defaultMessage: 'Preview data', diff --git a/x-pack/plugins/canvas/public/components/datasource/__stories__/datasource_component.stories.tsx b/x-pack/plugins/canvas/public/components/datasource/__stories__/datasource_component.stories.tsx new file mode 100644 index 0000000000000..773f462e71391 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/datasource/__stories__/datasource_component.stories.tsx @@ -0,0 +1,93 @@ +/* + * 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 { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import React from 'react'; +// @ts-expect-error untyped local +import { DatasourceComponent } from '../datasource_component'; +import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component'; +// @ts-expect-error untyped local +import { Datasource } from '../../../../public/expression_types/datasource'; + +const TestDatasource = ({ args }: any) => ( + + +

Hello! I am a datasource with a query arg of: {args.query}

+
+
+); + +const testDatasource = () => ({ + name: 'test', + displayName: 'Test Datasource', + help: 'This is a test data source', + image: 'training', + template: templateFromReactComponent(TestDatasource), +}); + +const wrappedTestDatasource = new Datasource(testDatasource()); + +const args = { + query: ['select * from kibana'], +}; + +storiesOf('components/datasource/DatasourceComponent', module) + .addParameters({ + info: { + inline: true, + styles: { + infoBody: { + margin: 20, + }, + infoStory: { + margin: '40px 60px', + width: '320px', + }, + }, + }, + }) + .add('simple datasource', () => ( + + )) + .add('datasource with expression arguments', () => ( + + )); diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_component.js b/x-pack/plugins/canvas/public/components/datasource/datasource_component.js index de9d192e4608c..171153efdac35 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource_component.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_component.js @@ -17,13 +17,12 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { isEqual } from 'lodash'; -import { ComponentStrings, DataSourceStrings } from '../../../i18n'; +import { ComponentStrings } from '../../../i18n'; import { getDefaultIndex } from '../../lib/es_service'; import { DatasourceSelector } from './datasource_selector'; import { DatasourcePreview } from './datasource_preview'; const { DatasourceDatasourceComponent: strings } = ComponentStrings; -const { DemoData: demoDataStrings } = DataSourceStrings; export class DatasourceComponent extends PureComponent { static propTypes = { @@ -133,14 +132,17 @@ export class DatasourceComponent extends PureComponent { /> ) : null; - const datasourceRender = stateDatasource.render({ - args: stateArgs, - updateArgs, - datasourceDef, - isInvalid, - setInvalid, - defaultIndex, - }); + const datasourceRender = () => + stateDatasource.render({ + args: stateArgs, + updateArgs, + datasourceDef, + isInvalid, + setInvalid, + defaultIndex, + }); + + const hasExpressionArgs = Object.values(stateArgs).some((a) => a && typeof a[0] === 'object'); return ( @@ -157,26 +159,34 @@ export class DatasourceComponent extends PureComponent { {stateDatasource.displayName} - {stateDatasource.name === 'demodata' ? ( - - {datasourceRender} - + {!hasExpressionArgs ? ( + <> + {datasourceRender()} + + + + setPreviewing(true)}> + {strings.getPreviewButtonLabel()} + + + + + {strings.getSaveButtonLabel()} + + + + ) : ( - datasourceRender + +

{strings.getExpressionArgDescription()}

+
)} - - - - setPreviewing(true)}> - {strings.getPreviewButtonLabel()} - - - - - {strings.getSaveButtonLabel()} - - - {datasourcePreview} diff --git a/x-pack/plugins/canvas/storybook/webpack.config.js b/x-pack/plugins/canvas/storybook/webpack.config.js index d8434bd5d9080..a7f712fb62da3 100644 --- a/x-pack/plugins/canvas/storybook/webpack.config.js +++ b/x-pack/plugins/canvas/storybook/webpack.config.js @@ -176,6 +176,10 @@ module.exports = async ({ config: storybookConfig }) => { /(lib)?\/ui_metric/, path.resolve(__dirname, '../tasks/mocks/uiMetric') ), + new webpack.NormalModuleReplacementPlugin( + /lib\/es_service/, + path.resolve(__dirname, '../tasks/mocks/esService') + ), ], resolve: { extensions: ['.ts', '.tsx', '.scss', '.mjs', '.html'], diff --git a/x-pack/plugins/canvas/tasks/mocks/esService.ts b/x-pack/plugins/canvas/tasks/mocks/esService.ts new file mode 100644 index 0000000000000..a0c2a42eafd7c --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/esService.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. + */ + +export function getDefaultIndex() { + return Promise.resolve('default-index'); +} From ad89e6f956f901fad1839c503162804d01337610 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Mon, 5 Oct 2020 13:00:58 -0500 Subject: [PATCH 04/14] [ML] Add runtime fields support (#78700) Co-authored-by: Elastic Machine Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/data_grid/common.ts | 13 +++++++++++ .../application/components/data_grid/index.ts | 1 + .../common/get_index_data.ts | 10 +++++++-- .../hooks/use_index_data.ts | 6 +++-- .../components/custom_url_editor/utils.js | 3 ++- .../application/services/job_service.js | 4 ++-- .../new_job/categorization/examples.ts | 14 ++++++++---- .../categorization/validation_results.ts | 22 +++++++++++-------- .../public/app/hooks/use_index_data.ts | 10 ++++----- 9 files changed, 58 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index 36b0573d609d8..a33b2e6b3e2d6 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -315,3 +315,16 @@ export const showDataGridColumnChartErrorMessageToast = ( }) ); }; + +// helper function to transform { [key]: [val] } => { [key]: val } +// for when `fields` is used in es.search since response is always an array of values +// since response always returns an array of values for each field +export const getProcessedFields = (originalObj: object) => { + const obj: { [key: string]: any } = { ...originalObj }; + for (const key of Object.keys(obj)) { + if (Array.isArray(obj[key]) && obj[key].length === 1) { + obj[key] = obj[key][0]; + } + } + return obj; +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index 633d70687dd27..cb5b6ecc18fa9 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -11,6 +11,7 @@ export { multiColumnSortFactory, showDataGridColumnChartErrorMessageToast, useRenderCellValue, + getProcessedFields, } from './common'; export { getFieldType, ChartData } from './use_column_chart'; export { useDataGrid } from './use_data_grid'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts index 361a79d42214d..667dea27de96e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts @@ -7,7 +7,7 @@ import type { SearchResponse7 } from '../../../../common/types/es_client'; import { extractErrorMessage } from '../../../../common/util/errors'; -import { EsSorting, UseDataGridReturnType } from '../../components/data_grid'; +import { EsSorting, UseDataGridReturnType, getProcessedFields } from '../../components/data_grid'; import { ml } from '../../services/ml_api_service'; import { isKeywordAndTextType } from '../common/fields'; @@ -47,9 +47,12 @@ export const getIndexData = async ( }, {} as EsSorting); const { pageIndex, pageSize } = pagination; + // TODO: remove results_field from `fields` when possible const resp: SearchResponse7 = await ml.esSearch({ index: jobConfig.dest.index, body: { + fields: ['*'], + _source: jobConfig.dest.results_field, query: searchQuery, from: pageIndex * pageSize, size: pageSize, @@ -58,8 +61,11 @@ export const getIndexData = async ( }); setRowCount(resp.hits.total.value); + const docs = resp.hits.hits.map((d) => ({ + ...getProcessedFields(d.fields), + [jobConfig.dest.results_field]: d._source[jobConfig.dest.results_field], + })); - const docs = resp.hits.hits.map((d) => d._source); setTableItems(docs); setStatus(INDEX_STATUS.LOADED); } catch (e) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index 74d45b86c8c4d..149919d9b36c2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -23,6 +23,7 @@ import { useRenderCellValue, EsSorting, UseIndexDataReturnType, + getProcessedFields, } from '../../../../components/data_grid'; import type { SearchResponse7 } from '../../../../../../common/types/es_client'; import { extractErrorMessage } from '../../../../../../common/util/errors'; @@ -81,6 +82,8 @@ export const useIndexData = ( query, // isDefaultQuery(query) ? matchAllQuery : query, from: pagination.pageIndex * pagination.pageSize, size: pagination.pageSize, + fields: ['*'], + _source: false, ...(Object.keys(sort).length > 0 ? { sort } : {}), }, }; @@ -88,8 +91,7 @@ export const useIndexData = ( try { const resp: IndexSearchResponse = await ml.esSearch(esSearchRequest); - const docs = resp.hits.hits.map((d) => d._source); - + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields)); setRowCount(resp.hits.total.value); setTableItems(docs); setStatus(INDEX_STATUS.LOADED); diff --git a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js index a0e9c33e42dfa..6e23d652b5c9f 100644 --- a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js @@ -18,6 +18,7 @@ import { ml } from '../../../services/ml_api_service'; import { mlJobService } from '../../../services/job_service'; import { escapeForElasticsearchQuery } from '../../../util/string_utils'; import { getSavedObjectsClient, getGetUrlGenerator } from '../../../util/dependency_cache'; +import { getProcessedFields } from '../../../components/data_grid'; export function getNewCustomUrlDefaults(job, dashboards, indexPatterns) { // Returns the settings object in the format used by the custom URL editor @@ -329,7 +330,7 @@ export function getTestUrl(job, customUrl) { }); } else { if (response.hits.total.value > 0) { - testDoc = response.hits.hits[0]._source; + testDoc = getProcessedFields(response.hits.hits[0].fields); } } diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 0971b47605135..4aa1f7ef81d59 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -509,10 +509,10 @@ class JobService { fields[job.data_description.time_field] = {}; } - // console.log('fields: ', fields); const fieldsList = Object.keys(fields); if (fieldsList.length) { - body._source = fieldsList; + body.fields = fieldsList; + body._source = false; } } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index 6b9f30b2ae00b..9e6c6f1552bad 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -56,18 +56,25 @@ export function categorizationExamplesProvider({ } } } - const { body } = await asCurrentUser.search>({ index: indexPatternTitle, size, body: { - _source: categorizationFieldName, + fields: [categorizationFieldName], + _source: false, query, sort: ['_doc'], }, }); - const tempExamples = body.hits.hits.map(({ _source }) => _source[categorizationFieldName]); + // hit.fields can be undefined if value is originally null + const tempExamples = body.hits.hits.map(({ fields }) => + fields && + Array.isArray(fields[categorizationFieldName]) && + fields[categorizationFieldName].length > 0 + ? fields[categorizationFieldName][0] + : null + ); validationResults.createNullValueResult(tempExamples); @@ -81,7 +88,6 @@ export function categorizationExamplesProvider({ const examplesWithTokens = await getTokens(CHUNK_SIZE, allExamples, analyzer); return { examples: examplesWithTokens }; } catch (err) { - // console.log('dropping to 50 chunk size'); // if an error is thrown when loading the tokens, lower the chunk size by half and try again // the error may have been caused by too many tokens being found. // the _analyze endpoint has a maximum of 10000 tokens. diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts index 60595ccedff45..5845064218ad8 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts @@ -123,15 +123,19 @@ export class ValidationResults { public createNullValueResult(examples: Array) { const nullCount = examples.filter((e) => e === null).length; - if (nullCount / examples.length >= NULL_COUNT_PERCENT_LIMIT) { - this._results.push({ - id: VALIDATION_RESULT.NULL_VALUES, - valid: CATEGORY_EXAMPLES_VALIDATION_STATUS.PARTIALLY_VALID, - message: i18n.translate('xpack.ml.models.jobService.categorization.messages.nullValues', { - defaultMessage: 'More than {percent}% of field values are null.', - values: { percent: NULL_COUNT_PERCENT_LIMIT * 100 }, - }), - }); + // if all values are null, VALIDATION_RESULT.NO_EXAMPLES will be raised + // so we don't need to display this warning as well + if (nullCount !== examples.length) { + if (nullCount / examples.length >= NULL_COUNT_PERCENT_LIMIT) { + this._results.push({ + id: VALIDATION_RESULT.NULL_VALUES, + valid: CATEGORY_EXAMPLES_VALIDATION_STATUS.PARTIALLY_VALID, + message: i18n.translate('xpack.ml.models.jobService.categorization.messages.nullValues', { + defaultMessage: 'More than {percent}% of field values are null.', + values: { percent: NULL_COUNT_PERCENT_LIMIT * 100 }, + }), + }); + } } } diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 6f24017b2274f..c057330507c02 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -12,13 +12,10 @@ import { isEsSearchResponse, isFieldHistogramsResponseSchema, } from '../../../common/api_schemas/type_guards'; - -import { getErrorMessage } from '../../../common/utils/errors'; - import type { EsSorting, UseIndexDataReturnType } from '../../shared_imports'; +import { getErrorMessage } from '../../../common/utils/errors'; import { isDefaultQuery, matchAllQuery, PivotQuery } from '../common'; - import { SearchItems } from './use_search_items'; import { useApi } from './use_api'; @@ -38,6 +35,7 @@ export const useIndexData = ( showDataGridColumnChartErrorMessageToast, useDataGrid, useRenderCellValue, + getProcessedFields, INDEX_STATUS, }, } = useAppDependencies(); @@ -86,6 +84,8 @@ export const useIndexData = ( const esSearchRequest = { index: indexPattern.title, body: { + fields: ['*'], + _source: false, // Instead of using the default query (`*`), fall back to a more efficient `match_all` query. query: isDefaultQuery(query) ? matchAllQuery : query, from: pagination.pageIndex * pagination.pageSize, @@ -102,7 +102,7 @@ export const useIndexData = ( return; } - const docs = resp.hits.hits.map((d) => d._source); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields)); setRowCount(resp.hits.total.value); setTableItems(docs); From a812db3e26be203d8f479f89657ecf1248e0c3bc Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Mon, 5 Oct 2020 14:05:34 -0400 Subject: [PATCH 05/14] [Alerts] resolve flaky lastExecutionDate tests (#79436) resolves https://github.com/elastic/kibana/issues/79249 The tests that were flaky were capturing a date while the test was running, and then comparing to a lastExecutionDate field. Most of the time this worked, but were sometimes out of order because of the way the searches are done and status changing dynamically. The only current dates we can test against are really at the very beginning and very end of the tests. --- .../spaces_only/tests/alerting/execution_status.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts index 1c2e51637fb41..16a37bdf77662 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts @@ -19,8 +19,7 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; export default function executionStatusAlertTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - // FLAKY: https://github.com/elastic/kibana/issues/79249 - describe.skip('executionStatus', () => { + describe('executionStatus', () => { const objectRemover = new ObjectRemover(supertest); after(async () => await objectRemover.removeAll()); @@ -65,7 +64,6 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon expect(response.status).to.eql(200); const alertId = response.body.id; dates.push(response.body.executionStatus.lastExecutionDate); - dates.push(Date.now()); objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); const executionStatus = await waitForStatus(alertId, new Set(['ok'])); @@ -100,7 +98,6 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon expect(response.status).to.eql(200); const alertId = response.body.id; dates.push(response.body.executionStatus.lastExecutionDate); - dates.push(Date.now()); objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); const executionStatus = await waitForStatus(alertId, new Set(['active'])); @@ -132,7 +129,6 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon expect(response.status).to.eql(200); const alertId = response.body.id; dates.push(response.body.executionStatus.lastExecutionDate); - dates.push(Date.now()); objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); const executionStatus = await waitForStatus(alertId, new Set(['error'])); From dca9e706166783106c5538bb31fbc83e15a7e29b Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 5 Oct 2020 20:07:23 +0200 Subject: [PATCH 06/14] Ignore intermediate unauthenticated session during repeated authentication attempt. (#79300) --- .github/CODEOWNERS | 2 -- x-pack/README.md | 4 +-- .../authentication/authenticator.test.ts | 28 ++++++------------- .../server/authentication/authenticator.ts | 14 ++++++---- x-pack/scripts/functional_tests.js | 4 +-- .../ftr_provider_context.d.ts | 11 -------- .../services.ts | 14 ---------- .../test/saml_api_integration/apis/index.ts | 14 ---------- .../ftr_provider_context.d.ts | 11 -------- x-pack/test/saml_api_integration/services.ts | 15 ---------- .../fixtures/saml}/idp_metadata.xml | 0 .../fixtures/saml}/idp_metadata_2.xml | 0 .../fixtures/saml}/saml_provider/kibana.json | 0 .../fixtures/saml}/saml_provider/metadata.xml | 0 .../saml}/saml_provider/server/index.ts | 2 +- .../saml}/saml_provider/server/init_routes.ts | 2 +- .../fixtures/saml}/saml_tools.ts | 0 .../login_selector.config.ts} | 14 +++------- .../saml.config.ts} | 6 ++-- .../test/security_api_integration/services.ts | 1 - .../login_selector/basic_functionality.ts} | 22 +++++++++++---- .../tests/login_selector}/index.ts | 6 ++-- .../tests/saml}/index.ts | 4 ++- .../tests/saml}/saml_login.ts | 6 +++- .../tests/session_idle/cleanup.ts | 4 ++- .../tests/session_lifespan/cleanup.ts | 4 ++- .../login_selector.config.ts | 10 +++++-- .../test/security_functional/saml.config.ts | 10 +++++-- 28 files changed, 80 insertions(+), 128 deletions(-) delete mode 100644 x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts delete mode 100644 x-pack/test/login_selector_api_integration/services.ts delete mode 100644 x-pack/test/saml_api_integration/apis/index.ts delete mode 100644 x-pack/test/saml_api_integration/ftr_provider_context.d.ts delete mode 100644 x-pack/test/saml_api_integration/services.ts rename x-pack/test/{saml_api_integration/fixtures => security_api_integration/fixtures/saml}/idp_metadata.xml (100%) rename x-pack/test/{saml_api_integration/fixtures => security_api_integration/fixtures/saml}/idp_metadata_2.xml (100%) rename x-pack/test/{saml_api_integration/fixtures => security_api_integration/fixtures/saml}/saml_provider/kibana.json (100%) rename x-pack/test/{saml_api_integration/fixtures => security_api_integration/fixtures/saml}/saml_provider/metadata.xml (100%) rename x-pack/test/{saml_api_integration/fixtures => security_api_integration/fixtures/saml}/saml_provider/server/index.ts (85%) rename x-pack/test/{saml_api_integration/fixtures => security_api_integration/fixtures/saml}/saml_provider/server/init_routes.ts (96%) rename x-pack/test/{saml_api_integration/fixtures => security_api_integration/fixtures/saml}/saml_tools.ts (100%) rename x-pack/test/{login_selector_api_integration/config.ts => security_api_integration/login_selector.config.ts} (95%) rename x-pack/test/{saml_api_integration/config.ts => security_api_integration/saml.config.ts} (92%) rename x-pack/test/{login_selector_api_integration/apis/login_selector.ts => security_api_integration/tests/login_selector/basic_functionality.ts} (96%) rename x-pack/test/{login_selector_api_integration/apis => security_api_integration/tests/login_selector}/index.ts (65%) rename x-pack/test/{saml_api_integration/apis/security => security_api_integration/tests/saml}/index.ts (84%) rename x-pack/test/{saml_api_integration/apis/security => security_api_integration/tests/saml}/saml_login.ts (99%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1cf0300b9e17..4db1c2dd3b5eb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -263,10 +263,8 @@ /x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security /x-pack/test/functional/apps/security/ @elastic/kibana-security /x-pack/test/kerberos_api_integration/ @elastic/kibana-security -/x-pack/test/login_selector_api_integration/ @elastic/kibana-security /x-pack/test/oidc_api_integration/ @elastic/kibana-security /x-pack/test/pki_api_integration/ @elastic/kibana-security -/x-pack/test/saml_api_integration/ @elastic/kibana-security /x-pack/test/security_api_integration/ @elastic/kibana-security /x-pack/test/security_functional/ @elastic/kibana-security /x-pack/test/spaces_api_integration/ @elastic/kibana-security diff --git a/x-pack/README.md b/x-pack/README.md index 0449f1fc1bdab..73d8736124843 100644 --- a/x-pack/README.md +++ b/x-pack/README.md @@ -55,7 +55,7 @@ yarn test:mocha For more info, see [the Elastic functional test development guide](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html). -The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.ts)), and *SAML API integration tests* ([specified by this config](test/saml_api_integration/config.ts)). +The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.ts)), and *SAML API integration tests* ([specified by this config](test/security_api_integration/saml.config.ts)). The script runs all sets of tests sequentially like so: * builds Elasticsearch and X-Pack @@ -108,7 +108,7 @@ node scripts/functional_tests --config test/api_integration/config We also have SAML API integration tests which set up Elasticsearch and Kibana with SAML support. Run _only_ API integration tests with SAML enabled like so: ```sh -node scripts/functional_tests --config test/saml_api_integration/config +node scripts/functional_tests --config test/security_api_integration/saml.config ``` #### Running Jest integration tests diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index fcc652505ba3a..4f52ebe3065a3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -1374,6 +1374,14 @@ describe('Authenticator', () => { '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' ) ); + + // Unauthenticated session should be treated as non-existent one. + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' + ) + ); expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); }); }); @@ -1591,26 +1599,6 @@ describe('Authenticator', () => { ); }); - it('does not redirect to Overwritten Session if session was unauthenticated before this authentication attempt', async () => { - const request = httpServerMock.createKibanaRequest(); - mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); - - const newMockUser = mockAuthenticatedUser({ username: 'new-username' }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(newMockUser, { - state: 'some-state', - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(newMockUser, { - state: 'some-state', - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - }); - it('redirects to Overwritten Session when username changes', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 1fb9d9221f041..b8ec6258eb0d5 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -131,6 +131,10 @@ function isLoginAttemptWithProviderType( ); } +function isSessionAuthenticated(sessionValue?: Readonly | null) { + return !!sessionValue?.username; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -558,7 +562,7 @@ export class Authenticator { return ownsSession ? { value: existingSessionValue, overwritten: false } : null; } - const isExistingSessionAuthenticated = !!existingSessionValue?.username; + const isExistingSessionAuthenticated = isSessionAuthenticated(existingSessionValue); const isNewSessionAuthenticated = !!authenticationResult.user; const providerHasChanged = !!existingSessionValue && !ownsSession; @@ -637,7 +641,7 @@ export class Authenticator { // 4. Request isn't attributed with HTTP Authorization header return ( canRedirectRequest(request) && - !sessionValue && + !isSessionAuthenticated(sessionValue) && this.options.config.authc.selector.enabled && HTTPAuthorizationHeader.parseFromRequest(request) == null ); @@ -688,14 +692,14 @@ export class Authenticator { return authenticationResult; } - const isSessionAuthenticated = !!sessionUpdateResult?.value?.username; + const isUpdatedSessionAuthenticated = isSessionAuthenticated(sessionUpdateResult?.value); let preAccessRedirectURL; - if (isSessionAuthenticated && sessionUpdateResult?.overwritten) { + if (isUpdatedSessionAuthenticated && sessionUpdateResult?.overwritten) { this.logger.debug('Redirecting user to the overwritten session UI.'); preAccessRedirectURL = `${this.options.basePath.serverBasePath}${OVERWRITTEN_SESSION_ROUTE}`; } else if ( - isSessionAuthenticated && + isUpdatedSessionAuthenticated && this.shouldRedirectToAccessAgreement(sessionUpdateResult?.value ?? null) ) { this.logger.debug('Redirecting user to the access agreement UI.'); diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 6271c4b601307..6c0edd904b0e7 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -31,16 +31,16 @@ const onlyNotInCoverageTests = [ require.resolve('../test/plugin_api_integration/config.ts'), require.resolve('../test/kerberos_api_integration/config.ts'), require.resolve('../test/kerberos_api_integration/anonymous_access.config.ts'), - require.resolve('../test/saml_api_integration/config.ts'), + require.resolve('../test/security_api_integration/saml.config.ts'), require.resolve('../test/security_api_integration/session_idle.config.ts'), require.resolve('../test/security_api_integration/session_lifespan.config.ts'), + require.resolve('../test/security_api_integration/login_selector.config.ts'), require.resolve('../test/token_api_integration/config.js'), require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), require.resolve('../test/observability_api_integration/basic/config.ts'), require.resolve('../test/observability_api_integration/trial/config.ts'), require.resolve('../test/pki_api_integration/config.ts'), - require.resolve('../test/login_selector_api_integration/config.ts'), require.resolve('../test/encrypted_saved_objects_api_integration/config.ts'), require.resolve('../test/spaces_api_integration/spaces_only/config.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'), diff --git a/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts b/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts deleted file mode 100644 index e3add3748f56d..0000000000000 --- a/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts +++ /dev/null @@ -1,11 +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 { GenericFtrProviderContext } from '@kbn/test/types/ftr'; - -import { services } from './services'; - -export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/login_selector_api_integration/services.ts b/x-pack/test/login_selector_api_integration/services.ts deleted file mode 100644 index 8bb2dae90bf59..0000000000000 --- a/x-pack/test/login_selector_api_integration/services.ts +++ /dev/null @@ -1,14 +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 { services as commonServices } from '../common/services'; -import { services as apiIntegrationServices } from '../api_integration/services'; - -export const services = { - ...commonServices, - randomness: apiIntegrationServices.randomness, - supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, -}; diff --git a/x-pack/test/saml_api_integration/apis/index.ts b/x-pack/test/saml_api_integration/apis/index.ts deleted file mode 100644 index 174e7828a11d4..0000000000000 --- a/x-pack/test/saml_api_integration/apis/index.ts +++ /dev/null @@ -1,14 +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 { FtrProviderContext } from '../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('apis SAML', function () { - this.tags('ciGroup6'); - loadTestFile(require.resolve('./security')); - }); -} diff --git a/x-pack/test/saml_api_integration/ftr_provider_context.d.ts b/x-pack/test/saml_api_integration/ftr_provider_context.d.ts deleted file mode 100644 index e3add3748f56d..0000000000000 --- a/x-pack/test/saml_api_integration/ftr_provider_context.d.ts +++ /dev/null @@ -1,11 +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 { GenericFtrProviderContext } from '@kbn/test/types/ftr'; - -import { services } from './services'; - -export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/saml_api_integration/services.ts b/x-pack/test/saml_api_integration/services.ts deleted file mode 100644 index de300af03bbe6..0000000000000 --- a/x-pack/test/saml_api_integration/services.ts +++ /dev/null @@ -1,15 +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 { services as commonServices } from '../common/services'; -import { services as apiIntegrationServices } from '../api_integration/services'; - -export const services = { - ...commonServices, - randomness: apiIntegrationServices.randomness, - legacyEs: apiIntegrationServices.legacyEs, - supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, -}; diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml b/x-pack/test/security_api_integration/fixtures/saml/idp_metadata.xml similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/idp_metadata.xml rename to x-pack/test/security_api_integration/fixtures/saml/idp_metadata.xml diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml b/x-pack/test/security_api_integration/fixtures/saml/idp_metadata_2.xml similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml rename to x-pack/test/security_api_integration/fixtures/saml/idp_metadata_2.xml diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/kibana.json rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/metadata.xml b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/metadata.xml similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/metadata.xml rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/metadata.xml diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/index.ts b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/index.ts similarity index 85% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/server/index.ts rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/index.ts index d4dda70cef694..25aa4ad61900e 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/index.ts +++ b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializer } from '../../../../../../src/core/server'; +import { PluginInitializer } from '../../../../../../../src/core/server'; import { initRoutes } from './init_routes'; export const plugin: PluginInitializer = () => ({ diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/init_routes.ts similarity index 96% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/init_routes.ts index f2c91ea7d1e03..10ec104db939b 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts +++ b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/init_routes.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from '../../../../../../src/core/server'; +import { CoreSetup } from '../../../../../../../src/core/server'; import { getSAMLResponse, getSAMLRequestId } from '../../saml_tools'; export function initRoutes(core: CoreSetup) { diff --git a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts b/x-pack/test/security_api_integration/fixtures/saml/saml_tools.ts similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/saml_tools.ts rename to x-pack/test/security_api_integration/fixtures/saml/saml_tools.ts diff --git a/x-pack/test/login_selector_api_integration/config.ts b/x-pack/test/security_api_integration/login_selector.config.ts similarity index 95% rename from x-pack/test/login_selector_api_integration/config.ts rename to x-pack/test/security_api_integration/login_selector.config.ts index fb711a8bef488..0e43715ba808e 100644 --- a/x-pack/test/login_selector_api_integration/config.ts +++ b/x-pack/test/security_api_integration/login_selector.config.ts @@ -23,14 +23,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const pkiKibanaCAPath = resolve(__dirname, '../pki_api_integration/fixtures/kibana_ca.crt'); - const saml1IdPMetadataPath = resolve( - __dirname, - '../saml_api_integration/fixtures/idp_metadata.xml' - ); - const saml2IdPMetadataPath = resolve( - __dirname, - '../saml_api_integration/fixtures/idp_metadata_2.xml' - ); + const saml1IdPMetadataPath = resolve(__dirname, './fixtures/saml/idp_metadata.xml'); + const saml2IdPMetadataPath = resolve(__dirname, './fixtures/saml/idp_metadata_2.xml'); const servers = { ...xPackAPITestsConfig.get('servers'), @@ -45,7 +39,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }; return { - testFiles: [require.resolve('./apis')], + testFiles: [require.resolve('./tests/login_selector')], servers, security: { disableTestUser: true }, services: { @@ -54,7 +48,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), }, junit: { - reportName: 'X-Pack Login Selector API Integration Tests', + reportName: 'X-Pack Security API Integration Tests (Login Selector)', }, esTestCluster: { diff --git a/x-pack/test/saml_api_integration/config.ts b/x-pack/test/security_api_integration/saml.config.ts similarity index 92% rename from x-pack/test/saml_api_integration/config.ts rename to x-pack/test/security_api_integration/saml.config.ts index 9edadca4c1667..133e52d68d87e 100644 --- a/x-pack/test/saml_api_integration/config.ts +++ b/x-pack/test/security_api_integration/saml.config.ts @@ -14,10 +14,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); - const idpPath = resolve(__dirname, '../../test/saml_api_integration/fixtures/idp_metadata.xml'); + const idpPath = resolve(__dirname, './fixtures/saml/idp_metadata.xml'); return { - testFiles: [require.resolve('./apis')], + testFiles: [require.resolve('./tests/saml')], servers: xPackAPITestsConfig.get('servers'), security: { disableTestUser: true }, services: { @@ -26,7 +26,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), }, junit: { - reportName: 'X-Pack SAML API Integration Tests', + reportName: 'X-Pack Security API Integration Tests (SAML)', }, esTestCluster: { diff --git a/x-pack/test/security_api_integration/services.ts b/x-pack/test/security_api_integration/services.ts index e2abfa71451bc..a8d8048462693 100644 --- a/x-pack/test/security_api_integration/services.ts +++ b/x-pack/test/security_api_integration/services.ts @@ -9,6 +9,5 @@ import { services as apiIntegrationServices } from '../api_integration/services' export const services = { ...commonServices, - legacyEs: apiIntegrationServices.legacyEs, supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, }; diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts similarity index 96% rename from x-pack/test/login_selector_api_integration/apis/login_selector.ts rename to x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts index 44582355cf890..2881020f521ee 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts @@ -10,13 +10,13 @@ import { resolve } from 'path'; import url from 'url'; import { CA_CERT_PATH } from '@kbn/dev-utils'; import expect from '@kbn/expect'; -import { getStateAndNonce } from '../../oidc_api_integration/fixtures/oidc_tools'; +import { getStateAndNonce } from '../../../oidc_api_integration/fixtures/oidc_tools'; import { getMutualAuthenticationResponseToken, getSPNEGOToken, -} from '../../kerberos_api_integration/fixtures/kerberos_tools'; -import { getSAMLRequestId, getSAMLResponse } from '../../saml_api_integration/fixtures/saml_tools'; -import { FtrProviderContext } from '../ftr_provider_context'; +} from '../../../kerberos_api_integration/fixtures/kerberos_tools'; +import { getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml/saml_tools'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const randomness = getService('randomness'); @@ -29,7 +29,7 @@ export default function ({ getService }: FtrProviderContext) { const CA_CERT = readFileSync(CA_CERT_PATH); const CLIENT_CERT = readFileSync( - resolve(__dirname, '../../pki_api_integration/fixtures/first_client.p12') + resolve(__dirname, '../../../pki_api_integration/fixtures/first_client.p12') ); async function checkSessionCookie( @@ -97,11 +97,23 @@ export default function ({ getService }: FtrProviderContext) { // to fully authenticate user yet. const intermediateAuthCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + // When login page is accessed directly. await supertest .get('/login') .ca(CA_CERT) .set('Cookie', intermediateAuthCookie.cookieString()) .expect(200); + + // When user tries to access any other page in Kibana. + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .set('Cookie', intermediateAuthCookie.cookieString()) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); }); describe('SAML', () => { diff --git a/x-pack/test/login_selector_api_integration/apis/index.ts b/x-pack/test/security_api_integration/tests/login_selector/index.ts similarity index 65% rename from x-pack/test/login_selector_api_integration/apis/index.ts rename to x-pack/test/security_api_integration/tests/login_selector/index.ts index a4d92ebc2e109..408bfe0b52c4b 100644 --- a/x-pack/test/login_selector_api_integration/apis/index.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/index.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('apis', function () { + describe('security APIs - Login Selector', function () { this.tags('ciGroup6'); - loadTestFile(require.resolve('./login_selector')); + loadTestFile(require.resolve('./basic_functionality')); }); } diff --git a/x-pack/test/saml_api_integration/apis/security/index.ts b/x-pack/test/security_api_integration/tests/saml/index.ts similarity index 84% rename from x-pack/test/saml_api_integration/apis/security/index.ts rename to x-pack/test/security_api_integration/tests/saml/index.ts index aac9a82ec5680..882c8774e54e6 100644 --- a/x-pack/test/saml_api_integration/apis/security/index.ts +++ b/x-pack/test/security_api_integration/tests/saml/index.ts @@ -7,7 +7,9 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('security', () => { + describe('security APIs - SAML', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./saml_login')); }); } diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/security_api_integration/tests/saml/saml_login.ts similarity index 99% rename from x-pack/test/saml_api_integration/apis/security/saml_login.ts rename to x-pack/test/security_api_integration/tests/saml/saml_login.ts index 2da7c92cd07b6..8770d87c0cf8c 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/security_api_integration/tests/saml/saml_login.ts @@ -9,7 +9,11 @@ import url from 'url'; import { delay } from 'bluebird'; import expect from '@kbn/expect'; import request, { Cookie } from 'request'; -import { getLogoutRequest, getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml_tools'; +import { + getLogoutRequest, + getSAMLRequestId, + getSAMLResponse, +} from '../../fixtures/saml/saml_tools'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index 01e2ad76fb3d2..703180442f8f5 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -30,7 +30,9 @@ export default function ({ getService }: FtrProviderContext) { } async function getNumberOfSessionDocuments() { - return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; + return (((await es.search({ index: '.kibana_security_session*' })).hits.total as unknown) as { + value: number; + }).value; } describe('Session Idle cleanup', () => { diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index 6036acf3d1cf1..8b136e540f13f 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -27,7 +27,9 @@ export default function ({ getService }: FtrProviderContext) { } async function getNumberOfSessionDocuments() { - return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; + return (((await es.search({ index: '.kibana_security_session*' })).hits.total as unknown) as { + value: number; + }).value; } describe('Session Lifespan cleanup', () => { diff --git a/x-pack/test/security_functional/login_selector.config.ts b/x-pack/test/security_functional/login_selector.config.ts index bdb4778740503..9fc4c54ba1344 100644 --- a/x-pack/test/security_functional/login_selector.config.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -20,8 +20,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ); const kibanaPort = kibanaFunctionalConfig.get('servers.kibana.port'); - const idpPath = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider/metadata.xml'); - const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); + const idpPath = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider/metadata.xml' + ); + const samlIdPPlugin = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider' + ); return { testFiles: [resolve(__dirname, './tests/login_selector')], diff --git a/x-pack/test/security_functional/saml.config.ts b/x-pack/test/security_functional/saml.config.ts index 9d925bee480a8..1e032bdcc6ac7 100644 --- a/x-pack/test/security_functional/saml.config.ts +++ b/x-pack/test/security_functional/saml.config.ts @@ -20,8 +20,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ); const kibanaPort = kibanaFunctionalConfig.get('servers.kibana.port'); - const idpPath = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider/metadata.xml'); - const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); + const idpPath = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider/metadata.xml' + ); + const samlIdPPlugin = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider' + ); return { testFiles: [resolve(__dirname, './tests/saml')], From 9011f42d7ffa8b1ad690d2ef4aa03216bc4401b5 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 5 Oct 2020 14:30:49 -0400 Subject: [PATCH 07/14] Skip failing suite (#79522) --- .../cypress/integration/alerts_detection_rules_eql.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts index a7ddba94bffd2..13e5edd1cfe23 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts @@ -87,7 +87,8 @@ const expectedNumberOfRules = 1; const expectedNumberOfAlerts = 7; const expectedNumberOfSequenceAlerts = 1; -describe('Detection rules, EQL', () => { +// Failing: See https://github.com/elastic/kibana/issues/79522 +describe.skip('Detection rules, EQL', () => { before(() => { esArchiverLoad('timeline'); }); From a074ef5e078650be607ddc48474586f2e080de47 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 5 Oct 2020 12:31:19 -0600 Subject: [PATCH 08/14] [Maps] enable auto fit to bounds by default (#79296) * [Maps] enable auto fit to bounds by default * type fixes and functional test fixes * final tslint fixes * update jest expect Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../set_default_auto_fit_to_bounds.test.tsx | 40 +++++++++++++++++++ .../set_default_auto_fit_to_bounds.ts | 34 ++++++++++++++++ .../navigation_panel.test.tsx.snap | 6 +-- .../public/reducers/default_map_settings.ts | 2 +- .../maps/server/saved_objects/migrations.js | 9 +++++ .../api_integration/apis/maps/migrations.js | 2 +- 6 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx create mode 100644 x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts diff --git a/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx new file mode 100644 index 0000000000000..cbdd253b6e2dd --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setDefaultAutoFitToBounds } from './set_default_auto_fit_to_bounds'; + +describe('setDefaultAutoFitToBounds', () => { + test('Should handle missing mapStateJSON attribute', () => { + const attributes = { + title: 'my map', + }; + expect(setDefaultAutoFitToBounds({ attributes })).toEqual({ + title: 'my map', + }); + }); + + test('Should set default auto fit to bounds when map settings exist in map state', () => { + const attributes = { + title: 'my map', + mapStateJSON: JSON.stringify({ + settings: { showSpatialFilters: false }, + }), + }; + expect(JSON.parse(setDefaultAutoFitToBounds({ attributes }).mapStateJSON!)).toEqual({ + settings: { autoFitToDataBounds: false, showSpatialFilters: false }, + }); + }); + + test('Should set default auto fit to bounds when map settings does not exist in map state', () => { + const attributes = { + title: 'my map', + mapStateJSON: JSON.stringify({}), + }; + expect(JSON.parse(setDefaultAutoFitToBounds({ attributes }).mapStateJSON!)).toEqual({ + settings: { autoFitToDataBounds: false }, + }); + }); +}); diff --git a/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts new file mode 100644 index 0000000000000..09e23b5213d6c --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts @@ -0,0 +1,34 @@ +/* + * 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 { MapSavedObjectAttributes } from '../map_saved_object_type'; + +export function setDefaultAutoFitToBounds({ + attributes, +}: { + attributes: MapSavedObjectAttributes; +}): MapSavedObjectAttributes { + if (!attributes || !attributes.mapStateJSON) { + return attributes; + } + + // MapState type is defined in public, no need to bring all of that to common for this migration + const mapState: { settings?: { autoFitToDataBounds: boolean } } = JSON.parse( + attributes.mapStateJSON + ); + if ('settings' in mapState) { + mapState.settings!.autoFitToDataBounds = false; + } else { + mapState.settings = { + autoFitToDataBounds: false, + }; + } + + return { + ...attributes, + mapStateJSON: JSON.stringify(mapState), + }; +} diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap index 1859c7d8177f8..a617fbc552854 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap @@ -25,7 +25,7 @@ exports[`should render 1`] = ` labelType="label" > { const attributes = removeBoundsFromSavedObject(doc); + return { + ...doc, + attributes, + }; + }, + '7.10.0': (doc) => { + const attributes = setDefaultAutoFitToBounds(doc); + return { ...doc, attributes, diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index a9ecaac09db9a..b634e7117e607 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -41,7 +41,7 @@ export default function ({ getService }) { type: 'index-pattern', }, ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.9.0' }); + expect(resp.body.migrationVersion).to.eql({ map: '7.10.0' }); expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); }); }); From 106ab7e1d95246b0f385d7f91c358c3b32f318dd Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Mon, 5 Oct 2020 14:33:37 -0400 Subject: [PATCH 09/14] [SECURITY_SOLUTION] rely on table in test for endpoint list kql bar (#79402) --- .../apps/endpoint/endpoint_list.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index dd28752bf29b4..5b5949821580f 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -64,8 +64,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ], ]; - // Failing: See https://github.com/elastic/kibana/issues/77278 - describe.skip('endpoint list', function () { + describe('endpoint list', function () { this.tags('ciGroup7'); const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -219,8 +218,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await deleteAllDocsFromMetadataCurrentIndex(getService); }); it('for the kql query: na, table shows an empty list', async () => { - await testSubjects.setValue('adminSearchBar', 'na'); - await (await testSubjects.find('querySubmitButton')).click(); + const adminSearchBar = await testSubjects.find('adminSearchBar'); + await adminSearchBar.clearValueWithKeyboard(); + await adminSearchBar.type('na'); + const querySubmitButton = await testSubjects.find('querySubmitButton'); + await querySubmitButton.click(); const expectedDataFromQuery = [ [ 'Hostname', @@ -240,18 +242,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); expect(tableData).to.eql(expectedDataFromQuery); }); - it('for the kql query: HostDetails.Endpoint.policy.applied.id : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A", table shows 2 items', async () => { - await testSubjects.setValue('adminSearchBar', ' '); - await (await testSubjects.find('querySubmitButton')).click(); - - const endpointListTableTotal = await testSubjects.getVisibleText('endpointListTableTotal'); - - await testSubjects.setValue( - 'adminSearchBar', + const adminSearchBar = await testSubjects.find('adminSearchBar'); + await adminSearchBar.clearValueWithKeyboard(); + await adminSearchBar.type( 'HostDetails.Endpoint.policy.applied.id : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" ' ); - await (await testSubjects.find('querySubmitButton')).click(); + const querySubmitButton = await testSubjects.find('querySubmitButton'); + await querySubmitButton.click(); const expectedDataFromQuery = [ [ 'Hostname', @@ -287,11 +285,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '', ], ]; - - await pageObjects.endpoint.waitForVisibleTextToChange( - 'endpointListTableTotal', - endpointListTableTotal - ); + await pageObjects.endpoint.waitForTableToHaveData('endpointListTable'); const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); expect(tableData).to.eql(expectedDataFromQuery); }); From 197510a73718c96e1e838ccd2613f771377ee235 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 5 Oct 2020 13:33:58 -0500 Subject: [PATCH 10/14] [Enterprise Search][Workplace Search] Migrate Groups to Kibana (#78679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial copying of Groups component tree This commit moves the base component tree from ent-search with the following changes to satisfy pre-commit hooks - All file names changed to camel_case - Copyright comment added to the top of each file - Semicolons and formatting to match Kibana - Default exports removed from components - Placeholder keyboard listener functions added to non-interactive elements with click handlers * Update paths, remove kea typecasting This commit does the following: - Updates the paths of the imports - Removed the need to typecast logic i.e. "as IGroupsLogic" after the Kea hooks - Fixed a few TypeScript errors with events and return values on functions - Finally, this commit does away with the ConfirmModal component (was never copied over), as it only wraps 2 EUI components and we decided it was not needed * Add constants and image * Update types - Adds new types - Moves types needed by server and app to common * Refactor ContentSection and ViewContentHeader With the groups components, we needed to add a header action that was right-aligned to ContentSection. After building what was needed, I realized that the header in the ContentSection was basically a ViewContentHeader with a different sized heading. I refactored ViewContentHeader to have variable sizes and for ContentSection to use that instead. Also added styles from ent-search * Add group routes This is both the server routes and the frontend paths. After conversation with Jonas, we decided to not use the `/org` prefix. Will clean up the other routes as we build out the rest the migration * Update logic files This commit converts the logic files for use in Kibana. This includes: - Using the new Kea 2.2 syntax that uses MakeLogicType to provide types - Uses the Kibana HttpLogic instead of the rails routes/http - Adds return types to Actions interfaces - Adds `path` key to help with debuggin in dev tools - Removes FashMessages in lieu of separate logic. Because of this, we have to manually clear the flash mesages with listeners where reducers used to do it when the messages were local to the logic file - Preplaces promises with async/await & try/catch - Also, in GroupsLogic’s getSearchResults method, we used Kea’s breakpoint functionality and replaced the useDebouce Hook that was used in the component (future commit) - Uses global lodash per Kibana’s new directive * Update routers an indexes This PR configures the routers to work with Kibana - Updates paths to imports - Adds top-level styles For GroupRouter - Removes AppView - Use global flash messages - Remove sidebar and breadcrumbs * Update GroupOverview Adds some changes to facilitate the new design for Kibana - Copy changes - Layout changes to have buttons inline and not conditionally shown * Various updates to components Adds some changes to facilitate the new design for Kibana. - Remove unnecessary TableHeader - Adds pencil in lieu of manage button per design * Update main groups component A previous commit did this for the components, as the intention was to do this for components in one PR and the others an a separate PR. Unfortunately the build does not pass with all the missing imports. This commit does the following: - Updates the paths of the imports - Removed the need to typecast logic i.e. "as IGroupsLogic" after the Kea hooks - Fixed a few TypeScript errors with events and return values on functions - Use global flash messages - Remove debounces filderValue, as it’s now debounces in logic file - Remove legacy isCurated props - Remove legact AppView * Add sub navigation to main nav Also removes redundant search link in sidebar * Update logic file to reset flashmessages correctly Because we have separated concerns with global flash message state, we now have to manually trigger resets of flash messages with listeners in Kea where we used to be able to use a reducer to listen to changes and reset flash messages. * Use navigateToUrl for navigation over history.push Thanks to work by @constance, we can now use the KibanaLogic’s navigateToUrl value to change routes directly from logic files * Fix failing test A previous commit removed the redundant Search link from the sidebar nav because of the one in the header. This commit fixes a filing test and now assets on the number of items as the link addresses will be changing as we migrate more components over * Convert React Router links to our wrappers * Replace anchors with EuiButtonEmpty The original pre-commit hooks failed because the anchors didn’t have key handlers. Pleaceholders were added with TODOs and these have been replaced with EuiButtonEmpty, which satisifies the UI needs and passes linting * Fix a bug where header actions disappearing There was a bug where changing routes would cause the header action, in the case of Workplace Search the “Go to search application” link, to disappear on route changes. Turns out that we didn’t need it in the useEffect and that moving it out keeps the unmount from removing the link from the header. * i18n top-level component and logic files * i18n for components * Fix failing test This was from https://github.com/elastic/kibana/pull/78679/commits/3254c6277e44a2f1de4ea84155a4f287d7fbd270 * Fix broken i18n Had duplicate ids and misnamed value * Fix a bug where manage group not in Flash message There is a button that appears in the Flash Message when a new group is added so that the user can navigate to manage the group and, because the order of setting the new group was before the instantiation of the global flashmessages, the button was not appearing. This commit moves the action after the flash message is set. * Refactor typings * Refactor ContentSection spacing The css was not being used and the bottom padding of 44px (xxl + xs spacer) was being generated with spacers. This has been changed to use only CSS * Remove canCreateInvitations * Remove hasMessages check rendering FlashMessages * snake_case telemetry and path Will convert other paths in a separate PR * Fix failing tests Co-authored-by: Elastic Machine Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/types/workplace_search.ts | 46 +++ .../shared/constants/default_meta.ts | 14 + .../applications/shared/constants/index.ts | 7 + .../workplace_search/assets/share_circle.svg | 1 + .../components/layout/nav.test.tsx | 2 +- .../components/layout/nav.tsx | 9 +- .../content_section/content_section.scss | 9 + .../content_section/content_section.test.tsx | 13 +- .../content_section/content_section.tsx | 15 +- .../shared/source_row/source_row.scss | 19 + .../shared/source_row/source_row.tsx | 24 +- .../view_content_header.test.tsx | 18 +- .../view_content_header.tsx | 52 ++- .../workplace_search/index.test.tsx | 1 - .../applications/workplace_search/index.tsx | 11 +- .../applications/workplace_search/routes.ts | 5 +- .../applications/workplace_search/types.ts | 40 +- .../groups/components/add_group_modal.tsx | 89 ++++ .../groups/components/clear_filters_link.tsx | 40 ++ .../components/filterable_users_list.tsx | 134 ++++++ .../components/filterable_users_popover.tsx | 62 +++ .../groups/components/group_manager_modal.tsx | 180 ++++++++ .../groups/components/group_overview.tsx | 274 +++++++++++++ .../views/groups/components/group_row.tsx | 103 +++++ .../components/group_row_sources_dropdown.tsx | 66 +++ .../components/group_row_users_dropdown.tsx | 55 +++ .../group_source_prioritization.tsx | 192 +++++++++ .../views/groups/components/group_sources.tsx | 43 ++ .../views/groups/components/group_sub_nav.tsx | 38 ++ .../views/groups/components/group_users.tsx | 45 ++ .../groups/components/group_users_table.tsx | 85 ++++ .../views/groups/components/groups_table.tsx | 96 +++++ .../groups/components/manage_users_modal.tsx | 56 +++ .../components/shared_sources_modal.tsx | 64 +++ .../groups/components/source_option_item.tsx | 31 ++ .../views/groups/components/sources_list.tsx | 41 ++ .../table_filter_sources_dropdown.tsx | 67 +++ .../table_filter_users_dropdown.tsx | 52 +++ .../views/groups/components/table_filters.tsx | 59 +++ .../groups/components/user_option_item.tsx | 25 ++ .../views/groups/group_logic.ts | 388 ++++++++++++++++++ .../views/groups/group_router.tsx | 47 +++ .../workplace_search/views/groups/groups.scss | 101 +++++ .../workplace_search/views/groups/groups.tsx | 146 +++++++ .../views/groups/groups_logic.ts | 351 ++++++++++++++++ .../views/groups/groups_router.tsx | 35 ++ .../workplace_search/views/groups/index.ts | 8 + .../enterprise_search/server/plugin.ts | 2 + .../server/routes/workplace_search/groups.ts | 256 ++++++++++++ 49 files changed, 3424 insertions(+), 93 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/share_circle.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts diff --git a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts index 6c82206706b32..886597fcd9891 100644 --- a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts +++ b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts @@ -30,3 +30,49 @@ export interface IConfiguredLimits { totalFields: number; }; } + +export interface IGroup { + id: string; + name: string; + createdAt: string; + updatedAt: string; + contentSources: IContentSource[]; + users: IUser[]; + usersCount: number; + color?: string; +} + +export interface IGroupDetails extends IGroup { + contentSources: IContentSourceDetails[]; + canEditGroup: boolean; + canDeleteGroup: boolean; +} + +export interface IUser { + id: string; + name: string | null; + initials: string; + pictureUrl: string | null; + color: string; + email: string; + role?: string; + groupIds: string[]; +} + +export interface IContentSource { + id: string; + serviceType: string; + name: string; +} + +export interface IContentSourceDetails extends IContentSource { + status: string; + statusMessage: string; + documentCount: string; + isFederatedSource: boolean; + searchable: boolean; + supportedByLicense: boolean; + errorReason: number; + allowsReauth: boolean; + boost: number; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts new file mode 100644 index 0000000000000..82f1c9d8b8914 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts @@ -0,0 +1,14 @@ +/* + * 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 DEFAULT_META = { + page: { + current: 1, + size: 10, + total_pages: 0, + total_results: 0, + }, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts new file mode 100644 index 0000000000000..4d4ff5f52ef20 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { DEFAULT_META } from './default_meta'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/share_circle.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/share_circle.svg new file mode 100644 index 0000000000000..730f4fb90f601 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/share_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 2553284744e4d..ccc0fe8b38ff3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -18,6 +18,6 @@ describe('WorkplaceSearchNav', () => { expect(wrapper.find(SideNav)).toHaveLength(1); expect(wrapper.find(SideNavLink).first().prop('to')).toEqual('/'); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('http://localhost:3002/ws/search'); + expect(wrapper.find(SideNavLink)).toHaveLength(7); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 5572716391112..7070659a951ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -12,6 +12,8 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SideNav, SideNavLink } from '../../../shared/layout'; +import { GroupSubNav } from '../../views/groups/components/group_sub_nav'; + import { ORG_SOURCES_PATH, SOURCES_PATH, @@ -35,7 +37,7 @@ export const WorkplaceSearchNav: React.FC = () => { defaultMessage: 'Sources', })} - + }> {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.groups', { defaultMessage: 'Groups', })} @@ -61,11 +63,6 @@ export const WorkplaceSearchNav: React.FC = () => { defaultMessage: 'View my personal dashboard', })} - - {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.search', { - defaultMessage: 'Go to search application', - })} - ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.scss new file mode 100644 index 0000000000000..c79e31370ebcf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.scss @@ -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. + */ + +.content-section { + padding-bottom: 44px; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx index cc827d7edb0af..559693d4c7891 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx @@ -6,9 +6,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { ContentSection } from './'; +import { ViewContentHeader } from '../view_content_header'; const props = { children:
, @@ -20,15 +21,16 @@ describe('ContentSection', () => { const wrapper = shallow(); expect(wrapper.prop('data-test-subj')).toEqual('contentSection'); - expect(wrapper.prop('className')).toEqual('test'); + expect(wrapper.prop('className')).toEqual('test content-section'); expect(wrapper.find('.children')).toHaveLength(1); }); it('displays title and description', () => { const wrapper = shallow(); - expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('p').text()).toEqual('bar'); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual('foo'); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual('bar'); }); it('displays header content', () => { @@ -41,7 +43,8 @@ describe('ContentSection', () => { /> ); - expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s'); + expect(wrapper.find(EuiSpacer).first().prop('size')).toEqual('s'); + expect(wrapper.find(EuiSpacer)).toHaveLength(1); expect(wrapper.find('.header')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx index b2a9eebc72e85..8111324632513 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -6,15 +6,20 @@ import React from 'react'; -import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { TSpacerSize } from '../../../types'; +import { ViewContentHeader } from '../view_content_header'; + +import './content_section.scss'; + interface IContentSectionProps { children: React.ReactNode; className?: string; title?: React.ReactNode; description?: React.ReactNode; + action?: React.ReactNode; headerChildren?: React.ReactNode; headerSpacer?: TSpacerSize; testSubj?: string; @@ -25,17 +30,15 @@ export const ContentSection: React.FC = ({ className = '', title, description, + action, headerChildren, headerSpacer, testSubj, }) => ( -
+
{title && ( <> - -

{title}

-
- {description &&

{description}

} + {headerChildren} {headerSpacer && } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss new file mode 100644 index 0000000000000..a099b974a0d41 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss @@ -0,0 +1,19 @@ +.source-row { + &__icon { + width: 24px; + height: 24px; + } + + &__name { + font-weight: 500; + } + + &__actions { + width: 100px; + } + + &__actions a { + opacity: 1.0; + pointer-events: auto; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx index a2e252c886354..ca01563d81eda 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx @@ -10,7 +10,6 @@ import classNames from 'classnames'; // Prefer importing entire lodash library, e.g. import { get } from "lodash" // eslint-disable-next-line no-restricted-imports import _kebabCase from 'lodash/kebabCase'; -import { Link } from 'react-router-dom'; import { EuiFlexGroup, @@ -24,12 +23,15 @@ import { EuiToolTip, } from '@elastic/eui'; +import { EuiLink } from '../../../../shared/react_router_helpers'; import { SOURCE_STATUSES as statuses } from '../../../constants'; import { IContentSourceDetails } from '../../../types'; import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, getContentSourcePath } from '../../../routes'; import { SourceIcon } from '../source_icon'; +import './source_row.scss'; + const CREDENTIALS_INVALID_ERROR_REASON = 1; export interface ISourceRow { @@ -75,14 +77,9 @@ export const SourceRow: React.FC = ({ const imageClass = classNames('source-row__icon', { 'source-row__icon--loading': isIndexing }); const fixLink = ( - + Fix - + ); const remoteTooltip = ( @@ -100,7 +97,12 @@ export const SourceRow: React.FC = ({ return ( - + = ({ {showFix && {fixLink}} {showDetails && ( - Details - + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx index 1bb9ff255f7ed..7d81e9df67289 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx @@ -19,7 +19,7 @@ describe('ViewContentHeader', () => { it('renders with title and alignItems', () => { const wrapper = shallow(); - expect(wrapper.find('h2').text()).toEqual('Header'); + expect(wrapper.find('h3').text()).toEqual('Header'); expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart'); }); @@ -35,4 +35,20 @@ describe('ViewContentHeader', () => { expect(wrapper.find('.action')).toHaveLength(1); }); + + it('renders small heading', () => { + const wrapper = shallow( + } /> + ); + + expect(wrapper.find('h4')).toHaveLength(1); + }); + + it('renders large heading', () => { + const wrapper = shallow( + } /> + ); + + expect(wrapper.find('h2')).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx index 0408517fd4ec5..0e2d781020294 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx @@ -15,28 +15,44 @@ interface IViewContentHeaderProps { description?: React.ReactNode; action?: React.ReactNode; alignItems?: FlexGroupAlignItems; + titleSize?: 's' | 'm' | 'l'; } export const ViewContentHeader: React.FC = ({ title, + titleSize = 'm', description, action, alignItems = 'center', -}) => ( - <> - - - -

{title}

-
- {description && ( - -

{description}

-
- )} -
- {action && {action}} -
- - -); +}) => { + let titleElement; + + switch (titleSize) { + case 's': + titleElement =

{title}

; + break; + case 'l': + titleElement =

{title}

; + break; + default: + titleElement =

{title}

; + break; + } + + return ( + <> + + + {titleElement} + {description && ( + +

{description}

+
+ )} +
+ {action && {action}} +
+ + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 25544b4a9bb68..6aa4cf59ab46c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -76,7 +76,6 @@ describe('WorkplaceSearchConfigured', () => { shallow(); expect(initializeAppData).not.toHaveBeenCalled(); - expect(mockKibanaValues.renderHeaderActions).not.toHaveBeenCalled(); }); it('renders ErrorState', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index b4c4217659043..a3c7f7d48a612 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -22,6 +22,7 @@ import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; +import { GroupsRouter } from './views/groups'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -37,10 +38,11 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { useEffect(() => { if (!hasInitialized) { initializeAppData(props); - renderHeaderActions(WorkplaceSearchHeaderActions); } }, [hasInitialized]); + renderHeaderActions(WorkplaceSearchHeaderActions); + return ( @@ -50,14 +52,13 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { {errorConnecting ? : } - } readOnlyMode={readOnlyMode}> + } restrictWidth readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( - - {/* Will replace with groups component subsequent PR */} -
+ + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index e833dde4c1b72..dfe664c33198c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -50,7 +50,7 @@ export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const USERS_PATH = `${ORG_PATH}/users`; export const SECURITY_PATH = `${ORG_PATH}/security`; -export const GROUPS_PATH = `${ORG_PATH}/groups`; +export const GROUPS_PATH = '/groups'; export const GROUP_PATH = `${GROUPS_PATH}/:groupId`; export const GROUP_SOURCE_PRIORITIZATION_PATH = `${GROUPS_PATH}/:groupId/source-prioritization`; @@ -114,3 +114,6 @@ export const getContentSourcePath = ( sourceId: string, isOrganization: boolean ): string => generatePath(isOrganization ? ORG_PATH + path : path, { sourceId }); +export const getGroupPath = (groupId: string) => generatePath(GROUP_PATH, { groupId }); +export const getGroupSourcePrioritizationPath = (groupId: string) => + `${GROUPS_PATH}/${groupId}/source_prioritization`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 3866da738cbb6..e398a868b2466 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -8,42 +8,6 @@ export * from '../../../common/types/workplace_search'; export type TSpacerSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl'; -export interface IGroup { - id: string; - name: string; - createdAt: string; - updatedAt: string; - contentSources: IContentSource[]; - users: IUser[]; - usersCount: number; - color?: string; -} - -export interface IUser { - id: string; - name: string | null; - initials: string; - pictureUrl: string | null; - color: string; - email: string; - role?: string; - groupIds: string[]; -} - -export interface IContentSource { - id: string; - serviceType: string; - name: string; -} - -export interface IContentSourceDetails extends IContentSource { - status: string; - statusMessage: string; - documentCount: string; - isFederatedSource: boolean; - searchable: boolean; - supportedByLicense: boolean; - errorReason: number; - allowsReauth: boolean; - boost: number; +export interface ISourcePriority { + [id: string]: number; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx new file mode 100644 index 0000000000000..766aa511ebb2d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, +} from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; + +const ADD_GROUP_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.heading', + { + defaultMessage: 'Add a group', + } +); +const ADD_GROUP_CANCEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.cancel.action', + { + defaultMessage: 'Cancel', + } +); +const ADD_GROUP_SUBMIT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.submit.action', + { + defaultMessage: 'Add Group', + } +); + +export const AddGroupModal: React.FC<{}> = () => { + const { closeNewGroupModal, saveNewGroup, setNewGroupName } = useActions(GroupsLogic); + const { newGroupNameErrors, newGroupName } = useValues(GroupsLogic); + const isInvalid = newGroupNameErrors.length > 0; + const handleFormSumbit = (e: React.FormEvent) => { + e.preventDefault(); + saveNewGroup(); + }; + + return ( + + +
+ + {ADD_GROUP_HEADER} + + + + + setNewGroupName(e.target.value)} + /> + + + + + {ADD_GROUP_CANCEL} + + {ADD_GROUP_SUBMIT} + + +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.tsx new file mode 100644 index 0000000000000..164c938fb5788 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; + +const CLEAR_FILTERS = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.clearFilters.action', + { + defaultMessage: 'Clear Filters', + } +); + +export const ClearFiltersLink: React.FC<{}> = () => { + const { resetGroupsFilters } = useActions(GroupsLogic); + + return ( + + + + + + + + + {CLEAR_FILTERS} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.tsx new file mode 100644 index 0000000000000..a7b5d3e83bee2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.tsx @@ -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 React, { useState } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiCard, + EuiFieldSearch, + EuiFilterSelectItem, + EuiIcon, + EuiPopoverTitle, + EuiSpacer, +} from '@elastic/eui'; + +import { IUser } from '../../../types'; + +import { UserOptionItem } from './user_option_item'; + +const MAX_VISIBLE_USERS = 20; + +const FILTER_USERS_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.placeholder', + { + defaultMessage: 'Filter users...', + } +); +const NO_USERS_FOUND = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.noUsersFound', + { + defaultMessage: 'No users found', + } +); + +interface IFilterableUsersListProps { + users: IUser[]; + selectedOptions?: string[]; + itemsClickable?: boolean; + isPopover?: boolean; + allGroupUsersLoading?: React.ReactElement; + addFilteredUser(userId: string): void; + removeFilteredUser(userId: string): void; +} + +export const FilterableUsersList: React.FC = ({ + users, + selectedOptions = [], + itemsClickable, + isPopover, + addFilteredUser, + allGroupUsersLoading, + removeFilteredUser, +}) => { + const [filterValue, updateValue] = useState(''); + + const filterUsers = (userId: string): boolean => { + if (!filterValue) return true; + const filterUser = users.find(({ id }) => id === userId) as IUser; + const filteredName = filterUser.name || filterUser.email; + return filteredName.toLowerCase().indexOf(filterValue.toLowerCase()) > -1; + }; + + // Only show the first 20 users in the dropdown. + const availableUsers = users.map(({ id }) => id).filter(filterUsers); + const hiddenUsers = [...availableUsers]; + const visibleUsers = hiddenUsers.splice(0, MAX_VISIBLE_USERS); + + const getOptionEl = (userId: string, index: number): React.ReactElement => { + const checked = selectedOptions.indexOf(userId) > -1 ? 'on' : undefined; + const handleClick = () => (checked ? removeFilteredUser(userId) : addFilteredUser(userId)); + const user = users.filter(({ id }) => id === userId)[0]; + const option = ; + + return itemsClickable ? ( + + {option} + + ) : ( +
+ {option} +
+ ); + }; + + const filterUsersBar = ( + updateValue(e.target.value)} + /> + ); + const noResults = ( + <> + {NO_USERS_FOUND} + + ); + + const options = + visibleUsers.length > 0 ? ( + visibleUsers.map((userId, index) => getOptionEl(userId, index)) + ) : ( + } + description={!!allGroupUsersLoading ? allGroupUsersLoading : noResults} + /> + ); + + const usersList = ( + <> + {hiddenUsers.length > 0 && ( +
+ + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.userListCount', { + defaultMessage: 'Showing {maxVisibleUsers} of {numUsers} users.', + values: { maxVisibleUsers: MAX_VISIBLE_USERS, numUsers: availableUsers.length }, + })} + +
+ )} + {options} + + ); + + return ( + <> + {isPopover ? {filterUsersBar} : filterUsersBar} + {isPopover ?
{usersList}
: usersList} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx new file mode 100644 index 0000000000000..e5fdcc3089059 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiFilterGroup, EuiPopover } from '@elastic/eui'; + +import { IUser } from '../../../types'; + +import { GroupsLogic } from '../groups_logic'; +import { FilterableUsersList } from './filterable_users_list'; + +interface IIFilterableUsersPopoverProps { + users: IUser[]; + selectedOptions?: string[]; + itemsClickable?: boolean; + isPopoverOpen: boolean; + allGroupUsersLoading?: React.ReactElement; + className?: string; + button: React.ReactElement; + closePopover(): void; +} + +export const FilterableUsersPopover: React.FC = ({ + users, + selectedOptions = [], + itemsClickable, + isPopoverOpen, + allGroupUsersLoading, + className, + button, + closePopover, +}) => { + const { addFilteredUser, removeFilteredUser } = useActions(GroupsLogic); + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx new file mode 100644 index 0000000000000..db576808b66e3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiButtonEmpty, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, +} from '@elastic/eui'; + +import { EuiButton as EuiLinkButton } from '../../../../shared/react_router_helpers'; + +import { IGroup } from '../../../types'; +import { ORG_SOURCES_PATH } from '../../../routes'; + +import noSharedSourcesIcon from '../../../assets/share_circle.svg'; + +import { GroupLogic } from '../group_logic'; +import { GroupsLogic } from '../groups_logic'; + +const CANCEL_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerCancel', + { + defaultMessage: 'Cancel', + } +); +const UPDATE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerUpdate', + { + defaultMessage: 'Update', + } +); +const ADD_SOURCE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerUpdateAddSourceButton', + { + defaultMessage: 'Add a Shared Source', + } +); +const EMPTY_STATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSourceEmpty.title', + { + defaultMessage: 'Whoops!', + } +); +const EMPTY_STATE_BODY = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSourceEmpty.body', + { + defaultMessage: 'Looks like you have not added any shared content sources yet.', + } +); + +interface IGroupManagerModalProps { + children: React.ReactElement; + label: string; + allItems: object[]; + numSelected: number; + hideModal(group: IGroup): void; + selectAll(allItems: object[]): void; + saveItems(): void; +} + +export const GroupManagerModal: React.FC = ({ + children, + label, + allItems, + numSelected, + hideModal, + selectAll, + saveItems, +}) => { + const { group, managerModalFormErrors } = useValues(GroupLogic); + const { contentSources } = useValues(GroupsLogic); + + const allSelected = numSelected === allItems.length; + const isSources = label === 'shared content sources'; + const showEmptyState = isSources && contentSources.length < 1; + const handleClose = () => hideModal(group); + const handleSelectAll = () => selectAll(allSelected ? [] : allItems); + + const sourcesButton = ( + + {ADD_SOURCE_BUTTON_TEXT} + + ); + + const emptyState = ( + + + {EMPTY_STATE_TITLE}} + body={EMPTY_STATE_BODY} + actions={sourcesButton} + /> + + ); + + const modalContent = ( + <> + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.groupManagerHeaderTitle', { + defaultMessage: 'Manage {label}', + values: { label }, + })} + + + + + + 0} + fullWidth + > + {children} + + + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSelectAllToggle', + { + defaultMessage: '{action} All', + values: { action: allSelected ? 'Deselect' : 'Select' }, + } + )} + + + + + + {CANCEL_BUTTON_TEXT} + + + + {UPDATE_BUTTON_TEXT} + + + + + + + + ); + + return ( + + + {showEmptyState ? emptyState : modalContent} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx new file mode 100644 index 0000000000000..fc9ee7a1a26dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -0,0 +1,274 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiConfirmModal, + EuiOverlayMask, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiHorizontalRule, +} from '@elastic/eui'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../../shared/telemetry'; + +import { AppLogic } from '../../../app_logic'; +import { TruncatedContent } from '../../../../shared/truncate'; +import { ContentSection } from '../../../components/shared/content_section'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { Loading } from '../../../components/shared/loading'; +import { SourcesTable } from '../../../components/shared/sources_table'; + +import { GroupUsersTable } from './group_users_table'; + +import { GroupLogic, MAX_NAME_LENGTH } from '../group_logic'; + +const EMPTY_SOURCES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.emptySourcesDescription', + { + defaultMessage: 'No content sources are shared with this group.', + } +); +const GROUP_USERS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.groupUsersDescription', + { + defaultMessage: 'Members will be able to search over the group’s sources.', + } +); +const EMPTY_USERS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.emptyUsersDescription', + { + defaultMessage: 'There are no users in this group.', + } +); +const MANAGE_SOURCES_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.manageSourcesButtonText', + { + defaultMessage: 'Manage shared content sources', + } +); +const MANAGE_USERS_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.manageUsersButtonText', + { + defaultMessage: 'Manage users', + } +); +const NAME_SECTION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.nameSectionTitle', + { + defaultMessage: 'Group name', + } +); +const NAME_SECTION_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.nameSectionDescription', + { + defaultMessage: 'Customize the name of this group.', + } +); +const SAVE_NAME_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.saveNameButtonText', + { + defaultMessage: 'Save name', + } +); +const REMOVE_SECTION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.removeSectionTitle', + { + defaultMessage: 'Remove this group', + } +); +const REMOVE_SECTION_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.removeSectionDescription', + { + defaultMessage: 'This action cannot be undone.', + } +); +const REMOVE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.removeButtonText', + { + defaultMessage: 'Remove group', + } +); +const CANCEL_REMOVE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.cancelRemoveButtonText', + { + defaultMessage: 'Cancel', + } +); +const CONFIRM_TITLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmTitleText', + { + defaultMessage: 'Confirm', + } +); + +export const GroupOverview: React.FC = () => { + const { + deleteGroup, + showSharedSourcesModal, + showManageUsersModal, + showConfirmDeleteModal, + hideConfirmDeleteModal, + updateGroupName, + onGroupNameInputChange, + } = useActions(GroupLogic); + const { + group: { name, contentSources, users, canDeleteGroup }, + groupNameInputValue, + dataLoading, + confirmDeleteModalVisible, + } = useValues(GroupLogic); + + const { isFederatedAuth } = useValues(AppLogic); + + if (dataLoading) return ; + + const truncatedName = ( + + ); + + const CONFIRM_REMOVE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveButtonText', + { + defaultMessage: 'Delete {name}', + values: { name }, + } + ); + const CONFIRM_REMOVE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveDescription', + { + defaultMessage: + 'Your group will be deleted from Workplace Search. Are you sure you want to remove {name}?', + values: { name }, + } + ); + const GROUP_SOURCES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.groupSourcesDescription', + { + defaultMessage: 'Searchable by all users in the "{name}" group.', + values: { name }, + } + ); + + const hasContentSources = contentSources.length > 0; + const hasUsers = users.length > 0; + + const manageSourcesButton = ( + + {MANAGE_SOURCES_BUTTON_TEXT} + + ); + const manageUsersButton = !isFederatedAuth && ( + + {MANAGE_USERS_BUTTON_TEXT} + + ); + const sourcesTable = ; + + const sourcesSection = ( + + {hasContentSources && sourcesTable} + + ); + + const usersSection = !isFederatedAuth && ( + + {hasUsers && } + + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateGroupName(); + }; + + const nameSection = ( + +
+ + + + onGroupNameInputChange(e.target.value)} + /> + + + + {SAVE_NAME_BUTTON_TEXT} + + + + +
+
+ ); + + const deleteSection = ( + <> + + + + + {confirmDeleteModalVisible && ( + + + {CONFIRM_REMOVE_DESCRIPTION} + + + )} + + {REMOVE_BUTTON_TEXT} + + + + ); + + return ( + <> + + + + + + {sourcesSection} + {usersSection} + {nameSection} + {canDeleteGroup && deleteSection} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx new file mode 100644 index 0000000000000..9c7276372cf54 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiTableRow, EuiTableRowCell, EuiIcon } from '@elastic/eui'; + +import { TruncatedContent } from '../../../../shared/truncate'; +import { EuiLink } from '../../../../shared/react_router_helpers'; + +import { IGroup } from '../../../types'; + +import { AppLogic } from '../../../app_logic'; +import { getGroupPath } from '../../../routes'; +import { MAX_NAME_LENGTH } from '../group_logic'; +import { GroupSources } from './group_sources'; +import { GroupUsers } from './group_users'; + +const DAYS_CUTOFF = 8; +const NO_SOURCES_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.noSourcesMessage', + { + defaultMessage: 'No shared content sources', + } +); +const NO_USERS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.noUsersMessage', + { + defaultMessage: 'No users', + } +); + +const dateDisplay = (date: string) => + moment(date).isAfter(moment().subtract(DAYS_CUTOFF, 'days')) + ? moment(date).fromNow() + : moment(date).format('MMMM D, YYYY'); + +export const GroupRow: React.FC = ({ + id, + name, + updatedAt, + contentSources, + users, + usersCount, +}) => { + const { isFederatedAuth } = useValues(AppLogic); + + const GROUP_UPDATED_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupUpdatedText', + { + defaultMessage: 'Last updated {updatedAt}.', + values: { updatedAt: dateDisplay(updatedAt) }, + } + ); + + return ( + + + + + + + +
+ {GROUP_UPDATED_TEXT} +
+ +
+ {contentSources.length > 0 ? ( + + ) : ( + NO_SOURCES_MESSAGE + )} +
+
+ {!isFederatedAuth && ( + +
+ {usersCount > 0 ? ( + + ) : ( + NO_USERS_MESSAGE + )} +
+
+ )} + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.tsx new file mode 100644 index 0000000000000..3e3840eab33da --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFilterGroup, EuiPopover, EuiPopoverTitle, EuiButtonEmpty } from '@elastic/eui'; + +import { IContentSource } from '../../../types'; + +import { SourceOptionItem } from './source_option_item'; + +interface IGroupRowSourcesDropdownProps { + isPopoverOpen: boolean; + numOptions: number; + groupSources: IContentSource[]; + onButtonClick(): void; + closePopover(): void; +} + +export const GroupRowSourcesDropdown: React.FC = ({ + isPopoverOpen, + numOptions, + groupSources, + onButtonClick, + closePopover, +}) => { + const toggleLink = ( + + + {numOptions} + + ); + const contentSourceCountHeading = ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.contentSourceCountHeading', { + defaultMessage: '{numSources} shared content sources', + values: { numSources: groupSources.length }, + })} + + ); + + const sources = groupSources.map((source, index) => ( +
+ id === source.id)[0]} /> +
+ )); + + return ( + + + {contentSourceCountHeading} +
{sources}
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.tsx new file mode 100644 index 0000000000000..7ecf01db9c044 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiLoadingContent, EuiButtonEmpty } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; +import { FilterableUsersPopover } from './filterable_users_popover'; + +interface IGroupRowUsersDropdownProps { + isPopoverOpen: boolean; + numOptions: number; + groupId: string; + onButtonClick(): void; + closePopover(): void; +} + +export const GroupRowUsersDropdown: React.FC = ({ + isPopoverOpen, + numOptions, + groupId, + onButtonClick, + closePopover, +}) => { + const { fetchGroupUsers } = useActions(GroupsLogic); + const { allGroupUsersLoading, allGroupUsers } = useValues(GroupsLogic); + + const handleLinkClick = () => { + fetchGroupUsers(groupId); + onButtonClick(); + }; + + const toggleLink = ( + + + {numOptions} + + ); + + return ( + : undefined} + className="user-group-source--additional__wrap" + button={toggleLink} + closePopover={closePopover} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx new file mode 100644 index 0000000000000..659f7f209e498 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ChangeEvent, MouseEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiRange, + EuiPanel, + EuiSpacer, + EuiTable, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableBody, + EuiTableRow, + EuiTableRowCell, +} from '@elastic/eui'; + +import { Loading } from '../../../components/shared/loading'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { SourceIcon } from '../../../components/shared/source_icon'; + +import { GroupLogic } from '../group_logic'; + +import { IContentSource } from '../../../types'; + +const HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.headerTitle', + { + defaultMessage: 'Shared content source prioritization', + } +); +const HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.headerDescription', + { + defaultMessage: 'Calibrate relative document importance across group content sources.', + } +); +const HEADER_ACTION_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.headerActionText', + { + defaultMessage: 'Save', + } +); +const ZERO_STATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.zeroStateTitle', + { + defaultMessage: 'No sources are shared with this group', + } +); +const ZERO_STATE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.zeroStateButtonText', + { + defaultMessage: 'Add shared content sources', + } +); +const SOURCE_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.sourceTableHeader', + { + defaultMessage: 'Source', + } +); +const PRIORITY_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.priorityTableHeader', + { + defaultMessage: 'Relevance Priority', + } +); + +export const GroupSourcePrioritization: React.FC = () => { + const { updatePriority, saveGroupSourcePrioritization, showSharedSourcesModal } = useActions( + GroupLogic + ); + + const { + group: { contentSources, name: groupName }, + dataLoading, + activeSourcePriorities, + groupPrioritiesUnchanged, + } = useValues(GroupLogic); + + if (dataLoading) return ; + + const headerAction = ( + + {HEADER_ACTION_TEXT} + + ); + const handleSliderChange = ( + id: string, + e: ChangeEvent | MouseEvent + ) => updatePriority(id, Number((e.target as HTMLInputElement).value)); + const hasSources = contentSources.length > 0; + + const zeroState = ( + + + {ZERO_STATE_TITLE}} + body={ + <> + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.zeroStateBody', + { + defaultMessage: + 'Share two or more sources with {groupName} to customize source prioritization.', + values: { groupName }, + } + )} + + } + actions={{ZERO_STATE_BUTTON_TEXT}} + /> + + + ); + + const sourceTable = ( + + + {SOURCE_TABLE_HEADER} + {PRIORITY_TABLE_HEADER} + + + {contentSources.map(({ id, name, serviceType }: IContentSource) => ( + + + + + + + + {name} + + + + + + + | MouseEvent) => + handleSliderChange(id, e) + } + /> + + +
+ {activeSourcePriorities[id]} +
+
+
+
+
+ ))} +
+
+ ); + + return ( + <> + + {hasSources ? sourceTable : zeroState} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.tsx new file mode 100644 index 0000000000000..7ae9856834443 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; + +import { SourceIcon } from '../../../components/shared/source_icon'; +import { MAX_TABLE_ROW_ICONS } from '../../../constants'; + +import { IContentSource } from '../../../types'; + +import { GroupRowSourcesDropdown } from './group_row_sources_dropdown'; + +interface IGroupSourcesProps { + groupSources: IContentSource[]; +} + +export const GroupSources: React.FC = ({ groupSources }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const closePopover = () => setPopoverOpen(false); + const togglePopover = () => setPopoverOpen(!popoverOpen); + const hiddenSources = [...groupSources]; + const visibleSources = hiddenSources.splice(0, MAX_TABLE_ROW_ICONS); + + return ( + <> + {visibleSources.map((source, index) => ( + + ))} + {hiddenSources.length > 0 && ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.tsx new file mode 100644 index 0000000000000..db8d390acce51 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { GroupLogic } from '../group_logic'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { getGroupPath, getGroupSourcePrioritizationPath } from '../../../routes'; + +export const GroupSubNav: React.FC = () => { + const { + group: { id }, + } = useValues(GroupLogic); + + if (!id) return null; + + return ( + <> + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.groups.groupOverview', { + defaultMessage: 'Overview', + })} + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.groups.sourcePrioritization', { + defaultMessage: 'Source Prioritization', + })} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.tsx new file mode 100644 index 0000000000000..6ce4370ccb8d1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; + +import { UserIcon } from '../../../components/shared/user_icon'; +import { MAX_TABLE_ROW_ICONS } from '../../../constants'; + +import { IUser } from '../../../types'; + +import { GroupRowUsersDropdown } from './group_row_users_dropdown'; + +interface IGroupUsersProps { + groupUsers: IUser[]; + usersCount: number; + groupId: string; +} + +export const GroupUsers: React.FC = ({ groupUsers, usersCount, groupId }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const closePopover = () => setPopoverOpen(false); + const togglePopover = () => setPopoverOpen(!popoverOpen); + const hiddenUsers = [...groupUsers]; + const visibleUsers = hiddenUsers.splice(0, MAX_TABLE_ROW_ICONS); + + return ( + <> + {visibleUsers.map((user, index) => ( + + ))} + {hiddenUsers.length > 0 && ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx new file mode 100644 index 0000000000000..5ab71056aba7e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; + +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiTable, EuiTableBody, EuiTablePagination } from '@elastic/eui'; +import { Pager } from '@elastic/eui'; + +import { IUser } from '../../../types'; + +import { TableHeader } from '../../../../shared/table_header'; +import { UserRow } from '../../../components/shared/user_row'; + +import { AppLogic } from '../../../app_logic'; +import { GroupLogic } from '../group_logic'; + +const USERS_PER_PAGE = 10; +const USERNAME_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader', + { + defaultMessage: 'Username', + } +); +const EMAIL_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader', + { + defaultMessage: 'Email', + } +); + +export const GroupUsersTable: React.FC = () => { + const { isFederatedAuth } = useValues(AppLogic); + const { + group: { users }, + } = useValues(GroupLogic); + const headerItems = [USERNAME_TABLE_HEADER]; + if (!isFederatedAuth) { + headerItems.push(EMAIL_TABLE_HEADER); + } + + const [firstItem, setFirstItem] = useState(0); + const [lastItem, setLastItem] = useState(USERS_PER_PAGE - 1); + const [currentPage, setCurrentPage] = useState(0); + + const numUsers = users.length; + const pager = new Pager(numUsers, USERS_PER_PAGE); + + const onChangePage = (pageIndex: number) => { + pager.goToPageIndex(pageIndex); + setFirstItem(pager.firstItemIndex); + setLastItem(pager.lastItemIndex); + setCurrentPage(pager.getCurrentPageIndex()); + }; + + const pagination = ( + + ); + + return ( + <> + + + + {users.slice(firstItem, lastItem + 1).map((user: IUser) => ( + + ))} + + + {numUsers > USERS_PER_PAGE && pagination} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx new file mode 100644 index 0000000000000..896a80e642be4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiSpacer, + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, +} from '@elastic/eui'; + +import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; + +import { AppLogic } from '../../../app_logic'; +import { GroupsLogic } from '../groups_logic'; +import { GroupRow } from './group_row'; + +import { ClearFiltersLink } from './clear_filters_link'; + +const GROUP_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.groupTableHeader', + { + defaultMessage: 'Group', + } +); +const SOURCES_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.sourcesTableHeader', + { + defaultMessage: 'Content sources', + } +); +const USERS_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.usersTableHeader', + { + defaultMessage: 'Users', + } +); + +export const GroupsTable: React.FC<{}> = () => { + const { setActivePage } = useActions(GroupsLogic); + const { + groupsMeta: { + page: { total_pages: totalPages, total_results: totalItems, current: activePage }, + }, + groups, + hasFiltersSet, + } = useValues(GroupsLogic); + const { isFederatedAuth } = useValues(AppLogic); + + const clearFiltersLink = hasFiltersSet ? : undefined; + + const paginationOptions = { + itemLabel: 'Groups', + totalPages, + totalItems, + activePage, + clearFiltersLink, + onChangePage: (page: number) => { + // EUI component starts page at 0. API starts at 1. + setActivePage(page + 1); + }, + }; + + const showPagination = totalPages > 1; + + return ( + <> + {showPagination ? : clearFiltersLink} + + + + {GROUP_TABLE_HEADER} + {SOURCES_TABLE_HEADER} + {!isFederatedAuth && {USERS_TABLE_HEADER}} + + + + {groups.map((group, index) => ( + + ))} + + + + {showPagination && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.tsx new file mode 100644 index 0000000000000..8a384cfd5a91a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { GroupLogic } from '../group_logic'; +import { GroupsLogic } from '../groups_logic'; + +import { FilterableUsersList } from './filterable_users_list'; +import { GroupManagerModal } from './group_manager_modal'; + +const MODAL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.usersModalLabel', + { + defaultMessage: 'users', + } +); + +export const ManageUsersModal: React.FC = () => { + const { + addGroupUser, + removeGroupUser, + selectAllUsers, + hideManageUsersModal, + saveGroupUsers, + } = useActions(GroupLogic); + + const { selectedGroupUsers } = useValues(GroupLogic); + const { users } = useValues(GroupsLogic); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.tsx new file mode 100644 index 0000000000000..1bc72f99d7be8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { GroupLogic } from '../group_logic'; +import { GroupsLogic } from '../groups_logic'; + +import { GroupManagerModal } from './group_manager_modal'; +import { SourcesList } from './sources_list'; + +const MODAL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourcesModalLabel', + { + defaultMessage: 'shared content sources', + } +); + +export const SharedSourcesModal: React.FC = () => { + const { + addGroupSource, + selectAllSources, + hideSharedSourcesModal, + removeGroupSource, + saveGroupSources, + } = useActions(GroupLogic); + + const { selectedGroupSources, group } = useValues(GroupLogic); + + const { contentSources } = useValues(GroupsLogic); + + return ( + + <> +

+ {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.sourcesModalTitle', { + defaultMessage: 'Select content sources to share with {groupName}', + values: { groupName: group.name }, + })} +

+ + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.tsx new file mode 100644 index 0000000000000..f6677670f8a88 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { TruncatedContent } from '../../../../shared/truncate'; + +import { SourceIcon } from '../../../components/shared/source_icon'; +import { IContentSource } from '../../../types'; + +const MAX_LENGTH = 28; + +interface ISourceOptionItemProps { + source: IContentSource; +} + +export const SourceOptionItem: React.FC = ({ source }) => ( + + + + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.tsx new file mode 100644 index 0000000000000..e8f9027d98e0d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFilterSelectItem } from '@elastic/eui'; + +import { IContentSource } from '../../../types'; + +import { SourceOptionItem } from './source_option_item'; + +interface ISourcesListProps { + contentSources: IContentSource[]; + filteredSources: string[]; + addFilteredSource(sourceId: string): void; + removeFilteredSource(sourceId: string): void; +} + +export const SourcesList: React.FC = ({ + contentSources, + filteredSources, + addFilteredSource, + removeFilteredSource, +}) => { + const sourceIds = contentSources.map(({ id }) => id); + const sources = sourceIds.map((sourceId, index) => { + const checked = filteredSources.indexOf(sourceId) > -1 ? 'on' : undefined; + const handleClick = () => + checked ? removeFilteredSource(sourceId) : addFilteredSource(sourceId); + return ( + + id === sourceId)[0]} /> + + ); + }); + + return
{sources}
; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.tsx new file mode 100644 index 0000000000000..220c33ca86ddd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; +import { SourcesList } from './sources_list'; + +const FILTER_SOURCES_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterSources.buttonText', + { + defaultMessage: 'Sources', + } +); + +export const TableFilterSourcesDropdown: React.FC = () => { + const { + addFilteredSource, + removeFilteredSource, + toggleFilterSourcesDropdown, + closeFilterSourcesDropdown, + } = useActions(GroupsLogic); + const { contentSources, filterSourcesDropdownOpen, filteredSources } = useValues(GroupsLogic); + + const sourceIds = contentSources.map(({ id }) => id); + + const filterButton = ( + 0} + numActiveFilters={filteredSources.length} + > + {FILTER_SOURCES_BUTTON_TEXT} + + ); + + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx new file mode 100644 index 0000000000000..6345c4378418f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFilterButton } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; +import { FilterableUsersPopover } from './filterable_users_popover'; + +const FILTER_USERS_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.buttonText', + { + defaultMessage: 'Users', + } +); + +export const TableFilterUsersDropdown: React.FC<{}> = () => { + const { closeFilterUsersDropdown, toggleFilterUsersDropdown } = useActions(GroupsLogic); + const { filteredUsers, filterUsersDropdownOpen, users } = useValues(GroupsLogic); + + const filterButton = ( + 0} + numActiveFilters={filteredUsers.length} + > + {FILTER_USERS_BUTTON_TEXT} + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.tsx new file mode 100644 index 0000000000000..d11af030822bf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ChangeEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { AppLogic } from '../../../app_logic'; +import { GroupsLogic } from '../groups_logic'; + +import { TableFilterSourcesDropdown } from './table_filter_sources_dropdown'; +import { TableFilterUsersDropdown } from './table_filter_users_dropdown'; + +const FILTER_GROUPS_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterGroups.placeholder', + { + defaultMessage: 'Filter groups by name...', + } +); + +export const TableFilters: React.FC = () => { + const { setFilterValue } = useActions(GroupsLogic); + const { filterValue } = useValues(GroupsLogic); + const { isFederatedAuth } = useValues(AppLogic); + + const handleSearchChange = (e: ChangeEvent) => setFilterValue(e.target.value); + + return ( + + + + + + + + + + {!isFederatedAuth && ( + + + + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.tsx new file mode 100644 index 0000000000000..8eb199d67cf92 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { UserIcon } from '../../../components/shared/user_icon'; +import { IUser } from '../../../types'; + +interface IUserOptionItemProps { + user: IUser; +} + +export const UserOptionItem: React.FC = ({ user }) => ( + + + + + {user.name || user.email} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts new file mode 100644 index 0000000000000..1ce0fe53726d4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts @@ -0,0 +1,388 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; +import { isEqual } from 'lodash'; +import { History } from 'history'; +import { i18n } from '@kbn/i18n'; + +import { HttpLogic } from '../../../shared/http'; +import { KibanaLogic } from '../../../shared/kibana'; + +import { + FlashMessagesLogic, + flashAPIErrors, + setSuccessMessage, + setQueuedSuccessMessage, +} from '../../../shared/flash_messages'; +import { GROUPS_PATH } from '../../routes'; + +import { IContentSourceDetails, IGroupDetails, IUser, ISourcePriority } from '../../types'; + +export const MAX_NAME_LENGTH = 40; + +export interface IGroupActions { + onInitializeGroup(group: IGroupDetails): IGroupDetails; + onGroupNameChanged(group: IGroupDetails): IGroupDetails; + onGroupPrioritiesChanged(group: IGroupDetails): IGroupDetails; + onGroupNameInputChange(groupName: string): string; + addGroupSource(sourceId: string): string; + removeGroupSource(sourceId: string): string; + addGroupUser(userId: string): string; + removeGroupUser(userId: string): string; + onGroupSourcesSaved(group: IGroupDetails): IGroupDetails; + onGroupUsersSaved(group: IGroupDetails): IGroupDetails; + setGroupModalErrors(errors: string[]): string[]; + hideSharedSourcesModal(group: IGroupDetails): IGroupDetails; + hideManageUsersModal(group: IGroupDetails): IGroupDetails; + selectAllSources(contentSources: IContentSourceDetails[]): IContentSourceDetails[]; + selectAllUsers(users: IUser[]): IUser[]; + updatePriority(id: string, boost: number): { id: string; boost: number }; + resetGroup(): void; + showConfirmDeleteModal(): void; + hideConfirmDeleteModal(): void; + showSharedSourcesModal(): void; + showManageUsersModal(): void; + resetFlashMessages(): void; + initializeGroup(groupId: string): { groupId: string }; + deleteGroup(): void; + updateGroupName(): void; + saveGroupSources(): void; + saveGroupUsers(): void; + saveGroupSourcePrioritization(): void; +} + +export interface IGroupValues { + contentSources: IContentSourceDetails[]; + users: IUser[]; + group: IGroupDetails; + dataLoading: boolean; + manageUsersModalVisible: boolean; + managerModalFormErrors: string[]; + sharedSourcesModalModalVisible: boolean; + confirmDeleteModalVisible: boolean; + groupNameInputValue: string; + selectedGroupSources: string[]; + selectedGroupUsers: string[]; + groupPrioritiesUnchanged: boolean; + activeSourcePriorities: ISourcePriority; + cachedSourcePriorities: ISourcePriority; +} + +export const GroupLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'group'], + actions: { + onInitializeGroup: (group: IGroupDetails) => group, + onGroupNameChanged: (group: IGroupDetails) => group, + onGroupPrioritiesChanged: (group: IGroupDetails) => group, + onGroupNameInputChange: (groupName: string) => groupName, + addGroupSource: (sourceId: string) => sourceId, + removeGroupSource: (sourceId: string) => sourceId, + addGroupUser: (userId: string) => userId, + removeGroupUser: (userId: string) => userId, + onGroupSourcesSaved: (group: IGroupDetails) => group, + onGroupUsersSaved: (group: IGroupDetails) => group, + setGroupModalErrors: (errors: string[]) => errors, + hideSharedSourcesModal: (group: IGroupDetails) => group, + hideManageUsersModal: (group: IGroupDetails) => group, + selectAllSources: (contentSources: IContentSourceDetails[]) => contentSources, + selectAllUsers: (users: IUser[]) => users, + updatePriority: (id: string, boost: number) => ({ id, boost }), + resetGroup: () => true, + showConfirmDeleteModal: () => true, + hideConfirmDeleteModal: () => true, + showSharedSourcesModal: () => true, + showManageUsersModal: () => true, + resetFlashMessages: () => true, + initializeGroup: (groupId: string, history: History) => ({ groupId, history }), + deleteGroup: () => true, + updateGroupName: () => true, + saveGroupSources: () => true, + saveGroupUsers: () => true, + saveGroupSourcePrioritization: () => true, + }, + reducers: { + group: [ + {} as IGroupDetails, + { + onInitializeGroup: (_, group) => group, + onGroupNameChanged: (_, group) => group, + onGroupSourcesSaved: (_, group) => group, + onGroupUsersSaved: (_, group) => group, + resetGroup: () => ({} as IGroupDetails), + }, + ], + dataLoading: [ + true, + { + onInitializeGroup: () => false, + onGroupPrioritiesChanged: () => false, + resetGroup: () => true, + }, + ], + manageUsersModalVisible: [ + false, + { + showManageUsersModal: () => true, + hideManageUsersModal: () => false, + onGroupUsersSaved: () => false, + }, + ], + managerModalFormErrors: [ + [], + { + setGroupModalErrors: (_, errors) => errors, + hideManageUsersModal: () => [], + }, + ], + sharedSourcesModalModalVisible: [ + false, + { + showSharedSourcesModal: () => true, + hideSharedSourcesModal: () => false, + onGroupSourcesSaved: () => false, + }, + ], + confirmDeleteModalVisible: [ + false, + { + showConfirmDeleteModal: () => true, + hideConfirmDeleteModal: () => false, + }, + ], + groupNameInputValue: [ + '', + { + onInitializeGroup: (_, { name }) => name, + onGroupNameChanged: (_, { name }) => name, + onGroupNameInputChange: (_, name) => name, + }, + ], + selectedGroupSources: [ + [], + { + onInitializeGroup: (_, { contentSources }) => contentSources.map(({ id }) => id), + onGroupSourcesSaved: (_, { contentSources }) => contentSources.map(({ id }) => id), + selectAllSources: (_, contentSources) => contentSources.map(({ id }) => id), + hideSharedSourcesModal: (_, { contentSources }) => contentSources.map(({ id }) => id), + addGroupSource: (state, sourceId) => [...state, sourceId].sort(), + removeGroupSource: (state, sourceId) => state.filter((id) => id !== sourceId), + }, + ], + selectedGroupUsers: [ + [], + { + onInitializeGroup: (_, { users }) => users.map(({ id }) => id), + onGroupUsersSaved: (_, { users }) => users.map(({ id }) => id), + selectAllUsers: (_, users) => users.map(({ id }) => id), + hideManageUsersModal: (_, { users }) => users.map(({ id }) => id), + addGroupUser: (state, userId) => [...state, userId].sort(), + removeGroupUser: (state, userId) => state.filter((id) => id !== userId), + }, + ], + cachedSourcePriorities: [ + {}, + { + onInitializeGroup: (_, { contentSources }) => mapPriorities(contentSources), + onGroupPrioritiesChanged: (_, { contentSources }) => mapPriorities(contentSources), + onGroupSourcesSaved: (_, { contentSources }) => mapPriorities(contentSources), + }, + ], + activeSourcePriorities: [ + {}, + { + onInitializeGroup: (_, { contentSources }) => mapPriorities(contentSources), + onGroupPrioritiesChanged: (_, { contentSources }) => mapPriorities(contentSources), + onGroupSourcesSaved: (_, { contentSources }) => mapPriorities(contentSources), + updatePriority: (state, { id, boost }) => { + const updated = { ...state }; + updated[id] = boost; + return updated; + }, + }, + ], + }, + selectors: ({ selectors }) => ({ + groupPrioritiesUnchanged: [ + () => [selectors.cachedSourcePriorities, selectors.activeSourcePriorities], + (cached, active) => isEqual(cached, active), + ], + }), + listeners: ({ actions, values }) => ({ + initializeGroup: async ({ groupId }) => { + try { + const response = await HttpLogic.values.http.get(`/api/workplace_search/groups/${groupId}`); + actions.onInitializeGroup(response); + } catch (e) { + const NOT_FOUND_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupNotFound', + { + defaultMessage: 'Unable to find group with ID: "{groupId}".', + values: { groupId }, + } + ); + + const error = e.response.status === 404 ? NOT_FOUND_MESSAGE : e; + + FlashMessagesLogic.actions.setQueuedMessages({ + type: 'error', + message: error, + }); + + KibanaLogic.values.navigateToUrl(GROUPS_PATH); + } + }, + deleteGroup: async () => { + const { + group: { id, name }, + } = values; + try { + await HttpLogic.values.http.delete(`/api/workplace_search/groups/${id}`); + const GROUP_DELETED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupDeleted', + { + defaultMessage: 'Group "{groupName}" was successfully deleted.', + values: { groupName: name }, + } + ); + + setQueuedSuccessMessage(GROUP_DELETED_MESSAGE); + KibanaLogic.values.navigateToUrl(GROUPS_PATH); + } catch (e) { + flashAPIErrors(e); + } + }, + updateGroupName: async () => { + const { + group: { id }, + groupNameInputValue, + } = values; + + try { + const response = await HttpLogic.values.http.put(`/api/workplace_search/groups/${id}`, { + body: JSON.stringify({ group: { name: groupNameInputValue } }), + }); + actions.onGroupNameChanged(response); + + const GROUP_RENAMED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupRenamed', + { + defaultMessage: 'Successfully renamed this group to "{groupName}".', + values: { groupName: response.name }, + } + ); + setSuccessMessage(GROUP_RENAMED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + saveGroupSources: async () => { + const { + group: { id }, + selectedGroupSources, + } = values; + + try { + const response = await HttpLogic.values.http.post( + `/api/workplace_search/groups/${id}/share`, + { + body: JSON.stringify({ content_source_ids: selectedGroupSources }), + } + ); + actions.onGroupSourcesSaved(response); + const GROUP_SOURCES_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupSourcesUpdated', + { + defaultMessage: 'Successfully updated shared content sources.', + } + ); + setSuccessMessage(GROUP_SOURCES_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + saveGroupUsers: async () => { + const { + group: { id }, + selectedGroupUsers, + } = values; + + try { + const response = await HttpLogic.values.http.post( + `/api/workplace_search/groups/${id}/assign`, + { + body: JSON.stringify({ user_ids: selectedGroupUsers }), + } + ); + actions.onGroupUsersSaved(response); + const GROUP_USERS_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupUsersUpdated', + { + defaultMessage: 'Successfully updated the users of this group', + } + ); + setSuccessMessage(GROUP_USERS_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + saveGroupSourcePrioritization: async () => { + const { + group: { id }, + activeSourcePriorities, + } = values; + + // server expects an array of id, value for each boost. + // example: [['123abc', 7], ['122abv', 1]] + const boosts = [] as Array>; + Object.keys(activeSourcePriorities).forEach((k: string) => + boosts.push([k, Number(activeSourcePriorities[k])]) + ); + + try { + const response = await HttpLogic.values.http.put( + `/api/workplace_search/groups/${id}/boosts`, + { + body: JSON.stringify({ content_source_boosts: boosts }), + } + ); + + const GROUP_PRIORITIZATION_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupPrioritizationUpdated', + { + defaultMessage: 'Successfully updated shared source prioritization', + } + ); + + setSuccessMessage(GROUP_PRIORITIZATION_UPDATED_MESSAGE); + actions.onGroupPrioritiesChanged(response); + } catch (e) { + flashAPIErrors(e); + } + }, + showConfirmDeleteModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + showManageUsersModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + showSharedSourcesModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + resetFlashMessages: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); + +const mapPriorities = (contentSources: IContentSourceDetails[]): ISourcePriority => { + const prioritiesMap = {} as ISourcePriority; + contentSources.forEach(({ id, boost }) => { + prioritiesMap[id] = boost; + }); + + return prioritiesMap; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx new file mode 100644 index 0000000000000..e5779a96b4687 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx @@ -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 React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; +import { Route, Switch, useParams } from 'react-router-dom'; + +import { FlashMessages, FlashMessagesLogic } from '../../../shared/flash_messages'; +import { GROUP_SOURCE_PRIORITIZATION_PATH, GROUP_PATH } from '../../routes'; +import { GroupLogic } from './group_logic'; + +import { ManageUsersModal } from './components/manage_users_modal'; +import { SharedSourcesModal } from './components/shared_sources_modal'; + +import { GroupOverview } from './components/group_overview'; +import { GroupSourcePrioritization } from './components/group_source_prioritization'; + +export const GroupRouter: React.FC = () => { + const { groupId } = useParams() as { groupId: string }; + + const { messages } = useValues(FlashMessagesLogic); + const { initializeGroup, resetGroup } = useActions(GroupLogic); + const { sharedSourcesModalModalVisible, manageUsersModalVisible } = useValues(GroupLogic); + + const hasMessages = messages.length > 0; + + useEffect(() => { + initializeGroup(groupId); + return resetGroup; + }, []); + + return ( + <> + {hasMessages && } + + + + + {sharedSourcesModalModalVisible && } + {manageUsersModalVisible && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.scss new file mode 100644 index 0000000000000..fbd4e6f87d19b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.scss @@ -0,0 +1,101 @@ +.groups-table { + background-color: transparent; +} + +.user-groups-header { + display: flex; + padding: 0 1.5rem; + margin-bottom: 1rem; + font-size: .875rem; + font-weight: 500; + color: $euiColorDarkShade; + + &__title { + flex: 1; + } + + &__sources, + &__accounts { + width: 25%; + } +} + +.user-group { + display: flex; + height: 80px; + background: $euiColorLightestShade; + color: $euiColorDarkestShade; + border-radius: 6px; + align-items: center; + padding: 0 1.5rem; + position: relative; + margin-bottom: 1rem; + &:hover { + background: $euiColorEmptyShade; + color: $euiColorFullShade; + box-shadow: + inset 0 0 0 1px $euiColorLightShade, + 0 2px 4px rgba(black, .05); + } + + &:after { + content: ''; + width: 8px; + height: 8px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + opacity: .5; + position: absolute; + transform: translateY(-50%) rotate(-45deg); + top: 50%; + right: 1.5rem; + } + + &__sources, + &__accounts { + display: flex; + align-items: center; + } + + &__item { + pointer-events: none; + } +} + +.user-group-source, +.user-group-account { + width: 30px; + height: 30px; + overflow: hidden; + margin-right: 4px; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + &--additional { + font-size: .875rem; + margin-left: .5rem; + opacity: .75; + font-weight: 500; + + &__wrap { + border: none; + box-shadow: none; + } + } + + img { + max-width: 100%; + } +} + +.user-groups-filters { + &__search-bar { + min-width: 260px!important; + } + + &__filter-sources { + min-width: 130px!important; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx new file mode 100644 index 0000000000000..5c475e717329e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import { EuiButton as EuiLinkButton } from '../../../shared/react_router_helpers'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; + +import { AppLogic } from '../../app_logic'; + +import { Loading } from '../../components/shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { getGroupPath, USERS_PATH } from '../../routes'; + +import { useDidUpdateEffect } from '../../../shared/use_did_update_effect'; +import { FlashMessages, FlashMessagesLogic } from '../../../shared/flash_messages'; + +import { GroupsLogic } from './groups_logic'; + +import { AddGroupModal } from './components/add_group_modal'; +import { ClearFiltersLink } from './components/clear_filters_link'; +import { GroupsTable } from './components/groups_table'; +import { TableFilters } from './components/table_filters'; + +export const Groups: React.FC = () => { + const { messages } = useValues(FlashMessagesLogic); + + const { getSearchResults, openNewGroupModal, resetGroups } = useActions(GroupsLogic); + const { + groupsDataLoading, + newGroupModalOpen, + newGroup, + groupListLoading, + hasFiltersSet, + groupsMeta: { + page: { current: activePage, total_results: numGroups }, + }, + filteredSources, + filteredUsers, + filterValue, + } = useValues(GroupsLogic); + + const { isFederatedAuth } = useValues(AppLogic); + + const hasMessages = messages.length > 0; + + useEffect(() => { + getSearchResults(true); + return resetGroups; + }, [filteredSources, filteredUsers, filterValue]); + + // Because the initial search happens above, we want to skip the initial search and use the custom hook to do so. + useDidUpdateEffect(() => { + getSearchResults(); + }, [activePage]); + + if (groupsDataLoading) { + return ; + } + + if (newGroup && hasMessages) { + messages[0].description = ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.newGroup.action', { + defaultMessage: 'Manage Group', + })} + + ); + } + + const clearFilters = hasFiltersSet && ; + const inviteUsersButton = !isFederatedAuth ? ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.inviteUsers.action', { + defaultMessage: 'Invite users', + })} + + ) : null; + + const headerAction = ( + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.addGroupForm.action', { + defaultMessage: 'Create a group', + })} + + + {inviteUsersButton} + + ); + + const noResults = ( + + + {groupListLoading ? ( + + ) : ( + <> + {clearFilters} +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.searchResults.notFoound', + { + defaultMessage: 'No results found.', + } + )} +

+ + )} +
+
+ ); + + return ( + <> + + + + + + + + {numGroups > 0 && !groupListLoading ? : noResults} + {newGroupModalOpen && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts new file mode 100644 index 0000000000000..35d4387b4cf3d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts @@ -0,0 +1,351 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { HttpLogic } from '../../../shared/http'; + +import { + FlashMessagesLogic, + flashAPIErrors, + setSuccessMessage, +} from '../../../shared/flash_messages'; + +import { IContentSource, IGroup, IUser } from '../../types'; + +import { JSON_HEADER as headers } from '../../../../../common/constants'; +import { DEFAULT_META } from '../../../shared/constants'; +import { IMeta } from '../../../../../common/types'; + +export const MAX_NAME_LENGTH = 40; + +interface IGroupsServerData { + contentSources: IContentSource[]; + users: IUser[]; +} + +interface IGroupsSearchResponse { + results: IGroup[]; + meta: IMeta; +} + +export interface IGroupsActions { + onInitializeGroups(data: IGroupsServerData): IGroupsServerData; + setSearchResults(data: IGroupsSearchResponse): IGroupsSearchResponse; + addFilteredSource(sourceId: string): string; + removeFilteredSource(sourceId: string): string; + addFilteredUser(userId: string): string; + removeFilteredUser(userId: string): string; + setGroupUsers(allGroupUsers: IUser[]): IUser[]; + setAllGroupLoading(allGroupUsersLoading: boolean): boolean; + setFilterValue(filterValue: string): string; + setActivePage(activePage: number): number; + setNewGroupName(newGroupName: string): string; + setNewGroup(newGroup: IGroup): IGroup; + setNewGroupFormErrors(errors: string[]): string[]; + openNewGroupModal(): void; + closeNewGroupModal(): void; + closeFilterSourcesDropdown(): void; + closeFilterUsersDropdown(): void; + toggleFilterSourcesDropdown(): void; + toggleFilterUsersDropdown(): void; + setGroupsLoading(): void; + resetGroupsFilters(): void; + resetGroups(): void; + initializeGroups(): void; + getSearchResults(resetPagination?: boolean): { resetPagination: boolean | undefined }; + fetchGroupUsers(groupId: string): { groupId: string }; + saveNewGroup(): void; +} + +export interface IGroupsValues { + groups: IGroup[]; + contentSources: IContentSource[]; + users: IUser[]; + groupsDataLoading: boolean; + groupListLoading: boolean; + newGroupModalOpen: boolean; + newGroupName: string; + newGroup: IGroup | null; + newGroupNameErrors: string[]; + filterSourcesDropdownOpen: boolean; + filteredSources: string[]; + filterUsersDropdownOpen: boolean; + filteredUsers: string[]; + allGroupUsersLoading: boolean; + allGroupUsers: IUser[]; + filterValue: string; + groupsMeta: IMeta; + hasFiltersSet: boolean; +} + +export const GroupsLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'groups'], + actions: { + onInitializeGroups: (data: IGroupsServerData) => data, + setSearchResults: (data: IGroupsSearchResponse) => data, + addFilteredSource: (sourceId: string) => sourceId, + removeFilteredSource: (sourceId: string) => sourceId, + addFilteredUser: (userId: string) => userId, + removeFilteredUser: (userId: string) => userId, + setGroupUsers: (allGroupUsers: IUser[]) => allGroupUsers, + setAllGroupLoading: (allGroupUsersLoading: boolean) => allGroupUsersLoading, + setFilterValue: (filterValue: string) => filterValue, + setActivePage: (activePage: number) => activePage, + setNewGroupName: (newGroupName: string) => newGroupName, + setNewGroup: (newGroup: IGroup) => newGroup, + setNewGroupFormErrors: (errors: string[]) => errors, + openNewGroupModal: () => true, + closeNewGroupModal: () => true, + closeFilterSourcesDropdown: () => true, + closeFilterUsersDropdown: () => true, + toggleFilterSourcesDropdown: () => true, + toggleFilterUsersDropdown: () => true, + setGroupsLoading: () => true, + resetGroupsFilters: () => true, + resetGroups: () => true, + initializeGroups: () => true, + getSearchResults: (resetPagination?: boolean) => ({ resetPagination }), + fetchGroupUsers: (groupId: string) => ({ groupId }), + saveNewGroup: () => true, + }, + reducers: { + groups: [ + [] as IGroup[], + { + setSearchResults: (_, { results }) => results, + }, + ], + contentSources: [ + [], + { + onInitializeGroups: (_, { contentSources }) => contentSources, + }, + ], + users: [ + [], + { + onInitializeGroups: (_, { users }) => users, + }, + ], + groupsDataLoading: [ + true, + { + onInitializeGroups: () => false, + }, + ], + groupListLoading: [ + true, + { + setSearchResults: () => false, + setGroupsLoading: () => true, + }, + ], + newGroupModalOpen: [ + false, + { + openNewGroupModal: () => true, + closeNewGroupModal: () => false, + setNewGroup: () => false, + }, + ], + newGroupName: [ + '', + { + setNewGroupName: (_, newGroupName) => newGroupName, + setSearchResults: () => '', + closeNewGroupModal: () => '', + }, + ], + newGroup: [ + null, + { + setNewGroup: (_, newGroup) => newGroup, + resetGroups: () => null, + openNewGroupModal: () => null, + }, + ], + newGroupNameErrors: [ + [], + { + setNewGroupFormErrors: (_, newGroupNameErrors) => newGroupNameErrors, + setNewGroup: () => [], + setNewGroupName: () => [], + closeNewGroupModal: () => [], + }, + ], + filterSourcesDropdownOpen: [ + false, + { + toggleFilterSourcesDropdown: (state) => !state, + closeFilterSourcesDropdown: () => false, + }, + ], + filteredSources: [ + [], + { + resetGroupsFilters: () => [], + setNewGroup: () => [], + addFilteredSource: (state, sourceId) => [...state, sourceId].sort(), + removeFilteredSource: (state, sourceId) => state.filter((id) => id !== sourceId), + }, + ], + filterUsersDropdownOpen: [ + false, + { + toggleFilterUsersDropdown: (state) => !state, + closeFilterUsersDropdown: () => false, + }, + ], + filteredUsers: [ + [], + { + resetGroupsFilters: () => [], + setNewGroup: () => [], + addFilteredUser: (state, userId) => [...state, userId].sort(), + removeFilteredUser: (state, userId) => state.filter((id) => id !== userId), + }, + ], + allGroupUsersLoading: [ + false, + { + setAllGroupLoading: (_, allGroupUsersLoading) => allGroupUsersLoading, + setGroupUsers: () => false, + }, + ], + allGroupUsers: [ + [], + { + setGroupUsers: (_, allGroupUsers) => allGroupUsers, + setAllGroupLoading: () => [], + }, + ], + filterValue: [ + '', + { + setFilterValue: (_, filterValue) => filterValue, + resetGroupsFilters: () => '', + }, + ], + groupsMeta: [ + DEFAULT_META, + { + resetGroupsFilters: () => DEFAULT_META, + setNewGroup: () => DEFAULT_META, + setSearchResults: (_, { meta }) => meta, + setActivePage: (state, activePage) => ({ + ...state, + page: { + ...state.page, + current: activePage, + }, + }), + }, + ], + }, + selectors: ({ selectors }) => ({ + hasFiltersSet: [ + () => [selectors.filteredUsers, selectors.filteredSources], + (filteredUsers, filteredSources) => filteredUsers.length > 0 || filteredSources.length > 0, + ], + }), + listeners: ({ actions, values }) => ({ + initializeGroups: async () => { + try { + const response = await HttpLogic.values.http.get('/api/workplace_search/groups'); + actions.onInitializeGroups(response); + } catch (e) { + flashAPIErrors(e); + } + }, + getSearchResults: async ({ resetPagination }, breakpoint) => { + // Debounce search results when typing + await breakpoint(300); + + actions.setGroupsLoading(); + + const { + groupsMeta: { + page: { current, size }, + }, + filterValue, + filteredSources, + filteredUsers, + } = values; + + // Is the user changes the query while on a different page, we want to start back over at 1. + const page = { + current: resetPagination ? 1 : current, + size, + }; + const search = { + query: filterValue, + content_source_ids: filteredSources, + user_ids: filteredUsers, + }; + + try { + const response = await HttpLogic.values.http.post('/api/workplace_search/groups/search', { + body: JSON.stringify({ + page, + search, + }), + headers, + }); + + actions.setSearchResults(response); + } catch (e) { + flashAPIErrors(e); + } + }, + fetchGroupUsers: async ({ groupId }) => { + actions.setAllGroupLoading(true); + try { + const response = await HttpLogic.values.http.get( + `/api/workplace_search/groups/${groupId}/group_users` + ); + actions.setGroupUsers(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveNewGroup: async () => { + try { + const response = await HttpLogic.values.http.post('/api/workplace_search/groups', { + body: JSON.stringify({ group_name: values.newGroupName }), + headers, + }); + actions.getSearchResults(true); + + const SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.newGroupSavedSuccess', + { + defaultMessage: 'Successfully created {groupName}', + values: { groupName: response.name }, + } + ); + + setSuccessMessage(SUCCESS_MESSAGE); + actions.setNewGroup(response); + } catch (e) { + flashAPIErrors(e); + } + }, + openNewGroupModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + resetGroupsFilters: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + toggleFilterSourcesDropdown: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + toggleFilterUsersDropdown: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx new file mode 100644 index 0000000000000..caa71d0d622f3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions } from 'kea'; + +import { Route, Switch } from 'react-router-dom'; + +import { GROUP_PATH, GROUPS_PATH } from '../../routes'; + +import { GroupsLogic } from './groups_logic'; + +import { GroupRouter } from './group_router'; +import { Groups } from './groups'; + +import './groups.scss'; + +export const GroupsRouter: React.FC = () => { + const { initializeGroups } = useActions(GroupsLogic); + + useEffect(() => { + initializeGroups(); + }, []); + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts new file mode 100644 index 0000000000000..79b5e39d2b27d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/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 { GroupsRouter } from './groups_router'; +export { GroupsLogic } from './groups_logic'; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index a9bd03e8f97d4..43b0be8a5b438 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -45,6 +45,7 @@ import { registerCredentialsRoutes } from './routes/app_search/credentials'; import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; import { registerWSOverviewRoute } from './routes/workplace_search/overview'; +import { registerWSGroupRoutes } from './routes/workplace_search/groups'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -129,6 +130,7 @@ export class EnterpriseSearchPlugin implements Plugin { registerEnginesRoute(dependencies); registerCredentialsRoutes(dependencies); registerWSOverviewRoute(dependencies); + registerWSGroupRoutes(dependencies); /** * Bootstrap the routes, saved objects, and collector for telemetry diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts new file mode 100644 index 0000000000000..21d08e5c8756b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts @@ -0,0 +1,256 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { IRouteDependencies } from '../../plugin'; + +import { IMeta } from '../../../common/types'; +import { IUser, IContentSource, IGroup } from '../../../common/types/workplace_search'; + +export function registerGroupsRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups', + hasValidData: (body: { users: IUser[]; contentSources: IContentSource[] }) => + typeof Array.isArray(body?.users) && typeof Array.isArray(body?.contentSources), + }) + ); + + router.post( + { + path: '/api/workplace_search/groups', + validate: { + body: schema.object({ + group_name: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups', + body: request.body, + hasValidData: (body: { created_at: string }) => typeof body?.created_at === 'string', + })(context, request, response); + } + ); +} + +export function registerSearchGroupsRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/search', + validate: { + body: schema.object({ + page: schema.object({ + current: schema.number(), + size: schema.number(), + }), + search: schema.object({ + query: schema.string(), + content_source_ids: schema.arrayOf(schema.string()), + user_ids: schema.arrayOf(schema.string()), + }), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups/search', + body: request.body, + hasValidData: (body: { results: IGroup[]; meta: IMeta }) => + typeof Array.isArray(body?.results) && + typeof body?.meta?.page?.total_results === 'number', + })(context, request, response); + } + ); +} + +export function registerGroupRoute({ router, enterpriseSearchRequestHandler }: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); + + router.put( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + group: schema.object({ + name: schema.string(), + }), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); + + router.delete( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + hasValidData: (body: { deleted: boolean }) => body?.deleted === true, + })(context, request, response); + } + ); +} + +export function registerGroupUsersRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups/{id}/group_users', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/group_users`, + hasValidData: (body: IUser[]) => typeof Array.isArray(body), + })(context, request, response); + } + ); +} + +export function registerShareGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/{id}/share', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + content_source_ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/share`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerAssignGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/{id}/assign', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + user_ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/assign`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerBoostsGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.put( + { + path: '/api/workplace_search/groups/{id}/boosts', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + content_source_boosts: schema.arrayOf( + schema.arrayOf(schema.oneOf([schema.string(), schema.number()])) + ), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/update_source_boosts`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerWSGroupRoutes(dependencies: IRouteDependencies) { + registerGroupsRoute(dependencies); + registerSearchGroupsRoute(dependencies); + registerGroupRoute(dependencies); + registerGroupUsersRoute(dependencies); + registerShareGroupRoute(dependencies); + registerAssignGroupRoute(dependencies); + registerBoostsGroupRoute(dependencies); +} From 3bad1fc328425e48ff0cd4f6f65f9cc07c84adf9 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 5 Oct 2020 12:44:29 -0600 Subject: [PATCH 11/14] Upgraded EUI to v29.3.0 (#78870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Upgraded EUI to v29.2.0 * Remove hacks on top of EuiHeaderLink(s) * Actual update to eui@29.2.0 * Resolve typescript issue when omitting a key over a union * Resolve nav menu test's expectations * Fix lint issue * Update to 29.3.0 * Revert vega snapshot update I don’t think this was intended to change when I ran the updater * [ui-shared-deps] strip proptypes from ui-shared-deps production build Co-authored-by: cchaos Co-authored-by: spalger --- package.json | 2 +- packages/kbn-ui-framework/package.json | 2 +- packages/kbn-ui-shared-deps/package.json | 3 +- packages/kbn-ui-shared-deps/webpack.config.js | 19 ++ .../public/top_nav_menu/_index.scss | 5 - .../public/top_nav_menu/top_nav_menu.test.tsx | 4 - .../public/top_nav_menu/top_nav_menu.tsx | 2 +- .../public/top_nav_menu/top_nav_menu_item.tsx | 2 +- src/plugins/share/public/types.ts | 6 +- .../plugins/kbn_tp_run_pipeline/package.json | 2 +- .../kbn_sample_panel_action/package.json | 2 +- .../kbn_tp_custom_visualizations/package.json | 2 +- x-pack/package.json | 2 +- .../color_picker_popover.stories.storyshot | 4 - .../shape_picker_popover.stories.storyshot | 3 - .../text_style_picker.stories.storyshot | 2 - .../__snapshots__/edit_menu.stories.storyshot | 6 - .../element_menu.stories.storyshot | 1 - .../share_menu.stories.storyshot | 1 - .../__snapshots__/view_menu.stories.storyshot | 4 - .../extended_template.stories.storyshot | 3 - .../__snapshots__/index.test.tsx.snap | 13 +- .../note_card_body.test.tsx.snap | 13 +- .../tests/csm/__snapshots__/page_views.snap | 280 ------------------ yarn.lock | 8 +- 25 files changed, 57 insertions(+), 334 deletions(-) delete mode 100644 x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap diff --git a/package.json b/package.json index b2252e2bd264b..0041736aaf5b2 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "dependencies": { "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "7.9.1", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", "@elastic/request-crypto": "1.1.4", diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json index 639d4e17d0e71..21d25311420ca 100644 --- a/packages/kbn-ui-framework/package.json +++ b/packages/kbn-ui-framework/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@babel/core": "^7.11.6", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/babel-preset": "1.0.0", "@kbn/optimizer": "1.0.0", "babel-loader": "^8.0.6", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index d2a590d29947b..719a60363a4dc 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "23.0.0", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", "@kbn/monaco": "1.0.0", @@ -39,6 +39,7 @@ "devDependencies": { "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "css-loader": "^3.4.2", "del": "^5.1.0", "loader-utils": "^1.2.3", diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index b7d4e929ac93f..986ddba209270 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -77,6 +77,25 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ }, ], }, + { + test: !dev ? /[\\\/]@elastic[\\\/]eui[\\\/].*\.js$/ : () => false, + use: [ + { + loader: 'babel-loader', + options: { + plugins: [ + [ + require.resolve('babel-plugin-transform-react-remove-prop-types'), + { + mode: 'remove', + removeImport: true, + }, + ], + ], + }, + }, + ], + }, ], }, diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 976ddd789ad22..230be399febda 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,8 +1,3 @@ .kbnTopNavMenu { margin-right: $euiSizeXS; } - -.kbnTopNavMenu > * > * { - // TEMP fix to adjust spacing between EuiHeaderList__list items - margin: 0 $euiSizeXS; -} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 212bc19208ca8..147feee3cd472 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -164,10 +164,6 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); - - const buttons = portalTarget.querySelectorAll('button'); - expect(buttons.length).toBe(menuItems.length + 1); // should be n+1 buttons in mobile for popover button - expect(buttons[buttons.length - 1].getAttribute('aria-label')).toBe('Open navigation menu'); // last button should be mobile button }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index a27addeb14393..1739b7d915adb 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -88,7 +88,7 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { function renderMenu(className: string): ReactElement | null { if (!config || config.length === 0) return null; return ( - + {renderItems()} ); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index e503ebb839f48..5c463902f77f5 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -52,7 +52,7 @@ export function TopNavMenuItem(props: TopNavMenuData) { {upperFirst(props.label || props.id!)} ) : ( - + {upperFirst(props.label || props.id!)} ); diff --git a/src/plugins/share/public/types.ts b/src/plugins/share/public/types.ts index 9dcfc3d9e8143..19f33a820a11a 100644 --- a/src/plugins/share/public/types.ts +++ b/src/plugins/share/public/types.ts @@ -18,7 +18,8 @@ */ import { ComponentType } from 'react'; -import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; /** * @public @@ -53,7 +54,8 @@ export interface ShareContext { * used to order the individual items in a flat list returned by all registered * menu providers. * */ -export interface ShareContextMenuPanelItem extends Omit { +export interface ShareContextMenuPanelItem + extends Omit { name: string; // EUI will accept a `ReactNode` for the `name` prop, but `ShareContentMenu` assumes a `string`. sortOrder?: number; } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index 87a1bc20920a4..0d6d0286c5a8f 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json index 8bbf6274bd15f..8efd2ee432415 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "react": "^16.12.0", "typescript": "4.0.2" } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index c0d9a03d02c32..4405063e54c06 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "typescript": "4.0.2" diff --git a/x-pack/package.json b/x-pack/package.json index ffe1a08855888..0222d198d4f91 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -275,7 +275,7 @@ "@babel/runtime": "^7.11.2", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.10.0", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", diff --git a/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot b/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot index 178cba0c99e4a..6cab47734039b 100644 --- a/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot @@ -4,7 +4,6 @@ exports[`Storyshots components/Color/ColorPickerPopover interactive 1`] = `
Date: Mon, 5 Oct 2020 20:46:51 +0200 Subject: [PATCH 12/14] [APM] Persist time range across apps (#79090) --- .../public/application/application.test.tsx | 7 ++ .../app/Home/__snapshots__/Home.test.tsx.snap | 20 ++++ .../shared/DatePicker/date_picker.test.tsx | 100 ++++++++++++++---- .../components/shared/DatePicker/index.tsx | 65 ++++++------ .../ApmPluginContext/MockApmPluginContext.tsx | 5 + 5 files changed, 145 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index 0566ff19017f4..3948b698fb482 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -42,6 +42,13 @@ describe('renderApp', () => { licensing: { license$: new Observable() }, triggers_actions_ui: { actionTypeRegistry: {}, alertTypeRegistry: {} }, usageCollection: { reportUiStats: () => {} }, + data: { + query: { + timefilter: { + timefilter: { setTime: () => {}, getTime: () => ({}) }, + }, + }, + }, }; const params = { element: document.createElement('div'), diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 00be0b37a0e82..0a22604837b97 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -53,6 +53,16 @@ exports[`Home component should render services 1`] = ` }, }, "plugins": Object { + "data": Object { + "query": Object { + "timefilter": Object { + "timefilter": Object { + "getTime": [Function], + "setTime": [Function], + }, + }, + }, + }, "ml": Object { "urlGenerator": MlUrlGenerator { "createUrl": [Function], @@ -126,6 +136,16 @@ exports[`Home component should render traces 1`] = ` }, }, "plugins": Object { + "data": Object { + "query": Object { + "timefilter": Object { + "timefilter": Object { + "getTime": [Function], + "setTime": [Function], + }, + }, + }, + }, "ml": Object { "urlGenerator": MlUrlGenerator { "createUrl": [Function], diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx index efa827a0e5df8..520cc2f423ddd 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx @@ -19,43 +19,64 @@ import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { DatePicker } from './'; const history = createMemoryHistory(); -const mockHistoryPush = jest.spyOn(history, 'push'); -const mockHistoryReplace = jest.spyOn(history, 'replace'); + const mockRefreshTimeRange = jest.fn(); function MockUrlParamsProvider({ - params = {}, + urlParams = {}, children, }: { children: ReactNode; - params?: IUrlParams; + urlParams?: IUrlParams; }) { return ( ); } -function mountDatePicker(params?: IUrlParams) { - return mount( - +function mountDatePicker(urlParams?: IUrlParams) { + const setTimeSpy = jest.fn(); + const getTimeSpy = jest.fn().mockReturnValue({}); + const wrapper = mount( + - + ); + + return { wrapper, setTimeSpy, getTimeSpy }; } describe('DatePicker', () => { + let mockHistoryPush: jest.SpyInstance; + let mockHistoryReplace: jest.SpyInstance; beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); }); afterAll(() => { @@ -76,16 +97,11 @@ describe('DatePicker', () => { ); }); - it('adds missing default value', () => { - mountDatePicker({ - rangeTo: 'now', - refreshInterval: 5000, - }); + it('adds missing `rangeFrom` to url', () => { + mountDatePicker({ rangeTo: 'now', refreshInterval: 5000 }); expect(mockHistoryReplace).toHaveBeenCalledTimes(1); expect(mockHistoryReplace).toHaveBeenCalledWith( - expect.objectContaining({ - search: 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000', - }) + expect.objectContaining({ search: 'rangeFrom=now-15m&rangeTo=now' }) ); }); @@ -100,9 +116,9 @@ describe('DatePicker', () => { }); it('updates the URL when the date range changes', () => { - const datePicker = mountDatePicker(); + const { wrapper } = mountDatePicker(); expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - datePicker.find(EuiSuperDatePicker).props().onTimeChange({ + wrapper.find(EuiSuperDatePicker).props().onTimeChange({ start: 'updated-start', end: 'updated-end', isInvalid: false, @@ -118,7 +134,7 @@ describe('DatePicker', () => { it('enables auto-refresh when refreshPaused is false', async () => { jest.useFakeTimers(); - const wrapper = mountDatePicker({ + const { wrapper } = mountDatePicker({ refreshPaused: false, refreshInterval: 1000, }); @@ -137,4 +153,46 @@ describe('DatePicker', () => { await waitFor(() => {}); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); }); + + describe('if both `rangeTo` and `rangeFrom` is set', () => { + it('calls setTime ', async () => { + const { setTimeSpy } = mountDatePicker({ + rangeTo: 'now-20m', + rangeFrom: 'now-22m', + }); + expect(setTimeSpy).toHaveBeenCalledWith({ + to: 'now-20m', + from: 'now-22m', + }); + }); + + it('does not update the url', () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + }); + + describe('if `rangeFrom` is missing from the urlParams', () => { + let setTimeSpy: jest.Mock; + beforeEach(() => { + const res = mountDatePicker({ rangeTo: 'now-5m' }); + setTimeSpy = res.setTimeSpy; + }); + + it('does not call setTime', async () => { + expect(setTimeSpy).toHaveBeenCalledTimes(0); + }); + + it('updates the url with the default `rangeFrom` ', async () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeFrom=now-15m' + ); + }); + + it('preserves `rangeTo`', () => { + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeTo=now-5m' + ); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index b4d716f89169e..f35cc06748911 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -5,8 +5,7 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import { isEmpty, isEqual, pickBy } from 'lodash'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; @@ -15,14 +14,10 @@ import { clearCache } from '../../../services/rest/callApi'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { TimePickerQuickRange, TimePickerTimeDefaults } from './typings'; -function removeUndefinedAndEmptyProps(obj: T): Partial { - return pickBy(obj, (value) => value !== undefined && !isEmpty(String(value))); -} - export function DatePicker() { const history = useHistory(); const location = useLocation(); - const { core } = useApmPluginContext(); + const { core, plugins } = useApmPluginContext(); const timePickerQuickRanges = core.uiSettings.get( UI_SETTINGS.TIMEPICKER_QUICK_RANGES @@ -32,11 +27,6 @@ export function DatePicker() { UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS ); - const DEFAULT_VALUES = { - rangeFrom: timePickerTimeDefaults.from, - rangeTo: timePickerTimeDefaults.to, - }; - const commonlyUsedRanges = timePickerQuickRanges.map( ({ from, to, display }) => ({ start: from, @@ -76,35 +66,48 @@ export function DatePicker() { updateUrl({ rangeFrom: start, rangeTo: end }); } - const { rangeFrom, rangeTo, refreshPaused, refreshInterval } = urlParams; - const timePickerURLParams = removeUndefinedAndEmptyProps({ - rangeFrom, - rangeTo, - refreshPaused, - refreshInterval, - }); + useEffect(() => { + // set time if both to and from are given in the url + if (urlParams.rangeFrom && urlParams.rangeTo) { + plugins.data.query.timefilter.timefilter.setTime({ + from: urlParams.rangeFrom, + to: urlParams.rangeTo, + }); + return; + } + + // read time from state and update the url + const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); - const nextParams = { - ...DEFAULT_VALUES, - ...timePickerURLParams, - }; - if (!isEqual(nextParams, timePickerURLParams)) { - // When the default parameters are not availbale in the url, replace it adding the necessary parameters. history.replace({ ...location, search: fromQuery({ ...toQuery(location.search), - ...nextParams, + rangeFrom: + urlParams.rangeFrom ?? + timePickerSharedState.from ?? + timePickerTimeDefaults.from, + rangeTo: + urlParams.rangeTo ?? + timePickerSharedState.to ?? + timePickerTimeDefaults.to, }), }); - } + }, [ + urlParams.rangeFrom, + urlParams.rangeTo, + plugins, + history, + location, + timePickerTimeDefaults, + ]); return ( { clearCache(); diff --git a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx index 65f6dca179e71..3b915045f54b6 100644 --- a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -87,6 +87,11 @@ const mockPlugin = { useHash: false, }), }, + data: { + query: { + timefilter: { timefilter: { setTime: () => {}, getTime: () => ({}) } }, + }, + }, }; export const mockApmPluginContextValue = { config: mockConfig, From a08fe39710fe698ab295f60db4b8ed48db5ceafc Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 5 Oct 2020 11:47:06 -0700 Subject: [PATCH 13/14] [build] only strip public code, leave other assets (#79504) Co-authored-by: spalger --- src/dev/build/tasks/copy_source_task.ts | 2 +- x-pack/tasks/build.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index a5039717760ae..da282f1940662 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -40,7 +40,7 @@ export const CopySource: Task = { '!src/dev/**', '!src/setup_node_env/babel_register/index.js', '!src/setup_node_env/babel_register/register.js', - '!**/public/**', + '!**/public/**/*.{js,ts,tsx,json}', 'typings/**', 'config/kibana.yml', 'config/node.options', diff --git a/x-pack/tasks/build.ts b/x-pack/tasks/build.ts index a3b08a16f4b08..aaeea9d14e385 100644 --- a/x-pack/tasks/build.ts +++ b/x-pack/tasks/build.ts @@ -73,7 +73,7 @@ async function copySourceAndBabelify() { '**/*.{test,test.mocks,mock,mocks}.*', '**/*.d.ts', '**/node_modules/**', - '**/public/**', + '**/public/**/*.{js,ts,tsx,json}', '**/{__tests__,__mocks__,__snapshots__}/**', 'plugins/canvas/shareable_runtime/test/**', ], From d5b8a956947f587a14a21eaf83821611160129c4 Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 5 Oct 2020 11:51:21 -0700 Subject: [PATCH 14/14] Temporarily remove Layout from App Search plugin for 7.10 release (#79506) - Since our other top level pages (settings, credentials, role mappings) aren't yet ready, we'll stick to the same UI for 7.9, only showing the Engines Overview to users --- .../applications/app_search/index.test.tsx | 14 +------ .../public/applications/app_search/index.tsx | 41 ++++++++++--------- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index ab5b3c9faeea7..3c7979ed3d4b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { Layout, SideNav, SideNavLink } from '../shared/layout'; +import { SideNav, SideNavLink } from '../shared/layout'; import { SetupGuide } from './components/setup_guide'; import { ErrorConnecting } from './components/error_connecting'; import { EngineOverview } from './components/engine_overview'; @@ -51,11 +51,9 @@ describe('AppSearchConfigured', () => { setMockActions({ initializeAppData: () => {} }); }); - it('renders with layout', () => { + it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(Layout)).toHaveLength(1); - expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(EngineOverview)).toHaveLength(1); }); @@ -86,14 +84,6 @@ describe('AppSearchConfigured', () => { expect(wrapper.find(ErrorConnecting)).toHaveLength(1); }); - it('passes readOnlyMode state', () => { - setMockValues({ myRole: {}, readOnlyMode: true }); - - const wrapper = shallow(); - - expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); - }); - describe('ability checks', () => { // TODO: Use this section for routes wrapped in canViewX conditionals // e.g., it('renders settings if a user can view settings') diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 9aa2cce9c74df..49e0a8a484de1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -8,6 +8,7 @@ import React, { useEffect } from 'react'; import { Route, Redirect, Switch } from 'react-router-dom'; import { useActions, useValues } from 'kea'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { getAppSearchUrl } from '../shared/enterprise_search_url'; @@ -17,7 +18,7 @@ import { AppLogic } from './app_logic'; import { IInitialAppData } from '../../../common/types'; import { APP_SEARCH_PLUGIN } from '../../../common/constants'; -import { Layout, SideNav, SideNavLink } from '../shared/layout'; +import { SideNav, SideNavLink } from '../shared/layout'; import { ROOT_PATH, @@ -52,7 +53,7 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC = (props) => { const { initializeAppData } = useActions(AppLogic); const { hasInitialized } = useValues(AppLogic); - const { errorConnecting, readOnlyMode } = useValues(HttpLogic); + const { errorConnecting } = useValues(HttpLogic); useEffect(() => { if (!hasInitialized) initializeAppData(props); @@ -64,23 +65,25 @@ export const AppSearchConfigured: React.FC = (props) => { - } readOnlyMode={readOnlyMode}> - {errorConnecting ? ( - - ) : ( - - - - - - - - - - - - )} - + + + {errorConnecting ? ( + + ) : ( + + + + + + + + + + + + )} + + );