From 639bb921200bea00ece107b7e8c00c7152c89f95 Mon Sep 17 00:00:00 2001 From: mateusz-winiarczyk <mateusz.winiarczyk@appunite.com> Date: Thu, 25 Jan 2024 12:08:50 +0100 Subject: [PATCH] feat(export): network download (MIN-240) --- .../ExportDrawer/Elements/Elements.utils.ts | 0 .../DownloadNetwork/DownloadNetwork.tsx | 2 +- .../ExportCompound.component.tsx | 20 +- .../ExportCompound/ExportCompound.constant.ts | 13 ++ ...actAndParseNumberIdFromCompartment.test.ts | 26 +++ .../extractAndParseNumberIdFromCompartment.ts | 10 + .../utils/getDownloadElementsBodyRequest.ts | 10 +- .../utils/getNetworkBodyRequest.test.ts | 35 ++- .../utils/getNetworkBodyRequest.ts | 33 ++- .../Network/Network.component.test.tsx | 210 ++++++++++++++++++ .../Network/Network.component.tsx | 2 +- src/models/exportSchema.ts | 2 + src/redux/apiPath.ts | 2 + src/redux/export/export.mock.ts | 7 + src/redux/export/export.reducers.test.ts | 62 +++++- src/redux/export/export.reducers.ts | 14 +- src/redux/export/export.slice.ts | 10 +- src/redux/export/export.thunks.test.ts | 41 +++- src/redux/export/export.thunks.ts | 42 +++- src/redux/export/export.types.ts | 4 + src/redux/export/export.utils.ts | 9 + src/types/models.ts | 3 +- 22 files changed, 520 insertions(+), 37 deletions(-) delete mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.test.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx create mode 100644 src/redux/export/export.utils.ts diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx index fbe769f0..8e0fb25d 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react'; import { Button } from '@/shared/Button'; import { ExportContext } from '../ExportCompound.context'; -export const DownloadElements = (): React.ReactNode => { +export const DownloadNetwork = (): React.ReactNode => { const { handleDownloadNetwork } = useContext(ExportContext); return ( diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx index 1d44751c..4936c27c 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx @@ -2,7 +2,7 @@ import { ReactNode, useCallback, useMemo, useState } from 'react'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { modelsIdsSelector } from '@/redux/models/models.selectors'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { downloadElements } from '@/redux/export/export.thunks'; +import { downloadNetwork, downloadElements } from '@/redux/export/export.thunks'; import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.component'; import { Annotations } from './Annotations'; import { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways'; @@ -11,7 +11,8 @@ import { DownloadElements } from './DownloadElements/DownloadElements'; import { ExportContext } from './ExportCompound.context'; import { getNetworkDownloadBodyRequest } from './utils/getNetworkBodyRequest'; import { getDownloadElementsBodyRequest } from './utils/getDownloadElementsBodyRequest'; -import { ELEMENTS_COLUMNS } from './ExportCompound.constant'; +import { DownloadNetwork } from './DownloadNetwork/DownloadNetwork'; +import { ELEMENTS_COLUMNS, NETWORK_COLUMNS } from './ExportCompound.constant'; type ExportProps = { children: ReactNode; @@ -40,9 +41,17 @@ export const Export = ({ children }: ExportProps): JSX.Element => { dispatch(downloadElements(body)); }, [modelIds, annotations, includedCompartmentPathways, excludedCompartmentPathways, dispatch]); - const handleDownloadNetwork = useCallback(() => { - getNetworkDownloadBodyRequest(); - }, []); + const handleDownloadNetwork = useCallback(async () => { + const data = getNetworkDownloadBodyRequest({ + columns: NETWORK_COLUMNS, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + }); + + dispatch(downloadNetwork(data)); + }, [modelIds, annotations, includedCompartmentPathways, excludedCompartmentPathways, dispatch]); const globalContextValue = useMemo( () => ({ @@ -62,3 +71,4 @@ Export.Annotations = Annotations; Export.IncludedCompartmentPathways = IncludedCompartmentPathways; Export.ExcludedCompartmentPathways = ExcludedCompartmentPathways; Export.DownloadElements = DownloadElements; +Export.DownloadNetwork = DownloadNetwork; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts index ea6f50ef..00c556b7 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts @@ -30,3 +30,16 @@ export const ELEMENTS_COLUMNS = [ 'linkedSubmodelId', 'elementId', ]; + +export const NETWORK_COLUMNS = [ + 'id', + 'type', + 'reactantIds', + 'productIds', + 'modifierIds', + 'description', + 'reactionId', + 'references', + 'modelId', + 'mapName', +]; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.test.ts new file mode 100644 index 00000000..7ea949af --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.test.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-magic-numbers */ +import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; + +describe('extractAndParseNumberIdFromCompartment', () => { + it('should extract and parse number id from compartment', () => { + const compartment = { id: 'compartment-123', label: 'x' }; + const result = extractAndParseNumberIdFromCompartment(compartment); + expect(result).toBe(123); + }); + + it('should handle id with non-numeric characters', () => { + const compartment = { id: 'compartment-abc', label: 'x' }; + + expect(() => extractAndParseNumberIdFromCompartment(compartment)).toThrowError( + 'compartment id is not a number', + ); + }); + + it('should handle missing id', () => { + const compartment = { id: 'compartment-', label: 'x' }; + + expect(() => extractAndParseNumberIdFromCompartment(compartment)).toThrowError( + 'compartment id is not a number', + ); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts new file mode 100644 index 00000000..1994a665 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts @@ -0,0 +1,10 @@ +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; + +export const extractAndParseNumberIdFromCompartment = (compartment: CheckboxItem): number => { + const [, id] = compartment.id.split('-'); + const numberId = Number(id); + + if (Number.isNaN(numberId) || id === '') throw new Error('compartment id is not a number'); + + return numberId; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts index 077e5e60..0da33820 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts @@ -1,4 +1,5 @@ import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; +import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; type DownloadBodyRequest = { columns: string[]; @@ -16,15 +17,6 @@ type GetDownloadBodyRequestProps = { excludedCompartmentPathways: CheckboxItem[]; }; -const extractAndParseNumberIdFromCompartment = (compartment: CheckboxItem): number => { - const [, id] = compartment.id.split('-'); - const numberId = Number(id); - - if (Number.isNaN(numberId) || id === '') throw new Error('compartment id is not a number'); - - return numberId; -}; - export const getDownloadElementsBodyRequest = ({ columns, modelIds, diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts index 1aa3d73b..42f38d9f 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts @@ -1,8 +1,37 @@ +/* eslint-disable no-magic-numbers */ import { getNetworkDownloadBodyRequest } from './getNetworkBodyRequest'; describe('getNetworkDownloadBodyRequest', () => { - it('should return an empty object', () => { - const result = getNetworkDownloadBodyRequest(); - expect(result).toEqual({}); + it('should return the correct DownloadBodyRequest object', () => { + const columns = ['column1', 'column2']; + const modelIds = [1, 2, 3]; + const annotations = [ + { id: 'Annotation 1', label: 'Annotation 1' }, + { id: 'Annotation 2', label: 'Annotation 2' }, + ]; + const includedCompartmentPathways = [ + { id: 'include-7', label: 'Compartment 1' }, + { id: 'include-8', label: 'Compartment 2' }, + ]; + const excludedCompartmentPathways = [ + { id: 'exclude-9', label: 'Compartment 3' }, + { id: 'exclude-10', label: 'Compartment 4' }, + ]; + + const result = getNetworkDownloadBodyRequest({ + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + }); + + expect(result).toEqual({ + columns: ['column1', 'column2'], + submaps: [1, 2, 3], + annotations: ['Annotation 1', 'Annotation 2'], + includedCompartmentIds: [7, 8], + excludedCompartmentIds: [9, 10], + }); }); }); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts index 6613aea7..9f83d705 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts @@ -1 +1,32 @@ -export const getNetworkDownloadBodyRequest = (): object => ({}); +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; +import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; + +type DownloadBodyRequest = { + columns: string[]; + submaps: number[]; + annotations: string[]; + includedCompartmentIds: number[]; + excludedCompartmentIds: number[]; +}; + +type GetDownloadBodyRequestProps = { + columns: string[]; + modelIds: number[]; + annotations: CheckboxItem[]; + includedCompartmentPathways: CheckboxItem[]; + excludedCompartmentPathways: CheckboxItem[]; +}; + +export const getNetworkDownloadBodyRequest = ({ + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, +}: GetDownloadBodyRequestProps): DownloadBodyRequest => ({ + columns, + submaps: modelIds, + annotations: annotations.map(annotation => annotation.id), + includedCompartmentIds: includedCompartmentPathways.map(extractAndParseNumberIdFromCompartment), + excludedCompartmentIds: excludedCompartmentPathways.map(extractAndParseNumberIdFromCompartment), +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx new file mode 100644 index 00000000..67321198 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx @@ -0,0 +1,210 @@ +/* eslint-disable no-magic-numbers */ +import { AppDispatch, RootState } from '@/redux/store'; +import { InitialStoreState } from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { CONFIGURATION_INITIAL_STORE_MOCK } from '@/redux/configuration/configuration.mock'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { act } from 'react-dom/test-utils'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { NETWORK_COLUMNS } from '../ExportCompound/ExportCompound.constant'; +import { Network } from './Network.component'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const renderComponent = ( + initialStore?: InitialStoreState, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStore); + return ( + render( + <Wrapper> + <Network /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Network - component', () => { + it('should render all network sections', () => { + renderComponent({ + configuration: { + ...CONFIGURATION_INITIAL_STORE_MOCK, + main: { + ...CONFIGURATION_INITIAL_STORE_MOCK.main, + data: { + ...configurationFixture, + miriamTypes: { + compartment_label: { + commonName: 'Compartment', + homepage: '', + registryIdentifier: '', + uris: [''], + }, + }, + }, + }, + }, + statistics: { + data: { + ...statisticsFixture, + reactionAnnotations: { + compartment_label: 1, + pathway: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + const annotations = screen.getByText('Select annotations'); + const includedCompartmentPathways = screen.getByText('Select included compartment / pathways'); + const excludedCompartmentPathways = screen.getByText('Select excluded compartment / pathways'); + const downloadButton = screen.getByText('Download'); + + expect(annotations).toBeVisible(); + expect(includedCompartmentPathways).toBeVisible(); + expect(excludedCompartmentPathways).toBeVisible(); + expect(downloadButton).toBeVisible(); + }); + it('should handle download button click and dispatch proper data', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + const FIRST_COMPARMENT_PATHWAY_NAME = compartmentPathwaysDetailsFixture[0].name; + const FIRST_COMPARMENT_PATHWAY_ID = compartmentPathwaysDetailsFixture[0].id; + const SECOND_COMPARMENT_PATHWAY_NAME = compartmentPathwaysDetailsFixture[1].name; + const SECOND_COMPARMENT_PATHWAY_ID = compartmentPathwaysDetailsFixture[1].id; + const { store } = renderComponent({ + configuration: { + ...CONFIGURATION_INITIAL_STORE_MOCK, + main: { + ...CONFIGURATION_INITIAL_STORE_MOCK.main, + data: { + ...configurationFixture, + miriamTypes: { + reaction: { + commonName: 'Reaction Label', + homepage: '', + registryIdentifier: '', + uris: [''], + }, + }, + }, + }, + }, + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: { + compartment: 1, + pathway: 0, + }, + reactionAnnotations: { + reaction: 2, + path: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + models: { + data: modelsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + + const annotations = screen.getByText('Select annotations'); + + await act(() => { + annotations.click(); + }); + const annotationInput = screen.getByLabelText('Reaction Label'); + + await act(() => { + annotationInput.click(); + }); + + expect(annotationInput).toBeChecked(); + + const includedCompartmentPathways = screen.getByText('Select included compartment / pathways'); + + await act(() => { + includedCompartmentPathways.click(); + }); + const includedCompartmentPathwaysInput = screen.getAllByLabelText( + FIRST_COMPARMENT_PATHWAY_NAME, + )[0]; + + await act(() => { + includedCompartmentPathwaysInput.click(); + }); + + expect(includedCompartmentPathwaysInput).toBeChecked(); + + const excludedCompartmentPathways = screen.getByText('Select excluded compartment / pathways'); + + await act(() => { + excludedCompartmentPathways.click(); + }); + const excludedCompartmentPathwaysInput = screen.getAllByLabelText( + SECOND_COMPARMENT_PATHWAY_NAME, + )[1]; + + await act(() => { + excludedCompartmentPathwaysInput.click(); + }); + + expect(excludedCompartmentPathwaysInput).toBeChecked(); + + const downloadButton = screen.getByText('Download'); + + await act(() => { + downloadButton.click(); + }); + + const actions = store.getActions(); + + const firstAction = actions[0]; + expect(firstAction.meta.arg).toEqual({ + columns: NETWORK_COLUMNS, + submaps: modelsFixture.map(item => item.idObject), + annotations: ['reaction'], + includedCompartmentIds: [FIRST_COMPARMENT_PATHWAY_ID], + excludedCompartmentIds: [SECOND_COMPARMENT_PATHWAY_ID], + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx index 201ab2ba..48f87045 100644 --- a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx @@ -8,7 +8,7 @@ export const Network = (): React.ReactNode => { <Export.Annotations type={ANNOTATIONS_TYPE.NETWORK} /> <Export.IncludedCompartmentPathways /> <Export.ExcludedCompartmentPathways /> - <Export.DownloadElements /> + <Export.DownloadNetwork /> </Export> </div> ); diff --git a/src/models/exportSchema.ts b/src/models/exportSchema.ts index da309fe9..1e01113d 100644 --- a/src/models/exportSchema.ts +++ b/src/models/exportSchema.ts @@ -1,3 +1,5 @@ import { z } from 'zod'; +export const exportNetworkchema = z.string(); + export const exportElementsSchema = z.string(); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 52755624..212660e5 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -44,6 +44,8 @@ export const apiPath = { getCompartmentPathwayDetails: (ids: number[]): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/?id=${ids.join(',')}`, sendCompartmentPathwaysIds: (): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/`, + downloadNetworkCsv: (): string => + `projects/${PROJECT_ID}/models/*/bioEntities/reactions/:downloadCsv`, getAllUserOverlaysByCreatorQuery: ({ publicOverlay, creator, diff --git a/src/redux/export/export.mock.ts b/src/redux/export/export.mock.ts index 33684ff0..c17abce7 100644 --- a/src/redux/export/export.mock.ts +++ b/src/redux/export/export.mock.ts @@ -1,6 +1,13 @@ import { ExportState } from './export.types'; export const EXPORT_INITIAL_STATE_MOCK: ExportState = { + downloadNetwork: { + error: { + message: '', + name: '', + }, + loading: 'idle', + }, downloadElements: { error: { message: '', diff --git a/src/redux/export/export.reducers.test.ts b/src/redux/export/export.reducers.test.ts index 5c31d229..894ee98a 100644 --- a/src/redux/export/export.reducers.test.ts +++ b/src/redux/export/export.reducers.test.ts @@ -7,11 +7,15 @@ import { HttpStatusCode } from 'axios'; import { ExportState } from './export.types'; import exportReducer from './export.slice'; import { apiPath } from '../apiPath'; -import { downloadElements } from './export.thunks'; +import { downloadNetwork, downloadElements } from './export.thunks'; const mockedAxiosClient = mockNetworkNewAPIResponse(); const INITIAL_STATE: ExportState = { + downloadNetwork: { + loading: 'idle', + error: { name: '', message: '' }, + }, downloadElements: { loading: 'idle', error: { name: '', message: '' }, @@ -30,6 +34,62 @@ describe('export reducer', () => { expect(exportReducer(undefined, action)).toEqual(INITIAL_STATE); }); + it('should update store after successful downloadNetwork query', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + const { loading } = store.getState().export.downloadNetwork; + + expect(loading).toEqual('succeeded'); + }); + + it('should update store on loading downloadNetwork query', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + const downloadNetworkPromise = store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + + const { loading } = store.getState().export.downloadNetwork; + expect(loading).toEqual('pending'); + + await downloadNetworkPromise; + + const { loading: promiseFulfilled } = store.getState().export.downloadNetwork; + + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should update store after failed downloadNetwork query', async () => { + mockedAxiosClient + .onPost(apiPath.downloadNetworkCsv()) + .reply(HttpStatusCode.NotFound, undefined); + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + const { loading } = store.getState().export.downloadNetwork; + + expect(loading).toEqual('failed'); + }); + it('should update store after successful downloadElements query', async () => { mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test'); await store.dispatch( diff --git a/src/redux/export/export.reducers.ts b/src/redux/export/export.reducers.ts index ee043d29..39ea3df4 100644 --- a/src/redux/export/export.reducers.ts +++ b/src/redux/export/export.reducers.ts @@ -1,7 +1,19 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { downloadElements } from './export.thunks'; +import { downloadNetwork, downloadElements } from './export.thunks'; import { ExportState } from './export.types'; +export const downloadNetworkReducer = (builder: ActionReducerMapBuilder<ExportState>): void => { + builder.addCase(downloadNetwork.pending, state => { + state.downloadNetwork.loading = 'pending'; + }); + builder.addCase(downloadNetwork.fulfilled, state => { + state.downloadNetwork.loading = 'succeeded'; + }); + builder.addCase(downloadNetwork.rejected, state => { + state.downloadNetwork.loading = 'failed'; + }); +}; + export const downloadElementsReducer = (builder: ActionReducerMapBuilder<ExportState>): void => { builder.addCase(downloadElements.pending, state => { state.downloadElements.loading = 'pending'; diff --git a/src/redux/export/export.slice.ts b/src/redux/export/export.slice.ts index 1195f55f..5b0774dd 100644 --- a/src/redux/export/export.slice.ts +++ b/src/redux/export/export.slice.ts @@ -1,8 +1,15 @@ import { createSlice } from '@reduxjs/toolkit'; import { ExportState } from './export.types'; -import { downloadElementsReducer } from './export.reducers'; +import { downloadNetworkReducer, downloadElementsReducer } from './export.reducers'; const initialState: ExportState = { + downloadNetwork: { + error: { + message: '', + name: '', + }, + loading: 'idle', + }, downloadElements: { error: { message: '', @@ -17,6 +24,7 @@ const exportSlice = createSlice({ initialState, reducers: {}, extraReducers: builder => { + downloadNetworkReducer(builder); downloadElementsReducer(builder); }, }); diff --git a/src/redux/export/export.thunks.test.ts b/src/redux/export/export.thunks.test.ts index 0d77c9f5..baad92ca 100644 --- a/src/redux/export/export.thunks.test.ts +++ b/src/redux/export/export.thunks.test.ts @@ -7,7 +7,7 @@ import { import { apiPath } from '../apiPath'; import { ExportState } from './export.types'; import exportReducer from './export.slice'; -import { downloadElements } from './export.thunks'; +import { downloadNetwork, downloadElements } from './export.thunks'; const mockedAxiosClient = mockNetworkNewAPIResponse(); @@ -19,6 +19,45 @@ describe('export thunks', () => { global.URL.createObjectURL = jest.fn(); global.document.body.appendChild = jest.fn(); }); + describe('downloadNetwork', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should download file when data response from API is valid', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + expect(global.URL.createObjectURL).toHaveBeenCalledWith(new Blob(['test'])); + + expect(global.document.body.appendChild).toHaveBeenCalled(); + }); + it('should not download file when data response from API is not valid', async () => { + mockedAxiosClient + .onPost(apiPath.downloadNetworkCsv()) + .reply(HttpStatusCode.NotFound, undefined); + + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + + expect(global.document.body.appendChild).not.toHaveBeenCalled(); + }); + }); + describe('downloadElements', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/redux/export/export.thunks.ts b/src/redux/export/export.thunks.ts index 4b8559ae..3a25cd8c 100644 --- a/src/redux/export/export.thunks.ts +++ b/src/redux/export/export.thunks.ts @@ -2,10 +2,11 @@ import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { PROJECT_ID } from '@/constants'; -import { ExportElements } from '@/types/models'; +import { ExportNetwork, ExportElements } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; -import { exportElementsSchema } from '@/models/exportSchema'; +import { exportNetworkchema, exportElementsSchema } from '@/models/exportSchema'; import { apiPath } from '../apiPath'; +import { downloadFileFromBlob } from './export.utils'; type DownloadElementsBodyRequest = { columns: string[]; @@ -15,16 +16,6 @@ type DownloadElementsBodyRequest = { excludedCompartmentIds: number[]; }; -const downloadFileFromBlob = (data: string, filename: string): void => { - const url = window.URL.createObjectURL(new Blob([data])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', filename); - document.body.appendChild(link); - link.click(); - link.remove(); -}; - export const downloadElements = createAsyncThunk( 'export/downloadElements', async (data: DownloadElementsBodyRequest): Promise<void> => { @@ -43,3 +34,30 @@ export const downloadElements = createAsyncThunk( } }, ); + +type DownloadNetworkBodyRequest = { + columns: string[]; + submaps: number[]; + annotations: string[]; + includedCompartmentIds: number[]; + excludedCompartmentIds: number[]; +}; + +export const downloadNetwork = createAsyncThunk( + 'export/downloadNetwork', + async (data: DownloadNetworkBodyRequest): Promise<void> => { + const response = await axiosInstanceNewAPI.post<ExportNetwork>( + apiPath.downloadNetworkCsv(), + data, + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, exportNetworkchema); + + if (isDataValid) { + downloadFileFromBlob(response.data, `${PROJECT_ID}-networkExport.csv`); + } + }, +); diff --git a/src/redux/export/export.types.ts b/src/redux/export/export.types.ts index 35a5704c..cf533102 100644 --- a/src/redux/export/export.types.ts +++ b/src/redux/export/export.types.ts @@ -1,6 +1,10 @@ import { Loading } from '@/types/loadingState'; export type ExportState = { + downloadNetwork: { + loading: Loading; + error: Error; + }; downloadElements: { loading: Loading; error: Error; diff --git a/src/redux/export/export.utils.ts b/src/redux/export/export.utils.ts new file mode 100644 index 00000000..60cba1fd --- /dev/null +++ b/src/redux/export/export.utils.ts @@ -0,0 +1,9 @@ +export const downloadFileFromBlob = (data: string, filename: string): void => { + const url = window.URL.createObjectURL(new Blob([data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); +}; diff --git a/src/types/models.ts b/src/types/models.ts index 63ff2529..e48801f3 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -12,7 +12,7 @@ import { configurationSchema, formatSchema, miriamTypesSchema } from '@/models/c import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; -import { exportElementsSchema } from '@/models/exportSchema'; +import { exportNetworkchema, exportElementsSchema } from '@/models/exportSchema'; import { loginSchema } from '@/models/loginSchema'; import { mapBackground } from '@/models/mapBackground'; import { @@ -74,4 +74,5 @@ export type Color = z.infer<typeof colorSchema>; export type Statistics = z.infer<typeof statisticsSchema>; export type CompartmentPathway = z.infer<typeof compartmentPathwaySchema>; export type CompartmentPathwayDetails = z.infer<typeof compartmentPathwayDetailsSchema>; +export type ExportNetwork = z.infer<typeof exportNetworkchema>; export type ExportElements = z.infer<typeof exportElementsSchema>; -- GitLab