Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -740,4 +740,4 @@ export const staticSourceData = [
connectStepDescription: connectStepDescription.empty,
accountContextOnly: false,
},
];
] as SourceDataItem[];
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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',
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down