From 46d587a19f678e2f3ef1cb3ba35817d843d095e9 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Tue, 17 Nov 2020 10:28:44 -0600 Subject: [PATCH] [Workplace Search] Migrate SourcesLogic from ent-search (#83544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Copy and paste sources logic This is simply a copy & paste of the sources_logic file from ent-search. The only changes were adding the comment at the top and changing how lodash imports, per linting requirements * Add types The “I” prefix has been removed, per agreed-upon standard * Add type declaration to staticSourceData Yay TypeScript :roll_eyes: * Update route path For all other routes, we use the account/org syntax. For this one, I missed it and forgot to add ‘account’ for the route path. This fixes it * Update SourcesLogic to work with Kibana - Remove routes/http in favor of HttpLogic - Remove local flash messages in favor of global messages - Update paths to imports - Remove "I"s from interface names - Varions type fixes --- .../applications/workplace_search/types.ts | 54 ++++ .../views/content_sources/source_data.tsx | 4 +- .../views/content_sources/sources_logic.ts | 284 ++++++++++++++++++ .../routes/workplace_search/sources.test.ts | 4 +- .../server/routes/workplace_search/sources.ts | 2 +- 5 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index f09160d513344a..801bcda2a319ac 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -36,6 +36,41 @@ export interface User { groupIds: string[]; } +export interface Features { + basicOrgContext?: FeatureIds[]; + basicOrgContextExcludedFeatures?: FeatureIds[]; + platinumOrgContext?: FeatureIds[]; + platinumPrivateContext: FeatureIds[]; +} + +export interface Configuration { + isPublicKey: boolean; + needsBaseUrl: boolean; + needsSubdomain?: boolean; + needsConfiguration?: boolean; + hasOauthRedirect: boolean; + baseUrlTitle?: string; + helpText: string; + documentationUrl: string; + applicationPortalUrl?: string; + applicationLinkTitle?: string; +} + +export interface SourceDataItem { + name: string; + serviceType: string; + configuration: Configuration; + configured?: boolean; + connected?: boolean; + features?: Features; + objTypes?: string[]; + sourceDescription: string; + connectStepDescription: string; + addPath: string; + editPath: string; + accountContextOnly: boolean; +} + export interface ContentSource { id: string; serviceType: string; @@ -54,6 +89,25 @@ export interface ContentSourceDetails extends ContentSource { boost: number; } +export interface ContentSourceStatus { + id: string; + name: string; + service_type: string; + status: { + status: string; + synced_at: string; + error_reason: number; + }; +} + +export interface Connector { + serviceType: string; + name: string; + configured: boolean; + supportedByLicense: boolean; + accountContextOnly: boolean; +} + export interface SourcePriority { [id: string]: number; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index d04b2cb16d3088..dff9895dd84f95 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -59,7 +59,7 @@ import { CUSTOM_SOURCE_DOCS_URL, } from '../../routes'; -import { FeatureIds } from '../../types'; +import { FeatureIds, SourceDataItem } from '../../types'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; @@ -740,4 +740,4 @@ export const staticSourceData = [ connectStepDescription: connectStepDescription.empty, accountContextOnly: false, }, -]; +] as SourceDataItem[]; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts new file mode 100644 index 00000000000000..eacba312d5da67 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -0,0 +1,284 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep, findIndex } from 'lodash'; + +import { kea, MakeLogicType } from 'kea'; + +import { HttpLogic } from '../../../shared/http'; + +import { + flashAPIErrors, + setSuccessMessage, + FlashMessagesLogic, +} from '../../../shared/flash_messages'; + +import { Connector, ContentSourceDetails, ContentSourceStatus, SourceDataItem } from '../../types'; + +import { staticSourceData } from './source_data'; + +import { AppLogic } from '../../app_logic'; + +const ORG_SOURCES_PATH = '/api/workplace_search/org/sources'; +const ACCOUNT_SOURCES_PATH = '/api/workplace_search/account/sources'; + +interface ServerStatuses { + [key: string]: string; +} + +export interface ISourcesActions { + setServerSourceStatuses(statuses: ContentSourceStatus[]): ContentSourceStatus[]; + onInitializeSources(serverResponse: ISourcesServerResponse): ISourcesServerResponse; + onSetSearchability( + sourceId: string, + searchable: boolean + ): { sourceId: string; searchable: boolean }; + setAddedSource( + addedSourceName: string, + additionalConfiguration: boolean, + serviceType: string + ): { addedSourceName: string; additionalConfiguration: boolean; serviceType: string }; + resetFlashMessages(): void; + resetPermissionsModal(): void; + resetSourcesState(): void; + initializeSources(): void; + pollForSourceStatusChanges(): void; + setSourceSearchability( + sourceId: string, + searchable: boolean + ): { sourceId: string; searchable: boolean }; +} + +export interface IPermissionsModalProps { + addedSourceName: string; + serviceType: string; + additionalConfiguration: boolean; +} + +type CombinedDataItem = SourceDataItem & ContentSourceDetails; + +export interface ISourcesValues { + contentSources: ContentSourceDetails[]; + privateContentSources: ContentSourceDetails[]; + sourceData: CombinedDataItem[]; + availableSources: SourceDataItem[]; + configuredSources: SourceDataItem[]; + serviceTypes: Connector[]; + permissionsModal: IPermissionsModalProps | null; + dataLoading: boolean; + serverStatuses: ServerStatuses | null; +} + +interface ISourcesServerResponse { + contentSources: ContentSourceDetails[]; + privateContentSources?: ContentSourceDetails[]; + serviceTypes: Connector[]; +} + +export const SourcesLogic = kea>({ + actions: { + setServerSourceStatuses: (statuses: ContentSourceStatus[]) => statuses, + onInitializeSources: (serverResponse: ISourcesServerResponse) => serverResponse, + onSetSearchability: (sourceId: string, searchable: boolean) => ({ sourceId, searchable }), + setAddedSource: ( + addedSourceName: string, + additionalConfiguration: boolean, + serviceType: string + ) => ({ addedSourceName, additionalConfiguration, serviceType }), + resetFlashMessages: () => true, + resetPermissionsModal: () => true, + resetSourcesState: () => true, + initializeSources: () => true, + pollForSourceStatusChanges: () => true, + setSourceSearchability: (sourceId: string, searchable: boolean) => ({ sourceId, searchable }), + }, + reducers: { + contentSources: [ + [], + { + onInitializeSources: (_, { contentSources }) => contentSources, + onSetSearchability: (contentSources, { sourceId, searchable }) => + updateSourcesOnToggle(contentSources, sourceId, searchable), + }, + ], + privateContentSources: [ + [], + { + onInitializeSources: (_, { privateContentSources }) => privateContentSources || [], + onSetSearchability: (privateContentSources, { sourceId, searchable }) => + updateSourcesOnToggle(privateContentSources, sourceId, searchable), + }, + ], + serviceTypes: [ + [], + { + onInitializeSources: (_, { serviceTypes }) => serviceTypes || [], + }, + ], + permissionsModal: [ + null, + { + setAddedSource: (_, data) => data, + resetPermissionsModal: () => null, + }, + ], + dataLoading: [ + true, + { + onInitializeSources: () => false, + resetSourcesState: () => true, + }, + ], + serverStatuses: [ + null, + { + setServerSourceStatuses: (_, sources) => { + const serverStatuses = {} as ServerStatuses; + sources.forEach((source) => { + serverStatuses[source.id as string] = source.status.status; + }); + return serverStatuses; + }, + }, + ], + }, + selectors: ({ selectors }) => ({ + availableSources: [ + () => [selectors.sourceData], + (sourceData: SourceDataItem[]) => sourceData.filter(({ configured }) => !configured), + ], + configuredSources: [ + () => [selectors.sourceData], + (sourceData: SourceDataItem[]) => sourceData.filter(({ configured }) => configured), + ], + sourceData: [ + () => [selectors.serviceTypes, selectors.contentSources], + (serviceTypes, contentSources) => + mergeServerAndStaticData(serviceTypes, staticSourceData, contentSources), + ], + }), + listeners: ({ actions, values }) => ({ + initializeSources: async () => { + const { isOrganization } = AppLogic.values; + const route = isOrganization ? ORG_SOURCES_PATH : ACCOUNT_SOURCES_PATH; + + try { + const response = await HttpLogic.values.http.get(route); + actions.onInitializeSources(response); + } catch (e) { + flashAPIErrors(e); + } + + 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); + actions.setServerSourceStatuses(sourceStatuses); + } + }, + // We poll the server and if the status update, we trigger a new fetch of the sources. + pollForSourceStatusChanges: async () => { + const { isOrganization } = AppLogic.values; + if (!isOrganization) return; + const serverStatuses = values.serverStatuses; + + const sourceStatuses = await fetchSourceStatuses(isOrganization); + + sourceStatuses.some((source: ContentSourceStatus) => { + if (serverStatuses && serverStatuses[source.id] !== source.status.status) { + return actions.initializeSources(); + } + }); + }, + setSourceSearchability: async ({ sourceId, searchable }) => { + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? `/api/workplace_search/org/sources/${sourceId}/searchable` + : `/api/workplace_search/account/sources/${sourceId}/searchable`; + + try { + await HttpLogic.values.http.put(route, { + body: JSON.stringify({ searchable }), + }); + actions.onSetSearchability(sourceId, searchable); + } catch (e) { + flashAPIErrors(e); + } + }, + setAddedSource: ({ addedSourceName, additionalConfiguration }) => { + setSuccessMessage( + [ + `Successfully connected ${addedSourceName}.`, + additionalConfiguration ? 'This source requires additional configuration.' : '', + ].join(' ') + ); + }, + resetFlashMessages: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); + +const fetchSourceStatuses = async (isOrganization: boolean) => { + const route = isOrganization ? ORG_SOURCES_PATH : ACCOUNT_SOURCES_PATH; + let response; + + try { + response = await HttpLogic.values.http.get(route); + SourcesLogic.actions.setServerSourceStatuses(response); + } catch (e) { + flashAPIErrors(e); + } + + return response; +}; + +const updateSourcesOnToggle = ( + contentSources: ContentSourceDetails[], + sourceId: string, + searchable: boolean +): ContentSourceDetails[] => { + if (!contentSources) return []; + const sources = cloneDeep(contentSources) as ContentSourceDetails[]; + const index = findIndex(sources, ({ id }) => id === sourceId); + const updatedSource = sources[index]; + sources[index] = { + ...updatedSource, + searchable, + }; + return sources; +}; + +/** + * We have 3 different data sets we have to combine in the UI. The first is the static (`staticSourceData`) + * data that contains the UI componets, such as the Path for React Router and the copy and images. + * + * 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 + * 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. + */ +const mergeServerAndStaticData = ( + serverData: ContentSourceDetails[], + staticData: SourceDataItem[], + contentSources: ContentSourceDetails[] +) => { + const combined = [] as CombinedDataItem[]; + serverData.forEach((serverItem) => { + const type = serverItem.serviceType; + const staticItem = staticData.find(({ serviceType }) => serviceType === type); + const connectedSource = contentSources.find(({ serviceType }) => serviceType === type); + combined.push({ + ...serverItem, + ...staticItem, + connected: !!connectedSource, + } as CombinedDataItem); + }); + + return combined; +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 6d22002222a666..9cf491b79fd24e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -411,7 +411,7 @@ describe('sources routes', () => { }); }); - describe('PUT /api/workplace_search/sources/{id}/searchable', () => { + describe('PUT /api/workplace_search/account/sources/{id}/searchable', () => { let mockRouter: MockRouter; beforeEach(() => { @@ -421,7 +421,7 @@ describe('sources routes', () => { it('creates a request handler', () => { mockRouter = new MockRouter({ method: 'put', - path: '/api/workplace_search/sources/{id}/searchable', + path: '/api/workplace_search/account/sources/{id}/searchable', payload: 'body', }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index efef53440117e7..bdd048438dae53 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -268,7 +268,7 @@ export function registerAccountSourceSearchableRoute({ }: RouteDependencies) { router.put( { - path: '/api/workplace_search/sources/{id}/searchable', + path: '/api/workplace_search/account/sources/{id}/searchable', validate: { body: schema.object({ searchable: schema.boolean(),