diff --git a/package-lock.json b/package-lock.json index dabf49dce6380c53f944390a58b1d5a32e3ba10b..8ac4a5d8aac76245502f5711c51d7396a93512f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@commitlint/config-conventional": "^17.7.0", "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", @@ -2150,6 +2151,19 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -15495,6 +15509,13 @@ "@types/react-dom": "^18.0.0" } }, + "@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "requires": {} + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", diff --git a/package.json b/package.json index 4fb3ac58884eefadf31d01b6ad6a9b331d450875..cc4afb15ce32397ce7e992b86c1b14c1fe6fd3d8 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@commitlint/config-conventional": "^17.7.0", "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx index 1fb3437a0f779358067827ff463df4d3f8ca76c2..7d514e917e452c4c0f64e81846c12e3e65a7ba35 100644 --- a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { CheckboxFilter } from './CheckboxFilter.component'; @@ -9,14 +8,16 @@ const options = [ { id: '3', label: 'Option 3' }, ]; +const currentOptions = [{ id: '2', label: 'Option 2' }]; + describe('CheckboxFilter - component', () => { it('should render CheckboxFilter properly', () => { - render(<CheckboxFilter options={options} />); + render(<CheckboxFilter options={options} currentOptions={[]} />); expect(screen.getByTestId('search')).toBeInTheDocument(); }); it('should filter options based on search term', async () => { - render(<CheckboxFilter options={options} />); + render(<CheckboxFilter options={options} currentOptions={[]} />); const searchInput = screen.getByLabelText('search-input'); fireEvent.change(searchInput, { target: { value: `Option 1` } }); @@ -28,7 +29,26 @@ describe('CheckboxFilter - component', () => { it('should handle checkbox value change', async () => { const onCheckedChange = jest.fn(); - render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + render( + <CheckboxFilter currentOptions={[]} options={options} onCheckedChange={onCheckedChange} />, + ); + const checkbox = screen.getByLabelText('Option 1'); + + fireEvent.click(checkbox); + + expect(onCheckedChange).toHaveBeenCalledWith([{ id: '1', label: 'Option 1' }]); + }); + + it('should handle radio value change', async () => { + const onCheckedChange = jest.fn(); + render( + <CheckboxFilter + currentOptions={[]} + type="radio" + options={options} + onCheckedChange={onCheckedChange} + />, + ); const checkbox = screen.getByLabelText('Option 1'); fireEvent.click(checkbox); @@ -38,7 +58,9 @@ describe('CheckboxFilter - component', () => { it('should call onFilterChange when searching new term', async () => { const onFilterChange = jest.fn(); - render(<CheckboxFilter options={options} onFilterChange={onFilterChange} />); + render( + <CheckboxFilter currentOptions={[]} options={options} onFilterChange={onFilterChange} />, + ); const searchInput = screen.getByLabelText('search-input'); fireEvent.change(searchInput, { target: { value: 'Option 1' } }); @@ -46,7 +68,7 @@ describe('CheckboxFilter - component', () => { expect(onFilterChange).toHaveBeenCalledWith([{ id: '1', label: 'Option 1' }]); }); it('should display message when no elements are found', async () => { - render(<CheckboxFilter options={options} />); + render(<CheckboxFilter currentOptions={[]} options={options} />); const searchInput = screen.getByLabelText('search-input'); fireEvent.change(searchInput, { target: { value: 'Nonexistent Option' } }); @@ -55,13 +77,15 @@ describe('CheckboxFilter - component', () => { }); it('should display message when options are empty', () => { const onFilterChange = jest.fn(); - render(<CheckboxFilter options={[]} onFilterChange={onFilterChange} />); + render(<CheckboxFilter currentOptions={[]} options={[]} onFilterChange={onFilterChange} />); expect(screen.getByText('No matching elements found.')).toBeInTheDocument(); }); it('should handle multiple checkbox selection', () => { const onCheckedChange = jest.fn(); - render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + render( + <CheckboxFilter currentOptions={[]} options={options} onCheckedChange={onCheckedChange} />, + ); const checkbox1 = screen.getByLabelText('Option 1'); const checkbox2 = screen.getByLabelText('Option 2'); @@ -74,9 +98,33 @@ describe('CheckboxFilter - component', () => { { id: '2', label: 'Option 2' }, ]); }); + + it('should handle multiple change of radio selection', () => { + const onCheckedChange = jest.fn(); + render( + <CheckboxFilter + currentOptions={[]} + options={options} + onCheckedChange={onCheckedChange} + type="radio" + />, + ); + + const checkbox1 = screen.getByLabelText('Option 1'); + const checkbox2 = screen.getByLabelText('Option 2'); + + fireEvent.click(checkbox1); + expect(onCheckedChange).toHaveBeenCalledWith([{ id: '1', label: 'Option 1' }]); + + fireEvent.click(checkbox2); + expect(onCheckedChange).toHaveBeenCalledWith([{ id: '2', label: 'Option 2' }]); + }); + it('should handle unchecking a checkbox', () => { const onCheckedChange = jest.fn(); - render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + render( + <CheckboxFilter currentOptions={[]} options={options} onCheckedChange={onCheckedChange} />, + ); const checkbox = screen.getByLabelText('Option 1'); @@ -86,19 +134,19 @@ describe('CheckboxFilter - component', () => { expect(onCheckedChange).toHaveBeenCalledWith([]); }); it('should render search input when isSearchEnabled is true', () => { - render(<CheckboxFilter options={options} />); + render(<CheckboxFilter currentOptions={[]} options={options} />); const searchInput = screen.getByLabelText('search-input'); expect(searchInput).toBeInTheDocument(); }); it('should not render search input when isSearchEnabled is false', () => { - render(<CheckboxFilter options={options} isSearchEnabled={false} />); + render(<CheckboxFilter currentOptions={[]} options={options} isSearchEnabled={false} />); const searchInput = screen.queryByLabelText('search-input'); expect(searchInput).not.toBeInTheDocument(); }); it('should not filter options based on search input when isSearchEnabled is false', () => { - render(<CheckboxFilter options={options} isSearchEnabled={false} />); + render(<CheckboxFilter currentOptions={[]} options={options} isSearchEnabled={false} />); const searchInput = screen.queryByLabelText('search-input'); expect(searchInput).not.toBeInTheDocument(); options.forEach(option => { @@ -106,4 +154,15 @@ describe('CheckboxFilter - component', () => { expect(checkboxLabel).toBeInTheDocument(); }); }); + + it('should set checked param based on currentOptions prop', async () => { + render(<CheckboxFilter options={options} currentOptions={currentOptions} />); + const option1: HTMLInputElement = screen.getByLabelText('Option 1'); + const option2: HTMLInputElement = screen.getByLabelText('Option 2'); + const option3: HTMLInputElement = screen.getByLabelText('Option 3'); + + expect(option1.checked).toBe(false); + expect(option2.checked).toBe(true); + expect(option3.checked).toBe(false); + }); }); diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx index 3075991fcde8eea97215405c1116cf1eabc6832a..e02000ab89c0b39f464420e9ff7deb2a1385f205 100644 --- a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx @@ -1,23 +1,27 @@ /* eslint-disable no-magic-numbers */ +import lensIcon from '@/assets/vectors/icons/lens.svg'; import Image from 'next/image'; import React, { useEffect, useState } from 'react'; -import lensIcon from '@/assets/vectors/icons/lens.svg'; import { twMerge } from 'tailwind-merge'; - -export type CheckboxItem = { id: string; label: string }; +import { CheckboxItem } from './CheckboxFilter.types'; +import { OptionInput } from './OptionInput'; type CheckboxFilterProps = { options: CheckboxItem[]; + currentOptions: CheckboxItem[]; onFilterChange?: (filteredItems: CheckboxItem[]) => void; onCheckedChange?: (filteredItems: CheckboxItem[]) => void; isSearchEnabled?: boolean; + type?: 'checkbox' | 'radio'; }; export const CheckboxFilter = ({ options, + currentOptions = [], onFilterChange, onCheckedChange, isSearchEnabled = true, + type = 'checkbox', }: CheckboxFilterProps): React.ReactNode => { const [searchTerm, setSearchTerm] = useState(''); const [filteredOptions, setFilteredOptions] = useState<CheckboxItem[]>(options); @@ -47,6 +51,11 @@ export const CheckboxFilter = ({ onCheckedChange?.(newCheckedCheckboxes); }; + const handleRadioChange = (option: CheckboxItem): void => { + setCheckedCheckboxes([option]); + onCheckedChange?.([option]); + }; + useEffect(() => { setFilteredOptions(options); }, [options]); @@ -86,15 +95,13 @@ export const CheckboxFilter = ({ <ul className="columns-2 gap-8"> {filteredOptions.map(option => ( <li key={option.id} className="mb-5 flex items-center gap-x-2"> - <input - type="checkbox" - id={option.id} - className=" h-4 w-4 shrink-0 accent-primary-500" - onChange={(): void => handleCheckboxChange(option)} + <OptionInput + option={option} + currentOptions={currentOptions} + handleRadioChange={handleRadioChange} + handleCheckboxChange={handleCheckboxChange} + type={type} /> - <label htmlFor={option.id} className="break-all text-sm"> - {option.label} - </label> </li> ))} </ul> diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.types.ts b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f060532b1071c39b9e1897b16e18e1c8bd4b173 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.types.ts @@ -0,0 +1 @@ +export type CheckboxItem = { id: string; label: string }; diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/OptionInput/OptionInput.component.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/OptionInput/OptionInput.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..61611797079b5af037629edf9f242f96b01d0f37 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/OptionInput/OptionInput.component.tsx @@ -0,0 +1,48 @@ +import { twMerge } from 'tailwind-merge'; +import { CheckboxItem } from '../CheckboxFilter.types'; + +interface Props { + option: CheckboxItem; + currentOptions: CheckboxItem[]; + type: 'checkbox' | 'radio'; + handleCheckboxChange(option: CheckboxItem): void; + handleRadioChange(option: CheckboxItem): void; +} + +export const OptionInput = ({ + option, + currentOptions = [], + type, + handleCheckboxChange, + handleRadioChange, +}: Props): React.ReactNode => { + const isChecked = Boolean(currentOptions.find(currentOption => currentOption.id === option.id)); + + const handleChange = (): void => { + switch (type) { + case 'checkbox': + handleCheckboxChange(option); + break; + case 'radio': + handleRadioChange(option); + break; + default: + throw new Error(`${type} is unknown option input type`); + } + }; + + return ( + <label className="flex items-center gap-x-2"> + <input + type={type} + className={twMerge( + 'h-4 w-4 shrink-0 accent-primary-500', + type === 'radio' && 'rounded-full', + )} + onChange={handleChange} + checked={isChecked} + /> + <div className="break-all text-sm">{option.label}</div> + </label> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/OptionInput/index.ts b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/OptionInput/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..55a9b8f1c138359e5af93aaf0ec5b88cc6e7eeff --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/OptionInput/index.ts @@ -0,0 +1 @@ +export { OptionInput } from './OptionInput.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.test.tsx index 01d1989e50f63cacf6cc3ec5453cf272bd16d9af..9fa8624b161e701d4836820a0d008319f53c1aa9 100644 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.test.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.test.tsx @@ -1,20 +1,21 @@ /* eslint-disable no-magic-numbers */ +import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { apiPath } from '@/redux/apiPath'; +import { CONFIGURATION_INITIAL_STORE_MOCK } from '@/redux/configuration/configuration.mock'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; import { AppDispatch, RootState } from '@/redux/store'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; import { InitialStoreState } from '@/utils/testing/getReduxWrapperWithStore'; import { render, screen } from '@testing-library/react'; -import { CONFIGURATION_INITIAL_STORE_MOCK } from '@/redux/configuration/configuration.mock'; -import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { HttpStatusCode } from 'axios'; import { act } from 'react-dom/test-utils'; -import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; -import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; import { MockStoreEnhanced } from 'redux-mock-store'; -import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; -import { modelsFixture } from '@/models/fixtures/modelsFixture'; -import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; -import { apiPath } from '@/redux/apiPath'; -import { HttpStatusCode } from 'axios'; -import { Elements } from './Elements.component'; import { ELEMENTS_COLUMNS } from '../ExportCompound/ExportCompound.constant'; +import { Elements } from './Elements.component'; const mockedAxiosClient = mockNetworkNewAPIResponse(); @@ -37,6 +38,7 @@ const renderComponent = ( describe('Elements - component', () => { it('should render all elements sections', () => { renderComponent({ + ...INITIAL_STORE_STATE_MOCK, configuration: { ...CONFIGURATION_INITIAL_STORE_MOCK, main: { @@ -95,6 +97,7 @@ describe('Elements - component', () => { const SECOND_COMPARMENT_PATHWAY_NAME = compartmentPathwaysDetailsFixture[1].name; const SECOND_COMPARMENT_PATHWAY_ID = compartmentPathwaysDetailsFixture[1].id; const { store } = renderComponent({ + ...INITIAL_STORE_STATE_MOCK, configuration: { ...CONFIGURATION_INITIAL_STORE_MOCK, main: { diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.tsx index 29c91f148dff7d8eed15ac08f1c2ef797798fabe..bd065f025aeb52bd4279b9cc3b362ca902049217 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.tsx @@ -1,23 +1,24 @@ -import { useContext } from 'react'; +import { ZERO } from '@/constants/common'; +import { miramiTypesSelector } from '@/redux/configuration/configuration.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { loadingStatisticsSelector, statisticsDataSelector, } from '@/redux/statistics/statistics.selectors'; -import { ZERO } from '@/constants/common'; -import { miramiTypesSelector } from '@/redux/configuration/configuration.selectors'; +import { useContext } from 'react'; import { CheckboxFilter } from '../../CheckboxFilter'; import { CollapsibleSection } from '../../CollapsibleSection'; import { ExportContext } from '../ExportCompound.context'; -import { getAnnotationsCheckboxElements } from './Annotations.utils'; import { AnnotationsType } from './Annotations.types'; +import { getAnnotationsCheckboxElements } from './Annotations.utils'; type AnnotationsProps = { type: AnnotationsType; }; export const Annotations = ({ type }: AnnotationsProps): React.ReactNode => { - const { setAnnotations } = useContext(ExportContext); + const { setAnnotations, data } = useContext(ExportContext); + const currentAnnotations = data.annotations; const loadingStatistics = useAppSelector(loadingStatisticsSelector); const statistics = useAppSelector(statisticsDataSelector); const miramiTypes = useAppSelector(miramiTypesSelector); @@ -28,7 +29,11 @@ export const Annotations = ({ type }: AnnotationsProps): React.ReactNode => { <CollapsibleSection title="Select annotations"> {isPending && <p>Loading...</p>} {!isPending && checkboxElements && checkboxElements.length > ZERO && ( - <CheckboxFilter options={checkboxElements} onCheckedChange={setAnnotations} /> + <CheckboxFilter + options={checkboxElements} + currentOptions={currentAnnotations} + onCheckedChange={setAnnotations} + /> )} </CollapsibleSection> ); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadGraphics/DownloadGraphics.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadGraphics/DownloadGraphics.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4d5833c4a7007e36b632a98cad2bcd4182bf7255 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadGraphics/DownloadGraphics.component.tsx @@ -0,0 +1,13 @@ +import { Button } from '@/shared/Button'; +import { useContext } from 'react'; +import { ExportContext } from '../ExportCompound.context'; + +export const DownloadGraphics = (): React.ReactNode => { + const { handleDownloadGraphics } = useContext(ExportContext); + + return ( + <div className="mt-6"> + <Button onClick={handleDownloadGraphics}>Download</Button> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadGraphics/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadGraphics/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..755e63384991acb5a730640c0cb1c04e27058bcf --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadGraphics/index.ts @@ -0,0 +1 @@ +export { DownloadGraphics } from './DownloadGraphics.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx index a66fba4e8b3867a8db0c550466eb5e572e210eb3..89007fae92f3b9ba261516041befe04ee004e3d9 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx @@ -1,17 +1,18 @@ -import { useContext } from 'react'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { ZERO } from '@/constants/common'; import { compartmentPathwaysDataSelector, loadingCompartmentPathwaysSelector, } from '@/redux/compartmentPathways/compartmentPathways.selectors'; -import { ZERO } from '@/constants/common'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { useContext } from 'react'; import { CheckboxFilter } from '../../CheckboxFilter'; import { CollapsibleSection } from '../../CollapsibleSection'; import { ExportContext } from '../ExportCompound.context'; import { getCompartmentPathwaysCheckboxElements } from '../utils/getCompartmentPathwaysCheckboxElements'; export const ExcludedCompartmentPathways = (): React.ReactNode => { - const { setExcludedCompartmentPathways } = useContext(ExportContext); + const { setExcludedCompartmentPathways, data } = useContext(ExportContext); + const currentExcludedCompartmentPathways = data.excludedCompartmentPathways; const loadingCompartmentPathways = useAppSelector(loadingCompartmentPathwaysSelector); const isPending = loadingCompartmentPathways === 'pending'; const compartmentPathways = useAppSelector(compartmentPathwaysDataSelector); @@ -24,6 +25,7 @@ export const ExcludedCompartmentPathways = (): React.ReactNode => { {isCheckboxFilterVisible && ( <CheckboxFilter options={checkboxElements} + currentOptions={currentExcludedCompartmentPathways} onCheckedChange={setExcludedCompartmentPathways} /> )} diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx index 4936c27c1dde4cf20a7e4b86504bb457d3fe579a..52bc373224656a1994d53acf5fbda4b6a9619273 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx @@ -1,18 +1,28 @@ -import { ReactNode, useCallback, useMemo, useState } from 'react'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { modelsIdsSelector } from '@/redux/models/models.selectors'; +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { currentBackgroundSelector } from '@/redux/backgrounds/background.selectors'; +import { downloadElements, downloadNetwork } from '@/redux/export/export.thunks'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { downloadNetwork, downloadElements } from '@/redux/export/export.thunks'; -import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.component'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { modelsDataSelector, modelsIdsSelector } from '@/redux/models/models.selectors'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; +import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.types'; import { Annotations } from './Annotations'; -import { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways'; -import { IncludedCompartmentPathways } from './IncludedCompartmentPathways '; import { DownloadElements } from './DownloadElements/DownloadElements'; -import { ExportContext } from './ExportCompound.context'; -import { getNetworkDownloadBodyRequest } from './utils/getNetworkBodyRequest'; -import { getDownloadElementsBodyRequest } from './utils/getDownloadElementsBodyRequest'; +import { DownloadGraphics } from './DownloadGraphics'; import { DownloadNetwork } from './DownloadNetwork/DownloadNetwork'; +import { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways'; import { ELEMENTS_COLUMNS, NETWORK_COLUMNS } from './ExportCompound.constant'; +import { ExportContext } from './ExportCompound.context'; +import { ImageFormat } from './ImageFormat'; +import { ImageSize } from './ImageSize'; +import { DEFAULT_IMAGE_SIZE } from './ImageSize/ImageSize.constants'; +import { ImageSize as ImageSizeType } from './ImageSize/ImageSize.types'; +import { IncludedCompartmentPathways } from './IncludedCompartmentPathways '; +import { Submap } from './Submap'; +import { getDownloadElementsBodyRequest } from './utils/getDownloadElementsBodyRequest'; +import { getGraphicsDownloadUrl } from './utils/getGraphicsDownloadUrl'; +import { getModelExportZoom } from './utils/getModelExportZoom'; +import { getNetworkDownloadBodyRequest } from './utils/getNetworkBodyRequest'; type ExportProps = { children: ReactNode; @@ -20,14 +30,19 @@ type ExportProps = { export const Export = ({ children }: ExportProps): JSX.Element => { const dispatch = useAppDispatch(); - const [annotations, setAnnotations] = useState<CheckboxItem[]>([]); const modelIds = useAppSelector(modelsIdsSelector); + const currentModels = useAppSelector(modelsDataSelector); + const currentBackground = useAppSelector(currentBackgroundSelector); + const [annotations, setAnnotations] = useState<CheckboxItem[]>([]); const [includedCompartmentPathways, setIncludedCompartmentPathways] = useState<CheckboxItem[]>( [], ); const [excludedCompartmentPathways, setExcludedCompartmentPathways] = useState<CheckboxItem[]>( [], ); + const [models, setModels] = useState<CheckboxItem[]>([]); + const [imageSize, setImageSize] = useState<ImageSizeType>(DEFAULT_IMAGE_SIZE); + const [imageFormats, setImageFormats] = useState<CheckboxItem[]>([]); const handleDownloadElements = useCallback(async () => { const body = getDownloadElementsBodyRequest({ @@ -53,15 +68,55 @@ export const Export = ({ children }: ExportProps): JSX.Element => { dispatch(downloadNetwork(data)); }, [modelIds, annotations, includedCompartmentPathways, excludedCompartmentPathways, dispatch]); + const handleDownloadGraphics = useCallback(async () => { + const modelId = models?.[FIRST_ARRAY_ELEMENT]?.id; + const model = currentModels.find(currentModel => currentModel.idObject === Number(modelId)); + + const url = getGraphicsDownloadUrl({ + backgroundId: currentBackground?.id, + modelId: models?.[FIRST_ARRAY_ELEMENT]?.id, + handler: imageFormats?.[FIRST_ARRAY_ELEMENT]?.id, + zoom: getModelExportZoom(imageSize.width, model), + }); + + if (url) { + window.open(url); + } + }, [models, imageFormats, currentBackground, currentModels, imageSize.width]); + + const globalContextDataValue = useMemo( + () => ({ + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + models, + imageSize, + imageFormats, + }), + [ + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + models, + imageSize, + imageFormats, + ], + ); + const globalContextValue = useMemo( () => ({ setAnnotations, setIncludedCompartmentPathways, setExcludedCompartmentPathways, + setModels, + setImageSize, + setImageFormats, handleDownloadElements, handleDownloadNetwork, + handleDownloadGraphics, + data: globalContextDataValue, }), - [handleDownloadElements, handleDownloadNetwork], + [handleDownloadElements, handleDownloadNetwork, globalContextDataValue, handleDownloadGraphics], ); return <ExportContext.Provider value={globalContextValue}>{children}</ExportContext.Provider>; @@ -71,4 +126,8 @@ Export.Annotations = Annotations; Export.IncludedCompartmentPathways = IncludedCompartmentPathways; Export.ExcludedCompartmentPathways = ExcludedCompartmentPathways; Export.DownloadElements = DownloadElements; +Export.Submap = Submap; +Export.ImageSize = ImageSize; +Export.ImageFormat = ImageFormat; Export.DownloadNetwork = DownloadNetwork; +Export.DownloadGraphics = DownloadGraphics; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts index 00c556b71ce7fd73f07b0c106c4af87d8c92b32b..a07ae4c58f7b2fa3ec8ef4f652c1d4c9dbca075b 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts @@ -1,3 +1,6 @@ +import { ExportContextType } from './ExportCompound.types'; +import { DEFAULT_IMAGE_SIZE } from './ImageSize/ImageSize.constants'; + export const ANNOTATIONS_TYPE = { ELEMENTS: 'Elements', NETWORK: 'Network', @@ -43,3 +46,23 @@ export const NETWORK_COLUMNS = [ 'modelId', 'mapName', ]; + +export const EXPORT_CONTEXT_DEFAULT_VALUE: ExportContextType = { + setAnnotations: () => {}, + setIncludedCompartmentPathways: () => {}, + setExcludedCompartmentPathways: () => {}, + setModels: () => {}, + setImageSize: () => {}, + setImageFormats: () => {}, + handleDownloadElements: () => {}, + handleDownloadNetwork: () => {}, + handleDownloadGraphics: () => {}, + data: { + annotations: [], + includedCompartmentPathways: [], + excludedCompartmentPathways: [], + models: [], + imageFormats: [], + imageSize: DEFAULT_IMAGE_SIZE, + }, +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts index d7d456bd94402625a34234a8f66e20e7bec1c6ee..86f005f09d4dbf9e8ec5643e825c6c0cf33f0486 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts @@ -1,18 +1,5 @@ import { createContext } from 'react'; -import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.component'; +import { EXPORT_CONTEXT_DEFAULT_VALUE } from './ExportCompound.constant'; +import { ExportContextType } from './ExportCompound.types'; -export type ExportContextType = { - setAnnotations: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; - setIncludedCompartmentPathways: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; - setExcludedCompartmentPathways: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; - handleDownloadElements: () => void; - handleDownloadNetwork: () => void; -}; - -export const ExportContext = createContext<ExportContextType>({ - setAnnotations: () => {}, - setIncludedCompartmentPathways: () => {}, - setExcludedCompartmentPathways: () => {}, - handleDownloadElements: () => {}, - handleDownloadNetwork: () => {}, -}); +export const ExportContext = createContext<ExportContextType>(EXPORT_CONTEXT_DEFAULT_VALUE); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.types.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6e70397e6d6edb1ca1a5220aebb814c3518ff4e --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.types.ts @@ -0,0 +1,22 @@ +import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.types'; +import { ImageSize } from './ImageSize/ImageSize.types'; + +export type ExportContextType = { + setAnnotations: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setIncludedCompartmentPathways: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setExcludedCompartmentPathways: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setModels: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setImageSize: React.Dispatch<React.SetStateAction<ImageSize>>; + setImageFormats: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + handleDownloadElements: () => void; + handleDownloadNetwork: () => void; + handleDownloadGraphics: () => void; + data: { + annotations: CheckboxItem[]; + includedCompartmentPathways: CheckboxItem[]; + excludedCompartmentPathways: CheckboxItem[]; + models: CheckboxItem[]; + imageSize: ImageSize; + imageFormats: CheckboxItem[]; + }; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/ImageFormat.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/ImageFormat.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e8c6df3921c6a70d96caeb543b922cd4e6de6ac8 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/ImageFormat.component.test.tsx @@ -0,0 +1,131 @@ +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { + CONFIGURATION_IMAGE_FORMATS_MOCK, + CONFIGURATION_IMAGE_FORMATS_TYPES_MOCK, +} from '@/models/mocks/configurationFormatsMock'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { StoreType } from '@/redux/store'; +import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { ImageFormat } from './ImageFormat.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <ImageFormat /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ImageFormat - component', () => { + it('should display formats checkboxes when fetching data is successful', async () => { + renderComponent({ + configuration: { + ...INITIAL_STORE_STATE_MOCK.configuration, + main: { + ...INITIAL_STORE_STATE_MOCK.configuration.main, + data: { + ...configurationFixture, + imageFormats: CONFIGURATION_IMAGE_FORMATS_MOCK, + }, + }, + }, + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); + + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Image format')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); + + CONFIGURATION_IMAGE_FORMATS_TYPES_MOCK.map(formatName => + expect(screen.getByLabelText(formatName)).toBeInTheDocument(), + ); + }); + }); + + it('should not display formats checkboxes when fetching data fails', async () => { + renderComponent({ + configuration: { + ...INITIAL_STORE_STATE_MOCK.configuration, + main: { + ...INITIAL_STORE_STATE_MOCK.configuration.main, + loading: 'failed', + }, + }, + }); + + expect(screen.getByText('Image format')).toBeInTheDocument(); + + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + + it('should not display formats checkboxes when fetched data is empty', async () => { + renderComponent({ + configuration: { + ...INITIAL_STORE_STATE_MOCK.configuration, + main: { + ...INITIAL_STORE_STATE_MOCK.configuration.main, + data: { + ...configurationFixture, + modelFormats: [], + imageFormats: [], + }, + }, + }, + }); + + expect(screen.getByText('Image format')).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({ + configuration: { + ...INITIAL_STORE_STATE_MOCK.configuration, + main: { + ...INITIAL_STORE_STATE_MOCK.configuration.main, + loading: 'pending', + }, + }, + }); + + expect(screen.getByText('Image format')).toBeInTheDocument(); + + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/ImageFormat.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/ImageFormat.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48ab881789d2445fc5f67e5f6a7ffc6c0b7c85e1 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/ImageFormat.component.tsx @@ -0,0 +1,40 @@ +import { ZERO } from '@/constants/common'; +import { + imageHandlersSelector, + loadingConfigurationMainSelector, +} from '@/redux/configuration/configuration.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { useContext } from 'react'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { ExportContext } from '../ExportCompound.context'; + +export const ImageFormat = (): React.ReactNode => { + const { setImageFormats, data } = useContext(ExportContext); + const currentImageFormats = data.imageFormats; + const imageHandlers = useAppSelector(imageHandlersSelector); + const loadingConfigurationMain = useAppSelector(loadingConfigurationMainSelector); + const isPending = loadingConfigurationMain === 'pending'; + + const mappedElementAnnotations = Object.entries(imageHandlers) + .filter(([, handler]) => Boolean(handler)) + .map(([name, handler]) => ({ + id: handler, + label: name, + })); + + return ( + <CollapsibleSection title="Image format"> + {isPending && <p>Loading...</p>} + {!isPending && mappedElementAnnotations.length > ZERO && ( + <CheckboxFilter + options={mappedElementAnnotations} + currentOptions={currentImageFormats} + onCheckedChange={setImageFormats} + type="radio" + isSearchEnabled={false} + /> + )} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4a3f82e0b569abf113ad6cd986ec01895009a2c --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageFormat/index.ts @@ -0,0 +1 @@ +export { ImageFormat } from './ImageFormat.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8affdcd88d2778baffcfd7932cb46c13b3784dc7 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.component.test.tsx @@ -0,0 +1,151 @@ +/* eslint-disable no-magic-numbers */ +import { StoreType } from '@/redux/store'; +import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Export } from '../ExportCompound.component'; +import { ImageSize } from './ImageSize.component'; +import { ImageSize as ImageSizeType } from './ImageSize.types'; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + + return ( + render( + <Wrapper> + <Export> + <ImageSize /> + </Export> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ImageSize - component', () => { + describe('width input', () => { + it('renders input with valid value', () => { + renderComponent(); + + const widthInput: HTMLInputElement = screen.getByLabelText('export graphics width input'); + expect(widthInput).toBeInTheDocument(); + expect(widthInput.value).toBe('600'); + }); + + // MAX_WIDTH 600 + // MAX_HEIGHT 200 + const widthCases: [number, ImageSizeType][] = [ + [ + // default + 600, + { + width: 600, + height: 200, + }, + ], + [ + // aspect ratio test + 100, + { + width: 100, + height: 33, + }, + ], + [ + // transform to integer + 120.2137, + { + width: 120, + height: 40, + }, + ], + [ + // max width + 997, + { + width: 600, + height: 200, + }, + ], + ]; + + it.each(widthCases)( + 'handles input events by setting correct values', + async (newWidth, newImageSize) => { + renderComponent(); + + const widthInput: HTMLInputElement = screen.getByLabelText('export graphics width input'); + const heightInput: HTMLInputElement = screen.getByLabelText('export graphics height input'); + + fireEvent.change(widthInput, { target: { value: `${newWidth}` } }); + + expect(widthInput).toHaveValue(newImageSize.width); + expect(heightInput).toHaveValue(newImageSize.height); + }, + ); + }); + + describe('height input', () => { + it('renders input', () => { + renderComponent(); + + const heightInput: HTMLInputElement = screen.getByLabelText('export graphics height input'); + expect(heightInput).toBeInTheDocument(); + expect(heightInput.value).toBe('200'); + }); + + // MAX_WIDTH 600 + // MAX_HEIGHT 200 + const heightCases: [number, ImageSizeType][] = [ + [ + // default + 200, + { + width: 600, + height: 200, + }, + ], + [ + // aspect ratio test + 100, + { + width: 300, + height: 100, + }, + ], + [ + // transform to integer + 120.2137, + { + width: 361, + height: 120, + }, + ], + [ + // max height + 997, + { + width: 600, + height: 200, + }, + ], + ]; + + it.each(heightCases)( + 'handles input events by setting correct values', + async (newHeight, newImageSize) => { + renderComponent(); + + const widthInput: HTMLInputElement = screen.getByLabelText('export graphics width input'); + const heightInput: HTMLInputElement = screen.getByLabelText('export graphics height input'); + + fireEvent.change(heightInput, { target: { value: `${newHeight}` } }); + + expect(widthInput).toHaveValue(newImageSize.width); + expect(heightInput).toHaveValue(newImageSize.height); + }, + ); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1a66c44e45e0895fb78380222618fa74666cb59b --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.component.tsx @@ -0,0 +1,37 @@ +import { CollapsibleSection } from '../../CollapsibleSection'; +import { useImageSize } from './utils/useImageSize'; + +export const ImageSize = (): React.ReactNode => { + const { width, height, handleChangeHeight, handleChangeWidth } = useImageSize(); + + return ( + <CollapsibleSection title="Image size"> + <div className="flex flex-col gap-4"> + <label className="flex h-9 items-center gap-4"> + <span className="w-12">Width: </span> + <input + className="w-full rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + name="width" + value={width} + type="number" + aria-label="export graphics width input" + onChange={(e): void => { + handleChangeWidth(Number(e.target.value)); + }} + /> + </label> + <label className="flex h-9 items-center gap-4"> + <span className="w-12">Height: </span> + <input + className="w-full rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + name="height" + value={height} + type="number" + aria-label="export graphics height input" + onChange={(e): void => handleChangeHeight(Number(e.target.value))} + /> + </label> + </div> + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.constants.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..c60ea1d751868d12d199b243da2087c828975739 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.constants.ts @@ -0,0 +1,14 @@ +import { ImageSize, ModelAspectRatios } from './ImageSize.types'; + +export const DEFAULT_IMAGE_WIDTH = 600; +export const DEFAULT_IMAGE_HEIGHT = 200; + +export const DEFAULT_IMAGE_SIZE: ImageSize = { + width: DEFAULT_IMAGE_WIDTH, + height: DEFAULT_IMAGE_HEIGHT, +}; + +export const DEFAULT_MODEL_ASPECT_RATIOS: ModelAspectRatios = { + vertical: DEFAULT_IMAGE_HEIGHT / DEFAULT_IMAGE_WIDTH, + horizontal: DEFAULT_IMAGE_WIDTH / DEFAULT_IMAGE_HEIGHT, +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.types.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..a27bad68532fa7cf1974c9ef3892bffb0812fa12 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/ImageSize.types.ts @@ -0,0 +1,9 @@ +export interface ImageSize { + width: number; + height: number; +} + +export interface ModelAspectRatios { + vertical: number; + horizontal: number; +} diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..42d58d9a6d287540e3888c4532f2a7eaf008552a --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/index.ts @@ -0,0 +1 @@ +export { ImageSize } from './ImageSize.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useExportGraphicsSelectedModel.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useExportGraphicsSelectedModel.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20ac3edf4839feec27757384638b0323ad8d4ace --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useExportGraphicsSelectedModel.test.tsx @@ -0,0 +1,98 @@ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; +import { renderHook } from '@testing-library/react'; +import { EXPORT_CONTEXT_DEFAULT_VALUE } from '../../ExportCompound.constant'; +import { getExportContextWithReduxWrapper } from '../../utils/getExportContextWithReduxWrapper'; +import { useExportGraphicsSelectedModel } from './useExportGraphicsSelectedModel'; + +describe('useExportGraphicsSelectedModel - util', () => { + describe('when current selected models is empty', () => { + const { Wrapper } = getExportContextWithReduxWrapper( + { + ...EXPORT_CONTEXT_DEFAULT_VALUE, + data: { + ...EXPORT_CONTEXT_DEFAULT_VALUE.data, + models: [], + }, + }, + { + models: { + data: [], + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }, + ); + + const { result } = renderHook(() => useExportGraphicsSelectedModel(), { + wrapper: Wrapper, + }); + + it('should return undefined', () => { + expect(result.current).toBeUndefined(); + }); + }); + + describe('when current selected models has one element', () => { + describe('when redux models has selected model', () => { + const selectedModel = MODELS_MOCK_SHORT[FIRST_ARRAY_ELEMENT]; + + const { Wrapper } = getExportContextWithReduxWrapper( + { + ...EXPORT_CONTEXT_DEFAULT_VALUE, + data: { + ...EXPORT_CONTEXT_DEFAULT_VALUE.data, + models: [ + { + id: selectedModel.idObject.toString(), + label: selectedModel.name, + }, + ], + }, + }, + { + models: { + data: MODELS_MOCK_SHORT, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }, + ); + + const { result } = renderHook(() => useExportGraphicsSelectedModel(), { + wrapper: Wrapper, + }); + + it('should return valid model from redux', () => { + expect(result.current).toEqual(selectedModel); + }); + }); + + describe('when redux models has not selected model', () => { + const { Wrapper } = getExportContextWithReduxWrapper( + { + ...EXPORT_CONTEXT_DEFAULT_VALUE, + data: { + ...EXPORT_CONTEXT_DEFAULT_VALUE.data, + models: [], + }, + }, + { + models: { + data: MODELS_MOCK_SHORT, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }, + ); + + const { result } = renderHook(() => useExportGraphicsSelectedModel(), { + wrapper: Wrapper, + }); + + it('should return undefined', () => { + expect(result.current).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useExportGraphicsSelectedModel.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useExportGraphicsSelectedModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ee35b4c5e7f3f698e20bcc2563ca9883ec21212 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useExportGraphicsSelectedModel.ts @@ -0,0 +1,18 @@ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { modelsDataSelector } from '@/redux/models/models.selectors'; +import { MapModel } from '@/types/models'; +import { useContext } from 'react'; +import { useSelector } from 'react-redux'; +import { ExportContext } from '../../ExportCompound.context'; + +export const useExportGraphicsSelectedModel = (): MapModel | undefined => { + const { data } = useContext(ExportContext); + const currentSelectedModelId = data.models?.[FIRST_ARRAY_ELEMENT]?.id; + const models = useSelector(modelsDataSelector); + + if (!currentSelectedModelId) { + return undefined; + } + + return models.find(model => model.idObject === Number(currentSelectedModelId)); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useImageSize.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useImageSize.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a182496ec28a34e4a70556cf03a87ac885a16b83 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useImageSize.test.ts @@ -0,0 +1,230 @@ +/* eslint-disable no-magic-numbers */ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; +import { renderHook } from '@testing-library/react'; +import { EXPORT_CONTEXT_DEFAULT_VALUE } from '../../ExportCompound.constant'; +import { getExportContextWithReduxWrapper } from '../../utils/getExportContextWithReduxWrapper'; +import { DEFAULT_IMAGE_SIZE } from '../ImageSize.constants'; +import { ImageSize } from '../ImageSize.types'; +import { useImageSize } from './useImageSize'; + +describe('useImageSize - hook', () => { + describe('when there is no selected model', () => { + const { Wrapper } = getExportContextWithReduxWrapper( + { + ...EXPORT_CONTEXT_DEFAULT_VALUE, + data: { + ...EXPORT_CONTEXT_DEFAULT_VALUE.data, + models: [], + }, + }, + { + models: { + data: [], + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }, + ); + + const { result } = renderHook(() => useImageSize(), { + wrapper: Wrapper, + }); + + it('should should return default image size', () => { + const { width, height } = result.current || {}; + expect({ width, height }).toEqual(DEFAULT_IMAGE_SIZE); + }); + }); + + describe('when there is a selected model', () => { + const selectedModel = MODELS_MOCK_SHORT[FIRST_ARRAY_ELEMENT]; + const setImageSize = jest.fn(); + + const { Wrapper } = getExportContextWithReduxWrapper( + { + ...EXPORT_CONTEXT_DEFAULT_VALUE, + setImageSize, + data: { + ...EXPORT_CONTEXT_DEFAULT_VALUE.data, + models: [ + { + id: selectedModel.idObject.toString(), + label: selectedModel.name, + }, + ], + imageSize: DEFAULT_IMAGE_SIZE, + }, + }, + { + models: { + data: MODELS_MOCK_SHORT, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }, + ); + + renderHook(() => useImageSize(), { + wrapper: Wrapper, + }); + + it('should should set size of selected model', () => { + expect(setImageSize).toHaveBeenCalledWith({ + width: 26779, + height: 13503, + }); + }); + }); + + describe('when always', () => { + describe('handleChangeHeight', () => { + const selectedModel = MODELS_MOCK_SHORT[FIRST_ARRAY_ELEMENT]; + const setImageSize = jest.fn(); + + const { Wrapper } = getExportContextWithReduxWrapper( + { + ...EXPORT_CONTEXT_DEFAULT_VALUE, + setImageSize, + data: { + ...EXPORT_CONTEXT_DEFAULT_VALUE.data, + models: [ + { + id: selectedModel.idObject.toString(), + label: selectedModel.name, + }, + ], + imageSize: DEFAULT_IMAGE_SIZE, + }, + }, + { + models: { + data: MODELS_MOCK_SHORT, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }, + ); + + const { + result: { + current: { handleChangeHeight }, + }, + } = renderHook(() => useImageSize(), { + wrapper: Wrapper, + }); + + // MAX_WIDTH 26779.25 + // MAX_HEIGHT 13503.0 + + const heightCases: [number, ImageSize][] = [ + [ + // aspect ratio test + 1000, + { + width: 1983, + height: 1000, + }, + ], + [ + // transform to integer + 997.2137, + { + width: 1978, + height: 997, + }, + ], + [ + // max height + 26779000, + { + width: 26779, + height: 13503, + }, + ], + ]; + + it.each(heightCases)( + 'should set valid height and width values', + (newHeight, newImageSize) => { + handleChangeHeight(newHeight); + + expect(setImageSize).toHaveBeenLastCalledWith(newImageSize); + }, + ); + }); + + describe('handleChangeWidth', () => { + const selectedModel = MODELS_MOCK_SHORT[FIRST_ARRAY_ELEMENT]; + const setImageSize = jest.fn(); + + const { Wrapper } = getExportContextWithReduxWrapper( + { + ...EXPORT_CONTEXT_DEFAULT_VALUE, + setImageSize, + data: { + ...EXPORT_CONTEXT_DEFAULT_VALUE.data, + models: [ + { + id: selectedModel.idObject.toString(), + label: selectedModel.name, + }, + ], + imageSize: DEFAULT_IMAGE_SIZE, + }, + }, + { + models: { + data: MODELS_MOCK_SHORT, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }, + ); + + const { + result: { + current: { handleChangeWidth }, + }, + } = renderHook(() => useImageSize(), { + wrapper: Wrapper, + }); + + // MAX_WIDTH 26779.25 + // MAX_HEIGHT 13503.0 + + const widthCases: [number, ImageSize][] = [ + [ + // aspect ratio test + 1000, + { + width: 1000, + height: 504, + }, + ], + [ + // transform to integer + 997.2137, + { + width: 997, + height: 503, + }, + ], + [ + // max width + 26779000, + { + width: 26779, + height: 13503, + }, + ], + ]; + + it.each(widthCases)('should set valid height and width values', (newWidth, newImageSize) => { + handleChangeWidth(newWidth); + + expect(setImageSize).toHaveBeenLastCalledWith(newImageSize); + }); + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useImageSize.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useImageSize.ts new file mode 100644 index 0000000000000000000000000000000000000000..f36afdd3864b7a7d284103b19c6da8c8641f20d3 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useImageSize.ts @@ -0,0 +1,88 @@ +import { MapModel } from '@/types/models'; +import { numberToSafeInt } from '@/utils/number/numberToInt'; +import { useCallback, useContext, useEffect } from 'react'; +import { ExportContext } from '../../ExportCompound.context'; +import { DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_WIDTH } from '../ImageSize.constants'; +import { ImageSize } from '../ImageSize.types'; +import { useExportGraphicsSelectedModel } from './useExportGraphicsSelectedModel'; +import { getModelAspectRatios } from './useModelAspectRatios'; + +interface UseImageSizeResults { + handleChangeWidth(width: number): void; + handleChangeHeight(height: number): void; + width: number; + height: number; +} + +export const useImageSize = (): UseImageSizeResults => { + const selectedModel = useExportGraphicsSelectedModel(); + const aspectRatios = getModelAspectRatios(selectedModel); + const { data, setImageSize } = useContext(ExportContext); + const { imageSize } = data; + const maxWidth = selectedModel?.width || DEFAULT_IMAGE_WIDTH; + const maxHeight = selectedModel?.height || DEFAULT_IMAGE_HEIGHT; + + const getNormalizedImageSize = useCallback( + (newImageSize: ImageSize): ImageSize => { + const newWidth = newImageSize.width; + const newHeight = newImageSize.height; + + const widthMinMax = Math.min(maxWidth, newWidth); + const heightMinMax = Math.min(maxHeight, newHeight); + + const widthInt = numberToSafeInt(widthMinMax); + const heightInt = numberToSafeInt(heightMinMax); + + return { + width: widthInt, + height: heightInt, + }; + }, + [maxWidth, maxHeight], + ); + + const setDefaultModelImageSize = useCallback( + (model: MapModel): void => { + const newImageSize = getNormalizedImageSize({ + width: model.width, + height: model.height, + }); + + setImageSize(newImageSize); + }, + [getNormalizedImageSize, setImageSize], + ); + + const handleChangeWidth = (width: number): void => { + const newImageSize = getNormalizedImageSize({ + width, + height: width / aspectRatios.horizontal, + }); + + setImageSize(newImageSize); + }; + + const handleChangeHeight = (height: number): void => { + const newImageSize = getNormalizedImageSize({ + height, + width: height / aspectRatios.vertical, + }); + + setImageSize(newImageSize); + }; + + useEffect(() => { + if (!selectedModel) { + return; + } + + setDefaultModelImageSize(selectedModel); + }, [setDefaultModelImageSize, selectedModel]); + + return { + handleChangeWidth, + handleChangeHeight, + width: imageSize.width, + height: imageSize.height, + }; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useModelAspectRatios.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useModelAspectRatios.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..42468de1d2911fb6f17ccca659874d892eaf7ab3 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useModelAspectRatios.test.ts @@ -0,0 +1,45 @@ +import { MapModel } from '@/types/models'; +import { DEFAULT_MODEL_ASPECT_RATIOS } from '../ImageSize.constants'; +import { ModelAspectRatios } from '../ImageSize.types'; +import { getModelAspectRatios } from './useModelAspectRatios'; + +describe('useModelAspectRatios - hook', () => { + describe('when model is not present', () => { + const model = undefined; + + it('should return default model aspect ratio', () => { + const result = getModelAspectRatios(model); + expect(result).toEqual(DEFAULT_MODEL_ASPECT_RATIOS); + }); + }); + + describe('when model is present', () => { + const modelCases: [Pick<MapModel, 'width' | 'height'>, ModelAspectRatios][] = [ + [ + { + width: 1000, + height: 500, + }, + { + vertical: 0.5, + horizontal: 2, + }, + ], + [ + { + width: 4200, + height: 420, + }, + { + vertical: 0.1, + horizontal: 10, + }, + ], + ]; + + it.each(modelCases)('should return valid model aspect ratio', (model, expectedResult) => { + const result = getModelAspectRatios(model); + expect(result).toEqual(expectedResult); + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useModelAspectRatios.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useModelAspectRatios.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4f23862eac2349a5f2e74ca217e03a06806d320 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ImageSize/utils/useModelAspectRatios.ts @@ -0,0 +1,16 @@ +import { MapModel } from '@/types/models'; +import { DEFAULT_MODEL_ASPECT_RATIOS } from '../ImageSize.constants'; +import { ModelAspectRatios } from '../ImageSize.types'; + +export const getModelAspectRatios = ( + model: Pick<MapModel, 'width' | 'height'> | undefined, +): ModelAspectRatios => { + if (!model) { + return DEFAULT_MODEL_ASPECT_RATIOS; + } + + return { + vertical: model.height / model.width, + horizontal: model.width / model.height, + }; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx index 40eac4ac4dcc817bff61a92139f7bcb24fba7c5b..4ef2cee4762cb803993095517e88c929323e056d 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx @@ -1,17 +1,18 @@ -import { useContext } from 'react'; +import { ZERO } from '@/constants/common'; import { compartmentPathwaysDataSelector, loadingCompartmentPathwaysSelector, } from '@/redux/compartmentPathways/compartmentPathways.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { ZERO } from '@/constants/common'; +import { useContext } from 'react'; import { CheckboxFilter } from '../../CheckboxFilter'; import { CollapsibleSection } from '../../CollapsibleSection'; import { ExportContext } from '../ExportCompound.context'; import { getCompartmentPathwaysCheckboxElements } from '../utils/getCompartmentPathwaysCheckboxElements'; export const IncludedCompartmentPathways = (): React.ReactNode => { - const { setIncludedCompartmentPathways } = useContext(ExportContext); + const { setIncludedCompartmentPathways, data } = useContext(ExportContext); + const currentIncludedCompartmentPathways = data.includedCompartmentPathways; const loadingCompartmentPathways = useAppSelector(loadingCompartmentPathwaysSelector); const isPending = loadingCompartmentPathways === 'pending'; const compartmentPathways = useAppSelector(compartmentPathwaysDataSelector); @@ -23,6 +24,7 @@ export const IncludedCompartmentPathways = (): React.ReactNode => { {!isPending && checkboxElements && checkboxElements.length > ZERO && ( <CheckboxFilter options={checkboxElements} + currentOptions={currentIncludedCompartmentPathways} onCheckedChange={setIncludedCompartmentPathways} /> )} diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/Submap.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/Submap.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2374285dc947b4e0fa4c66e0ab8384f2ab58571f --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/Submap.component.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable no-magic-numbers */ +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { Submap } from './Submap.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Submap /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const CHECKBOX_ELEMENT_NAME = modelsFixture[0].name; + +describe('Submap - component', () => { + it('should display submaps checkboxes when fetching data is successful', async () => { + renderComponent({ + models: { + data: modelsFixture, + 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('Submap')).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 submaps checkboxes when fetching data fails', async () => { + renderComponent({ + models: { + data: [], + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Submap')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + it('should not display submaps checkboxes when fetched data is empty', async () => { + renderComponent({ + models: { + data: [], + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Submap')).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({ + models: { + data: [], + loading: 'pending', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Submap')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/Submap.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/Submap.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c5590ce5c669473922f966cfdfcc13df4269f77 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/Submap.component.tsx @@ -0,0 +1,34 @@ +import { ZERO } from '@/constants/common'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { loadingModelsSelector, modelsDataSelector } from '@/redux/models/models.selectors'; +import { useContext } from 'react'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { ExportContext } from '../ExportCompound.context'; + +export const Submap = (): React.ReactNode => { + const { setModels, data } = useContext(ExportContext); + const currentSelectedModels = data.models; + const models = useAppSelector(modelsDataSelector); + const loadingModels = useAppSelector(loadingModelsSelector); + const isPending = loadingModels === 'pending'; + + const mappedElementAnnotations = models.map(({ idObject, name }) => ({ + id: `${idObject}`, + label: name, + })); + + return ( + <CollapsibleSection title="Submap"> + {isPending && <p>Loading...</p>} + {!isPending && mappedElementAnnotations && mappedElementAnnotations.length > ZERO && ( + <CheckboxFilter + options={mappedElementAnnotations} + currentOptions={currentSelectedModels} + onCheckedChange={setModels} + type="radio" + /> + )} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..89fa39d0a0caeb34a8012e61e25b2b9fa9a1b7ed --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Submap/index.ts @@ -0,0 +1 @@ +export { Submap } from './Submap.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts index 1994a6654a7d14190c3d170502de718ee4130c09..e0cc82546fda54de629e92dbf24fa4e68d41de0e 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts @@ -1,4 +1,4 @@ -import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.types'; export const extractAndParseNumberIdFromCompartment = (compartment: CheckboxItem): number => { const [, id] = compartment.id.split('-'); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts index 0da338206a975e41432e619c0d238d9ae6d7f285..6cfb3494a9668d99be8fb6ac5152fa01bd1f5486 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts @@ -1,4 +1,4 @@ -import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.types'; import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; type DownloadBodyRequest = { diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getExportContextWithReduxWrapper.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getExportContextWithReduxWrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1d84ad2413c15fac273dbc6995b2dade33ad7928 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getExportContextWithReduxWrapper.tsx @@ -0,0 +1,46 @@ +/* eslint-disable react/no-multi-comp */ +import { StoreType } from '@/redux/store'; +import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { EXPORT_CONTEXT_DEFAULT_VALUE } from '../ExportCompound.constant'; +import { ExportContext } from '../ExportCompound.context'; +import { ExportContextType } from '../ExportCompound.types'; + +interface WrapperProps { + children: React.ReactNode; +} + +export type ComponentWrapper = ({ children }: WrapperProps) => JSX.Element; + +export type GetExportContextWithReduxWrapper = ( + contextValue?: ExportContextType, + initialState?: InitialStoreState, +) => { + Wrapper: ComponentWrapper; + store: StoreType; +}; + +export const getExportContextWithReduxWrapper: GetExportContextWithReduxWrapper = ( + contextValue, + initialState, +) => { + const { Wrapper: ReduxWrapper, store } = getReduxWrapperWithStore(initialState); + + const ContextWrapper: ComponentWrapper = ({ children }) => { + return ( + <ExportContext.Provider value={contextValue || EXPORT_CONTEXT_DEFAULT_VALUE}> + {children} + </ExportContext.Provider> + ); + }; + + const ContextWrapperWithRedux: ComponentWrapper = ({ children }) => { + return ( + <ReduxWrapper> + <ContextWrapper>{children}</ContextWrapper> + </ReduxWrapper> + ); + }; + + return { Wrapper: ContextWrapperWithRedux, store }; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getGraphicsDownloadUrl.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getGraphicsDownloadUrl.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e6c4db9026bd78b950223fa64e5b3afe5161606 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getGraphicsDownloadUrl.test.ts @@ -0,0 +1,42 @@ +import { BASE_API_URL, PROJECT_ID } from '@/constants'; +import { GetGraphicsDownloadUrlProps, getGraphicsDownloadUrl } from './getGraphicsDownloadUrl'; + +describe('getGraphicsDownloadUrl - util', () => { + const cases: [GetGraphicsDownloadUrlProps, string | undefined][] = [ + [{}, undefined], + [ + { + backgroundId: 50, + }, + undefined, + ], + [ + { + backgroundId: 50, + modelId: '30', + }, + undefined, + ], + [ + { + backgroundId: 50, + modelId: '30', + handler: 'any.handler.image', + }, + undefined, + ], + [ + { + backgroundId: 50, + modelId: '30', + handler: 'any.handler.image', + zoom: 7, + }, + `${BASE_API_URL}/projects/${PROJECT_ID}/models/30:downloadImage?backgroundOverlayId=50&handlerClass=any.handler.image&zoomLevel=7`, + ], + ]; + + it.each(cases)('should return valid result', (input, result) => { + expect(getGraphicsDownloadUrl(input)).toBe(result); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getGraphicsDownloadUrl.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getGraphicsDownloadUrl.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4020e9899f75685c7b732db23dd246077d433e7 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getGraphicsDownloadUrl.ts @@ -0,0 +1,26 @@ +import { BASE_API_URL, PROJECT_ID } from '@/constants'; + +export interface GetGraphicsDownloadUrlProps { + backgroundId?: number; + modelId?: string; + handler?: string; + zoom?: number; +} + +export const getGraphicsDownloadUrl = ({ + backgroundId, + modelId, + handler, + zoom, +}: GetGraphicsDownloadUrlProps): string | undefined => { + const isAllElementsTruthy = [backgroundId, modelId, handler, zoom].reduce( + (a, b) => Boolean(a) && Boolean(b), + true, + ); + + if (!isAllElementsTruthy) { + return undefined; + } + + return `${BASE_API_URL}/projects/${PROJECT_ID}/models/${modelId}:downloadImage?backgroundOverlayId=${backgroundId}&handlerClass=${handler}&zoomLevel=${zoom}`; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getModelExportZoom.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getModelExportZoom.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..247ce802c96c25153d55fce4fc07a4eea5bbc431 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getModelExportZoom.test.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-magic-numbers */ +import { FIRST_ARRAY_ELEMENT, ZERO } from '@/constants/common'; +import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; +import { getModelExportZoom } from './getModelExportZoom'; + +describe('getModelExportZoom - util', () => { + describe('when there is no model', () => { + const model = undefined; + const exportWidth = 100; + + it('should return return zero', () => { + expect(getModelExportZoom(exportWidth, model)).toBe(ZERO); + }); + }); + + // Math.log2 of zero is -Infty + describe('when model width is zero', () => { + const model = { + ...MODELS_MOCK_SHORT[FIRST_ARRAY_ELEMENT], + width: 0, + }; + const exportWidth = 100; + + it('should return return zero', () => { + expect(getModelExportZoom(exportWidth, model)).toBe(ZERO); + }); + }); + + describe('when model is present and model width > ZERO', () => { + const model = MODELS_MOCK_SHORT[FIRST_ARRAY_ELEMENT]; + + // MAX_WIDTH 26779.25 + // [zoom, width] + const cases: [number, number][] = [ + [2, 100], // MIN ZOOM + [2.7142, 420], + [4.5391, 1488], + [9, 80000000], // MAX ZOOM + ]; + + it.each(cases)('should return export zoom=%s for width=%s', (zoom, width) => { + expect(getModelExportZoom(width, model)).toBeCloseTo(zoom); + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getModelExportZoom.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getModelExportZoom.ts new file mode 100644 index 0000000000000000000000000000000000000000..c198def10be36fe02577193626180f285ede6e9b --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getModelExportZoom.ts @@ -0,0 +1,22 @@ +import { ZERO } from '@/constants/common'; +import { MapModel } from '@/types/models'; + +const ZOOM_BASE = 6; + +/* + * Width of exported image for zoom=1 is 128, for zoom=2 is 256, for zoom=3 is 1024 + * So zoom level holds pattern of log2(width) with base of log2(128)=7 + * Zoom base defined in this file is 6 as we need to provide minumum zoom of 1 + */ + +export const getModelExportZoom = (exportWidth: number, model?: MapModel): number => { + // log2 of zero is -Infty + if (!model || model.width === ZERO) { + return ZERO; + } + + const { maxZoom, minZoom } = model; + const exportZoom = Math.log2(exportWidth) - ZOOM_BASE; + + return Math.min(Math.max(exportZoom, minZoom), maxZoom); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts index 9f83d70582058f6d5d5bd6038ec00c3858e5c65b..eadc0e8c0b9091093d92f3f0b5acf587770b036d 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts @@ -1,4 +1,4 @@ -import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.types'; import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; type DownloadBodyRequest = { diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx index 1d98f663a79aae7c2dbe4b2e954c58c32536794f..408b68c779a1f5a9e488e662ca24c9a3c0120b0a 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx @@ -1,14 +1,15 @@ +import { getCompartmentPathways } from '@/redux/compartmentPathways/compartmentPathways.thunks'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { modelsIdsSelector } from '@/redux/models/models.selectors'; import { DrawerHeading } from '@/shared/DrawerHeading'; -import { getCompartmentPathways } from '@/redux/compartmentPathways/compartmentPathways.thunks'; import { useEffect, useState } from 'react'; -import { TabNavigator } from './TabNavigator'; import { Elements } from './Elements'; +import { Graphics } from './Graphics'; +import { Network } from './Network'; +import { TabNavigator } from './TabNavigator'; import { TAB_NAMES } from './TabNavigator/TabNavigator.constants'; import { TabNames } from './TabNavigator/TabNavigator.types'; -import { Network } from './Network'; export const ExportDrawer = (): React.ReactNode => { const modelsIds = useAppSelector(modelsIdsSelector); @@ -30,7 +31,7 @@ export const ExportDrawer = (): React.ReactNode => { <TabNavigator activeTab={activeTab} onTabChange={handleTabChange} /> {activeTab === TAB_NAMES.ELEMENTS && <Elements />} {activeTab === TAB_NAMES.NETWORK && <Network />} - {activeTab === TAB_NAMES.GRAPHICS && <div>Graphics</div>} + {activeTab === TAB_NAMES.GRAPHICS && <Graphics />} </div> </div> ); diff --git a/src/components/Map/Drawer/ExportDrawer/Graphics/Graphics.component.tsx b/src/components/Map/Drawer/ExportDrawer/Graphics/Graphics.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..547c2c14ed2b487a38bb5773552aa386ef84fadd --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Graphics/Graphics.component.tsx @@ -0,0 +1,14 @@ +import { Export } from '../ExportCompound'; + +export const Graphics = (): React.ReactNode => { + return ( + <div data-testid="graphics-tab"> + <Export> + <Export.Submap /> + <Export.ImageSize /> + <Export.ImageFormat /> + <Export.DownloadGraphics /> + </Export> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Graphics/index.ts b/src/components/Map/Drawer/ExportDrawer/Graphics/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e82824ec6623b9e6e88f9c7b1374b449878e1fa4 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Graphics/index.ts @@ -0,0 +1 @@ +export { Graphics } from './Graphics.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx index 67321198348d16a13f633d31936ed28329b567f9..4bf77ac6c6bfd29c2bc9d3d2212198fedfee3f91 100644 --- a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx @@ -1,18 +1,19 @@ /* eslint-disable no-magic-numbers */ +import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { apiPath } from '@/redux/apiPath'; +import { CONFIGURATION_INITIAL_STORE_MOCK } from '@/redux/configuration/configuration.mock'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; import { AppDispatch, RootState } from '@/redux/store'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; import { InitialStoreState } from '@/utils/testing/getReduxWrapperWithStore'; import { render, screen } from '@testing-library/react'; -import { CONFIGURATION_INITIAL_STORE_MOCK } from '@/redux/configuration/configuration.mock'; -import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { HttpStatusCode } from 'axios'; import { act } from 'react-dom/test-utils'; -import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; -import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; import { MockStoreEnhanced } from 'redux-mock-store'; -import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; -import { modelsFixture } from '@/models/fixtures/modelsFixture'; -import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; -import { apiPath } from '@/redux/apiPath'; -import { HttpStatusCode } from 'axios'; import { NETWORK_COLUMNS } from '../ExportCompound/ExportCompound.constant'; import { Network } from './Network.component'; @@ -37,6 +38,7 @@ const renderComponent = ( describe('Network - component', () => { it('should render all network sections', () => { renderComponent({ + ...INITIAL_STORE_STATE_MOCK, configuration: { ...CONFIGURATION_INITIAL_STORE_MOCK, main: { @@ -94,6 +96,7 @@ describe('Network - component', () => { const SECOND_COMPARMENT_PATHWAY_NAME = compartmentPathwaysDetailsFixture[1].name; const SECOND_COMPARMENT_PATHWAY_ID = compartmentPathwaysDetailsFixture[1].id; const { store } = renderComponent({ + ...INITIAL_STORE_STATE_MOCK, configuration: { ...CONFIGURATION_INITIAL_STORE_MOCK, main: { diff --git a/src/models/mocks/configurationFormatsMock.ts b/src/models/mocks/configurationFormatsMock.ts index 8d6d4e8afa75491a2fcafee702da4320a633f1b9..34ec36a2e7bc287f63d45eef763b1c0b22c21637 100644 --- a/src/models/mocks/configurationFormatsMock.ts +++ b/src/models/mocks/configurationFormatsMock.ts @@ -29,3 +29,23 @@ export const CONFIGURATION_FORMATS_MOCK: ConfigurationFormatSchema[] = [ extension: 'gpml', }, ]; + +export const CONFIGURATION_IMAGE_FORMATS_TYPES_MOCK: string[] = ['PNG image', 'PDF', 'SVG image']; + +export const CONFIGURATION_IMAGE_FORMATS_MOCK: ConfigurationFormatSchema[] = [ + { + name: 'PNG image', + handler: 'lcsb.mapviewer.converter.graphics.PngImageGenerator', + extension: 'png', + }, + { + name: 'PDF', + handler: 'lcsb.mapviewer.converter.graphics.PdfImageGenerator', + extension: 'pdf', + }, + { + name: 'SVG image', + handler: 'lcsb.mapviewer.converter.graphics.SvgImageGenerator', + extension: 'svg', + }, +]; diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 212660e5ee66c695f389fd6c49b7168b6ec78ecb..c6cb7455da5915358e1eb1314cc5bd2c849475ed 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -1,6 +1,6 @@ import { PROJECT_ID } from '@/constants'; -import { PerfectSearchParams } from '@/types/search'; import { Point } from '@/types/map'; +import { PerfectSearchParams } from '@/types/search'; export const apiPath = { getBioEntityContentsStringWithQuery: ({ diff --git a/src/redux/backgrounds/background.selectors.ts b/src/redux/backgrounds/background.selectors.ts index b8443ab5545da2f894ca43fe284669b56fcdf038..b815cf59b879e5cf3460cbeb092bef85612ae065 100644 --- a/src/redux/backgrounds/background.selectors.ts +++ b/src/redux/backgrounds/background.selectors.ts @@ -1,12 +1,12 @@ -import { createSelector } from '@reduxjs/toolkit'; import { EMPTY_BACKGROUND_NAME } from '@/constants/backgrounds'; +import { createSelector } from '@reduxjs/toolkit'; import { mapDataSelector } from '../map/map.selectors'; import { rootSelector } from '../root/root.selectors'; export const backgroundsSelector = createSelector(rootSelector, state => state.backgrounds); export const backgroundsDataSelector = createSelector(backgroundsSelector, backgrounds => { - return backgrounds.data; + return backgrounds?.data || []; }); const MAIN_BACKGROUND = 0; diff --git a/src/redux/configuration/configuration.constants.ts b/src/redux/configuration/configuration.constants.ts index 56448d47fce6e44bf31adfe19acd78b25b340453..bcf3c906f5ab72e0012749ad77fe2d710e2cd866 100644 --- a/src/redux/configuration/configuration.constants.ts +++ b/src/redux/configuration/configuration.constants.ts @@ -15,3 +15,7 @@ export const GPML_HANDLER_NAME_ID = 'GPML'; export const SBML_HANDLER_NAME_ID = 'SBML'; export const CELL_DESIGNER_SBML_HANDLER_NAME_ID = 'CellDesigner SBML'; export const SBGN_ML_HANDLER_NAME_ID = 'SBGN-ML'; + +export const PNG_IMAGE_HANDLER_NAME_ID = 'PNG image'; +export const PDF_HANDLER_NAME_ID = 'PDF'; +export const SVG_IMAGE_HANDLER_NAME_ID = 'SVG image'; diff --git a/src/redux/configuration/configuration.selectors.ts b/src/redux/configuration/configuration.selectors.ts index e4bf178be216aea6ab1233b87fb06b7c2f152d6c..25cbfbfa134c8e574480d310a96781e7bd0cc776 100644 --- a/src/redux/configuration/configuration.selectors.ts +++ b/src/redux/configuration/configuration.selectors.ts @@ -10,11 +10,14 @@ import { MIN_COLOR_VAL_NAME_ID, NEUTRAL_COLOR_VAL_NAME_ID, OVERLAY_OPACITY_NAME_ID, + PDF_HANDLER_NAME_ID, + PNG_IMAGE_HANDLER_NAME_ID, SBGN_ML_HANDLER_NAME_ID, SBML_HANDLER_NAME_ID, SIMPLE_COLOR_VAL_NAME_ID, + SVG_IMAGE_HANDLER_NAME_ID, } from './configuration.constants'; -import { ConfigurationHandlersIds } from './configuration.types'; +import { ConfigurationHandlersIds, ConfigurationImageHandlersIds } from './configuration.types'; const configurationSelector = createSelector(rootSelector, state => state.configuration); const configurationOptionsSelector = createSelector(configurationSelector, state => state.options); @@ -63,7 +66,7 @@ export const modelFormatsSelector = createSelector( state => state?.modelFormats, ); -export const formatsEntriesSelector = createSelector( +export const modelFormatsEntriesSelector = createSelector( modelFormatsSelector, (modelFormats): Record<string, ConfigurationFormatSchema> => { return Object.fromEntries( @@ -78,7 +81,7 @@ export const formatsEntriesSelector = createSelector( ); export const formatsHandlersSelector = createSelector( - formatsEntriesSelector, + modelFormatsEntriesSelector, (formats): ConfigurationHandlersIds => { return { [GPML_HANDLER_NAME_ID]: formats[GPML_HANDLER_NAME_ID]?.handler, @@ -89,7 +92,37 @@ export const formatsHandlersSelector = createSelector( }, ); +export const imageFormatsSelector = createSelector( + configurationMainSelector, + state => state?.imageFormats, +); + +export const imageFormatsEntriesSelector = createSelector( + imageFormatsSelector, + (modelFormats): Record<string, ConfigurationFormatSchema> => { + return Object.fromEntries( + (modelFormats || []).flat().map((format: ConfigurationFormatSchema) => [format.name, format]), + ); + }, +); + +export const imageHandlersSelector = createSelector( + imageFormatsEntriesSelector, + (formats): ConfigurationImageHandlersIds => { + return { + [PNG_IMAGE_HANDLER_NAME_ID]: formats[PNG_IMAGE_HANDLER_NAME_ID]?.handler, + [PDF_HANDLER_NAME_ID]: formats[PDF_HANDLER_NAME_ID]?.handler, + [SVG_IMAGE_HANDLER_NAME_ID]: formats[SVG_IMAGE_HANDLER_NAME_ID]?.handler, + }; + }, +); + export const miramiTypesSelector = createSelector( configurationMainSelector, state => state?.miriamTypes, ); + +export const loadingConfigurationMainSelector = createSelector( + configurationSelector, + state => state?.main?.loading, +); diff --git a/src/redux/configuration/configuration.types.ts b/src/redux/configuration/configuration.types.ts index e797b88764723d2f18b1c2c7b2898c46d3ade799..343c8b86557ada9505aa50413a2b109b2e367569 100644 --- a/src/redux/configuration/configuration.types.ts +++ b/src/redux/configuration/configuration.types.ts @@ -3,8 +3,11 @@ import { Configuration } from '@/types/models'; import { CELL_DESIGNER_SBML_HANDLER_NAME_ID, GPML_HANDLER_NAME_ID, + PDF_HANDLER_NAME_ID, + PNG_IMAGE_HANDLER_NAME_ID, SBGN_ML_HANDLER_NAME_ID, SBML_HANDLER_NAME_ID, + SVG_IMAGE_HANDLER_NAME_ID, } from './configuration.constants'; export type ConfigurationMainState = FetchDataState<Configuration>; @@ -15,3 +18,9 @@ export interface ConfigurationHandlersIds { [CELL_DESIGNER_SBML_HANDLER_NAME_ID]?: string; [SBGN_ML_HANDLER_NAME_ID]?: string; } + +export interface ConfigurationImageHandlersIds { + [PNG_IMAGE_HANDLER_NAME_ID]?: string; + [PDF_HANDLER_NAME_ID]?: string; + [SVG_IMAGE_HANDLER_NAME_ID]?: string; +} diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts index c113be0700fcc4be0e946f1e0de3241d04348fe0..652c018f1f3cdf8db33587a5c3972b3f01f87848 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -34,6 +34,9 @@ export const modelByIdSelector = createSelector( const MAIN_MAP = 0; export const mainMapModelSelector = createSelector(modelsDataSelector, models => models[MAIN_MAP]); + +export const loadingModelsSelector = createSelector(modelsSelector, state => state.loading); + export const mainMapModelDescriptionSelector = createSelector( modelsDataSelector, models => models[MAIN_MAP].description, diff --git a/src/utils/number/numberToInt.ts b/src/utils/number/numberToInt.ts new file mode 100644 index 0000000000000000000000000000000000000000..b57608e083e67663a5e6552db0d227c4cbe6319c --- /dev/null +++ b/src/utils/number/numberToInt.ts @@ -0,0 +1,10 @@ +import { ZERO } from '@/constants/common'; + +export const numberToSafeInt = (num: number): number => { + // zero or NaN + if (!num) { + return ZERO; + } + + return Number(num.toFixed(ZERO)); +};