From 8d839a33cbc212d917d5ed927810bd0895b071d6 Mon Sep 17 00:00:00 2001 From: mateusz-winiarczyk <mateusz.winiarczyk@appunite.com> Date: Thu, 11 Jan 2024 10:31:27 +0100 Subject: [PATCH] feat(export): included compartment pathways and excluded compartment pathways --- .../Elements/Elements.component.tsx | 20 ++- .../Elements/Elements.utils.test.ts | 61 +++++++++ .../ExportDrawer/Elements/Elements.utils.ts | 42 ++++++ ...udedCompartmentPathways.component.test.tsx | 116 ++++++++++++++++ .../ExcludedCompartmentPathways.component.tsx | 24 ++++ .../ExcludedCompartmentPathways/index.ts | 1 + ...udedCompartmentPathways.component.test.tsx | 116 ++++++++++++++++ .../IncludedCompartmentPathways.component.tsx | 25 ++++ .../IncludedCompartmentPathways /index.ts | 1 + src/models/compartmentPathwaySchema.ts | 50 +++++++ src/models/fixtures/compartmentPathways.ts | 29 ++++ src/redux/apiPath.ts | 5 + .../comparmentPathways.constants.ts | 1 + .../compartmentPathways.mock.ts | 64 +++++++++ .../compartmentPathways.reducers.test.ts | 122 +++++++++++++++++ .../compartmentPathways.reducers.ts | 20 +++ .../compartmentPathways.selectors.ts | 17 +++ .../compartmentPathways.slice.ts | 20 +++ .../compartmentPathways.thunks.test.ts | 126 ++++++++++++++++++ .../compartmentPathways.thunks.ts | 120 +++++++++++++++++ .../compartmentPathways.types.ts | 4 + src/redux/root/root.fixtures.ts | 4 +- src/redux/store.ts | 2 + src/types/models.ts | 6 + 24 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.test.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /index.ts create mode 100644 src/models/compartmentPathwaySchema.ts create mode 100644 src/models/fixtures/compartmentPathways.ts create mode 100644 src/redux/compartmentPathways/comparmentPathways.constants.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.mock.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.reducers.test.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.reducers.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.selectors.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.slice.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.thunks.test.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.thunks.ts create mode 100644 src/redux/compartmentPathways/compartmentPathways.types.ts diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx index c4d5d6f4..ffc3bf45 100644 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx @@ -1,13 +1,31 @@ -import { Types } from './Types'; +import { useEffect } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { modelsDataSelector } from '@/redux/models/models.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { getCompartmentPathways } from '@/redux/compartmentPathways/compartmentPathways.thunks'; import { Annotations } from '../Annotations'; +import { Types } from './Types'; +import { IncludedCompartmentPathways } from './IncludedCompartmentPathways '; +import { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways'; import { Columns } from './Columns'; +import { getModelsIds } from './Elements.utils'; export const Elements = (): React.ReactNode => { + const models = useAppSelector(modelsDataSelector); + const dispatch = useAppDispatch(); + + useEffect(() => { + const modelsIds = getModelsIds(models); + dispatch(getCompartmentPathways(modelsIds)); + }, [dispatch, models]); + return ( <div data-testid="elements-tab"> <Types /> <Columns /> <Annotations /> + <IncludedCompartmentPathways /> + <ExcludedCompartmentPathways /> </div> ); }; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.test.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.test.ts new file mode 100644 index 00000000..76d37442 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.test.ts @@ -0,0 +1,61 @@ +/* eslint-disable no-magic-numbers */ +import { CompartmentPathwayDetails } from '@/types/models'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { getCompartmentPathwaysCheckboxElements, getModelsIds } from './Elements.utils'; + +describe('getCompartmentPathwaysCheckboxElements', () => { + it('should return an empty array when given an empty items array', () => { + const items: CompartmentPathwayDetails[] = []; + const result = getCompartmentPathwaysCheckboxElements(items); + expect(result).toEqual([]); + }); + + it('should correctly extract unique names and corresponding ids from items', () => { + const items = [ + { id: 1, name: 'Compartment A' }, + { id: 2, name: 'Compartment B' }, + { id: 3, name: 'Compartment A' }, + { id: 4, name: 'Compartment C' }, + ] as CompartmentPathwayDetails[]; + + const result = getCompartmentPathwaysCheckboxElements(items); + + expect(result).toEqual([ + { id: '1', label: 'Compartment A' }, + { id: '2', label: 'Compartment B' }, + { id: '4', label: 'Compartment C' }, + ]); + }); + it('should correctly extract unique names and corresponding ids from items and sorts them alphabetically', () => { + const items = [ + { id: 1, name: 'Compartment C' }, + { id: 2, name: 'Compartment A' }, + { id: 3, name: 'Compartment B' }, + { id: 4, name: 'Compartment A' }, + { id: 5, name: 'Compartment D' }, + ] as CompartmentPathwayDetails[]; + + const result = getCompartmentPathwaysCheckboxElements(items); + + expect(result).toEqual([ + { id: '2', label: 'Compartment A' }, + { id: '3', label: 'Compartment B' }, + { id: '1', label: 'Compartment C' }, + { id: '5', label: 'Compartment D' }, + ]); + }); +}); + +const MODELS_IDS = modelsFixture.map(item => item.idObject); + +describe('getModelsIds', () => { + it('should return an empty array if models are not provided', () => { + const result = getModelsIds(undefined); + expect(result).toEqual([]); + }); + + it('should return an array of model IDs', () => { + const result = getModelsIds(modelsFixture); + expect(result).toEqual(MODELS_IDS); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts new file mode 100644 index 00000000..25c68180 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts @@ -0,0 +1,42 @@ +/* eslint-disable no-magic-numbers */ +import { CompartmentPathwayDetails, MapModel } from '@/types/models'; + +type AddedNames = { [key: string]: number }; + +type CheckboxElement = { id: string; label: string }; + +type CheckboxElements = CheckboxElement[]; + +export const getCompartmentPathwaysCheckboxElements = ( + items: CompartmentPathwayDetails[], +): CheckboxElements => { + const addedNames: AddedNames = {}; + + const setNameToIdIfUndefined = (item: CompartmentPathwayDetails): void => { + if (addedNames[item.name] === undefined) { + addedNames[item.name] = item.id; + } + }; + + items.forEach(setNameToIdIfUndefined); + + const parseIdAndLabel = ([name, id]: [name: string, id: number]): CheckboxElement => ({ + id: id.toString(), + label: name, + }); + + const sortByLabel = (a: CheckboxElement, b: CheckboxElement): number => { + if (a.label > b.label) return 1; + return -1; + }; + + const elements = Object.entries(addedNames).map(parseIdAndLabel).sort(sortByLabel); + + return elements; +}; + +export const getModelsIds = (models: MapModel[] | undefined): number[] => { + if (!models) return []; + + return models.map(model => model.idObject); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.test.tsx new file mode 100644 index 00000000..ded8e680 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, waitFor } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { act } from 'react-dom/test-utils'; +import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; +import { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <ExcludedCompartmentPathways /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const CHECKBOX_ELEMENT_NAME = compartmentPathwaysDetailsFixture[0].name; + +describe('ExcludedCompartmentPathways - component', () => { + it('should display compartment / pathways checkboxes when fetching data is successful', async () => { + renderComponent({ + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); + + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); + expect(screen.getByLabelText('search-input')).toBeInTheDocument(); + expect(screen.getByLabelText(CHECKBOX_ELEMENT_NAME)).toBeInTheDocument(); + }); + }); + it('should not display compartment / pathways checkboxes when fetching data fails', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + it('should not display compartment / pathways checkboxes when fetched data is empty', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + + it('should display loading message when fetching data is pending', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'pending', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx new file mode 100644 index 00000000..73e95759 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx @@ -0,0 +1,24 @@ +/* eslint-disable no-magic-numbers */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + compartmentPathwaysDataSelector, + loadingCompartmentPathwaysSelector, +} from '@/redux/compartmentPathways/compartmentPathways.selectors'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { getCompartmentPathwaysCheckboxElements } from '../Elements.utils'; + +export const ExcludedCompartmentPathways = (): React.ReactNode => { + const loadingCompartmentPathways = useAppSelector(loadingCompartmentPathwaysSelector); + const isPending = loadingCompartmentPathways === 'pending'; + const compartmentPathways = useAppSelector(compartmentPathwaysDataSelector); + const checkboxElements = getCompartmentPathwaysCheckboxElements(compartmentPathways); + const isCheckboxFilterVisible = !isPending && checkboxElements && checkboxElements.length > 0; + + return ( + <CollapsibleSection title="Select excluded compartment / pathways"> + {isPending && <p>Loading...</p>} + {isCheckboxFilterVisible && <CheckboxFilter options={checkboxElements} />} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/index.ts new file mode 100644 index 00000000..d1e759c9 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/ExcludedCompartmentPathways/index.ts @@ -0,0 +1 @@ +export { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.test.tsx new file mode 100644 index 00000000..59a59c99 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, waitFor } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { act } from 'react-dom/test-utils'; +import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; +import { IncludedCompartmentPathways } from './IncludedCompartmentPathways.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <IncludedCompartmentPathways /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const CHECKBOX_ELEMENT_NAME = compartmentPathwaysDetailsFixture[0].name; + +describe('IncludedCompartmentPathways - component', () => { + it('should display compartment / pathways checkboxes when fetching data is successful', async () => { + renderComponent({ + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); + + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Select included compartment / pathways')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); + expect(screen.getByLabelText('search-input')).toBeInTheDocument(); + expect(screen.getByLabelText(CHECKBOX_ELEMENT_NAME)).toBeInTheDocument(); + }); + }); + it('should not display compartment / pathways checkboxes when fetching data fails', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select included compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + it('should not display compartment / pathways checkboxes when fetched data is empty', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select included compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + + it('should display loading message when fetching data is pending', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'pending', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select included compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx new file mode 100644 index 00000000..77a64aed --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx @@ -0,0 +1,25 @@ +/* eslint-disable no-magic-numbers */ +import { + compartmentPathwaysDataSelector, + loadingCompartmentPathwaysSelector, +} from '@/redux/compartmentPathways/compartmentPathways.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { getCompartmentPathwaysCheckboxElements } from '../Elements.utils'; + +export const IncludedCompartmentPathways = (): React.ReactNode => { + const loadingCompartmentPathways = useAppSelector(loadingCompartmentPathwaysSelector); + const isPending = loadingCompartmentPathways === 'pending'; + const compartmentPathways = useAppSelector(compartmentPathwaysDataSelector); + const checkboxElements = getCompartmentPathwaysCheckboxElements(compartmentPathways); + + return ( + <CollapsibleSection title="Select included compartment / pathways"> + {isPending && <p>Loading...</p>} + {!isPending && checkboxElements && checkboxElements.length > 0 && ( + <CheckboxFilter options={checkboxElements} /> + )} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /index.ts new file mode 100644 index 00000000..56b78aea --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/IncludedCompartmentPathways /index.ts @@ -0,0 +1 @@ +export { IncludedCompartmentPathways } from './IncludedCompartmentPathways.component'; diff --git a/src/models/compartmentPathwaySchema.ts b/src/models/compartmentPathwaySchema.ts new file mode 100644 index 00000000..b7f3cdc4 --- /dev/null +++ b/src/models/compartmentPathwaySchema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const compartmentPathwaySchema = z.object({ + id: z.number(), +}); + +export const boundsSchema = z.object({ + height: z.number(), + width: z.number(), + x: z.number(), + y: z.number(), + z: z.number(), +}); + +export const otherSchema = z.object({ + modifications: z.array(z.unknown()), + structuralState: z.null(), + structures: z.object({}), +}); + +export const compartmentPathwayDetailsSchema = z.object({ + abbreviation: z.null(), + activity: z.null(), + boundaryCondition: z.null(), + bounds: boundsSchema, + compartmentId: z.number().nullable(), + complexId: z.null(), + constant: z.null(), + elementId: z.string(), + formerSymbols: z.array(z.unknown()), + formula: z.null(), + fullName: z.string().nullable(), + glyph: z.any(), + hierarchyVisibilityLevel: z.string(), + homomultimer: z.null(), + hypothetical: z.null(), + id: z.number(), + initialAmount: z.null(), + initialConcentration: z.null(), + linkedSubmodel: z.null(), + modelId: z.number(), + name: z.string(), + notes: z.string(), + other: otherSchema, + references: z.array(z.unknown()), + symbol: z.null(), + synonyms: z.array(z.unknown()), + transparencyLevel: z.string(), + type: z.string(), +}); diff --git a/src/models/fixtures/compartmentPathways.ts b/src/models/fixtures/compartmentPathways.ts new file mode 100644 index 00000000..c93b4dc0 --- /dev/null +++ b/src/models/fixtures/compartmentPathways.ts @@ -0,0 +1,29 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { z } from 'zod'; +import { + compartmentPathwayDetailsSchema, + compartmentPathwaySchema, +} from '../compartmentPathwaySchema'; + +export const compartmentPathwaysFixture = createFixture(z.array(compartmentPathwaySchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); + +export const compartmentPathwaysOverLimitFixture = createFixture( + z.array(compartmentPathwaySchema), + { + seed: ZOD_SEED, + array: { min: 101, max: 101 }, + }, +); + +export const compartmentPathwaysDetailsFixture = createFixture( + z.array(compartmentPathwayDetailsSchema), + { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, + }, +); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 433b6c49..c417dbca 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -39,4 +39,9 @@ export const apiPath = { createOverlayFile: (): string => `files/`, uploadOverlayFileContent: (fileId: number): string => `files/${fileId}:uploadContent`, getStatisticsById: (projectId: string): string => `projects/${projectId}/statistics/`, + getCompartmentPathwaysIds: (objectId: number): string => + `projects/${PROJECT_ID}/models/${objectId}/bioEntities/elements/?columns=id&type=Compartment,Pathway`, + getCompartmentPathwayDetails: (ids: number[]): string => + `projects/${PROJECT_ID}/models/*/bioEntities/elements/?id=${ids.join(',')}`, + sendCompartmentPathwaysIds: (): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/`, }; diff --git a/src/redux/compartmentPathways/comparmentPathways.constants.ts b/src/redux/compartmentPathways/comparmentPathways.constants.ts new file mode 100644 index 00000000..2bf4d519 --- /dev/null +++ b/src/redux/compartmentPathways/comparmentPathways.constants.ts @@ -0,0 +1 @@ +export const MAX_NUMBER_OF_IDS_IN_GET_QUERY = 100; diff --git a/src/redux/compartmentPathways/compartmentPathways.mock.ts b/src/redux/compartmentPathways/compartmentPathways.mock.ts new file mode 100644 index 00000000..6d62817d --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.mock.ts @@ -0,0 +1,64 @@ +import { MapModel } from '@/types/models'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; + +export const COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK: CompartmentPathwaysState = { + loading: 'idle', + data: [], + error: { name: '', message: '' }, +}; +export const MODELS_MOCK: MapModel[] = [ + { + idObject: 5053, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, + { + idObject: 5054, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, +]; + +export const MODELS_MOCK_SHORT: MapModel[] = [ + { + idObject: 5050, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, +]; diff --git a/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts new file mode 100644 index 00000000..2c263903 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable no-magic-numbers */ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { + compartmentPathwaysDetailsFixture, + compartmentPathwaysFixture, + compartmentPathwaysOverLimitFixture, +} from '@/models/fixtures/compartmentPathways'; +import { getModelsIds } from '@/components/Map/Drawer/ExportDrawer/Elements/Elements.utils'; +import { apiPath } from '../apiPath'; +import compartmentPathwaysReducer from './compartmentPathways.slice'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; +import { getCompartmentPathways } from './compartmentPathways.thunks'; +import { MODELS_MOCK } from './compartmentPathways.mock'; + +const mockedAxiosClient = mockNetworkResponse(); +const MODELS_MOCK_IDS = getModelsIds(MODELS_MOCK); +const INITIAL_STATE: CompartmentPathwaysState = { + loading: 'idle', + error: { name: '', message: '' }, + data: [], +}; + +describe('compartmentPathways reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<CompartmentPathwaysState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('compartmentPathways', compartmentPathwaysReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + expect(compartmentPathwaysReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store on loading getCompartmentPathways query', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(52)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails([1, 2, 3])) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('idle'); + expect(data).toEqual([]); + + store.dispatch(getCompartmentPathways()); + + const { loading: loadingPending, data: dataPending } = store.getState().compartmentPathways; + + expect(loadingPending).toEqual('pending'); + expect(dataPending).toEqual([]); + }); + + it('should update store after succesful getCompartmentPathways query', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5053)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5054)) + .reply(HttpStatusCode.Ok, compartmentPathwaysOverLimitFixture); + + const ids = compartmentPathwaysFixture.map(el => el.id); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails(ids)) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways(MODELS_MOCK_IDS)); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + const { type } = await compartmentPathwaysPromise; + + expect(type).toBe('compartmentPathways/getCompartmentPathways/fulfilled'); + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(dataFulfilled).toEqual([ + ...compartmentPathwaysDetailsFixture, + ...compartmentPathwaysDetailsFixture, + ]); + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should update store after failed getCompartmentPathways query', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5053)) + .reply(HttpStatusCode.NotFound, []); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails([])) + .reply(HttpStatusCode.NotFound, []); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.NotFound, []); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways(MODELS_MOCK_IDS)); + + const { loading, data } = store.getState().compartmentPathways; + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + await compartmentPathwaysPromise; + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(promiseFulfilled).toEqual('failed'); + expect(dataFulfilled).toEqual([]); + }); +}); diff --git a/src/redux/compartmentPathways/compartmentPathways.reducers.ts b/src/redux/compartmentPathways/compartmentPathways.reducers.ts new file mode 100644 index 00000000..8010fbc0 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.reducers.ts @@ -0,0 +1,20 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getCompartmentPathways } from './compartmentPathways.thunks'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; + +export const getCompartmentPathwaysReducer = ( + builder: ActionReducerMapBuilder<CompartmentPathwaysState>, +): void => { + builder + .addCase(getCompartmentPathways.pending, state => { + state.loading = 'pending'; + }) + .addCase(getCompartmentPathways.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }) + .addCase(getCompartmentPathways.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); +}; diff --git a/src/redux/compartmentPathways/compartmentPathways.selectors.ts b/src/redux/compartmentPathways/compartmentPathways.selectors.ts new file mode 100644 index 00000000..d6c82942 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.selectors.ts @@ -0,0 +1,17 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const compartmentPathwaysSelector = createSelector( + rootSelector, + state => state.compartmentPathways, +); + +export const compartmentPathwaysDataSelector = createSelector( + compartmentPathwaysSelector, + state => state.data, +); + +export const loadingCompartmentPathwaysSelector = createSelector( + compartmentPathwaysSelector, + state => state.loading, +); diff --git a/src/redux/compartmentPathways/compartmentPathways.slice.ts b/src/redux/compartmentPathways/compartmentPathways.slice.ts new file mode 100644 index 00000000..3cc1b375 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; +import { getCompartmentPathwaysReducer } from './compartmentPathways.reducers'; + +export const initialState: CompartmentPathwaysState = { + loading: 'idle', + error: { name: '', message: '' }, + data: [], +}; + +export const compartmentPathwaysSlice = createSlice({ + name: 'compartmentPathways', + initialState, + reducers: {}, + extraReducers: builder => { + getCompartmentPathwaysReducer(builder); + }, +}); + +export default compartmentPathwaysSlice.reducer; diff --git a/src/redux/compartmentPathways/compartmentPathways.thunks.test.ts b/src/redux/compartmentPathways/compartmentPathways.thunks.test.ts new file mode 100644 index 00000000..b2d801e3 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.thunks.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-magic-numbers */ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { + compartmentPathwaysDetailsFixture, + compartmentPathwaysFixture, + compartmentPathwaysOverLimitFixture, +} from '@/models/fixtures/compartmentPathways'; +import { getModelsIds } from '@/components/Map/Drawer/ExportDrawer/Elements/Elements.utils'; +import { apiPath } from '../apiPath'; +import compartmentPathwaysReducer from './compartmentPathways.slice'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; +import { getCompartmentPathways } from './compartmentPathways.thunks'; +import { MODELS_MOCK, MODELS_MOCK_SHORT } from './compartmentPathways.mock'; + +const mockedAxiosClient = mockNetworkResponse(); +const MODELS_MOCK_IDS = getModelsIds(MODELS_MOCK); + +describe('compartmentPathways thunk', () => { + let store = {} as ToolkitStoreWithSingleSlice<CompartmentPathwaysState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('compartmentPathways', compartmentPathwaysReducer); + }); + + it('should handle query getCompartmentPathways properly when models are undefined', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(52)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails([1, 2, 3])) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('idle'); + expect(data).toEqual([]); + + const comparmentPathwaysPromise = store.dispatch(getCompartmentPathways()); + + const { loading: loadingPending, data: dataPending } = store.getState().compartmentPathways; + + expect(loadingPending).toEqual('pending'); + expect(dataPending).toEqual([]); + + await comparmentPathwaysPromise; + const { loading: loadingFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(loadingFulfilled).toEqual('succeeded'); + expect(dataFulfilled).toEqual([]); + }); + it('should handle sendCompartmentPathwaysIds request properly if it is more than 100 ids', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5053)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5054)) + .reply(HttpStatusCode.Ok, compartmentPathwaysOverLimitFixture); + + const ids = compartmentPathwaysFixture.map(el => el.id); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails(ids)) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways(MODELS_MOCK_IDS)); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + const { type } = await compartmentPathwaysPromise; + + expect(type).toBe('compartmentPathways/getCompartmentPathways/fulfilled'); + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(dataFulfilled).toEqual([ + ...compartmentPathwaysDetailsFixture, + ...compartmentPathwaysDetailsFixture, + ]); + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should not do a network request sendCompartmentPathwaysIds if it is less than 100 ids', async () => { + const ONE_MODEL = MODELS_MOCK_SHORT[0]; + const ID = ONE_MODEL.idObject; + + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(ID)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + + const ids = compartmentPathwaysFixture.map(el => el.id); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails(ids)) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways([ONE_MODEL.idObject])); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + const { type } = await compartmentPathwaysPromise; + + expect(type).toBe('compartmentPathways/getCompartmentPathways/fulfilled'); + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(dataFulfilled).toEqual(compartmentPathwaysDetailsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); +}); diff --git a/src/redux/compartmentPathways/compartmentPathways.thunks.ts b/src/redux/compartmentPathways/compartmentPathways.thunks.ts new file mode 100644 index 00000000..e0d69617 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.thunks.ts @@ -0,0 +1,120 @@ +/* eslint-disable no-restricted-syntax */ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { CompartmentPathway, CompartmentPathwayDetails } from '@/types/models'; +import { + compartmentPathwayDetailsSchema, + compartmentPathwaySchema, +} from '@/models/compartmentPathwaySchema'; +import { z } from 'zod'; +import { MAX_NUMBER_OF_IDS_IN_GET_QUERY } from './comparmentPathways.constants'; +import { apiPath } from '../apiPath'; + +/** UTILS */ + +const fetchCompartmentPathwaysIds = async ( + modelsIds: number[] | undefined, +): Promise<number[][]> => { + if (!modelsIds) return []; + + const compartmentIds = []; + + for (const modelId of modelsIds) { + /* eslint-disable no-await-in-loop */ + const response = await axiosInstance<CompartmentPathway[]>( + apiPath.getCompartmentPathwaysIds(modelId), + ); + + const isDataValid = validateDataUsingZodSchema( + response.data, + z.array(compartmentPathwaySchema), + ); + + if (isDataValid) { + const result = response.data; + const ids: number[] = []; + + result.forEach(item => { + ids.push(item.id); + }); + + compartmentIds.push(ids); + } + } + + return compartmentIds; +}; + +const fetchCompartmentPathwayDetailsByPost = async ( + compartmentPathwayIds: number[], +): Promise<CompartmentPathwayDetails[]> => { + const params = { + id: compartmentPathwayIds.join(','), + }; + const body = new URLSearchParams(params); + + const response = await axiosInstance.post<CompartmentPathwayDetails[]>( + apiPath.sendCompartmentPathwaysIds(), + body, + ); + + return response.data; +}; + +const fetchCompartmentPathwayDetailsByGet = async ( + compartmentPathwayIds: number[], +): Promise<CompartmentPathwayDetails[]> => { + const response = await axiosInstance.get<CompartmentPathwayDetails[]>( + apiPath.getCompartmentPathwayDetails(compartmentPathwayIds), + ); + + return response.data; +}; + +const fetchCompartmentPathwayDetails = async ( + compartmentPathwayIds: number[], +): Promise<CompartmentPathwayDetails[]> => { + if (compartmentPathwayIds.length) { + let compartmentPathwayDetails; + if (compartmentPathwayIds.length > MAX_NUMBER_OF_IDS_IN_GET_QUERY) { + compartmentPathwayDetails = await fetchCompartmentPathwayDetailsByPost(compartmentPathwayIds); + } else { + compartmentPathwayDetails = await fetchCompartmentPathwayDetailsByGet(compartmentPathwayIds); + } + + const isDataValid = validateDataUsingZodSchema( + compartmentPathwayDetails, + z.array(compartmentPathwayDetailsSchema), + ); + + if (isDataValid) return compartmentPathwayDetails; + } + return []; +}; + +export const fetchCompartmentPathways = async ( + compartmentPathwaysData: number[][], +): Promise<CompartmentPathwayDetails[]> => { + const compartments = []; + + /* eslint-disable no-await-in-loop */ + for (const compartmentPathwayIds of compartmentPathwaysData) { + const compartmentPathwayDetails = await fetchCompartmentPathwayDetails(compartmentPathwayIds); + + if (compartmentPathwayDetails) compartments.push(...compartmentPathwayDetails); + } + + return compartments; +}; + +/** UTILS */ + +export const getCompartmentPathways = createAsyncThunk( + 'compartmentPathways/getCompartmentPathways', + async (modelsIds: number[] | undefined) => { + const compartmentIds = await fetchCompartmentPathwaysIds(modelsIds); + const comparmentPathways = await fetchCompartmentPathways(compartmentIds); + return comparmentPathways; + }, +); diff --git a/src/redux/compartmentPathways/compartmentPathways.types.ts b/src/redux/compartmentPathways/compartmentPathways.types.ts new file mode 100644 index 00000000..76718b54 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { CompartmentPathwayDetails } from '@/types/models'; + +export type CompartmentPathwaysState = FetchDataState<CompartmentPathwayDetails[], []>; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index aaca59eb..a8494c46 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -15,9 +15,10 @@ import { OVERLAYS_INITIAL_STATE_MOCK } from '../overlays/overlays.mock'; import { PROJECT_STATE_INITIAL_MOCK } from '../project/project.mock'; import { REACTIONS_STATE_INITIAL_MOCK } from '../reactions/reactions.mock'; import { SEARCH_STATE_INITIAL_MOCK } from '../search/search.mock'; -import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; 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'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, @@ -39,4 +40,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { user: USER_INITIAL_STATE_MOCK, legend: LEGEND_INITIAL_STATE_MOCK, statistics: STATISTICS_STATE_INITIAL_MOCK, + compartmentPathways: COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index b79cf2b2..944288e8 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -25,6 +25,7 @@ import { import legendReducer from './legend/legend.slice'; import { mapListenerMiddleware } from './map/middleware/map.middleware'; import statisticsReducer from './statistics/statistics.slice'; +import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; export const reducers = { search: searchReducer, @@ -46,6 +47,7 @@ export const reducers = { overlayBioEntity: overlayBioEntityReducer, legend: legendReducer, statistics: statisticsReducer, + compartmentPathways: compartmentPathwaysReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/types/models.ts b/src/types/models.ts index 8f4582e5..23d350f7 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -3,6 +3,10 @@ import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; import { bioEntitySchema } from '@/models/bioEntitySchema'; import { chemicalSchema } from '@/models/chemicalSchema'; import { colorSchema } from '@/models/colorSchema'; +import { + compartmentPathwayDetailsSchema, + compartmentPathwaySchema, +} from '@/models/compartmentPathwaySchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; import { configurationSchema } from '@/models/configurationSchema'; import { disease } from '@/models/disease'; @@ -65,3 +69,5 @@ export type UploadedOverlayFileContent = z.infer<typeof uploadedOverlayFileConte export type CreatedOverlay = z.infer<typeof createdOverlaySchema>; 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>; -- GitLab