[Enterprise Search]Add util function to create Kea logic files for API calls (#134932)

* [Enterprise Search]Add util function to create Kea logic files for API calls

* Fixed unit tests for add custom source logic

* Make Status an enum, add tests, move flash messages

* Fix some more tests
This commit is contained in:
Sander Philipse 2022-06-23 13:33:39 +02:00 committed by GitHub
parent bee025b3c0
commit 8e1feb327a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 402 additions and 366 deletions

View file

@ -26,99 +26,42 @@ Slicing up components into smaller chunks, designing clear interfaces for those
State management tools are most powerful when used to coordinate state across an entire application, or large slices of that application. To do that well, state needs to be shared and it needs to be clear where in the existing state to find what information. We do this by separating API data from component data.
This means API interactions and their data should live in their own logic files, and the resulting data and API status should be imported by other logic files or directly by components consuming that data. Those API logic files should contain all interactions with APIs, and the current status of those API requests. Our idiomatic way of doing this follows:
This means API interactions and their data should live in their own logic files, and the resulting data and API status should be imported by other logic files or directly by components consuming that data. Those API logic files should contain all interactions with APIs, and the current status of those API requests. We have a util function to help you create those, located in [create_api_logic.ts](public/applications/shared/api_logic/create_api_logic.ts). You can grab the `status`, `data` and `error` values from any API created with that util. And you can listen to the `initiateCall`, `apiSuccess`, `apiError` and `apiReset` actions in other listeners.
You will need to provide a function that actually makes the api call, as well as the logic path. The function will need to accept and return a single object, not separate values.
```typescript
import { kea, MakeLogicType } from 'kea';
import { ApiStatus, HttpError } from '../../../../../../../../common/types/api';
import { flashAPIErrors, clearFlashMessages } from '../../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../../shared/http';
export interface AddCustomSourceActions {
fetchSource(): void;
fetchSourceError(code: number, error: string): HttpError;
fetchSourceSuccess(source: CustomSource): CustomSource;
}
interface CustomSource {
export const addCustomSource = async ({
name,
baseServiceType,
}: {
name: string;
}
baseServiceType?: string;
}) => {
const { isOrganization } = AppLogic.values;
const route = isOrganization
? '/internal/workplace_search/org/create_source'
: '/internal/workplace_search/account/create_source';
interface AddCustomSourceValues {
sourceApiStatus: ApiStatus<CustomSource>;
}
const params = {
service_type: 'custom',
name,
base_service_type: baseServiceType,
};
const source = await HttpLogic.values.http.post<CustomSource>(route, {
body: JSON.stringify(params),
});
return { source };
};
export const AddCustomSourceLogic = kea<
MakeLogicType<AddCustomSourceValues, AddCustomSourceActions>
>({
path: ['enterprise_search', 'workplace_search', 'add_custom_source_logic'],
actions: {
fetchSource: true,
fetchSourceError: (code, error) => ({ code, error }),
fetchSourceSuccess: (customSource) => customSource,
},
reducers: () => ({
sourceApiStatus: [
{
status: 'IDLE',
},
{
fetchSource: () => ({
status: 'PENDING',
}),
fetchSourceError: (_, error) => ({
status: 'ERROR',
error,
}),
fetchSourceSuccess: (_, data) => ({
status: 'SUCCESS',
data,
}),
},
],
}),
listeners: ({ actions }) => ({
fetchSource: async () => {
clearFlashMessages();
try {
const response = await HttpLogic.values.http.post<CustomSource>('/api/source');
actions.fetchSourceSuccess(response);
} catch (e) {
flashAPIErrors(e);
actions.fetchSourceError(e.code, e.message);
}
},
}),
});
export const AddCustomSourceApiLogic = createApiLogic(
['add_custom_source_api_logic'],
addCustomSource
);
```
The types used above can be found in our [common Enterprise Search types file](common/types/api.ts). While the above assumes a single, idempotent API, this approach can be easily extended to use a dictionary approach:
```typescript
reducers: () => ({
sourceApiStatus: [
{
},
{
fetchSource: (state, id) => ({...state,
id: {
status: 'PENDING',
data: state[id]?.data,
}}),
fetchSourceError: (_, ({id, error})) => ({...state,
id: {
status: 'ERROR',
error,
}}),
fetchSourceSuccess: (_, ({id, data})) => ({...state, id: {
status: 'SUCCESS',
data,
}}),
},
],
}),
```
The types used in that util can be found in our [common Enterprise Search types file](common/types/api.ts).
## Import actions and values from API logic files into component and view logic.
Once you have an API interactions file set up, components and other Kea logic files can import the values from those files to build their own logic. Use the Kea 'connect' functionality to do this, as the auto-connect functionality has a few bugs and was removed in Kea 3.0. This allows you to read the status and value of an API, react to any API events, and abstract those APIs away from the components. Those components can now become more functional and reactive to the current state of the application, rather than to directly responding to API events.
@ -130,34 +73,19 @@ export const AddCustomSourceLogic = kea<
MakeLogicType<AddCustomSourceValues, AddCustomSourceActions, AddCustomSourceProps>
>({
connect: {
actions: [AddCustomSourceApiLogic, ['addSource', 'addSourceSuccess', 'addSourceError']],
values: [AddCustomSourceApiLogic, ['sourceApi']],
actions: [AddCustomSourceApiLogic, ['initiateCall', 'apiSuccess', ]],
values: [AddCustomSourceApiLogic, ['status']],
},
path: ['enterprise_search', 'workplace_search', 'add_custom_source_logic'],
actions: {
createContentSource: true,
setCustomSourceNameValue: (customSourceNameValue) => customSourceNameValue,
setNewCustomSource: (data) => data,
},
reducers: ({ props }) => ({
customSourceNameValue: [
props.initialValue || '',
{
setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue,
},
],
newCustomSource: [
undefined,
{
setNewCustomSource: (_, newCustomSource) => newCustomSource,
},
],
}),
listeners: ({ actions, values, props }) => ({
createContentSource: () => {
const { customSourceNameValue } = values;
const { baseServiceType } = props;
actions.addSource(customSourceNameValue, baseServiceType);
actions.initiateCall({ source: customSourceNameValue, baseServiceType });
},
addSourceSuccess: (customSource: CustomSource) => {
actions.setNewCustomSource(customSource);
@ -165,20 +93,14 @@ export const AddCustomSourceLogic = kea<
}),
selectors: {
buttonLoading: [
(selectors) => [selectors.sourceApi],
(apiStatus) => apiStatus?.status === 'PENDING',
(selectors) => [selectors.status],
(apiStatus) => status === 'LOADING',
],
},
});
```
You'll have to add the imported the actions and values types you're already using for your function, preferably by importing the types off the imported logic. Like so:
```typescript
export interface AddCustomSourceActions {
addSource: AddCustomSourceApiActions['addSource'];
addSourceSuccess: AddCustomSourceApiActions['addSourceSuccess'];
}
```
You'll have to add the imported the actions and values types you're already using for your function, preferably by importing the types off the imported logic, so TypeScript can warn you if you're misusing the function.
## Keep your logic files small
Using the above methods, you can keep your logic files small and isolated. Keep API calls separate from view and component logic. Keep the amount of logic you're processing limited per file. If your logic file starts exceeding about 150 lines of code, you should start thinking about splitting it up into separate chunks, if possible.

View file

@ -5,36 +5,53 @@
* 2.0.
*/
import { HttpResponse } from '@kbn/core/public';
/**
* These types track an API call's status and result
* Each Status string corresponds to a possible status in a request's lifecycle
*/
export type Status = 'IDLE' | 'PENDING' | 'SUCCESS' | 'ERROR';
export interface HttpError {
code: number;
message?: string;
export const enum Status {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export interface ErrorResponse {
statusCode: number;
error: string;
message: string;
attributes: {
errors: string[];
};
}
export type HttpError = HttpResponse<ErrorResponse>;
export interface ApiSuccess<T> {
status: 'SUCCESS';
status: Status.SUCCESS;
data: T;
error?: undefined;
}
export interface ApiPending<T> {
status: 'PENDING';
status: Status.LOADING;
data?: T;
error?: undefined;
}
export interface ApiIdle<T> {
status: 'IDLE';
status: Status.IDLE;
data?: T;
error?: undefined;
}
export interface ApiError {
status: Status;
status: Status.ERROR;
error: HttpError;
data?: undefined;
}
export type ApiStatus<T> = ApiSuccess<T> | ApiPending<T> | ApiIdle<T> | ApiError;

View file

@ -0,0 +1,130 @@
/*
* 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 } from '../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { HttpError, Status } from '../../../../common/types/api';
import { createApiLogic } from './create_api_logic';
const DEFAULT_VALUES = {
apiStatus: {
status: Status.IDLE,
},
data: undefined,
error: undefined,
status: Status.IDLE,
};
describe('CreateApiLogic', () => {
const apiCallMock = jest.fn();
const logic = createApiLogic(['path'], apiCallMock);
const { mount } = new LogicMounter(logic);
beforeEach(() => {
jest.clearAllMocks();
mount({});
});
it('has expected default values', () => {
expect(logic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('makeRequest', () => {
it('should set status to LOADING', () => {
logic.actions.makeRequest({});
expect(logic.values).toEqual({
...DEFAULT_VALUES,
status: Status.LOADING,
apiStatus: {
status: Status.LOADING,
},
});
});
});
describe('apiSuccess', () => {
it('should set status to SUCCESS and load data', () => {
logic.actions.apiSuccess({ success: 'data' });
expect(logic.values).toEqual({
...DEFAULT_VALUES,
status: Status.SUCCESS,
data: { success: 'data' },
apiStatus: {
status: Status.SUCCESS,
data: { success: 'data' },
},
});
});
});
describe('apiError', () => {
it('should set status to ERROR and set error data', () => {
logic.actions.apiError('error' as any as HttpError);
expect(logic.values).toEqual({
...DEFAULT_VALUES,
status: Status.ERROR,
data: undefined,
error: 'error',
apiStatus: {
status: Status.ERROR,
data: undefined,
error: 'error',
},
});
});
});
describe('apiReset', () => {
it('should reset api', () => {
logic.actions.apiError('error' as any as HttpError);
expect(logic.values).toEqual({
...DEFAULT_VALUES,
status: Status.ERROR,
data: undefined,
error: 'error',
apiStatus: {
status: Status.ERROR,
data: undefined,
error: 'error',
},
});
logic.actions.apiReset();
expect(logic.values).toEqual(DEFAULT_VALUES);
});
});
});
describe('listeners', () => {
describe('makeRequest', () => {
it('calls apiCall on success', async () => {
const apiSuccessMock = jest.spyOn(logic.actions, 'apiSuccess');
const apiErrorMock = jest.spyOn(logic.actions, 'apiError');
apiCallMock.mockReturnValue(Promise.resolve('result'));
logic.actions.makeRequest({ arg: 'argument1' });
expect(apiCallMock).toHaveBeenCalledWith({ arg: 'argument1' });
await nextTick();
expect(apiErrorMock).not.toHaveBeenCalled();
expect(apiSuccessMock).toHaveBeenCalledWith('result');
});
it('calls apiError on error', async () => {
const apiSuccessMock = jest.spyOn(logic.actions, 'apiSuccess');
const apiErrorMock = jest.spyOn(logic.actions, 'apiError');
apiCallMock.mockReturnValue(
Promise.reject({ body: { statusCode: 404, message: 'message' } })
);
logic.actions.makeRequest({ arg: 'argument1' });
expect(apiCallMock).toHaveBeenCalledWith({ arg: 'argument1' });
await nextTick();
expect(apiSuccessMock).not.toHaveBeenCalled();
expect(apiErrorMock).toHaveBeenCalledWith({
body: { statusCode: 404, message: 'message' },
});
});
});
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 { ApiStatus, Status, HttpError } from '../../../../common/types/api';
export interface Values<T> {
apiStatus: ApiStatus<T>;
status: Status;
data?: T;
error: HttpError;
}
export interface Actions<Args extends Record<string, unknown> | undefined, Result> {
makeRequest(args: Args): Args;
apiError(error: HttpError): HttpError;
apiSuccess(result: Result): Result;
apiReset(): void;
}
export const createApiLogic = <Result, Args extends Record<string, unknown> | undefined>(
path: string[],
apiFunction: (args: Args) => Promise<Result>
) =>
kea<MakeLogicType<Values<Result>, Actions<Args, Result>>>({
path: ['enterprise_search', ...path],
actions: {
makeRequest: (args) => args,
apiError: (error) => error,
apiSuccess: (result) => result,
apiReset: true,
},
reducers: () => ({
apiStatus: [
{
status: Status.IDLE,
},
{
makeRequest: () => ({
status: Status.LOADING,
}),
apiError: (_, error) => ({
status: Status.ERROR,
error,
}),
apiSuccess: (_, data) => ({
status: Status.SUCCESS,
data,
}),
apiReset: () => ({
status: Status.IDLE,
}),
},
],
}),
listeners: ({ actions }) => ({
makeRequest: async (args) => {
try {
const result = await apiFunction(args);
actions.apiSuccess(result);
} catch (e) {
actions.apiError(e);
}
},
}),
selectors: ({ selectors }) => ({
status: [() => [selectors.apiStatus], (apiStatus: ApiStatus<Result>) => apiStatus.status],
data: [() => [selectors.apiStatus], (apiStatus: ApiStatus<Result>) => apiStatus.data],
error: [() => [selectors.apiStatus], (apiStatus: ApiStatus<Result>) => apiStatus.error],
}),
});

View file

@ -5,151 +5,45 @@
* 2.0.
*/
import {
LogicMounter,
mockFlashMessageHelpers,
mockHttpValues,
} from '../../../../../../__mocks__/kea_logic';
import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock';
import { mockHttpValues } from '../../../../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers';
jest.mock('../../../../../app_logic', () => ({
AppLogic: { values: { isOrganization: true } },
}));
import { AppLogic } from '../../../../../app_logic';
import { AddCustomSourceApiLogic } from './add_custom_source_api_logic';
import { addCustomSource } from './add_custom_source_api_logic';
const DEFAULT_VALUES = {
sourceApi: {
status: 'IDLE',
},
};
const MOCK_NAME = 'name';
describe('AddCustomSourceLogic', () => {
const { mount } = new LogicMounter(AddCustomSourceApiLogic);
describe('addCustomSource', () => {
const { http } = mockHttpValues;
const { clearFlashMessages } = mockFlashMessageHelpers;
beforeEach(() => {
jest.clearAllMocks();
mount({});
});
it('has expected default values', () => {
expect(AddCustomSourceApiLogic.values).toEqual(DEFAULT_VALUES);
it('calls correct route for organization', async () => {
const promise = Promise.resolve('result');
http.post.mockReturnValue(promise);
addCustomSource({ name: 'name', baseServiceType: 'baseServiceType' });
await nextTick();
expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', {
body: JSON.stringify({
service_type: 'custom',
name: 'name',
base_service_type: 'baseServiceType',
}),
});
});
describe('listeners', () => {
beforeEach(() => {
mount();
});
describe('organization context', () => {
describe('createContentSource', () => {
it('calls API and sets values', async () => {
const addSourceSuccessSpy = jest.spyOn(
AddCustomSourceApiLogic.actions,
'addSourceSuccess'
);
http.post.mockReturnValue(Promise.resolve({ sourceConfigData }));
AddCustomSourceApiLogic.actions.addSource(MOCK_NAME);
expect(clearFlashMessages).toHaveBeenCalled();
expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', {
body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }),
});
await nextTick();
expect(addSourceSuccessSpy).toHaveBeenCalledWith({ sourceConfigData });
});
it('submits a base service type for pre-configured sources', async () => {
const addSourceSuccessSpy = jest.spyOn(
AddCustomSourceApiLogic.actions,
'addSourceSuccess'
);
http.post.mockReturnValue(Promise.resolve({ sourceConfigData }));
AddCustomSourceApiLogic.actions.addSource(MOCK_NAME, 'base_service_type');
expect(clearFlashMessages).toHaveBeenCalled();
expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', {
body: JSON.stringify({
service_type: 'custom',
name: MOCK_NAME,
base_service_type: 'base_service_type',
}),
});
await nextTick();
expect(addSourceSuccessSpy).toHaveBeenCalledWith({ sourceConfigData });
});
itShowsServerErrorAsFlashMessage(http.post, () => {
AddCustomSourceApiLogic.actions.addSource(MOCK_NAME);
});
});
});
describe('account context routes', () => {
beforeEach(() => {
AppLogic.values.isOrganization = false;
});
describe('createContentSource', () => {
it('calls API and sets values', async () => {
const addSourceSuccessSpy = jest.spyOn(
AddCustomSourceApiLogic.actions,
'addSourceSuccess'
);
http.post.mockReturnValue(Promise.resolve({ sourceConfigData }));
AddCustomSourceApiLogic.actions.addSource(MOCK_NAME);
expect(clearFlashMessages).toHaveBeenCalled();
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/account/create_source',
{
body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }),
}
);
await nextTick();
expect(addSourceSuccessSpy).toHaveBeenCalledWith({ sourceConfigData });
});
it('submits a base service type for pre-configured sources', async () => {
const addSourceSuccessSpy = jest.spyOn(
AddCustomSourceApiLogic.actions,
'addSourceSuccess'
);
http.post.mockReturnValue(Promise.resolve({ sourceConfigData }));
AddCustomSourceApiLogic.actions.addSource(MOCK_NAME, 'base_service_type');
expect(clearFlashMessages).toHaveBeenCalled();
expect(http.post).toHaveBeenCalledWith(
'/internal/workplace_search/account/create_source',
{
body: JSON.stringify({
service_type: 'custom',
name: MOCK_NAME,
base_service_type: 'base_service_type',
}),
}
);
await nextTick();
expect(addSourceSuccessSpy).toHaveBeenCalledWith({ sourceConfigData });
});
itShowsServerErrorAsFlashMessage(http.post, () => {
AddCustomSourceApiLogic.actions.addSource(MOCK_NAME);
});
});
it('calls correct route for account', async () => {
const promise = Promise.resolve('result');
AppLogic.values.isOrganization = false;
http.post.mockReturnValue(promise);
addCustomSource({ name: 'name' });
await nextTick();
expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/account/create_source', {
body: JSON.stringify({ service_type: 'custom', name: 'name' }),
});
});
});

View file

@ -5,76 +5,35 @@
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { ApiStatus, HttpError } from '../../../../../../../../common/types/api';
import { flashAPIErrors, clearFlashMessages } from '../../../../../../shared/flash_messages';
import { createApiLogic } from '../../../../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../../../../shared/http';
import { AppLogic } from '../../../../../app_logic';
import { CustomSource } from '../../../../../types';
export interface AddCustomSourceApiActions {
addSource(name: string, baseServiceType?: string): { name: string; baseServiceType: string };
addSourceError(code: number, error: string): HttpError;
addSourceSuccess(source: CustomSource): { source: CustomSource };
}
export const addCustomSource = async ({
name,
baseServiceType,
}: {
name: string;
baseServiceType?: string;
}) => {
const { isOrganization } = AppLogic.values;
const route = isOrganization
? '/internal/workplace_search/org/create_source'
: '/internal/workplace_search/account/create_source';
export interface AddCustomSourceApiValues {
sourceApi: ApiStatus<CustomSource>;
}
const params = {
service_type: 'custom',
name,
base_service_type: baseServiceType,
};
const source = await HttpLogic.values.http.post<CustomSource>(route, {
body: JSON.stringify(params),
});
return { source };
};
export const AddCustomSourceApiLogic = kea<
MakeLogicType<AddCustomSourceApiValues, AddCustomSourceApiActions>
>({
path: ['enterprise_search', 'workplace_search', 'add_custom_source_api_logic'],
actions: {
addSource: (name, baseServiceType) => ({ name, baseServiceType }),
addSourceError: (code, error) => ({ code, error }),
addSourceSuccess: (customSource) => ({ source: customSource }),
},
reducers: () => ({
sourceApi: [
{
status: 'IDLE',
},
{
addSource: () => ({
status: 'PENDING',
}),
addSourceError: (_, error) => ({
status: 'ERROR',
error,
}),
addSourceSuccess: (_, { source }) => ({
status: 'SUCCESS',
data: source,
}),
},
],
}),
listeners: ({ actions }) => ({
addSource: async ({ name, baseServiceType }) => {
clearFlashMessages();
const { isOrganization } = AppLogic.values;
const route = isOrganization
? '/internal/workplace_search/org/create_source'
: '/internal/workplace_search/account/create_source';
const params = {
service_type: 'custom',
name,
base_service_type: baseServiceType,
};
try {
const response = await HttpLogic.values.http.post<CustomSource>(route, {
body: JSON.stringify(params),
});
actions.addSourceSuccess(response);
} catch (e) {
flashAPIErrors(e);
actions.addSourceError(e?.body?.statusCode, e?.body?.message);
}
},
}),
});
export const AddCustomSourceApiLogic = createApiLogic(
['workplace_search', 'add_custom_source_api_logic'],
addCustomSource
);

View file

@ -7,12 +7,17 @@
import { LogicMounter } from '../../../../../../__mocks__/kea_logic';
import { mockFlashMessageHelpers } from '../../../../../../__mocks__/kea_logic';
import { Status } from '../../../../../../../../common/types/api';
jest.mock('../../../../../app_logic', () => ({
AppLogic: { values: { isOrganization: true } },
}));
import { CustomSource } from '../../../../../types';
import { AddCustomSourceApiLogic } from './add_custom_source_api_logic';
import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic';
const DEFAULT_VALUES = {
@ -20,15 +25,14 @@ const DEFAULT_VALUES = {
buttonLoading: false,
customSourceNameValue: '',
newCustomSource: {} as CustomSource,
sourceApi: {
status: 'IDLE',
},
status: Status.IDLE,
};
const MOCK_NAME = 'name';
describe('AddCustomSourceLogic', () => {
const { mount } = new LogicMounter(AddCustomSourceLogic);
const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers;
beforeEach(() => {
jest.clearAllMocks();
@ -40,27 +44,48 @@ describe('AddCustomSourceLogic', () => {
});
describe('actions', () => {
describe('addSourceSuccess', () => {
describe('apiSuccess', () => {
it('sets a new source', () => {
const customSource: CustomSource = {
AddCustomSourceLogic.actions.makeRequest({ name: 'name' });
const source: CustomSource = {
accessToken: 'a',
name: 'b',
id: '1',
};
AddCustomSourceLogic.actions.addSourceSuccess(customSource);
AddCustomSourceLogic.actions.apiSuccess({ source });
expect(AddCustomSourceLogic.values).toEqual({
...DEFAULT_VALUES,
customSourceNameValue: '',
newCustomSource: customSource,
sourceApi: {
status: 'SUCCESS',
data: customSource,
},
newCustomSource: source,
status: Status.SUCCESS,
currentStep: AddCustomSourceSteps.SaveCustomStep,
});
});
});
describe('makeRequest', () => {
it('sets button to loading', () => {
AddCustomSourceLogic.actions.makeRequest({ name: 'name' });
expect(AddCustomSourceLogic.values).toEqual({
...DEFAULT_VALUES,
buttonLoading: true,
status: Status.LOADING,
});
});
});
describe('apiError', () => {
it('sets button to not loading', () => {
AddCustomSourceLogic.actions.makeRequest({ name: 'name' });
AddCustomSourceLogic.actions.apiError('error' as any);
expect(AddCustomSourceLogic.values).toEqual({
...DEFAULT_VALUES,
buttonLoading: false,
status: Status.ERROR,
});
});
});
describe('setCustomSourceNameValue', () => {
it('saves the name', () => {
AddCustomSourceLogic.actions.setCustomSourceNameValue('name');
@ -98,33 +123,48 @@ describe('AddCustomSourceLogic', () => {
customSourceNameValue: MOCK_NAME,
});
});
describe('createContentSource', () => {
it('calls addSource on AddCustomSourceApi logic', async () => {
const addSourceSpy = jest.spyOn(AddCustomSourceLogic.actions, 'makeRequest');
describe('organization context', () => {
describe('createContentSource', () => {
it('calls addSource on AddCustomSourceApi logic', async () => {
const addSourceSpy = jest.spyOn(AddCustomSourceLogic.actions, 'addSource');
AddCustomSourceLogic.actions.createContentSource();
expect(addSourceSpy).toHaveBeenCalledWith(MOCK_NAME, undefined);
AddCustomSourceLogic.actions.createContentSource();
expect(addSourceSpy).toHaveBeenCalledWith({
name: MOCK_NAME,
baseServiceType: undefined,
});
});
it('submits a base service type for pre-configured sources', () => {
mount(
{
customSourceNameValue: MOCK_NAME,
},
{
baseServiceType: 'share_point_server',
}
);
it('submits a base service type for pre-configured sources', () => {
mount(
{
customSourceNameValue: MOCK_NAME,
},
{
baseServiceType: 'share_point_server',
}
);
const addSourceSpy = jest.spyOn(AddCustomSourceLogic.actions, 'addSource');
const addSourceSpy = jest.spyOn(AddCustomSourceLogic.actions, 'makeRequest');
AddCustomSourceLogic.actions.createContentSource();
AddCustomSourceLogic.actions.createContentSource();
expect(addSourceSpy).toHaveBeenCalledWith(MOCK_NAME, 'share_point_server');
expect(addSourceSpy).toHaveBeenCalledWith({
name: MOCK_NAME,
baseServiceType: 'share_point_server',
});
});
});
describe('makeRequest', () => {
it('should call clearFlashMessages', () => {
AddCustomSourceApiLogic.actions.makeRequest({ name: 'name' });
expect(clearFlashMessages).toHaveBeenCalled();
});
});
describe('apiError', () => {
it('should call flashAPIError', () => {
AddCustomSourceApiLogic.actions.apiError('error' as any);
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
});
});

View file

@ -7,13 +7,11 @@
import { kea, MakeLogicType } from 'kea';
import { HttpError, Status } from '../../../../../../../../common/types/api';
import { clearFlashMessages, flashAPIErrors } from '../../../../../../shared/flash_messages';
import { CustomSource } from '../../../../../types';
import {
AddCustomSourceApiActions,
AddCustomSourceApiLogic,
AddCustomSourceApiValues,
} from './add_custom_source_api_logic';
import { AddCustomSourceApiLogic } from './add_custom_source_api_logic';
export interface AddCustomSourceProps {
baseServiceType?: string;
@ -26,8 +24,9 @@ export enum AddCustomSourceSteps {
}
export interface AddCustomSourceActions {
addSource: AddCustomSourceApiActions['addSource'];
addSourceSuccess: AddCustomSourceApiActions['addSourceSuccess'];
makeRequest: typeof AddCustomSourceApiLogic.actions.makeRequest;
apiSuccess({ source }: { source: CustomSource }): { source: CustomSource };
apiError(error: HttpError): HttpError;
createContentSource(): void;
setCustomSourceNameValue(customSourceNameValue: string): string;
setNewCustomSource(data: CustomSource): CustomSource;
@ -38,7 +37,7 @@ interface AddCustomSourceValues {
currentStep: AddCustomSourceSteps;
customSourceNameValue: string;
newCustomSource: CustomSource;
sourceApi: AddCustomSourceApiValues['sourceApi'];
status: Status;
}
/**
@ -50,8 +49,8 @@ export const AddCustomSourceLogic = kea<
MakeLogicType<AddCustomSourceValues, AddCustomSourceActions, AddCustomSourceProps>
>({
connect: {
actions: [AddCustomSourceApiLogic, ['addSource', 'addSourceSuccess', 'addSourceError']],
values: [AddCustomSourceApiLogic, ['sourceApi']],
actions: [AddCustomSourceApiLogic, ['makeRequest', 'apiError', 'apiSuccess']],
values: [AddCustomSourceApiLogic, ['status']],
},
path: ['enterprise_search', 'workplace_search', 'add_custom_source_logic'],
actions: {
@ -64,8 +63,8 @@ export const AddCustomSourceLogic = kea<
false,
{
createContentSource: () => true,
addSourceSuccess: () => false,
addSourceError: () => false,
apiSuccess: () => false,
apiError: () => false,
},
],
currentStep: [
@ -91,16 +90,15 @@ export const AddCustomSourceLogic = kea<
createContentSource: () => {
const { customSourceNameValue } = values;
const { baseServiceType } = props;
actions.addSource(customSourceNameValue, baseServiceType);
actions.makeRequest({ name: customSourceNameValue, baseServiceType });
},
addSourceSuccess: ({ source }) => {
makeRequest: () => clearFlashMessages(),
apiError: (error) => flashAPIErrors(error),
apiSuccess: ({ source }) => {
actions.setNewCustomSource(source);
},
}),
selectors: {
buttonLoading: [
(selectors) => [selectors.sourceApi],
(apiStatus) => apiStatus?.status === 'PENDING',
],
buttonLoading: [(selectors) => [selectors.status], (status) => status === Status.LOADING],
},
});