[Workplace Search] Migrates Org Settings tree (#88092)

* 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>
This commit is contained in:
Scotty Bollinger 2021-01-18 14:14:42 -06:00 committed by GitHub
parent 89dc4f2e00
commit 5f0e9f4248
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1729 additions and 9 deletions

View file

@ -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: {

View file

@ -24,9 +24,14 @@ import {
interface Props {
sourcesSubNav?: React.ReactNode;
groupsSubNav?: React.ReactNode;
settingsSubNav?: React.ReactNode;
}
export const WorkplaceSearchNav: React.FC<Props> = ({ sourcesSubNav, groupsSubNav }) => (
export const WorkplaceSearchNav: React.FC<Props> = ({
sourcesSubNav,
groupsSubNav,
settingsSubNav,
}) => (
<SideNav product={WORKPLACE_SEARCH_PLUGIN}>
<SideNavLink to="/" isRoot>
{NAV.OVERVIEW}
@ -43,7 +48,7 @@ export const WorkplaceSearchNav: React.FC<Props> = ({ sourcesSubNav, groupsSubNa
<SideNavLink isExternal to={getWorkplaceSearchUrl(`#${SECURITY_PATH}`)}>
{NAV.SECURITY}
</SideNavLink>
<SideNavLink isExternal to={getWorkplaceSearchUrl(ORG_SETTINGS_PATH)}>
<SideNavLink subNav={settingsSubNav} to={ORG_SETTINGS_PATH}>
{NAV.SETTINGS}
</SideNavLink>
<EuiSpacer />

View file

@ -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';

View file

@ -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(<LicenseCallout message="foo" />);
expect(wrapper.find(EuiLink)).toHaveLength(1);
expect(wrapper.find(EuiText)).toHaveLength(1);
});
});

View file

@ -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<LicenseCalloutProps> = ({ message }) => {
const title = (
<>
{message}{' '}
<EuiLink
className="wsLicenseLink"
target="_blank"
external
href={ENT_SEARCH_LICENSE_MANAGEMENT}
>
<strong>Explore Platinum features</strong>
</EuiLink>
</>
);
return (
<div className="wsLicenseCallout">
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<div className="wsLicenseIcon">
<strong>&#8593;</strong>
</div>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs">{title}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};

View file

@ -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.',
}
);

View file

@ -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<InitialAppData> = (props) => {
const { config } = useValues(KibanaLogic);
@ -88,6 +96,15 @@ export const WorkplaceSearchConfigured: React.FC<InitialAppData> = (props) => {
<GroupsRouter />
</Layout>
</Route>
<Route path={ORG_SETTINGS_PATH}>
<Layout
navigation={<WorkplaceSearchNav settingsSubNav={<SettingsSubNav />} />}
restrictWidth
readOnlyMode={readOnlyMode}
>
<SettingsRouter />
</Layout>
</Route>
<Route>
<Layout navigation={<WorkplaceSearchNav />} restrictWidth readOnlyMode={readOnlyMode}>
{errorConnecting ? (

View file

@ -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(<Connectors />);
expect(wrapper.find('[data-test-subj="ConnectorRow"]')).toHaveLength(configuredSources.length);
});
it('returns loading when loading', () => {
setMockValues({
connectors: configuredSources,
dataLoading: true,
});
const wrapper = shallow(<Connectors />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
it('renders LicenseCallout for restricted items', () => {
const wrapper = shallow(<Connectors />);
const numUnsupportedAccountOnly = configuredSources.filter(
(s) => s.accountContextOnly && !s.supportedByLicense
).length;
expect(wrapper.find(LicenseCallout)).toHaveLength(numUnsupportedAccountOnly);
});
});

View file

@ -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 <Loading />;
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 = (
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmptyTo to={editPath} data-test-subj="UpdateButton">
{UPDATE_BUTTON}
</EuiButtonEmptyTo>
</EuiFlexItem>
</EuiFlexGroup>
);
const configureButton = supportedByLicense ? (
<EuiButtonEmptyTo to={configurePath} data-test-subj="ConfigureButton">
{CONFIGURE_BUTTON}
</EuiButtonEmptyTo>
) : (
<EuiButtonEmpty data-test-subj="ConfigureButton" disabled>
{CONFIGURE_BUTTON}
</EuiButtonEmpty>
);
return configured ? updateButtons : configureButton;
};
const platinumLicenseCallout = <LicenseCallout message={PRIVATE_PLATINUM_LICENSE_CALLOUT} />;
const connectorsList = (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{availableConnectors.map(
({ serviceType, name, configured, accountContextOnly, supportedByLicense }) => (
<EuiFlexItem key={serviceType} data-test-subj="ConnectorRow">
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}>
<EuiFlexItem grow={1}>
<EuiFlexGroup justifyContent="flexStart" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<SourceIcon
serviceType={serviceType}
name={name}
className="source-row__icon"
/>
</EuiFlexItem>
<EuiFlexItem>
<span className="source-row__name">
{name}
&nbsp;&nbsp;
{accountContextOnly && <EuiBadge color="hollow">{PRIVATE_SOURCE}</EuiBadge>}
</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{getRowActions(configured, serviceType, supportedByLicense)}
</EuiFlexItem>
</EuiFlexGroup>
{accountContextOnly && !supportedByLicense && (
<EuiFlexGroup justifyContent="flexStart" alignItems="center" responsive={false}>
<EuiFlexItem>
<EuiSpacer size="s" />
{platinumLicenseCallout}
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiFlexItem>
)
)}
</EuiFlexGroup>
);
return (
<>
<ViewContentHeader
title={CONNECTORS_HEADER_TITLE}
description={CONNECTORS_HEADER_DESCRIPTION}
/>
{connectorsList}
</>
);
};

View file

@ -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(<Customize />);
expect(wrapper.find(ContentSection)).toHaveLength(1);
});
it('handles input change', () => {
const wrapper = shallow(<Customize />);
const input = wrapper.find(EuiFieldText);
input.simulate('change', { target: { value: 'foobar' } });
expect(onOrgNameInputChange).toHaveBeenCalledWith('foobar');
});
it('handles form submission', () => {
const wrapper = shallow(<Customize />);
const preventDefault = jest.fn();
wrapper.find('form').simulate('submit', { preventDefault });
expect(updateOrgName).toHaveBeenCalled();
});
});

View file

@ -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 (
<form onSubmit={handleSubmit}>
<ViewContentHeader
title={CUSTOMIZE_HEADER_TITLE}
description={CUSTOMIZE_HEADER_DESCRIPTION}
/>
<ContentSection>
<EuiFormRow label={CUSTOMIZE_NAME_LABEL} fullWidth isInvalid={false}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFieldText
isInvalid={false}
required
value={orgNameInputValue}
data-test-subj="OrgNameInput"
onChange={(e) => onOrgNameInputChange(e.target.value)}
/>
</EuiFlexItem>
<EuiFlexItem />
</EuiFlexGroup>
</EuiFormRow>
<EuiFormRow>
<EuiButton color="primary" data-test-subj="SaveOrgNameButton" type="submit">
{CUSTOMIZE_NAME_BUTTON}
</EuiButton>
</EuiFormRow>
</ContentSection>
</form>
);
};

View file

@ -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(<OauthApplication />);
expect(wrapper.find(EuiForm)).toHaveLength(1);
});
it('does not render when no oauthApp', () => {
setMockValues({ hasPlatinumLicense: true, oauthApplication: undefined });
const wrapper = shallow(<OauthApplication />);
expect(wrapper.find(EuiForm)).toHaveLength(0);
});
it('handles OAuthAppName change', () => {
const wrapper = shallow(<OauthApplication />);
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(<OauthApplication />);
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(<OauthApplication />);
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(<OauthApplication />);
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(<OauthApplication />);
expect(wrapper.find(CredentialItem)).toHaveLength(2);
});
it('renders license modal', () => {
setMockValues({
hasPlatinumLicense: false,
oauthApplication,
});
const wrapper = shallow(<OauthApplication />);
expect(wrapper.find(EuiModal)).toHaveLength(1);
});
it('closes license modal', () => {
setMockValues({
hasPlatinumLicense: false,
oauthApplication,
});
const wrapper = shallow(<OauthApplication />);
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(<OauthApplication />);
expect(wrapper.find(ViewContentHeader).prop('description')).toEqual(OAUTH_DESCRIPTION);
expect(wrapper.find('[data-test-subj="RedirectURIsRow"]').prop('error')).toEqual(
REDIRECT_INSECURE_ERROR_TEXT
);
});
});

View file

@ -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 = (
<span>
<strong>{REDIRECT_HELP_TEXT}</strong>{' '}
{oauthApplication.nativeRedirectUri && (
<span>
{REDIRECT_NATIVE_HELP_TEXT} <EuiCode>{oauthApplication.nativeRedirectUri}</EuiCode>
</span>
)}
</span>
);
const redirectErrorText = insecureRedirectUri
? REDIRECT_INSECURE_ERROR_TEXT
: REDIRECT_SECURE_ERROR_TEXT;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
updateOauthApplication();
};
const licenseModal = (
<EuiOverlayMask className="oauth-platinum-modal">
<EuiModal maxWidth={500} onClose={closeLicenseModal} data-test-subj="LicenseModal">
<EuiModalBody>
<EuiSpacer size="xl" />
<LicenseBadge />
<EuiSpacer />
<EuiTitle size="l">
<h1>{LICENSE_MODAL_TITLE}</h1>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">{LICENSE_MODAL_DESCRIPTION}</EuiText>
<EuiSpacer />
<EuiLink external target="_blank" href={ENT_SEARCH_LICENSE_MANAGEMENT}>
{LICENSE_MODAL_LINK}
</EuiLink>
<EuiSpacer />
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
);
return (
<>
<form onSubmit={handleSubmit}>
<EuiForm>
<ViewContentHeader title={NAV.SETTINGS_OAUTH} description={description} />
<ContentSection>
<EuiFormRow label={NAME_LABEL}>
<EuiFieldText
value={oauthApplication.name}
data-test-subj="OAuthAppName"
onChange={(e) => setOauthApplication({ ...oauthApplication, name: e.target.value })}
required
disabled={!hasPlatinumLicense}
/>
</EuiFormRow>
<EuiSpacer size="xl" />
<EuiFormRow
data-test-subj="RedirectURIsRow"
label={REDIRECT_URIS_LABEL}
helpText={redirectUriHelpText}
isInvalid={redirectUriInvalid}
error={redirectErrorText}
>
<EuiTextArea
value={oauthApplication.redirectUri}
data-test-subj="RedirectURIsTextArea"
onChange={(e) =>
setOauthApplication({ ...oauthApplication, redirectUri: e.target.value })
}
required
disabled={!hasPlatinumLicense}
/>
</EuiFormRow>
<EuiSpacer size="xl" />
<EuiFormRow helpText={CONFIDENTIAL_HELP_TEXT}>
<EuiSwitch
label={CONFIDENTIAL_LABEL}
checked={oauthApplication.confidential}
data-test-subj="ConfidentialToggle"
onChange={(e) =>
setOauthApplication({ ...oauthApplication, confidential: e.target.checked })
}
disabled={!hasPlatinumLicense}
/>
</EuiFormRow>
<EuiSpacer size="xl" />
<EuiButton
fill
color="primary"
data-test-subj="SaveOAuthApp"
type="submit"
disabled={!hasPlatinumLicense}
>
{SAVE_CHANGES_BUTTON}
</EuiButton>
</ContentSection>
{persisted && (
<ContentSection title={CREDENTIALS_TITLE} description={CREDENTIALS_DESCRIPTION}>
<EuiFormRow>
<CredentialItem
label={CLIENT_ID_LABEL}
value={oauthApplication.uid}
testSubj="ClientID"
/>
</EuiFormRow>
{oauthApplication.confidential && (
<>
<EuiSpacer size="s" />
<EuiFormRow>
<CredentialItem
label={CLIENT_SECRET_LABEL}
value={oauthApplication.secret}
testSubj="ClientSecret"
/>
</EuiFormRow>
</>
)}
</ContentSection>
)}
</EuiForm>
</form>
{isLicenseModalVisible && licenseModal}
</>
);
};

View file

@ -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(<SettingsSubNav />);
expect(wrapper.find(SideNavLink)).toHaveLength(3);
});
});

View file

@ -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 = () => (
<>
<SideNavLink to={ORG_SETTINGS_CUSTOMIZE_PATH}>{NAV.SETTINGS_CUSTOMIZE}</SideNavLink>
<SideNavLink to={ORG_SETTINGS_CONNECTORS_PATH}>
{NAV.SETTINGS_SOURCE_PRIORITIZATION}
</SideNavLink>
<SideNavLink to={ORG_SETTINGS_OAUTH_APPLICATION_PATH}>{NAV.SETTINGS_OAUTH}</SideNavLink>
</>
);

View file

@ -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(<SourceConfig sourceIndex={1} />);
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(<SourceConfig sourceIndex={1} />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
it('handles delete click', () => {
const wrapper = shallow(<SourceConfig sourceIndex={1} />);
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(<SourceConfig sourceIndex={1} />);
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(<SourceConfig sourceIndex={1} />);
const saveConfig = wrapper.find(SaveConfig);
// Trigger modal visibility
saveConfig.prop('onDeleteConfig')!();
wrapper.find(EuiConfirmModal).prop('onCancel')!({} as any);
expect(wrapper.find(EuiConfirmModal)).toHaveLength(0);
});
});

View file

@ -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<SourceConfigProps> = ({ 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 <Loading />;
const hideConfirmModal = () => setConfirmModalVisibility(false);
const showConfirmModal = () => setConfirmModalVisibility(true);
const saveUpdatedConfig = () => saveSourceConfig(true);
const header = <AddSourceHeader name={name} serviceType={serviceType} categories={categories} />;
return (
<>
<SaveConfig
name={name}
configuration={configuration}
advanceStep={saveUpdatedConfig}
onDeleteConfig={showConfirmModal}
header={header}
/>
{confirmModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
onConfirm={() => 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 },
}
)}
</EuiConfirmModal>
</EuiOverlayMask>
)}
</>
);
};

View file

@ -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';

View file

@ -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);
});
});
});

View file

@ -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<MakeLogicType<SettingsValues, SettingsActions>>({
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();
},
}),
});

View file

@ -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(<SettingsRouter />);
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);
});
});

View file

@ -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 (
<>
<FlashMessages />
<Switch>
<Redirect exact from={ORG_SETTINGS_PATH} to={ORG_SETTINGS_CUSTOMIZE_PATH} />
<Route exact path={ORG_SETTINGS_CUSTOMIZE_PATH}>
<Customize />
</Route>
<Route exact path={ORG_SETTINGS_CONNECTORS_PATH}>
<Connectors />
</Route>
<Route exact path={ORG_SETTINGS_OAUTH_APPLICATION_PATH}>
<OauthApplication />
</Route>
{staticSourceData.map(({ editPath }, i) => (
<Route key={i} exact path={editPath}>
<SourceConfig sourceIndex={i} />
</Route>
))}
</Switch>
</>
);
};