Extract useRequest and sendRequest from SR into es-ui-shared plugin (#40777)

- Refactor SR's consumption to send UI metric telemetry in the promise chain, to uncouple the request functions from telemetry.
- Refactor useRequest to return isInitialRequest instead of tracking polling state.
- Rename request callback to sendRequest.
- Rename loading flag to isLoading.
- Rename changeInterval callback to setRequestInterval.
- Rename interval parameter to pollIntervalMs.
- Remove initialData from return value.
- Preserve complete response shape instead of removing everything except the data property.
- Send a request when path, body, or method changes.
- Don't send a request when the interval changes.
- Remove setRequestInterval from return value.
- Expect the consumer to change the interval config in order to set it to a new value.
- Refactor internals so that calling sendRequest resets the interval.
- Migrate Watcher to use shared request library.
- Rename onSuccess to deserializer and use it to process data.
- Rename updateInterval function to scheduleRequest.
- Don’t watch method parameter.
This commit is contained in:
CJ Cenizal 2019-07-29 14:28:18 -07:00 committed by GitHub
parent 55ba53ce1d
commit c91e94510e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 682 additions and 439 deletions

View file

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

View file

@ -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(<TestHook callback={callback} />);
};
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();
});
});
});
});
});

View file

@ -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<Partial<SendRequestResponse>> => {
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 | any>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [data, setData] = useState<any>(initialData);
// Consumers can use isInitialRequest to implement a polling UX.
const [isInitialRequest, setIsInitialRequest] = useState<boolean>(true);
const pollInterval = useRef<any>(null);
const pollIntervalId = useRef<any>(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
};
};

View file

@ -54,7 +54,7 @@ export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
// Load repository types
const {
error: repositoryTypesError,
loading: repositoryTypesLoading,
isLoading: repositoryTypesLoading,
data: repositoryTypes = [],
} = useLoadRepositoryTypes();

View file

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

View file

@ -36,11 +36,11 @@ export const PolicyList: React.FunctionComponent<RouteComponentProps<MatchParams
const {
error,
loading,
isLoading,
data: { policies } = {
policies: undefined,
},
request: reload,
sendRequest: reload,
} = useLoadPolicies();
const openPolicyDetailsUrl = (newPolicyName: SlmPolicy['name']): string => {
@ -61,7 +61,7 @@ export const PolicyList: React.FunctionComponent<RouteComponentProps<MatchParams
let content;
if (loading) {
if (isLoading) {
content = (
<SectionLoading>
<FormattedMessage

View file

@ -36,12 +36,12 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
const {
error,
loading,
isLoading,
data: { repositories, managedRepository } = {
repositories: undefined,
managedRepository: undefined,
},
request: reload,
sendRequest: reload,
} = useLoadRepositories();
const openRepositoryDetailsUrl = (newRepositoryName: Repository['name']): string => {
@ -71,7 +71,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
let content;
if (loading) {
if (isLoading) {
content = (
<SectionLoading>
<FormattedMessage

View file

@ -50,7 +50,7 @@ export const RestoreList: React.FunctionComponent = () => {
const [currentInterval, setCurrentInterval] = useState<number>(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 = (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.loadingRestoresDescription"
defaultMessage="Loading restores…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<SectionError
title={
if (isInitialRequest) {
if (isLoading) {
// Because we're polling for new data, we only want to hide the list during the initial fetch.
content = (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.LoadingRestoresErrorMessage"
defaultMessage="Error loading restores"
id="xpack.snapshotRestore.restoreList.loadingRestoresDescription"
defaultMessage="Loading restores…"
/>
}
error={error}
/>
);
} else if (restores && restores.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
</SectionLoading>
);
} 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 = (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptTitle"
defaultMessage="You don't have any restored snapshots"
id="xpack.snapshotRestore.restoreList.loadingRestoresErrorMessage"
defaultMessage="Error loading restores"
/>
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescription"
defaultMessage="Go to {snapshotsLink} to start a restore."
values={{
snapshotsLink: (
<EuiLink href={linkToSnapshots()}>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescriptionLink"
defaultMessage="Snapshots"
/>
</EuiLink>
),
}}
/>
</p>
</Fragment>
}
data-test-subj="emptyPrompt"
/>
);
}
error={error}
/>
);
}
} else {
content = (
<Fragment>
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiPopover
id="srRestoreListIntervalMenu"
button={
<EuiButtonEmpty
size="xs"
type="text"
iconType="arrowDown"
iconSide="right"
onClick={() => setIsIntervalMenuOpen(!isIntervalMenuOpen)}
>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenuButtonText"
defaultMessage="Refresh data every {interval}"
values={{
interval:
currentInterval >= ONE_MINUTE_MS ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.minutesIntervalValue"
defaultMessage="{minutes} {minutes, plural, one {minute} other {minutes}}"
values={{ minutes: Math.ceil(currentInterval / ONE_MINUTE_MS) }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.secondsIntervalValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(currentInterval / ONE_SECOND_MS) }}
/>
),
}}
/>
</EuiButtonEmpty>
}
isOpen={isIntervalMenuOpen}
closePopover={() => setIsIntervalMenuOpen(false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
items={INTERVAL_OPTIONS.map(interval => (
<EuiContextMenuItem
key={interval}
icon="empty"
onClick={() => {
changeInterval(interval);
setCurrentInterval(interval);
setIsIntervalMenuOpen(false);
}}
>
{interval >= ONE_MINUTE_MS ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.minutesIntervalValue"
defaultMessage="{minutes} {minutes, plural, one {minute} other {minutes}}"
values={{ minutes: Math.ceil(interval / ONE_MINUTE_MS) }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.secondsIntervalValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(interval / ONE_SECOND_MS) }}
/>
)}
</EuiContextMenuItem>
))}
if (restores && restores.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptTitle"
defaultMessage="You don't have any restored snapshots"
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>{polling ? <EuiLoadingSpinner size="m" /> : null}</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<RestoreTable restores={restores || []} />
</Fragment>
);
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescription"
defaultMessage="Go to {snapshotsLink} to start a restore."
values={{
snapshotsLink: (
<EuiLink href={linkToSnapshots()}>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescriptionLink"
defaultMessage="Snapshots"
/>
</EuiLink>
),
}}
/>
</p>
</Fragment>
}
data-test-subj="emptyPrompt"
/>
);
} else {
content = (
<Fragment>
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiPopover
id="srRestoreListIntervalMenu"
button={
<EuiButtonEmpty
size="xs"
type="text"
iconType="arrowDown"
iconSide="right"
onClick={() => setIsIntervalMenuOpen(!isIntervalMenuOpen)}
>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenuButtonText"
defaultMessage="Refresh data every {interval}"
values={{
interval:
currentInterval >= ONE_MINUTE_MS ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.minutesIntervalValue"
defaultMessage="{minutes} {minutes, plural, one {minute} other {minutes}}"
values={{ minutes: Math.ceil(currentInterval / ONE_MINUTE_MS) }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.secondsIntervalValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(currentInterval / ONE_SECOND_MS) }}
/>
),
}}
/>
</EuiButtonEmpty>
}
isOpen={isIntervalMenuOpen}
closePopover={() => setIsIntervalMenuOpen(false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
items={INTERVAL_OPTIONS.map(interval => (
<EuiContextMenuItem
key={interval}
icon="empty"
onClick={() => {
sendRequest();
setCurrentInterval(interval);
setIsIntervalMenuOpen(false);
}}
>
{interval >= ONE_MINUTE_MS ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.minutesIntervalValue"
defaultMessage="{minutes} {minutes, plural, one {minute} other {minutes}}"
values={{ minutes: Math.ceil(interval / ONE_MINUTE_MS) }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.secondsIntervalValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(interval / ONE_SECOND_MS) }}
/>
)}
</EuiContextMenuItem>
))}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isLoading ? <EuiLoadingSpinner size="m" /> : null}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<RestoreTable restores={restores || []} />
</Fragment>
);
}
}
return (

View file

@ -41,9 +41,9 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
const {
error,
loading,
isLoading,
data: { snapshots = [], repositories = [], errors = {} },
request: reload,
sendRequest: reload,
} = useLoadSnapshots();
const openSnapshotDetailsUrl = (
@ -104,7 +104,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
let content;
if (loading) {
if (isLoading) {
content = (
<SectionLoading>
<FormattedMessage

View file

@ -46,7 +46,7 @@ export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchPa
// Load repository
const {
error: repositoryError,
loading: loadingRepository,
isLoading: loadingRepository,
data: repositoryData,
} = useLoadRepository(name);

View file

@ -39,7 +39,7 @@ export const RestoreSnapshot: React.FunctionComponent<RouteComponentProps<MatchP
const [snapshotDetails, setSnapshotDetails] = useState<SnapshotDetails | {}>({});
// Load snapshot
const { error: snapshotError, loading: loadingSnapshot, data: snapshotData } = useLoadSnapshot(
const { error: snapshotError, isLoading: loadingSnapshot, data: snapshotData } = useLoadSnapshot(
repositoryName,
snapshotId
);

View file

@ -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<Repository['name']>) => {
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;
};

View file

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

View file

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

View file

@ -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 {
SendRequestConfig,
SendRequestResponse,
UseRequestConfig,
sendRequest as _sendRequest,
useRequest as _useRequest,
} from '../../../shared_imports';
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<Partial<SendRequestResponse>> => {
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);
}
return {
data: response.data,
};
} catch (e) {
return {
error: e.response ? e.response : e,
};
}
export const sendRequest = (config: SendRequestConfig): Promise<Partial<SendRequestResponse>> => {
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 | any>(null);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<any>(initialData);
// States for tracking polling
const [polling, setPolling] = useState<boolean>(false);
const [currentInterval, setCurrentInterval] = useState<UseRequest['interval']>(interval);
const intervalRequest = useRef<any>(null);
const isFirstRequest = useRef<boolean>(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);
};

View file

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

View file

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

View file

@ -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<Partial<SendRequestResponse>> => {
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<Partial<SendRequestResponse>> => {
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 | any>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [data, setData] = useState<any>(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);
};

View file

@ -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<boolean>(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 (
<EuiEmptyPrompt
title={<EuiLoadingChart size="xl" />}
@ -283,5 +283,6 @@ export const WatchVisualization = () => {
</div>
);
}
return null;
};

View file

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