diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.component.test.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d44c61bad6dba6ac0b6ad38e5b67dabaeb9f7c83 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.component.test.tsx @@ -0,0 +1,140 @@ +/* eslint-disable react/no-children-prop */ +import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT, THIRD_ARRAY_ELEMENT } from '@/constants/common'; +import { DEFAULT_ERROR } from '@/constants/errors'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { MODELS_MOCK } from '@/models/mocks/modelsMock'; +import { PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK } from '@/models/mocks/publicationsResponseMock'; +import { apiPath } from '@/redux/apiPath'; +import { downloadFileFromBlob } from '@/redux/export/export.utils'; +import { StoreType } from '@/redux/store'; +import { BioEntityContent, Publication } from '@/types/models'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen, waitFor } from '@testing-library/react'; +import { HttpStatusCode } from 'axios'; +import { fetchElementData } from '../utils/fetchElementData'; +import { PublicationsModalLayout } from './PublicationsModalLayout.component'; + +const FIRST_MODEL_ID = MODELS_MOCK[FIRST_ARRAY_ELEMENT].idObject; +const SECOND_MODEL_ID = MODELS_MOCK[SECOND_ARRAY_ELEMENT].idObject; +const THIRD_MODEL_ID = MODELS_MOCK[THIRD_ARRAY_ELEMENT].idObject; + +const FIRST_ELEMENT_ID = 100; +const SECOND_ELEMENT_ID = 200; +const THIRD_ELEMENT_ID = 300; +const FOURTH_ELEMENT_ID = 400; + +const BASE_PUBLICATION: Publication = + PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK.data[FIRST_ARRAY_ELEMENT]; +const BASE_ELEMENT = BASE_PUBLICATION.elements[FIRST_ARRAY_ELEMENT]; +const PUBLICATION: Publication = { + ...BASE_PUBLICATION, + elements: [ + { + ...BASE_ELEMENT, + id: FIRST_ELEMENT_ID, + modelId: FIRST_MODEL_ID, + }, + { + ...BASE_ELEMENT, + id: SECOND_ELEMENT_ID, + modelId: SECOND_MODEL_ID, + }, + { + ...BASE_ELEMENT, + id: THIRD_ELEMENT_ID, + modelId: THIRD_MODEL_ID, // model id duplicate + }, + { + ...BASE_ELEMENT, + id: FOURTH_ELEMENT_ID, + modelId: THIRD_MODEL_ID, // model id duplicate + }, + ], +}; + +const BIO_ENTITY_CONTENT = (id: number, elementId: string): BioEntityContent => ({ + ...bioEntityContentFixture, + bioEntity: { + ...bioEntityContentFixture.bioEntity, + id, + elementId, + }, +}); + +jest.mock('../../../../../redux/export/export.utils'); +jest.mock('../utils/fetchElementData'); +(fetchElementData as jest.Mock).mockImplementation( + (id: string): BioEntityContent => + ({ + [`${FIRST_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(FIRST_ELEMENT_ID, 'mi100'), + [`${SECOND_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(SECOND_ELEMENT_ID, 'ne200'), + [`${THIRD_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(THIRD_ELEMENT_ID, 'r300'), + [`${FOURTH_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(FOURTH_ELEMENT_ID, 'va400'), + })[id] as BioEntityContent, +); + +const mockedAxiosClient = mockNetworkResponse(); + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + + return ( + render( + <Wrapper> + <PublicationsModalLayout children={null} /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('PublicationsModalLayout - component', () => { + const length = 1; + mockedAxiosClient + .onGet(apiPath.getPublications({ params: { length } })) + .reply(HttpStatusCode.Ok, { + ...PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK, + data: [PUBLICATION], + }); + + it('should render download csv button', () => { + renderComponent(); + + expect(screen.getByTestId('download-csv-button')).toBeInTheDocument(); + }); + + it('should run download file with valid content when download csv clicked', async () => { + renderComponent({ + publications: { + data: { + ...PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK, + data: [PUBLICATION], + filteredSize: length, + }, + loading: 'succeeded', + error: DEFAULT_ERROR, + sortColumn: '', + sortOrder: 'asc', + searchValue: '', + selectedModelId: undefined, + }, + models: { data: MODELS_MOCK, loading: 'idle', error: DEFAULT_ERROR }, + }); + + const downloadButton = screen.getByTestId('download-csv-button'); + downloadButton.click(); + + await waitFor(() => { + expect(downloadFileFromBlob).toHaveBeenCalledWith( + '"10049997","The glutamate receptor ion channels.","Dingledine R, Borges K, Bowie D, Traynelis SF.","Pharmacological reviews","1999","mi100,ne200,r300,va400","Core PD map,Histamine signaling,PRKN substrates"', + 'publications.csv', + ); + }); + }); +}); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.component.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.component.tsx index ba67f423b7af32dcacd1f7969bb2d756a624f926..0b5536b0953cbea2aa434bcf0bc8d86f9fb91abc 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.component.tsx @@ -1,19 +1,24 @@ -import { twMerge } from 'tailwind-merge'; +import spinnerIcon from '@/assets/vectors/icons/spinner.svg'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { closeModal } from '@/redux/modal/modal.slice'; -import { Icon } from '@/shared/Icon'; import { filteredSizeSelector } from '@/redux/publications/publications.selectors'; -import { MODAL_ROLE } from './PublicationsModalLayout.constants'; +import { Button } from '@/shared/Button'; +import { Icon } from '@/shared/Icon'; +import Image from 'next/image'; +import { twMerge } from 'tailwind-merge'; import { PublicationsSearch } from '../PublicationsSearch'; +import { MODAL_ROLE } from './PublicationsModalLayout.constants'; +import { useDownloadPublicationsAsCSVFile } from './utils/useDownloadPublicationsAsCSVFile'; type ModalLayoutProps = { - children: React.ReactNode; + children: React.ReactNode | null; }; export const PublicationsModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { const dispatch = useAppDispatch(); const numberOfPublications = useAppSelector(filteredSizeSelector); + const { downloadPublicationsAsCSVFile, isLoading } = useDownloadPublicationsAsCSVFile(); const handleCloseModal = (): void => { dispatch(closeModal()); @@ -27,8 +32,26 @@ export const PublicationsModalLayout = ({ children }: ModalLayoutProps): JSX.Ele <div className="flex h-full w-full items-center justify-center"> <div className={twMerge('flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg')}> <div className="flex items-center justify-between bg-white p-[24px] text-xl"> - <div className="font-semibold"> - <div>Publications ({numberOfPublications} results)</div> + <div className="flex items-center gap-4"> + <div className="font-semibold"> + <div>Publications ({numberOfPublications} results)</div> + </div> + <Button + onClick={downloadPublicationsAsCSVFile} + disabled={isLoading || !numberOfPublications} + data-testid="download-csv-button" + > + {isLoading && ( + <Image + src={spinnerIcon} + alt="spinner icon" + height={12} + width={12} + className="animate-spin" + /> + )} + Download CSV + </Button> </div> <div className="flex flex-row flex-nowrap items-center"> <PublicationsSearch /> diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.test.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb9a4b10c26bcb879b2e9066173d1e728f355e1c --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.test.ts @@ -0,0 +1,49 @@ +import { apiPath } from '@/redux/apiPath'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK } from '../../../../../../models/mocks/publicationsResponseMock'; +import { showToast } from '../../../../../../utils/showToast'; +import { getBasePublications } from './getBasePublications'; + +const mockedAxiosClient = mockNetworkResponse(); + +jest.mock('./../../../../../../utils/showToast'); + +describe('getBasePublications - util', () => { + const length = 10; + + it('should return valid data if provided', async () => { + mockedAxiosClient + .onGet(apiPath.getPublications({ params: { length } })) + .reply(HttpStatusCode.Ok, PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK); + + const result = await getBasePublications({ length }); + + expect(result).toStrictEqual(PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK.data); + }); + + it('should return empty array if data structure is invalid', async () => { + mockedAxiosClient + .onGet(apiPath.getPublications({ params: { length } })) + .reply(HttpStatusCode.Ok, { randomObject: true }); + + const result = await getBasePublications({ length }); + + expect(result).toStrictEqual([]); + }); + + it('should return empty array and show toast error if http error', async () => { + mockedAxiosClient + .onGet(apiPath.getPublications({ params: { length } })) + .reply(HttpStatusCode.BadRequest); + + const result = await getBasePublications({ length }); + + expect(result).toStrictEqual([]); + expect(showToast).toHaveBeenCalledWith({ + message: + "Problem with fetching publications: The server couldn't understand your request. Please check your input and try again.", + type: 'error', + }); + }); +}); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts new file mode 100644 index 0000000000000000000000000000000000000000..7725df112b3f7d8f4d5464a52ba0caee6e29557c --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts @@ -0,0 +1,32 @@ +import { publicationsResponseSchema } from '@/models/publicationsResponseSchema'; +import { apiPath } from '@/redux/apiPath'; +import { PUBLICATIONS_FETCHING_ERROR_PREFIX } from '@/redux/publications/publications.constatns'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Publication, PublicationsResponse } from '@/types/models'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { showToast } from '@/utils/showToast'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; + +interface Args { + length: number; +} + +export const getBasePublications = async ({ length }: Args): Promise<Publication[]> => { + try { + const response = await axiosInstance.get<PublicationsResponse>( + apiPath.getPublications({ params: { length } }), + ); + + const isDataValid = validateDataUsingZodSchema(response.data, publicationsResponseSchema); + + return isDataValid ? response.data.data : []; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: PUBLICATIONS_FETCHING_ERROR_PREFIX }); + showToast({ + type: 'error', + message: errorMessage, + }); + + return []; + } +}; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getStandarizedPublications.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getStandarizedPublications.ts new file mode 100644 index 0000000000000000000000000000000000000000..c59d2e00d5b823e2cf9ff9ecd1b072ea00f85f87 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getStandarizedPublications.ts @@ -0,0 +1,30 @@ +import { Publication } from '@/types/models'; +import { StandarizedPublication } from '@/types/publications'; +import { runInSequence } from '@/utils/promise/runInSequence'; +import { mapBasePublicationToStandarized } from './mapBasePublicationToStandarized'; + +interface Args { + modelNameIdMap: Record<number, string>; + publications: Publication[]; +} + +const SEQUENCE_CHUNK_SIZE = 250; + +export const getStandarizedPublications = async ({ + publications, + modelNameIdMap, +}: Args): Promise<StandarizedPublication[]> => { + const getStandarizedPublicationsFuncs = publications.map( + publication => () => + mapBasePublicationToStandarized(publication, { + modelNameIdMap, + }), + ); + + const standarizedPublications = await runInSequence<StandarizedPublication>( + getStandarizedPublicationsFuncs, + SEQUENCE_CHUNK_SIZE, + ); + + return standarizedPublications; +}; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapBasePublicationToStandarized.test.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapBasePublicationToStandarized.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a556efff2d648e8d1f548b4ee944d323d4e09aa --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapBasePublicationToStandarized.test.ts @@ -0,0 +1,116 @@ +/* eslint-disable no-magic-numbers */ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK } from '@/models/mocks/publicationsResponseMock'; +import { BioEntityContent, Publication } from '@/types/models'; +import { StandarizedPublication } from '@/types/publications'; +import { fetchElementData } from '../../utils/fetchElementData'; +import { mapBasePublicationToStandarized } from './mapBasePublicationToStandarized'; + +const FIRST_MODEL_ID = 53; +const SECOND_MODEL_ID = 63; +const THIRD_MODEL_ID = 99; + +const FIRST_ELEMENT_ID = 100; +const SECOND_ELEMENT_ID = 200; +const THIRD_ELEMENT_ID = 300; +const FOURTH_ELEMENT_ID = 400; + +const MODEL_NAME_ID_MAP: Record<number, string> = { + [FIRST_MODEL_ID]: 'first model', + [SECOND_MODEL_ID]: 'second model', + [THIRD_MODEL_ID]: 'third model', +}; + +const BASE_PUBLICATION: Publication = + PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK.data[FIRST_ARRAY_ELEMENT]; +const BASE_ELEMENT = BASE_PUBLICATION.elements[FIRST_ARRAY_ELEMENT]; +const PUBLICATION: Publication = { + ...BASE_PUBLICATION, + elements: [ + { + ...BASE_ELEMENT, + id: FIRST_ELEMENT_ID, + modelId: FIRST_MODEL_ID, + }, + { + ...BASE_ELEMENT, + id: SECOND_ELEMENT_ID, + modelId: SECOND_MODEL_ID, + }, + { + ...BASE_ELEMENT, + id: THIRD_ELEMENT_ID, + modelId: THIRD_MODEL_ID, // model id duplicate + }, + { + ...BASE_ELEMENT, + id: FOURTH_ELEMENT_ID, + modelId: THIRD_MODEL_ID, // model id duplicate + }, + ], +}; + +const BIO_ENTITY_CONTENT = (id: number, elementId: string): BioEntityContent => ({ + ...bioEntityContentFixture, + bioEntity: { + ...bioEntityContentFixture.bioEntity, + id, + elementId, + }, +}); + +jest.mock('../../utils/fetchElementData'); +(fetchElementData as jest.Mock).mockImplementation( + (id: string): BioEntityContent => + ({ + [`${FIRST_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(FIRST_ELEMENT_ID, 'mi100'), + [`${SECOND_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(SECOND_ELEMENT_ID, 'ne200'), + [`${THIRD_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(THIRD_ELEMENT_ID, 'r300'), + [`${FOURTH_ELEMENT_ID}`]: BIO_ENTITY_CONTENT(FOURTH_ELEMENT_ID, 'va400'), + })[id] as BioEntityContent, +); + +const getFuncResult = async ( + publication: Publication, + modelNameIdMap: Record<number, string> = MODEL_NAME_ID_MAP, +): Promise<StandarizedPublication> => + mapBasePublicationToStandarized(publication, { modelNameIdMap }); + +describe('mapBasePublicationToStandarized - util', () => { + it('should return valid pubmedId, journal, year and title', async () => { + const results = await getFuncResult(PUBLICATION); + const { pubmedId, journal, year, title } = PUBLICATION.publication.article; + + expect(results.pubmedId).toBe(pubmedId); + expect(results.journal).toBe(journal); + expect(results.year).toBe(year.toString()); + expect(results.title).toBe(title); + }); + + it('should return joined authors', async () => { + const results = await getFuncResult(PUBLICATION); + const { authors } = PUBLICATION.publication.article; + + expect(results.authors).toBe(authors.join(',')); + }); + + it('should return joined and unique model names if present', async () => { + const results = await getFuncResult(PUBLICATION); + + expect(results.modelNames).toBe(['first model', 'second model', 'third model'].join(',')); + }); + + it('should return empty model names string if model not existing', async () => { + const EMPTY_MODEL_ID_MAP = {}; + const results = await getFuncResult(PUBLICATION, EMPTY_MODEL_ID_MAP); + + expect(results.modelNames).toBe(''); + }); + + it('should return joined elementsIds', async () => { + const results = await getFuncResult(PUBLICATION); + + expect(results.elementsIds).toBe(['mi100', 'ne200', 'r300', 'va400'].join(',')); + }); +}); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapBasePublicationToStandarized.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapBasePublicationToStandarized.ts new file mode 100644 index 0000000000000000000000000000000000000000..84b7d269b8259c2e7140aa96a555f42ab897e6b5 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapBasePublicationToStandarized.ts @@ -0,0 +1,37 @@ +import { BioEntityContent, Publication } from '@/types/models'; +import { StandarizedPublication } from '@/types/publications'; +import { getUniqueArray } from '@/utils/array/getUniqueArray'; +import { fetchElementData } from '../../utils/fetchElementData'; + +interface Options { + modelNameIdMap: Record<number, string>; +} + +const JOIN_SEPARATOR = ','; + +export const mapBasePublicationToStandarized = async ( + publication: Publication, + options: Options, +): Promise<StandarizedPublication> => { + const { + publication: { article }, + elements, + } = publication; + const { modelNameIdMap } = options; + const { title, authors, journal, year, pubmedId } = article; + const modelNames = elements.map(({ modelId }) => modelNameIdMap[modelId]); + const elementsData = await Promise.all(elements.map(async ({ id }) => fetchElementData(`${id}`))); + const elementsIds = elementsData + .filter((element): element is BioEntityContent => element !== undefined) + .map(({ bioEntity }) => bioEntity.elementId); + + return { + pubmedId, + journal, + title, + year: year ? `${year}` : '', + authors: authors.join(JOIN_SEPARATOR), + modelNames: getUniqueArray(modelNames).join(JOIN_SEPARATOR), + elementsIds: elementsIds.join(JOIN_SEPARATOR), + }; +}; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapStandarizedPublicationsToCSVString.test.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapStandarizedPublicationsToCSVString.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07588cfd0f413c303a87ae13ca9e395f91c76348 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapStandarizedPublicationsToCSVString.test.ts @@ -0,0 +1,40 @@ +import { StandarizedPublication } from '@/types/publications'; +import { mapStandarizedPublicationsToCSVString } from './mapStandarizedPublicationsToCSVString'; + +const CASES: [StandarizedPublication[], string][] = [ + [[], ''], + [ + [ + { + pubmedId: '', + year: '', + journal: '', + authors: '', + title: '', + modelNames: '', + elementsIds: '', + }, + ], + '"","","","","","",""', + ], + [ + [ + { + authors: 'authors', + title: 'title', + journal: 'journal', + pubmedId: 'pubmedId', + modelNames: 'modelNames', + elementsIds: 'elementsIds', + year: 'year', + }, + ], + '"pubmedId","title","authors","journal","year","elementsIds","modelNames"', + ], +]; + +describe('mapStandarizedPublicationsToCSVString - util', () => { + it.each(CASES)('should return valid string', (input, result) => { + expect(mapStandarizedPublicationsToCSVString(input)).toBe(result); + }); +}); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapStandarizedPublicationsToCSVString.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapStandarizedPublicationsToCSVString.ts new file mode 100644 index 0000000000000000000000000000000000000000..d24ff87e7bcce7a1c18a8ce991fc6556ab74f27b --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/mapStandarizedPublicationsToCSVString.ts @@ -0,0 +1,12 @@ +import { StandarizedPublication } from '@/types/publications'; + +export const mapStandarizedPublicationsToCSVString = ( + publications: StandarizedPublication[], +): string => + publications + .map(({ pubmedId, title, authors, journal, year, elementsIds, modelNames }) => + [pubmedId, title, authors, journal, year, elementsIds, modelNames] + .map(text => `"${text}"`) + .join(','), + ) + .join('\n'); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/useDownloadPublicationsAsCSVFile.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/useDownloadPublicationsAsCSVFile.ts new file mode 100644 index 0000000000000000000000000000000000000000..531faf9c6429071231383a9fbf0731ea753774ee --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/useDownloadPublicationsAsCSVFile.ts @@ -0,0 +1,65 @@ +import { ZERO } from '@/constants/common'; +import { downloadFileFromBlob } from '@/redux/export/export.utils'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { modelsNameMapSelector } from '@/redux/models/models.selectors'; +import { + filteredSizeSelector, + publicationsListDataSelector, + searchValueSelector, +} from '@/redux/publications/publications.selectors'; +import { Publication } from '@/types/models'; +import { StandarizedPublication } from '@/types/publications'; +import { useState } from 'react'; +import { getBasePublications } from './getBasePublications'; +import { getStandarizedPublications } from './getStandarizedPublications'; +import { mapStandarizedPublicationsToCSVString } from './mapStandarizedPublicationsToCSVString'; + +export type DownloadPublicationsAsCSVFile = () => Promise<void>; + +interface UseDownloadPublicationsAsCSVFileResult { + downloadPublicationsAsCSVFile: DownloadPublicationsAsCSVFile; + isLoading: boolean; +} + +const CSV_FILE_NAME = 'publications.csv'; + +export const useDownloadPublicationsAsCSVFile = (): UseDownloadPublicationsAsCSVFileResult => { + const [isLoading, setIsLoading] = useState<boolean>(false); + const modelNameIdMap = useAppSelector(modelsNameMapSelector); + const numberOfPublications = useAppSelector(filteredSizeSelector); + const searchValue = useAppSelector(searchValueSelector); + const searchedPublicationsList = useAppSelector(publicationsListDataSelector); + + const getAllBasePublications = async (): Promise<Publication[]> => + getBasePublications({ + length: numberOfPublications || ZERO, + }); + + const getPublicationsAsList = async (): Promise<StandarizedPublication[]> => { + const publications = + searchValue && searchedPublicationsList + ? searchedPublicationsList + : await getAllBasePublications(); + + const standarizedPublications = await getStandarizedPublications({ + publications, + modelNameIdMap, + }); + + return standarizedPublications; + }; + + const downloadPublicationsAsCSVFile = async (): Promise<void> => { + setIsLoading(true); + const data = await getPublicationsAsList(); + const dataString = mapStandarizedPublicationsToCSVString(data); + + downloadFileFromBlob(dataString, CSV_FILE_NAME); + setIsLoading(false); + }; + + return { + downloadPublicationsAsCSVFile, + isLoading, + }; +}; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.tsx index efb424f3b36df1861110b9a3e26f576f3e4e6973..a3f28f15b9c79b302864b3572a8ce16024a9ba56 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.tsx +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.tsx @@ -16,7 +16,7 @@ import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; import { LoadingIndicator } from '@/shared/LoadingIndicator'; import { BioEntityContent, TargetElement } from '@/types/models'; import { useEffect, useState } from 'react'; -import { fetchElementLinkData } from './utils/fetchElementLinkData'; +import { fetchElementData } from '../../../utils/fetchElementData'; interface Props { target: TargetElement; @@ -35,7 +35,7 @@ export const ElementLink = ({ target }: Props): JSX.Element => { openedMaps.some(map => map.modelId === modelId); const getElementLinkData = async (searchQuery: string): Promise<void> => { - const fetchedData = await fetchElementLinkData(searchQuery).finally(() => setIsLoading(false)); + const fetchedData = await fetchElementData(searchQuery).finally(() => setIsLoading(false)); if (fetchedData) { setData(fetchedData); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/utils/fetchElementLinkData.ts b/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts similarity index 96% rename from src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/utils/fetchElementLinkData.ts rename to src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts index a4d1ed80ab54399adebfb0a8e0ee720b03105df1..ceaf1b8a355e43d7c29c4c54eb8635c1f1f3233c 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/utils/fetchElementLinkData.ts +++ b/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts @@ -8,7 +8,7 @@ import { getErrorMessage } from '@/utils/getErrorMessage'; import { showToast } from '@/utils/showToast'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; -export const fetchElementLinkData = async ( +export const fetchElementData = async ( searchQuery: string, ): Promise<BioEntityContent | undefined> => { try { diff --git a/src/models/mocks/publicationsResponseMock.ts b/src/models/mocks/publicationsResponseMock.ts index 09021f4ed644552f37e1fa98ccbd0240f424ee00..c165eb985d4ca669ba36272d8d65ed3935c947c8 100644 --- a/src/models/mocks/publicationsResponseMock.ts +++ b/src/models/mocks/publicationsResponseMock.ts @@ -1,4 +1,6 @@ -export const PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK = { +import { PublicationsResponse } from '@/types/models'; + +export const PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK: PublicationsResponse = { data: [ { elements: [ diff --git a/src/types/publications.ts b/src/types/publications.ts new file mode 100644 index 0000000000000000000000000000000000000000..4549ba60a15ffaa4a73048c8731ea5142edb888e --- /dev/null +++ b/src/types/publications.ts @@ -0,0 +1,9 @@ +export interface StandarizedPublication { + pubmedId: string; + year: string; + journal: string; + authors: string; + title: string; + modelNames: string; + elementsIds: string; +} diff --git a/src/utils/array/getUniqueArray.ts b/src/utils/array/getUniqueArray.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4b1603d378b4704994cc8eceea695c9f27bbc6c --- /dev/null +++ b/src/utils/array/getUniqueArray.ts @@ -0,0 +1 @@ +export const getUniqueArray = <T>(elements: T[]): T[] => [...new Set([...elements])]; diff --git a/src/utils/promise/runInSequence.ts b/src/utils/promise/runInSequence.ts new file mode 100644 index 0000000000000000000000000000000000000000..15d0cca4aa5b3cdbfe709496cad9cc0ebbf2541b --- /dev/null +++ b/src/utils/promise/runInSequence.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ +import { ONE, ZERO } from '@/constants/common'; + +const getFuncsInChuncks = <T>( + funcs: (() => Promise<T>)[], + chunkSize: number, +): (() => Promise<T>)[][] => { + const localFunc = [...funcs]; + const chunks: (() => Promise<T>)[][] = []; + + while (localFunc.length) { + chunks.push(localFunc.splice(ZERO, chunkSize)); + } + + return chunks; +}; + +export const runInSequence = async <T>( + funcs: (() => Promise<T>)[], + chunkSize: number = ONE, +): Promise<T[]> => { + const chunks = getFuncsInChuncks(funcs, chunkSize); + const results: T[] = []; + + for (const chunk of chunks) { + const chunkResult = await Promise.all(chunk.map(func => func())); + results.push(...chunkResult); + } + + return results; +};