[Workplace Search] Migrate SourcesLogic from ent-search (#83544)

* 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  🙄

* 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
This commit is contained in:
Scotty Bollinger 2020-11-17 10:28:44 -06:00 committed by GitHub
parent 9c0164a2d8
commit 46d587a19f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 343 additions and 5 deletions

View file

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

View file

@ -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[];

View file

@ -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<MakeLogicType<ISourcesValues, ISourcesActions>>({
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;
};

View file

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

View file

@ -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(),