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/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/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index c1ebeedf2f140..b0ace3c63d82e 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/**', // this is the dev-only entry '!src/setup_node_env/index.js', - '!**/public/**', + '!**/public/**/*.{js,ts,tsx,json}', 'typings/**', 'config/kibana.yml', 'config/node.options', 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/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/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/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, 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/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`] = `
( + + +

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/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot index 939440750c288..7a40bee5fedc3 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot @@ -3,7 +3,6 @@ exports[`Storyshots components/Shapes/ShapePickerPopover default 1`] = `
{ /(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/test/login_selector_api_integration/ftr_provider_context.d.ts b/x-pack/plugins/canvas/tasks/mocks/esService.ts similarity index 56% rename from x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts rename to x-pack/plugins/canvas/tasks/mocks/esService.ts index e3add3748f56d..a0c2a42eafd7c 100644 --- a/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts +++ b/x-pack/plugins/canvas/tasks/mocks/esService.ts @@ -4,8 +4,6 @@ * 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; +export function getDefaultIndex() { + return Promise.resolve('default-index'); +} 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/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 ? ( + + ) : ( + + + + + + + + + + + + )} + + ); diff --git a/x-pack/test/saml_api_integration/ftr_provider_context.d.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts similarity index 56% rename from x-pack/test/saml_api_integration/ftr_provider_context.d.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts index e3add3748f56d..82f1c9d8b8914 100644 --- a/x-pack/test/saml_api_integration/ftr_provider_context.d.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts @@ -4,8 +4,11 @@ * 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; +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); +} 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, }; 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/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/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 + ), }); }; 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/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'); }); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index af022fc3d525d..3df8663324fdd 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -66,7 +66,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiButtonHeight": "40px", "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { - "danger": "#ff6666", + "accent": "#f990c0", + "danger": "#ff7575", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -217,7 +218,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiDataGridColumnResizerWidth": "3px", "euiDataGridPopoverMaxHeight": "400px", "euiDataGridPrefix": ".euiDataGrid--", - "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'fontSizeSmall', 'fontSizeLarge', 'noControls'", + "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'footerShade', 'footerOverline', 'fontSizeSmall', 'fontSizeLarge', 'noControls', 'stickyFooter'", "euiDataGridVerticalBorder": "solid 1px #24272e", "euiDatePickerCalendarWidth": "284px", "euiDragAndDropSpacing": Object { @@ -292,9 +293,15 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiHeaderChildSize": "48px", "euiHeaderHeight": "48px", "euiHeaderHeightCompensation": "49px", + "euiHeaderLinksGutterSizes": Object { + "gutterL": "24px", + "gutterM": "12px", + "gutterS": "8px", + "gutterXS": "4px", + }, "euiIconColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "ghost": "#ffffff", "primary": "#1ba9f5", "secondary": "#7de2d1", diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap index 4eb320571a75b..10ad0123f7fc6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap @@ -66,7 +66,8 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiButtonHeight": "40px", "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { - "danger": "#ff6666", + "accent": "#f990c0", + "danger": "#ff7575", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -217,7 +218,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiDataGridColumnResizerWidth": "3px", "euiDataGridPopoverMaxHeight": "400px", "euiDataGridPrefix": ".euiDataGrid--", - "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'fontSizeSmall', 'fontSizeLarge', 'noControls'", + "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'footerShade', 'footerOverline', 'fontSizeSmall', 'fontSizeLarge', 'noControls', 'stickyFooter'", "euiDataGridVerticalBorder": "solid 1px #24272e", "euiDatePickerCalendarWidth": "284px", "euiDragAndDropSpacing": Object { @@ -292,9 +293,15 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiHeaderChildSize": "48px", "euiHeaderHeight": "48px", "euiHeaderHeightCompensation": "49px", + "euiHeaderLinksGutterSizes": Object { + "gutterL": "24px", + "gutterM": "12px", + "gutterS": "8px", + "gutterXS": "4px", + }, "euiIconColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "ghost": "#ffffff", "primary": "#1ba9f5", "secondary": "#7de2d1", 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); 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/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/**', ], 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'])); 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); }); }); diff --git a/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap b/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap deleted file mode 100644 index 38b009fc73d34..0000000000000 --- a/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap +++ /dev/null @@ -1,280 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CSM page views when there is data returns page views 1`] = ` -Object { - "items": Array [ - Object { - "x": 1600149947000, - "y": 1, - }, - Object { - "x": 1600149957000, - "y": 0, - }, - Object { - "x": 1600149967000, - "y": 0, - }, - Object { - "x": 1600149977000, - "y": 0, - }, - Object { - "x": 1600149987000, - "y": 0, - }, - Object { - "x": 1600149997000, - "y": 0, - }, - Object { - "x": 1600150007000, - "y": 0, - }, - Object { - "x": 1600150017000, - "y": 0, - }, - Object { - "x": 1600150027000, - "y": 1, - }, - Object { - "x": 1600150037000, - "y": 0, - }, - Object { - "x": 1600150047000, - "y": 0, - }, - Object { - "x": 1600150057000, - "y": 0, - }, - Object { - "x": 1600150067000, - "y": 0, - }, - Object { - "x": 1600150077000, - "y": 1, - }, - Object { - "x": 1600150087000, - "y": 0, - }, - Object { - "x": 1600150097000, - "y": 0, - }, - Object { - "x": 1600150107000, - "y": 0, - }, - Object { - "x": 1600150117000, - "y": 0, - }, - Object { - "x": 1600150127000, - "y": 0, - }, - Object { - "x": 1600150137000, - "y": 0, - }, - Object { - "x": 1600150147000, - "y": 0, - }, - Object { - "x": 1600150157000, - "y": 0, - }, - Object { - "x": 1600150167000, - "y": 0, - }, - Object { - "x": 1600150177000, - "y": 1, - }, - Object { - "x": 1600150187000, - "y": 0, - }, - Object { - "x": 1600150197000, - "y": 0, - }, - Object { - "x": 1600150207000, - "y": 1, - }, - Object { - "x": 1600150217000, - "y": 0, - }, - Object { - "x": 1600150227000, - "y": 0, - }, - Object { - "x": 1600150237000, - "y": 1, - }, - ], - "topItems": Array [], -} -`; - -exports[`CSM page views when there is data returns page views with breakdown 1`] = ` -Object { - "items": Array [ - Object { - "Chrome": 1, - "x": 1600149947000, - "y": 1, - }, - Object { - "x": 1600149957000, - "y": 0, - }, - Object { - "x": 1600149967000, - "y": 0, - }, - Object { - "x": 1600149977000, - "y": 0, - }, - Object { - "x": 1600149987000, - "y": 0, - }, - Object { - "x": 1600149997000, - "y": 0, - }, - Object { - "x": 1600150007000, - "y": 0, - }, - Object { - "x": 1600150017000, - "y": 0, - }, - Object { - "Chrome": 1, - "x": 1600150027000, - "y": 1, - }, - Object { - "x": 1600150037000, - "y": 0, - }, - Object { - "x": 1600150047000, - "y": 0, - }, - Object { - "x": 1600150057000, - "y": 0, - }, - Object { - "x": 1600150067000, - "y": 0, - }, - Object { - "Chrome": 1, - "x": 1600150077000, - "y": 1, - }, - Object { - "x": 1600150087000, - "y": 0, - }, - Object { - "x": 1600150097000, - "y": 0, - }, - Object { - "x": 1600150107000, - "y": 0, - }, - Object { - "x": 1600150117000, - "y": 0, - }, - Object { - "x": 1600150127000, - "y": 0, - }, - Object { - "x": 1600150137000, - "y": 0, - }, - Object { - "x": 1600150147000, - "y": 0, - }, - Object { - "x": 1600150157000, - "y": 0, - }, - Object { - "x": 1600150167000, - "y": 0, - }, - Object { - "Chrome": 1, - "x": 1600150177000, - "y": 1, - }, - Object { - "x": 1600150187000, - "y": 0, - }, - Object { - "x": 1600150197000, - "y": 0, - }, - Object { - "Chrome Mobile": 1, - "x": 1600150207000, - "y": 1, - }, - Object { - "x": 1600150217000, - "y": 0, - }, - Object { - "x": 1600150227000, - "y": 0, - }, - Object { - "Chrome Mobile": 1, - "x": 1600150237000, - "y": 1, - }, - ], - "topItems": Array [ - "Chrome", - "Chrome Mobile", - ], -} -`; - -exports[`CSM page views when there is no data returns empty list 1`] = ` -Object { - "items": Array [], - "topItems": Array [], -} -`; - -exports[`CSM page views when there is no data returns empty list with breakdowns 1`] = ` -Object { - "items": Array [], - "topItems": Array [], -} -`; 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/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')], 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); }); diff --git a/yarn.lock b/yarn.lock index fabd775570bb5..65568ef22f697 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1267,10 +1267,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@29.0.0": - version "29.0.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-29.0.0.tgz#1c8d822c62ad5e29298a3a36f5b02fd9b32a5550" - integrity sha512-YsDjtN/nRA4vvWukg5FDN4iPQgHUVxDwn/JZ1mArCeMe34JwzYJlEkk6Z/+iNbJOZQNHngmV8I2TStcP8k82gg== +"@elastic/eui@29.3.0": + version "29.3.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-29.3.0.tgz#5fd74110d9e3c9634566b37f5696947bce27c083" + integrity sha512-Ga/IsPXQajmYySliuGmux1UgqIQWNZssoCdT6ZGylZSVMdiKk+TJTh06eebGoTLrMXBJcNRV3JauQxeErQaarw== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160"