Unverified Commit 801291b0 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Integrate InstantDownloadTranscript in zmenu (#288)

- Add InstantDownloadTranscript to Zmenu
- Add useApiService to fetch data from components
parent 7878c1e1
Pipeline #79836 passed with stages
in 7 minutes and 36 seconds
...@@ -39,6 +39,7 @@ ...@@ -39,6 +39,7 @@
.zmenuAppLinks { .zmenuAppLinks {
display: flex; display: flex;
align-items: center;
} }
.zmenuAppButton { .zmenuAppButton {
...@@ -61,7 +62,15 @@ ...@@ -61,7 +62,15 @@
} }
.zmenuFooterContent { .zmenuFooterContent {
margin-top: 12px; margin-top: 19px;
border-top: 1px solid rgba($grey, 0.3); border-top: 1px solid rgba($grey, 0.3);
padding-top: 12px; padding-top: 12px;
} }
.zmenuInstantDowload {
&Loading {
display: flex;
flex-direction: column;
align-items: center;
}
}
...@@ -25,6 +25,7 @@ import { ...@@ -25,6 +25,7 @@ import {
ToolboxExpandableContent ToolboxExpandableContent
} from 'src/shared/components/toolbox'; } from 'src/shared/components/toolbox';
import ZmenuContent from './ZmenuContent'; import ZmenuContent from './ZmenuContent';
import ZmenuInstantDownload from './ZmenuInstantDownload';
import { ZmenuData, ZmenuAction } from './zmenu-types'; import { ZmenuData, ZmenuAction } from './zmenu-types';
...@@ -55,9 +56,6 @@ const Zmenu = (props: ZmenuProps) => { ...@@ -55,9 +56,6 @@ const Zmenu = (props: ZmenuProps) => {
direction === Direction.LEFT ? ToolboxPosition.LEFT : ToolboxPosition.RIGHT; direction === Direction.LEFT ? ToolboxPosition.LEFT : ToolboxPosition.RIGHT;
const mainContent = <ZmenuContent content={props.content} />; const mainContent = <ZmenuContent content={props.content} />;
const footerContent = (
<div className={styles.zmenuFooterContent}>Zmenu footer content</div>
);
const anchorStyles = getAnchorInlineStyles(props); const anchorStyles = getAnchorInlineStyles(props);
return ( return (
...@@ -70,7 +68,7 @@ const Zmenu = (props: ZmenuProps) => { ...@@ -70,7 +68,7 @@ const Zmenu = (props: ZmenuProps) => {
> >
<ToolboxExpandableContent <ToolboxExpandableContent
mainContent={mainContent} mainContent={mainContent}
footerContent={footerContent} footerContent={getToolboxFooterContent(props.id)}
/> />
</Toolbox> </Toolbox>
)} )}
...@@ -96,4 +94,10 @@ const chooseDirection = (params: ZmenuProps) => { ...@@ -96,4 +94,10 @@ const chooseDirection = (params: ZmenuProps) => {
return x > width / 2 ? Direction.LEFT : Direction.RIGHT; return x > width / 2 ? Direction.LEFT : Direction.RIGHT;
}; };
const getToolboxFooterContent = (id: string) => (
<div className={styles.zmenuFooterContent}>
<ZmenuInstantDownload id={id} />
</div>
);
export default Zmenu; export default Zmenu;
import React from 'react';
import useApiService from 'src/shared/hooks/useApiService';
import { InstantDownloadTranscript } from 'src/shared/components/instant-download';
import { CircleLoader } from 'src/shared/components/loader/Loader';
import { TranscriptInResponse } from 'src/content/app/entity-viewer/shared/rest/rest-data-fetchers/transcriptData';
import { LoadingState } from 'src/shared/types/loading-state';
import styles from './Zmenu.scss';
type Props = {
id: string;
};
const ZmenuInstantDownload = (props: Props) => {
const transcriptId = getStableId(props.id);
const params = {
endpoint: `/lookup/id/${transcriptId}?content-type=application/json;expand=1`,
host: 'https://rest.ensembl.org'
};
const { loadingState, data, error } = useApiService<TranscriptInResponse>(
params
);
if (loadingState === LoadingState.LOADING) {
return (
<div className={styles.zmenuInstantDowloadLoading}>
<CircleLoader />
</div>
);
}
if (error) {
// TODO: decide how we handle errors in this case
return null;
}
return (
<InstantDownloadTranscript
{...preparePayload(data as TranscriptInResponse)}
layout="vertical"
/>
);
};
// TODO: we may want to move this to a common helper file that deals with messaging with Genome Browser
const getStableId = (id: string) => id.split(':').pop();
const preparePayload = (transcript: TranscriptInResponse) => {
const geneId = transcript.Parent;
const transcriptId = transcript.id;
const so_term = transcript.biotype;
return {
transcript: {
id: transcriptId,
so_term
},
gene: {
id: geneId
}
};
};
export default ZmenuInstantDownload;
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
import config from 'config'; import config from 'config';
import LRUCache from 'src/shared/utils/lruCache'; import LRUCache from 'src/shared/utils/lruCache';
import JSONValue from 'src/shared/types/JSON';
export enum HTTPMethod { export enum HTTPMethod {
GET = 'GET', GET = 'GET',
POST = 'POST', POST = 'POST',
...@@ -28,17 +30,24 @@ export enum HTTPMethod { ...@@ -28,17 +30,24 @@ export enum HTTPMethod {
DELETE = 'DELETE' DELETE = 'DELETE'
} }
// Note: Currently does not conform to Core Data Modelling definition of an API error
export type APIError = {
status: number;
message: string | JSONValue;
};
type ApiServiceConfig = { type ApiServiceConfig = {
host: string; host: string;
}; };
type FetchOptions = { export type FetchOptions = {
host?: string; host?: string;
method?: HTTPMethod; method?: HTTPMethod;
headers?: { [key: string]: string }; headers?: { [key: string]: string };
body?: string; // stringified json body?: string; // stringified json
preserveEndpoint?: boolean; preserveEndpoint?: boolean;
noCache?: boolean; noCache?: boolean;
signal?: AbortSignal;
}; };
const defaultMethod = HTTPMethod.GET; const defaultMethod = HTTPMethod.GET;
...@@ -75,7 +84,8 @@ class ApiService { ...@@ -75,7 +84,8 @@ class ApiService {
return { return {
method: options.method || defaultMethod, method: options.method || defaultMethod,
headers: { ...defaultHeaders, ...options.headers }, headers: { ...defaultHeaders, ...options.headers },
body: options.body body: options.body,
signal: options.signal
}; };
} }
...@@ -107,7 +117,9 @@ class ApiService { ...@@ -107,7 +117,9 @@ class ApiService {
} }
return processedResponse; return processedResponse;
} catch (error) { } catch (error) {
throw error; if (error.name !== 'AbortError') {
throw error;
}
} }
} }
......
...@@ -15,3 +15,4 @@ ...@@ -15,3 +15,4 @@
*/ */
export { default as InstantDownloadTranscript } from './instant-download-transcript/InstantDownloadTranscript'; export { default as InstantDownloadTranscript } from './instant-download-transcript/InstantDownloadTranscript';
export type { InstantDownloadTranscriptEntityProps } from './instant-download-transcript/InstantDownloadTranscript';
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import intersection from 'lodash/intersection';
import { fetchForTranscript } from '../instant-download-fetch/fetchForTranscript'; import { fetchForTranscript } from '../instant-download-fetch/fetchForTranscript';
...@@ -37,9 +38,12 @@ type GeneFields = { ...@@ -37,9 +38,12 @@ type GeneFields = {
id: string; id: string;
}; };
type Props = { export type InstantDownloadTranscriptEntityProps = {
transcript: TranscriptFields; transcript: TranscriptFields;
gene: GeneFields; gene: GeneFields;
};
type Props = InstantDownloadTranscriptEntityProps & {
layout: Layout; layout: Layout;
}; };
...@@ -165,7 +169,11 @@ InstantDownloadTranscript.defaultProps = { ...@@ -165,7 +169,11 @@ InstantDownloadTranscript.defaultProps = {
const TranscriptSection = (props: TranscriptSectionProps) => { const TranscriptSection = (props: TranscriptSectionProps) => {
const { transcript, options } = props; const { transcript, options } = props;
const checkboxes = transcriptOptionsOrder.map((key) => ( const orderedOptionKeys = intersection(
transcriptOptionsOrder,
Object.keys(options)
);
const checkboxes = orderedOptionKeys.map((key) => (
<Checkbox <Checkbox
key={key} key={key}
classNames={{ unchecked: styles.checkboxUnchecked }} classNames={{ unchecked: styles.checkboxUnchecked }}
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
.circleLoader { .circleLoader {
display: inline-block; display: inline-block;
border: 3px solid $light-grey; border: 3px solid $grey;
border-top-color: $red; border-top-color: $red;
width: 40px; width: 40px;
height: 40px; height: 40px;
......
import React from 'react';
import faker from 'faker';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import apiService from 'src/services/api-service';
import useApiService from '../useApiService';
import { LoadingState } from 'src/shared/types/loading-state';
const mockSuccessData = {
message: 'success'
};
const mockErrorData = {
message: 'error'
};
const onAbort = jest.fn();
const mockEndpoint = faker.internet.url();
const mockSuccessfulFetch = (
_: string,
{ signal }: { signal: AbortSignal }
) => {
signal && (signal.onabort = onAbort);
return new Promise((resolve) => {
setTimeout(() => resolve(mockSuccessData), 1);
});
};
const mockFailedFetch = () => {
return Promise.reject(mockErrorData);
};
type TestingComponentProps = {
isAbortable?: boolean;
skip?: boolean;
};
const TestingComponent = (props: TestingComponentProps) => {
const params = {
endpoint: mockEndpoint,
...props
};
const { loadingState, data, error } = useApiService<{ message: string }>(
params
);
if (loadingState === LoadingState.NOT_REQUESTED) {
return <div>Data not requested</div>;
}
if (data) {
return <div className="success">{data.message}</div>;
}
if (error) {
return <div className="error">{error.message}</div>;
}
return null;
};
describe('useApiService', () => {
beforeEach(() => {
jest
.spyOn(apiService, 'fetch')
.mockImplementation(mockSuccessfulFetch as any);
});
afterEach(() => {
jest.resetAllMocks();
});
it('fetches data', async () => {
const wrapper = mount(<TestingComponent />);
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 2);
});
wrapper.update();
});
expect(apiService.fetch).toHaveBeenCalledWith(mockEndpoint, {});
expect(wrapper.find('.success').text()).toBe(mockSuccessData.message);
expect(onAbort).not.toHaveBeenCalled();
});
it('does not fetch data if the `skip` option is set to true', async () => {
const wrapper = mount(<TestingComponent skip={true} />);
// waiting the same duration of time as in the test where the hook successfully fetches the data
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 2);
});
wrapper.update();
});
expect(apiService.fetch).not.toHaveBeenCalled();
expect(wrapper.text()).toBe('Data not requested');
});
it('returns error if request errored out', async () => {
jest.spyOn(apiService, 'fetch').mockImplementation(mockFailedFetch as any);
const wrapper = mount(<TestingComponent />);
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 1);
});
wrapper.update();
});
expect(wrapper.find('.success').length).toBe(0);
expect(wrapper.find('.error').text()).toBe(mockErrorData.message);
});
it('aborts request on unmount if passed isAbortable option', () => {
const wrapper = mount(<TestingComponent isAbortable={true} />);
wrapper.unmount();
expect(onAbort).toHaveBeenCalled();
});
});
import { useEffect, useReducer, Reducer } from 'react';
import apiService, { FetchOptions, APIError } from 'src/services/api-service';
import { LoadingState } from 'src/shared/types/loading-state';
type Params = FetchOptions & {
endpoint: string;
isAbortable?: boolean;
skip?: boolean;
};
type StateBeforeRequest = {
loadingState: LoadingState.NOT_REQUESTED;
data: null;
error: null;
};
type StateAtLoading = {
loadingState: LoadingState.LOADING;
data: null;
error: null;
};
type StateAtSuccess<T> = {
loadingState: LoadingState.SUCCESS;
data: T;
error: null;
};
type StateAtError = {
loadingState: LoadingState.ERROR;
data: null;
error: APIError;
};
type State<T> =
| StateBeforeRequest
| StateAtLoading
| StateAtSuccess<T>
| StateAtError;
type LoadingAction = {
type: 'loading';
};
type SuccessAction<T> = {
type: 'success';
payload: T;
};
type ErrorAction = {
type: 'error';
payload: APIError;
};
type Action<T> = LoadingAction | SuccessAction<T> | ErrorAction;
const initialState: StateBeforeRequest = {
loadingState: LoadingState.NOT_REQUESTED,
data: null,
error: null
};
const reducer = <T>(state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case 'loading':
return initialState;
case 'success':
return {
loadingState: LoadingState.SUCCESS,
data: action.payload,
error: null
};
case 'error':
return {
loadingState: LoadingState.ERROR,
data: null,
error: action.payload
};
default:
return state;
}
};
const useApiService = <T>(params: Params): State<T> => {
const [state, dispatch] = useReducer<Reducer<State<T>, Action<T>>>(
reducer,
initialState
);
useEffect(() => {
if (params.skip) {
return;
}
let canUpdate = true;
dispatch({ type: 'loading' });
const { endpoint, isAbortable, ...fetchOptions } = params;
const abortController = new AbortController();
if (isAbortable) {
fetchOptions.signal = abortController.signal;
}
apiService
.fetch(endpoint, fetchOptions)
.then((data) => {
// notice that if the request has been aborted and the abort error caught in api service,
// the promise will resolve with empty data; but, since canUpdate will be false,
// the state will not get updated with empty data
if (canUpdate) {
dispatch({ type: 'success', payload: data });
}
})
.catch((error) => {
dispatch({ type: 'error', payload: error });
});
return () => {
canUpdate = false;
isAbortable && abortController.abort();
};
}, [params.endpoint, params.host, params.skip]);
return state;
};
export default useApiService;
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
<body> <body>
<div id="ens-app" class="ens-app"></div> <div id="ens-app" class="ens-app"></div>
<script src="https://polyfill.io/v3/polyfill.min.js?features=Object.assign%2CPromise%2Cfetch%2CIntersectionObserver%2CIntersectionObserverEntry%2CResizeObserver"></script> <script src="https://polyfill.io/v3/polyfill.min.js?features=AbortController%2Object.assign%2CPromise%2Cfetch%2CIntersectionObserver%2CIntersectionObserverEntry%2CResizeObserver"></script>
</body> </body>
</html> </html>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment