Skip to content
Snippets Groups Projects
Commit d1abccf7 authored by Adrian Orłów's avatar Adrian Orłów :fire:
Browse files

Merge branch 'MIN-242-publications-list-modal-download-csv' into 'development'

feat: Add publications list modal download csv (MIN-242)

Closes MIN-242

See merge request !179
parents c74e3377 2d522990
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!179feat: Add publications list modal download csv (MIN-242)
Pipeline #88989 passed
Showing
with 598 additions and 10 deletions
/* 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',
);
});
});
});
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 />
......
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',
});
});
});
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 [];
}
};
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;
};
/* 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(','));
});
});
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),
};
};
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);
});
});
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');
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,
};
};
......@@ -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);
......
......@@ -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 {
......
export const PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK = {
import { PublicationsResponse } from '@/types/models';
export const PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK: PublicationsResponse = {
data: [
{
elements: [
......
export interface StandarizedPublication {
pubmedId: string;
year: string;
journal: string;
authors: string;
title: string;
modelNames: string;
elementsIds: string;
}
export const getUniqueArray = <T>(elements: T[]): T[] => [...new Set([...elements])];
/* 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;
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment