[Workplace Search] Enables external SharePoint Online and custom SharePoint Server connectors (#126172)

This adds the ability to add and manage an External SharePoint Online connector and a Custom SharePoint Server connector via Kibana.

Co-authored-by: Byron Hulcher <byron.hulcher@elastic.co>
This commit is contained in:
Sander Philipse 2022-03-02 16:53:44 +01:00 committed by GitHub
parent 0bb021199e
commit 5023a03313
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1984 additions and 543 deletions

View file

@ -8,6 +8,7 @@
import { groups } from './groups.mock';
import { IndexingRule } from '../types';
import { SourceConfigData } from '../views/content_sources/components/add_source/add_source_logic';
import { staticSourceData } from '../views/content_sources/source_data';
import { mergeServerAndStaticData } from '../views/content_sources/sources_logic';
@ -339,23 +340,23 @@ export const mergedConfiguredSources = mergeServerAndStaticData(
contentSources
);
export const sourceConfigData = {
export const sourceConfigData: SourceConfigData = {
serviceType: 'confluence_cloud',
name: 'Confluence',
configured: true,
needsPermissions: true,
accountContextOnly: false,
supportedByLicense: true,
privateSourcesEnabled: false,
categories: ['wiki', 'atlassian', 'intranet'],
configuredFields: {
isOauth1: false,
clientId: 'CyztADsSECRETCSAUCEh1a',
clientSecret: 'GSjJxqSECRETCSAUCEksHk',
baseUrl: 'https://mine.atlassian.net',
privateKey: '-----BEGIN PRIVATE KEY-----\nkeykeykeykey==\n-----END PRIVATE KEY-----\n',
publicKey: '-----BEGIN PUBLIC KEY-----\nkeykeykeykey\n-----END PUBLIC KEY-----\n',
consumerKey: 'elastic_enterprise_search_123',
apiKey: 'asdf1234',
url: 'https://www.elastic.co',
},
};

View file

@ -19,6 +19,7 @@ import oneDrive from './onedrive.svg';
import salesforce from './salesforce.svg';
import serviceNow from './servicenow.svg';
import sharePoint from './sharepoint.svg';
import sharePointServer from './sharepoint_server.svg';
import slack from './slack.svg';
import zendesk from './zendesk.svg';
@ -29,6 +30,8 @@ export const images = {
confluenceServer: confluence,
custom,
dropbox,
// TODO: For now external sources are all SharePoint. When this is no longer the case, this needs to be dynamic.
external: sharePoint,
github,
githubEnterpriseServer: github,
githubViaApp: github,
@ -44,6 +47,7 @@ export const images = {
salesforceSandbox: salesforce,
serviceNow,
sharePoint,
sharePointServer,
slack,
zendesk,
} as { [key: string]: string };

View file

@ -0,0 +1 @@
<svg fill="none" height="100" viewBox="0 0 102 100" width="102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="9.88794" x2="43.4615" y1="20.9988" y2="78.9304"><stop offset="0" stop-color="#058f92"/><stop offset=".5" stop-color="#038489"/><stop offset="1" stop-color="#026d71"/></linearGradient><path d="m52.1629 57.022c15.4125 0 27.9068-12.5409 27.9068-28.011 0-15.47-12.4943-28.011-27.9068-28.011s-27.9068 12.541-27.9068 28.011c0 15.4701 12.4943 28.011 27.9068 28.011z" fill="#036c70"/><path d="m75.4188 80.3617c14.1281 0 25.5812-11.4959 25.5812-25.6768 0-14.1808-11.4531-25.6767-25.5812-25.6767s-25.5812 11.4959-25.5812 25.6767c0 14.1809 11.4531 25.6768 25.5812 25.6768z" fill="#1a9ba1"/><path d="m55.6508 99.0371c10.9172 0 19.7673-8.8832 19.7673-19.8411 0-10.958-8.8501-19.8411-19.7673-19.8411-10.9171 0-19.7673 8.8831-19.7673 19.8411 0 10.9579 8.8502 19.8411 19.7673 19.8411z" fill="#37c6d0"/><g fill="#000"><path d="m56.8143 26.2805v49.8128c-.0116 1.7309-1.0566 3.2859-2.6512 3.9449-.5077.2156-1.0534.3267-1.6046.3268h-16.6511c-.0232-.3968-.0232-.7703-.0232-1.1671-.0078-.3896.0077-.7794.0465-1.1672.4256-7.4615 5.0052-14.0449 11.8372-17.0166v-4.3417c-15.2049-2.4185-25.5776-16.751-23.1681-32.0125.0167-.1057.034-.2114.0519-.3169.1158-.7872.2789-1.5667.4884-2.3342h27.4184c2.3467.009 4.2468 1.9162 4.2558 4.2717z" opacity=".1"/><path d="m50.2321 24.342h-25.5812c-2.5842 15.2341 7.6245 29.6865 22.8019 32.2803.4596.0785.921.1455 1.3839.2008-7.2092 3.4314-12.4627 13.1769-12.9092 21.2067-.0387.3877-.0542.7775-.0465 1.1671 0 .3968 0 .7703.0232 1.1671.042.7847.1431 1.565.3023 2.3343h14.0232c1.7245-.0116 3.2737-1.0605 3.9302-2.6611.2148-.5096.3255-1.0573.3256-1.6106v-49.8129c-.0088-2.3545-1.9076-4.2614-4.2534-4.2717z" opacity=".2"/><path d="m50.2332 24.342h-25.5812c-2.5837 15.2355 7.6266 29.6885 22.8054 32.2819.3105.053.6219.1008.9339.1432-6.9766 3.6788-12.0231 13.401-12.4603 21.2627h14.3022c2.343-.0178 4.2381-1.9199 4.2558-4.2717v-45.1444c-.009-2.3554-1.9091-4.2627-4.2558-4.2717z" opacity=".2"/><path d="m47.9068 24.342h-23.2556c-2.4396 14.3825 6.539 28.2329 20.6278 31.8205-5.3347 6.1179-8.6068 13.7714-9.3488 21.8673h11.9767c2.3467-.009 4.2468-1.9162 4.2558-4.2717v-45.1444c-.0013-2.3586-1.906-4.2703-4.2559-4.2717z" opacity=".2"/></g><path d="m5.26276 24.342h42.63684c2.3543 0 4.2628 1.9157 4.2628 4.2787v42.7961c0 2.3631-1.9085 4.2787-4.2628 4.2787h-42.63684c-2.35426 0-4.26276-1.9156-4.26276-4.2787v-42.7961c0-2.363 1.9085-4.2787 4.26276-4.2787z" fill="url(#a)"/><path d="m20.0404 49.4935c-.999-.6651-1.8334-1.5506-2.4395-2.5887-.5873-1.0853-.8801-2.3065-.8489-3.5411-.0523-1.6716.5097-3.3042 1.579-4.5868 1.1237-1.2842 2.5762-2.2351 4.2-2.7498 1.8507-.6114 3.789-.9126 5.7372-.8916 2.5619-.094 5.1203.2656 7.5581 1.0621v5.3688c-1.0592-.6441-2.2129-1.1168-3.4186-1.4006-1.3083-.322-2.6507-.4835-3.9977-.4809-1.4204-.0523-2.8318.2476-4.1093.8731-.9862.4269-1.6258 1.4009-1.6278 2.4789-.004.654.2463 1.2838.6976 1.7554.5332.556 1.1638 1.0087 1.8605 1.3352.7752.3875 1.938.9026 3.4884 1.5453.1707.0542.3371.1213.4976.2008 1.5259.5985 2.9979 1.3272 4.4 2.1778 1.0618.6571 1.9529 1.558 2.6 2.6284.6635 1.2134.985 2.5852.9302 3.9682.0758 1.7162-.4473 3.4052-1.479 4.7759-1.0284 1.2601-2.4032 2.1888-3.9535 2.6704-1.8235.5736-3.7265.851-5.6372.8216-1.7143.0078-3.426-.1328-5.1162-.4202-1.4272-.2344-2.8199-.6452-4.1465-1.2231v-5.6606c1.268.909 2.6847 1.5884 4.186 2.0075 1.4962.468 3.0516.718 4.6186.7423 1.4503.0922 2.898-.2162 4.186-.8917.9023-.511 1.451-1.479 1.4279-2.5186.0061-.7234-.279-1.4186-.7906-1.9281-.6363-.6269-1.3729-1.1422-2.1791-1.5243-.9302-.4669-2.3-1.0823-4.1093-1.8464-1.4393-.5808-2.8174-1.3041-4.1139-2.1592z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -192,6 +192,10 @@ export const SOURCE_NAMES = {
'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePoint',
{ defaultMessage: 'SharePoint Online' }
),
SHAREPOINT_SERVER: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePointServer',
{ defaultMessage: 'SharePoint Server' }
),
SLACK: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.slack', {
defaultMessage: 'Slack',
}),
@ -357,6 +361,7 @@ export const GITHUB_VIA_APP_SERVICE_TYPE = 'github_via_app';
export const GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE = 'github_enterprise_server_via_app';
export const CUSTOM_SERVICE_TYPE = 'custom';
export const EXTERNAL_SERVICE_TYPE = 'external';
export const WORKPLACE_SEARCH_URL_PREFIX = '/app/enterprise_search/workplace_search';

View file

@ -7,11 +7,6 @@
import { generatePath } from 'react-router-dom';
import {
GITHUB_VIA_APP_SERVICE_TYPE,
GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE,
} from './constants';
export const SETUP_GUIDE_PATH = '/setup_guide';
export const NOT_FOUND_PATH = '/404';
@ -40,25 +35,7 @@ export const PRIVATE_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`;
export const SOURCE_ADDED_PATH = `${SOURCES_PATH}/added`;
export const ADD_SOURCE_PATH = `${SOURCES_PATH}/add`;
export const ADD_BOX_PATH = `${SOURCES_PATH}/add/box`;
export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence_cloud`;
export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence_server`;
export const ADD_DROPBOX_PATH = `${SOURCES_PATH}/add/dropbox`;
export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github_enterprise_server`;
export const ADD_GITHUB_PATH = `${SOURCES_PATH}/add/github`;
export const ADD_GITHUB_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_VIA_APP_SERVICE_TYPE}`;
export const ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE}`;
export const ADD_GMAIL_PATH = `${SOURCES_PATH}/add/gmail`;
export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google_drive`;
export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira_cloud`;
export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira_server`;
export const ADD_ONEDRIVE_PATH = `${SOURCES_PATH}/add/one_drive`;
export const ADD_SALESFORCE_PATH = `${SOURCES_PATH}/add/salesforce`;
export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce_sandbox`;
export const ADD_SERVICENOW_PATH = `${SOURCES_PATH}/add/servicenow`;
export const ADD_SHAREPOINT_PATH = `${SOURCES_PATH}/add/share_point`;
export const ADD_SLACK_PATH = `${SOURCES_PATH}/add/slack`;
export const ADD_ZENDESK_PATH = `${SOURCES_PATH}/add/zendesk`;
export const ADD_EXTERNAL_PATH = `${SOURCES_PATH}/add/external`;
export const ADD_CUSTOM_PATH = `${SOURCES_PATH}/add/custom`;
export const PERSONAL_SETTINGS_PATH = `${PERSONAL_PATH}/settings`;
@ -83,24 +60,6 @@ export const ORG_SETTINGS_PATH = '/settings';
export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`;
export const ORG_SETTINGS_CONNECTORS_PATH = `${ORG_SETTINGS_PATH}/connectors`;
export const ORG_SETTINGS_OAUTH_APPLICATION_PATH = `${ORG_SETTINGS_PATH}/oauth`;
export const EDIT_BOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/box/edit`;
export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_cloud/edit`;
export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_server/edit`;
export const EDIT_DROPBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/dropbox/edit`;
export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github_enterprise_server/edit`;
export const EDIT_GITHUB_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github/edit`;
export const EDIT_GMAIL_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/gmail/edit`;
export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google_drive/edit`;
export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_cloud/edit`;
export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_server/edit`;
export const EDIT_ONEDRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/one_drive/edit`;
export const EDIT_SALESFORCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce/edit`;
export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce_sandbox/edit`;
export const EDIT_SERVICENOW_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/servicenow/edit`;
export const EDIT_SHAREPOINT_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/share_point/edit`;
export const EDIT_SLACK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/slack/edit`;
export const EDIT_ZENDESK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/zendesk/edit`;
export const EDIT_CUSTOM_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/custom/edit`;
export const getContentSourcePath = (
path: string,
@ -118,3 +77,6 @@ export const getReindexJobRoute = (
isOrganization: boolean
) =>
getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization);
export const getAddPath = (serviceType: string): string => `${SOURCES_PATH}/add/${serviceType}`;
export const getEditPath = (serviceType: string): string =>
`${ORG_SETTINGS_CONNECTORS_PATH}/${serviceType}/edit`;

View file

@ -66,23 +66,27 @@ export interface Configuration {
needsConfiguration?: boolean;
hasOauthRedirect: boolean;
baseUrlTitle?: string;
helpText: string;
helpText?: string;
documentationUrl: string;
applicationPortalUrl?: string;
applicationLinkTitle?: string;
githubRepository?: string;
}
export interface SourceDataItem {
name: string;
iconName: string;
categories?: string[];
serviceType: string;
configuration: Configuration;
configured?: boolean;
connected?: boolean;
features?: Features;
objTypes?: string[];
addPath: string;
editPath?: string; // undefined for GitHub apps, as they are configured on a source level, and don't use a connector where you can edit the configuration
accountContextOnly: boolean;
internalConnectorAvailable?: boolean;
externalConnectorAvailable?: boolean;
customConnectorAvailable?: boolean;
}
export interface ContentSource {

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SourceDataItem } from '../types';
export const hasMultipleConnectorOptions = ({
internalConnectorAvailable,
externalConnectorAvailable,
customConnectorAvailable,
}: SourceDataItem) =>
[externalConnectorAvailable, internalConnectorAvailable, customConnectorAvailable].filter(
(available) => !!available
).length > 1;

View file

@ -11,3 +11,5 @@ export { mimeType } from './mime_types';
export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64';
export { readUploadedFileAsText } from './read_uploaded_file_as_text';
export { handlePrivateKeyUpload } from './handle_private_key_upload';
export { hasMultipleConnectorOptions } from './has_multiple_connector_options';
export { isNotNullish } from './is_not_nullish';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export function isNotNullish<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../../../__mocks__/shallow_useeffect.mock';
import { setMockValues } from '../../../../../__mocks__/kea_logic';
import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
import React from 'react';
import { shallow } from 'enzyme';
import {
WorkplaceSearchPageTemplate,
PersonalDashboardLayout,
} from '../../../../components/layout';
import { staticSourceData } from '../../source_data';
import { AddCustomSource } from './add_custom_source';
import { AddCustomSourceSteps } from './add_custom_source_logic';
import { ConfigureCustom } from './configure_custom';
import { SaveCustom } from './save_custom';
describe('AddCustomSource', () => {
const props = {
sourceData: staticSourceData[0],
initialValues: undefined,
};
const values = {
sourceConfigData,
isOrganization: true,
};
beforeEach(() => {
setMockValues({ ...values });
});
it('renders', () => {
const wrapper = shallow(<AddCustomSource {...props} />);
expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1);
});
it('should show correct layout for personal dashboard', () => {
setMockValues({ isOrganization: false });
const wrapper = shallow(<AddCustomSource {...props} />);
expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0);
expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1);
});
it('should show Configure Custom for custom configuration step', () => {
setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep });
const wrapper = shallow(<AddCustomSource {...props} />);
expect(wrapper.find(ConfigureCustom)).toHaveLength(1);
});
it('should show Save Custom for save custom step', () => {
setMockValues({ currentStep: AddCustomSourceSteps.SaveCustomStep });
const wrapper = shallow(<AddCustomSource {...props} />);
expect(wrapper.find(SaveCustom)).toHaveLength(1);
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { AppLogic } from '../../../../app_logic';
import {
WorkplaceSearchPageTemplate,
PersonalDashboardLayout,
} from '../../../../components/layout';
import { NAV } from '../../../../constants';
import { SourceDataItem } from '../../../../types';
import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic';
import { ConfigureCustom } from './configure_custom';
import { SaveCustom } from './save_custom';
import './add_source.scss';
interface Props {
sourceData: SourceDataItem;
initialValue?: string;
}
export const AddCustomSource: React.FC<Props> = ({ sourceData, initialValue = '' }) => {
const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue });
const { currentStep } = useValues(addCustomSourceLogic);
const { isOrganization } = useValues(AppLogic);
const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout;
return (
<Layout pageChrome={[NAV.SOURCES, NAV.ADD_SOURCE, sourceData.name || '...']}>
{currentStep === AddCustomSourceSteps.ConfigureCustomStep && <ConfigureCustom />}
{currentStep === AddCustomSourceSteps.SaveCustomStep && <SaveCustom />}
</Layout>
);
};

View file

@ -0,0 +1,182 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
LogicMounter,
mockFlashMessageHelpers,
mockHttpValues,
} from '../../../../../__mocks__/kea_logic';
import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
import { i18n } from '@kbn/i18n';
import { nextTick } from '@kbn/test-jest-helpers';
import { docLinks } from '../../../../../shared/doc_links';
import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers';
jest.mock('../../../../app_logic', () => ({
AppLogic: { values: { isOrganization: true } },
}));
import { AppLogic } from '../../../../app_logic';
import { SOURCE_NAMES } from '../../../../constants';
import { CustomSource, SourceDataItem } from '../../../../types';
import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic';
const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = {
name: SOURCE_NAMES.CUSTOM,
iconName: SOURCE_NAMES.CUSTOM,
serviceType: 'custom',
configuration: {
isPublicKey: false,
hasOauthRedirect: false,
needsBaseUrl: false,
helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', {
defaultMessage:
'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.',
}),
documentationUrl: docLinks.workplaceSearchCustomSources,
applicationPortalUrl: '',
},
accountContextOnly: false,
};
const DEFAULT_VALUES = {
currentStep: AddCustomSourceSteps.ConfigureCustomStep,
buttonLoading: false,
customSourceNameValue: '',
newCustomSource: {} as CustomSource,
sourceData: CUSTOM_SOURCE_DATA_ITEM,
};
const MOCK_PROPS = { initialValue: '', sourceData: CUSTOM_SOURCE_DATA_ITEM };
const MOCK_NAME = 'name';
describe('AddCustomSourceLogic', () => {
const { mount } = new LogicMounter(AddCustomSourceLogic);
const { http } = mockHttpValues;
const { clearFlashMessages } = mockFlashMessageHelpers;
beforeEach(() => {
jest.clearAllMocks();
mount({}, MOCK_PROPS);
});
it('has expected default values', () => {
expect(AddCustomSourceLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('setButtonNotLoading', () => {
it('turns off the button loading flag', () => {
AddCustomSourceLogic.actions.setButtonNotLoading();
expect(AddCustomSourceLogic.values).toEqual({
...DEFAULT_VALUES,
buttonLoading: false,
});
});
});
describe('setCustomSourceNameValue', () => {
it('saves the name', () => {
AddCustomSourceLogic.actions.setCustomSourceNameValue('name');
expect(AddCustomSourceLogic.values).toEqual({
...DEFAULT_VALUES,
customSourceNameValue: 'name',
});
});
});
describe('setNewCustomSource', () => {
it('saves the custom source', () => {
const newCustomSource = {
accessToken: 'foo',
key: 'bar',
name: 'source',
id: '123key',
};
AddCustomSourceLogic.actions.setNewCustomSource(newCustomSource);
expect(AddCustomSourceLogic.values).toEqual({
...DEFAULT_VALUES,
newCustomSource,
currentStep: AddCustomSourceSteps.SaveCustomStep,
});
});
});
});
describe('listeners', () => {
beforeEach(() => {
mount(
{
customSourceNameValue: MOCK_NAME,
},
MOCK_PROPS
);
});
describe('organization context', () => {
describe('createContentSource', () => {
it('calls API and sets values', async () => {
const setButtonNotLoadingSpy = jest.spyOn(
AddCustomSourceLogic.actions,
'setButtonNotLoading'
);
const setNewCustomSourceSpy = jest.spyOn(
AddCustomSourceLogic.actions,
'setNewCustomSource'
);
http.post.mockReturnValue(Promise.resolve({ sourceConfigData }));
AddCustomSourceLogic.actions.createContentSource();
expect(clearFlashMessages).toHaveBeenCalled();
expect(AddCustomSourceLogic.values.buttonLoading).toEqual(true);
expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', {
body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }),
});
await nextTick();
expect(setNewCustomSourceSpy).toHaveBeenCalledWith({ sourceConfigData });
expect(setButtonNotLoadingSpy).toHaveBeenCalled();
});
itShowsServerErrorAsFlashMessage(http.post, () => {
AddCustomSourceLogic.actions.createContentSource();
});
});
});
describe('account context routes', () => {
beforeEach(() => {
AppLogic.values.isOrganization = false;
});
describe('createContentSource', () => {
it('sends relevant fields to the API', () => {
AddCustomSourceLogic.actions.createContentSource();
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/account/create_source',
{
body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }),
}
);
});
itShowsServerErrorAsFlashMessage(http.post, () => {
AddCustomSourceLogic.actions.createContentSource();
});
});
});
});
});

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { flashAPIErrors, clearFlashMessages } from '../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../shared/http';
import { AppLogic } from '../../../../app_logic';
import { CustomSource, SourceDataItem } from '../../../../types';
export interface AddCustomSourceProps {
sourceData: SourceDataItem;
initialValue: string;
}
export enum AddCustomSourceSteps {
ConfigureCustomStep = 'Configure Custom',
SaveCustomStep = 'Save Custom',
}
export interface AddCustomSourceActions {
createContentSource(): void;
setButtonNotLoading(): void;
setCustomSourceNameValue(customSourceNameValue: string): string;
setNewCustomSource(data: CustomSource): CustomSource;
}
interface AddCustomSourceValues {
buttonLoading: boolean;
currentStep: AddCustomSourceSteps;
customSourceNameValue: string;
newCustomSource: CustomSource;
sourceData: SourceDataItem;
}
/**
* Workplace Search needs to know the host for the redirect. As of yet, we do not
* have access to this in Kibana. We parse it from the browser and pass it as a param.
*/
export const AddCustomSourceLogic = kea<
MakeLogicType<AddCustomSourceValues, AddCustomSourceActions, AddCustomSourceProps>
>({
path: ['enterprise_search', 'workplace_search', 'add_custom_source_logic'],
actions: {
createContentSource: true,
setButtonNotLoading: true,
setCustomSourceNameValue: (customSourceNameValue) => customSourceNameValue,
setNewCustomSource: (data) => data,
},
reducers: ({ props }) => ({
buttonLoading: [
false,
{
setButtonNotLoading: () => false,
createContentSource: () => true,
},
],
currentStep: [
AddCustomSourceSteps.ConfigureCustomStep,
{
setNewCustomSource: () => AddCustomSourceSteps.SaveCustomStep,
},
],
customSourceNameValue: [
props.initialValue,
{
setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue,
},
],
newCustomSource: [
{} as CustomSource,
{
setNewCustomSource: (_, newCustomSource) => newCustomSource,
},
],
sourceData: [props.sourceData],
}),
listeners: ({ actions, values }) => ({
createContentSource: async () => {
clearFlashMessages();
const { isOrganization } = AppLogic.values;
const route = isOrganization
? '/internal/workplace_search/org/create_source'
: '/internal/workplace_search/account/create_source';
const { customSourceNameValue } = values;
const params = {
service_type: 'custom',
name: customSourceNameValue,
};
try {
const response = await HttpLogic.values.http.post<CustomSource>(route, {
body: JSON.stringify({ ...params }),
});
actions.setNewCustomSource(response);
} catch (e) {
flashAPIErrors(e);
} finally {
actions.setButtonNotLoading();
}
},
}),
});

View file

@ -22,16 +22,16 @@ import {
PersonalDashboardLayout,
} from '../../../../components/layout';
import { staticSourceData } from '../../source_data';
import { AddSource } from './add_source';
import { AddSourceSteps } from './add_source_logic';
import { ConfigCompleted } from './config_completed';
import { ConfigurationIntro } from './configuration_intro';
import { ConfigureCustom } from './configure_custom';
import { ConfigureOauth } from './configure_oauth';
import { ConnectInstance } from './connect_instance';
import { Reauthenticate } from './reauthenticate';
import { SaveConfig } from './save_config';
import { SaveCustom } from './save_custom';
describe('AddSourceList', () => {
const { navigateToUrl } = mockKibanaValues;
@ -65,7 +65,7 @@ describe('AddSourceList', () => {
});
it('renders default state', () => {
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[0]} />);
wrapper.find(ConfigurationIntro).prop('advanceStep')();
expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep);
@ -74,14 +74,14 @@ describe('AddSourceList', () => {
describe('layout', () => {
it('renders the default workplace search layout when on an organization view', () => {
setMockValues({ ...mockValues, isOrganization: true });
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} />);
expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate);
});
it('renders the personal dashboard layout when not in an organization', () => {
setMockValues({ ...mockValues, isOrganization: false });
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} />);
expect(wrapper.type()).toEqual(PersonalDashboardLayout);
});
@ -89,7 +89,7 @@ describe('AddSourceList', () => {
it('renders a breadcrumb fallback while data is loading', () => {
setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} });
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} />);
expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']);
});
@ -99,7 +99,7 @@ describe('AddSourceList', () => {
...mockValues,
addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep,
});
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} />);
wrapper.find(ConfigCompleted).prop('advanceStep')();
expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect');
@ -111,7 +111,7 @@ describe('AddSourceList', () => {
...mockValues,
addSourceCurrentStep: AddSourceSteps.SaveConfigStep,
});
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} />);
const saveConfig = wrapper.find(SaveConfig);
saveConfig.prop('advanceStep')();
saveConfig.prop('goBackStep')!();
@ -126,51 +126,30 @@ describe('AddSourceList', () => {
sourceConfigData,
addSourceCurrentStep: AddSourceSteps.ConnectInstanceStep,
});
const wrapper = shallow(<AddSource sourceIndex={1} connect />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} connect />);
wrapper.find(ConnectInstance).prop('onFormCreated')('foo');
expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect');
});
it('renders Configure Custom step', () => {
setMockValues({
...mockValues,
addSourceCurrentStep: AddSourceSteps.ConfigureCustomStep,
});
const wrapper = shallow(<AddSource sourceIndex={1} />);
wrapper.find(ConfigureCustom).prop('advanceStep')();
expect(createContentSource).toHaveBeenCalled();
});
it('renders Configure Oauth step', () => {
setMockValues({
...mockValues,
addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep,
});
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} />);
wrapper.find(ConfigureOauth).prop('onFormCreated')('foo');
expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect');
});
it('renders Save Custom step', () => {
setMockValues({
...mockValues,
addSourceCurrentStep: AddSourceSteps.SaveCustomStep,
});
const wrapper = shallow(<AddSource sourceIndex={1} />);
expect(wrapper.find(SaveCustom)).toHaveLength(1);
});
it('renders Reauthenticate step', () => {
setMockValues({
...mockValues,
addSourceCurrentStep: AddSourceSteps.ReauthenticateStep,
});
const wrapper = shallow(<AddSource sourceIndex={1} />);
const wrapper = shallow(<AddSource sourceData={staticSourceData[1]} />);
expect(wrapper.find(Reauthenticate)).toHaveLength(1);
});

View file

@ -18,49 +18,28 @@ import {
WorkplaceSearchPageTemplate,
PersonalDashboardLayout,
} from '../../../../components/layout';
import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants';
import { SOURCES_PATH, getSourcesPath } from '../../../../routes';
import { SourceDataItem } from '../../../../types';
import { staticSourceData } from '../../source_data';
import { NAV } from '../../../../constants';
import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes';
import { AddSourceHeader } from './add_source_header';
import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic';
import { ConfigCompleted } from './config_completed';
import { ConfigurationIntro } from './configuration_intro';
import { ConfigureCustom } from './configure_custom';
import { ConfigureOauth } from './configure_oauth';
import { ConnectInstance } from './connect_instance';
import { Reauthenticate } from './reauthenticate';
import { SaveConfig } from './save_config';
import { SaveCustom } from './save_custom';
import './add_source.scss';
export const AddSource: React.FC<AddSourceProps> = (props) => {
const {
initializeAddSource,
setAddSourceStep,
saveSourceConfig,
createContentSource,
resetSourceState,
} = useActions(AddSourceLogic);
const {
addSourceCurrentStep,
sourceConfigData: {
name,
categories,
needsPermissions,
accountContextOnly,
privateSourcesEnabled,
},
dataLoading,
newCustomSource,
} = useValues(AddSourceLogic);
const { serviceType, configuration, features, objTypes, addPath } = staticSourceData[
props.sourceIndex
] as SourceDataItem;
const { initializeAddSource, setAddSourceStep, saveSourceConfig, resetSourceState } =
useActions(AddSourceLogic);
const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(AddSourceLogic);
const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } =
sourceConfigData;
const { serviceType, configuration, features, objTypes } = props.sourceData;
const addPath = getAddPath(serviceType);
const { isOrganization } = useValues(AppLogic);
useEffect(() => {
@ -85,9 +64,6 @@ export const AddSource: React.FC<AddSourceProps> = (props) => {
KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`);
};
const saveCustomSuccess = () => setAddSourceStep(AddSourceSteps.SaveCustomStep);
const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess);
const goToFormSourceCreated = () => {
KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`);
flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE);
@ -131,24 +107,9 @@ export const AddSource: React.FC<AddSourceProps> = (props) => {
header={header}
/>
)}
{addSourceCurrentStep === AddSourceSteps.ConfigureCustomStep && (
<ConfigureCustom
helpText={configuration.helpText}
advanceStep={goToSaveCustom}
header={header}
/>
)}
{addSourceCurrentStep === AddSourceSteps.ConfigureOauthStep && (
<ConfigureOauth name={name} onFormCreated={goToFormSourceCreated} header={header} />
)}
{addSourceCurrentStep === AddSourceSteps.SaveCustomStep && (
<SaveCustom
documentationUrl={configuration.documentationUrl}
newCustomSource={newCustomSource}
isOrganization={isOrganization}
header={header}
/>
)}
{addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && (
<Reauthenticate name={name} header={header} />
)}

View file

@ -27,7 +27,7 @@ import {
} from '../../../../components/layout';
import { ContentSection } from '../../../../components/shared/content_section';
import { ViewContentHeader } from '../../../../components/shared/view_content_header';
import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants';
import { NAV, CUSTOM_SERVICE_TYPE, EXTERNAL_SERVICE_TYPE } from '../../../../constants';
import { SourceDataItem } from '../../../../types';
import { SourcesLogic } from '../../sources_logic';
@ -90,12 +90,12 @@ export const AddSourceList: React.FC = () => {
const filterConfiguredSources = (source: SourceDataItem) =>
filterSources(source, configuredSources);
const visibleAvailableSources = availableSources.filter(
filterAvailableSources
) as SourceDataItem[];
const visibleConfiguredSources = configuredSources.filter(
filterConfiguredSources
) as SourceDataItem[];
const visibleAvailableSources = availableSources
.filter(filterAvailableSources)
.filter((source) => source.serviceType !== EXTERNAL_SERVICE_TYPE);
// The API returns available external sources as a separate entry, but we don't want to present them as options to add
const visibleConfiguredSources = configuredSources.filter(filterConfiguredSources);
const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout;

View file

@ -15,6 +15,7 @@ import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
import { nextTick } from '@kbn/test-jest-helpers';
import { docLinks } from '../../../../../shared/doc_links';
import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers';
jest.mock('../../../../app_logic', () => ({
@ -22,13 +23,9 @@ jest.mock('../../../../app_logic', () => ({
}));
import { AppLogic } from '../../../../app_logic';
import {
ADD_GITHUB_PATH,
SOURCES_PATH,
PRIVATE_SOURCES_PATH,
getSourcesPath,
} from '../../../../routes';
import { CustomSource } from '../../../../types';
import { SOURCE_NAMES, SOURCE_OBJ_TYPES } from '../../../../constants';
import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../../../routes';
import { FeatureIds } from '../../../../types';
import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants';
import { SourcesLogic } from '../../sources_logic';
@ -38,6 +35,8 @@ import {
SourceConfigData,
SourceConnectData,
OrganizationsMap,
AddSourceValues,
AddSourceProps,
} from './add_source_logic';
describe('AddSourceLogic', () => {
@ -46,13 +45,12 @@ describe('AddSourceLogic', () => {
const { navigateToUrl } = mockKibanaValues;
const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers;
const DEFAULT_VALUES = {
const DEFAULT_VALUES: AddSourceValues = {
addSourceCurrentStep: AddSourceSteps.ConfigIntroStep,
addSourceProps: {},
addSourceProps: {} as AddSourceProps,
dataLoading: true,
sectionLoading: true,
buttonLoading: false,
customSourceNameValue: '',
clientIdValue: '',
clientSecretValue: '',
baseUrlValue: '',
@ -62,7 +60,6 @@ describe('AddSourceLogic', () => {
indexPermissionsValue: false,
sourceConfigData: {} as SourceConfigData,
sourceConnectData: {} as SourceConnectData,
newCustomSource: {} as CustomSource,
oauthConfigCompleted: false,
currentServiceType: '',
githubOrganizations: [],
@ -81,8 +78,34 @@ describe('AddSourceLogic', () => {
serviceType: 'github',
githubOrganizations: ['foo', 'bar'],
};
const CUSTOM_SERVICE_TYPE_INDEX = 17;
const DEFAULT_SERVICE_TYPE = {
name: SOURCE_NAMES.BOX,
iconName: SOURCE_NAMES.BOX,
serviceType: 'box',
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
needsBaseUrl: false,
documentationUrl: docLinks.workplaceSearchBox,
applicationPortalUrl: 'https://app.box.com/developers/console',
},
objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES],
features: {
basicOrgContext: [
FeatureIds.SyncFrequency,
FeatureIds.SyncedItems,
FeatureIds.GlobalAccessPermissions,
],
basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions],
platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
platinumPrivateContext: [
FeatureIds.Private,
FeatureIds.SyncFrequency,
FeatureIds.SyncedItems,
],
},
accountContextOnly: false,
};
beforeEach(() => {
jest.clearAllMocks();
@ -145,15 +168,6 @@ describe('AddSourceLogic', () => {
});
});
it('setCustomSourceNameValue', () => {
AddSourceLogic.actions.setCustomSourceNameValue('name');
expect(AddSourceLogic.values).toEqual({
...DEFAULT_VALUES,
customSourceNameValue: 'name',
});
});
it('setSourceLoginValue', () => {
AddSourceLogic.actions.setSourceLoginValue('login');
@ -190,22 +204,6 @@ describe('AddSourceLogic', () => {
});
});
it('setCustomSourceData', () => {
const newCustomSource = {
accessToken: 'foo',
key: 'bar',
name: 'source',
id: '123key',
};
AddSourceLogic.actions.setCustomSourceData(newCustomSource);
expect(AddSourceLogic.values).toEqual({
...DEFAULT_VALUES,
newCustomSource,
});
});
it('setPreContentSourceConfigData', () => {
AddSourceLogic.actions.setPreContentSourceConfigData(config);
@ -260,13 +258,14 @@ describe('AddSourceLogic', () => {
});
it('handles fallback states', () => {
const { publicKey, privateKey, consumerKey } = sourceConfigData.configuredFields;
const sourceConfigDataMock = {
const { publicKey, privateKey, consumerKey, apiKey } = sourceConfigData.configuredFields;
const sourceConfigDataMock: SourceConfigData = {
...sourceConfigData,
configuredFields: {
publicKey,
privateKey,
consumerKey,
apiKey,
},
};
AddSourceLogic.actions.setSourceConfigData(sourceConfigDataMock);
@ -284,7 +283,7 @@ describe('AddSourceLogic', () => {
describe('listeners', () => {
it('initializeAddSource', () => {
const addSourceProps = { sourceIndex: 1 };
const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE };
const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData');
const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps');
const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep');
@ -293,21 +292,13 @@ describe('AddSourceLogic', () => {
expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps });
expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep);
expect(getSourceConfigDataSpy).toHaveBeenCalledWith('confluence_cloud');
expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box');
});
describe('getFirstStep', () => {
it('sets custom as first step', () => {
const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep');
const addSourceProps = { sourceIndex: CUSTOM_SERVICE_TYPE_INDEX };
AddSourceLogic.actions.initializeAddSource(addSourceProps);
expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureCustomStep);
});
it('sets connect as first step', () => {
const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep');
const addSourceProps = { sourceIndex: 1, connect: true };
const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true };
AddSourceLogic.actions.initializeAddSource(addSourceProps);
expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep);
@ -315,7 +306,7 @@ describe('AddSourceLogic', () => {
it('sets configure as first step', () => {
const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep');
const addSourceProps = { sourceIndex: 1, configure: true };
const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true };
AddSourceLogic.actions.initializeAddSource(addSourceProps);
expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep);
@ -323,7 +314,7 @@ describe('AddSourceLogic', () => {
it('sets reAuthenticate as first step', () => {
const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep');
const addSourceProps = { sourceIndex: 1, reAuthenticate: true };
const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true };
AddSourceLogic.actions.initializeAddSource(addSourceProps);
expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep);
@ -401,7 +392,7 @@ describe('AddSourceLogic', () => {
await nextTick();
expect(setPreContentSourceIdSpy).toHaveBeenCalledWith(preContentSourceId);
expect(navigateToUrl).toHaveBeenCalledWith(`${ADD_GITHUB_PATH}/configure${queryString}`);
expect(navigateToUrl).toHaveBeenCalledWith(`/sources/add/github/configure${queryString}`);
});
describe('Github error edge case', () => {
@ -635,7 +626,6 @@ describe('AddSourceLogic', () => {
const errorCallback = jest.fn();
const serviceType = 'zendesk';
const name = 'name';
const login = 'login';
const password = 'password';
const indexPermissions = false;
@ -643,7 +633,6 @@ describe('AddSourceLogic', () => {
let params: any;
beforeEach(() => {
AddSourceLogic.actions.setCustomSourceNameValue(name);
AddSourceLogic.actions.setSourceLoginValue(login);
AddSourceLogic.actions.setSourcePasswordValue(password);
AddSourceLogic.actions.setPreContentSourceConfigData(config);
@ -652,7 +641,6 @@ describe('AddSourceLogic', () => {
params = {
service_type: serviceType,
name,
login,
password,
organizations: ['foo'],
@ -661,8 +649,7 @@ describe('AddSourceLogic', () => {
it('calls API and sets values', async () => {
const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading');
const setCustomSourceDataSpy = jest.spyOn(AddSourceLogic.actions, 'setCustomSourceData');
http.post.mockReturnValue(Promise.resolve({ sourceConfigData }));
http.post.mockReturnValue(Promise.resolve());
AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback);
@ -672,7 +659,6 @@ describe('AddSourceLogic', () => {
body: JSON.stringify({ ...params }),
});
await nextTick();
expect(setCustomSourceDataSpy).toHaveBeenCalledWith({ sourceConfigData });
expect(successCallback).toHaveBeenCalled();
expect(setButtonNotLoadingSpy).toHaveBeenCalled();
});

View file

@ -21,20 +21,14 @@ import {
import { HttpLogic } from '../../../../../shared/http';
import { KibanaLogic } from '../../../../../shared/kibana';
import { AppLogic } from '../../../../app_logic';
import { CUSTOM_SERVICE_TYPE, WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants';
import {
SOURCES_PATH,
ADD_GITHUB_PATH,
PRIVATE_SOURCES_PATH,
getSourcesPath,
} from '../../../../routes';
import { CustomSource } from '../../../../types';
import { WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants';
import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes';
import { SourceDataItem } from '../../../../types';
import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants';
import { staticSourceData } from '../../source_data';
import { SourcesLogic } from '../../sources_logic';
export interface AddSourceProps {
sourceIndex: number;
sourceData: SourceDataItem;
connect?: boolean;
configure?: boolean;
reAuthenticate?: boolean;
@ -45,9 +39,7 @@ export enum AddSourceSteps {
SaveConfigStep = 'Save Config',
ConfigCompletedStep = 'Config Completed',
ConnectInstanceStep = 'Connect Instance',
ConfigureCustomStep = 'Configure Custom',
ConfigureOauthStep = 'Configure Oauth',
SaveCustomStep = 'Save Custom',
ReauthenticateStep = 'Reauthenticate',
}
@ -71,12 +63,10 @@ export interface AddSourceActions {
setClientIdValue(clientIdValue: string): string;
setClientSecretValue(clientSecretValue: string): string;
setBaseUrlValue(baseUrlValue: string): string;
setCustomSourceNameValue(customSourceNameValue: string): string;
setSourceLoginValue(loginValue: string): string;
setSourcePasswordValue(passwordValue: string): string;
setSourceSubdomainValue(subdomainValue: string): string;
setSourceIndexPermissionsValue(indexPermissionsValue: boolean): boolean;
setCustomSourceData(data: CustomSource): CustomSource;
setPreContentSourceConfigData(data: PreContentSourceResponse): PreContentSourceResponse;
setPreContentSourceId(preContentSourceId: string): string;
setSelectedGithubOrganizations(option: string): string;
@ -119,6 +109,8 @@ export interface SourceConfigData {
baseUrl?: string;
clientId?: string;
clientSecret?: string;
url?: string;
apiKey?: string;
};
accountContextOnly?: boolean;
}
@ -132,13 +124,12 @@ export interface OrganizationsMap {
[key: string]: string | boolean;
}
interface AddSourceValues {
export interface AddSourceValues {
addSourceProps: AddSourceProps;
addSourceCurrentStep: AddSourceSteps;
dataLoading: boolean;
sectionLoading: boolean;
buttonLoading: boolean;
customSourceNameValue: string;
clientIdValue: string;
clientSecretValue: string;
baseUrlValue: string;
@ -148,7 +139,6 @@ interface AddSourceValues {
indexPermissionsValue: boolean;
sourceConfigData: SourceConfigData;
sourceConnectData: SourceConnectData;
newCustomSource: CustomSource;
currentServiceType: string;
githubOrganizations: string[];
selectedGithubOrganizationsMap: OrganizationsMap;
@ -185,12 +175,10 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
setClientIdValue: (clientIdValue: string) => clientIdValue,
setClientSecretValue: (clientSecretValue: string) => clientSecretValue,
setBaseUrlValue: (baseUrlValue: string) => baseUrlValue,
setCustomSourceNameValue: (customSourceNameValue: string) => customSourceNameValue,
setSourceLoginValue: (loginValue: string) => loginValue,
setSourcePasswordValue: (passwordValue: string) => passwordValue,
setSourceSubdomainValue: (subdomainValue: string) => subdomainValue,
setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue,
setCustomSourceData: (data: CustomSource) => data,
setPreContentSourceConfigData: (data: PreContentSourceResponse) => data,
setPreContentSourceId: (preContentSourceId: string) => preContentSourceId,
setSelectedGithubOrganizations: (option: string) => option,
@ -322,20 +310,6 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
resetSourceState: () => false,
},
],
customSourceNameValue: [
'',
{
setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue,
resetSourceState: () => '',
},
],
newCustomSource: [
{} as CustomSource,
{
setCustomSourceData: (_, newCustomSource) => newCustomSource,
resetSourceState: () => ({} as CustomSource),
},
],
currentServiceType: [
'',
{
@ -383,7 +357,7 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
}),
listeners: ({ actions, values }) => ({
initializeAddSource: ({ addSourceProps }) => {
const { serviceType } = staticSourceData[addSourceProps.sourceIndex];
const { serviceType } = addSourceProps.sourceData;
actions.setAddSourceProps({ addSourceProps });
actions.setAddSourceStep(getFirstStep(addSourceProps));
actions.getSourceConfigData(serviceType);
@ -540,7 +514,9 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
// GitHub requires an intermediate configuration step, where we collect the repos to index.
if (hasConfigureStep && !values.oauthConfigCompleted) {
actions.setPreContentSourceId(preContentSourceId);
navigateToUrl(getSourcesPath(`${ADD_GITHUB_PATH}/configure${search}`, isOrganization));
navigateToUrl(
getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization)
);
} else {
setAddedSource(serviceName, indexPermissions, serviceType);
navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization));
@ -559,7 +535,6 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
const {
selectedGithubOrganizations: githubOrganizations,
customSourceNameValue,
loginValue,
passwordValue,
indexPermissionsValue,
@ -567,7 +542,6 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
const params = {
service_type: serviceType,
name: customSourceNameValue || undefined,
login: loginValue || undefined,
password: passwordValue || undefined,
organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined,
@ -580,10 +554,9 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
try {
const response = await HttpLogic.values.http.post<CustomSource>(route, {
await HttpLogic.values.http.post(route, {
body: JSON.stringify({ ...params }),
});
actions.setCustomSourceData(response);
successCallback();
} catch (e) {
flashAPIErrors(e);
@ -596,11 +569,7 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
});
const getFirstStep = (props: AddSourceProps): AddSourceSteps => {
const { sourceIndex, connect, configure, reAuthenticate } = props;
const { serviceType } = staticSourceData[sourceIndex];
const isCustom = serviceType === CUSTOM_SERVICE_TYPE;
if (isCustom) return AddSourceSteps.ConfigureCustomStep;
const { connect, configure, reAuthenticate } = props;
if (connect) return AddSourceSteps.ConnectInstanceStep;
if (configure) return AddSourceSteps.ConfigureOauthStep;
if (reAuthenticate) return AddSourceSteps.ReauthenticateStep;

View file

@ -26,7 +26,7 @@ describe('AvailableSourcesList', () => {
const wrapper = shallow(<AvailableSourcesList sources={mergedAvailableSources} />);
expect(wrapper.find(EuiTitle)).toHaveLength(1);
expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(11);
expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(20);
expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1);
});
@ -34,7 +34,7 @@ describe('AvailableSourcesList', () => {
setMockValues({ hasPlatinumLicense: false });
const wrapper = shallow(<AvailableSourcesList sources={mergedAvailableSources} />);
expect(wrapper.find(EuiToolTip)).toHaveLength(1);
expect(wrapper.find(EuiToolTip)).toHaveLength(2);
});
it('handles empty state', () => {

View file

@ -24,9 +24,11 @@ import { i18n } from '@kbn/i18n';
import { LicensingLogic } from '../../../../../shared/licensing';
import { EuiButtonEmptyTo, EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { SourceIcon } from '../../../../components/shared/source_icon';
import { ADD_CUSTOM_PATH, getSourcesPath } from '../../../../routes';
import { ADD_CUSTOM_PATH, getAddPath, getSourcesPath } from '../../../../routes';
import { SourceDataItem } from '../../../../types';
import { staticCustomSourceData } from '../../source_data';
import {
AVAILABLE_SOURCE_EMPTY_STATE,
AVAILABLE_SOURCE_TITLE,
@ -41,7 +43,8 @@ interface AvailableSourcesListProps {
export const AvailableSourcesList: React.FC<AvailableSourcesListProps> = ({ sources }) => {
const { hasPlatinumLicense } = useValues(LicensingLogic);
const getSourceCard = ({ name, serviceType, addPath, accountContextOnly }: SourceDataItem) => {
const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => {
const addPath = getAddPath(serviceType);
const disabled = !hasPlatinumLicense && accountContextOnly;
const connectButton = () => {
@ -105,6 +108,15 @@ export const AvailableSourcesList: React.FC<AvailableSourcesListProps> = ({ sour
</EuiFlexGroup>
</EuiFlexItem>
))}
<EuiFlexItem grow={false} data-test-subj="AvailableSourceListItem">
<EuiFlexGroup
justifyContent="center"
alignItems="stretch"
data-test-subj="AvailableSourceCard"
>
<EuiFlexItem>{getSourceCard(staticCustomSourceData)}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGrid>
</>
);

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiText, EuiButton } from '@elastic/eui';
import {
PersonalDashboardLayout,
WorkplaceSearchPageTemplate,
} from '../../../../components/layout';
import { staticSourceData } from '../../source_data';
import { ConfigurationChoice } from './configuration_choice';
describe('ConfigurationChoice', () => {
const { navigateToUrl } = mockKibanaValues;
const props = {
sourceData: staticSourceData[0],
};
const mockValues = {
isOrganization: true,
};
beforeEach(() => {
setMockValues(mockValues);
jest.clearAllMocks();
});
describe('layout', () => {
it('renders the default workplace search layout when on an organization view', () => {
setMockValues({ ...mockValues, isOrganization: true });
const wrapper = shallow(<ConfigurationChoice sourceData={staticSourceData[1]} />);
expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate);
});
it('renders the personal dashboard layout when not in an organization', () => {
setMockValues({ ...mockValues, isOrganization: false });
const wrapper = shallow(<ConfigurationChoice sourceData={staticSourceData[1]} />);
expect(wrapper.type()).toEqual(PersonalDashboardLayout);
});
});
it('renders internal connector if available', () => {
const wrapper = shallow(<ConfigurationChoice {...{ ...props }} />);
expect(wrapper.find('EuiPanel')).toHaveLength(1);
expect(wrapper.find(EuiText)).toHaveLength(3);
expect(wrapper.find(EuiButton)).toHaveLength(1);
});
it('should navigate to internal connector on internal connector click', () => {
const wrapper = shallow(<ConfigurationChoice {...props} />);
const button = wrapper.find(EuiButton);
button.simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/internal/');
});
it('renders external connector if available', () => {
const wrapper = shallow(
<ConfigurationChoice
{...{
...props,
sourceData: {
...props.sourceData,
internalConnectorAvailable: false,
externalConnectorAvailable: true,
},
}}
/>
);
expect(wrapper.find('EuiPanel')).toHaveLength(1);
expect(wrapper.find(EuiText)).toHaveLength(3);
expect(wrapper.find(EuiButton)).toHaveLength(1);
});
it('should navigate to external connector on external connector click', () => {
const wrapper = shallow(
<ConfigurationChoice
{...{
...props,
sourceData: {
...props.sourceData,
internalConnectorAvailable: false,
externalConnectorAvailable: true,
},
}}
/>
);
const button = wrapper.find(EuiButton);
button.simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/');
});
it('renders custom connector if available', () => {
const wrapper = shallow(
<ConfigurationChoice
{...{
...props,
sourceData: {
...props.sourceData,
internalConnectorAvailable: false,
externalConnectorAvailable: false,
customConnectorAvailable: true,
},
}}
/>
);
expect(wrapper.find('EuiPanel')).toHaveLength(1);
expect(wrapper.find(EuiText)).toHaveLength(3);
expect(wrapper.find(EuiButton)).toHaveLength(1);
});
it('should navigate to custom connector on internal connector click', () => {
const wrapper = shallow(
<ConfigurationChoice
{...{
...props,
sourceData: {
...props.sourceData,
internalConnectorAvailable: false,
externalConnectorAvailable: false,
customConnectorAvailable: true,
},
}}
/>
);
const button = wrapper.find(EuiButton);
button.simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/custom/');
});
});

View file

@ -0,0 +1,236 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { KibanaLogic } from '../../../../../shared/kibana';
import { AppLogic } from '../../../../app_logic';
import {
WorkplaceSearchPageTemplate,
PersonalDashboardLayout,
} from '../../../../components/layout';
import { NAV } from '../../../../constants';
import { getAddPath, getSourcesPath } from '../../../../routes';
import { SourceDataItem } from '../../../../types';
import { AddSourceHeader } from './add_source_header';
interface ConfigurationIntroProps {
sourceData: SourceDataItem;
}
export const ConfigurationChoice: React.FC<ConfigurationIntroProps> = ({
sourceData: {
name,
serviceType,
externalConnectorAvailable,
internalConnectorAvailable,
customConnectorAvailable,
},
}) => {
const { isOrganization } = useValues(AppLogic);
const goToInternal = () =>
KibanaLogic.values.navigateToUrl(
`${getSourcesPath(
`${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`,
isOrganization
)}/`
);
const goToExternal = () =>
KibanaLogic.values.navigateToUrl(
`${getSourcesPath(
`${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`,
isOrganization
)}/`
);
const goToCustom = () =>
KibanaLogic.values.navigateToUrl(
`${getSourcesPath(
`${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`,
isOrganization
)}/`
);
const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout;
return (
<Layout pageChrome={[NAV.SOURCES, NAV.ADD_SOURCE]} pageViewTelemetry="add_source_choice">
<AddSourceHeader name={name} serviceType={serviceType} categories={[]} />
<EuiSpacer />
<EuiFlexGroup
justifyContent="flexStart"
alignItems="flexStart"
direction="row"
responsive={false}
>
{internalConnectorAvailable && (
<EuiFlexItem grow>
<EuiPanel color="plain" hasShadow={false} hasBorder>
<EuiFlexGroup
justifyContent="center"
alignItems="center"
direction="column"
gutterSize="s"
responsive={false}
>
<EuiFlexItem>
<EuiText size="s">
<h4>{name}</h4>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<h3>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title',
{
defaultMessage: 'Default connector',
}
)}
</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description',
{
defaultMessage: 'Use our out-of-the-box connector to get started quickly.',
}
)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton color="primary" fill onClick={goToInternal}>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.button',
{
defaultMessage: 'Connect',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
)}
{externalConnectorAvailable && (
<EuiFlexItem grow>
<EuiPanel color="plain" hasShadow={false} hasBorder>
<EuiFlexGroup
justifyContent="center"
alignItems="center"
direction="column"
gutterSize="s"
responsive={false}
>
<EuiFlexItem>
<EuiText size="s">
<h4>{name}</h4>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<h3>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title',
{
defaultMessage: 'Custom connector',
}
)}
</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description',
{
defaultMessage:
'Set up a custom connector for more configurability and control.',
}
)}
</EuiText>
</EuiFlexItem>
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButton color="primary" fill onClick={goToExternal}>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.button',
{
defaultMessage: 'Instructions',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
)}
{customConnectorAvailable && (
<EuiFlexItem grow>
<EuiPanel>
<EuiFlexGroup
justifyContent="center"
alignItems="center"
direction="column"
gutterSize="s"
responsive={false}
>
<EuiFlexItem>
<EuiText size="s">
<h4>{name}</h4>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<h3>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.title',
{
defaultMessage: 'Custom connector',
}
)}
</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.description',
{
defaultMessage:
'Set up a custom connector for more configurability and control.',
}
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButton color="primary" fill onClick={goToCustom}>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.button',
{
defaultMessage: 'Instructions',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
)}
</EuiFlexGroup>
</Layout>
);
};

View file

@ -14,45 +14,45 @@ import { shallow } from 'enzyme';
import { EuiForm, EuiFieldText } from '@elastic/eui';
import { staticSourceData } from '../../source_data';
import { ConfigureCustom } from './configure_custom';
describe('ConfigureCustom', () => {
const advanceStep = jest.fn();
const setCustomSourceNameValue = jest.fn();
const props = {
header: <h1>Header</h1>,
helpText: 'I bet you could use a hand.',
advanceStep,
};
const createContentSource = jest.fn();
beforeEach(() => {
setMockActions({ setCustomSourceNameValue });
setMockValues({ customSourceNameValue: 'name', buttonLoading: false });
setMockActions({ setCustomSourceNameValue, createContentSource });
setMockValues({
customSourceNameValue: 'name',
buttonLoading: false,
sourceData: staticSourceData[1],
});
});
it('renders', () => {
const wrapper = shallow(<ConfigureCustom {...props} />);
const wrapper = shallow(<ConfigureCustom />);
expect(wrapper.find(EuiForm)).toHaveLength(1);
});
it('handles input change', () => {
const wrapper = shallow(<ConfigureCustom {...props} />);
const TEXT = 'changed for the better';
const wrapper = shallow(<ConfigureCustom />);
const text = 'changed for the better';
const input = wrapper.find(EuiFieldText);
input.simulate('change', { target: { value: TEXT } });
input.simulate('change', { target: { value: text } });
expect(setCustomSourceNameValue).toHaveBeenCalledWith(TEXT);
expect(setCustomSourceNameValue).toHaveBeenCalledWith(text);
});
it('handles form submission', () => {
const wrapper = shallow(<ConfigureCustom {...props} />);
const wrapper = shallow(<ConfigureCustom />);
const preventDefault = jest.fn();
wrapper.find('form').simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(advanceStep).toHaveBeenCalled();
expect(createContentSource).toHaveBeenCalled();
});
});

View file

@ -24,51 +24,64 @@ import { docLinks } from '../../../../../shared/doc_links';
import { SOURCE_NAME_LABEL } from '../../constants';
import { AddSourceLogic } from './add_source_logic';
import { AddCustomSourceLogic } from './add_custom_source_logic';
import { AddSourceHeader } from './add_source_header';
import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT } from './constants';
interface ConfigureCustomProps {
header: React.ReactNode;
helpText: string;
advanceStep(): void;
}
export const ConfigureCustom: React.FC<ConfigureCustomProps> = ({
helpText,
advanceStep,
header,
}) => {
const { setCustomSourceNameValue } = useActions(AddSourceLogic);
const { customSourceNameValue, buttonLoading } = useValues(AddSourceLogic);
export const ConfigureCustom: React.FC = () => {
const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic);
const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic);
const handleFormSubmit = (e: FormEvent) => {
e.preventDefault();
advanceStep();
createContentSource();
};
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) =>
setCustomSourceNameValue(e.target.value);
const {
serviceType,
configuration: { documentationUrl, helpText },
name,
categories = [],
} = sourceData;
return (
<>
{header}
<AddSourceHeader name={name} serviceType={serviceType} categories={categories} />
<EuiSpacer />
<form onSubmit={handleFormSubmit}>
<EuiForm>
<EuiText grow={false}>
<p>{helpText}</p>
<p>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.configCustom.docs.link.description"
defaultMessage="{link} to learn more about Custom API Sources."
values={{
link: (
<EuiLink href={docLinks.workplaceSearchCustomSources} target="_blank">
{CONFIG_CUSTOM_LINK_TEXT}
</EuiLink>
),
}}
/>
{serviceType === 'custom' ? (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.configCustom.docs.link.description"
defaultMessage="{link} to learn more about Custom API Sources."
values={{
link: (
<EuiLink href={docLinks.workplaceSearchCustomSources} target="_blank">
{CONFIG_CUSTOM_LINK_TEXT}
</EuiLink>
),
}}
/>
) : (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.configCustom.deploymentDocs.link.description"
defaultMessage="{link} to learn more about deploying a {name} source."
values={{
link: (
<EuiLink target="_blank" href={documentationUrl}>
{CONFIG_CUSTOM_LINK_TEXT}
</EuiLink>
),
name,
}}
/>
)}
</p>
</EuiText>
<EuiSpacer size="xxl" />
@ -90,7 +103,17 @@ export const ConfigureCustom: React.FC<ConfigureCustomProps> = ({
isLoading={buttonLoading}
data-test-subj="CreateCustomButton"
>
{CONFIG_CUSTOM_BUTTON}
{serviceType === 'custom' ? (
CONFIG_CUSTOM_BUTTON
) : (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.configCustom.createNamedSourceButtonLabel"
defaultMessage="Create {name} source"
values={{
name,
}}
/>
)}
</EuiButton>
</EuiFormRow>
</EuiForm>

View file

@ -22,9 +22,9 @@ describe('ConfiguredSourcesList', () => {
it('renders', () => {
const wrapper = shallow(<ConfiguredSourcesList {...props} />);
expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(5);
expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(6);
expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(16);
expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2);
expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(19);
});
it('handles empty state', () => {

View file

@ -22,8 +22,9 @@ import {
import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers';
import { SourceIcon } from '../../../../components/shared/source_icon';
import { getSourcesPath } from '../../../../routes';
import { getAddPath, getSourcesPath } from '../../../../routes';
import { SourceDataItem } from '../../../../types';
import { hasMultipleConnectorOptions } from '../../../../utils';
import {
CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP,
@ -68,54 +69,62 @@ export const ConfiguredSourcesList: React.FC<ConfiguredSourcesProps> = ({
const visibleSources = (
<EuiFlexGrid columns={3} gutterSize="m" className="source-grid-configured">
{sources.map(({ name, serviceType, addPath, connected, accountContextOnly }, i) => (
<React.Fragment key={i}>
<EuiFlexItem
grow
className="organizational-content-source-item"
data-test-subj="ConfiguredSourcesListItem"
>
<EuiSplitPanel.Outer color="plain" hasShadow={false} hasBorder>
<EuiSplitPanel.Inner>
<EuiFlexGroup
justifyContent="center"
alignItems="center"
direction="column"
gutterSize="s"
responsive={false}
>
<EuiFlexItem>
<SourceIcon serviceType={serviceType} name={name} size="xxl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<h4>
{name}
{!connected && !accountContextOnly && isOrganization && unConnectedTooltip}
{accountContextOnly && isOrganization && accountOnlyTooltip}
</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner color="subdued" paddingSize="none">
{((!isOrganization || (isOrganization && !accountContextOnly)) && (
<EuiButtonEmptyTo
className="eui-fullWidth"
to={`${getSourcesPath(addPath, isOrganization)}/connect`}
{sources.map((sourceData, i) => {
const { connected, accountContextOnly, name, serviceType } = sourceData;
return (
<React.Fragment key={i}>
<EuiFlexItem
grow
className="organizational-content-source-item"
data-test-subj="ConfiguredSourcesListItem"
>
<EuiSplitPanel.Outer color="plain" hasShadow={false} hasBorder>
<EuiSplitPanel.Inner>
<EuiFlexGroup
justifyContent="center"
alignItems="center"
direction="column"
gutterSize="s"
responsive={false}
>
{CONFIGURED_SOURCES_CONNECT_BUTTON}
</EuiButtonEmptyTo>
)) || (
<EuiButtonEmpty className="eui-fullWidth" isDisabled>
{ADD_SOURCE_ORG_SOURCES_TITLE}
</EuiButtonEmpty>
)}
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiFlexItem>
</React.Fragment>
))}
<EuiFlexItem>
<SourceIcon serviceType={serviceType} name={name} size="xxl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<h4>
{name}
{!connected &&
!accountContextOnly &&
isOrganization &&
unConnectedTooltip}
{accountContextOnly && isOrganization && accountOnlyTooltip}
</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner color="subdued" paddingSize="none">
{((!isOrganization || (isOrganization && !accountContextOnly)) && (
<EuiButtonEmptyTo
className="eui-fullWidth"
to={`${getSourcesPath(getAddPath(serviceType), isOrganization)}/${
hasMultipleConnectorOptions(sourceData) ? '' : 'connect'
}`}
>
{CONFIGURED_SOURCES_CONNECT_BUTTON}
</EuiButtonEmptyTo>
)) || (
<EuiButtonEmpty className="eui-fullWidth" isDisabled>
{ADD_SOURCE_ORG_SOURCES_TITLE}
</EuiButtonEmpty>
)}
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiFlexItem>
</React.Fragment>
);
})}
</EuiFlexGrid>
);

View file

@ -43,7 +43,7 @@ describe('ConnectInstance', () => {
const credentialsSourceData = staticSourceData[13];
const oauthSourceData = staticSourceData[0];
const subdomainSourceData = staticSourceData[16];
const subdomainSourceData = staticSourceData[18];
const props = {
...credentialsSourceData,

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../../../__mocks__/shallow_useeffect.mock';
import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiSteps } from '@elastic/eui';
import { staticSourceData } from '../../source_data';
import { ExternalConnectorConfig } from './external_connector_config';
describe('ExternalConnectorConfig', () => {
const goBack = jest.fn();
const onDeleteConfig = jest.fn();
const setExternalConnectorApiKey = jest.fn();
const setExternalConnectorUrl = jest.fn();
const saveExternalConnectorConfig = jest.fn();
const fetchExternalSource = jest.fn();
const props = {
sourceData: staticSourceData[0],
goBack,
onDeleteConfig,
};
const values = {
sourceConfigData,
buttonLoading: false,
clientIdValue: 'foo',
clientSecretValue: 'bar',
baseUrlValue: 'http://foo.baz',
hasPlatinumLicense: true,
};
beforeEach(() => {
setMockActions({
setExternalConnectorApiKey,
setExternalConnectorUrl,
saveExternalConnectorConfig,
fetchExternalSource,
});
setMockValues({ ...values });
});
it('renders', () => {
const wrapper = shallow(<ExternalConnectorConfig {...props} />);
expect(wrapper.find(EuiSteps)).toHaveLength(1);
});
it('handles form submission', () => {
const wrapper = shallow(<ExternalConnectorConfig {...props} />);
const preventDefault = jest.fn();
wrapper.find('form').simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(saveExternalConnectorConfig).toHaveBeenCalled();
});
describe('external connector configuration', () => {
it('handles url change', () => {
const wrapper = shallow(<ExternalConnectorConfig {...props} />);
const steps = wrapper.find(EuiSteps);
const input = steps.dive().find('[name="external-connector-url"]');
input.simulate('change', { target: { value: 'url' } });
expect(setExternalConnectorUrl).toHaveBeenCalledWith('url');
});
it('handles Client secret change', () => {
const wrapper = shallow(<ExternalConnectorConfig {...props} />);
const steps = wrapper.find(EuiSteps);
const input = steps.dive().find('[name="external-connector-api-key"]');
input.simulate('change', { target: { value: 'api-key' } });
expect(setExternalConnectorApiKey).toHaveBeenCalledWith('api-key');
});
});
});

View file

@ -0,0 +1,168 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FormEvent, useEffect } from 'react';
import { useActions, useValues } from 'kea';
import {
EuiButton,
EuiButtonEmpty,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiSteps,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AppLogic } from '../../../../app_logic';
import {
PersonalDashboardLayout,
WorkplaceSearchPageTemplate,
} from '../../../../components/layout';
import { NAV, REMOVE_BUTTON } from '../../../../constants';
import { SourceDataItem } from '../../../../types';
import { AddSourceHeader } from './add_source_header';
import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants';
import { ExternalConnectorLogic } from './external_connector_logic';
interface SaveConfigProps {
sourceData: SourceDataItem;
goBack?: () => void;
onDeleteConfig?: () => void;
}
export const ExternalConnectorConfig: React.FC<SaveConfigProps> = ({ goBack, onDeleteConfig }) => {
const serviceType = 'external';
const {
fetchExternalSource,
setExternalConnectorApiKey,
setExternalConnectorUrl,
saveExternalConnectorConfig,
} = useActions(ExternalConnectorLogic);
const { buttonLoading, externalConnectorUrl, externalConnectorApiKey, sourceConfigData } =
useValues(ExternalConnectorLogic);
useEffect(() => {
fetchExternalSource();
}, []);
const handleFormSubmission = (e: FormEvent) => {
e.preventDefault();
saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey });
};
const { name, categories } = sourceConfigData;
const { isOrganization } = useValues(AppLogic);
const saveButton = (
<EuiButton color="primary" fill isLoading={buttonLoading} type="submit">
{OAUTH_SAVE_CONFIG_BUTTON}
</EuiButton>
);
const deleteButton = (
<EuiButton color="danger" fill disabled={buttonLoading} onClick={onDeleteConfig}>
{REMOVE_BUTTON}
</EuiButton>
);
const backButton = <EuiButtonEmpty onClick={goBack}>{OAUTH_BACK_BUTTON}</EuiButtonEmpty>;
const formActions = (
<EuiFormRow>
<EuiFlexGroup justifyContent="flexStart" gutterSize="m" responsive={false}>
<EuiFlexItem grow={false}>{saveButton}</EuiFlexItem>
<EuiFlexItem grow={false}>
{goBack && backButton}
{onDeleteConfig && deleteButton}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
const connectorForm = (
<EuiFlexGroup justifyContent="flexStart" direction="column" responsive={false}>
{/* TODO: get a docs link in here for the external connector
<ConfigDocsLinks
name={name}
documentationUrl={documentationUrl}
applicationPortalUrl={applicationPortalUrl}
applicationLinkTitle={applicationLinkTitle}
/> */}
<EuiSpacer />
<EuiForm>
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.urlLabel',
{
defaultMessage: 'URL',
}
)}
>
<EuiFieldText
value={externalConnectorUrl}
required
type="text"
autoComplete="off"
onChange={(e) => setExternalConnectorUrl(e.target.value)}
name="external-connector-url"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.apiKeyLabel',
{
defaultMessage: 'API key',
}
)}
>
<EuiFieldText
value={externalConnectorApiKey}
required
type="text"
autoComplete="off"
onChange={(e) => setExternalConnectorApiKey(e.target.value)}
name="external-connector-api-key"
/>
</EuiFormRow>
<EuiSpacer />
{formActions}
</EuiForm>
</EuiFlexGroup>
);
const configSteps = [
{
title: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.stepTitle',
{
defaultMessage: 'Provide the appropriate configuration information',
}
),
children: connectorForm,
},
];
const header = <AddSourceHeader name={name} serviceType={serviceType} categories={categories} />;
const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout;
return (
<Layout pageChrome={[NAV.SOURCES, NAV.ADD_SOURCE, name || '...']} isLoading={false}>
{header}
<EuiSpacer size="l" />
<form onSubmit={handleFormSubmission}>
<EuiSteps steps={configSteps} />
</form>
</Layout>
);
};

View file

@ -0,0 +1,144 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LogicMounter, mockHttpValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic';
import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers';
jest.mock('../../../../app_logic', () => ({
AppLogic: { values: { isOrganization: true } },
}));
import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic';
describe('ExternalConnectorLogic', () => {
const { mount } = new LogicMounter(ExternalConnectorLogic);
const { http } = mockHttpValues;
const { navigateToUrl } = mockKibanaValues;
const DEFAULT_VALUES: ExternalConnectorValues = {
dataLoading: true,
buttonLoading: false,
externalConnectorUrl: '',
externalConnectorApiKey: '',
sourceConfigData: {
name: '',
categories: [],
},
};
beforeEach(() => {
jest.clearAllMocks();
mount();
});
it('has expected default values', () => {
expect(ExternalConnectorLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('fetchExternalSourceSuccess', () => {
beforeEach(() => {
ExternalConnectorLogic.actions.fetchExternalSourceSuccess(sourceConfigData);
});
it('turns off the data loading flag', () => {
expect(ExternalConnectorLogic.values.dataLoading).toEqual(false);
});
it('saves the external url', () => {
expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual(
sourceConfigData.configuredFields.url
);
});
it('saves the source config', () => {
expect(ExternalConnectorLogic.values.sourceConfigData).toEqual(sourceConfigData);
});
it('sets undefined url to empty string', () => {
ExternalConnectorLogic.actions.fetchExternalSourceSuccess({
...sourceConfigData,
configuredFields: { ...sourceConfigData.configuredFields, url: undefined },
});
expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual('');
});
it('sets undefined api key to empty string', () => {
ExternalConnectorLogic.actions.fetchExternalSourceSuccess({
...sourceConfigData,
configuredFields: { ...sourceConfigData.configuredFields, apiKey: undefined },
});
expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual('');
});
});
describe('saveExternalConnectorConfigSuccess', () => {
it('turns off the button loading flag', () => {
mount({
buttonLoading: true,
});
ExternalConnectorLogic.actions.saveExternalConnectorConfigSuccess('external');
expect(ExternalConnectorLogic.values.buttonLoading).toEqual(false);
});
});
describe('setExternalConnectorApiKey', () => {
it('updates the api key', () => {
ExternalConnectorLogic.actions.setExternalConnectorApiKey('abcd1234');
expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual('abcd1234');
});
});
describe('setExternalConnectorUrl', () => {
it('updates the url', () => {
ExternalConnectorLogic.actions.setExternalConnectorUrl('https://www.elastic.co');
expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual(
'https://www.elastic.co'
);
});
});
});
describe('listeners', () => {
describe('fetchExternalSource', () => {
it('retrieves config info on the "external" connector', () => {
const promise = Promise.resolve();
http.get.mockReturnValue(promise);
ExternalConnectorLogic.actions.fetchExternalSource();
expect(http.get).toHaveBeenCalledWith(
'/internal/workplace_search/org/settings/connectors/external'
);
});
itShowsServerErrorAsFlashMessage(http.get, () => {
mount();
ExternalConnectorLogic.actions.fetchExternalSource();
});
});
describe('saveExternalConnectorConfig', () => {
it('saves the external connector config', () => {
const saveExternalConnectorConfigSuccess = jest.spyOn(
ExternalConnectorLogic.actions,
'saveExternalConnectorConfigSuccess'
);
ExternalConnectorLogic.actions.saveExternalConnectorConfig({
url: 'url',
apiKey: 'apiKey',
});
expect(saveExternalConnectorConfigSuccess).toHaveBeenCalled();
expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/external');
});
});
});
});

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { i18n } from '@kbn/i18n';
import {
flashAPIErrors,
flashSuccessToast,
clearFlashMessages,
} from '../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../shared/http';
import { KibanaLogic } from '../../../../../shared/kibana';
import { AppLogic } from '../../../../app_logic';
import { getAddPath, getSourcesPath } from '../../../../routes';
import { SourceConfigData } from './add_source_logic';
export interface ExternalConnectorActions {
fetchExternalSource: () => true;
fetchExternalSourceSuccess(sourceConfigData: SourceConfigData): SourceConfigData;
saveExternalConnectorConfigSuccess(externalConnectorId: string): string;
setExternalConnectorApiKey(externalConnectorApiKey: string): string;
saveExternalConnectorConfig(config: ExternalConnectorConfig): ExternalConnectorConfig;
setExternalConnectorUrl(externalConnectorUrl: string): string;
resetSourceState: () => true;
}
export interface ExternalConnectorConfig {
url: string;
apiKey: string;
}
export interface ExternalConnectorValues {
buttonLoading: boolean;
dataLoading: boolean;
externalConnectorApiKey: string;
externalConnectorUrl: string;
sourceConfigData: SourceConfigData | Pick<SourceConfigData, 'name' | 'categories'>;
}
export const ExternalConnectorLogic = kea<
MakeLogicType<ExternalConnectorValues, ExternalConnectorActions>
>({
path: ['enterprise_search', 'workplace_search', 'external_connector_logic'],
actions: {
fetchExternalSource: true,
fetchExternalSourceSuccess: (sourceConfigData) => sourceConfigData,
saveExternalConnectorConfigSuccess: (externalConnectorId) => externalConnectorId,
saveExternalConnectorConfig: (config) => config,
setExternalConnectorApiKey: (externalConnectorApiKey: string) => externalConnectorApiKey,
setExternalConnectorUrl: (externalConnectorUrl: string) => externalConnectorUrl,
},
reducers: {
dataLoading: [
true,
{
fetchExternalSourceSuccess: () => false,
},
],
buttonLoading: [
false,
{
saveExternalConnectorConfigSuccess: () => false,
saveExternalConnectorConfig: () => true,
},
],
externalConnectorUrl: [
'',
{
fetchExternalSourceSuccess: (_, { configuredFields: { url } }) => url || '',
setExternalConnectorUrl: (_, url) => url,
},
],
externalConnectorApiKey: [
'',
{
fetchExternalSourceSuccess: (_, { configuredFields: { apiKey } }) => apiKey || '',
setExternalConnectorApiKey: (_, apiKey) => apiKey,
},
],
sourceConfigData: [
{ name: '', categories: [] },
{
fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData,
},
],
},
listeners: ({ actions }) => ({
fetchExternalSource: async () => {
const route = '/internal/workplace_search/org/settings/connectors/external';
try {
const response = await HttpLogic.values.http.get<SourceConfigData>(route);
actions.fetchExternalSourceSuccess(response);
} catch (e) {
flashAPIErrors(e);
}
},
saveExternalConnectorConfig: async () => {
clearFlashMessages();
// const route = '/internal/workplace_search/org/settings/connectors';
// const http = HttpLogic.values.http.post;
// const params = {
// url,
// api_key: apiKey,
// service_type: 'external',
// };
try {
// const response = await http<SourceConfigData>(route, {
// body: JSON.stringify(params),
// });
flashSuccessToast(
i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.externalConnectorCreated',
{
defaultMessage: 'Successfully updated configuration.',
}
)
);
// TODO: use response data instead
actions.saveExternalConnectorConfigSuccess('external');
KibanaLogic.values.navigateToUrl(
getSourcesPath(`${getAddPath('external')}`, AppLogic.values.isOrganization)
);
} catch (e) {
// flashAPIErrors(e);
}
},
}),
});

View file

@ -61,7 +61,8 @@ export const GitHubViaApp: React.FC<GithubViaAppProps> = ({ isGithubEnterpriseSe
const { hasPlatinumLicense } = useValues(LicensingLogic);
const name = isGithubEnterpriseServer ? SOURCE_NAMES.GITHUB_ENTERPRISE : SOURCE_NAMES.GITHUB;
const data = staticSourceData.find((source) => source.name === name);
const serviceType = isGithubEnterpriseServer ? 'github_enterprise_server' : 'github';
const data = staticSourceData.find((source) => source.serviceType === serviceType);
const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout;
const handleSubmit = (e: FormEvent) => {

View file

@ -11,40 +11,45 @@ import React from 'react';
import { shallow } from 'enzyme';
import { EuiLink, EuiPanel, EuiTitle } from '@elastic/eui';
import { EuiPanel, EuiTitle } from '@elastic/eui';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { LicenseBadge } from '../../../../components/shared/license_badge';
import { staticCustomSourceData } from '../../source_data';
import { SaveCustom } from './save_custom';
describe('SaveCustom', () => {
const props = {
documentationUrl: 'http://string.boolean',
const mockValues = {
newCustomSource: {
accessToken: 'dsgfsd',
key: 'sdfs',
name: 'source',
id: '12e1',
id: 'id',
accessToken: 'token',
name: 'name',
},
sourceData: staticCustomSourceData,
isOrganization: true,
header: <h1>Header</h1>,
hasPlatinumLicense: true,
};
beforeEach(() => {
setMockValues(mockValues);
});
it('renders', () => {
setMockValues({ hasPlatinumLicense: true });
const wrapper = shallow(<SaveCustom {...props} />);
const wrapper = shallow(<SaveCustom />);
expect(wrapper.find(EuiPanel)).toHaveLength(1);
expect(wrapper.find(EuiTitle)).toHaveLength(4);
expect(wrapper.find(EuiLinkTo)).toHaveLength(1);
expect(wrapper.find(LicenseBadge)).toHaveLength(0);
});
it('renders platinum LicenseBadge and link', () => {
setMockValues({ hasPlatinumLicense: false });
const wrapper = shallow(<SaveCustom {...props} />);
it('renders platinum license badge if license is not present', () => {
setMockValues({ ...mockValues, hasPlatinumLicense: false });
const wrapper = shallow(<SaveCustom />);
expect(wrapper.find(LicenseBadge)).toHaveLength(1);
expect(wrapper.find(EuiLink)).toHaveLength(1);
expect(wrapper.find(EuiTitle)).toHaveLength(4);
expect(wrapper.find(EuiLinkTo)).toHaveLength(1);
});
});

View file

@ -20,6 +20,7 @@ import {
EuiTitle,
EuiLink,
EuiPanel,
EuiCode,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -27,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { docLinks } from '../../../../../shared/doc_links';
import { LicensingLogic } from '../../../../../shared/licensing';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { AppLogic } from '../../../../app_logic';
import { LicenseBadge } from '../../../../components/shared/license_badge';
import {
SOURCES_PATH,
@ -34,11 +36,12 @@ import {
getContentSourcePath,
getSourcesPath,
} from '../../../../routes';
import { CustomSource } from '../../../../types';
import { LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants';
import { SourceIdentifier } from '../source_identifier';
import { AddCustomSourceLogic } from './add_custom_source_logic';
import { AddSourceHeader } from './add_source_header';
import {
SAVE_CUSTOM_BODY1,
SAVE_CUSTOM_BODY2,
@ -51,23 +54,20 @@ import {
SAVE_CUSTOM_DOC_PERMISSIONS_LINK,
} from './constants';
interface SaveCustomProps {
documentationUrl: string;
newCustomSource: CustomSource;
isOrganization: boolean;
header: React.ReactNode;
}
export const SaveCustom: React.FC<SaveCustomProps> = ({
documentationUrl,
newCustomSource: { id, name },
isOrganization,
header,
}) => {
export const SaveCustom: React.FC = () => {
const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic);
const { isOrganization } = useValues(AppLogic);
const { hasPlatinumLicense } = useValues(LicensingLogic);
const {
serviceType,
configuration: { githubRepository, documentationUrl },
name,
categories = [],
} = sourceData;
return (
<>
{header}
<AddSourceHeader name={name} serviceType={serviceType} categories={categories} />
<EuiSpacer />
<EuiFlexGroup direction="row">
<EuiFlexItem grow={2}>
@ -84,7 +84,7 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({
'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading',
{
defaultMessage: '{name} Created',
values: { name },
values: { name: newCustomSource.name },
}
)}
</h1>
@ -93,7 +93,22 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({
<EuiText grow={false}>
<EuiTextAlign textAlign="center">
{SAVE_CUSTOM_BODY1}
<br />
<EuiSpacer size="s" />
{serviceType !== 'custom' && githubRepository && (
<>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.repositoryInstructions"
defaultMessage="First you'll need to clone and deploy this repository"
/>
<br />
<EuiCode>
<EuiLinkTo to={`https://github.com/${githubRepository}`}>
{githubRepository}
</EuiLinkTo>
</EuiCode>
<EuiSpacer size="s" />
</>
)}
{SAVE_CUSTOM_BODY2}
<br />
<EuiLinkTo to={getSourcesPath(SOURCES_PATH, isOrganization)}>
@ -105,7 +120,7 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({
</EuiFlexGroup>
<EuiHorizontalRule />
<EuiSpacer size="s" />
<SourceIdentifier id={id} />
<SourceIdentifier id={newCustomSource.id} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={1}>
@ -119,17 +134,32 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({
<EuiSpacer size="xs" />
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.documentation.text"
defaultMessage="{link} to learn more about Custom API Sources."
values={{
link: (
<EuiLink target="_blank" href={documentationUrl}>
{SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK}
</EuiLink>
),
}}
/>
{serviceType === 'custom' ? (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.documentation.text"
defaultMessage="{link} to learn more about Custom API Sources."
values={{
link: (
<EuiLink target="_blank" href={documentationUrl}>
{SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK}
</EuiLink>
),
}}
/>
) : (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.namedSourceDocumentation.text"
defaultMessage="{link} to learn more about deploying a {name} source."
values={{
link: (
<EuiLink target="_blank" href={documentationUrl}>
{SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK}
</EuiLink>
),
name,
}}
/>
)}
</p>
</EuiText>
</div>
@ -149,7 +179,7 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({
<EuiLinkTo
to={getContentSourcePath(
SOURCE_DISPLAY_SETTINGS_PATH,
id,
newCustomSource.id,
isOrganization
)}
>

View file

@ -41,7 +41,7 @@ import {
SAVE_CHANGES_BUTTON,
REMOVE_BUTTON,
} from '../../../constants';
import { SourceDataItem } from '../../../types';
import { getEditPath } from '../../../routes';
import { handlePrivateKeyUpload } from '../../../utils';
import { AddSourceLogic } from '../components/add_source/add_source_logic';
import {
@ -57,7 +57,6 @@ import {
SYNC_DIAGNOSTICS_DESCRIPTION,
SYNC_DIAGNOSTICS_BUTTON,
} from '../constants';
import { staticSourceData } from '../source_data';
import { SourceLogic } from '../source_logic';
import { DownloadDiagnosticsButton } from './download_diagnostics_button';
@ -96,8 +95,7 @@ export const SourceSettings: React.FC = () => {
const editPath = isGithubApp
? undefined // undefined for GitHub apps, as they are configured source-wide, and don't use a connector where you can edit the configuration
: (staticSourceData.find((source) => source.serviceType === serviceType) as SourceDataItem)
.editPath;
: getEditPath(serviceType);
const [inputValue, setValue] = useState(name);
const [confirmModalVisible, setModalVisibility] = useState(false);

View file

@ -10,52 +10,13 @@ import { i18n } from '@kbn/i18n';
import { docLinks } from '../../../shared/doc_links';
import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants';
import {
ADD_BOX_PATH,
ADD_CONFLUENCE_PATH,
ADD_CONFLUENCE_SERVER_PATH,
ADD_DROPBOX_PATH,
ADD_GITHUB_ENTERPRISE_PATH,
ADD_GITHUB_PATH,
ADD_GMAIL_PATH,
ADD_GOOGLE_DRIVE_PATH,
ADD_JIRA_PATH,
ADD_JIRA_SERVER_PATH,
ADD_ONEDRIVE_PATH,
ADD_SALESFORCE_PATH,
ADD_SALESFORCE_SANDBOX_PATH,
ADD_SERVICENOW_PATH,
ADD_SHAREPOINT_PATH,
ADD_SLACK_PATH,
ADD_ZENDESK_PATH,
ADD_CUSTOM_PATH,
EDIT_BOX_PATH,
EDIT_CONFLUENCE_PATH,
EDIT_CONFLUENCE_SERVER_PATH,
EDIT_DROPBOX_PATH,
EDIT_GITHUB_ENTERPRISE_PATH,
EDIT_GITHUB_PATH,
EDIT_GMAIL_PATH,
EDIT_GOOGLE_DRIVE_PATH,
EDIT_JIRA_PATH,
EDIT_JIRA_SERVER_PATH,
EDIT_ONEDRIVE_PATH,
EDIT_SALESFORCE_PATH,
EDIT_SALESFORCE_SANDBOX_PATH,
EDIT_SERVICENOW_PATH,
EDIT_SHAREPOINT_PATH,
EDIT_SLACK_PATH,
EDIT_ZENDESK_PATH,
EDIT_CUSTOM_PATH,
} from '../../routes';
import { FeatureIds, SourceDataItem } from '../../types';
export const staticSourceData = [
export const staticSourceData: SourceDataItem[] = [
{
name: SOURCE_NAMES.BOX,
iconName: SOURCE_NAMES.BOX,
serviceType: 'box',
addPath: ADD_BOX_PATH,
editPath: EDIT_BOX_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -79,12 +40,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.CONFLUENCE,
iconName: SOURCE_NAMES.CONFLUENCE,
serviceType: 'confluence_cloud',
addPath: ADD_CONFLUENCE_PATH,
editPath: EDIT_CONFLUENCE_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -113,12 +74,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.CONFLUENCE_SERVER,
iconName: SOURCE_NAMES.CONFLUENCE_SERVER,
serviceType: 'confluence_server',
addPath: ADD_CONFLUENCE_SERVER_PATH,
editPath: EDIT_CONFLUENCE_SERVER_PATH,
configuration: {
isPublicKey: true,
hasOauthRedirect: true,
@ -145,12 +106,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.DROPBOX,
iconName: SOURCE_NAMES.DROPBOX,
serviceType: 'dropbox',
addPath: ADD_DROPBOX_PATH,
editPath: EDIT_DROPBOX_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -174,12 +135,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.GITHUB,
iconName: SOURCE_NAMES.GITHUB,
serviceType: 'github',
addPath: ADD_GITHUB_PATH,
editPath: EDIT_GITHUB_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -210,12 +171,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.GITHUB_ENTERPRISE,
iconName: SOURCE_NAMES.GITHUB_ENTERPRISE,
serviceType: 'github_enterprise_server',
addPath: ADD_GITHUB_ENTERPRISE_PATH,
editPath: EDIT_GITHUB_ENTERPRISE_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -252,12 +213,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.GMAIL,
iconName: SOURCE_NAMES.GMAIL,
serviceType: 'gmail',
addPath: ADD_GMAIL_PATH,
editPath: EDIT_GMAIL_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -273,9 +234,8 @@ export const staticSourceData = [
},
{
name: SOURCE_NAMES.GOOGLE_DRIVE,
iconName: SOURCE_NAMES.GOOGLE_DRIVE,
serviceType: 'google_drive',
addPath: ADD_GOOGLE_DRIVE_PATH,
editPath: EDIT_GOOGLE_DRIVE_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -303,12 +263,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.JIRA,
iconName: SOURCE_NAMES.JIRA,
serviceType: 'jira_cloud',
addPath: ADD_JIRA_PATH,
editPath: EDIT_JIRA_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -339,12 +299,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.JIRA_SERVER,
iconName: SOURCE_NAMES.JIRA_SERVER,
serviceType: 'jira_server',
addPath: ADD_JIRA_SERVER_PATH,
editPath: EDIT_JIRA_SERVER_PATH,
configuration: {
isPublicKey: true,
hasOauthRedirect: true,
@ -374,12 +334,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.ONEDRIVE,
iconName: SOURCE_NAMES.ONEDRIVE,
serviceType: 'one_drive',
addPath: ADD_ONEDRIVE_PATH,
editPath: EDIT_ONEDRIVE_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -403,12 +363,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.SALESFORCE,
iconName: SOURCE_NAMES.SALESFORCE,
serviceType: 'salesforce',
addPath: ADD_SALESFORCE_PATH,
editPath: EDIT_SALESFORCE_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -439,12 +399,13 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.SALESFORCE_SANDBOX,
iconName: SOURCE_NAMES.SALESFORCE_SANDBOX,
serviceType: 'salesforce_sandbox',
addPath: ADD_SALESFORCE_SANDBOX_PATH,
editPath: EDIT_SALESFORCE_SANDBOX_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -475,12 +436,12 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.SERVICENOW,
iconName: SOURCE_NAMES.SERVICENOW,
serviceType: 'service_now',
addPath: ADD_SERVICENOW_PATH,
editPath: EDIT_SERVICENOW_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: false,
@ -508,12 +469,44 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.SHAREPOINT,
iconName: SOURCE_NAMES.SHAREPOINT,
serviceType: 'share_point',
addPath: ADD_SHAREPOINT_PATH,
editPath: EDIT_SHAREPOINT_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
needsBaseUrl: false,
documentationUrl: docLinks.workplaceSearchSharePoint,
applicationPortalUrl: 'https://portal.azure.com/',
},
objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES],
features: {
basicOrgContext: [
FeatureIds.SyncFrequency,
FeatureIds.SyncedItems,
FeatureIds.GlobalAccessPermissions,
],
basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions],
platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
platinumPrivateContext: [
FeatureIds.Private,
FeatureIds.SyncFrequency,
FeatureIds.SyncedItems,
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
externalConnectorAvailable: true,
},
// TODO: temporary hack until backend sends us stuff
{
name: SOURCE_NAMES.SHAREPOINT,
iconName: SOURCE_NAMES.SHAREPOINT,
serviceType: 'external',
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -537,12 +530,54 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
externalConnectorAvailable: false,
customConnectorAvailable: false,
},
{
name: SOURCE_NAMES.SHAREPOINT_SERVER,
iconName: SOURCE_NAMES.SHAREPOINT_SERVER,
categories: [
i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.fileSharing', {
defaultMessage: 'File Sharing',
}),
i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.storage', {
defaultMessage: 'Storage',
}),
i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.cloud', {
defaultMessage: 'Cloud',
}),
i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.microsoft', {
defaultMessage: 'Microsoft',
}),
i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.office', {
defaultMessage: 'Office 365',
}),
],
serviceType: 'share_point_server', // this doesn't exist on the BE
configuration: {
isPublicKey: false,
hasOauthRedirect: false,
needsBaseUrl: false,
// helpText: i18n.translate( // TODO updatae this
// 'xpack.enterpriseSearch.workplaceSearch.sources.helpText.sharepointServer',
// {
// defaultMessage:
// "Here is some help text. It should probably give the user a heads up that they're going to have to deploy some code.",
// }
// ),
documentationUrl: docLinks.workplaceSearchCustomSources, // TODO update this
applicationPortalUrl: '',
githubRepository: 'elastic/enterprise-search-sharepoint-server-connector',
},
accountContextOnly: false,
internalConnectorAvailable: false,
customConnectorAvailable: true,
},
{
name: SOURCE_NAMES.SLACK,
iconName: SOURCE_NAMES.SLACK,
serviceType: 'slack',
addPath: ADD_SLACK_PATH,
editPath: EDIT_SLACK_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -559,12 +594,13 @@ export const staticSourceData = [
platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent],
},
accountContextOnly: true,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.ZENDESK,
iconName: SOURCE_NAMES.ZENDESK,
serviceType: 'zendesk',
addPath: ADD_ZENDESK_PATH,
editPath: EDIT_ZENDESK_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: true,
@ -588,23 +624,26 @@ export const staticSourceData = [
],
},
accountContextOnly: false,
internalConnectorAvailable: true,
},
{
name: SOURCE_NAMES.CUSTOM,
serviceType: 'custom',
addPath: ADD_CUSTOM_PATH,
editPath: EDIT_CUSTOM_PATH,
configuration: {
isPublicKey: false,
hasOauthRedirect: false,
needsBaseUrl: false,
helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', {
defaultMessage:
'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.',
}),
documentationUrl: docLinks.workplaceSearchCustomSources,
applicationPortalUrl: '',
},
accountContextOnly: false,
];
export const staticCustomSourceData: SourceDataItem = {
name: SOURCE_NAMES.CUSTOM,
iconName: SOURCE_NAMES.CUSTOM,
categories: ['API', 'Custom'],
serviceType: 'custom',
configuration: {
isPublicKey: false,
hasOauthRedirect: false,
needsBaseUrl: false,
helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', {
defaultMessage:
'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.',
}),
documentationUrl: docLinks.workplaceSearchCustomSources,
applicationPortalUrl: '',
},
] as SourceDataItem[];
accountContextOnly: false,
customConnectorAvailable: true,
};

View file

@ -18,6 +18,7 @@ jest.mock('../../app_logic', () => ({
import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers';
import { AppLogic } from '../../app_logic';
import { staticSourceData } from './source_data';
import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic';
describe('SourcesLogic', () => {
@ -32,8 +33,8 @@ describe('SourcesLogic', () => {
const defaultValues = {
contentSources: [],
privateContentSources: [],
sourceData: [],
availableSources: [],
sourceData: staticSourceData.map((data) => ({ ...data, connected: false })),
availableSources: staticSourceData.map((data) => ({ ...data, connected: false })),
configuredSources: [],
serviceTypes: [],
permissionsModal: null,
@ -316,7 +317,7 @@ describe('SourcesLogic', () => {
it('availableSources & configuredSources have correct length', () => {
SourcesLogic.actions.onInitializeSources(serverResponse);
expect(SourcesLogic.values.availableSources).toHaveLength(1);
expect(SourcesLogic.values.availableSources).toHaveLength(14);
expect(SourcesLogic.values.configuredSources).toHaveLength(5);
});
});

View file

@ -178,7 +178,7 @@ export const SourcesLogic = kea<MakeLogicType<ISourcesValues, ISourcesActions>>(
if (isOrganization && !values.serverStatuses) {
// We want to get the initial statuses from the server to compare our polling results to.
const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint);
actions.setServerSourceStatuses(sourceStatuses);
actions.setServerSourceStatuses(sourceStatuses ?? []);
}
},
// We poll the server and if the status update, we trigger a new fetch of the sources.
@ -190,7 +190,7 @@ export const SourcesLogic = kea<MakeLogicType<ISourcesValues, ISourcesActions>>(
pollingInterval = window.setInterval(async () => {
const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint);
sourceStatuses.some((source: ContentSourceStatus) => {
(sourceStatuses ?? []).some((source: ContentSourceStatus) => {
if (serverStatuses && serverStatuses[source.id] !== source.status.status) {
return actions.initializeSources();
}
@ -249,7 +249,7 @@ export const SourcesLogic = kea<MakeLogicType<ISourcesValues, ISourcesActions>>(
export const fetchSourceStatuses = async (
isOrganization: boolean,
breakpoint: BreakPointFunction
) => {
): Promise<ContentSourceStatus[] | undefined> => {
const route = isOrganization
? '/internal/workplace_search/org/sources/status'
: '/internal/workplace_search/account/sources/status';
@ -267,8 +267,7 @@ export const fetchSourceStatuses = async (
}
}
// TODO: remove casting. return type should be ContentSourceStatus[] | undefined
return response as ContentSourceStatus[];
return response;
};
const updateSourcesOnToggle = (
@ -293,7 +292,7 @@ const updateSourcesOnToggle = (
* The second is the base list of available sources that the server sends back in the collection,
* `availableTypes` that is the source of truth for the name and whether the source has been configured.
*
* Fnally, also in the collection response is the current set of connected sources. We check for the
* Finally, also in the collection response is the current set of connected sources. We check for the
* existence of a `connectedSource` of the type in the loop and set `connected` to true so that the UI
* can diplay "Add New" instead of "Connect", the latter of which is displated only when a connector
* has been configured but there are no connected sources yet.
@ -304,13 +303,13 @@ export const mergeServerAndStaticData = (
contentSources: ContentSourceDetails[]
) => {
const combined = [] as CombinedDataItem[];
serverData.forEach((serverItem) => {
const type = serverItem.serviceType;
const staticItem = staticData.find(({ serviceType }) => serviceType === type);
staticData.forEach((staticItem) => {
const type = staticItem.serviceType;
const serverItem = serverData.find(({ serviceType }) => serviceType === type);
const connectedSource = contentSources.find(({ serviceType }) => serviceType === type);
combined.push({
...serverItem,
...staticItem,
...serverItem,
connected: !!connectedSource,
} as CombinedDataItem);
});

View file

@ -34,7 +34,7 @@ describe('SourcesRouter', () => {
});
it('renders sources routes', () => {
const TOTAL_ROUTES = 63;
const TOTAL_ROUTES = 86;
const wrapper = shallow(<SourcesRouter />);
expect(wrapper.find(Switch)).toHaveLength(1);
@ -45,8 +45,8 @@ describe('SourcesRouter', () => {
setMockValues({ ...mockValues, hasPlatinumLicense: false });
const wrapper = shallow(<SourcesRouter />);
expect(wrapper.find(Redirect).first().prop('from')).toEqual(ADD_SOURCE_PATH);
expect(wrapper.find(Redirect).first().prop('to')).toEqual(SOURCES_PATH);
expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH);
expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH);
});
it('redirects when cannot create sources', () => {

View file

@ -14,19 +14,27 @@ import { useActions, useValues } from 'kea';
import { LicensingLogic } from '../../../shared/licensing';
import { AppLogic } from '../../app_logic';
import {
ADD_GITHUB_VIA_APP_PATH,
ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH,
GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE,
GITHUB_VIA_APP_SERVICE_TYPE,
} from '../../constants';
import {
ADD_SOURCE_PATH,
SOURCE_DETAILS_PATH,
PRIVATE_SOURCES_PATH,
SOURCES_PATH,
getSourcesPath,
getAddPath,
ADD_CUSTOM_PATH,
} from '../../routes';
import { hasMultipleConnectorOptions } from '../../utils';
import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source';
import { AddCustomSource } from './components/add_source/add_custom_source';
import { ConfigurationChoice } from './components/add_source/configuration_choice';
import { ExternalConnectorConfig } from './components/add_source/external_connector_config';
import { OrganizationSources } from './organization_sources';
import { PrivateSources } from './private_sources';
import { staticSourceData } from './source_data';
import { staticCustomSourceData, staticSourceData as sources } from './source_data';
import { SourceRouter } from './source_router';
import { SourcesLogic } from './sources_logic';
@ -68,36 +76,121 @@ export const SourcesRouter: React.FC = () => {
<Route exact path={SOURCES_PATH}>
<OrganizationSources />
</Route>
<Route exact path={ADD_GITHUB_VIA_APP_PATH}>
<Route exact path={getAddPath(GITHUB_VIA_APP_SERVICE_TYPE)}>
<GitHubViaApp isGithubEnterpriseServer={false} />
</Route>
<Route exact path={ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH}>
<Route exact path={getAddPath(GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE)}>
<GitHubViaApp isGithubEnterpriseServer />
</Route>
{staticSourceData.map(({ addPath, accountContextOnly }, i) => (
<Route key={i} exact path={getSourcesPath(addPath, isOrganization)}>
{!hasPlatinumLicense && accountContextOnly ? (
<Redirect exact from={ADD_SOURCE_PATH} to={SOURCES_PATH} />
) : (
<AddSource sourceIndex={i} />
)}
</Route>
))}
{staticSourceData.map(({ addPath }, i) => (
<Route key={i} exact path={`${getSourcesPath(addPath, isOrganization)}/connect`}>
<AddSource connect sourceIndex={i} />
</Route>
))}
{staticSourceData.map(({ addPath }, i) => (
<Route key={i} exact path={`${getSourcesPath(addPath, isOrganization)}/reauthenticate`}>
<AddSource reAuthenticate sourceIndex={i} />
</Route>
))}
{staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => {
if (needsConfiguration)
{sources.map((sourceData, i) => {
const { serviceType, externalConnectorAvailable, internalConnectorAvailable } = sourceData;
const path = `${getSourcesPath(getAddPath(serviceType), isOrganization)}`;
const defaultOption = internalConnectorAvailable
? 'internal'
: externalConnectorAvailable
? 'external'
: 'custom';
return (
<Route key={i} exact path={path}>
{hasMultipleConnectorOptions(sourceData) ? (
<ConfigurationChoice sourceData={sourceData} />
) : (
<Redirect exact from={path} to={`${path}/${defaultOption}`} />
)}
</Route>
);
})}
<Route exact path={getSourcesPath(ADD_CUSTOM_PATH, isOrganization)}>
<AddCustomSource sourceData={staticCustomSourceData} />
</Route>
{sources
.filter((sourceData) => sourceData.internalConnectorAvailable)
.map((sourceData, i) => {
const { serviceType, accountContextOnly } = sourceData;
return (
<Route key={i} exact path={`${getSourcesPath(addPath, isOrganization)}/configure`}>
<AddSource configure sourceIndex={i} />
<Route
key={i}
exact
path={`${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`}
>
{!hasPlatinumLicense && accountContextOnly ? (
<Redirect exact from={ADD_SOURCE_PATH} to={SOURCES_PATH} />
) : (
<AddSource sourceData={sourceData} />
)}
</Route>
);
})}
{sources
.filter((sourceData) => sourceData.externalConnectorAvailable)
.map((sourceData, i) => {
const { serviceType, accountContextOnly } = sourceData;
return (
<Route
key={i}
exact
path={`${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`}
>
{!hasPlatinumLicense && accountContextOnly ? (
<Redirect exact from={ADD_SOURCE_PATH} to={SOURCES_PATH} />
) : (
<ExternalConnectorConfig sourceData={sourceData} />
)}
</Route>
);
})}
{sources
.filter((sourceData) => sourceData.customConnectorAvailable)
.map((sourceData, i) => {
const { serviceType, accountContextOnly } = sourceData;
return (
<Route
key={i}
exact
path={`${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`}
>
{!hasPlatinumLicense && accountContextOnly ? (
<Redirect exact from={ADD_SOURCE_PATH} to={SOURCES_PATH} />
) : (
<AddCustomSource sourceData={sourceData} initialValue={sourceData.name} />
)}
</Route>
);
})}
{sources.map((sourceData, i) => (
<Route
key={i}
exact
path={`${getSourcesPath(getAddPath(sourceData.serviceType), isOrganization)}/connect`}
>
<AddSource connect sourceData={sourceData} />
</Route>
))}
{sources.map((sourceData, i) => (
<Route
key={i}
exact
path={`${getSourcesPath(
getAddPath(sourceData.serviceType),
isOrganization
)}/reauthenticate`}
>
<AddSource reAuthenticate sourceData={sourceData} />
</Route>
))}
{sources.map((sourceData, i) => {
if (sourceData.configuration.needsConfiguration)
return (
<Route
key={i}
exact
path={`${getSourcesPath(
getAddPath(sourceData.serviceType),
isOrganization
)}/configure`}
>
<AddSource configure sourceData={sourceData} />
</Route>
);
})}

View file

@ -33,9 +33,7 @@ import {
PRIVATE_SOURCE,
UPDATE_BUTTON,
} from '../../../constants';
import { getSourcesPath } from '../../../routes';
import { SourceDataItem } from '../../../types';
import { staticSourceData } from '../../content_sources/source_data';
import { getAddPath, getEditPath, getSourcesPath } from '../../../routes';
import { SettingsLogic } from '../settings_logic';
export const Connectors: React.FC = () => {
@ -52,9 +50,9 @@ export const Connectors: React.FC = () => {
);
const getRowActions = (configured: boolean, serviceType: string, supportedByLicense: boolean) => {
const { addPath, editPath } = staticSourceData.find(
(s) => s.serviceType === serviceType
) as SourceDataItem;
const addPath = getAddPath(serviceType);
const editPath = getEditPath(serviceType);
const configurePath = getSourcesPath(addPath, true);
const updateButtons = (

View file

@ -18,6 +18,8 @@ import { EuiConfirmModal } from '@elastic/eui';
import { SaveConfig } from '../../content_sources/components/add_source/save_config';
import { staticSourceData } from '../../content_sources/source_data';
import { SourceConfig } from './source_config';
describe('SourceConfig', () => {
@ -31,7 +33,7 @@ describe('SourceConfig', () => {
});
it('renders', () => {
const wrapper = shallow(<SourceConfig sourceIndex={1} />);
const wrapper = shallow(<SourceConfig sourceData={staticSourceData[1]} />);
const saveConfig = wrapper.find(SaveConfig);
// Trigger modal visibility
@ -42,13 +44,13 @@ describe('SourceConfig', () => {
it('renders a breadcrumb fallback while data is loading', () => {
setMockValues({ dataLoading: true, sourceConfigData: {} });
const wrapper = shallow(<SourceConfig sourceIndex={1} />);
const wrapper = shallow(<SourceConfig sourceData={staticSourceData[1]} />);
expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']);
});
it('handles delete click', () => {
const wrapper = shallow(<SourceConfig sourceIndex={1} />);
const wrapper = shallow(<SourceConfig sourceData={staticSourceData[1]} />);
const saveConfig = wrapper.find(SaveConfig);
// Trigger modal visibility
@ -60,7 +62,7 @@ describe('SourceConfig', () => {
});
it('saves source config', () => {
const wrapper = shallow(<SourceConfig sourceIndex={1} />);
const wrapper = shallow(<SourceConfig sourceData={staticSourceData[1]} />);
const saveConfig = wrapper.find(SaveConfig);
// Trigger modal visibility
@ -72,7 +74,7 @@ describe('SourceConfig', () => {
});
it('cancels and closes modal', () => {
const wrapper = shallow(<SourceConfig sourceIndex={1} />);
const wrapper = shallow(<SourceConfig sourceData={staticSourceData[1]} />);
const saveConfig = wrapper.find(SaveConfig);
// Trigger modal visibility

View file

@ -18,16 +18,15 @@ import { SourceDataItem } from '../../../types';
import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header';
import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic';
import { SaveConfig } from '../../content_sources/components/add_source/save_config';
import { staticSourceData } from '../../content_sources/source_data';
import { SettingsLogic } from '../settings_logic';
interface SourceConfigProps {
sourceIndex: number;
sourceData: SourceDataItem;
}
export const SourceConfig: React.FC<SourceConfigProps> = ({ sourceIndex }) => {
export const SourceConfig: React.FC<SourceConfigProps> = ({ sourceData }) => {
const [confirmModalVisible, setConfirmModalVisibility] = useState(false);
const { configuration, serviceType } = staticSourceData[sourceIndex] as SourceDataItem;
const { configuration, serviceType } = sourceData;
const { deleteSourceConfig } = useActions(SettingsLogic);
const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic);
const {

View file

@ -14,6 +14,7 @@ import {
ORG_SETTINGS_CUSTOMIZE_PATH,
ORG_SETTINGS_CONNECTORS_PATH,
ORG_SETTINGS_OAUTH_APPLICATION_PATH,
getEditPath,
} from '../../routes';
import { staticSourceData } from '../content_sources/source_data';
@ -41,9 +42,9 @@ export const SettingsRouter: React.FC = () => {
<Route exact path={ORG_SETTINGS_OAUTH_APPLICATION_PATH}>
<OauthApplication />
</Route>
{staticSourceData.map(({ editPath }, i) => (
<Route key={i} exact path={editPath}>
<SourceConfig sourceIndex={i} />
{staticSourceData.map((sourceData, i) => (
<Route key={i} exact path={getEditPath(sourceData.serviceType)}>
<SourceConfig sourceData={sourceData} />
</Route>
))}
<Route>

View file

@ -38,6 +38,14 @@ const oauthConfigSchema = schema.object({
consumer_key: schema.maybe(schema.string()),
});
const externalConnectorSchema = schema.object({
url: schema.string(),
api_key: schema.string(),
service_type: schema.string(),
});
const postConnectorSchema = schema.oneOf([externalConnectorSchema, oauthConfigSchema]);
const displayFieldSchema = schema.object({
fieldName: schema.string(),
label: schema.string(),
@ -872,7 +880,7 @@ export function registerOrgSourceOauthConfigurationsRoute({
{
path: '/internal/workplace_search/org/settings/connectors',
validate: {
body: oauthConfigSchema,
body: postConnectorSchema,
},
},
enterpriseSearchRequestHandler.createRequest({