diff --git a/src/plugins/es_ui_shared/public/request/index.ts b/src/plugins/es_ui_shared/public/request/index.ts new file mode 100644 index 0000000000000..a19005c0191a2 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/index.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + SendRequestConfig, + SendRequestResponse, + UseRequestConfig, + sendRequest, + useRequest, +} from './request'; diff --git a/src/plugins/es_ui_shared/public/request/request.test.js b/src/plugins/es_ui_shared/public/request/request.test.js new file mode 100644 index 0000000000000..c6482f9f81731 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/request.test.js @@ -0,0 +1,247 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import sinon from 'sinon'; +import { + sendRequest as sendRequestUnbound, + useRequest as useRequestUnbound, +} from './request'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; + +const TestHook = ({ callback }) => { + callback(); + return null; +}; + +let element; + +const testHook = (callback) => { + element = mount(); +}; + +const wait = async wait => + new Promise(resolve => setTimeout(resolve, wait || 1)); + +describe('request lib', () => { + const successRequest = { path: '/success', method: 'post', body: {} }; + const errorRequest = { path: '/error', method: 'post', body: {} }; + const successResponse = { statusCode: 200, data: { message: 'Success message' } }; + const errorResponse = { statusCode: 400, statusText: 'Error message' }; + + let sendPost; + let sendRequest; + let useRequest; + + beforeEach(() => { + sendPost = sinon.stub(); + sendPost.withArgs(successRequest.path, successRequest.body).returns(successResponse); + sendPost.withArgs(errorRequest.path, errorRequest.body).throws(errorResponse); + + const httpClient = { + post: (...args) => { + return sendPost(...args); + }, + }; + + sendRequest = sendRequestUnbound.bind(null, httpClient); + useRequest = useRequestUnbound.bind(null, httpClient); + }); + + describe('sendRequest function', () => { + it('uses the provided path, method, and body to send the request', async () => { + const response = await sendRequest({ ...successRequest }); + sinon.assert.calledOnce(sendPost); + expect(response).toEqual({ data: successResponse.data }); + }); + + it('surfaces errors', async () => { + try { + await sendRequest({ ...errorRequest }); + } catch(e) { + sinon.assert.calledOnce(sendPost); + expect(e).toBe(errorResponse.error); + } + }); + }); + + describe('useRequest hook', () => { + let hook; + + function initUseRequest(config) { + act(() => { + testHook(() => { + hook = useRequest(config); + }); + }); + } + + describe('parameters', () => { + describe('path, method, body', () => { + it('is used to send the request', async () => { + initUseRequest({ ...successRequest }); + await wait(10); + expect(hook.data).toBe(successResponse.data); + }); + }); + + describe('pollIntervalMs', () => { + it('sends another request after the specified time has elapsed', async () => { + initUseRequest({ ...successRequest, pollIntervalMs: 30 }); + await wait(5); + sinon.assert.calledOnce(sendPost); + + await wait(40); + sinon.assert.calledTwice(sendPost); + + // We have to manually clean up or else the interval will continue to fire requests, + // interfering with other tests. + element.unmount(); + }); + }); + + describe('initialData', () => { + it('sets the initial data value', () => { + initUseRequest({ ...successRequest, initialData: 'initialData' }); + expect(hook.data).toBe('initialData'); + }); + }); + + describe('deserializer', () => { + it('is called once the request resolves', async () => { + const deserializer = sinon.stub(); + initUseRequest({ ...successRequest, deserializer }); + sinon.assert.notCalled(deserializer); + + await wait(5); + sinon.assert.calledOnce(deserializer); + sinon.assert.calledWith(deserializer, successResponse.data); + }); + + it('processes data', async () => { + initUseRequest({ ...successRequest, deserializer: () => 'intercepted' }); + await wait(5); + expect(hook.data).toBe('intercepted'); + }); + }); + }); + + describe('state', () => { + describe('isInitialRequest', () => { + it('is true for the first request and false for subsequent requests', async () => { + initUseRequest({ ...successRequest }); + expect(hook.isInitialRequest).toBe(true); + + hook.sendRequest(); + await wait(5); + expect(hook.isInitialRequest).toBe(false); + }); + }); + + describe('isLoading', () => { + it('represents in-flight request status', async () => { + initUseRequest({ ...successRequest }); + expect(hook.isLoading).toBe(true); + + await wait(5); + expect(hook.isLoading).toBe(false); + }); + }); + + describe('error', () => { + it('surfaces errors from requests', async () => { + initUseRequest({ ...errorRequest }); + await wait(10); + expect(hook.error).toBe(errorResponse); + }); + + it('persists while a request is in-flight', async () => { + initUseRequest({ ...errorRequest }); + await wait(5); + hook.sendRequest(); + expect(hook.isLoading).toBe(true); + expect(hook.error).toBe(errorResponse); + }); + + it('is undefined when the request is successful', async () => { + initUseRequest({ ...successRequest }); + await wait(10); + expect(hook.isLoading).toBe(false); + expect(hook.error).toBeUndefined(); + }); + }); + + describe('data', () => { + it('surfaces payloads from requests', async () => { + initUseRequest({ ...successRequest }); + await wait(10); + expect(hook.data).toBe(successResponse.data); + }); + + it('persists while a request is in-flight', async () => { + initUseRequest({ ...successRequest }); + await wait(5); + hook.sendRequest(); + expect(hook.isLoading).toBe(true); + expect(hook.data).toBe(successResponse.data); + }); + + it('is undefined when the request fails', async () => { + initUseRequest({ ...errorRequest }); + await wait(10); + expect(hook.isLoading).toBe(false); + expect(hook.data).toBeUndefined(); + }); + }); + }); + + describe('callbacks', () => { + describe('sendRequest', () => { + it('sends the request', () => { + initUseRequest({ ...successRequest }); + sinon.assert.calledOnce(sendPost); + hook.sendRequest(); + sinon.assert.calledTwice(sendPost); + }); + + it('resets the pollIntervalMs', async () => { + initUseRequest({ ...successRequest, pollIntervalMs: 30 }); + await wait(5); + sinon.assert.calledOnce(sendPost); + + await wait(20); + hook.sendRequest(); + + // If the request didn't reset the interval, there would have been three requests sent by now. + await wait(20); + sinon.assert.calledTwice(sendPost); + + await wait(20); + sinon.assert.calledThrice(sendPost); + + // We have to manually clean up or else the interval will continue to fire requests, + // interfering with other tests. + element.unmount(); + }); + }); + }); + }); +}); diff --git a/src/plugins/es_ui_shared/public/request/request.ts b/src/plugins/es_ui_shared/public/request/request.ts new file mode 100644 index 0000000000000..168ad8e2f3780 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/request.ts @@ -0,0 +1,154 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState, useRef } from 'react'; + +export interface SendRequestConfig { + path: string; + method: string; + body?: any; +} + +export interface SendRequestResponse { + data: any; + error: Error; +} + +export interface UseRequestConfig extends SendRequestConfig { + pollIntervalMs?: number; + initialData?: any; + deserializer?: (data: any) => any; +} + +export const sendRequest = async ( + httpClient: ng.IHttpService, + { path, method, body }: SendRequestConfig +): Promise> => { + try { + const response = await (httpClient as any)[method](path, body); + + if (typeof response.data === 'undefined') { + throw new Error(response.statusText); + } + + return { data: response.data }; + } catch (e) { + return { + error: e.response ? e.response : e, + }; + } +}; + +export const useRequest = ( + httpClient: ng.IHttpService, + { + path, + method, + body, + pollIntervalMs, + initialData, + deserializer = (data: any): any => data, + }: UseRequestConfig +) => { + // Main states for tracking request status and data + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [data, setData] = useState(initialData); + + // Consumers can use isInitialRequest to implement a polling UX. + const [isInitialRequest, setIsInitialRequest] = useState(true); + const pollInterval = useRef(null); + const pollIntervalId = useRef(null); + + // We always want to use the most recently-set interval in scheduleRequest. + pollInterval.current = pollIntervalMs; + + // Tied to every render and bound to each request. + let isOutdatedRequest = false; + + const scheduleRequest = () => { + // Clear current interval + if (pollIntervalId.current) { + clearTimeout(pollIntervalId.current); + } + + // Set new interval + if (pollInterval.current) { + pollIntervalId.current = setTimeout(_sendRequest, pollInterval.current); + } + }; + + const _sendRequest = async () => { + // We don't clear error or data, so it's up to the consumer to decide whether to display the + // "old" error/data or loading state when a new request is in-flight. + setIsLoading(true); + + const requestBody = { + path, + method, + body, + }; + + const response = await sendRequest(httpClient, requestBody); + const { data: serializedResponseData, error: responseError } = response; + const responseData = deserializer(serializedResponseData); + + // If an outdated request has resolved, DON'T update state, but DO allow the processData handler + // to execute side effects like update telemetry. + if (isOutdatedRequest) { + return; + } + + setError(responseError); + setData(responseData); + setIsLoading(false); + setIsInitialRequest(false); + + // If we're on an interval, we need to schedule the next request. This also allows us to reset + // the interval if the user has manually requested the data, to avoid doubled-up requests. + scheduleRequest(); + }; + + useEffect(() => { + _sendRequest(); + // To be functionally correct we'd send a new request if the method, path, or body changes. + // But it doesn't seem likely that the method will change and body is likely to be a new + // object even if its shape hasn't changed, so for now we're just watching the path. + }, [path]); + + useEffect(() => { + scheduleRequest(); + + // Clean up intervals and inflight requests and corresponding state changes + return () => { + isOutdatedRequest = true; + if (pollIntervalId.current) { + clearTimeout(pollIntervalId.current); + } + }; + }, [pollIntervalMs]); + + return { + isInitialRequest, + isLoading, + error, + data, + sendRequest: _sendRequest, // Gives the user the ability to manually request data + }; +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_form/step_one.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_form/step_one.tsx index f6364e2ac86ae..9f66353af9614 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_form/step_one.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/repository_form/step_one.tsx @@ -54,7 +54,7 @@ export const RepositoryFormStepOne: React.FunctionComponent = ({ // Load repository types const { error: repositoryTypesError, - loading: repositoryTypesLoading, + isLoading: repositoryTypesLoading, data: repositoryTypes = [], } = useLoadRepositoryTypes(); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/lib/authorization/components/authorization_provider.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/lib/authorization/components/authorization_provider.tsx index e16ef842159d9..9a219b225c63c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/lib/authorization/components/authorization_provider.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/lib/authorization/components/authorization_provider.tsx @@ -45,14 +45,14 @@ interface Props { } export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) => { - const { loading, error, data: privilegesData } = useRequest({ + const { isLoading, error, data: privilegesData } = useRequest({ path: privilegesEndpoint, method: 'get', }); const value = { - isLoading: loading, - privileges: loading ? { hasAllPrivileges: true, missingPrivileges: {} } : privilegesData, + isLoading, + privileges: isLoading ? { hasAllPrivileges: true, missingPrivileges: {} } : privilegesData, apiError: error ? error : null, }; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx index ac4903442ec46..c0b17b1d488df 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx @@ -36,11 +36,11 @@ export const PolicyList: React.FunctionComponent { @@ -61,7 +61,7 @@ export const PolicyList: React.FunctionComponent { @@ -71,7 +71,7 @@ export const RepositoryList: React.FunctionComponent { const [currentInterval, setCurrentInterval] = useState(INTERVAL_OPTIONS[1]); // Load restores - const { error, loading, data: restores = [], polling, changeInterval } = useLoadRestores( + const { error, isLoading, data: restores = [], isInitialRequest, sendRequest } = useLoadRestores( currentInterval ); @@ -62,139 +62,148 @@ export const RestoreList: React.FunctionComponent = () => { let content: JSX.Element; - if (loading) { - content = ( - - - - ); - } else if (error) { - content = ( - - } - error={error} - /> - ); - } else if (restores && restores.length === 0) { - content = ( - + + ); + } else if (error) { + // If we get an error while polling we don't need to show it to the user because they can still + // work with the table. + content = ( + - - } - body={ - -

+ } + error={error} + /> + ); + } + } else { + if (restores && restores.length === 0) { + content = ( + - - - ), - }} + id="xpack.snapshotRestore.restoreList.emptyPromptTitle" + defaultMessage="You don't have any restored snapshots" /> -

-
- } - data-test-subj="emptyPrompt" - /> - ); - } else { - content = ( - - - - setIsIntervalMenuOpen(!isIntervalMenuOpen)} - > - = ONE_MINUTE_MS ? ( - - ) : ( - - ), - }} - /> - - } - isOpen={isIntervalMenuOpen} - closePopover={() => setIsIntervalMenuOpen(false)} - panelPaddingSize="none" - anchorPosition="downLeft" - > - ( - { - changeInterval(interval); - setCurrentInterval(interval); - setIsIntervalMenuOpen(false); - }} + + } + body={ + +

+ + + + ), + }} + /> +

+
+ } + data-test-subj="emptyPrompt" + /> + ); + } else { + content = ( + + + + setIsIntervalMenuOpen(!isIntervalMenuOpen)} > - {interval >= ONE_MINUTE_MS ? ( - - ) : ( - - )} -
- ))} - /> -
-
- {polling ? : null} -
- - -
- ); + = ONE_MINUTE_MS ? ( + + ) : ( + + ), + }} + /> + + } + isOpen={isIntervalMenuOpen} + closePopover={() => setIsIntervalMenuOpen(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + ( + { + sendRequest(); + setCurrentInterval(interval); + setIsIntervalMenuOpen(false); + }} + > + {interval >= ONE_MINUTE_MS ? ( + + ) : ( + + )} + + ))} + /> + + + + {isLoading ? : null} + + + + + + ); + } } return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_list.tsx index fccf3a3fd130f..f6b716bcc18b6 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_list.tsx @@ -41,9 +41,9 @@ export const SnapshotList: React.FunctionComponent ({}); // Load snapshot - const { error: snapshotError, loading: loadingSnapshot, data: snapshotData } = useLoadSnapshot( + const { error: snapshotError, isLoading: loadingSnapshot, data: snapshotData } = useLoadSnapshot( repositoryName, snapshotId ); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts index 94560b46417b4..171e949ccee75 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts @@ -12,6 +12,7 @@ import { UIM_REPOSITORY_DELETE_MANY, UIM_REPOSITORY_DETAIL_PANEL_VERIFY, } from '../../constants'; +import { uiMetricService } from '../ui_metric'; import { httpService } from './http'; import { sendRequest, useRequest } from './use_request'; @@ -30,14 +31,17 @@ export const useLoadRepository = (name: Repository['name']) => { }); }; -export const verifyRepository = (name: Repository['name']) => { - return sendRequest({ +export const verifyRepository = async (name: Repository['name']) => { + const result = await sendRequest({ path: httpService.addBasePath( `${API_BASE_PATH}repositories/${encodeURIComponent(name)}/verify` ), method: 'get', - uimActionType: UIM_REPOSITORY_DETAIL_PANEL_VERIFY, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_REPOSITORY_DETAIL_PANEL_VERIFY); + return result; }; export const useLoadRepositoryTypes = () => { @@ -49,31 +53,40 @@ export const useLoadRepositoryTypes = () => { }; export const addRepository = async (newRepository: Repository | EmptyRepository) => { - return sendRequest({ + const result = await sendRequest({ path: httpService.addBasePath(`${API_BASE_PATH}repositories`), method: 'put', body: newRepository, - uimActionType: UIM_REPOSITORY_CREATE, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_REPOSITORY_CREATE); + return result; }; export const editRepository = async (editedRepository: Repository | EmptyRepository) => { - return sendRequest({ + const result = await sendRequest({ path: httpService.addBasePath( `${API_BASE_PATH}repositories/${encodeURIComponent(editedRepository.name)}` ), method: 'put', body: editedRepository, - uimActionType: UIM_REPOSITORY_UPDATE, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_REPOSITORY_UPDATE); + return result; }; export const deleteRepositories = async (names: Array) => { - return sendRequest({ + const result = await sendRequest({ path: httpService.addBasePath( `${API_BASE_PATH}repositories/${names.map(name => encodeURIComponent(name)).join(',')}` ), method: 'delete', - uimActionType: names.length > 1 ? UIM_REPOSITORY_DELETE_MANY : UIM_REPOSITORY_DELETE, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(names.length > 1 ? UIM_REPOSITORY_DELETE_MANY : UIM_REPOSITORY_DELETE); + return result; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/restore_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/restore_requests.ts index c816831132d24..049db1bebe9e8 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/restore_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/restore_requests.ts @@ -6,6 +6,7 @@ import { API_BASE_PATH } from '../../../../common/constants'; import { RestoreSettings } from '../../../../common/types'; import { UIM_RESTORE_CREATE } from '../../constants'; +import { uiMetricService } from '../ui_metric'; import { httpService } from './http'; import { sendRequest, useRequest } from './use_request'; @@ -14,21 +15,24 @@ export const executeRestore = async ( snapshot: string, restoreSettings: RestoreSettings ) => { - return sendRequest({ + const result = await sendRequest({ path: httpService.addBasePath( `${API_BASE_PATH}restore/${encodeURIComponent(repository)}/${encodeURIComponent(snapshot)}` ), method: 'post', body: restoreSettings, - uimActionType: UIM_RESTORE_CREATE, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_RESTORE_CREATE); + return result; }; -export const useLoadRestores = (interval?: number) => { +export const useLoadRestores = (pollIntervalMs?: number) => { return useRequest({ path: httpService.addBasePath(`${API_BASE_PATH}restores`), method: 'get', initialData: [], - interval, + pollIntervalMs, }); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/snapshot_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/snapshot_requests.ts index 917fae7be7b86..1f21662580976 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/snapshot_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/snapshot_requests.ts @@ -5,6 +5,7 @@ */ import { API_BASE_PATH } from '../../../../common/constants'; import { UIM_SNAPSHOT_DELETE, UIM_SNAPSHOT_DELETE_MANY } from '../../constants'; +import { uiMetricService } from '../ui_metric'; import { httpService } from './http'; import { sendRequest, useRequest } from './use_request'; @@ -28,13 +29,16 @@ export const useLoadSnapshot = (repositoryName: string, snapshotId: string) => export const deleteSnapshots = async ( snapshotIds: Array<{ snapshot: string; repository: string }> ) => { - return sendRequest({ + const result = await sendRequest({ path: httpService.addBasePath( `${API_BASE_PATH}snapshots/${snapshotIds .map(({ snapshot, repository }) => encodeURIComponent(`${repository}/${snapshot}`)) .join(',')}` ), method: 'delete', - uimActionType: snapshotIds.length > 1 ? UIM_SNAPSHOT_DELETE_MANY : UIM_SNAPSHOT_DELETE, }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(snapshotIds.length > 1 ? UIM_SNAPSHOT_DELETE_MANY : UIM_SNAPSHOT_DELETE); + return result; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts index edae4751b864e..dc5f3d9b990a6 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/use_request.ts @@ -3,160 +3,20 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useState, useRef } from 'react'; -import { httpService } from './index'; -import { uiMetricService } from '../ui_metric'; - -interface SendRequest { - path: string; - method: string; - body?: any; - uimActionType?: string; -} - -interface SendRequestResponse { - data: any; - error: Error; -} - -const { trackUiMetric } = uiMetricService; - -export const sendRequest = async ({ - path, - method, - body, - uimActionType, -}: SendRequest): Promise> => { - try { - const response = await httpService.httpClient[method](path, body); - - if (typeof response.data === 'undefined') { - throw new Error(response.statusText); - } - // Track successful request - if (uimActionType) { - trackUiMetric(uimActionType); - } +import { + SendRequestConfig, + SendRequestResponse, + UseRequestConfig, + sendRequest as _sendRequest, + useRequest as _useRequest, +} from '../../../shared_imports'; +import { httpService } from './index'; - return { - data: response.data, - }; - } catch (e) { - return { - error: e.response ? e.response : e, - }; - } +export const sendRequest = (config: SendRequestConfig): Promise> => { + return _sendRequest(httpService.httpClient, config); }; -interface UseRequest extends SendRequest { - interval?: number; - initialData?: any; -} - -export const useRequest = ({ - path, - method, - body, - interval, - initialData, - uimActionType, -}: UseRequest) => { - // Main states for tracking request status and data - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [data, setData] = useState(initialData); - - // States for tracking polling - const [polling, setPolling] = useState(false); - const [currentInterval, setCurrentInterval] = useState(interval); - const intervalRequest = useRef(null); - const isFirstRequest = useRef(true); - - // Tied to every render and bound to each request. - let isOutdatedRequest = false; - - const request = async () => { - const isPollRequest = currentInterval && !isFirstRequest.current; - - // Don't reset main error/loading states if we are doing polling - if (isPollRequest) { - setPolling(true); - } else { - setError(null); - setData(initialData); - setLoading(true); - setPolling(false); - } - - const requestBody = { - path, - method, - body, - uimActionType, - }; - - const response = await sendRequest(requestBody); - - // Don't update state if an outdated request has resolved. - if (isOutdatedRequest) { - return; - } - - // Set just data if we are doing polling - if (isPollRequest) { - setPolling(false); - if (response.data) { - setData(response.data); - } - } else { - setError(response.error); - setData(response.data); - setLoading(false); - } - - isFirstRequest.current = false; - }; - - const cancelOutdatedRequest = () => { - isOutdatedRequest = true; - }; - - useEffect(() => { - // Perform request - request(); - - // Clear current interval - if (intervalRequest.current) { - clearInterval(intervalRequest.current); - } - - // Set new interval - if (currentInterval) { - intervalRequest.current = setInterval(request, currentInterval); - } - - // Cleanup intervals and inflight requests and corresponding state changes - return () => { - cancelOutdatedRequest(); - if (intervalRequest.current) { - clearInterval(intervalRequest.current); - } - }; - }, [path, currentInterval]); - - return { - error, - loading, - data, - request, - polling, - changeInterval: (newInterval: UseRequest['interval']) => { - // Allow changing polling interval if there was one set - if (!interval) { - return; - } - setCurrentInterval(newInterval); - }, - }; +export const useRequest = (config: UseRequestConfig) => { + return _useRequest(httpService.httpClient, config); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/shared_imports.ts b/x-pack/legacy/plugins/snapshot_restore/public/shared_imports.ts new file mode 100644 index 0000000000000..3d93b882733ab --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/public/shared_imports.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export { + SendRequestConfig, + SendRequestResponse, + UseRequestConfig, + sendRequest, + useRequest, +} from '../../../../../src/plugins/es_ui_shared/public/request'; diff --git a/x-pack/legacy/plugins/watcher/public/lib/api.ts b/x-pack/legacy/plugins/watcher/public/lib/api.ts index 359725f5540da..d5c430f9244c4 100644 --- a/x-pack/legacy/plugins/watcher/public/lib/api.ts +++ b/x-pack/legacy/plugins/watcher/public/lib/api.ts @@ -36,12 +36,12 @@ export const getSavedObjectsClient = () => { const basePath = chrome.addBasePath(ROUTES.API_ROOT); -export const loadWatches = (interval: number) => { +export const loadWatches = (pollIntervalMs: number) => { return useRequest({ path: `${basePath}/watches`, method: 'get', - interval, - processData: ({ watches = [] }: { watches: any[] }) => { + pollIntervalMs, + deserializer: ({ watches = [] }: { watches: any[] }) => { return watches.map((watch: any) => Watch.fromUpstreamJson(watch)); }, }); @@ -51,7 +51,7 @@ export const loadWatchDetail = (id: string) => { return useRequest({ path: `${basePath}/watch/${id}`, method: 'get', - processData: ({ watch = {} }: { watch: any }) => Watch.fromUpstreamJson(watch), + deserializer: ({ watch = {} }: { watch: any }) => Watch.fromUpstreamJson(watch), }); }; @@ -65,7 +65,7 @@ export const loadWatchHistory = (id: string, startTime: string) => { return useRequest({ path, method: 'get', - processData: ({ watchHistoryItems = [] }: { watchHistoryItems: any }) => { + deserializer: ({ watchHistoryItems = [] }: { watchHistoryItems: any }) => { return watchHistoryItems.map((historyItem: any) => WatchHistoryItem.fromUpstreamJson(historyItem) ); @@ -75,9 +75,9 @@ export const loadWatchHistory = (id: string, startTime: string) => { export const loadWatchHistoryDetail = (id: string | undefined) => { return useRequest({ - path: !id ? undefined : `${basePath}/history/${id}`, + path: !id ? '' : `${basePath}/history/${id}`, method: 'get', - processData: ({ watchHistoryItem }: { watchHistoryItem: any }) => + deserializer: ({ watchHistoryItem }: { watchHistoryItem: any }) => WatchHistoryItem.fromUpstreamJson(watchHistoryItem), }); }; @@ -164,7 +164,7 @@ export const getWatchVisualizationData = (watchModel: BaseWatch, visualizeOption watch: watchModel.upstreamJson, options: visualizeOptions.upstreamJson, }, - processData: ({ visualizeData }: { visualizeData: any }) => visualizeData, + deserializer: ({ visualizeData }: { visualizeData: any }) => visualizeData, }); }; @@ -172,7 +172,7 @@ export const loadSettings = () => { return useRequest({ path: `${basePath}/settings`, method: 'get', - processData: (data: { + deserializer: (data: { action_types: { [key: string]: { enabled: boolean; diff --git a/x-pack/legacy/plugins/watcher/public/lib/use_request.ts b/x-pack/legacy/plugins/watcher/public/lib/use_request.ts index 4bc0000430346..cb50ea3e9f136 100644 --- a/x-pack/legacy/plugins/watcher/public/lib/use_request.ts +++ b/x-pack/legacy/plugins/watcher/public/lib/use_request.ts @@ -4,120 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useState } from 'react'; +import { + SendRequestConfig, + SendRequestResponse, + UseRequestConfig, + sendRequest as _sendRequest, + useRequest as _useRequest, +} from '../shared_imports'; import { getHttpClient } from './api'; -interface SendRequest { - path?: string; - method: string; - body?: any; -} - -interface SendRequestResponse { - data: any; - error: Error; -} - -export const sendRequest = async ({ - path, - method, - body, -}: SendRequest): Promise> => { - try { - const response = await (getHttpClient() as any)[method](path, body); - - if (typeof response.data === 'undefined') { - throw new Error(response.statusText); - } - - return { - data: response.data, - }; - } catch (e) { - return { - error: e.response ? e.response : e, - }; - } +export const sendRequest = (config: SendRequestConfig): Promise> => { + return _sendRequest(getHttpClient(), config); }; -interface UseRequest extends SendRequest { - interval?: number; - initialData?: any; - processData?: any; -} - -export const useRequest = ({ - path, - method, - body, - interval, - initialData, - processData, -}: UseRequest) => { - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [data, setData] = useState(initialData); - - // Tied to every render and bound to each request. - let isOutdatedRequest = false; - - const createRequest = async (isInitialRequest = true) => { - // Set a neutral state for a non-request. - if (!path) { - setError(null); - setData(initialData); - setIsLoading(false); - return; - } - - setError(null); - - // Only set loading state to true and initial data on the first request - if (isInitialRequest) { - setIsLoading(true); - setData(initialData); - } - - const { data: responseData, error: responseError } = await sendRequest({ - path, - method, - body, - }); - - // Don't update state if an outdated request has resolved. - if (isOutdatedRequest) { - return; - } - - setError(responseError); - setData(processData && responseData ? processData(responseData) : responseData); - setIsLoading(false); - }; - - useEffect(() => { - function cancelOutdatedRequest() { - isOutdatedRequest = true; - } - - createRequest(); - - if (interval) { - const intervalRequest = setInterval(createRequest.bind(null, false), interval); - - return () => { - cancelOutdatedRequest(); - clearInterval(intervalRequest); - }; - } - - // Called when a new render will trigger this effect. - return cancelOutdatedRequest; - }, [path]); - - return { - error, - isLoading, - data, - createRequest, - }; +export const useRequest = (config: UseRequestConfig) => { + return _useRequest(getHttpClient(), config); }; diff --git a/x-pack/legacy/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx b/x-pack/legacy/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx index e0a78aed7ac84..5288734493587 100644 --- a/x-pack/legacy/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx +++ b/x-pack/legacy/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useContext, useEffect, useState } from 'react'; +import React, { Fragment, useContext, useEffect } from 'react'; import { AnnotationDomainTypes, Axis, @@ -130,21 +130,21 @@ export const WatchVisualization = () => { // Fetching visualization data is independent of watch actions const watchWithoutActions = new ThresholdWatch({ ...watch, actions: [] }); - const [isInitialRequest, setIsInitialRequest] = useState(true); - const { + isInitialRequest, isLoading, data: watchVisualizationData, error, - createRequest: reload, + sendRequest: reload, } = getWatchVisualizationData(watchWithoutActions, visualizeOptions); useEffect(() => { - // Prevents refetch on initial render + // Prevent sending a second request on initial render. if (isInitialRequest) { - return setIsInitialRequest(false); + return; } - reload(false); + + reload(); }, [ index, timeField, @@ -161,7 +161,7 @@ export const WatchVisualization = () => { threshold, ]); - if (isLoading) { + if (isInitialRequest && isLoading) { return ( } @@ -283,5 +283,6 @@ export const WatchVisualization = () => { ); } + return null; }; diff --git a/x-pack/legacy/plugins/watcher/public/shared_imports.ts b/x-pack/legacy/plugins/watcher/public/shared_imports.ts new file mode 100644 index 0000000000000..3d93b882733ab --- /dev/null +++ b/x-pack/legacy/plugins/watcher/public/shared_imports.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export { + SendRequestConfig, + SendRequestResponse, + UseRequestConfig, + sendRequest, + useRequest, +} from '../../../../../src/plugins/es_ui_shared/public/request';