Skip to content
Snippets Groups Projects
Commit 22fbec74 authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

feat(export): elements download (MIN-157)

parent 2e797ddc
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...,!109feat(export): elements download (MIN-157)
Showing
with 333 additions and 83 deletions
type ElementTypes =
| {
className: string;
name: string;
parentClass: string;
}[]
| undefined;
type MappedElementTypes = { id: string; label: string }[];
type PresenceMap = { [key: string]: boolean };
export const getCheckboxElements = (elementTypes: ElementTypes): MappedElementTypes => {
if (!elementTypes) return [];
const excludedTypes: PresenceMap = {};
elementTypes?.forEach(type => {
excludedTypes[type.parentClass] = true;
});
const mappedElementTypes: MappedElementTypes = [];
const processedNames: PresenceMap = {};
elementTypes.forEach(elementType => {
if (excludedTypes[elementType.className] || processedNames[elementType.name]) return;
processedNames[elementType.name] = true;
mappedElementTypes.push({
id: elementType.name,
label: elementType.name,
});
});
return mappedElementTypes;
};
export { Types } from './Types.component';
......@@ -5,7 +5,7 @@ import { getCompartmentPathwaysCheckboxElements } from './getCompartmentPathways
describe('getCompartmentPathwaysCheckboxElements', () => {
it('should return an empty array when given an empty items array', () => {
const items: CompartmentPathwayDetails[] = [];
const result = getCompartmentPathwaysCheckboxElements(items);
const result = getCompartmentPathwaysCheckboxElements('include', items);
expect(result).toEqual([]);
});
......@@ -17,12 +17,12 @@ describe('getCompartmentPathwaysCheckboxElements', () => {
{ id: 4, name: 'Compartment C' },
] as CompartmentPathwayDetails[];
const result = getCompartmentPathwaysCheckboxElements(items);
const result = getCompartmentPathwaysCheckboxElements('test', items);
expect(result).toEqual([
{ id: '1', label: 'Compartment A' },
{ id: '2', label: 'Compartment B' },
{ id: '4', label: 'Compartment C' },
{ id: 'test-1', label: 'Compartment A' },
{ id: 'test-2', label: 'Compartment B' },
{ id: 'test-4', label: 'Compartment C' },
]);
});
it('should correctly extract unique names and corresponding ids from items and sorts them alphabetically', () => {
......@@ -34,13 +34,13 @@ describe('getCompartmentPathwaysCheckboxElements', () => {
{ id: 5, name: 'Compartment D' },
] as CompartmentPathwayDetails[];
const result = getCompartmentPathwaysCheckboxElements(items);
const result = getCompartmentPathwaysCheckboxElements('prefix', items);
expect(result).toEqual([
{ id: '2', label: 'Compartment A' },
{ id: '3', label: 'Compartment B' },
{ id: '1', label: 'Compartment C' },
{ id: '5', label: 'Compartment D' },
{ id: 'prefix-2', label: 'Compartment A' },
{ id: 'prefix-3', label: 'Compartment B' },
{ id: 'prefix-1', label: 'Compartment C' },
{ id: 'prefix-5', label: 'Compartment D' },
]);
});
});
......@@ -8,6 +8,7 @@ type CheckboxElement = { id: string; label: string };
type CheckboxElements = CheckboxElement[];
export const getCompartmentPathwaysCheckboxElements = (
prefix: string,
items: CompartmentPathwayDetails[],
): CheckboxElements => {
const addedNames: AddedNames = {};
......@@ -21,7 +22,7 @@ export const getCompartmentPathwaysCheckboxElements = (
items.forEach(setNameToIdIfUndefined);
const parseIdAndLabel = ([name, id]: [name: string, id: number]): CheckboxElement => ({
id: id.toString(),
id: `${prefix}-${id}`,
label: name,
});
......
/* eslint-disable no-magic-numbers */
import { getDownloadElementsBodyRequest } from './getDownloadElementsBodyRequest';
describe('getDownloadElementsBodyRequest', () => {
it('should return the correct DownloadBodyRequest object', () => {
const types = [
{ id: '1', label: 'Type 1' },
{ id: '2', label: 'Type 2' },
];
const columns = [
{ id: '1', label: 'Column 1' },
{ id: '2', label: 'Column 2' },
];
// eslint-disable-next-line no-magic-numbers
const modelIds = [1, 2, 3];
const annotations = [
{ id: '1', label: 'Annotation 1' },
{ id: '2', label: 'Annotation 2' },
{ id: 'Annotation 1', label: 'Annotation 1' },
{ id: 'Annotation 2', label: 'Annotation 2' },
];
const includedCompartmentPathways = [
{ id: '1', label: 'Compartment 1' },
{ id: '2', label: 'Compartment 2' },
{ id: 'include-7', label: 'Compartment 1' },
{ id: 'include-8', label: 'Compartment 2' },
];
const excludedCompartmentPathways = [
{ id: '1', label: 'Compartment 3' },
{ id: '2', label: 'Compartment 4' },
{ id: 'exclude-9', label: 'Compartment 3' },
{ id: 'exclude-10', label: 'Compartment 4' },
];
const result = getDownloadElementsBodyRequest({
types,
columns,
columns: ['Column 23', 'Column99'],
modelIds,
annotations,
includedCompartmentPathways,
......@@ -35,13 +27,37 @@ describe('getDownloadElementsBodyRequest', () => {
});
expect(result).toEqual({
types: ['Type 1', 'Type 2'],
columns: ['Column 1', 'Column 2'],
columns: ['Column 23', 'Column99'],
// eslint-disable-next-line no-magic-numbers
submaps: [1, 2, 3],
annotations: ['Annotation 1', 'Annotation 2'],
includedCompartmentIds: ['Compartment 1', 'Compartment 2'],
excludedCompartmentIds: ['Compartment 3', 'Compartment 4'],
includedCompartmentIds: [7, 8],
excludedCompartmentIds: [9, 10],
});
});
it('should throw error if compartment id is not a number', () => {
const modelIds = [1, 2, 3];
const annotations = [
{ id: 'Annotation 1', label: 'Annotation 1' },
{ id: 'Annotation 2', label: 'Annotation 2' },
];
const includedCompartmentPathways = [
{ id: '', label: 'Compartment 1' },
{ id: '', label: 'Compartment 2' },
];
const excludedCompartmentPathways = [
{ id: '', label: 'Compartment 3' },
{ id: '', label: 'Compartment 4' },
];
expect(() =>
getDownloadElementsBodyRequest({
columns: ['Column 23', 'Column99'],
modelIds,
annotations,
includedCompartmentPathways,
excludedCompartmentPathways,
}),
).toThrow('compartment id is not a number');
});
});
import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component';
type DownloadBodyRequest = {
types: string[];
columns: string[];
submaps: number[];
annotations: string[];
includedCompartmentIds: string[];
excludedCompartmentIds: string[];
includedCompartmentIds: number[];
excludedCompartmentIds: number[];
};
type GetDownloadBodyRequestProps = {
types: CheckboxItem[];
columns: CheckboxItem[];
columns: string[];
modelIds: number[];
annotations: CheckboxItem[];
includedCompartmentPathways: CheckboxItem[];
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 = ({
types,
columns,
modelIds,
annotations,
includedCompartmentPathways,
excludedCompartmentPathways,
}: GetDownloadBodyRequestProps): DownloadBodyRequest => ({
types: types.map(type => type.label),
columns: columns.map(column => column.label),
columns,
submaps: modelIds,
annotations: annotations.map(annotation => annotation.label),
includedCompartmentIds: includedCompartmentPathways.map(compartment => compartment.label),
excludedCompartmentIds: excludedCompartmentPathways.map(compartment => compartment.label),
annotations: annotations.map(annotation => annotation.id),
includedCompartmentIds: includedCompartmentPathways.map(extractAndParseNumberIdFromCompartment),
excludedCompartmentIds: excludedCompartmentPathways.map(extractAndParseNumberIdFromCompartment),
});
import { Export } from '../ExportCompound';
import { ANNOTATIONS_TYPE } from '../ExportCompound/ExportCompound.constant';
export const Network = (): React.ReactNode => {
return (
<div data-testid="export-tab">
<Export>
<Export.Types />
<Export.Columns />
<Export.Annotations />
<Export.Annotations type={ANNOTATIONS_TYPE.NETWORK} />
<Export.IncludedCompartmentPathways />
<Export.ExcludedCompartmentPathways />
<Export.DownloadElements />
......
/* eslint-disable no-magic-numbers */
import { z } from 'zod';
export const compartmentPathwaySchema = z.object({
......@@ -34,7 +35,7 @@ export const compartmentPathwayDetailsSchema = z.object({
hierarchyVisibilityLevel: z.string(),
homomultimer: z.null(),
hypothetical: z.null(),
id: z.number(),
id: z.number().gt(-1),
initialAmount: z.null(),
initialConcentration: z.null(),
linkedSubmodel: z.null(),
......
import { z } from 'zod';
export const exportElementsSchema = z.string();
......@@ -5,5 +5,5 @@ import { configurationSchema } from '../configurationSchema';
export const configurationFixture = createFixture(configurationSchema, {
seed: ZOD_SEED,
array: { min: 1, max: 1 },
array: { min: 3, max: 3 },
});
......@@ -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/`,
downloadElementsCsv: (): string =>
`projects/${PROJECT_ID}/models/*/bioEntities/elements/:downloadCsv`,
downloadOverlay: (overlayId: number): string =>
`projects/${PROJECT_ID}/overlays/${overlayId}:downloadSource`,
getSourceFile: (): string => `/projects/${PROJECT_ID}:downloadSource`,
......
......@@ -88,3 +88,8 @@ export const formatsHandlersSelector = createSelector(
};
},
);
export const miramiTypesSelector = createSelector(
configurationMainSelector,
state => state?.miriamTypes,
);
import { ExportState } from './export.types';
export const EXPORT_INITIAL_STATE_MOCK: ExportState = {
downloadElements: {
error: {
message: '',
name: '',
},
loading: 'idle',
},
};
import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse';
import {
ToolkitStoreWithSingleSlice,
createStoreInstanceUsingSliceReducer,
} from '@/utils/createStoreInstanceUsingSliceReducer';
import { HttpStatusCode } from 'axios';
import { ExportState } from './export.types';
import exportReducer from './export.slice';
import { apiPath } from '../apiPath';
import { downloadElements } from './export.thunks';
const mockedAxiosClient = mockNetworkNewAPIResponse();
const INITIAL_STATE: ExportState = {
downloadElements: {
loading: 'idle',
error: { name: '', message: '' },
},
};
describe('export reducer', () => {
global.URL.createObjectURL = jest.fn();
let store = {} as ToolkitStoreWithSingleSlice<ExportState>;
beforeEach(() => {
store = createStoreInstanceUsingSliceReducer('export', exportReducer);
});
it('should match initial state', () => {
const action = { type: 'unknown' };
expect(exportReducer(undefined, action)).toEqual(INITIAL_STATE);
});
it('should update store after successful downloadElements query', async () => {
mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test');
await store.dispatch(
downloadElements({
annotations: [],
columns: [],
excludedCompartmentIds: [],
includedCompartmentIds: [],
submaps: [],
}),
);
const { loading } = store.getState().export.downloadElements;
expect(loading).toEqual('succeeded');
});
it('should update store on loading downloadElements query', async () => {
mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test');
const downloadElementsPromise = store.dispatch(
downloadElements({
annotations: [],
columns: [],
excludedCompartmentIds: [],
includedCompartmentIds: [],
submaps: [],
}),
);
const { loading } = store.getState().export.downloadElements;
expect(loading).toEqual('pending');
await downloadElementsPromise;
const { loading: promiseFulfilled } = store.getState().export.downloadElements;
expect(promiseFulfilled).toEqual('succeeded');
});
it('should update store after failed downloadElements query', async () => {
mockedAxiosClient
.onPost(apiPath.downloadElementsCsv())
.reply(HttpStatusCode.NotFound, undefined);
await store.dispatch(
downloadElements({
annotations: [],
columns: [],
excludedCompartmentIds: [],
includedCompartmentIds: [],
submaps: [],
}),
);
const { loading } = store.getState().export.downloadElements;
expect(loading).toEqual('failed');
});
});
import { ActionReducerMapBuilder } from '@reduxjs/toolkit';
import { downloadElements } from './export.thunks';
import { ExportState } from './export.types';
export const downloadElementsReducer = (builder: ActionReducerMapBuilder<ExportState>): void => {
builder.addCase(downloadElements.pending, state => {
state.downloadElements.loading = 'pending';
});
builder.addCase(downloadElements.fulfilled, state => {
state.downloadElements.loading = 'succeeded';
});
builder.addCase(downloadElements.rejected, state => {
state.downloadElements.loading = 'failed';
// TODO to discuss manage state of failure
});
};
import { createSlice } from '@reduxjs/toolkit';
import { ExportState } from './export.types';
import { downloadElementsReducer } from './export.reducers';
const initialState: ExportState = {
downloadElements: {
error: {
message: '',
name: '',
},
loading: 'idle',
},
};
const exportSlice = createSlice({
name: 'export',
initialState,
reducers: {},
extraReducers: builder => {
downloadElementsReducer(builder);
},
});
export default exportSlice.reducer;
import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse';
import { HttpStatusCode } from 'axios';
import {
ToolkitStoreWithSingleSlice,
createStoreInstanceUsingSliceReducer,
} from '@/utils/createStoreInstanceUsingSliceReducer';
import { apiPath } from '../apiPath';
import { ExportState } from './export.types';
import exportReducer from './export.slice';
import { downloadElements } from './export.thunks';
const mockedAxiosClient = mockNetworkNewAPIResponse();
describe('export thunks', () => {
let store = {} as ToolkitStoreWithSingleSlice<ExportState>;
beforeEach(() => {
store = createStoreInstanceUsingSliceReducer('export', exportReducer);
global.URL.createObjectURL = jest.fn();
global.document.body.appendChild = jest.fn();
});
describe('downloadElements', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should download file when data response from API is valid', async () => {
mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test');
await store.dispatch(
downloadElements({
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.downloadElementsCsv())
.reply(HttpStatusCode.NotFound, undefined);
await store.dispatch(
downloadElements({
annotations: [],
columns: [],
excludedCompartmentIds: [],
includedCompartmentIds: [],
submaps: [],
}),
);
expect(global.document.body.appendChild).not.toHaveBeenCalled();
});
});
});
/* eslint-disable no-magic-numbers */
import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { PROJECT_ID } from '@/constants';
import { ExportElements } from '@/types/models';
import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema';
import { exportElementsSchema } from '@/models/exportSchema';
import { apiPath } from '../apiPath';
type DownloadElementsBodyRequest = {
columns: string[];
submaps: number[];
annotations: string[];
includedCompartmentIds: number[];
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> => {
const response = await axiosInstanceNewAPI.post<ExportElements>(
apiPath.downloadElementsCsv(),
data,
{
withCredentials: true,
},
);
const isDataValid = validateDataUsingZodSchema(response.data, exportElementsSchema);
if (isDataValid) {
downloadFileFromBlob(response.data, `${PROJECT_ID}-elementExport.csv`);
}
},
);
import { Loading } from '@/types/loadingState';
export type ExportState = {
downloadElements: {
loading: Loading;
error: Error;
};
};
......@@ -19,6 +19,7 @@ import { RootState } from '../store';
import { USER_INITIAL_STATE_MOCK } from '../user/user.mock';
import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock';
import { COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK } from '../compartmentPathways/compartmentPathways.mock';
import { EXPORT_INITIAL_STATE_MOCK } from '../export/export.mock';
export const INITIAL_STORE_STATE_MOCK: RootState = {
search: SEARCH_STATE_INITIAL_MOCK,
......@@ -41,4 +42,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = {
legend: LEGEND_INITIAL_STATE_MOCK,
statistics: STATISTICS_STATE_INITIAL_MOCK,
compartmentPathways: COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK,
export: EXPORT_INITIAL_STATE_MOCK,
};
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