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 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx index fbe769f0877561ab755049749e12577e13c1b005..8e0fb25dd7a80742635bc5039e63a74ceae686cc 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 1d44751caafad5060f8fb04e11880b6a1980497f..4936c27c1dde4cf20a7e4b86504bb457d3fe579a 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 ea6f50ef6cfd1bc9e5fd3ae6f4d85f64110d4421..00c556b71ce7fd73f07b0c106c4af87d8c92b32b 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 0000000000000000000000000000000000000000..7ea949afd9f0716a6af6aabdc8d2694fe1490f16 --- /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 0000000000000000000000000000000000000000..1994a6654a7d14190c3d170502de718ee4130c09 --- /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 077e5e608f0cd3766121cb444d92bd5764dd4d1f..0da338206a975e41432e619c0d238d9ae6d7f285 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 1aa3d73b227fb3f5ba7ce6a3fd69e70b161ac58e..42f38d9fa45920846738d08a3aec0e79d6bc1d68 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 6613aea72d35cc71d858350896d3d8ea79121e73..9f83d70582058f6d5d5bd6038ec00c3858e5c65b 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 0000000000000000000000000000000000000000..67321198348d16a13f633d31936ed28329b567f9 --- /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 201ab2ba0fcc319c2258527eaef881a16a0bbfa8..48f87045de1809a1062674b9adcb52d447717d33 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 da309fe92cc35e24158cadfd427599d5c2fb4c2b..1e01113d88696d3fb4c79183d6d29a1b5c37f869 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 5275562485dd09a9d3982905371d61e2f834500e..212660e5ee66c695f389fd6c49b7168b6ec78ecb 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 33684ff0044d28bfad2ea5565d1efb5db0fdbac4..c17abce73156f1eb8a1ea32c3fda8d2e9fef392f 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 5c31d2291d336562a2d186b297bdf04cb6cb3827..894ee98a802339b948da3a12556716284b4aa41d 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 ee043d29dc7ecd64a51adb35a1cffbed928439af..39ea3df45651233260da3b6b1603ce18abf33fa0 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 1195f55f9d5204d66cd2d29c879e3d443812620f..5b0774dd52b3c1ce33507be9366ff636425c02ed 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 0d77c9f55c2af9f2944bec93d1bac3ba27a96780..baad92cad09cdd06c7b68b7e3929a0d89780bcbe 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 4b8559aeb6a76c0b1a9a778d642eff51a4c5ccf8..3a25cd8c40f76e9442d43fa354bd7877d29b4306 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 35a5704c0d97183c8e129d6cb730c667934dd4d3..cf53310214bbb1713690081f292f9c85284c5291 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 0000000000000000000000000000000000000000..60cba1fd90f1fe16a31e44a266f7bc8dd41206a6 --- /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 63ff252959a080d56554c843e00ef11c4e764556..e48801f3cd773468ab419c4d233424833c4b3126 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>;