From 5f0e9f424838b38f5f432c04e37c53c02d3903c1 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 18 Jan 2021 14:14:42 -0600 Subject: [PATCH] [Workplace Search] Migrates Org Settings tree (#88092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial copy/paste of component tree Only does linting changes and lodash import * Replace withRouter HOC with hooks Use useLocation and no longer pass around history, using the KibanaLogic navigateToUrl method instead * Migrate LicenseCallout component * Update paths Also change name of component to OauthApplication as the default import was named that before * Remove conditional and passed in flash messages This is no longer needed with the Kibana syntax. Flash messages are set globally and only render when present. * Replace removed ConfirmModal In Kibana, we use the Eui components directly * Use internal tools for determining license * Fix a bunch of type issues * Remove telemetry settings section We no longer control telemetry in Workplace Search. It is handled by Kibana itself * Add SettingsSubNav component * Add route and update nav * Remove legacy AppView and sidenav * Clear flash messages globally * Remove global name change method call The global org name is not used anywhere outside of this section so we no longer need to update the global organization object as it is re-fetched when this section is entered. Previously, we displayed the org name in the sidebar but that has been removed in Kibana * Refactor saveUpdatedConfig We recently split out the logic from SourceLogic to the new AddSourceLogic and in settings, we were calling the saveSourceConfig method from the SourceLogic (now in AddSourceLogic) file and passing a callback that set the flash messages from that component’s state. Now, we set the flash messages globally and no longer need to have a saveUpdatedConfig method in the logic file, but can call it directly in the component and the flash messages will be set globally. * Update logic file to use global flash messages * Update server routes * Replace Rails http with kibana http * Fix subnav * Update routes to use consistent syntax We use this method across both Enterprise Search products in Kibana * Shorten nav item copy This would be the only place in the sidebar with a nav item breaking to a second line. * Fix some random typos * Replace React Router Link with helper * Add i18n * Remove redundant clearing of flash messages This happens automatically now in the global flash messages logic; route changes trigger clearing of messages * Add unit tests for components * Add tests for router * Store oauthApplication in mock for reuse * Add tests for SettingsLogic * Fix typo * Remove unncessary imports Copied from this PR: https://github.com/elastic/kibana/pull/88525 * Refactor to use new helpers when mocking See https://github.com/elastic/kibana/pull/88494 * Update logic test to use error helper See https://github.com/elastic/kibana/pull/88422 * Fix type issue * Fix whitespace lint issue Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__mocks__/content_sources.mock.ts | 9 + .../components/layout/nav.tsx | 9 +- .../shared/license_callout/index.ts | 7 + .../license_callout/license_callout.test.tsx | 21 ++ .../license_callout/license_callout.tsx | 46 ++++ .../workplace_search/constants.ts | 224 ++++++++++++++++- .../applications/workplace_search/index.tsx | 19 +- .../settings/components/connectors.test.tsx | 53 ++++ .../views/settings/components/connectors.tsx | 141 +++++++++++ .../settings/components/customize.test.tsx | 50 ++++ .../views/settings/components/customize.tsx | 63 +++++ .../components/oauth_application.test.tsx | 133 ++++++++++ .../settings/components/oauth_application.tsx | 203 +++++++++++++++ .../components/settings_sub_nav.test.tsx | 20 ++ .../settings/components/settings_sub_nav.tsx | 27 ++ .../components/source_config.test.tsx | 87 +++++++ .../settings/components/source_config.tsx | 80 ++++++ .../workplace_search/views/settings/index.ts | 7 + .../views/settings/settings_logic.test.ts | 234 ++++++++++++++++++ .../views/settings/settings_logic.ts | 197 +++++++++++++++ .../views/settings/settings_router.test.tsx | 49 ++++ .../views/settings/settings_router.tsx | 59 +++++ 22 files changed, 1729 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index edd69fa626b1..b3962a7c88cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -227,6 +227,15 @@ export const sourceConfigData = { }, }; +export const oauthApplication = { + name: 'app', + uid: '123uid123', + secret: 'shhhhhhhhh', + redirectUri: 'https://foo', + confidential: false, + nativeRedirectUri: 'https://bar', +}; + export const exampleResult = { sourceName: 'source', searchResultConfig: { 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 944820c4b1c4..8a83e9aad5fd 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 @@ -24,9 +24,14 @@ import { interface Props { sourcesSubNav?: React.ReactNode; groupsSubNav?: React.ReactNode; + settingsSubNav?: React.ReactNode; } -export const WorkplaceSearchNav: React.FC = ({ sourcesSubNav, groupsSubNav }) => ( +export const WorkplaceSearchNav: React.FC = ({ + sourcesSubNav, + groupsSubNav, + settingsSubNav, +}) => ( {NAV.OVERVIEW} @@ -43,7 +48,7 @@ export const WorkplaceSearchNav: React.FC = ({ sourcesSubNav, groupsSubNa {NAV.SECURITY} - + {NAV.SETTINGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/index.ts new file mode 100644 index 000000000000..706dc1d75d9c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/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 { LicenseCallout } from './license_callout'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.test.tsx new file mode 100644 index 000000000000..b3bbd850be50 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.test.tsx @@ -0,0 +1,21 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { EuiLink, EuiText } from '@elastic/eui'; + +import { LicenseCallout } from '.'; + +describe('LicenseCallout', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx new file mode 100644 index 000000000000..166925c50232 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx @@ -0,0 +1,46 @@ +/* + * 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 { EuiLink, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; + +import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; + +interface LicenseCalloutProps { + message?: string; +} + +export const LicenseCallout: React.FC = ({ message }) => { + const title = ( + <> + {message}{' '} + + Explore Platinum features + + + ); + + return ( +
+ + +
+ +
+
+ + {title} + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 4ca256ac91a3..6eedc9270b83 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -43,6 +43,21 @@ export const NAV = { SETTINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.settings', { defaultMessage: 'Settings', }), + SETTINGS_CUSTOMIZE: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.nav.settingsCustomize', + { + defaultMessage: 'Customize', + } + ), + SETTINGS_SOURCE_PRIORITIZATION: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.nav.settingsSourcePrioritization', + { + defaultMessage: 'Content source connectors', + } + ), + SETTINGS_OAUTH: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.settingsOauth', { + defaultMessage: 'OAuth application', + }), ADD_SOURCE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.addSource', { defaultMessage: 'Add Source', }), @@ -275,43 +290,240 @@ export const DOCUMENTATION_LINK_TITLE = i18n.translate( ); export const PUBLIC_KEY_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.publicKey.label', + 'xpack.enterpriseSearch.workplaceSearch.publicKey.label', { defaultMessage: 'Public Key', } ); export const CONSUMER_KEY_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.consumerKey.label', + 'xpack.enterpriseSearch.workplaceSearch.consumerKey.label', { defaultMessage: 'Consumer Key', } ); export const BASE_URI_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.baseUri.label', + 'xpack.enterpriseSearch.workplaceSearch.baseUri.label', { defaultMessage: 'Base URI', } ); export const BASE_URL_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.baseUrl.label', + 'xpack.enterpriseSearch.workplaceSearch.baseUrl.label', { defaultMessage: 'Base URL', } ); export const CLIENT_ID_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.clientId.label', + 'xpack.enterpriseSearch.workplaceSearch.clientId.label', { defaultMessage: 'Client id', } ); export const CLIENT_SECRET_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.clientSecret.label', + 'xpack.enterpriseSearch.workplaceSearch.clientSecret.label', { defaultMessage: 'Client secret', } ); + +export const CONFIDENTIAL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.confidential.label', + { + defaultMessage: 'Confidential', + } +); + +export const CONFIDENTIAL_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.confidential.text', + { + defaultMessage: + 'Deselect for environments in which the client secret cannot be kept confidential, such as native mobile apps and single page applications.', + } +); + +export const CREDENTIALS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.credentials.title', + { + defaultMessage: 'Credentials', + } +); + +export const CREDENTIALS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.credentials.description', + { + defaultMessage: + 'Use the following credentials within your client to request access tokens from our authentication server.', + } +); + +export const ORG_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.orgUpdated.message', + { + defaultMessage: 'Successfully updated organization.', + } +); + +export const OAUTH_APP_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.oauthAppUpdated.message', + { + defaultMessage: 'Successfully updated application.', + } +); + +export const SAVE_CHANGES_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.saveChanges.button', + { + defaultMessage: 'Save changes', + } +); + +export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.name.label', { + defaultMessage: 'Name', +}); + +export const OAUTH_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.oauth.description', + { + defaultMessage: 'Create an OAuth client for your organization.', + } +); + +export const OAUTH_PERSISTED_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.oauthPersisted.description', + { + defaultMessage: + "Access your organization's OAuth client credentials and manage OAuth settings.", + } +); + +export const REDIRECT_URIS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectURIs.label', + { + defaultMessage: 'Redirect URIs', + } +); + +export const REDIRECT_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectHelp.text', + { + defaultMessage: 'Provide one URI per line.', + } +); + +export const REDIRECT_NATIVE_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectNativeHelp.text', + { + defaultMessage: 'For local development URIs, use format', + } +); + +export const REDIRECT_SECURE_ERROR_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectSecureError.text', + { + defaultMessage: 'Cannot contain duplicate redirect URIs.', + } +); + +export const REDIRECT_INSECURE_ERROR_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectInsecureError.text', + { + defaultMessage: 'Using an insecure redirect URI (http) is not recommended.', + } +); + +export const LICENSE_MODAL_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.licenseModal.title', + { + defaultMessage: 'Configuring OAuth for Custom Search Applications', + } +); + +export const LICENSE_MODAL_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.licenseModal.description', + { + defaultMessage: + 'Configure an OAuth application for secure use of the Workplace Search Search API. Upgrade to a Platinum license to enable the Search API and create your OAuth application.', + } +); + +export const LICENSE_MODAL_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.licenseModal.link', + { + defaultMessage: 'Explore Platinum features', + } +); + +export const CUSTOMIZE_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.header.title', + { + defaultMessage: 'Customize Workplace Search', + } +); + +export const CUSTOMIZE_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.header.description', + { + defaultMessage: 'Personalize general organization settings.', + } +); + +export const CUSTOMIZE_NAME_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.name.label', + { + defaultMessage: 'Personalize general organization settings.', + } +); + +export const CUSTOMIZE_NAME_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.name.button', + { + defaultMessage: 'Save organization name', + } +); + +export const UPDATE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.update.button', + { + defaultMessage: 'Update', + } +); + +export const CONFIGURE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.configure.button', + { + defaultMessage: 'Configure', + } +); + +export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.privatePlatinumCallout.text', + { + defaultMessage: 'Private Sources require a Platinum license.', + } +); + +export const PRIVATE_SOURCE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.privateSource.text', + { + defaultMessage: 'Private Source', + } +); + +export const CONNECTORS_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.connectors.header.title', + { + defaultMessage: 'Content source connectors', + } +); + +export const CONNECTORS_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.connectors.header.description', + { + defaultMessage: 'All of your configurable connectors.', + } +); 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 562a2ffb3288..65a2c7a4a44d 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 @@ -16,7 +16,13 @@ import { AppLogic } from './app_logic'; import { Layout } from '../shared/layout'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; -import { GROUPS_PATH, SETUP_GUIDE_PATH, SOURCES_PATH, PERSONAL_SOURCES_PATH } from './routes'; +import { + GROUPS_PATH, + SETUP_GUIDE_PATH, + SOURCES_PATH, + PERSONAL_SOURCES_PATH, + ORG_SETTINGS_PATH, +} from './routes'; import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; @@ -24,9 +30,11 @@ import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; import { GroupsRouter } from './views/groups'; import { SourcesRouter } from './views/content_sources'; +import { SettingsRouter } from './views/settings'; import { GroupSubNav } from './views/groups/components/group_sub_nav'; import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; +import { SettingsSubNav } from './views/settings/components/settings_sub_nav'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -88,6 +96,15 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { + + } />} + restrictWidth + readOnlyMode={readOnlyMode} + > + + + } restrictWidth readOnlyMode={readOnlyMode}> {errorConnecting ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.test.tsx new file mode 100644 index 000000000000..0bc3d3dadd09 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import { configuredSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Loading } from '../../../../shared/loading'; +import { LicenseCallout } from '../../../components/shared/license_callout'; + +import { Connectors } from './connectors'; + +describe('Connectors', () => { + const initializeConnectors = jest.fn(); + + beforeEach(() => { + setMockActions({ initializeConnectors }); + setMockValues({ connectors: configuredSources }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="ConnectorRow"]')).toHaveLength(configuredSources.length); + }); + + it('returns loading when loading', () => { + setMockValues({ + connectors: configuredSources, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('renders LicenseCallout for restricted items', () => { + const wrapper = shallow(); + + const numUnsupportedAccountOnly = configuredSources.filter( + (s) => s.accountContextOnly && !s.supportedByLicense + ).length; + expect(wrapper.find(LicenseCallout)).toHaveLength(numUnsupportedAccountOnly); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx new file mode 100644 index 000000000000..4029809a07fe --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx @@ -0,0 +1,141 @@ +/* + * 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 { reject } from 'lodash'; + +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; + +import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers'; +import { Loading } from '../../../../shared/loading'; +import { SourceIcon } from '../../../components/shared/source_icon'; +import { LicenseCallout } from '../../../components/shared/license_callout'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; + +import { + CONFIGURE_BUTTON, + CONNECTORS_HEADER_TITLE, + CONNECTORS_HEADER_DESCRIPTION, + CUSTOM_SERVICE_TYPE, + PRIVATE_PLATINUM_LICENSE_CALLOUT, + PRIVATE_SOURCE, + UPDATE_BUTTON, +} from '../../../constants'; +import { getSourcesPath } from '../../../routes'; +import { SourceDataItem } from '../../../types'; + +import { staticSourceData } from '../../content_sources/source_data'; + +import { SettingsLogic } from '../settings_logic'; + +export const Connectors: React.FC = () => { + const { initializeConnectors } = useActions(SettingsLogic); + const { dataLoading, connectors } = useValues(SettingsLogic); + + useEffect(() => { + initializeConnectors(); + }, []); + + if (dataLoading) return ; + + const availableConnectors = reject( + connectors, + ({ serviceType }) => serviceType === CUSTOM_SERVICE_TYPE + ); + + const getRowActions = (configured: boolean, serviceType: string, supportedByLicense: boolean) => { + const { addPath, editPath } = staticSourceData.find( + (s) => s.serviceType === serviceType + ) as SourceDataItem; + const configurePath = getSourcesPath(addPath, true); + + const updateButtons = ( + + + + {UPDATE_BUTTON} + + + + ); + + const configureButton = supportedByLicense ? ( + + {CONFIGURE_BUTTON} + + ) : ( + + {CONFIGURE_BUTTON} + + ); + + return configured ? updateButtons : configureButton; + }; + + const platinumLicenseCallout = ; + + const connectorsList = ( + + {availableConnectors.map( + ({ serviceType, name, configured, accountContextOnly, supportedByLicense }) => ( + + + + + + + + + + + {name} +    + {accountContextOnly && {PRIVATE_SOURCE}} + + + + + + {getRowActions(configured, serviceType, supportedByLicense)} + + + {accountContextOnly && !supportedByLicense && ( + + + + {platinumLicenseCallout} + + + )} + + ) + )} + + ); + + return ( + <> + + {connectorsList} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx new file mode 100644 index 000000000000..4db0a60b75ee --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiFieldText } from '@elastic/eui'; + +import { ContentSection } from '../../../components/shared/content_section'; + +import { Customize } from './customize'; + +describe('Customize', () => { + const onOrgNameInputChange = jest.fn(); + const updateOrgName = jest.fn(); + + beforeEach(() => { + setMockActions({ onOrgNameInputChange, updateOrgName }); + setMockValues({ orgNameInputValue: '' }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ContentSection)).toHaveLength(1); + }); + + it('handles input change', () => { + const wrapper = shallow(); + const input = wrapper.find(EuiFieldText); + input.simulate('change', { target: { value: 'foobar' } }); + + expect(onOrgNameInputChange).toHaveBeenCalledWith('foobar'); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(updateOrgName).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx new file mode 100644 index 000000000000..b87e00965f55 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FormEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiButton, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; + +import { + CUSTOMIZE_HEADER_TITLE, + CUSTOMIZE_HEADER_DESCRIPTION, + CUSTOMIZE_NAME_LABEL, + CUSTOMIZE_NAME_BUTTON, +} from '../../../constants'; + +import { ContentSection } from '../../../components/shared/content_section'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; + +import { SettingsLogic } from '../settings_logic'; + +export const Customize: React.FC = () => { + const { onOrgNameInputChange, updateOrgName } = useActions(SettingsLogic); + const { orgNameInputValue } = useValues(SettingsLogic); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + updateOrgName(); + }; + + return ( +
+ + + + + + onOrgNameInputChange(e.target.value)} + /> + + + + + + + {CUSTOMIZE_NAME_BUTTON} + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx new file mode 100644 index 000000000000..ec831492ee90 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx @@ -0,0 +1,133 @@ +/* + * 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 '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiModal, EuiForm } from '@elastic/eui'; + +import { oauthApplication } from '../../../__mocks__/content_sources.mock'; +import { OAUTH_DESCRIPTION, REDIRECT_INSECURE_ERROR_TEXT } from '../../../constants'; + +import { CredentialItem } from '../../../components/shared/credential_item'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { OauthApplication } from './oauth_application'; + +describe('OauthApplication', () => { + const setOauthApplication = jest.fn(); + const updateOauthApplication = jest.fn(); + + beforeEach(() => { + setMockValues({ hasPlatinumLicense: true, oauthApplication }); + setMockActions({ setOauthApplication, updateOauthApplication }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiForm)).toHaveLength(1); + }); + + it('does not render when no oauthApp', () => { + setMockValues({ hasPlatinumLicense: true, oauthApplication: undefined }); + const wrapper = shallow(); + + expect(wrapper.find(EuiForm)).toHaveLength(0); + }); + + it('handles OAuthAppName change', () => { + const wrapper = shallow(); + const input = wrapper.find('[data-test-subj="OAuthAppName"]'); + input.simulate('change', { target: { value: 'foo' } }); + + expect(setOauthApplication).toHaveBeenCalledWith({ ...oauthApplication, name: 'foo' }); + }); + + it('handles RedirectURIsTextArea change', () => { + const wrapper = shallow(); + const input = wrapper.find('[data-test-subj="RedirectURIsTextArea"]'); + input.simulate('change', { target: { value: 'bar' } }); + + expect(setOauthApplication).toHaveBeenCalledWith({ + ...oauthApplication, + redirectUri: 'bar', + }); + }); + + it('handles ConfidentialToggle change', () => { + const wrapper = shallow(); + const input = wrapper.find('[data-test-subj="ConfidentialToggle"]'); + input.simulate('change', { target: { checked: true } }); + + expect(setOauthApplication).toHaveBeenCalledWith({ + ...oauthApplication, + confidential: true, + }); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(updateOauthApplication).toHaveBeenCalled(); + }); + + it('renders ClientSecret on confidential item', () => { + setMockValues({ + hasPlatinumLicense: true, + oauthApplication: { + ...oauthApplication, + confidential: true, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(CredentialItem)).toHaveLength(2); + }); + + it('renders license modal', () => { + setMockValues({ + hasPlatinumLicense: false, + oauthApplication, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiModal)).toHaveLength(1); + }); + + it('closes license modal', () => { + setMockValues({ + hasPlatinumLicense: false, + oauthApplication, + }); + const wrapper = shallow(); + wrapper.find(EuiModal).prop('onClose')(); + + expect(wrapper.find(EuiModal)).toHaveLength(0); + }); + + it('handles conditional copy', () => { + setMockValues({ + hasPlatinumLicense: true, + oauthApplication: { + ...oauthApplication, + uid: undefined, + redirectUri: 'http://foo', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual(OAUTH_DESCRIPTION); + expect(wrapper.find('[data-test-subj="RedirectURIsRow"]').prop('error')).toEqual( + REDIRECT_INSECURE_ERROR_TEXT + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx new file mode 100644 index 000000000000..b648b2577963 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FormEvent, useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiTextArea, + EuiSwitch, + EuiCode, + EuiSpacer, + EuiOverlayMask, + EuiLink, + EuiModal, + EuiModalBody, + EuiTitle, + EuiText, +} from '@elastic/eui'; + +import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; +import { + CLIENT_ID_LABEL, + CLIENT_SECRET_LABEL, + CONFIDENTIAL_HELP_TEXT, + CONFIDENTIAL_LABEL, + CREDENTIALS_TITLE, + CREDENTIALS_DESCRIPTION, + NAME_LABEL, + NAV, + OAUTH_DESCRIPTION, + OAUTH_PERSISTED_DESCRIPTION, + REDIRECT_HELP_TEXT, + REDIRECT_NATIVE_HELP_TEXT, + REDIRECT_INSECURE_ERROR_TEXT, + REDIRECT_SECURE_ERROR_TEXT, + REDIRECT_URIS_LABEL, + SAVE_CHANGES_BUTTON, + LICENSE_MODAL_TITLE, + LICENSE_MODAL_DESCRIPTION, + LICENSE_MODAL_LINK, +} from '../../../constants'; + +import { LicensingLogic } from '../../../../shared/licensing'; +import { ContentSection } from '../../../components/shared/content_section'; +import { LicenseBadge } from '../../../components/shared/license_badge'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { CredentialItem } from '../../../components/shared/credential_item'; +import { SettingsLogic } from '../settings_logic'; + +export const OauthApplication: React.FC = () => { + const { setOauthApplication, updateOauthApplication } = useActions(SettingsLogic); + const { oauthApplication } = useValues(SettingsLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + + const [isLicenseModalVisible, setIsLicenseModalVisible] = useState(!hasPlatinumLicense); + const closeLicenseModal = () => setIsLicenseModalVisible(false); + + if (!oauthApplication) return null; + + const persisted = !!(oauthApplication.uid && oauthApplication.secret); + const description = persisted ? OAUTH_PERSISTED_DESCRIPTION : OAUTH_DESCRIPTION; + const insecureRedirectUri = /(^|\s)http:/i.test(oauthApplication.redirectUri); + const redirectUris = oauthApplication.redirectUri.split('\n').map((uri) => uri.trim()); + const uniqRedirectUri = Array.from(new Set(redirectUris)); + const redirectUriInvalid = insecureRedirectUri || redirectUris.length !== uniqRedirectUri.length; + + const redirectUriHelpText = ( + + {REDIRECT_HELP_TEXT}{' '} + {oauthApplication.nativeRedirectUri && ( + + {REDIRECT_NATIVE_HELP_TEXT} {oauthApplication.nativeRedirectUri} + + )} + + ); + + const redirectErrorText = insecureRedirectUri + ? REDIRECT_INSECURE_ERROR_TEXT + : REDIRECT_SECURE_ERROR_TEXT; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + updateOauthApplication(); + }; + + const licenseModal = ( + + + + + + + +

{LICENSE_MODAL_TITLE}

+
+ + {LICENSE_MODAL_DESCRIPTION} + + + {LICENSE_MODAL_LINK} + + +
+
+
+ ); + + return ( + <> +
+ + + + + setOauthApplication({ ...oauthApplication, name: e.target.value })} + required + disabled={!hasPlatinumLicense} + /> + + + + + setOauthApplication({ ...oauthApplication, redirectUri: e.target.value }) + } + required + disabled={!hasPlatinumLicense} + /> + + + + + setOauthApplication({ ...oauthApplication, confidential: e.target.checked }) + } + disabled={!hasPlatinumLicense} + /> + + + + {SAVE_CHANGES_BUTTON} + + + {persisted && ( + + + + + + {oauthApplication.confidential && ( + <> + + + + + + )} + + )} + +
+ + {isLicenseModalVisible && licenseModal} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.test.tsx new file mode 100644 index 000000000000..3a2d4833da68 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.test.tsx @@ -0,0 +1,20 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { SettingsSubNav } from './settings_sub_nav'; + +describe('SettingsSubNav', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SideNavLink)).toHaveLength(3); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.tsx new file mode 100644 index 000000000000..794718044b9f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.tsx @@ -0,0 +1,27 @@ +/* + * 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 { NAV } from '../../../constants'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { + ORG_SETTINGS_CUSTOMIZE_PATH, + ORG_SETTINGS_CONNECTORS_PATH, + ORG_SETTINGS_OAUTH_APPLICATION_PATH, +} from '../../../routes'; + +export const SettingsSubNav: React.FC = () => ( + <> + {NAV.SETTINGS_CUSTOMIZE} + + {NAV.SETTINGS_SOURCE_PRIORITIZATION} + + {NAV.SETTINGS_OAUTH} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx new file mode 100644 index 000000000000..2ecbc2502502 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiConfirmModal } from '@elastic/eui'; + +import { sourceConfigData } from '../../../__mocks__/content_sources.mock'; + +import { Loading } from '../../../../shared/loading'; +import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { SourceConfig } from './source_config'; + +describe('SourceConfig', () => { + const deleteSourceConfig = jest.fn(); + const getSourceConfigData = jest.fn(); + const saveSourceConfig = jest.fn(); + + beforeEach(() => { + setMockValues({ sourceConfigData, dataLoading: false }); + setMockActions({ deleteSourceConfig, getSourceConfigData, saveSourceConfig }); + }); + + it('renders', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); + }); + + it('returns loading when loading', () => { + setMockValues({ + sourceConfigData, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('handles delete click', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + wrapper.find(EuiConfirmModal).prop('onConfirm')!({} as any); + + expect(deleteSourceConfig).toHaveBeenCalled(); + }); + + it('saves source config', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + saveConfig.prop('advanceStep')!(); + + expect(saveSourceConfig).toHaveBeenCalled(); + }); + + it('cancels and closes modal', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + wrapper.find(EuiConfirmModal).prop('onCancel')!({} as any); + + expect(wrapper.find(EuiConfirmModal)).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx new file mode 100644 index 000000000000..52e87311284f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -0,0 +1,80 @@ +/* + * 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, useState } from 'react'; + +import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +import { Loading } from '../../../../shared/loading'; +import { SourceDataItem } from '../../../types'; +import { staticSourceData } from '../../content_sources/source_data'; +import { SourceLogic } from '../../content_sources/source_logic'; +import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; + +import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; +import { SaveConfig } from '../../content_sources/components/add_source/save_config'; + +import { SettingsLogic } from '../settings_logic'; + +interface SourceConfigProps { + sourceIndex: number; +} + +export const SourceConfig: React.FC = ({ sourceIndex }) => { + const [confirmModalVisible, setConfirmModalVisibility] = useState(false); + const { configuration, serviceType } = staticSourceData[sourceIndex] as SourceDataItem; + const { deleteSourceConfig } = useActions(SettingsLogic); + const { getSourceConfigData } = useActions(SourceLogic); + const { saveSourceConfig } = useActions(AddSourceLogic); + const { + sourceConfigData: { name, categories }, + dataLoading: sourceDataLoading, + } = useValues(SourceLogic); + + useEffect(() => { + getSourceConfigData(serviceType); + }, []); + + if (sourceDataLoading) return ; + const hideConfirmModal = () => setConfirmModalVisibility(false); + const showConfirmModal = () => setConfirmModalVisibility(true); + const saveUpdatedConfig = () => saveSourceConfig(true); + + const header = ; + + return ( + <> + + {confirmModalVisible && ( + + deleteSourceConfig(serviceType, name)} + onCancel={hideConfirmModal} + buttonColor="danger" + > + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.confirmRemoveConfig.message', + { + defaultMessage: + 'Are you sure you want to remove the OAuth configuration for {name}?', + values: { name }, + } + )} + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/index.ts new file mode 100644 index 000000000000..cfea0684c105 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/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 { SettingsRouter } from './settings_router'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts new file mode 100644 index 000000000000..aaeae08d552d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LogicMounter } from '../../../__mocks__/kea.mock'; + +import { + mockFlashMessageHelpers, + mockHttpValues, + expectedAsyncError, + mockKibanaValues, +} from '../../../__mocks__'; + +import { configuredSources, oauthApplication } from '../../__mocks__/content_sources.mock'; + +import { ORG_UPDATED_MESSAGE, OAUTH_APP_UPDATED_MESSAGE } from '../../constants'; +import { SettingsLogic } from './settings_logic'; + +describe('SettingsLogic', () => { + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { + clearFlashMessages, + flashAPIErrors, + setSuccessMessage, + setQueuedSuccessMessage, + } = mockFlashMessageHelpers; + const { mount } = new LogicMounter(SettingsLogic); + const ORG_NAME = 'myOrg'; + const defaultValues = { + dataLoading: true, + connectors: [], + orgNameInputValue: '', + oauthApplication: null, + }; + const serverProps = { organizationName: ORG_NAME, oauthApplication }; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(SettingsLogic.values).toEqual(defaultValues); + }); + + describe('actions', () => { + it('onInitializeConnectors', () => { + SettingsLogic.actions.onInitializeConnectors(configuredSources); + }); + + it('onOrgNameInputChange', () => { + const NAME = 'foo'; + SettingsLogic.actions.onOrgNameInputChange(NAME); + + expect(SettingsLogic.values.orgNameInputValue).toEqual(NAME); + }); + + it('setUpdatedName', () => { + const NAME = 'bar'; + SettingsLogic.actions.setUpdatedName({ organizationName: NAME }); + + expect(SettingsLogic.values.orgNameInputValue).toEqual(NAME); + }); + + it('setServerProps', () => { + SettingsLogic.actions.setServerProps(serverProps); + + expect(SettingsLogic.values.orgNameInputValue).toEqual(ORG_NAME); + expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); + }); + + it('setOauthApplication', () => { + SettingsLogic.actions.setOauthApplication(oauthApplication); + + expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); + }); + + it('setUpdatedOauthApplication', () => { + SettingsLogic.actions.setUpdatedOauthApplication({ oauthApplication }); + + expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); + }); + }); + + describe('listeners', () => { + describe('initializeSettings', () => { + it('calls API and sets values', async () => { + const setServerPropsSpy = jest.spyOn(SettingsLogic.actions, 'setServerProps'); + const promise = Promise.resolve(configuredSources); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeSettings(); + + expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings'); + await promise; + expect(setServerPropsSpy).toHaveBeenCalledWith(configuredSources); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeSettings(); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('initializeConnectors', () => { + it('calls API and sets values', async () => { + const onInitializeConnectorsSpy = jest.spyOn( + SettingsLogic.actions, + 'onInitializeConnectors' + ); + const promise = Promise.resolve(serverProps); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeConnectors(); + + expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings/connectors'); + await promise; + expect(onInitializeConnectorsSpy).toHaveBeenCalledWith(serverProps); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeConnectors(); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('updateOrgName', () => { + it('calls API and sets values', async () => { + const NAME = 'updated name'; + SettingsLogic.actions.onOrgNameInputChange(NAME); + const setUpdatedNameSpy = jest.spyOn(SettingsLogic.actions, 'setUpdatedName'); + const promise = Promise.resolve({ organizationName: NAME }); + http.put.mockReturnValue(promise); + + SettingsLogic.actions.updateOrgName(); + + expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/customize', { + body: JSON.stringify({ name: NAME }), + }); + await promise; + expect(setSuccessMessage).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(setUpdatedNameSpy).toHaveBeenCalledWith({ organizationName: NAME }); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.put.mockReturnValue(promise); + SettingsLogic.actions.updateOrgName(); + + await expectedAsyncError(promise); + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('updateOauthApplication', () => { + it('calls API and sets values', async () => { + const { name, redirectUri, confidential } = oauthApplication; + const setUpdatedOauthApplicationSpy = jest.spyOn( + SettingsLogic.actions, + 'setUpdatedOauthApplication' + ); + const promise = Promise.resolve({ oauthApplication }); + http.put.mockReturnValue(promise); + SettingsLogic.actions.setOauthApplication(oauthApplication); + SettingsLogic.actions.updateOauthApplication(); + + expect(clearFlashMessages).toHaveBeenCalled(); + + expect(http.put).toHaveBeenCalledWith( + '/api/workplace_search/org/settings/oauth_application', + { + body: JSON.stringify({ + oauth_application: { name, confidential, redirect_uri: redirectUri }, + }), + } + ); + await promise; + expect(setUpdatedOauthApplicationSpy).toHaveBeenCalledWith({ oauthApplication }); + expect(setSuccessMessage).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.put.mockReturnValue(promise); + SettingsLogic.actions.updateOauthApplication(); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('deleteSourceConfig', () => { + const SERVICE_TYPE = 'github'; + const NAME = 'baz'; + + it('calls API and sets values', async () => { + const promise = Promise.resolve({}); + http.delete.mockReturnValue(promise); + SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); + + await promise; + expect(navigateToUrl).toHaveBeenCalledWith('/settings/connectors'); + expect(setQueuedSuccessMessage).toHaveBeenCalled(); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.delete.mockReturnValue(promise); + SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + it('resetSettingsState', () => { + // needed to set dataLoading to false + SettingsLogic.actions.onInitializeConnectors(configuredSources); + SettingsLogic.actions.resetSettingsState(); + + expect(clearFlashMessages).toHaveBeenCalled(); + expect(SettingsLogic.values.dataLoading).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts new file mode 100644 index 000000000000..b5370ec09f67 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts @@ -0,0 +1,197 @@ +/* + * 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 { + clearFlashMessages, + setQueuedSuccessMessage, + setSuccessMessage, + flashAPIErrors, +} from '../../../shared/flash_messages'; +import { KibanaLogic } from '../../../shared/kibana'; +import { HttpLogic } from '../../../shared/http'; + +import { Connector } from '../../types'; +import { ORG_UPDATED_MESSAGE, OAUTH_APP_UPDATED_MESSAGE } from '../../constants'; + +import { ORG_SETTINGS_CONNECTORS_PATH } from '../../routes'; + +interface IOauthApplication { + name: string; + uid: string; + secret: string; + redirectUri: string; + confidential: boolean; + nativeRedirectUri: string; +} + +export interface SettingsServerProps { + organizationName: string; + oauthApplication: IOauthApplication; +} + +interface SettingsActions { + onInitializeConnectors(connectors: Connector[]): Connector[]; + onOrgNameInputChange(orgNameInputValue: string): string; + setUpdatedName({ organizationName }: { organizationName: string }): string; + setServerProps(props: SettingsServerProps): SettingsServerProps; + setOauthApplication(oauthApplication: IOauthApplication): IOauthApplication; + setUpdatedOauthApplication({ + oauthApplication, + }: { + oauthApplication: IOauthApplication; + }): IOauthApplication; + resetSettingsState(): void; + initializeSettings(): void; + initializeConnectors(): void; + updateOauthApplication(): void; + updateOrgName(): void; + deleteSourceConfig( + serviceType: string, + name: string + ): { + serviceType: string; + name: string; + }; +} + +interface SettingsValues { + dataLoading: boolean; + connectors: Connector[]; + orgNameInputValue: string; + oauthApplication: IOauthApplication | null; +} + +export const SettingsLogic = kea>({ + actions: { + onInitializeConnectors: (connectors: Connector[]) => connectors, + onOrgNameInputChange: (orgNameInputValue: string) => orgNameInputValue, + setUpdatedName: ({ organizationName }) => organizationName, + setServerProps: (props: SettingsServerProps) => props, + setOauthApplication: (oauthApplication: IOauthApplication) => oauthApplication, + setUpdatedOauthApplication: ({ oauthApplication }: { oauthApplication: IOauthApplication }) => + oauthApplication, + resetSettingsState: () => true, + initializeSettings: () => true, + initializeConnectors: () => true, + updateOrgName: () => true, + updateOauthApplication: () => true, + deleteSourceConfig: (serviceType: string, name: string) => ({ + serviceType, + name, + }), + }, + reducers: { + connectors: [ + [], + { + onInitializeConnectors: (_, connectors) => connectors, + }, + ], + orgNameInputValue: [ + '', + { + setServerProps: (_, { organizationName }) => organizationName, + onOrgNameInputChange: (_, orgNameInputValue) => orgNameInputValue, + setUpdatedName: (_, organizationName) => organizationName, + }, + ], + oauthApplication: [ + null, + { + setServerProps: (_, { oauthApplication }) => oauthApplication, + setOauthApplication: (_, oauthApplication) => oauthApplication, + setUpdatedOauthApplication: (_, oauthApplication) => oauthApplication, + }, + ], + dataLoading: [ + true, + { + onInitializeConnectors: () => false, + resetSettingsState: () => true, + }, + ], + }, + listeners: ({ actions, values }) => ({ + initializeSettings: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings'; + + try { + const response = await http.get(route); + actions.setServerProps(response); + } catch (e) { + flashAPIErrors(e); + } + }, + initializeConnectors: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings/connectors'; + + try { + const response = await http.get(route); + actions.onInitializeConnectors(response); + } catch (e) { + flashAPIErrors(e); + } + }, + updateOrgName: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings/customize'; + const { orgNameInputValue: name } = values; + const body = JSON.stringify({ name }); + + try { + const response = await http.put(route, { body }); + actions.setUpdatedName(response); + setSuccessMessage(ORG_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + updateOauthApplication: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings/oauth_application'; + const oauthApplication = values.oauthApplication || ({} as IOauthApplication); + const { name, redirectUri, confidential } = oauthApplication; + const body = JSON.stringify({ + oauth_application: { name, confidential, redirect_uri: redirectUri }, + }); + + clearFlashMessages(); + + try { + const response = await http.put(route, { body }); + actions.setUpdatedOauthApplication(response); + setSuccessMessage(OAUTH_APP_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + deleteSourceConfig: async ({ serviceType, name }) => { + const { http } = HttpLogic.values; + const route = `/api/workplace_search/org/settings/connectors/${serviceType}`; + + try { + await http.delete(route); + KibanaLogic.values.navigateToUrl(ORG_SETTINGS_CONNECTORS_PATH); + setQueuedSuccessMessage( + i18n.translate('xpack.enterpriseSearch.workplaceSearch.settings.configRemoved.message', { + defaultMessage: 'Successfully removed configuration for {name}.', + values: { name }, + }) + ); + } catch (e) { + flashAPIErrors(e); + } + }, + resetSettingsState: () => { + clearFlashMessages(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx new file mode 100644 index 000000000000..315f6c4561cc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockActions } from '../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Route, Redirect, Switch } from 'react-router-dom'; + +import { staticSourceData } from '../content_sources/source_data'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { Connectors } from './components/connectors'; +import { Customize } from './components/customize'; +import { OauthApplication } from './components/oauth_application'; +import { SourceConfig } from './components/source_config'; + +import { SettingsRouter } from './settings_router'; + +describe('SettingsRouter', () => { + const initializeSettings = jest.fn(); + const NUM_SOURCES = staticSourceData.length; + // Should be 3 routes other than the sources listed Connectors, Customize, & OauthApplication + const NUM_ROUTES = NUM_SOURCES + 3; + + beforeEach(() => { + setMockActions({ initializeSettings }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(FlashMessages)).toHaveLength(1); + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(NUM_ROUTES); + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(Connectors)).toHaveLength(1); + expect(wrapper.find(Customize)).toHaveLength(1); + expect(wrapper.find(OauthApplication)).toHaveLength(1); + expect(wrapper.find(SourceConfig)).toHaveLength(NUM_SOURCES); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx new file mode 100644 index 000000000000..c5df8b3c7aa2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.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, { useEffect } from 'react'; + +import { useActions } from 'kea'; +import { Redirect, Route, Switch } from 'react-router-dom'; + +import { + ORG_SETTINGS_PATH, + ORG_SETTINGS_CUSTOMIZE_PATH, + ORG_SETTINGS_CONNECTORS_PATH, + ORG_SETTINGS_OAUTH_APPLICATION_PATH, +} from '../../routes'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { Connectors } from './components/connectors'; +import { Customize } from './components/customize'; +import { OauthApplication } from './components/oauth_application'; +import { SourceConfig } from './components/source_config'; + +import { staticSourceData } from '../content_sources/source_data'; + +import { SettingsLogic } from './settings_logic'; + +export const SettingsRouter: React.FC = () => { + const { initializeSettings } = useActions(SettingsLogic); + + useEffect(() => { + initializeSettings(); + }, []); + + return ( + <> + + + + + + + + + + + + + {staticSourceData.map(({ editPath }, i) => ( + + + + ))} + + + ); +};