diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000000000000000000000000000000000000..58af6267784516957b43981226f5e22bbd95820e --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,82 @@ +# Plugin Integration with Minerva + +To seamlessly integrate your plugin with Minerva, follow these guidelines to ensure smooth registration, HTML structure creation, and interaction with Minerva. + +## Registering plugin with Minerva + +Your plugin should utilize the `window.minerva.plugins.registerPlugin` method for plugin registration. When the plugin is initialized, this method should be called inside plugin initialization method. The function `window.minerva.plugins.registerPlugin` takes an object as an argument: + +```ts +{ + pluginName: string; + pluginVersion: string; + pluginUrl: string; +} +``` + +##### Usage example: + +```javascript +window.minerva.plugins.registerPlugin({ + pluginName: 'Your Plugin Name', + pluginVersion: '1.8.3', + pluginUrl: 'https://example.com/plugins/plugin.js', +}); +``` + +## Creating Plugin's HTML Structure + +The `window.minerva.plugins.registerPlugin` method returns object with `element` property which is a DOM element, allowing your plugin to append its HTML content to the DOM. Use this element to create and modify the HTML structure of your plugin. + +``` +// Plugin registration +const { element } = window.minerva.plugins.registerPlugin({ + pluginName: "Your Plugin Name", + pluginVersion: "1.0.0", + pluginUrl: "your-plugin-url", +}); + +// Modify plugin's HTML structure +const yourContent = document.createElement('div'); +yourContent.textContent = "Your Plugin Content"; +element.appendChild(yourContent); +``` + +## Interacting with Minerva + +All interactions with Minerva should happen through the `window.minerva` object. This object includes: + +- configuration: includes information about available types of elements, reactions, miriam types, configuration options, map types and so on +- methods will be added in the future + +## Example of plugin code before bundling: + +```javascript +require('../css/styles.css'); +const $ = require('jquery'); + +let pluginContainer; + +const createStructure = () => { + $( + `<div class="flex flex-col items-center p-2.5"> + <h1 class="text-lg">My plugin ${minerva.configuration.overlayTypes[0].name}</h1> + <input class="mt-2.5 p-2.5 rounded-s font-semibold outline-none border border-[#cacaca] bg-[#f7f7f8]" value="https://minerva-dev.lcsb.uni.lu/minerva"> + </div>`, + ).appendTo(pluginContainer); +}; + +function initPlugin() { + const { element } = window.minerva.plugins.registerPlugin({ + pluginName: 'perfect-plugin', + pluginVersion: '9.9.9', + pluginUrl: 'https://example.com/plugins/perfect-plugin.js', + }); + + pluginContainer = element; + + createStructure(); +} + +initPlugin(); +``` diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..248f6f8ecb2d8aa13e21e2859fbfafacc263eba2 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,27 @@ +import { MinervaConfiguration } from '@/services/pluginsManager/pluginsManager'; + +type Plugin = { + pluginName: string; + pluginVersion: string; + pluginUrl: string; +}; + +type RegisterPlugin = ({ pluginName, pluginVersion, pluginUrl }: Plugin) => { + element: HTMLDivElement; +}; + +type HashPlugin = { + pluginUrl: string; + pluginScript: string; +}; + +declare global { + interface Window { + minerva: { + configuration?: MinervaConfiguration; + plugins: { + registerPlugin: RegisterPlugin; + }; + }; + } +} diff --git a/package-lock.json b/package-lock.json index fda23e3ba417c3a63f180521727bc071a4ac8ff4..f03034da82f849c388f0ea38401273ee54e4d1ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "autoprefixer": "10.4.15", "axios": "^1.5.1", "axios-hooks": "^5.0.0", + "crypto-js": "^4.2.0", "downshift": "^8.2.3", "eslint-config-next": "13.4.19", "molart": "github:davidhoksza/MolArt", @@ -28,6 +29,8 @@ "query-string": "7.1.3", "react": "18.2.0", "react-accessible-accordion": "^5.0.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", @@ -42,6 +45,8 @@ "@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/crypto-js": "^4.2.2", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", @@ -1959,6 +1964,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "node_modules/@reduxjs/toolkit": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", @@ -2165,6 +2185,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", @@ -2245,6 +2278,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "node_modules/@types/downloadjs": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@types/downloadjs/-/downloadjs-1.4.6.tgz", @@ -4340,6 +4379,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -4929,6 +4973,16 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -11559,6 +11613,43 @@ "react-dom": "^16.3.3 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -15325,6 +15416,21 @@ "integrity": "sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ==", "dev": true }, + "@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "@reduxjs/toolkit": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", @@ -15461,6 +15567,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", @@ -15538,6 +15651,12 @@ "@babel/types": "^7.20.7" } }, + "@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "@types/downloadjs": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@types/downloadjs/-/downloadjs-1.4.6.tgz", @@ -17064,6 +17183,11 @@ "which": "^2.0.1" } }, + "crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -17524,6 +17648,16 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "requires": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -22205,6 +22339,26 @@ "integrity": "sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ==", "requires": {} }, + "react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "requires": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "requires": { + "dnd-core": "^16.0.1" + } + }, "react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", diff --git a/package.json b/package.json index e45e31b66caee289777e05e7e30301a7a8592d93..730be9f5d6988dabee2e69c54847d7ce860defb5 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "check-types": "tsc --pretty --noEmit", "prepare": "husky install", "postinstall": "husky install", - "test": "jest --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'", - "test:watch": "jest --watch --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'", - "test:ci": "jest --config ./jest.config.ts --collectCoverage --coverageDirectory=\"./coverage\" --ci --reporters=default --reporters=jest-junit --watchAll=false --passWithNoTests --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'", + "test": "jest --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$|@react-dnd|@babel|redux|react-dnd|dnd-core|react-dnd-html5-backend))'", + "test:watch": "jest --watch --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$|@react-dnd|@babel|redux|react-dnd|dnd-core|react-dnd-html5-backend))'", + "test:ci": "jest --config ./jest.config.ts --collectCoverage --coverageDirectory=\"./coverage\" --ci --reporters=default --reporters=jest-junit --watchAll=false --passWithNoTests --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$|@react-dnd|@babel|redux|react-dnd|dnd-core|react-dnd-html5-backend))'", "test:coverage": "jest --watchAll --coverage --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|.*\\.mjs$))'", "test:coveragee": "jest --coverage --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|.*\\.mjs$))'", "coverage": "open ./coverage/lcov-report/index.html", @@ -33,6 +33,7 @@ "autoprefixer": "10.4.15", "axios": "^1.5.1", "axios-hooks": "^5.0.0", + "crypto-js": "^4.2.0", "downshift": "^8.2.3", "eslint-config-next": "13.4.19", "molart": "github:davidhoksza/MolArt", @@ -42,6 +43,8 @@ "query-string": "7.1.3", "react": "18.2.0", "react-accessible-accordion": "^5.0.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", @@ -56,6 +59,8 @@ "@commitlint/config-conventional": "^17.7.0", "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^14.0.0", + "@types/crypto-js": "^4.2.2", + "@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/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f0934d79f6f54d7ea9008e43f5e84ba7f5faac04 --- /dev/null +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { DEFAULT_ERROR } from '@/constants/errors'; +import { act } from 'react-dom/test-utils'; +import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock'; +import { Modal } from '../Modal.component'; + +const mockedAxiosClient = mockNetworkResponse(); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Modal /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('EditOverlayModal - component', () => { + it('should render modal with correct data', () => { + renderComponent({ + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + expect(screen.getByLabelText('Name')).toBeVisible(); + expect(screen.getByLabelText('Description')).toBeVisible(); + expect(screen.getByTestId('overlay-name')).toHaveValue(overlayFixture.name); + expect(screen.getByTestId('overlay-description')).toHaveValue(overlayFixture.description); + }); + it('should handle input change correctly', () => { + renderComponent({ + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + const overlayNameInput: HTMLInputElement = screen.getByTestId('overlay-name'); + const overlayDescriptionInput: HTMLTextAreaElement = screen.getByTestId('overlay-description'); + + fireEvent.change(overlayNameInput, { target: { value: 'Test name' } }); + fireEvent.change(overlayDescriptionInput, { target: { value: 'Descripiton' } }); + + expect(overlayNameInput.value).toBe('Test name'); + expect(overlayDescriptionInput.value).toBe('Descripiton'); + }); + it('should handle remove user overlay', async () => { + const { store } = renderComponent({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + mockedAxiosClient + .onDelete(apiPath.removeOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, {}); + + const removeButton = screen.getByTestId('remove-button'); + expect(removeButton).toBeVisible(); + await act(() => { + removeButton.click(); + }); + + const { loading } = store.getState().overlays.removeOverlay; + expect(loading).toBe('succeeded'); + expect(removeButton).not.toBeVisible(); + }); + it('should handle save edited user overlay', async () => { + const { store } = renderComponent({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + mockedAxiosClient + .onPatch(apiPath.updateOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, overlayFixture); + + const saveButton = screen.getByTestId('save-button'); + expect(saveButton).toBeVisible(); + await act(() => { + saveButton.click(); + }); + + const { loading } = store.getState().overlays.updateOverlays; + expect(loading).toBe('succeeded'); + expect(saveButton).not.toBeVisible(); + }); + + it('should handle cancel edit user overlay', async () => { + const { store } = renderComponent({ + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + const cancelButton = screen.getByTestId('cancel-button'); + expect(cancelButton).toBeVisible(); + await act(() => { + cancelButton.click(); + }); + + const { isOpen } = store.getState().modal; + expect(isOpen).toBe(false); + expect(cancelButton).not.toBeVisible(); + }); +}); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..46091536090665c866f0df6e1c8be6e35590330a --- /dev/null +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.tsx @@ -0,0 +1,77 @@ +import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; +import { Textarea } from '@/shared/Textarea'; +import React from 'react'; +import { useEditOverlay } from './hooks/useEditOverlay'; + +export const EditOverlayModal = (): React.ReactNode => { + const { + description, + name, + handleCancelEdit, + handleDescriptionChange, + handleNameChange, + handleRemoveOverlay, + handleSaveEditedOverlay, + } = useEditOverlay(); + + return ( + <div className="w-full border border-t-[#E1E0E6] bg-white p-[24px]"> + <form> + <label className="text-sm font-semibold" htmlFor="overlayName"> + Name + <Input + type="text" + value={name} + onChange={handleNameChange} + name="overlayName" + id="overlayName" + className="mt-2.5 text-sm font-medium" + data-testid="overlay-name" + /> + </label> + <label className="mt-5 block text-sm font-semibold" htmlFor="overlayDescription"> + Description + <Textarea + rows={4} + value={description} + onChange={handleDescriptionChange} + name="overlayDescription" + id="overlayDescription" + className="mt-2.5 text-sm font-medium" + data-testid="overlay-description" + /> + </label> + <div className="mt-10 flex items-center justify-between gap-5 text-center"> + <Button + type="button" + variantStyles="ghost" + className="flex-1 justify-center" + onClick={handleCancelEdit} + data-testid="cancel-button" + > + Cancel + </Button> + <Button + type="button" + variantStyles="ghost" + className="flex-1 justify-center" + onClick={handleRemoveOverlay} + data-testid="remove-button" + > + Remove + </Button> + + <Button + type="button" + className="flex-1 justify-center" + onClick={handleSaveEditedOverlay} + data-testid="save-button" + > + Save + </Button> + </div> + </form> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fcac18fbda172096b00d88362d8ec73476a098f --- /dev/null +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts @@ -0,0 +1,175 @@ +/* eslint-disable no-magic-numbers */ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { renderHook } from '@testing-library/react'; +import { useEditOverlay } from './useEditOverlay'; + +describe('useEditOverlay', () => { + it('should handle cancel edit overlay', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + const { + result: { + current: { handleCancelEdit }, + }, + } = renderHook(() => useEditOverlay(), { + wrapper: Wrapper, + }); + + handleCancelEdit(); + + const actions = store.getActions(); + + expect(actions[0].type).toBe('modal/closeModal'); + }); + + it('should handle handleRemoveOverlay if proper data is provided', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + const { + result: { + current: { handleRemoveOverlay }, + }, + } = renderHook(() => useEditOverlay(), { + wrapper: Wrapper, + }); + + handleRemoveOverlay(); + + const actions = store.getActions(); + + expect(actions[0].type).toBe('overlays/removeOverlay/pending'); + + const { login, overlayId } = actions[0].meta.arg; + expect(login).toBe('test'); + expect(overlayId).toBe(overlayFixture.idObject); + }); + it('should not handle handleRemoveOverlay if proper data is not provided', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'failed', + error: DEFAULT_ERROR, + login: null, + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + const { + result: { + current: { handleRemoveOverlay }, + }, + } = renderHook(() => useEditOverlay(), { + wrapper: Wrapper, + }); + + handleRemoveOverlay(); + + const actions = store.getActions(); + + expect(actions.length).toBe(0); + }); + it('should handle handleSaveEditedOverlay if proper data is provided', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + const { + result: { + current: { handleSaveEditedOverlay }, + }, + } = renderHook(() => useEditOverlay(), { + wrapper: Wrapper, + }); + + handleSaveEditedOverlay(); + + const actions = store.getActions(); + + expect(actions[0].type).toBe('overlays/updateOverlays/pending'); + expect(actions[0].meta.arg).toEqual([overlayFixture]); + }); + it('should not handle handleSaveEditedOverlay if proper data is not provided', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: null, + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + }); + + const { + result: { + current: { handleSaveEditedOverlay }, + }, + } = renderHook(() => useEditOverlay(), { + wrapper: Wrapper, + }); + + handleSaveEditedOverlay(); + + const actions = store.getActions(); + + expect(actions.length).toBe(0); + }); +}); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.ts new file mode 100644 index 0000000000000000000000000000000000000000..adb7778cddb6a3959480fe319a97ea888efca29a --- /dev/null +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.ts @@ -0,0 +1,100 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { currentEditedOverlaySelector } from '@/redux/modal/modal.selector'; +import { closeModal } from '@/redux/modal/modal.slice'; +import { + getAllUserOverlaysByCreator, + removeOverlay, + updateOverlays, +} from '@/redux/overlays/overlays.thunks'; +import { loginUserSelector } from '@/redux/user/user.selectors'; +import { MapOverlay } from '@/types/models'; +import { useState } from 'react'; + +type UseEditOverlayReturn = { + name: string | undefined; + description: string | undefined; + handleCancelEdit: () => void; + handleRemoveOverlay: () => void; + handleSaveEditedOverlay: () => Promise<void>; + handleNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void; + handleDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; +}; + +type UpdatedOverlay = { + editedOverlay: MapOverlay; + overlayName: string; + overlayDescription: string; +}; + +export const useEditOverlay = (): UseEditOverlayReturn => { + const currentEditedOverlay = useAppSelector(currentEditedOverlaySelector); + const login = useAppSelector(loginUserSelector); + const dispatch = useAppDispatch(); + const [name, setName] = useState(currentEditedOverlay?.name); + const [description, setDescription] = useState(currentEditedOverlay?.description); + + const handleCancelEdit = (): void => { + dispatch(closeModal()); + }; + + const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + setName(e.target.value); + }; + + const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => { + setDescription(e.target.value); + }; + + const handleRemoveOverlay = (): void => { + if (!login || !currentEditedOverlay) return; + dispatch(removeOverlay({ overlayId: currentEditedOverlay.idObject, login })); + }; + + const handleUpdateOverlay = async ({ + editedOverlay, + overlayDescription, + overlayName, + }: UpdatedOverlay): Promise<void> => { + await dispatch( + updateOverlays([ + { + ...editedOverlay, + name: overlayName, + description: overlayDescription, + }, + ]), + ); + }; + + const getUserOverlaysByCreator = async (creator: string): Promise<void> => { + await dispatch(getAllUserOverlaysByCreator(creator)); + }; + + const handleCloseModal = (): void => { + dispatch(closeModal()); + }; + + const handleSaveEditedOverlay = async (): Promise<void> => { + if (!currentEditedOverlay || !name || !description || !login) return; + await handleUpdateOverlay({ + editedOverlay: currentEditedOverlay, + overlayDescription: description, + overlayName: name, + }); + + await getUserOverlaysByCreator(login); + + handleCloseModal(); + }; + + return { + handleCancelEdit, + handleRemoveOverlay, + handleSaveEditedOverlay, + handleNameChange, + handleDescriptionChange, + name, + description, + }; +}; diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/index.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f847a6bd48e5dc648bfceb8a6865fb7316394d67 --- /dev/null +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/index.ts @@ -0,0 +1 @@ +export { EditOverlayModal } from './EditOverlayModal.component'; diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 00e22313022d97811b8f5de845cdd44eb0c87b93..e6500c336107407611d0c6b1646b67dccce12202 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -5,6 +5,7 @@ import { LoginModal } from './LoginModal'; import { OverviewImagesModal } from './OverviewImagesModal'; import { PublicationsModal } from './PublicationsModal'; import { ModalLayout } from './ModalLayout'; +import { EditOverlayModal } from './EditOverlayModal'; const MolArtModal = dynamic( () => import('./MolArtModal/MolArtModal.component').then(mod => mod.MolArtModal), @@ -32,6 +33,11 @@ export const Modal = (): React.ReactNode => { </ModalLayout> )} {isOpen && modalName === 'publications' && <PublicationsModal />} + {isOpen && modalName === 'edit-overlay' && ( + <ModalLayout> + <EditOverlayModal /> + </ModalLayout> + )} </> ); }; diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.tsx index fbc5c000ee41e1dd810219d5d9cf181263fc02a7..41177e15ebdf3dccdb1d870f0e78acf59c98f71d 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.tsx @@ -15,7 +15,7 @@ export const NavBar = (): JSX.Element => { }; const openDrawerPlugins = (): void => { - dispatch(openDrawer('plugins')); + dispatch(openDrawer('available-plugins')); }; const openDrawerExport = (): void => { @@ -34,14 +34,14 @@ export const NavBar = (): JSX.Element => { <div className="flex min-h-full w-[88px] flex-col items-center justify-between bg-cultured py-8"> <div data-testid="nav-buttons"> <div className="mb-8 flex flex-col gap-[10px]"> - <IconButton icon="info" onClick={openDrawerInfo} /> - <IconButton icon="page" /> - <IconButton icon="plugin" onClick={openDrawerPlugins} /> - <IconButton icon="export" onClick={openDrawerExport} /> + <IconButton icon="info" onClick={openDrawerInfo} title="Project info" /> + <IconButton icon="page" title="API Doc" /> + <IconButton icon="plugin" onClick={openDrawerPlugins} title="Available plugins" /> + <IconButton icon="export" onClick={openDrawerExport} title="Export" /> </div> <div className="flex flex-col gap-[10px]"> - <IconButton icon="admin" onClick={openModalLogin} /> - <IconButton icon="legend" onClick={openDrawerLegend} /> + <IconButton icon="admin" onClick={openModalLogin} title="Login" /> + <IconButton icon="legend" onClick={openDrawerLegend} title="Legend" /> </div> </div> diff --git a/src/components/FunctionalArea/TopBar/TopBar.component.tsx b/src/components/FunctionalArea/TopBar/TopBar.component.tsx index 77c4eab9a59caabf1aebe8739f2679fe4966cab2..243ededa1542fd5ae10a6af2a9b65df8277932ca 100644 --- a/src/components/FunctionalArea/TopBar/TopBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/TopBar.component.tsx @@ -20,10 +20,17 @@ export const TopBar = (): JSX.Element => { <div className="flex flex-row items-center"> <UserAvatar /> <SearchBar /> - <Button icon="plus" isIcon isFrontIcon className="ml-8 mr-4" onClick={onSubmapsClick}> + <Button + icon="plus" + isIcon + isFrontIcon + className="ml-8 mr-4" + onClick={onSubmapsClick} + title="Submaps" + > Submaps </Button> - <Button icon="plus" isIcon isFrontIcon onClick={onOverlaysClick}> + <Button icon="plus" isIcon isFrontIcon onClick={onOverlaysClick} title="Overlays"> Overlays </Button> </div> diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.test.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06d70d35abe59da6cf05b3c3700d991746cc3db8 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.test.tsx @@ -0,0 +1,53 @@ +import { PLUGINS_MOCK } from '@/models/mocks/pluginsMock'; +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 { render, screen } from '@testing-library/react'; +import { AvailablePluginsDrawer } from './AvailablePluginsDrawer.component'; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <AvailablePluginsDrawer /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('AvailablePluginsDrawer - component', () => { + describe('when always', () => { + it('should render drawer heading', () => { + renderComponent(INITIAL_STORE_STATE_MOCK); + const drawerTitle = screen.getByText('Available plugins'); + expect(drawerTitle).toBeInTheDocument(); + }); + + it('should render load plugin from url', () => { + renderComponent(INITIAL_STORE_STATE_MOCK); + const loadPluginFromUrlInput = screen.getByTestId('load-plugin-input-url'); + expect(loadPluginFromUrlInput).toBeInTheDocument(); + }); + + it.each(PLUGINS_MOCK)('should render render all public plugins', currentPlugin => { + renderComponent({ + ...INITIAL_STORE_STATE_MOCK, + plugins: { + ...INITIAL_STORE_STATE_MOCK.plugins, + list: { + ...INITIAL_STORE_STATE_MOCK.plugins.list, + data: PLUGINS_MOCK, + }, + }, + }); + + const pluginLabel = screen.getByText(currentPlugin.name); + expect(pluginLabel).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80af258edc647d357865948e2e52a9dc333dcaba --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.component.tsx @@ -0,0 +1,17 @@ +import { DrawerHeading } from '@/shared/DrawerHeading'; +import { LoadPluginFromUrl } from './LoadPluginFromUrl'; +import { PublicPlugins } from './PublicPlugins'; +import { PrivateActivePlugins } from './PrivateActivePlugins'; + +export const AvailablePluginsDrawer = (): JSX.Element => { + return ( + <div className="h-full max-h-full" data-testid="available-plugins-drawer"> + <DrawerHeading title="Available plugins" /> + <div className="flex flex-col gap-6 p-6"> + <LoadPluginFromUrl /> + <PrivateActivePlugins /> + <PublicPlugins /> + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.test.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba8ea94c6ced7739c43e902ac3ac60020959b57a --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.test.tsx @@ -0,0 +1,104 @@ +/* eslint-disable no-magic-numbers */ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { PLUGINS_MOCK } from '@/models/mocks/pluginsMock'; +import { render, screen } from '@testing-library/react'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; +import { StoreType } from '@/redux/store'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import MockAdapter from 'axios-mock-adapter'; +import axios, { HttpStatusCode } from 'axios'; +import { apiPath } from '@/redux/apiPath'; +import { act } from 'react-dom/test-utils'; +import { PLUGINS_INITIAL_STATE_LIST_MOCK } from '@/redux/plugins/plugins.mock'; +import { LoadPlugin, Props } from './LoadPlugin.component'; + +const mockedAxiosApiClient = mockNetworkResponse(); +const mockedAxiosClient = new MockAdapter(axios); + +const renderComponent = ( + { plugin }: Props, + initialStore?: InitialStoreState, +): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <LoadPlugin plugin={plugin} /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('LoadPlugin - component', () => { + describe('when always', () => { + const plugin = PLUGINS_MOCK[FIRST_ARRAY_ELEMENT]; + + it('renders plugin name', () => { + renderComponent({ plugin }); + + const title = screen.getByText(plugin.name); + expect(title).toBeInTheDocument(); + }); + + it('renders plugin load button', () => { + renderComponent({ plugin }); + + const loadButton = screen.getByText('Load'); + expect(loadButton.tagName).toBe('BUTTON'); + expect(loadButton).toBeInTheDocument(); + }); + it('should change button label to unload if plugin is active', () => { + renderComponent( + { plugin }, + { + plugins: { + activePlugins: { + data: { + [plugin.hash]: plugin, + }, + pluginsId: [plugin.hash], + }, + list: PLUGINS_INITIAL_STATE_LIST_MOCK, + }, + }, + ); + + expect(screen.queryByTestId('toggle-plugin')).toHaveTextContent('Unload'); + }); + it('should change button label to load if plugin is not active', () => { + renderComponent({ plugin }); + expect(screen.queryByTestId('toggle-plugin')).toHaveTextContent('Load'); + }); + it('should unload plugin after click', async () => { + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, plugin); + mockedAxiosApiClient.onGet(apiPath.getPlugin(plugin.hash)).reply(HttpStatusCode.Ok, plugin); + mockedAxiosClient.onGet(plugin.urls[0]).reply(HttpStatusCode.Ok, ''); + const { store } = renderComponent( + { plugin }, + { + plugins: { + activePlugins: { + data: { + [plugin.hash]: plugin, + }, + pluginsId: [plugin.hash], + }, + list: PLUGINS_INITIAL_STATE_LIST_MOCK, + }, + }, + ); + + await act(() => { + screen.queryByTestId('toggle-plugin')?.click(); + }); + + const { activePlugins } = store.getState().plugins; + expect(activePlugins.pluginsId).toEqual([]); + expect(activePlugins.data).toEqual({}); + }); + }); +}); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f129d79d0797827601fbff4068bec1b27e5fca2e --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/LoadPlugin.component.tsx @@ -0,0 +1,30 @@ +/* eslint-disable no-magic-numbers */ +import { Button } from '@/shared/Button'; +import { MinervaPlugin } from '@/types/models'; +import { useLoadPlugin } from './hooks/useLoadPlugin'; + +export interface Props { + plugin: MinervaPlugin; +} + +export const LoadPlugin = ({ plugin }: Props): JSX.Element => { + const { isPluginActive, togglePlugin, isPluginLoading } = useLoadPlugin({ + hash: plugin.hash, + pluginUrl: plugin.urls[0], + }); + + return ( + <div className="flex w-full items-center justify-between text-sm"> + <span className="text-cetacean-blue">{plugin.name}</span> + <Button + variantStyles="secondary" + className="h-10 self-end rounded-e rounded-s text-xs font-medium" + onClick={togglePlugin} + data-testid="toggle-plugin" + disabled={isPluginLoading} + > + {isPluginActive ? 'Unload' : 'Load'} + </Button> + </div> + ); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..04245fbf3a2191bdfc1344bee1c12d75137339c9 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable no-magic-numbers */ +import { renderHook, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import axios, { HttpStatusCode } from 'axios'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import MockAdapter from 'axios-mock-adapter'; +import { PluginsManager } from '@/services/pluginsManager'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { useLoadPlugin } from './useLoadPlugin'; + +const mockedAxiosClient = new MockAdapter(axios); +jest.mock('../../../../../../services/pluginsManager/pluginsManager'); + +describe('useLoadPlugin', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should unload plugin successfully', async () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + ...INITIAL_STORE_STATE_MOCK, + plugins: { + activePlugins: { + pluginsId: [pluginFixture.hash], + data: { + [pluginFixture.hash]: pluginFixture, + }, + }, + list: INITIAL_STORE_STATE_MOCK.plugins.list, + }, + }); + + const { + result: { + current: { isPluginActive, isPluginLoading, togglePlugin }, + }, + } = renderHook( + () => useLoadPlugin({ hash: pluginFixture.hash, pluginUrl: pluginFixture.urls[0] }), + { + wrapper: Wrapper, + }, + ); + + expect(isPluginActive).toBe(true); + expect(isPluginLoading).toBe(false); + + act(() => { + togglePlugin(); + }); + + const actions = store.getActions(); + expect(actions[0]).toEqual({ + payload: { pluginId: pluginFixture.hash }, + type: 'plugins/removePlugin', + }); + }); + it('should load plugin successfully', async () => { + const hash = 'pluginHash'; + const pluginUrl = 'http://example.com/plugin.js'; + Math.max = jest.fn(); + const { Wrapper } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK); + const pluginScript = `function init() {${Math.max(1, 2)}} init()`; + + mockedAxiosClient.onGet(pluginUrl).reply(HttpStatusCode.Ok, pluginScript); + + const { + result: { + current: { isPluginActive, isPluginLoading, togglePlugin }, + }, + } = renderHook(() => useLoadPlugin({ hash, pluginUrl }), { + wrapper: Wrapper, + }); + + expect(isPluginActive).toBe(false); + expect(isPluginLoading).toBe(false); + + togglePlugin(); + + expect(Math.max).toHaveBeenCalledWith(1, 2); + + await waitFor(() => { + expect(PluginsManager.setHashedPlugin).toHaveBeenCalledWith({ + pluginScript, + pluginUrl, + }); + }); + }); +}); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1b04f6549b8eb8b790302a7ff3ee31b45c822f7 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts @@ -0,0 +1,57 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { isPluginActiveSelector, isPluginLoadingSelector } from '@/redux/plugins/plugins.selectors'; +import { removePlugin } from '@/redux/plugins/plugins.slice'; +import { PluginsManager } from '@/services/pluginsManager'; +import axios from 'axios'; + +type UseLoadPluginReturnType = { + togglePlugin: () => void; + isPluginActive: boolean; + isPluginLoading: boolean; +}; + +type UseLoadPluginProps = { + hash: string; + pluginUrl: string; +}; + +export const useLoadPlugin = ({ hash, pluginUrl }: UseLoadPluginProps): UseLoadPluginReturnType => { + const isPluginActive = useAppSelector(state => isPluginActiveSelector(state, hash)); + const isPluginLoading = useAppSelector(state => isPluginLoadingSelector(state, hash)); + + const dispatch = useAppDispatch(); + + const handleLoadPlugin = async (): Promise<void> => { + const response = await axios(pluginUrl); + const pluginScript = response.data; + + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + + PluginsManager.setHashedPlugin({ + pluginUrl, + pluginScript, + }); + + loadPlugin(); + }; + + const handleUnloadPlugin = (): void => { + dispatch(removePlugin({ pluginId: hash })); + }; + + const togglePlugin = (): void => { + if (isPluginActive) { + handleUnloadPlugin(); + } else { + handleLoadPlugin(); + } + }; + + return { + togglePlugin, + isPluginActive, + isPluginLoading, + }; +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e27e6ba35e4443188bf5a6e08a655ddb97b3262c --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/index.ts @@ -0,0 +1 @@ +export { LoadPlugin } from './LoadPlugin.component'; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..10d387fbba06a482aa6a79be309671efaabfd01c --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx @@ -0,0 +1,163 @@ +/* eslint-disable no-magic-numbers */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; +import { StoreType } from '@/redux/store'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import MockAdapter from 'axios-mock-adapter'; +import axios, { HttpStatusCode } from 'axios'; +import { apiPath } from '@/redux/apiPath'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { act } from 'react-dom/test-utils'; +import { PLUGINS_INITIAL_STATE_LIST_MOCK } from '@/redux/plugins/plugins.mock'; +import { LoadPluginFromUrl } from './LoadPluginFromUrl.component'; + +const mockedAxiosApiClient = mockNetworkResponse(); +const mockedAxiosClient = new MockAdapter(axios); + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <LoadPluginFromUrl /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('LoadPluginFromUrl - component', () => { + global.URL.canParse = jest.fn(); + + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('when always', () => { + it('renders plugin input label', () => { + renderComponent(); + + const pluginInputLabel = screen.getByLabelText('URL:'); + expect(pluginInputLabel).toBeInTheDocument(); + }); + + it('renders plugin input', () => { + renderComponent(); + + const pluginInput = screen.getByTestId('load-plugin-input-url'); + expect(pluginInput).toBeInTheDocument(); + }); + + it('renders plugin load button', () => { + renderComponent(); + + const loadButton = screen.getByText('Load'); + expect(loadButton.tagName).toBe('BUTTON'); + expect(loadButton).toBeInTheDocument(); + }); + it('should unload plugin after click', async () => { + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture); + mockedAxiosApiClient + .onGet(apiPath.getPlugin(pluginFixture.hash)) + .reply(HttpStatusCode.Ok, pluginFixture); + mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.Ok, ''); + + renderComponent(); + const input = screen.getByTestId('load-plugin-input-url'); + expect(input).toBeVisible(); + + act(() => { + fireEvent.change(input, { target: { value: pluginFixture.urls[0] } }); + }); + + expect(input).toHaveValue(pluginFixture.urls[0]); + + const button = screen.queryByTestId('load-plugin-button'); + expect(button).toBeVisible(); + + act(() => { + button?.click(); + }); + + expect(button).toBeDisabled(); + }); + it('should not load plugin if it`s loaded already', async () => { + global.URL.canParse = jest.fn().mockReturnValue(true); + + const plugin = { + ...pluginFixture, + urls: ['https://example.com/min.js'], + }; + mockedAxiosClient.onGet(plugin.urls[0]).reply(HttpStatusCode.Ok, ''); + + const { store } = renderComponent({ + plugins: { + activePlugins: { + pluginsId: [plugin.hash], + data: { + [plugin.hash]: plugin, + }, + }, + list: PLUGINS_INITIAL_STATE_LIST_MOCK, + }, + }); + const input = screen.getByTestId('load-plugin-input-url'); + expect(input).toBeVisible(); + + act(() => { + fireEvent.change(input, { target: { value: plugin.urls[0] } }); + }); + + expect(input).toHaveValue(plugin.urls[0]); + + const button = screen.getByTestId('load-plugin-button'); + expect(button).not.toBeDisabled(); + await act(() => { + button.click(); + }); + + const { activePlugins } = store.getState().plugins; + + expect(activePlugins).toEqual({ + pluginsId: [plugin.hash], + data: { + [plugin.hash]: plugin, + }, + }); + + expect(input).toHaveValue(''); + }); + it('should disable url input if url is empty', async () => { + renderComponent(); + const input = screen.getByTestId('load-plugin-input-url'); + expect(input).toBeVisible(); + + act(() => { + fireEvent.change(input, { target: { value: '' } }); + }); + + expect(input).toHaveValue(''); + + const button = screen.getByTestId('load-plugin-button'); + expect(button).toBeDisabled(); + }); + + it('should disable url input if url is not correct', async () => { + global.URL.canParse = jest.fn().mockReturnValue(false); + renderComponent(); + const input = screen.getByTestId('load-plugin-input-url'); + expect(input).toBeVisible(); + + act(() => { + fireEvent.change(input, { target: { value: 'abcd' } }); + }); + + expect(input).toHaveValue('abcd'); + + const button = screen.getByTestId('load-plugin-button'); + expect(button).toBeDisabled(); + }); + }); +}); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f57224d048c4af8fd407c8c38cff19f111e8dccf --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.tsx @@ -0,0 +1,31 @@ +import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; +import { useLoadPluginFromUrl } from './hooks/useLoadPluginFromUrl'; + +export const LoadPluginFromUrl = (): JSX.Element => { + const { handleChangePluginUrl, handleLoadPlugin, isPending, pluginUrl } = useLoadPluginFromUrl(); + + return ( + <div className="flex w-full"> + <label className="flex w-full flex-col gap-2 text-sm text-cetacean-blue"> + <span>URL:</span> + <Input + className="h-10 w-full flex-none bg-cultured p-3" + type="url" + value={pluginUrl} + onChange={handleChangePluginUrl} + data-testid="load-plugin-input-url" + /> + </label> + <Button + variantStyles="secondary" + className="h-10 self-end rounded-e rounded-s text-xs font-medium" + onClick={handleLoadPlugin} + disabled={isPending} + data-testid="load-plugin-button" + > + Load + </Button> + </div> + ); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts new file mode 100644 index 0000000000000000000000000000000000000000..123e220ccc3e05c23d54bbcf62e8d9c607acb373 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -0,0 +1,57 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { activePluginsDataSelector } from '@/redux/plugins/plugins.selectors'; +import { PluginsManager } from '@/services/pluginsManager'; +import axios from 'axios'; +import { ChangeEvent, useMemo, useState } from 'react'; + +type UseLoadPluginReturnType = { + handleChangePluginUrl: (event: ChangeEvent<HTMLInputElement>) => void; + handleLoadPlugin: () => Promise<void>; + isPending: boolean; + pluginUrl: string; +}; + +export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { + const [pluginUrl, setPluginUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const activePlugins = useAppSelector(activePluginsDataSelector); + + const isPending = useMemo( + () => !pluginUrl || isLoading || !URL.canParse(pluginUrl), + [pluginUrl, isLoading], + ); + + const handleLoadPlugin = async (): Promise<void> => { + try { + setIsLoading(true); + const response = await axios(pluginUrl); + const pluginScript = response.data; + + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + + const hash = PluginsManager.setHashedPlugin({ + pluginUrl, + pluginScript, + }); + + if (!(hash in activePlugins)) { + loadPlugin(); + } + + setPluginUrl(''); + } finally { + setIsLoading(false); + } + }; + const handleChangePluginUrl = (event: ChangeEvent<HTMLInputElement>): void => { + setPluginUrl(event.target.value); + }; + + return { + handleChangePluginUrl, + handleLoadPlugin, + isPending, + pluginUrl, + }; +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cedb0f665553574022fb55f4f00ef9678b546f1d --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/index.ts @@ -0,0 +1 @@ +export { LoadPluginFromUrl } from './LoadPluginFromUrl.component'; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/PrivateActivePlugins.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/PrivateActivePlugins.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..557b997413b41d2299194cfe1d08caa92907c45f --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/PrivateActivePlugins.component.tsx @@ -0,0 +1,8 @@ +import { privateActivePluginsSelector } from '@/redux/plugins/plugins.selectors'; +import { useSelector } from 'react-redux'; +import { LoadPlugin } from '../LoadPlugin'; + +export const PrivateActivePlugins = (): React.ReactNode => { + const privateActivePlugins = useSelector(privateActivePluginsSelector); + return privateActivePlugins.map(plugin => <LoadPlugin key={plugin.hash} plugin={plugin} />); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a8bc8b5fb91dbd12bfdff759888d4266870c8f5 --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PrivateActivePlugins/index.ts @@ -0,0 +1 @@ +export { PrivateActivePlugins } from './PrivateActivePlugins.component'; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/PublicPlugins.component.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/PublicPlugins.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..674b88756126ae4460e8b81d5dfd06a1047b86cf --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/PublicPlugins.component.tsx @@ -0,0 +1,9 @@ +import { publicPluginsListSelector } from '@/redux/plugins/plugins.selectors'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { LoadPlugin } from '../LoadPlugin'; + +export const PublicPlugins = (): React.ReactNode => { + const publicPlugins = useSelector(publicPluginsListSelector); + return publicPlugins.map(plugin => <LoadPlugin key={plugin.hash} plugin={plugin} />); +}; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c1fd8a7776423954e1da451bdf1ba757e14a1db --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/PublicPlugins/index.ts @@ -0,0 +1 @@ +export { PublicPlugins } from './PublicPlugins.component'; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/index.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..85f2fe7f30853779b57a030199940cd3055f8e3a --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/index.ts @@ -0,0 +1 @@ +export { AvailablePluginsDrawer } from './AvailablePluginsDrawer.component'; diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index 098c4f989ec233798af6de0addfbb28fca84b02e..de1aa94ea44f6f9e2203ea3a386e373cbce620d6 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -2,13 +2,14 @@ import { DRAWER_ROLE } from '@/components/Map/Drawer/Drawer.constants'; import { drawerSelector } from '@/redux/drawer/drawer.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { twMerge } from 'tailwind-merge'; -import { ReactionDrawer } from './ReactionDrawer'; -import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrapper'; -import { SubmapsDrawer } from './SubmapsDrawer'; -import { OverlaysDrawer } from './OverlaysDrawer'; +import { AvailablePluginsDrawer } from './AvailablePluginsDrawer'; import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component'; import { ExportDrawer } from './ExportDrawer'; +import { OverlaysDrawer } from './OverlaysDrawer'; import { ProjectInfoDrawer } from './ProjectInfoDrawer'; +import { ReactionDrawer } from './ReactionDrawer'; +import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrapper'; +import { SubmapsDrawer } from './SubmapsDrawer'; export const Drawer = (): JSX.Element => { const { isOpen, drawerName } = useAppSelector(drawerSelector); @@ -28,6 +29,7 @@ export const Drawer = (): JSX.Element => { {isOpen && drawerName === 'bio-entity' && <BioEntityDrawer />} {isOpen && drawerName === 'project-info' && <ProjectInfoDrawer />} {isOpen && drawerName === 'export' && <ExportDrawer />} + {isOpen && drawerName === 'available-plugins' && <AvailablePluginsDrawer />} </div> ); }; 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 54ac8df4fc0f8634d21a474a3d4cfca07b239434..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); @@ -39,14 +43,19 @@ export const CheckboxFilter = ({ }; const handleCheckboxChange = (option: CheckboxItem): void => { - const newCheckedCheckboxes = checkedCheckboxes.includes(option) - ? checkedCheckboxes.filter(item => item !== option) + const newCheckedCheckboxes = checkedCheckboxes.some(item => item.id === option.id) + ? checkedCheckboxes.filter(item => item.id !== option.id) : [...checkedCheckboxes, option]; setCheckedCheckboxes(newCheckedCheckboxes); 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 new file mode 100644 index 0000000000000000000000000000000000000000..9fa8624b161e701d4836820a0d008319f53c1aa9 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.test.tsx @@ -0,0 +1,210 @@ +/* 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 { HttpStatusCode } from 'axios'; +import { act } from 'react-dom/test-utils'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { ELEMENTS_COLUMNS } from '../ExportCompound/ExportCompound.constant'; +import { Elements } from './Elements.component'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const renderComponent = ( + initialStore?: InitialStoreState, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStore); + return ( + render( + <Wrapper> + <Elements /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Elements - component', () => { + it('should render all elements sections', () => { + renderComponent({ + ...INITIAL_STORE_STATE_MOCK, + configuration: { + ...CONFIGURATION_INITIAL_STORE_MOCK, + main: { + ...CONFIGURATION_INITIAL_STORE_MOCK.main, + data: { + ...configurationFixture, + miriamTypes: { + compartment_label: { + commonName: 'Compartment', + homepage: '', + registryIdentifier: '', + uris: [''], + }, + }, + }, + }, + }, + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: { + compartment_label: 1, + pathway: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + + const annotations = screen.getByText('Select annotations'); + const includedCompartmentPathways = screen.getByText('Select included compartment / pathways'); + const excludedCompartmentPathways = screen.getByText('Select excluded compartment / pathways'); + const downloadButton = screen.getByText('Download'); + + expect(annotations).toBeVisible(); + expect(includedCompartmentPathways).toBeVisible(); + expect(excludedCompartmentPathways).toBeVisible(); + expect(downloadButton).toBeVisible(); + }); + it('should handle download button click and dispatch proper data', async () => { + mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test'); + const FIRST_COMPARMENT_PATHWAY_NAME = compartmentPathwaysDetailsFixture[0].name; + const FIRST_COMPARMENT_PATHWAY_ID = compartmentPathwaysDetailsFixture[0].id; + 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: { + ...CONFIGURATION_INITIAL_STORE_MOCK.main, + data: { + ...configurationFixture, + miriamTypes: { + compartment_label: { + commonName: 'Compartment', + homepage: '', + registryIdentifier: '', + uris: [''], + }, + }, + }, + }, + }, + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: { + compartment_label: 1, + pathway: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + models: { + data: modelsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + + const annotations = screen.getByText('Select annotations'); + + await act(() => { + annotations.click(); + }); + const annotationInput = screen.getByLabelText('Compartment'); + + await act(() => { + annotationInput.click(); + }); + + expect(annotationInput).toBeChecked(); + + const includedCompartmentPathways = screen.getByText('Select included compartment / pathways'); + + await act(() => { + includedCompartmentPathways.click(); + }); + const includedCompartmentPathwaysInput = screen.getAllByLabelText( + FIRST_COMPARMENT_PATHWAY_NAME, + )[0]; + + await act(() => { + includedCompartmentPathwaysInput.click(); + }); + + expect(includedCompartmentPathwaysInput).toBeChecked(); + + const excludedCompartmentPathways = screen.getByText('Select excluded compartment / pathways'); + + await act(() => { + excludedCompartmentPathways.click(); + }); + const excludedCompartmentPathwaysInput = screen.getAllByLabelText( + SECOND_COMPARMENT_PATHWAY_NAME, + )[1]; + + await act(() => { + excludedCompartmentPathwaysInput.click(); + }); + + expect(excludedCompartmentPathwaysInput).toBeChecked(); + + const downloadButton = screen.getByText('Download'); + + await act(() => { + downloadButton.click(); + }); + + const actions = store.getActions(); + + const firstAction = actions[0]; + expect(firstAction.meta.arg).toEqual({ + columns: ELEMENTS_COLUMNS, + submaps: modelsFixture.map(item => item.idObject), + annotations: ['compartment_label'], + includedCompartmentIds: [FIRST_COMPARMENT_PATHWAY_ID], + excludedCompartmentIds: [SECOND_COMPARMENT_PATHWAY_ID], + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx index fcf01b6a0c8fd6cae6b847e245f70e3d072508e7..d8993b4e914f047d36d44732dcfe074c080f90f0 100644 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx @@ -1,12 +1,11 @@ import { Export } from '../ExportCompound'; +import { ANNOTATIONS_TYPE } from '../ExportCompound/ExportCompound.constant'; export const Elements = (): React.ReactNode => { return ( <div data-testid="elements-tab"> <Export> - <Export.Types /> - <Export.Columns /> - <Export.Annotations /> + <Export.Annotations type={ANNOTATIONS_TYPE.ELEMENTS} /> <Export.IncludedCompartmentPathways /> <Export.ExcludedCompartmentPathways /> <Export.DownloadElements /> diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.test.tsx index df19cb66cbdd04c7511105db937cf4f5f41be111..9ce4c9db49f0d1c0fc1619ad8c5d4d1cc13c5eb0 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.test.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.test.tsx @@ -6,7 +6,10 @@ import { import { StoreType } from '@/redux/store'; import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; import { act } from 'react-dom/test-utils'; +import { CONFIGURATION_INITIAL_STORE_MOCK } from '@/redux/configuration/configuration.mock'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; import { Annotations } from './Annotations.component'; +import { ANNOTATIONS_TYPE } from '../ExportCompound.constant'; const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); @@ -14,7 +17,7 @@ const renderComponent = (initialStoreState: InitialStoreState = {}): { store: St return ( render( <Wrapper> - <Annotations /> + <Annotations type={ANNOTATIONS_TYPE.ELEMENTS} /> </Wrapper>, ), { @@ -26,11 +29,28 @@ const renderComponent = (initialStoreState: InitialStoreState = {}): { store: St describe('Annotations - component', () => { it('should display annotations checkboxes when fetching data is successful', async () => { renderComponent({ + configuration: { + ...CONFIGURATION_INITIAL_STORE_MOCK, + main: { + ...CONFIGURATION_INITIAL_STORE_MOCK.main, + data: { + ...configurationFixture, + miriamTypes: { + compartment_label: { + commonName: 'Compartment', + homepage: '', + registryIdentifier: '', + uris: [''], + }, + }, + }, + }, + }, statistics: { data: { ...statisticsFixture, elementAnnotations: { - compartment: 1, + compartment_label: 1, pathway: 0, }, }, @@ -54,7 +74,7 @@ describe('Annotations - component', () => { await waitFor(() => { expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); - expect(screen.getByLabelText('compartment')).toBeInTheDocument(); + expect(screen.getByLabelText('Compartment')).toBeInTheDocument(); expect(screen.getByLabelText('search-input')).toBeInTheDocument(); }); }); 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 6f7034f871d9034f2a038493d426d6784d552b02..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,29 +1,39 @@ -import { useContext } from 'react'; +import { ZERO } from '@/constants/common'; +import { miramiTypesSelector } from '@/redux/configuration/configuration.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { - elementAnnotationsSelector, loadingStatisticsSelector, + statisticsDataSelector, } from '@/redux/statistics/statistics.selectors'; -import { ZERO } from '@/constants/common'; +import { useContext } from 'react'; import { CheckboxFilter } from '../../CheckboxFilter'; import { CollapsibleSection } from '../../CollapsibleSection'; import { ExportContext } from '../ExportCompound.context'; +import { AnnotationsType } from './Annotations.types'; +import { getAnnotationsCheckboxElements } from './Annotations.utils'; -export const Annotations = (): React.ReactNode => { - const { setAnnotations } = useContext(ExportContext); +type AnnotationsProps = { + type: AnnotationsType; +}; + +export const Annotations = ({ type }: AnnotationsProps): React.ReactNode => { + const { setAnnotations, data } = useContext(ExportContext); + const currentAnnotations = data.annotations; const loadingStatistics = useAppSelector(loadingStatisticsSelector); - const elementAnnotations = useAppSelector(elementAnnotationsSelector); + const statistics = useAppSelector(statisticsDataSelector); + const miramiTypes = useAppSelector(miramiTypesSelector); const isPending = loadingStatistics === 'pending'; - - const mappedElementAnnotations = elementAnnotations - ? Object.keys(elementAnnotations)?.map(el => ({ id: el, label: el })) - : []; + const checkboxElements = getAnnotationsCheckboxElements({ type, statistics, miramiTypes }); return ( <CollapsibleSection title="Select annotations"> {isPending && <p>Loading...</p>} - {!isPending && mappedElementAnnotations && mappedElementAnnotations.length > ZERO && ( - <CheckboxFilter options={mappedElementAnnotations} onCheckedChange={setAnnotations} /> + {!isPending && checkboxElements && checkboxElements.length > ZERO && ( + <CheckboxFilter + options={checkboxElements} + currentOptions={currentAnnotations} + onCheckedChange={setAnnotations} + /> )} </CollapsibleSection> ); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.types.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..15ae345efceb1830a1d3c6ff42533d3dc5feaf58 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.types.ts @@ -0,0 +1,3 @@ +import { ANNOTATIONS_TYPE } from '../ExportCompound.constant'; + +export type AnnotationsType = (typeof ANNOTATIONS_TYPE)[keyof typeof ANNOTATIONS_TYPE]; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.utils.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a12c973e87d3148f47bb5ff69612362595c94e6 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.utils.test.ts @@ -0,0 +1,98 @@ +import { getAnnotationsCheckboxElements } from './Annotations.utils'; + +describe('getAnnotationsCheckboxElements', () => { + const statisticsMock = { + elementAnnotations: { + chebi: 2, + mesh: 0, + reactome: 1, + }, + publications: 1234, + reactionAnnotations: { + brenda: 0, + reactome: 3, + rhea: 1, + }, + }; + + const miramiTypeMock = { + commonName: 'Name', + homepage: '', + registryIdentifier: '', + uris: [''], + }; + + const miramiTypesMock = { + chebi: { + ...miramiTypeMock, + commonName: 'Chebi', + }, + mesh: { + ...miramiTypeMock, + commonName: 'MeSH', + }, + reactome: { + ...miramiTypeMock, + commonName: 'Reactome', + }, + rhea: { + ...miramiTypeMock, + commonName: 'Rhea', + }, + brenda: { + ...miramiTypeMock, + commonName: 'BRENDA', + }, + gene_ontology: { + ...miramiTypeMock, + commonName: 'Gene Ontology', + }, + }; + + it('returns an empty array when statistics or miramiTypes are undefined', () => { + const result = getAnnotationsCheckboxElements({ + type: 'Elements', + statistics: undefined, + miramiTypes: undefined, + }); + expect(result).toEqual([]); + }); + + it('returns checkbox elements for element annotations sorted by label', () => { + const result = getAnnotationsCheckboxElements({ + type: 'Elements', + statistics: statisticsMock, + miramiTypes: miramiTypesMock, + }); + expect(result).toEqual([ + { id: 'chebi', label: 'Chebi' }, + { id: 'reactome', label: 'Reactome' }, + ]); + }); + + it('returns checkbox elements for reaction annotations sorted by count', () => { + const result = getAnnotationsCheckboxElements({ + type: 'Network', + statistics: statisticsMock, + miramiTypes: miramiTypesMock, + }); + expect(result).toEqual([ + { id: 'reactome', label: 'Reactome' }, + { id: 'rhea', label: 'Rhea' }, + ]); + }); + + it('returns an empty array when no annotations have count greater than 0', () => { + const statisticsMockEmpty = { + elementAnnotations: { annotation1: 0, annotation2: 0 }, + publications: 0, + reactionAnnotations: { annotation1: 0, annotation2: 0 }, + }; + const result = getAnnotationsCheckboxElements({ + type: 'Elements', + statistics: statisticsMockEmpty, + miramiTypes: miramiTypesMock, + }); + expect(result).toEqual([]); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.utils.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..35e31e656c3ce6cc7ea1c2e08b8e09e6d9efa004 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.utils.ts @@ -0,0 +1,51 @@ +/* eslint-disable no-magic-numbers */ +import { ConfigurationMiramiTypes, Statistics } from '@/types/models'; +import { ANNOTATIONS_TYPE } from '../ExportCompound.constant'; +import { AnnotationsType } from './Annotations.types'; + +type CheckboxElement = { id: string; label: string }; + +type CheckboxElements = CheckboxElement[]; + +type GetAnnotationsCheckboxElements = { + type: AnnotationsType; + statistics: Statistics | undefined; + miramiTypes: ConfigurationMiramiTypes | undefined; +}; + +const sortByCount = (countA: number, countB: number): number => { + return countA > countB ? -1 : 1; +}; + +const mapToCheckboxElement = ( + annotation: string, + miramiTypes: ConfigurationMiramiTypes, +): CheckboxElement => ({ + id: annotation, + label: miramiTypes[annotation].commonName, +}); + +const filterAnnotationsByCount = (annotations: Record<string, number>): string[] => { + return Object.keys(annotations).filter(annotation => annotations[annotation] > 0); +}; + +export const getAnnotationsCheckboxElements = ({ + type, + statistics, + miramiTypes, +}: GetAnnotationsCheckboxElements): CheckboxElements => { + if (!statistics || !miramiTypes) return []; + + const annotations = + type === ANNOTATIONS_TYPE.ELEMENTS + ? statistics.elementAnnotations + : statistics.reactionAnnotations; + + const availableAnnotations = filterAnnotationsByCount(annotations); + + return availableAnnotations + .sort((firstAnnotation, secondAnnotation) => + sortByCount(annotations[firstAnnotation], annotations[secondAnnotation]), + ) + .map(annotation => mapToCheckboxElement(annotation, miramiTypes)); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.test.tsx deleted file mode 100644 index 381ba5cc9c7fb3f22761c28516e469633a262a85..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; -import { Columns } from './Columns.component'; - -describe('Columns - component', () => { - it('should display select column accordion', async () => { - render(<Columns />); - - expect(screen.getByText('Select column')).toBeInTheDocument(); - expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); - }); - it('should display columns checkboxes', async () => { - render(<Columns />); - - expect(screen.getByText('Select column')).toBeInTheDocument(); - expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); - - const navigationButton = screen.getByTestId('accordion-item-button'); - act(() => { - navigationButton.click(); - }); - - expect(screen.queryByTestId('checkbox-filter')).toBeVisible(); - expect(screen.queryByLabelText('References')).toBeVisible(); - }); -}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.tsx deleted file mode 100644 index 954a4c60a4354f675d4c7bab265f45d69c384039..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useContext } from 'react'; -import { CheckboxFilter } from '../../CheckboxFilter'; -import { CollapsibleSection } from '../../CollapsibleSection'; -import { COLUMNS } from './Columns.constants'; -import { ExportContext } from '../ExportCompound.context'; - -export const Columns = (): React.ReactNode => { - const { setColumns } = useContext(ExportContext); - - return ( - <CollapsibleSection title="Select column"> - <CheckboxFilter options={COLUMNS} isSearchEnabled={false} onCheckedChange={setColumns} /> - </CollapsibleSection> - ); -}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.constants.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.constants.tsx deleted file mode 100644 index e2ece6b51ec445bd3c3b172120ce8679d5fe795c..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.constants.tsx +++ /dev/null @@ -1,86 +0,0 @@ -export const COLUMNS = [ - { - id: 'id', - label: 'ID', - }, - { - id: 'description', - label: 'Description', - }, - { - id: 'modelId', - label: 'Map id', - }, - { - id: 'mapName', - label: 'Map name', - }, - { - id: 'symbol', - label: 'Symbol', - }, - { - id: 'abbreviation', - label: 'Abbreviation', - }, - { - id: 'synonyms', - label: 'Synonyms', - }, - { - id: 'references', - label: 'References', - }, - { - id: 'name', - label: 'Name', - }, - { - id: 'type', - label: 'Type', - }, - { - id: 'complexId', - label: 'Complex id', - }, - { - id: 'complexName', - label: 'Complex name', - }, - { - id: 'compartmentId', - label: 'Compartment/Pathway id', - }, - { - id: 'compartmentName', - label: 'Compartment/Pathway name', - }, - { - id: 'charge', - label: 'Charge', - }, - { - id: 'fullName', - label: 'Full name', - }, - { - id: 'formula', - label: 'Formula', - }, - { - id: 'formerSymbols', - label: 'Former symbols', - }, - { - id: 'linkedSubmodelId', - label: 'Linked submap id', - }, - { - id: 'elementId', - label: 'Element external id', - }, - { - id: 'ALL', - label: 'All', - }, -]; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/index.ts deleted file mode 100644 index 167db8672847d14dac8a6cc038be63cfe105a582..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Columns } from './Columns.component'; 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/DownloadNetwork/DownloadNetwork.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx index fbe769f0877561ab755049749e12577e13c1b005..8e0fb25dd7a80742635bc5039e63a74ceae686cc 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react'; import { Button } from '@/shared/Button'; import { ExportContext } from '../ExportCompound.context'; -export const DownloadElements = (): React.ReactNode => { +export const DownloadNetwork = (): React.ReactNode => { const { handleDownloadNetwork } = useContext(ExportContext); return ( 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 a1de0816bef5e657fee1ee41ca4ed3f0937d0160..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,21 +1,22 @@ -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); - const checkboxElements = getCompartmentPathwaysCheckboxElements(compartmentPathways); + const checkboxElements = getCompartmentPathwaysCheckboxElements('excluded', compartmentPathways); const isCheckboxFilterVisible = !isPending && checkboxElements && checkboxElements.length > ZERO; return ( @@ -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 15f4767e22864d74979a577f62314f5af2d828de..52bc373224656a1994d53acf5fbda4b6a9619273 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx @@ -1,74 +1,133 @@ -import { ReactNode, useCallback, useMemo, useState } from 'react'; +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 { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { modelsIdsSelector } from '@/redux/models/models.selectors'; -import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.component'; -import { Types } from './Types'; -import { Columns } from './Columns'; +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 { 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 { getNetworkDownloadBodyRequest } from './utils/getNetworkBodyRequest'; +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; }; export const Export = ({ children }: ExportProps): JSX.Element => { - const [types, setTypes] = useState<CheckboxItem[]>([]); - const [columns, setColumns] = useState<CheckboxItem[]>([]); - const [annotations, setAnnotations] = useState<CheckboxItem[]>([]); + const dispatch = useAppDispatch(); 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(() => { - getDownloadElementsBodyRequest({ - types, - columns, + const handleDownloadElements = useCallback(async () => { + const body = getDownloadElementsBodyRequest({ + columns: ELEMENTS_COLUMNS, modelIds, annotations, includedCompartmentPathways, excludedCompartmentPathways, }); - }, [ - types, - columns, - modelIds, - annotations, - includedCompartmentPathways, - excludedCompartmentPathways, - ]); - const handleDownloadNetwork = useCallback(() => { - getNetworkDownloadBodyRequest(); - }, []); + dispatch(downloadElements(body)); + }, [modelIds, annotations, includedCompartmentPathways, excludedCompartmentPathways, dispatch]); + + const handleDownloadNetwork = useCallback(async () => { + const data = getNetworkDownloadBodyRequest({ + columns: NETWORK_COLUMNS, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + }); + + 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( () => ({ - setTypes, - setColumns, 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>; }; -Export.Types = Types; -Export.Columns = Columns; 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 new file mode 100644 index 0000000000000000000000000000000000000000..a07ae4c58f7b2fa3ec8ef4f652c1d4c9dbca075b --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.constant.ts @@ -0,0 +1,68 @@ +import { ExportContextType } from './ExportCompound.types'; +import { DEFAULT_IMAGE_SIZE } from './ImageSize/ImageSize.constants'; + +export const ANNOTATIONS_TYPE = { + ELEMENTS: 'Elements', + NETWORK: 'Network', +} as const; + +export const COLUMNS_TYPE = { + ELEMENTS: 'Elements', + NETWORK: 'Network', +} as const; + +export const ELEMENTS_COLUMNS = [ + 'id', + 'type', + 'name', + 'symbol', + 'abbreviation', + 'fullName', + 'synonyms', + 'formerSymbols', + 'complexId', + 'complexName', + 'compartmentId', + 'compartmentName', + 'modelId', + 'mapName', + 'description', + 'references', + 'charge', + 'formula', + 'linkedSubmodelId', + 'elementId', +]; + +export const NETWORK_COLUMNS = [ + 'id', + 'type', + 'reactantIds', + 'productIds', + 'modifierIds', + 'description', + 'reactionId', + 'references', + '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 3490162eb61f13c9ddbf4f2a13d732693e98d003..86f005f09d4dbf9e8ec5643e825c6c0cf33f0486 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts @@ -1,22 +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 = { - setTypes: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; - setColumns: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; - 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>({ - setTypes: () => {}, - setColumns: () => {}, - 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 39164e58904ff7d2667b9573c82ad1d5a38e58f1..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,21 +1,22 @@ -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); - const checkboxElements = getCompartmentPathwaysCheckboxElements(compartmentPathways); + const checkboxElements = getCompartmentPathwaysCheckboxElements('included', compartmentPathways); return ( <CollapsibleSection title="Select included compartment / pathways"> @@ -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/Types/Types.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.test.tsx deleted file mode 100644 index 4d228509706c27621afef1d5620a23dc03d647ab..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { - InitialStoreState, - getReduxWrapperWithStore, -} from '@/utils/testing/getReduxWrapperWithStore'; -import { StoreType } from '@/redux/store'; -import { Types } from './Types.component'; - -const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { - const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); - - return ( - render( - <Wrapper> - <Types /> - </Wrapper>, - ), - { - store, - } - ); -}; - -describe('Types Component', () => { - test('renders without crashing', () => { - renderComponent(); - expect(screen.getByText('Select types')).toBeInTheDocument(); - }); -}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.tsx deleted file mode 100644 index 9398790028d9a1e60cbaeee9bf85014523839570..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useContext } from 'react'; -import { elementTypesSelector } from '@/redux/configuration/configuration.selectors'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { getCheckboxElements } from './Types.utils'; -import { CheckboxFilter } from '../../CheckboxFilter'; -import { CollapsibleSection } from '../../CollapsibleSection'; -import { ExportContext } from '../ExportCompound.context'; - -export const Types = (): React.ReactNode => { - const { setTypes } = useContext(ExportContext); - const elementTypes = useAppSelector(elementTypesSelector); - const checkboxElements = getCheckboxElements(elementTypes); - - return ( - <CollapsibleSection title="Select types"> - {checkboxElements && ( - <CheckboxFilter - options={checkboxElements} - isSearchEnabled={false} - onCheckedChange={setTypes} - /> - )} - </CollapsibleSection> - ); -}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.test.ts deleted file mode 100644 index 34e10ae6cf11eba8a045e3929738f636f2a03620..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getCheckboxElements } from './Types.utils'; - -describe('getCheckboxElements', () => { - it('should return an empty array when elementTypes is undefined', () => { - const result = getCheckboxElements(undefined); - expect(result).toEqual([]); - }); - - it('should map elementTypes to MappedElementTypes and exclude duplicates based on name and parentClass', () => { - const elementTypes = [ - { className: 'class1', name: 'type1', parentClass: 'parent1' }, - { className: 'class2', name: 'type2', parentClass: 'parent2' }, - { className: 'class1', name: 'type1', parentClass: 'parent1' }, - { className: 'class3', name: 'type3', parentClass: 'parent3' }, - { className: 'class2', name: 'type2', parentClass: 'parent2' }, - ]; - - const result = getCheckboxElements(elementTypes); - - expect(result).toEqual([ - { id: 'type1', label: 'type1' }, - { id: 'type2', label: 'type2' }, - { id: 'type3', label: 'type3' }, - ]); - }); - - it('should handle an empty array of elementTypes', () => { - const result = getCheckboxElements([]); - expect(result).toEqual([]); - }); - - it('should return an empty array when elementTypes is undefined', () => { - const result = getCheckboxElements(undefined); - expect(result).toEqual([]); - }); -}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.ts deleted file mode 100644 index a8a7cc990d683cad01bd17ca5f8007f4bce4e86b..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -type ElementTypes = - | { - className: string; - name: string; - parentClass: string; - }[] - | undefined; - -type MappedElementTypes = { id: string; label: string }[]; - -type PresenceMap = { [key: string]: boolean }; - -export const getCheckboxElements = (elementTypes: ElementTypes): MappedElementTypes => { - if (!elementTypes) return []; - - const excludedTypes: PresenceMap = {}; - elementTypes?.forEach(type => { - excludedTypes[type.parentClass] = true; - }); - - const mappedElementTypes: MappedElementTypes = []; - const processedNames: PresenceMap = {}; - - elementTypes.forEach(elementType => { - if (excludedTypes[elementType.className] || processedNames[elementType.name]) return; - - processedNames[elementType.name] = true; - mappedElementTypes.push({ - id: elementType.name, - label: elementType.name, - }); - }); - - return mappedElementTypes; -}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/index.ts deleted file mode 100644 index ce8a0cc157c89e6d8b723d3b67d9479b8a1df515..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Types } from './Types.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ea949afd9f0716a6af6aabdc8d2694fe1490f16 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.test.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-magic-numbers */ +import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; + +describe('extractAndParseNumberIdFromCompartment', () => { + it('should extract and parse number id from compartment', () => { + const compartment = { id: 'compartment-123', label: 'x' }; + const result = extractAndParseNumberIdFromCompartment(compartment); + expect(result).toBe(123); + }); + + it('should handle id with non-numeric characters', () => { + const compartment = { id: 'compartment-abc', label: 'x' }; + + expect(() => extractAndParseNumberIdFromCompartment(compartment)).toThrowError( + 'compartment id is not a number', + ); + }); + + it('should handle missing id', () => { + const compartment = { id: 'compartment-', label: 'x' }; + + expect(() => extractAndParseNumberIdFromCompartment(compartment)).toThrowError( + 'compartment id is not a number', + ); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0cc82546fda54de629e92dbf24fa4e68d41de0e --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/extractAndParseNumberIdFromCompartment.ts @@ -0,0 +1,10 @@ +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.types'; + +export const extractAndParseNumberIdFromCompartment = (compartment: CheckboxItem): number => { + const [, id] = compartment.id.split('-'); + const numberId = Number(id); + + if (Number.isNaN(numberId) || id === '') throw new Error('compartment id is not a number'); + + return numberId; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.test.ts index d0a7806ce4d62ea5210b1249087b97345affa819..6254cd6bd795c47606f394660040e720e61983ea 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.test.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.test.ts @@ -5,7 +5,7 @@ import { getCompartmentPathwaysCheckboxElements } from './getCompartmentPathways describe('getCompartmentPathwaysCheckboxElements', () => { it('should return an empty array when given an empty items array', () => { const items: CompartmentPathwayDetails[] = []; - const result = getCompartmentPathwaysCheckboxElements(items); + const result = getCompartmentPathwaysCheckboxElements('include', items); expect(result).toEqual([]); }); @@ -17,12 +17,12 @@ describe('getCompartmentPathwaysCheckboxElements', () => { { id: 4, name: 'Compartment C' }, ] as CompartmentPathwayDetails[]; - const result = getCompartmentPathwaysCheckboxElements(items); + const result = getCompartmentPathwaysCheckboxElements('test', items); expect(result).toEqual([ - { id: '1', label: 'Compartment A' }, - { id: '2', label: 'Compartment B' }, - { id: '4', label: 'Compartment C' }, + { id: 'test-1', label: 'Compartment A' }, + { id: 'test-2', label: 'Compartment B' }, + { id: 'test-4', label: 'Compartment C' }, ]); }); it('should correctly extract unique names and corresponding ids from items and sorts them alphabetically', () => { @@ -34,13 +34,13 @@ describe('getCompartmentPathwaysCheckboxElements', () => { { id: 5, name: 'Compartment D' }, ] as CompartmentPathwayDetails[]; - const result = getCompartmentPathwaysCheckboxElements(items); + const result = getCompartmentPathwaysCheckboxElements('prefix', items); expect(result).toEqual([ - { id: '2', label: 'Compartment A' }, - { id: '3', label: 'Compartment B' }, - { id: '1', label: 'Compartment C' }, - { id: '5', label: 'Compartment D' }, + { id: 'prefix-2', label: 'Compartment A' }, + { id: 'prefix-3', label: 'Compartment B' }, + { id: 'prefix-1', label: 'Compartment C' }, + { id: 'prefix-5', label: 'Compartment D' }, ]); }); }); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.ts index e0f4bf81a14c4fece41eff986e4b3685b2506f16..9a807f04e97bc26296e9a4922fd312637bb7e54e 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.ts @@ -8,6 +8,7 @@ type CheckboxElement = { id: string; label: string }; type CheckboxElements = CheckboxElement[]; export const getCompartmentPathwaysCheckboxElements = ( + prefix: string, items: CompartmentPathwayDetails[], ): CheckboxElements => { const addedNames: AddedNames = {}; @@ -21,7 +22,7 @@ export const getCompartmentPathwaysCheckboxElements = ( items.forEach(setNameToIdIfUndefined); const parseIdAndLabel = ([name, id]: [name: string, id: number]): CheckboxElement => ({ - id: id.toString(), + id: `${prefix}-${id}`, label: name, }); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.test.ts index 68d1e8f0ae0858439f4ed21fc681eef713d921b1..6e9dbdac7995fcc6aa82867abf9e5ee6ec6c6546 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.test.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.test.ts @@ -1,33 +1,25 @@ +/* eslint-disable no-magic-numbers */ import { getDownloadElementsBodyRequest } from './getDownloadElementsBodyRequest'; describe('getDownloadElementsBodyRequest', () => { it('should return the correct DownloadBodyRequest object', () => { - const types = [ - { id: '1', label: 'Type 1' }, - { id: '2', label: 'Type 2' }, - ]; - const columns = [ - { id: '1', label: 'Column 1' }, - { id: '2', label: 'Column 2' }, - ]; // eslint-disable-next-line no-magic-numbers const modelIds = [1, 2, 3]; const annotations = [ - { id: '1', label: 'Annotation 1' }, - { id: '2', label: 'Annotation 2' }, + { id: 'Annotation 1', label: 'Annotation 1' }, + { id: 'Annotation 2', label: 'Annotation 2' }, ]; const includedCompartmentPathways = [ - { id: '1', label: 'Compartment 1' }, - { id: '2', label: 'Compartment 2' }, + { id: 'include-7', label: 'Compartment 1' }, + { id: 'include-8', label: 'Compartment 2' }, ]; const excludedCompartmentPathways = [ - { id: '1', label: 'Compartment 3' }, - { id: '2', label: 'Compartment 4' }, + { id: 'exclude-9', label: 'Compartment 3' }, + { id: 'exclude-10', label: 'Compartment 4' }, ]; const result = getDownloadElementsBodyRequest({ - types, - columns, + columns: ['Column 23', 'Column99'], modelIds, annotations, includedCompartmentPathways, @@ -35,13 +27,37 @@ describe('getDownloadElementsBodyRequest', () => { }); expect(result).toEqual({ - types: ['Type 1', 'Type 2'], - columns: ['Column 1', 'Column 2'], + columns: ['Column 23', 'Column99'], // eslint-disable-next-line no-magic-numbers submaps: [1, 2, 3], annotations: ['Annotation 1', 'Annotation 2'], - includedCompartmentIds: ['Compartment 1', 'Compartment 2'], - excludedCompartmentIds: ['Compartment 3', 'Compartment 4'], + includedCompartmentIds: [7, 8], + excludedCompartmentIds: [9, 10], }); }); + it('should throw error if compartment id is not a number', () => { + const modelIds = [1, 2, 3]; + const annotations = [ + { id: 'Annotation 1', label: 'Annotation 1' }, + { id: 'Annotation 2', label: 'Annotation 2' }, + ]; + const includedCompartmentPathways = [ + { id: '', label: 'Compartment 1' }, + { id: '', label: 'Compartment 2' }, + ]; + const excludedCompartmentPathways = [ + { id: '', label: 'Compartment 3' }, + { id: '', label: 'Compartment 4' }, + ]; + + expect(() => + getDownloadElementsBodyRequest({ + columns: ['Column 23', 'Column99'], + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + }), + ).toThrow('compartment id is not a number'); + }); }); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts index 1a262a95703df1efc1e0baba873acf11cc50e13a..6cfb3494a9668d99be8fb6ac5152fa01bd1f5486 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts @@ -1,17 +1,16 @@ -import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.types'; +import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; type DownloadBodyRequest = { - types: string[]; columns: string[]; submaps: number[]; annotations: string[]; - includedCompartmentIds: string[]; - excludedCompartmentIds: string[]; + includedCompartmentIds: number[]; + excludedCompartmentIds: number[]; }; type GetDownloadBodyRequestProps = { - types: CheckboxItem[]; - columns: CheckboxItem[]; + columns: string[]; modelIds: number[]; annotations: CheckboxItem[]; includedCompartmentPathways: CheckboxItem[]; @@ -19,17 +18,15 @@ type GetDownloadBodyRequestProps = { }; export const getDownloadElementsBodyRequest = ({ - types, columns, modelIds, annotations, includedCompartmentPathways, excludedCompartmentPathways, }: GetDownloadBodyRequestProps): DownloadBodyRequest => ({ - types: types.map(type => type.label), - columns: columns.map(column => column.label), + columns, submaps: modelIds, - annotations: annotations.map(annotation => annotation.label), - includedCompartmentIds: includedCompartmentPathways.map(compartment => compartment.label), - excludedCompartmentIds: excludedCompartmentPathways.map(compartment => compartment.label), + annotations: annotations.map(annotation => annotation.id), + includedCompartmentIds: includedCompartmentPathways.map(extractAndParseNumberIdFromCompartment), + excludedCompartmentIds: excludedCompartmentPathways.map(extractAndParseNumberIdFromCompartment), }); 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.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts index 1aa3d73b227fb3f5ba7ce6a3fd69e70b161ac58e..42f38d9fa45920846738d08a3aec0e79d6bc1d68 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts @@ -1,8 +1,37 @@ +/* eslint-disable no-magic-numbers */ import { getNetworkDownloadBodyRequest } from './getNetworkBodyRequest'; describe('getNetworkDownloadBodyRequest', () => { - it('should return an empty object', () => { - const result = getNetworkDownloadBodyRequest(); - expect(result).toEqual({}); + it('should return the correct DownloadBodyRequest object', () => { + const columns = ['column1', 'column2']; + const modelIds = [1, 2, 3]; + const annotations = [ + { id: 'Annotation 1', label: 'Annotation 1' }, + { id: 'Annotation 2', label: 'Annotation 2' }, + ]; + const includedCompartmentPathways = [ + { id: 'include-7', label: 'Compartment 1' }, + { id: 'include-8', label: 'Compartment 2' }, + ]; + const excludedCompartmentPathways = [ + { id: 'exclude-9', label: 'Compartment 3' }, + { id: 'exclude-10', label: 'Compartment 4' }, + ]; + + const result = getNetworkDownloadBodyRequest({ + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + }); + + expect(result).toEqual({ + columns: ['column1', 'column2'], + submaps: [1, 2, 3], + annotations: ['Annotation 1', 'Annotation 2'], + includedCompartmentIds: [7, 8], + excludedCompartmentIds: [9, 10], + }); }); }); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts index 6613aea72d35cc71d858350896d3d8ea79121e73..eadc0e8c0b9091093d92f3f0b5acf587770b036d 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts @@ -1 +1,32 @@ -export const getNetworkDownloadBodyRequest = (): object => ({}); +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.types'; +import { extractAndParseNumberIdFromCompartment } from './extractAndParseNumberIdFromCompartment'; + +type DownloadBodyRequest = { + columns: string[]; + submaps: number[]; + annotations: string[]; + includedCompartmentIds: number[]; + excludedCompartmentIds: number[]; +}; + +type GetDownloadBodyRequestProps = { + columns: string[]; + modelIds: number[]; + annotations: CheckboxItem[]; + includedCompartmentPathways: CheckboxItem[]; + excludedCompartmentPathways: CheckboxItem[]; +}; + +export const getNetworkDownloadBodyRequest = ({ + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, +}: GetDownloadBodyRequestProps): DownloadBodyRequest => ({ + columns, + submaps: modelIds, + annotations: annotations.map(annotation => annotation.id), + includedCompartmentIds: includedCompartmentPathways.map(extractAndParseNumberIdFromCompartment), + excludedCompartmentIds: excludedCompartmentPathways.map(extractAndParseNumberIdFromCompartment), +}); 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 new file mode 100644 index 0000000000000000000000000000000000000000..4bf77ac6c6bfd29c2bc9d3d2212198fedfee3f91 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.test.tsx @@ -0,0 +1,213 @@ +/* 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 { HttpStatusCode } from 'axios'; +import { act } from 'react-dom/test-utils'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { NETWORK_COLUMNS } from '../ExportCompound/ExportCompound.constant'; +import { Network } from './Network.component'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const renderComponent = ( + initialStore?: InitialStoreState, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStore); + return ( + render( + <Wrapper> + <Network /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Network - component', () => { + it('should render all network sections', () => { + renderComponent({ + ...INITIAL_STORE_STATE_MOCK, + configuration: { + ...CONFIGURATION_INITIAL_STORE_MOCK, + main: { + ...CONFIGURATION_INITIAL_STORE_MOCK.main, + data: { + ...configurationFixture, + miriamTypes: { + compartment_label: { + commonName: 'Compartment', + homepage: '', + registryIdentifier: '', + uris: [''], + }, + }, + }, + }, + }, + statistics: { + data: { + ...statisticsFixture, + reactionAnnotations: { + compartment_label: 1, + pathway: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + const annotations = screen.getByText('Select annotations'); + const includedCompartmentPathways = screen.getByText('Select included compartment / pathways'); + const excludedCompartmentPathways = screen.getByText('Select excluded compartment / pathways'); + const downloadButton = screen.getByText('Download'); + + expect(annotations).toBeVisible(); + expect(includedCompartmentPathways).toBeVisible(); + expect(excludedCompartmentPathways).toBeVisible(); + expect(downloadButton).toBeVisible(); + }); + it('should handle download button click and dispatch proper data', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + const FIRST_COMPARMENT_PATHWAY_NAME = compartmentPathwaysDetailsFixture[0].name; + const FIRST_COMPARMENT_PATHWAY_ID = compartmentPathwaysDetailsFixture[0].id; + 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: { + ...CONFIGURATION_INITIAL_STORE_MOCK.main, + data: { + ...configurationFixture, + miriamTypes: { + reaction: { + commonName: 'Reaction Label', + homepage: '', + registryIdentifier: '', + uris: [''], + }, + }, + }, + }, + }, + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: { + compartment: 1, + pathway: 0, + }, + reactionAnnotations: { + reaction: 2, + path: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + models: { + data: modelsFixture, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + + const annotations = screen.getByText('Select annotations'); + + await act(() => { + annotations.click(); + }); + const annotationInput = screen.getByLabelText('Reaction Label'); + + await act(() => { + annotationInput.click(); + }); + + expect(annotationInput).toBeChecked(); + + const includedCompartmentPathways = screen.getByText('Select included compartment / pathways'); + + await act(() => { + includedCompartmentPathways.click(); + }); + const includedCompartmentPathwaysInput = screen.getAllByLabelText( + FIRST_COMPARMENT_PATHWAY_NAME, + )[0]; + + await act(() => { + includedCompartmentPathwaysInput.click(); + }); + + expect(includedCompartmentPathwaysInput).toBeChecked(); + + const excludedCompartmentPathways = screen.getByText('Select excluded compartment / pathways'); + + await act(() => { + excludedCompartmentPathways.click(); + }); + const excludedCompartmentPathwaysInput = screen.getAllByLabelText( + SECOND_COMPARMENT_PATHWAY_NAME, + )[1]; + + await act(() => { + excludedCompartmentPathwaysInput.click(); + }); + + expect(excludedCompartmentPathwaysInput).toBeChecked(); + + const downloadButton = screen.getByText('Download'); + + await act(() => { + downloadButton.click(); + }); + + const actions = store.getActions(); + + const firstAction = actions[0]; + expect(firstAction.meta.arg).toEqual({ + columns: NETWORK_COLUMNS, + submaps: modelsFixture.map(item => item.idObject), + annotations: ['reaction'], + includedCompartmentIds: [FIRST_COMPARMENT_PATHWAY_ID], + excludedCompartmentIds: [SECOND_COMPARMENT_PATHWAY_ID], + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx index 1438a0851f3581298f226532ff874bf44821a1b6..48f87045de1809a1062674b9adcb52d447717d33 100644 --- a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx @@ -1,15 +1,14 @@ import { Export } from '../ExportCompound'; +import { ANNOTATIONS_TYPE } from '../ExportCompound/ExportCompound.constant'; export const Network = (): React.ReactNode => { return ( <div data-testid="export-tab"> <Export> - <Export.Types /> - <Export.Columns /> - <Export.Annotations /> + <Export.Annotations type={ANNOTATIONS_TYPE.NETWORK} /> <Export.IncludedCompartmentPathways /> <Export.ExcludedCompartmentPathways /> - <Export.DownloadElements /> + <Export.DownloadNetwork /> </Export> </div> ); diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx index 37ede68975b5ade8a9631fc4d13d80d1ecfd4d2c..081a86ba29e4f8f4651e67b9dcb412380a21f5d4 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx @@ -17,6 +17,7 @@ import { apiPath } from '@/redux/apiPath'; import { CORE_PD_MODEL_MOCK } from '@/models/mocks/modelsMock'; import { MODELS_INITIAL_STATE_MOCK } from '@/redux/models/models.mock'; import { parseOverlayBioEntityToOlRenderingFormat } from '@/redux/overlayBioEntity/overlayBioEntity.utils'; +import { BASE_API_URL } from '@/constants'; import { OverlayListItem } from './OverlayListItem.component'; const mockedAxiosNewClient = mockNetworkNewAPIResponse(); @@ -111,6 +112,29 @@ describe('OverlayListItem - component', () => { }); }); - // TODO implement when connecting logic to component - it.skip('should trigger download overlay to PC on download button click', () => {}); + it('should trigger download overlay to PC on download button click', () => { + const OVERLAY_ID = 21; + renderComponent({ + map: { + ...initialMapStateFixture, + data: { ...initialMapStateFixture.data, backgroundId: EMPTY_BACKGROUND_ID }, + }, + backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, + overlayBioEntity: { ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, overlaysId: [OVERLAY_ID] }, + models: { ...MODELS_INITIAL_STATE_MOCK, data: [CORE_PD_MODEL_MOCK] }, + }); + + const downloadButton = screen.getByText('Download'); + + expect(downloadButton).toBeVisible(); + + const windowOpenMock = jest.spyOn(window, 'open').mockImplementation(); + + downloadButton.click(); + + expect(windowOpenMock).toHaveBeenCalledWith( + `${BASE_API_URL}/${apiPath.downloadOverlay(OVERLAY_ID)}`, + '_blank', + ); + }); }); diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx index 21eefae1d72c6821d494efd7ed29c8b34553bee9..82c5965d44326e1440e1752719094222db49f3b9 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx @@ -1,7 +1,7 @@ import { Button } from '@/shared/Button'; import Image from 'next/image'; import spinnerIcon from '@/assets/vectors/icons/spinner.svg'; -import { useOverlay } from './hooks/useOverlay'; +import { useOverlay } from '../../hooks/useOverlay'; interface OverlayListItemProps { name: string; @@ -9,8 +9,8 @@ interface OverlayListItemProps { } export const OverlayListItem = ({ name, overlayId }: OverlayListItemProps): JSX.Element => { - const onDownloadOverlay = (): void => {}; - const { toggleOverlay, isOverlayActive, isOverlayLoading } = useOverlay(overlayId); + const { toggleOverlay, isOverlayActive, isOverlayLoading, downloadOverlay } = + useOverlay(overlayId); return ( <li className="flex flex-row flex-nowrap justify-between pl-5 [&:not(:last-of-type)]:mb-4"> @@ -32,7 +32,7 @@ export const OverlayListItem = ({ name, overlayId }: OverlayListItemProps): JSX. )} {isOverlayActive || isOverlayActive ? 'Disable' : 'View'} </Button> - <Button className="max-h-8" variantStyles="ghost" onClick={onDownloadOverlay}> + <Button className="max-h-8" variantStyles="ghost" onClick={downloadOverlay}> Download </Button> </div> diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx index b7c754c8f76d782d67e55a0beec1f0c2f1a3021c..9fb75c283445be61f5ea351185806fb7ca790eb1 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx @@ -7,7 +7,6 @@ import { getReduxWrapperWithStore, } from '@/utils/testing/getReduxWrapperWithStore'; import { AppDispatch, RootState, StoreType } from '@/redux/store'; -import { DEFAULT_ERROR } from '@/constants/errors'; import { drawerOverlaysStepOneFixture } from '@/redux/drawer/drawerFixture'; import { MockStoreEnhanced } from 'redux-mock-store'; import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; @@ -19,6 +18,7 @@ import { createdOverlayFixture, uploadedOverlayFileContentFixture, } from '@/models/fixtures/overlaysFixture'; +import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock'; import { UserOverlayForm } from './UserOverlayForm.component'; const mockedAxiosClient = mockNetworkResponse(); @@ -81,15 +81,7 @@ describe('UserOverlayForm - Component', () => { loading: 'succeeded', error: { message: '', name: '' }, }, - overlays: { - data: [], - loading: 'idle', - error: DEFAULT_ERROR, - addOverlay: { - loading: 'idle', - error: DEFAULT_ERROR, - }, - }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, }); fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); @@ -112,15 +104,7 @@ describe('UserOverlayForm - Component', () => { loading: 'succeeded', error: { message: '', name: '' }, }, - overlays: { - data: [], - loading: 'idle', - error: DEFAULT_ERROR, - addOverlay: { - loading: 'idle', - error: DEFAULT_ERROR, - }, - }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, }); fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); @@ -139,15 +123,7 @@ describe('UserOverlayForm - Component', () => { it('should update the form inputs based on overlay content provided by elements list', async () => { renderComponent({ - overlays: { - data: [], - loading: 'idle', - error: DEFAULT_ERROR, - addOverlay: { - loading: 'idle', - error: DEFAULT_ERROR, - }, - }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, }); fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); @@ -171,15 +147,7 @@ describe('UserOverlayForm - Component', () => { type: 'text/plain', }); renderComponent({ - overlays: { - data: [], - loading: 'idle', - error: DEFAULT_ERROR, - addOverlay: { - loading: 'idle', - error: DEFAULT_ERROR, - }, - }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, }); fireEvent.change(screen.getByTestId('dropzone-input'), { @@ -192,15 +160,7 @@ describe('UserOverlayForm - Component', () => { it('should not submit when form is not filled', async () => { renderComponent({ - overlays: { - data: [], - loading: 'idle', - error: DEFAULT_ERROR, - addOverlay: { - loading: 'idle', - error: DEFAULT_ERROR, - }, - }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, }); expect(screen.getByTestId('overlay-description')).toHaveValue(''); fireEvent.click(screen.getByLabelText('upload overlay')); @@ -214,15 +174,7 @@ describe('UserOverlayForm - Component', () => { loading: 'succeeded', error: { message: '', name: '' }, }, - overlays: { - data: [], - loading: 'idle', - error: DEFAULT_ERROR, - addOverlay: { - loading: 'idle', - error: DEFAULT_ERROR, - }, - }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, }); const backButton = screen.getByRole('back-button'); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx index e5e7267c317de2608eead6063a5149f397b2dcad..116c9a792f8b7534412468a0631d4cc410ca30a9 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx @@ -61,6 +61,7 @@ describe('UserOverlays component', () => { modalTitle: '', overviewImagesState: {}, molArtState: {}, + editOverlayState: null, }, }); screen.getByLabelText('login button').click(); @@ -81,4 +82,16 @@ describe('UserOverlays component', () => { expect(screen.getByLabelText('add overlay button')).toBeInTheDocument(); }); + it('renders user overlays section when user is authenticated', () => { + renderComponent({ + user: { + loading: 'succeeded', + authenticated: true, + error: { name: '', message: '' }, + login: 'test', + }, + }); + + expect(screen.getByText('Without group')).toBeInTheDocument(); + }); }); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx index 2db18b4201bb8ce15a36ea59994a5238e011cb79..08e161b2cb9ee434c28220c41f7636e56b8fa1e6 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx @@ -4,6 +4,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { openLoginModal } from '@/redux/modal/modal.slice'; import { authenticatedUserSelector, loadingUserSelector } from '@/redux/user/user.selectors'; import { Button } from '@/shared/Button'; +import { UserOverlaysWithoutGroup } from './UserOverlaysWithoutGroup'; export const UserOverlays = (): JSX.Element => { const dispatch = useAppDispatch(); @@ -20,11 +21,11 @@ export const UserOverlays = (): JSX.Element => { }; return ( - <div className="p-6"> + <div className="py-6"> {isPending && <h1>Loading</h1>} {!isPending && !authenticatedUser && ( - <> + <div className="px-6"> <p className="mb-5 font-semibold">User provided overlays:</p> <p className="mb-5 text-sm"> You are not logged in, please login to upload and view custom overlays @@ -32,16 +33,19 @@ export const UserOverlays = (): JSX.Element => { <Button onClick={handleLoginClick} aria-label="login button"> Login </Button> - </> + </div> )} {authenticatedUser && ( - <div className="flex items-center justify-between"> - <p>User provided overlays:</p> - <Button onClick={handleAddOverlay} aria-label="add overlay button"> - Add overlay - </Button> - </div> + <> + <div className="flex items-center justify-between px-6"> + <p className="font-semibold">User provided overlays:</p> + <Button onClick={handleAddOverlay} aria-label="add overlay button"> + Add overlay + </Button> + </div> + <UserOverlaysWithoutGroup /> + </> )} </div> ); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/UserOverlayActions.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/UserOverlayActions.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..16b0cda2c027907d6da026dcddc5d0db25d52a1a --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/UserOverlayActions.component.tsx @@ -0,0 +1,54 @@ +import { useSelect } from 'downshift'; +import { twMerge } from 'tailwind-merge'; +import React, { useMemo } from 'react'; +import { MapOverlay } from '@/types/models'; +import { Icon } from '@/shared/Icon'; +import { useUserOverlayActions } from './hooks/useUserOverlayActions'; +import { ACTION_TYPES } from './UserOverlayActions.constants'; + +type UserOverlayActionsProps = { + overlay: MapOverlay; +}; + +export const UserOverlayActions = ({ overlay }: UserOverlayActionsProps): React.ReactNode => { + const actions = useMemo(() => Object.values(ACTION_TYPES), []); + const { isOpen, getToggleButtonProps, getMenuProps, getItemProps } = useSelect({ + items: actions, + }); + + const { handleActionClick } = useUserOverlayActions(overlay); + + return ( + <div className="relative"> + <div + className="flex cursor-pointer justify-between bg-white p-2" + {...getToggleButtonProps()} + data-testid="actions-button" + > + <Icon name="three-dots" className="h-[22px] w-[4px]" /> + </div> + <ul + className={twMerge( + `absolute right-0 top-0 z-10 w-28 rounded-lg border border-[#DBD9D9] bg-white px-2.5 text-center shadow-md`, + !isOpen && 'hidden', + )} + {...getMenuProps()} + > + {isOpen && + actions.map((item, index) => ( + <li + key={item} + {...getItemProps({ + item, + index, + onClick: () => handleActionClick(item), + })} + className='relative cursor-pointer px-2.5 py-4 text-xs before:absolute before:bottom-0 before:left-1/2 before:top-full before:block before:h-px before:w-11/12 before:-translate-x-1/2 before:bg-[#E3E3E3] before:content-[""] before:last-of-type:hidden' + > + {item} + </li> + ))} + </ul> + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/UserOverlayActions.constants.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/UserOverlayActions.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf82dee881829f9b3f50100681c434d8b565ab95 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/UserOverlayActions.constants.ts @@ -0,0 +1,4 @@ +export const ACTION_TYPES = { + EDIT: 'Edit', + DOWNLOAD: 'Download', +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/hooks/useUserOverlayActions.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/hooks/useUserOverlayActions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7e7135bb456ff9d20bd35780c9c673bbec2a257 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/hooks/useUserOverlayActions.test.ts @@ -0,0 +1,68 @@ +/* eslint-disable no-magic-numbers */ +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { BASE_API_URL } from '@/constants'; +import { apiPath } from '@/redux/apiPath'; +import { useUserOverlayActions } from './useUserOverlayActions'; + +describe('useUserOverlayActions', () => { + it('should handle handleActionClick based on edit action', async () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({}); + + const { + result: { + current: { handleActionClick }, + }, + } = renderHook(() => useUserOverlayActions(overlayFixture), { + wrapper: Wrapper, + }); + + await act(() => { + handleActionClick('Edit'); + }); + + const actions = store.getActions(); + + const FIRST_ACTION = actions[0]; + + expect(FIRST_ACTION.payload).toBe(overlayFixture); + expect(FIRST_ACTION.type).toBe('modal/openEditOverlayModal'); + }); + it('should handle handleActionClick based on download action', async () => { + const { Wrapper } = getReduxStoreWithActionsListener({}); + + const { + result: { + current: { handleActionClick }, + }, + } = renderHook(() => useUserOverlayActions(overlayFixture), { + wrapper: Wrapper, + }); + + const windowOpenMock = jest.spyOn(window, 'open').mockImplementation(); + + await act(() => { + handleActionClick('Download'); + }); + + expect(windowOpenMock).toHaveBeenCalledWith( + `${BASE_API_URL}/${apiPath.downloadOverlay(overlayFixture.idObject)}`, + '_blank', + ); + }); + it('should throw Error if handleActionClick action is not valid', async () => { + const { Wrapper } = getReduxStoreWithActionsListener({}); + + const { + result: { + current: { handleActionClick }, + }, + } = renderHook(() => useUserOverlayActions(overlayFixture), { + wrapper: Wrapper, + }); + + expect(() => handleActionClick('Wrong Action')).toThrow('Wrong Action is not valid'); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/hooks/useUserOverlayActions.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/hooks/useUserOverlayActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b10cb09b23152180c8789be7a4dbf37e1ae7a4b --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/hooks/useUserOverlayActions.ts @@ -0,0 +1,39 @@ +import { BASE_API_URL } from '@/constants'; +import { apiPath } from '@/redux/apiPath'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { openEditOverlayModal } from '@/redux/modal/modal.slice'; +import { MapOverlay } from '@/types/models'; +import { ACTION_TYPES } from '../UserOverlayActions.constants'; + +type UseUserOverlayActionsReturn = { + handleActionClick: (action: string) => void; +}; + +export const useUserOverlayActions = (overlay: MapOverlay): UseUserOverlayActionsReturn => { + const dispatch = useAppDispatch(); + + const handleDownloadOverlay = (): void => { + window.open(`${BASE_API_URL}/${apiPath.downloadOverlay(overlay.idObject)}`, '_blank'); + }; + + const handleEditOverlay = (): void => { + dispatch(openEditOverlayModal(overlay)); + }; + + const handleActionClick = (action: string): void => { + switch (action) { + case ACTION_TYPES.DOWNLOAD: + handleDownloadOverlay(); + break; + case ACTION_TYPES.EDIT: + handleEditOverlay(); + break; + default: + throw new Error(`${action} is not valid`); + } + }; + + return { + handleActionClick, + }; +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ebaabbff8fbc3d71d4f6a9142dcf2ddf3eb8f46 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayActions/index.ts @@ -0,0 +1 @@ +export { UserOverlayActions } from './UserOverlayActions.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayInfo/UserOverlayInfo.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayInfo/UserOverlayInfo.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..78dff20cd2f87dc290da3ff75d77af02dfa316c6 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayInfo/UserOverlayInfo.component.tsx @@ -0,0 +1,32 @@ +/* eslint-disable no-magic-numbers */ +import { twMerge } from 'tailwind-merge'; +import React, { useMemo } from 'react'; +import { Icon } from '@/shared/Icon'; + +type UserOverlayInfoProps = { + description: string; + name: string; +}; + +export const UserOverlayInfo = ({ description, name }: UserOverlayInfoProps): React.ReactNode => { + const isOverflowPossibility = useMemo(() => name.length > 25, [name]); + + return ( + <div className="flex items-center gap-x-2.5"> + <span className="text-sm">{name}</span> + + <div className="group relative" data-testid="info"> + <Icon name="info" className="h-4 w-4 fill-black" /> + + <div + className={twMerge( + 'absolute bottom-0 left-0 top-auto z-20 hidden min-w-[200px] max-w-xs rounded-lg bg-white px-3 py-4 drop-shadow-md group-hover:block', + isOverflowPossibility && 'min-w-[100px] max-w-[200px]', + )} + > + <p className="text-xs">{description}</p> + </div> + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayListItem.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayListItem.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e96e8751115186f741bafd321277d9a0fb993965 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/UserOverlayListItem.component.tsx @@ -0,0 +1,62 @@ +import { Button } from '@/shared/Button'; +import { MapOverlay } from '@/types/models'; +import { twMerge } from 'tailwind-merge'; +import Image from 'next/image'; +import spinnerIcon from '@/assets/vectors/icons/spinner.svg'; +import { useOverlay } from '../../../hooks/useOverlay'; +import { UserOverlayActions } from './UserOverlayActions'; +import { UserOverlayInfo } from './UserOverlayInfo/UserOverlayInfo.component'; +import { useDragAndDrop } from './hooks/useDragAndDrop'; + +type OverlayListItemProps = { + index: number; + moveUserOverlay: (dragIndex: number, hoverIndex: number) => void; + userOverlay: MapOverlay; + updateUserOverlaysOrder: () => void; +}; + +export const UserOverlayListItem = ({ + index, + moveUserOverlay, + userOverlay, + updateUserOverlaysOrder, +}: OverlayListItemProps): JSX.Element => { + const { toggleOverlay, isOverlayActive, isOverlayLoading } = useOverlay(userOverlay.idObject); + const { dragRef, dropRef, isDragging } = useDragAndDrop({ + onDrop: updateUserOverlaysOrder, + onHover: moveUserOverlay, + index, + }); + + return ( + <li + ref={node => dragRef(dropRef(node))} + className={twMerge( + 'flex flex-row flex-nowrap items-center justify-between overflow-visible py-4 pl-10 pr-5', + isDragging ? 'opacity-0' : 'opacity-100', + )} + > + <UserOverlayInfo description={userOverlay.description} name={userOverlay.name} /> + <div className="flex flex-row flex-nowrap items-center"> + <Button + variantStyles="ghost" + className="mr-4 max-h-8 flex-none gap-1.5" + onClick={toggleOverlay} + data-testid="toggle-overlay-button" + > + {isOverlayLoading && ( + <Image + src={spinnerIcon} + alt="spinner icon" + height={12} + width={12} + className="animate-spin" + /> + )} + {isOverlayActive || isOverlayLoading ? 'Disable' : 'View'} + </Button> + <UserOverlayActions overlay={userOverlay} /> + </div> + </li> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/hooks/useDragAndDrop.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/hooks/useDragAndDrop.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e3eebc9b1cd5ed60ab25411efb8e581a5161135 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/hooks/useDragAndDrop.ts @@ -0,0 +1,51 @@ +/* eslint-disable no-param-reassign */ +import { ConnectDragSource, ConnectDropTarget, useDrag, useDrop } from 'react-dnd'; + +const ITEM_TYPE = 'card'; + +type UseDragAndDropProps = { + index: number; + onHover: (dragIndex: number, hoverIndex: number) => void; + onDrop: () => void; +}; + +type UseDragAndDropReturn = { + isDragging: boolean; + dragRef: ConnectDragSource; + dropRef: ConnectDropTarget; +}; + +export const useDragAndDrop = ({ + index, + onDrop, + onHover, +}: UseDragAndDropProps): UseDragAndDropReturn => { + const [{ isDragging }, dragRef] = useDrag({ + type: ITEM_TYPE, + item: { index }, + collect: monitor => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [, dropRef] = useDrop({ + accept: ITEM_TYPE, + hover: (item: { index: number }) => { + const dragIndex = item.index; + const hoverIndex = index; + + onHover(dragIndex, hoverIndex); + + item.index = hoverIndex; + }, + drop() { + onDrop(); + }, + }); + + return { + isDragging, + dragRef, + dropRef, + }; +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1950f98f4a85ba30819bc6a4a09ee28a4e2e8e43 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlayListItem/index.ts @@ -0,0 +1 @@ +export { UserOverlayListItem } from './UserOverlayListItem.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1174b330a03faa1d38a7ae3b3fb93b4682bc0b3f --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.test.tsx @@ -0,0 +1,345 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { overlayFixture, overlaysFixture } from '@/models/fixtures/overlaysFixture'; +import { apiPath } from '@/redux/apiPath'; +import { DEFAULT_ERROR } from '@/constants/errors'; +import { act } from 'react-dom/test-utils'; +import { BASE_API_URL } from '@/constants'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock'; +import { MODAL_INITIAL_STATE_MOCK } from '@/redux/modal/modal.mock'; +import { UserOverlaysWithoutGroup } from './UserOverlaysWithoutGroup.component'; + +const mockedAxiosClient = mockNetworkResponse(); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <UserOverlaysWithoutGroup /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('UserOverlaysWithoutGroup - component', () => { + it('should render list of overlays', async () => { + mockedAxiosClient + .onGet(apiPath.getAllUserOverlaysByCreatorQuery({ creator: 'test', publicOverlay: false })) + .reply(HttpStatusCode.Ok, overlaysFixture); + renderComponent({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: overlaysFixture, + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const withoutGroupTitle = screen.getByText('Without group'); + + expect(withoutGroupTitle).toBeVisible(); + + await act(() => { + withoutGroupTitle.click(); + }); + + for (let index = 0; index < overlaysFixture.length; index += 1) { + const overlay = overlaysFixture[index]; + expect(screen.getByText(overlay.name)).toBeVisible(); + } + }); + + it('should display loading message if fetching user overlays is pending', async () => { + mockedAxiosClient + .onGet(apiPath.getAllUserOverlaysByCreatorQuery({ creator: 'test', publicOverlay: false })) + .reply(HttpStatusCode.Ok, overlaysFixture); + + renderComponent({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should display functioning action types list after click', async () => { + renderComponent({ + modal: MODAL_INITIAL_STATE_MOCK, + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: overlaysFixture, + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const withoutGroupTitle = screen.getByText('Without group'); + + expect(withoutGroupTitle).toBeVisible(); + + await act(() => { + withoutGroupTitle.click(); + }); + + const actionsButton = screen.getAllByTestId('actions-button'); + + const firstActionsButton = actionsButton[0]; + + expect(firstActionsButton).toBeVisible(); + + await act(() => { + firstActionsButton.click(); + }); + + expect(screen.getByText('Download')).toBeVisible(); + expect(screen.getByText('Edit')).toBeVisible(); + }); + it('should display overlay description on info icon hover/click', async () => { + mockedAxiosClient + .onGet(apiPath.getAllUserOverlaysByCreatorQuery({ creator: 'test', publicOverlay: false })) + .reply(HttpStatusCode.Ok, [overlayFixture]); + renderComponent({ + modal: MODAL_INITIAL_STATE_MOCK, + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: [overlayFixture], + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const withoutGroupTitle = screen.getByText('Without group'); + + expect(withoutGroupTitle).toBeVisible(); + + await act(() => { + withoutGroupTitle.click(); + }); + + const info = screen.getByTestId('info'); + + expect(info).toBeVisible(); + + await act(() => { + info.click(); + }); + + expect(screen.getByText(overlayFixture.description)).toBeVisible(); + }); + it('should change state to display edit overlay modal after edit action click', async () => { + const { store } = renderComponent({ + modal: MODAL_INITIAL_STATE_MOCK, + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: overlaysFixture, + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const withoutGroupTitle = screen.getByText('Without group'); + + expect(withoutGroupTitle).toBeVisible(); + + await act(() => { + withoutGroupTitle.click(); + }); + + const actionsButton = screen.getAllByTestId('actions-button'); + + const firstActionsButton = actionsButton[0]; + + expect(firstActionsButton).toBeVisible(); + + await act(() => { + firstActionsButton.click(); + }); + + const editAction = screen.getByText('Edit'); + + expect(editAction).toBeVisible(); + + await act(() => { + editAction.click(); + }); + + const { modalName, isOpen } = store.getState().modal; + + expect(modalName).toBe('edit-overlay'); + expect(isOpen).toBe(true); + }); + it('should display propert text for toggle overlay button', async () => { + renderComponent({ + modal: MODAL_INITIAL_STATE_MOCK, + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: [overlayFixture], + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + overlayBioEntity: { + data: [], + overlaysId: [], + }, + }); + + const withoutGroupTitle = screen.getByText('Without group'); + + expect(withoutGroupTitle).toBeVisible(); + + await act(() => { + withoutGroupTitle.click(); + }); + + const toggleOverlayButton = screen.getByTestId('toggle-overlay-button'); + + expect(toggleOverlayButton).toBeVisible(); + + await act(() => { + toggleOverlayButton.click(); + }); + + expect(screen.getByTestId('toggle-overlay-button')).toHaveTextContent('Disable'); + }); + it('should call window.open with download link after download action click', async () => { + renderComponent({ + modal: MODAL_INITIAL_STATE_MOCK, + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: [overlayFixture], + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const withoutGroupTitle = screen.getByText('Without group'); + + expect(withoutGroupTitle).toBeVisible(); + + await act(() => { + withoutGroupTitle.click(); + }); + + const actionButton = screen.getByTestId('actions-button'); + + await act(() => { + actionButton.click(); + }); + + const windowOpenMock = jest.spyOn(window, 'open').mockImplementation(); + + const downloadButton = screen.getByText('Download'); + expect(downloadButton).toBeVisible(); + + await act(() => { + downloadButton.click(); + }); + + expect(windowOpenMock).toHaveBeenCalledWith( + `${BASE_API_URL}/${apiPath.downloadOverlay(overlayFixture.idObject)}`, + '_blank', + ); + }); + it('should display spinner icon if user overlay is loading', async () => { + const OVERLAY_ID = overlayFixture.idObject; + renderComponent({ + modal: MODAL_INITIAL_STATE_MOCK, + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: [overlayFixture], + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + overlayBioEntity: { + data: { + [OVERLAY_ID]: {}, + }, + overlaysId: [OVERLAY_ID], + }, + }); + + const withoutGroupTitle = screen.getByText('Without group'); + + expect(withoutGroupTitle).toBeVisible(); + + await act(() => { + withoutGroupTitle.click(); + }); + + expect(screen.getByAltText('spinner icon')).toBeVisible(); + expect(screen.getByText('Disable')).toBeVisible(); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad61cc51b83e80675de64cddaf2e163b9d9b48c1 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.tsx @@ -0,0 +1,49 @@ +import { + Accordion, + AccordionItem, + AccordionItemButton, + AccordionItemHeading, + AccordionItemPanel, +} from '@/shared/Accordion'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { UserOverlayListItem } from './UserOverlayListItem'; +import { useUserOverlays } from './hooks/useUserOverlays'; + +export const UserOverlaysWithoutGroup = (): React.ReactNode => { + const { moveUserOverlayListItem, updateUserOverlaysOrder, isPending, userOverlaysList } = + useUserOverlays(); + + return ( + <DndProvider backend={HTML5Backend}> + <div className="mt-2.5"> + <Accordion allowZeroExpanded> + <AccordionItem className="border-b-0"> + <AccordionItemHeading> + <AccordionItemButton className="px-6 text-sm font-semibold"> + Without group + </AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel> + {isPending ? ( + <span className="py-4 pl-10 pr-5">Loading...</span> + ) : ( + <ul> + {userOverlaysList?.map((userOverlay, index) => ( + <UserOverlayListItem + moveUserOverlay={moveUserOverlayListItem} + key={userOverlay.idObject} + index={index} + userOverlay={userOverlay} + updateUserOverlaysOrder={updateUserOverlaysOrder} + /> + ))} + </ul> + )} + </AccordionItemPanel> + </AccordionItem> + </Accordion> + </div> + </DndProvider> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.utils.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c07bab617c8bcb8d8d65ce44031e06f8f6fab6e --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.utils.test.ts @@ -0,0 +1,230 @@ +/* eslint-disable no-magic-numbers */ +import { MapOverlay } from '@/types/models'; +import { moveArrayElement } from './UserOverlaysWithoutGroup.utils'; + +const INPUT_ARRAY: MapOverlay[] = [ + { + name: 'Overlay1', + description: 'Description1', + type: 'Type1', + googleLicenseConsent: true, + creator: 'Creator1', + genomeType: 'GenomeType1', + genomeVersion: 'GenomeVersion1', + idObject: 1, + publicOverlay: false, + order: 1, + }, + { + name: 'Overlay2', + description: 'Description2', + type: 'Type2', + googleLicenseConsent: false, + creator: 'Creator2', + genomeType: 'GenomeType2', + genomeVersion: 'GenomeVersion2', + idObject: 2, + publicOverlay: true, + order: 2, + }, + { + name: 'Overlay3', + description: 'Description3', + type: 'Type3', + googleLicenseConsent: true, + creator: 'Creator3', + genomeType: 'GenomeType3', + genomeVersion: 'GenomeVersion3', + idObject: 3, + publicOverlay: false, + order: 3, + }, +]; + +describe('moveArrayElement', () => { + it('should move an element down in the array', () => { + const expectedResult: MapOverlay[] = [ + { + name: 'Overlay1', + description: 'Description1', + type: 'Type1', + googleLicenseConsent: true, + creator: 'Creator1', + genomeType: 'GenomeType1', + genomeVersion: 'GenomeVersion1', + idObject: 1, + publicOverlay: false, + order: 1, + }, + { + name: 'Overlay3', + description: 'Description3', + type: 'Type3', + googleLicenseConsent: true, + creator: 'Creator3', + genomeType: 'GenomeType3', + genomeVersion: 'GenomeVersion3', + idObject: 3, + publicOverlay: false, + order: 3, + }, + { + name: 'Overlay2', + description: 'Description2', + type: 'Type2', + googleLicenseConsent: false, + creator: 'Creator2', + genomeType: 'GenomeType2', + genomeVersion: 'GenomeVersion2', + idObject: 2, + publicOverlay: true, + order: 2, + }, + ]; + + const result = moveArrayElement(INPUT_ARRAY, 1, 2); + + expect(result).toEqual(expectedResult); + }); + + it('should move an element up in the array', () => { + const expectedResult: MapOverlay[] = [ + { + name: 'Overlay1', + description: 'Description1', + type: 'Type1', + googleLicenseConsent: true, + creator: 'Creator1', + genomeType: 'GenomeType1', + genomeVersion: 'GenomeVersion1', + idObject: 1, + publicOverlay: false, + order: 1, + }, + { + name: 'Overlay3', + description: 'Description3', + type: 'Type3', + googleLicenseConsent: true, + creator: 'Creator3', + genomeType: 'GenomeType3', + genomeVersion: 'GenomeVersion3', + idObject: 3, + publicOverlay: false, + order: 3, + }, + { + name: 'Overlay2', + description: 'Description2', + type: 'Type2', + googleLicenseConsent: false, + creator: 'Creator2', + genomeType: 'GenomeType2', + genomeVersion: 'GenomeVersion2', + idObject: 2, + publicOverlay: true, + order: 2, + }, + ]; + + const result = moveArrayElement(INPUT_ARRAY, 2, 1); + + expect(result).toEqual(expectedResult); + }); + + it('should handle moving an element to the beginning of the array', () => { + const expectedResult: MapOverlay[] = [ + { + name: 'Overlay3', + description: 'Description3', + type: 'Type3', + googleLicenseConsent: true, + creator: 'Creator3', + genomeType: 'GenomeType3', + genomeVersion: 'GenomeVersion3', + idObject: 3, + publicOverlay: false, + order: 3, + }, + { + name: 'Overlay1', + description: 'Description1', + type: 'Type1', + googleLicenseConsent: true, + creator: 'Creator1', + genomeType: 'GenomeType1', + genomeVersion: 'GenomeVersion1', + idObject: 1, + publicOverlay: false, + order: 1, + }, + { + name: 'Overlay2', + description: 'Description2', + type: 'Type2', + googleLicenseConsent: false, + creator: 'Creator2', + genomeType: 'GenomeType2', + genomeVersion: 'GenomeVersion2', + idObject: 2, + publicOverlay: true, + order: 2, + }, + ]; + + const result = moveArrayElement(INPUT_ARRAY, 2, 0); + + expect(result).toEqual(expectedResult); + }); + + it('should handle moving an element to the end of the array', () => { + const expectedResult: MapOverlay[] = [ + { + name: 'Overlay2', + description: 'Description2', + type: 'Type2', + googleLicenseConsent: false, + creator: 'Creator2', + genomeType: 'GenomeType2', + genomeVersion: 'GenomeVersion2', + idObject: 2, + publicOverlay: true, + order: 2, + }, + { + name: 'Overlay3', + description: 'Description3', + type: 'Type3', + googleLicenseConsent: true, + creator: 'Creator3', + genomeType: 'GenomeType3', + genomeVersion: 'GenomeVersion3', + idObject: 3, + publicOverlay: false, + order: 3, + }, + { + name: 'Overlay1', + description: 'Description1', + type: 'Type1', + googleLicenseConsent: true, + creator: 'Creator1', + genomeType: 'GenomeType1', + genomeVersion: 'GenomeVersion1', + idObject: 1, + publicOverlay: false, + order: 1, + }, + ]; + + const result = moveArrayElement(INPUT_ARRAY, 0, 2); + + expect(result).toEqual(expectedResult); + }); + + it('should handle out-of-bounds indices gracefully', () => { + const result = moveArrayElement(INPUT_ARRAY, 5, 1); + + expect(result).toEqual(INPUT_ARRAY); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.utils.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ce68cd3642b76b3c6e63657c40b024772a3f1b6 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.utils.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-magic-numbers */ +import { MapOverlay } from '@/types/models'; + +export const moveArrayElement = ( + arr: MapOverlay[], + dragIndex: number, + hoverIndex: number, +): MapOverlay[] => { + const arrayClone = [...arr]; + + const lastIndex = arr.length - 1; + if (hoverIndex > lastIndex || dragIndex > lastIndex) return arrayClone; + + const [removedElement] = arrayClone.splice(dragIndex, 1); + + arrayClone.splice(hoverIndex, 0, removedElement); + + return arrayClone; +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..230d5c13df247dd24c121257ad495dfa93a908f3 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.test.ts @@ -0,0 +1,204 @@ +/* eslint-disable no-magic-numbers */ +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { renderHook, waitFor } from '@testing-library/react'; +import { DEFAULT_ERROR } from '@/constants/errors'; +import { overlayFixture, overlaysFixture } from '@/models/fixtures/overlaysFixture'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { act } from 'react-dom/test-utils'; +import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock'; +import { useUserOverlays } from './useUserOverlays'; + +const mockedAxiosClient = mockNetworkResponse(); + +describe('useUserOverlays', () => { + it('should fetch user overlays on mount if login exists', async () => { + mockedAxiosClient + .onGet( + apiPath.getAllUserOverlaysByCreatorQuery({ + publicOverlay: false, + creator: 'test', + }), + ) + .reply(HttpStatusCode.Ok, overlaysFixture); + + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + + renderHook(() => useUserOverlays(), { + wrapper: Wrapper, + }); + + const actions = store.getActions(); + const firstAction = actions[0]; + + expect(firstAction.meta.arg).toBe('test'); + expect(firstAction.type).toBe('overlays/getAllUserOverlaysByCreator/pending'); + + await waitFor(() => { + expect(actions[1].type).toBe('overlays/getAllUserOverlaysByCreator/fulfilled'); + }); + }); + it('should not fetch user overlays on mount if login does not exist', async () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: false, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: null, + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + + const { + result: { + current: { userOverlaysList }, + }, + } = renderHook(() => useUserOverlays(), { + wrapper: Wrapper, + }); + + const actions = store.getActions(); + const firstAction = actions[0]; + + expect(firstAction).toBeUndefined(); + expect(userOverlaysList).toEqual([]); + }); + it('should store fetched user overlays to userOverlaysList state', () => { + mockedAxiosClient + .onGet( + apiPath.getAllUserOverlaysByCreatorQuery({ + publicOverlay: false, + creator: 'test', + }), + ) + .reply(HttpStatusCode.Ok, overlaysFixture); + + const { Wrapper } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: overlaysFixture, + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const { + result: { + current: { userOverlaysList }, + }, + } = renderHook(() => useUserOverlays(), { + wrapper: Wrapper, + }); + + expect(userOverlaysList).toEqual(overlaysFixture); + }); + it('should move user overlay list item on order change', async () => { + const FIRST_USER_OVERLAY = overlayFixture; + const SECOND_USER_OVERLAY = overlayFixture; + mockedAxiosClient + .onGet( + apiPath.getAllUserOverlaysByCreatorQuery({ + publicOverlay: false, + creator: 'test', + }), + ) + .reply(HttpStatusCode.Ok, [FIRST_USER_OVERLAY, SECOND_USER_OVERLAY]); + + const { Wrapper } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: [FIRST_USER_OVERLAY, SECOND_USER_OVERLAY], + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const { + result: { + current: { moveUserOverlayListItem, userOverlaysList }, + }, + } = renderHook(() => useUserOverlays(), { + wrapper: Wrapper, + }); + + await act(() => { + moveUserOverlayListItem(0, 1); + }); + + expect(userOverlaysList).toEqual([SECOND_USER_OVERLAY, FIRST_USER_OVERLAY]); + }); + it('calls updateOverlays on calling updateUserOverlaysOrder', async () => { + const FIRST_USER_OVERLAY = { ...overlayFixture, order: 1, idObject: 12 }; + const SECOND_USER_OVERLAY = { ...overlayFixture, order: 2, idObject: 92 }; + mockedAxiosClient + .onGet( + apiPath.getAllUserOverlaysByCreatorQuery({ + publicOverlay: false, + creator: 'test', + }), + ) + .reply(HttpStatusCode.Ok, [FIRST_USER_OVERLAY, SECOND_USER_OVERLAY]); + + const { Wrapper, store } = getReduxStoreWithActionsListener({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + overlays: { + ...OVERLAYS_INITIAL_STATE_MOCK, + userOverlays: { + data: [FIRST_USER_OVERLAY, SECOND_USER_OVERLAY], + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const { + result: { + current: { moveUserOverlayListItem, updateUserOverlaysOrder }, + }, + } = renderHook(() => useUserOverlays(), { + wrapper: Wrapper, + }); + + await act(() => { + moveUserOverlayListItem(0, 1); + }); + + updateUserOverlaysOrder(); + + const actions = store.getActions(); + expect(actions[1].type).toBe('overlays/getAllUserOverlaysByCreator/fulfilled'); + + const secondAction = actions[2]; + expect(secondAction.type).toBe('overlays/updateOverlays/pending'); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fe3ee559c9022bf6c05bd336dccbba7c8ae8bc2 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.ts @@ -0,0 +1,71 @@ +/* eslint-disable no-magic-numbers */ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + loadingUserOverlaysSelector, + userOverlaysDataSelector, +} from '@/redux/overlays/overlays.selectors'; +import { getAllUserOverlaysByCreator, updateOverlays } from '@/redux/overlays/overlays.thunks'; +import { loginUserSelector } from '@/redux/user/user.selectors'; +import { MapOverlay } from '@/types/models'; +import { useEffect, useState } from 'react'; +import { moveArrayElement } from '../UserOverlaysWithoutGroup.utils'; + +type UseUserOverlaysReturn = { + isPending: boolean; + userOverlaysList: MapOverlay[]; + moveUserOverlayListItem: (dragIndex: number, hoverIndex: number) => void; + updateUserOverlaysOrder: () => void; +}; + +export const useUserOverlays = (): UseUserOverlaysReturn => { + const dispatch = useAppDispatch(); + const login = useAppSelector(loginUserSelector); + const [userOverlaysList, setUserOverlaysList] = useState<MapOverlay[]>([]); + const userOverlays = useAppSelector(userOverlaysDataSelector); + const loadingUserOverlays = useAppSelector(loadingUserOverlaysSelector); + const isPending = loadingUserOverlays === 'pending'; + + useEffect(() => { + if (login) { + dispatch(getAllUserOverlaysByCreator(login)); + } + }, [login, dispatch]); + + useEffect(() => { + if (userOverlays) { + setUserOverlaysList(userOverlays); + } + }, [userOverlays]); + + const moveUserOverlayListItem = (dragIndex: number, hoverIndex: number): void => { + const updatedUserOverlays = moveArrayElement(userOverlaysList, dragIndex, hoverIndex); + setUserOverlaysList(updatedUserOverlays); + }; + + const updateUserOverlaysOrder = (): void => { + const reorderedUserOverlays = []; + if (!userOverlays) return; + + for (let index = 0; index < userOverlays.length; index += 1) { + const userOverlay = userOverlays[index]; + const newOrderedUserOverlay = { + ...userOverlaysList[index], + order: index + 1, + }; + + if (userOverlay.idObject !== newOrderedUserOverlay.idObject) { + reorderedUserOverlays.push(newOrderedUserOverlay); + } + } + + dispatch(updateOverlays(reorderedUserOverlays)); + }; + + return { + moveUserOverlayListItem, + updateUserOverlaysOrder, + isPending, + userOverlaysList, + }; +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8de206dcdfc757c2d7e20091aa14c03f835620f --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/index.ts @@ -0,0 +1 @@ +export { UserOverlaysWithoutGroup } from './UserOverlaysWithoutGroup.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.test.ts b/src/components/Map/Drawer/OverlaysDrawer/hooks/useEmptyBackground.test.ts similarity index 100% rename from src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.test.ts rename to src/components/Map/Drawer/OverlaysDrawer/hooks/useEmptyBackground.test.ts diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.ts b/src/components/Map/Drawer/OverlaysDrawer/hooks/useEmptyBackground.ts similarity index 100% rename from src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.ts rename to src/components/Map/Drawer/OverlaysDrawer/hooks/useEmptyBackground.ts diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts b/src/components/Map/Drawer/OverlaysDrawer/hooks/useOverlay.ts similarity index 79% rename from src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts rename to src/components/Map/Drawer/OverlaysDrawer/hooks/useOverlay.ts index d69c8df3d5755a839d24ef06a3cbca6f2d3f3e66..2016e292da4bb59d28659a4a0cff46535b1425a9 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts +++ b/src/components/Map/Drawer/OverlaysDrawer/hooks/useOverlay.ts @@ -6,10 +6,13 @@ import { } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; import { removeOverlayBioEntityForGivenOverlay } from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import { getOverlayBioEntityForAllModels } from '@/redux/overlayBioEntity/overlayBioEntity.thunk'; +import { BASE_API_URL } from '@/constants'; +import { apiPath } from '@/redux/apiPath'; import { useEmptyBackground } from './useEmptyBackground'; type UseOverlay = { toggleOverlay: () => void; + downloadOverlay: () => void; isOverlayActive: boolean; isOverlayLoading: boolean; }; @@ -29,5 +32,9 @@ export const useOverlay = (overlayId: number): UseOverlay => { } }; - return { toggleOverlay, isOverlayActive, isOverlayLoading }; + const downloadOverlay = (): void => { + window.open(`${BASE_API_URL}/${apiPath.downloadOverlay(overlayId)}`, '_blank'); + }; + + return { toggleOverlay, isOverlayActive, isOverlayLoading, downloadOverlay }; }; diff --git a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx index c0b7368f0b9ab106806ac58284a9e0a87f6f669d..96f3870a818b1b86b7f482c47b9cccafc7351982 100644 --- a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx +++ b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx @@ -17,6 +17,7 @@ export const MapAdditionalActions = (): JSX.Element => { className="flex h-12 w-12 items-center justify-center rounded-full bg-white" onClick={zoomInToBioEntities} data-testid="location-button" + title="Center map" > <Icon className="h-[28px] w-[28px]" name="location" /> </button> @@ -26,6 +27,7 @@ export const MapAdditionalActions = (): JSX.Element => { className="flex h-12 w-12 items-center justify-center" onClick={zoomIn} data-testid="zoom-in-button" + title="Zoom in" > <Icon className="h-[24px] w-[24px]" name="magnifier-zoom-in" /> </button> @@ -35,6 +37,7 @@ export const MapAdditionalActions = (): JSX.Element => { className="flex h-12 w-12 items-center justify-center" onClick={zoomOut} data-testid="zoom-out-button" + title="Zoom out" > <Icon className="h-[24px] w-[24px]" name="magnifier-zoom-out" /> </button> diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts index 7d296729a6fde1d04dec7fa42f3b4f8a313b54a7..f711b392c959c975ed207dccd23b09365166ed6b 100644 --- a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts +++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts @@ -6,6 +6,8 @@ import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreA import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { renderHook } from '@testing-library/react'; import Map from 'ol/Map'; +import { initialMapDataFixture } from '@/redux/map/map.fixtures'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; import { useAddtionalActions } from './useAdditionalActions'; import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates'; @@ -122,8 +124,54 @@ describe('useAddtionalActions - hook', () => { useVisibleBioEntitiesPolygonCoordinatesMock.mockImplementation(() => undefined); }); - it('should return undefined', () => { - const { Wrapper } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK); + it('should return undefined and center map by calculating coordinates when default center coordinates do not exist', () => { + const MAP_CONFIG = { + size: { + width: 3500, + height: 2000, + }, + zoom: { + minZoom: 2, + maxZoom: 9, + }, + position: { + x: 1300, + y: 1900, + z: 7, + }, + }; + const { Wrapper, store } = getReduxStoreWithActionsListener({ + ...INITIAL_STORE_STATE_MOCK, + models: { + ...INITIAL_STORE_STATE_MOCK.models, + data: [ + { + ...modelsFixture[0], + ...MAP_CONFIG.size, + ...MAP_CONFIG.zoom, + defaultCenterX: null, + defaultCenterY: null, + defaultZoomLevel: null, + }, + ], + }, + map: { + ...INITIAL_STORE_STATE_MOCK.map, + data: { + ...initialMapDataFixture, + position: { + last: MAP_CONFIG.position, + initial: MAP_CONFIG.position, + }, + size: { + ...MAP_CONFIG.size, + ...MAP_CONFIG.zoom, + tileSize: 256, + }, + modelId: modelsFixture[0].idObject, + }, + }, + }); const { result: { current: { zoomInToBioEntities }, @@ -131,8 +179,81 @@ describe('useAddtionalActions - hook', () => { } = renderHook(() => useAddtionalActions(), { wrapper: Wrapper, }); + const result = zoomInToBioEntities(); + expect(result).toBeUndefined(); + const actions = store.getActions(); + const position = store.getState().map?.data.position; + expect(position?.last).toEqual(MAP_CONFIG.position); + expect(actions[0]).toEqual({ + payload: { x: 1750, y: 1000, z: 5 }, + type: 'map/setMapPosition', + }); + }); - expect(zoomInToBioEntities()).toBeUndefined(); + it('should return undefined and center map using default center coordinates if exist', () => { + const MAP_CONFIG = { + size: { + width: 5000, + height: 10000, + }, + zoom: { + minZoom: 2, + maxZoom: 9, + }, + position: { + x: 1300, + y: 1900, + z: 7, + }, + }; + const { Wrapper, store } = getReduxStoreWithActionsListener({ + ...INITIAL_STORE_STATE_MOCK, + models: { + ...INITIAL_STORE_STATE_MOCK.models, + data: [ + { + ...modelsFixture[0], + ...MAP_CONFIG.size, + ...MAP_CONFIG.zoom, + defaultCenterX: 2500, + defaultCenterY: 5000, + defaultZoomLevel: 3, + }, + ], + }, + map: { + ...INITIAL_STORE_STATE_MOCK.map, + data: { + ...initialMapDataFixture, + position: { + last: MAP_CONFIG.position, + initial: MAP_CONFIG.position, + }, + size: { + ...MAP_CONFIG.size, + ...MAP_CONFIG.zoom, + tileSize: 256, + }, + modelId: modelsFixture[0].idObject, + }, + }, + }); + const { + result: { + current: { zoomInToBioEntities }, + }, + } = renderHook(() => useAddtionalActions(), { + wrapper: Wrapper, + }); + const result = zoomInToBioEntities(); + expect(result).toBeUndefined(); + const actions = store.getActions(); + const position = store.getState().map?.data.position; + expect(position?.last).toEqual(MAP_CONFIG.position); + expect(actions[0]).toEqual({ + payload: { x: 2500, y: 5000, z: 3 }, + type: 'map/setMapPosition', + }); }); }); }); diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts index b93fc761920b36c99d3a51426a9bbed0f25f6512..bf0e8527932ebaa94cb138568d7bb984dfc67fe5 100644 --- a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts +++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts @@ -1,9 +1,12 @@ -import { varyPositionZoom } from '@/redux/map/map.slice'; +import { setMapPosition, varyPositionZoom } from '@/redux/map/map.slice'; import { SetBoundsResult, useSetBounds } from '@/utils/map/useSetBounds'; import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import { MAP_ZOOM_IN_DELTA, MAP_ZOOM_OUT_DELTA } from '../MappAdditionalActions.constants'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { currentModelIdSelector, modelByIdSelector } from '@/redux/models/models.selectors'; +import { DEFAULT_ZOOM } from '@/constants/map'; import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates'; +import { MAP_ZOOM_IN_DELTA, MAP_ZOOM_OUT_DELTA } from '../MappAdditionalActions.constants'; interface UseAddtionalActionsResult { zoomIn(): void; @@ -15,13 +18,26 @@ export const useAddtionalActions = (): UseAddtionalActionsResult => { const dispatch = useDispatch(); const setBounds = useSetBounds(); const polygonCoordinates = useVisibleBioEntitiesPolygonCoordinates(); + const currentMapModelId = useAppSelector(currentModelIdSelector); + const currentModel = useAppSelector(state => modelByIdSelector(state, currentMapModelId)); const zoomInToBioEntities = (): SetBoundsResult | undefined => { - if (!polygonCoordinates) { - return undefined; + if (polygonCoordinates) { + return setBounds(polygonCoordinates); + } + + if (currentModel) { + const HALF = 2; + const defaultPosition = { + x: currentModel?.defaultCenterX ?? currentModel.width / HALF, + y: currentModel?.defaultCenterY ?? currentModel.height / HALF, + z: currentModel?.defaultZoomLevel ?? DEFAULT_ZOOM, + }; + + dispatch(setMapPosition(defaultPosition)); } - return setBounds(polygonCoordinates); + return undefined; }; const varyZoomByDelta = useCallback( diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayLineFeature.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayLineFeature.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..74f6839491421bf93129b446cc6d543636b7961f --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayLineFeature.test.ts @@ -0,0 +1,91 @@ +import { LINE_WIDTH } from '@/constants/canvas'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { LinePoint } from '@/types/reactions'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { Feature } from 'ol'; +import { Geometry } from 'ol/geom'; +import { createOverlayLineFeature } from './createOverlayLineFeature'; + +/* eslint-disable no-magic-numbers */ +const CASES: [LinePoint, number[]][] = [ + [ + [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ], + [0, 0, 0, 0], + ], + [ + [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ], + [0, -238107693, Infinity, 0], + ], + [ + [ + { x: 100, y: 100 }, + { x: 0, y: 0 }, + ], + [0, -238107693, Infinity, 0], + ], + [ + [ + { x: 100, y: 0 }, + { x: 0, y: 100 }, + ], + [0, 0, 0, 0], + ], + [ + [ + { x: -50, y: 0 }, + { x: 0, y: -50 }, + ], + [0, 0, 0, 0], + ], +]; + +const COLOR = '#FFB3B3cc'; + +const getFeature = (linePoint: LinePoint): Feature<Geometry> => { + const { Wrapper } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + }); + const { + result: { current: pointToProjection }, + } = renderHook(() => usePointToProjection(), { + wrapper: Wrapper, + }); + + return createOverlayLineFeature(linePoint, { pointToProjection, color: COLOR }); +}; + +describe('createOverlayLineFeature - util', () => { + it.each(CASES)('should return Feature instance', linePoint => { + const feature = getFeature(linePoint); + + expect(feature).toBeInstanceOf(Feature); + }); + + it.each(CASES)('should return Feature instance with valid style and stroke', linePoint => { + const feature = getFeature(linePoint); + const style = feature.getStyle(); + + expect(style).toMatchObject({ + fill_: { color_: COLOR }, + stroke_: { + color_: COLOR, + width_: LINE_WIDTH, + }, + }); + }); + + it.each(CASES)('should return Feature instance with valid geometry', (linePoint, extent) => { + const feature = getFeature(linePoint); + const geometry = feature.getGeometry(); + + expect(geometry?.getExtent()).toEqual(extent); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayLineFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayLineFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..b38d603bbb793c6578722f2c8170cd8f5c895b51 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayLineFeature.ts @@ -0,0 +1,22 @@ +import { LinePoint } from '@/types/reactions'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { SimpleGeometry } from 'ol/geom'; +import { getLineFeature } from '../reactionsLayer/getLineFeature'; +import { getOverlayLineFeatureStyle } from './getOverlayLineFeatureStyle'; + +interface Options { + color: string; + pointToProjection: UsePointToProjectionResult; +} + +export const createOverlayLineFeature = ( + points: LinePoint, + { color, pointToProjection }: Options, +): Feature<SimpleGeometry> => { + const feature = getLineFeature(points, pointToProjection); + + feature.setStyle(getOverlayLineFeatureStyle(color)); + + return feature; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayLineFeatureStyle.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayLineFeatureStyle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..174c620d5c12b95a9033da7cf11a04a942302c91 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayLineFeatureStyle.test.ts @@ -0,0 +1,27 @@ +import { LINE_WIDTH } from '@/constants/canvas'; +import { Fill, Stroke, Style } from 'ol/style'; +import { getOverlayLineFeatureStyle } from './getOverlayLineFeatureStyle'; + +const COLORS = ['#000000', '#FFFFFF', '#F5F5F5', '#C0C0C0', '#C0C0C0aa', '#C0C0C0bb']; + +describe('getOverlayLineFeatureStyle - util', () => { + it.each(COLORS)('should return Style object', color => { + const result = getOverlayLineFeatureStyle(color); + expect(result).toBeInstanceOf(Style); + }); + + it.each(COLORS)('should set valid color values for Fill', color => { + const result = getOverlayLineFeatureStyle(color); + const fill = result.getFill(); + expect(fill).toBeInstanceOf(Fill); + expect(fill?.getColor()).toBe(color); + }); + + it.each(COLORS)('should set valid color values for Fill', color => { + const result = getOverlayLineFeatureStyle(color); + const stroke = result.getStroke(); + expect(stroke).toBeInstanceOf(Stroke); + expect(stroke?.getColor()).toBe(color); + expect(stroke?.getWidth()).toBe(LINE_WIDTH); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayLineFeatureStyle.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayLineFeatureStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..407c2416bbcee994a3d954d866520e0f2d65064c --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayLineFeatureStyle.ts @@ -0,0 +1,5 @@ +import { LINE_WIDTH } from '@/constants/canvas'; +import { Fill, Stroke, Style } from 'ol/style'; + +export const getOverlayLineFeatureStyle = (color: string): Style => + new Style({ fill: new Fill({ color }), stroke: new Stroke({ color, width: LINE_WIDTH }) }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.test.ts index b580a998904b2975b82c0a527aa805c748c38288..61841611e83b4818cf7c1382cc0ff8795e85996e 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.test.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.test.ts @@ -1,7 +1,7 @@ -import { renderHook } from '@testing-library/react'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { CONFIGURATION_INITIAL_STORE_MOCKS } from '@/redux/configuration/configuration.mock'; import { OverlayBioEntityRender } from '@/types/OLrendering'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; import { useGetOverlayColor } from './useGetOverlayColor'; describe('useOverlayFeatures - hook', () => { @@ -20,6 +20,7 @@ describe('useOverlayFeatures - hook', () => { describe('getOverlayBioEntityColorByAvailableProperties - function', () => { const ENTITY: OverlayBioEntityRender = { + type: 'rectangle', id: 0, modelId: 0, x1: 0, diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.test.ts index 19c9b309415c6943c7c86e8b1fe477c606affe81..15c9cf3e5e9820d3d3f9d604bc19a7e5dad1c383 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.test.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.test.ts @@ -1,11 +1,11 @@ /* eslint-disable no-magic-numbers */ -import { renderHook } from '@testing-library/react'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { CONFIGURATION_INITIAL_STORE_MOCKS } from '@/redux/configuration/configuration.mock'; -import { OVERLAYS_PUBLIC_FETCHED_STATE_MOCK } from '@/redux/overlays/overlays.mock'; import { mapStateWithCurrentlySelectedMainMapFixture } from '@/redux/map/map.fixtures'; import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; import { MOCKED_OVERLAY_BIO_ENTITY_RENDER } from '@/redux/overlayBioEntity/overlayBioEntity.mock'; +import { OVERLAYS_PUBLIC_FETCHED_STATE_MOCK } from '@/redux/overlays/overlays.mock'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; import { useOverlayFeatures } from './useOverlayFeatures'; /** @@ -52,7 +52,9 @@ describe('useOverlayFeatures', () => { wrapper: Wrapper, }); - expect(features).toHaveLength(6); + expect(features).toHaveLength(10); + + // type: rectangle expect(features[0].getGeometry()?.getCoordinates()).toEqual([ [ [-13149141, 18867005], @@ -65,5 +67,19 @@ describe('useOverlayFeatures', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore expect(features[0].getStyle().getFill().getColor()).toBe('#FFFFFFcc'); + + // type: line + expect(features[7].getGeometry()?.getCoordinates()).toEqual([ + [ + [-13149141, 18867005], + [-13149141, 18881970], + [-13141659, 18881970], + [-13141659, 18867005], + [-13149141, 18867005], + ], + ]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(features[7].getStyle().getFill().getColor()).toBe('#ff0000cc'); }); }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts index 06985195eb9dafe82dbf23f616b3214945c9aff2..477a95dd324f06f8099d88db81055e7324b77d5f 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts @@ -1,18 +1,21 @@ -import { useMemo } from 'react'; -import { usePointToProjection } from '@/utils/map/usePointToProjection'; -import type Feature from 'ol/Feature'; -import type Polygon from 'ol/geom/Polygon'; import { ZERO } from '@/constants/common'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { getOverlayOrderSelector, overlayBioEntitiesForCurrentModelSelector, } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { LinePoint } from '@/types/reactions'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import type Feature from 'ol/Feature'; +import { SimpleGeometry } from 'ol/geom'; +import type Polygon from 'ol/geom/Polygon'; +import { useMemo } from 'react'; import { createOverlayGeometryFeature } from './createOverlayGeometryFeature'; +import { createOverlayLineFeature } from './createOverlayLineFeature'; import { getPolygonLatitudeCoordinates } from './getPolygonLatitudeCoordinates'; import { useGetOverlayColor } from './useGetOverlayColor'; -export const useOverlayFeatures = (): Feature<Polygon>[] => { +export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometry>[] => { const pointToProjection = usePointToProjection(); const { getOverlayBioEntityColorByAvailableProperties } = useGetOverlayColor(); const overlaysOrder = useAppSelector(getOverlayOrderSelector); @@ -34,12 +37,27 @@ export const useOverlayFeatures = (): Feature<Polygon>[] => { overlaysOrder.find(({ id }) => id === entity.overlayId)?.index || ZERO, }); - return createOverlayGeometryFeature( + const color = getOverlayBioEntityColorByAvailableProperties(entity); + + if (entity.type === 'rectangle') { + return createOverlayGeometryFeature( + [ + ...pointToProjection({ x: xMin, y: entity.y1 }), + ...pointToProjection({ x: xMax, y: entity.y2 }), + ], + color, + ); + } + + return createOverlayLineFeature( [ - ...pointToProjection({ x: xMin, y: entity.y1 }), - ...pointToProjection({ x: xMax, y: entity.y2 }), - ], - getOverlayBioEntityColorByAvailableProperties(entity), + { x: entity.x1, y: entity.y1 }, + { x: entity.x2, y: entity.y2 }, + ] as LinePoint, + { + color, + pointToProjection, + }, ); }), [overlaysOrder, bioEntities, pointToProjection, getOverlayBioEntityColorByAvailableProperties], diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.ts index 40211171605c35ff87995e6ed9abf02bc300c76b..b7ce166ad5bf96b39976fc6d08fabb8fae5e6f1a 100644 --- a/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.ts +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.ts @@ -1,12 +1,12 @@ import { LinePoint } from '@/types/reactions'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import { Feature } from 'ol'; -import { LineString } from 'ol/geom'; +import { LineString, SimpleGeometry } from 'ol/geom'; export const getLineFeature = ( linePoints: LinePoint, pointToProjection: UsePointToProjectionResult, -): Feature => { +): Feature<SimpleGeometry> => { const points = linePoints.map(pointToProjection); return new Feature({ diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts index 3ab05530b0776d285bb698ea023174f678521398..295b91d1aad607f60565dbb88c33f6bc043659a0 100644 --- a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts @@ -50,7 +50,7 @@ describe('onMapPositionChange - util', () => { { x: 1479, y: 581, - z: 7, + z: 6.68620779943448, }, ], ]; diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts index 8bdf55b6e8614a029db153428d9f7defd248b114..7102fec7fdecfd1f771295030dd82e30c42bc767 100644 --- a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts +++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts @@ -18,7 +18,7 @@ export const onMapPositionChange = setMapPosition({ x, y, - z: Math.round(zoom), + z: zoom, }), ); }; diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 5d0d77ae259b644fe129036bd5bdb759fb3b9b52..5762a59b846afa2583c0454ef3fcef59d036fddb 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -3,6 +3,8 @@ import { Map } from '@/components/Map'; import { manrope } from '@/constants/font'; import { useReduxBusQueryManager } from '@/utils/query-manager/useReduxBusQueryManager'; import { twMerge } from 'tailwind-merge'; +import { useEffect } from 'react'; +import { PluginsManager } from '@/services/pluginsManager'; import { useInitializeStore } from '../../utils/initialize/useInitializeStore'; import { Modal } from '../FunctionalArea/Modal'; import { ContextMenu } from '../FunctionalArea/ContextMenu'; @@ -12,6 +14,12 @@ export const MinervaSPA = (): JSX.Element => { useInitializeStore(); useReduxBusQueryManager(); + useEffect(() => { + const unsubscribe = PluginsManager.init(); + + return () => unsubscribe(); + }, []); + return ( <div className={twMerge('relative', manrope.variable)}> <FunctionalArea /> diff --git a/src/models/compartmentPathwaySchema.ts b/src/models/compartmentPathwaySchema.ts index b7f3cdc4e4939565b564c4961086db3f26879bd1..368ff17fc1f0fc77251f2bec1b917ec1f44c2858 100644 --- a/src/models/compartmentPathwaySchema.ts +++ b/src/models/compartmentPathwaySchema.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-magic-numbers */ import { z } from 'zod'; export const compartmentPathwaySchema = z.object({ @@ -34,7 +35,7 @@ export const compartmentPathwayDetailsSchema = z.object({ hierarchyVisibilityLevel: z.string(), homomultimer: z.null(), hypothetical: z.null(), - id: z.number(), + id: z.number().gt(-1), initialAmount: z.null(), initialConcentration: z.null(), linkedSubmodel: z.null(), diff --git a/src/models/exportSchema.ts b/src/models/exportSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e01113d88696d3fb4c79183d6d29a1b5c37f869 --- /dev/null +++ b/src/models/exportSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const exportNetworkchema = z.string(); + +export const exportElementsSchema = z.string(); diff --git a/src/models/fixtures/configurationFixture.ts b/src/models/fixtures/configurationFixture.ts index 56e19f7adcb59678ca144fd6a63bb741c8e51876..c27f2840b8ae3a037decb1b48dd99cce790a3d21 100644 --- a/src/models/fixtures/configurationFixture.ts +++ b/src/models/fixtures/configurationFixture.ts @@ -5,5 +5,5 @@ import { configurationSchema } from '../configurationSchema'; export const configurationFixture = createFixture(configurationSchema, { seed: ZOD_SEED, - array: { min: 1, max: 1 }, + array: { min: 3, max: 3 }, }); diff --git a/src/models/fixtures/overlayBioEntityFixture.ts b/src/models/fixtures/overlayBioEntityFixture.ts index da0c6da654ba996874863860034ebf6856762054..4cdeaebfde4d08c304d56de8d8db889a385b7c85 100644 --- a/src/models/fixtures/overlayBioEntityFixture.ts +++ b/src/models/fixtures/overlayBioEntityFixture.ts @@ -2,9 +2,21 @@ import { ZOD_SEED } from '@/constants'; import { z } from 'zod'; // eslint-disable-next-line import/no-extraneous-dependencies import { createFixture } from 'zod-fixture'; -import { overlayBioEntitySchema } from '../overlayBioEntitySchema'; +import { + overlayBioEntitySchema, + overlayElementWithBioEntitySchema, + overlayElementWithReactionSchema, +} from '../overlayBioEntitySchema'; export const overlayBioEntityFixture = createFixture(z.array(overlayBioEntitySchema), { seed: ZOD_SEED, array: { min: 3, max: 3 }, }); + +export const overlayElementWithReactionFixture = createFixture(overlayElementWithReactionSchema, { + seed: ZOD_SEED, +}); + +export const overlayElementWithBioEntityFixture = createFixture(overlayElementWithBioEntitySchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/fixtures/pluginFixture.ts b/src/models/fixtures/pluginFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e1d83f8fe79ef99b8f6326fb1f392387752e884 --- /dev/null +++ b/src/models/fixtures/pluginFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pluginSchema } from '../pluginSchema'; + +export const pluginFixture = createFixture(pluginSchema, { + seed: ZOD_SEED, +}); 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/models/mocks/pluginsMock.ts b/src/models/mocks/pluginsMock.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5c91eafdb32833dff36caedddfc4b85d9bac2f6 --- /dev/null +++ b/src/models/mocks/pluginsMock.ts @@ -0,0 +1,44 @@ +import { MinervaPlugin } from '@/types/models'; + +export const PLUGINS_MOCK: MinervaPlugin[] = [ + { + hash: '5e3fcb59588cc311ef9839feea6382eb', + name: 'Disease-variant associations', + version: '1.0.0', + isPublic: true, + isDefault: false, + urls: ['https://minerva-service.lcsb.uni.lu/plugins/disease-associations/plugin.js'], + }, + { + hash: '20df86476c311824bbfe73d1034af89e', + name: 'GSEA', + version: '0.9.2', + isPublic: true, + isDefault: false, + urls: ['https://minerva-service.lcsb.uni.lu/plugins/gsea/plugin.js'], + }, + { + hash: '5314b9f996e56e67f0dad65e7df8b73b', + name: 'PD map guide', + version: '1.0.2', + isPublic: true, + isDefault: false, + urls: ['https://minerva-service.lcsb.uni.lu/plugins/guide/plugin.js'], + }, + { + hash: 'b85ae2f4cd67736489b5fd2b635b1013', + name: 'Map exploation', + version: '1.0.0', + isPublic: true, + isDefault: false, + urls: ['https://minerva-service.lcsb.uni.lu/plugins/exploration/plugin.js'], + }, + { + hash: '77c32edf387652dfaad8a20f2a0ce76b', + name: 'Drug reactions', + version: '1.0.0', + isPublic: true, + isDefault: false, + urls: ['https://minerva-service.lcsb.uni.lu/plugins/drug-reactions/plugin.js'], + }, +]; diff --git a/src/models/overlayBioEntitySchema.ts b/src/models/overlayBioEntitySchema.ts index d9dd58950b85a6d21bdde12e38e668af4d9b30e4..ffa8f5847d578da84236314ce036d5bc425d123c 100644 --- a/src/models/overlayBioEntitySchema.ts +++ b/src/models/overlayBioEntitySchema.ts @@ -1,8 +1,19 @@ import { z } from 'zod'; import { overlayLeftBioEntitySchema } from './overlayLeftBioEntitySchema'; +import { overlayLeftReactionSchema } from './overlayLeftReactionSchema'; import { overlayRightBioEntitySchema } from './overlayRightBioEntitySchema'; -export const overlayBioEntitySchema = z.object({ +export const overlayElementWithBioEntitySchema = z.object({ left: overlayLeftBioEntitySchema, right: overlayRightBioEntitySchema, }); + +export const overlayElementWithReactionSchema = z.object({ + left: overlayLeftReactionSchema, + right: overlayRightBioEntitySchema, +}); + +export const overlayBioEntitySchema = z.union([ + overlayElementWithBioEntitySchema, + overlayElementWithReactionSchema, +]); diff --git a/src/models/overlayLeftReactionSchema.ts b/src/models/overlayLeftReactionSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..c47febcff7136233ac38e7d4c41c6eb3d85c2144 --- /dev/null +++ b/src/models/overlayLeftReactionSchema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { lineSchema } from './lineSchema'; +import { reactionProduct } from './reactionProduct'; +import { referenceSchema } from './referenceSchema'; + +export const overlayLeftReactionSchema = z.object({ + id: z.number(), + notes: z.string(), + idReaction: z.string(), + name: z.string(), + reversible: z.boolean(), + symbol: z.null(), + abbreviation: z.null(), + formula: z.null(), + mechanicalConfidenceScore: z.null(), + lowerBound: z.null(), + upperBound: z.null(), + subsystem: z.null(), + geneProteinReaction: z.null(), + visibilityLevel: z.string(), + z: z.number(), + synonyms: z.array(z.unknown()), + model: z.number(), + kinetics: z.null(), + line: lineSchema, + processCoordinates: z.null(), + stringType: z.string(), + modifiers: z.array(reactionProduct), + reactants: z.array(reactionProduct), + products: z.array(reactionProduct), + elementId: z.string(), + operators: z.array(z.unknown()), + references: z.array(referenceSchema), +}); diff --git a/src/models/pluginSchema.ts b/src/models/pluginSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cfb0725fc31a652b5db6d5e38996002eb262af9 --- /dev/null +++ b/src/models/pluginSchema.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-magic-numbers */ +import { z } from 'zod'; + +export const pluginSchema = z.object({ + hash: z.string(), + name: z.string(), + version: z.string(), + isPublic: z.boolean(), + isDefault: z.boolean(), + urls: z.array(z.string().min(1)), +}); diff --git a/src/models/reactionProduct.ts b/src/models/reactionProduct.ts new file mode 100644 index 0000000000000000000000000000000000000000..96905877910e382f1736d595351286f5fa636807 --- /dev/null +++ b/src/models/reactionProduct.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { lineSchema } from './lineSchema'; + +export const reactionProduct = z.object({ + id: z.number(), + line: lineSchema, + stoichiometry: z.null(), + element: z.number(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 7dd6cac479e541a068318ab6fc143d683f371026..ac815f25ece6507d4ad61f5ebc2a8bc87dc6e1f9 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'; import { GetPublicationsParams, PublicationsQueryParams } from './publications/publications.types'; const getPublicationsURLSearchParams = ( @@ -61,6 +61,22 @@ export const apiPath = { getCompartmentPathwayDetails: (ids: number[]): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/?id=${ids.join(',')}`, sendCompartmentPathwaysIds: (): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/`, + downloadNetworkCsv: (): string => + `projects/${PROJECT_ID}/models/*/bioEntities/reactions/:downloadCsv`, + getAllUserOverlaysByCreatorQuery: ({ + publicOverlay, + creator, + }: { + publicOverlay: boolean; + creator: string; + }): string => + `projects/${PROJECT_ID}/overlays/?creator=${creator}&publicOverlay=${String(publicOverlay)}`, + updateOverlay: (overlayId: number): string => `projects/${PROJECT_ID}/overlays/${overlayId}/`, + removeOverlay: (overlayId: number): string => `projects/pdmap_appu_test/overlays/${overlayId}/`, + downloadElementsCsv: (): string => + `projects/${PROJECT_ID}/models/*/bioEntities/elements/:downloadCsv`, + downloadOverlay: (overlayId: number): string => + `projects/${PROJECT_ID}/overlays/${overlayId}:downloadSource`, getSourceFile: (): string => `/projects/${PROJECT_ID}:downloadSource`, getMesh: (meshId: string): string => `mesh/${meshId}`, getTaxonomy: (taxonomyId: string): string => `taxonomy/${taxonomyId}`, @@ -68,4 +84,7 @@ export const apiPath = { `/projects/${PROJECT_ID}/models/${modelId}/publications/?${getPublicationsURLSearchParams( params, )}`, + registerPluign: (): string => `plugins/`, + getPlugin: (pluginId: string): string => `plugins/${pluginId}/`, + getAllPlugins: (): string => `/plugins/`, }; 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 451590bbca72cf44ab73bec01501175c15f96612..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, @@ -88,3 +91,38 @@ 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/export/export.mock.ts b/src/redux/export/export.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..c17abce73156f1eb8a1ea32c3fda8d2e9fef392f --- /dev/null +++ b/src/redux/export/export.mock.ts @@ -0,0 +1,18 @@ +import { ExportState } from './export.types'; + +export const EXPORT_INITIAL_STATE_MOCK: ExportState = { + downloadNetwork: { + error: { + message: '', + name: '', + }, + loading: 'idle', + }, + downloadElements: { + error: { + message: '', + name: '', + }, + loading: 'idle', + }, +}; diff --git a/src/redux/export/export.reducers.test.ts b/src/redux/export/export.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..894ee98a802339b948da3a12556716284b4aa41d --- /dev/null +++ b/src/redux/export/export.reducers.test.ts @@ -0,0 +1,148 @@ +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { HttpStatusCode } from 'axios'; +import { ExportState } from './export.types'; +import exportReducer from './export.slice'; +import { apiPath } from '../apiPath'; +import { downloadNetwork, downloadElements } from './export.thunks'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const INITIAL_STATE: ExportState = { + downloadNetwork: { + loading: 'idle', + error: { name: '', message: '' }, + }, + downloadElements: { + loading: 'idle', + error: { name: '', message: '' }, + }, +}; + +describe('export reducer', () => { + global.URL.createObjectURL = jest.fn(); + let store = {} as ToolkitStoreWithSingleSlice<ExportState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('export', exportReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + expect(exportReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful downloadNetwork query', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + const { loading } = store.getState().export.downloadNetwork; + + expect(loading).toEqual('succeeded'); + }); + + it('should update store on loading downloadNetwork query', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + const downloadNetworkPromise = store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + + const { loading } = store.getState().export.downloadNetwork; + expect(loading).toEqual('pending'); + + await downloadNetworkPromise; + + const { loading: promiseFulfilled } = store.getState().export.downloadNetwork; + + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should update store after failed downloadNetwork query', async () => { + mockedAxiosClient + .onPost(apiPath.downloadNetworkCsv()) + .reply(HttpStatusCode.NotFound, undefined); + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + const { loading } = store.getState().export.downloadNetwork; + + expect(loading).toEqual('failed'); + }); + + it('should update store after successful downloadElements query', async () => { + mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test'); + await store.dispatch( + downloadElements({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + const { loading } = store.getState().export.downloadElements; + + expect(loading).toEqual('succeeded'); + }); + + it('should update store on loading downloadElements query', async () => { + mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test'); + const downloadElementsPromise = store.dispatch( + downloadElements({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + + const { loading } = store.getState().export.downloadElements; + expect(loading).toEqual('pending'); + + await downloadElementsPromise; + + const { loading: promiseFulfilled } = store.getState().export.downloadElements; + + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should update store after failed downloadElements query', async () => { + mockedAxiosClient + .onPost(apiPath.downloadElementsCsv()) + .reply(HttpStatusCode.NotFound, undefined); + await store.dispatch( + downloadElements({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + const { loading } = store.getState().export.downloadElements; + + expect(loading).toEqual('failed'); + }); +}); diff --git a/src/redux/export/export.reducers.ts b/src/redux/export/export.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..39ea3df45651233260da3b6b1603ce18abf33fa0 --- /dev/null +++ b/src/redux/export/export.reducers.ts @@ -0,0 +1,28 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { downloadNetwork, downloadElements } from './export.thunks'; +import { ExportState } from './export.types'; + +export const downloadNetworkReducer = (builder: ActionReducerMapBuilder<ExportState>): void => { + builder.addCase(downloadNetwork.pending, state => { + state.downloadNetwork.loading = 'pending'; + }); + builder.addCase(downloadNetwork.fulfilled, state => { + state.downloadNetwork.loading = 'succeeded'; + }); + builder.addCase(downloadNetwork.rejected, state => { + state.downloadNetwork.loading = 'failed'; + }); +}; + +export const downloadElementsReducer = (builder: ActionReducerMapBuilder<ExportState>): void => { + builder.addCase(downloadElements.pending, state => { + state.downloadElements.loading = 'pending'; + }); + builder.addCase(downloadElements.fulfilled, state => { + state.downloadElements.loading = 'succeeded'; + }); + builder.addCase(downloadElements.rejected, state => { + state.downloadElements.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/export/export.slice.ts b/src/redux/export/export.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b0774dd52b3c1ce33507be9366ff636425c02ed --- /dev/null +++ b/src/redux/export/export.slice.ts @@ -0,0 +1,32 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { ExportState } from './export.types'; +import { downloadNetworkReducer, downloadElementsReducer } from './export.reducers'; + +const initialState: ExportState = { + downloadNetwork: { + error: { + message: '', + name: '', + }, + loading: 'idle', + }, + downloadElements: { + error: { + message: '', + name: '', + }, + loading: 'idle', + }, +}; + +const exportSlice = createSlice({ + name: 'export', + initialState, + reducers: {}, + extraReducers: builder => { + downloadNetworkReducer(builder); + downloadElementsReducer(builder); + }, +}); + +export default exportSlice.reducer; diff --git a/src/redux/export/export.thunks.test.ts b/src/redux/export/export.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..baad92cad09cdd06c7b68b7e3929a0d89780bcbe --- /dev/null +++ b/src/redux/export/export.thunks.test.ts @@ -0,0 +1,99 @@ +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { apiPath } from '../apiPath'; +import { ExportState } from './export.types'; +import exportReducer from './export.slice'; +import { downloadNetwork, downloadElements } from './export.thunks'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +describe('export thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<ExportState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('export', exportReducer); + + global.URL.createObjectURL = jest.fn(); + global.document.body.appendChild = jest.fn(); + }); + describe('downloadNetwork', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should download file when data response from API is valid', async () => { + mockedAxiosClient.onPost(apiPath.downloadNetworkCsv()).reply(HttpStatusCode.Ok, 'test'); + + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + expect(global.URL.createObjectURL).toHaveBeenCalledWith(new Blob(['test'])); + + expect(global.document.body.appendChild).toHaveBeenCalled(); + }); + it('should not download file when data response from API is not valid', async () => { + mockedAxiosClient + .onPost(apiPath.downloadNetworkCsv()) + .reply(HttpStatusCode.NotFound, undefined); + + await store.dispatch( + downloadNetwork({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + + expect(global.document.body.appendChild).not.toHaveBeenCalled(); + }); + }); + + describe('downloadElements', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should download file when data response from API is valid', async () => { + mockedAxiosClient.onPost(apiPath.downloadElementsCsv()).reply(HttpStatusCode.Ok, 'test'); + + await store.dispatch( + downloadElements({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + expect(global.URL.createObjectURL).toHaveBeenCalledWith(new Blob(['test'])); + + expect(global.document.body.appendChild).toHaveBeenCalled(); + }); + it('should not download file when data response from API is not valid', async () => { + mockedAxiosClient + .onPost(apiPath.downloadElementsCsv()) + .reply(HttpStatusCode.NotFound, undefined); + + await store.dispatch( + downloadElements({ + annotations: [], + columns: [], + excludedCompartmentIds: [], + includedCompartmentIds: [], + submaps: [], + }), + ); + + expect(global.document.body.appendChild).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/redux/export/export.thunks.ts b/src/redux/export/export.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a25cd8c40f76e9442d43fa354bd7877d29b4306 --- /dev/null +++ b/src/redux/export/export.thunks.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-magic-numbers */ +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { PROJECT_ID } from '@/constants'; +import { ExportNetwork, ExportElements } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { exportNetworkchema, exportElementsSchema } from '@/models/exportSchema'; +import { apiPath } from '../apiPath'; +import { downloadFileFromBlob } from './export.utils'; + +type DownloadElementsBodyRequest = { + columns: string[]; + submaps: number[]; + annotations: string[]; + includedCompartmentIds: number[]; + excludedCompartmentIds: number[]; +}; + +export const downloadElements = createAsyncThunk( + 'export/downloadElements', + async (data: DownloadElementsBodyRequest): Promise<void> => { + const response = await axiosInstanceNewAPI.post<ExportElements>( + apiPath.downloadElementsCsv(), + data, + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, exportElementsSchema); + + if (isDataValid) { + downloadFileFromBlob(response.data, `${PROJECT_ID}-elementExport.csv`); + } + }, +); + +type DownloadNetworkBodyRequest = { + columns: string[]; + submaps: number[]; + annotations: string[]; + includedCompartmentIds: number[]; + excludedCompartmentIds: number[]; +}; + +export const downloadNetwork = createAsyncThunk( + 'export/downloadNetwork', + async (data: DownloadNetworkBodyRequest): Promise<void> => { + const response = await axiosInstanceNewAPI.post<ExportNetwork>( + apiPath.downloadNetworkCsv(), + data, + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, exportNetworkchema); + + if (isDataValid) { + downloadFileFromBlob(response.data, `${PROJECT_ID}-networkExport.csv`); + } + }, +); diff --git a/src/redux/export/export.types.ts b/src/redux/export/export.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf53310214bbb1713690081f292f9c85284c5291 --- /dev/null +++ b/src/redux/export/export.types.ts @@ -0,0 +1,12 @@ +import { Loading } from '@/types/loadingState'; + +export type ExportState = { + downloadNetwork: { + loading: Loading; + error: Error; + }; + downloadElements: { + loading: Loading; + error: Error; + }; +}; diff --git a/src/redux/export/export.utils.ts b/src/redux/export/export.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..60cba1fd90f1fe16a31e44a266f7bc8dd41206a6 --- /dev/null +++ b/src/redux/export/export.utils.ts @@ -0,0 +1,9 @@ +export const downloadFileFromBlob = (data: string, filename: string): void => { + const url = window.URL.createObjectURL(new Blob([data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); +}; diff --git a/src/redux/modal/modal.constants.ts b/src/redux/modal/modal.constants.ts index 8d8f27bd056e1fb05496918ffe10dc64725a3cb5..f0df9964801af60a083ceb6a773b4a7c8375c40f 100644 --- a/src/redux/modal/modal.constants.ts +++ b/src/redux/modal/modal.constants.ts @@ -11,4 +11,5 @@ export const MODAL_INITIAL_STATE: ModalState = { molArtState: { uniprotId: MOL_ART_UNIPROT_ID_DEFAULT, }, + editOverlayState: null, }; diff --git a/src/redux/modal/modal.mock.ts b/src/redux/modal/modal.mock.ts index 47c46050e50f3f1c4c1257ab11c6aee3315f0fec..22b833031510bdea484a479d1fc43faa52c66c74 100644 --- a/src/redux/modal/modal.mock.ts +++ b/src/redux/modal/modal.mock.ts @@ -11,4 +11,5 @@ export const MODAL_INITIAL_STATE_MOCK: ModalState = { molArtState: { uniprotId: MOL_ART_UNIPROT_ID_DEFAULT, }, + editOverlayState: null, }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index 7b1581806cf4062d447c6c3a3d9e32286f5655df..2cda42b432235ac40f371a35be3f93fe6629f653 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -1,6 +1,6 @@ import { ModalName } from '@/types/modal'; import { PayloadAction } from '@reduxjs/toolkit'; -import { ModalState } from './modal.types'; +import { ModalState, OpenEditOverlayModalAction } from './modal.types'; export const openModalReducer = (state: ModalState, action: PayloadAction<ModalName>): void => { state.isOpen = true; @@ -56,3 +56,13 @@ export const openPublicationsModalReducer = (state: ModalState): void => { state.modalName = 'publications'; state.modalTitle = 'Publications'; }; + +export const openEditOverlayModalReducer = ( + state: ModalState, + action: OpenEditOverlayModalAction, +): void => { + state.isOpen = true; + state.modalName = 'edit-overlay'; + state.modalTitle = action.payload.name; + state.editOverlayState = action.payload; +}; diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts index 3ac54031a23b84c37876e56b2b8b6820da8a6203..c77223ea30f739d335170d382c9ecb524b6b6a49 100644 --- a/src/redux/modal/modal.selector.ts +++ b/src/redux/modal/modal.selector.ts @@ -15,3 +15,8 @@ export const currentSelectedBioEntityIdSelector = createSelector( modalSelector, modal => modal?.molArtState.uniprotId || MOL_ART_UNIPROT_ID_DEFAULT, ); + +export const currentEditedOverlaySelector = createSelector( + modalSelector, + modal => modal.editOverlayState, +); diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index a324db237e715053968b125448f9048234da523e..75de9c430304b2829e30352a9ffb1561cb39336e 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -8,6 +8,7 @@ import { openMolArtModalByIdReducer, setOverviewImageIdReducer, openPublicationsModalReducer, + openEditOverlayModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -21,6 +22,7 @@ const modalSlice = createSlice({ setOverviewImageId: setOverviewImageIdReducer, openLoginModal: openLoginModalReducer, openPublicationsModal: openPublicationsModalReducer, + openEditOverlayModal: openEditOverlayModalReducer, }, }); @@ -32,6 +34,7 @@ export const { openMolArtModalById, openLoginModal, openPublicationsModal, + openEditOverlayModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts index a6ddf286297afd30e9c97a00edb1b531ad5fccf1..dfb5ca5d5c5a1f7b020b766bed4b7602e9cc2526 100644 --- a/src/redux/modal/modal.types.ts +++ b/src/redux/modal/modal.types.ts @@ -1,4 +1,6 @@ import { ModalName } from '@/types/modal'; +import { MapOverlay } from '@/types/models'; +import { PayloadAction } from '@reduxjs/toolkit'; export type OverviewImagesModalState = { imageId?: number; @@ -8,10 +10,17 @@ export type MolArtModalState = { uniprotId?: string | undefined; }; +export type EditOverlayState = MapOverlay | null; + export interface ModalState { isOpen: boolean; modalName: ModalName; modalTitle: string; overviewImagesState: OverviewImagesModalState; molArtState: MolArtModalState; + editOverlayState: EditOverlayState; } + +export type OpenEditOverlayModalPayload = MapOverlay; + +export type OpenEditOverlayModalAction = PayloadAction<OpenEditOverlayModalPayload>; diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts index f078c75776c7b9148fc23c94ac1464780638ac82..99d94b7648a22f1d07d77ac63cdf69592d55c0f9 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -45,6 +45,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/redux/overlayBioEntity/overlayBioEntity.mock.ts b/src/redux/overlayBioEntity/overlayBioEntity.mock.ts index 84fab91d714d1acdbb49aa3275b6b5fe13cc668c..9b093c3eeaecee77b8fc2923f37f8cd3da8c5eec 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.mock.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.mock.ts @@ -8,6 +8,7 @@ export const OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK: OverlaysBioEntityState = { export const MOCKED_OVERLAY_BIO_ENTITY_RENDER: OverlayBioEntityRender[] = [ { + type: 'rectangle', id: 1, modelId: 52, width: 30, @@ -21,6 +22,7 @@ export const MOCKED_OVERLAY_BIO_ENTITY_RENDER: OverlayBioEntityRender[] = [ color: null, }, { + type: 'rectangle', id: 2, modelId: 52, width: 30, @@ -34,6 +36,7 @@ export const MOCKED_OVERLAY_BIO_ENTITY_RENDER: OverlayBioEntityRender[] = [ color: null, }, { + type: 'rectangle', id: 3, modelId: 52, width: 40, @@ -46,4 +49,32 @@ export const MOCKED_OVERLAY_BIO_ENTITY_RENDER: OverlayBioEntityRender[] = [ value: null, color: { rgb: -65536, alpha: 0 }, }, + { + type: 'line', + id: 66143, + modelId: 52, + x1: 4462.61826820353, + x2: 4571.99387254902, + y1: 7105.89040426431, + y2: 6979.823529411765, + width: 109.3756043454905, + height: 126.06687485254497, + value: null, + overlayId: 20, + color: null, + }, + { + type: 'line', + id: 66144, + modelId: 52, + x1: 4454.850442288663, + x2: 4463.773636826477, + y1: 7068.434324866321, + y2: 7112.188429617157, + width: 8.923194537814197, + height: 43.75410475083663, + value: null, + overlayId: 20, + color: null, + }, ]; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts index 8a09ebb08aca68fb46d2bb1a908fceb45c41bd56..555c1c87e768169ccba7b2454b313314320c7c9d 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts @@ -25,6 +25,9 @@ export const getOverlayBioEntity = createAsyncThunk( }: GetOverlayBioEntityThunkProps): Promise<OverlayBioEntityRender[] | undefined> => { const response = await axiosInstanceNewAPI.get<OverlayBioEntity[]>( apiPath.getOverlayBioEntity({ overlayId, modelId }), + { + withCredentials: true, + }, ); const validOverlayBioEntities = getValidOverlayBioEntities(response.data); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts index 7c92f7a20080e78660bcf391194990c13940451b..6656d725badcafef167e19d63226804077ca4a21 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts @@ -2,6 +2,8 @@ import { ONE } from '@/constants/common'; import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; import { OverlayBioEntityRender } from '@/types/OLrendering'; import { OverlayBioEntity } from '@/types/models'; +import { getOverlayReactionCoordsFromLine } from '@/utils/overlays/getOverlayReactionCoords'; +import { isBioEntity, isReaction } from '@/utils/overlays/overlaysElementsTypeGuards'; import { z } from 'zod'; export const parseOverlayBioEntityToOlRenderingFormat = ( @@ -9,8 +11,16 @@ export const parseOverlayBioEntityToOlRenderingFormat = ( overlayId: number, ): OverlayBioEntityRender[] => data.reduce((acc: OverlayBioEntityRender[], entity: OverlayBioEntity) => { - if (entity.left.x && entity.left.y) { + /** + * The're two types of entities - bioentity and reaction + * Bioentity comes with the single only element + * And reaction comes with many different lines that needs to be merged together + * Every reaction line is a different entity after reduce + */ + + if (isBioEntity(entity)) { acc.push({ + type: 'rectangle', id: entity.left.id, modelId: entity.left.model, x1: entity.left.x, @@ -24,6 +34,31 @@ export const parseOverlayBioEntityToOlRenderingFormat = ( color: entity.right.color, }); } + + if (isReaction(entity)) { + const { products, reactants, modifiers } = entity.left; + const lines = [products, reactants, modifiers].flat().map(element => element.line); + const coords = lines.map(getOverlayReactionCoordsFromLine).flat(); + const elements = coords.map( + ({ x1, x2, y1, y2, id, width, height }): OverlayBioEntityRender => ({ + type: 'line', + id, + modelId: entity.left.model, + x1, + x2, + y1, + y2, + width, + height: Math.abs(height), + value: entity.right.value, + overlayId, + color: entity.right.color, + }), + ); + + acc.push(...elements); + } + return acc; }, []); diff --git a/src/redux/overlays/overlays.mock.ts b/src/redux/overlays/overlays.mock.ts index 3e1557ba4e7604dd58aa294eeba2bd13ebced06e..7942bb040427bcc40b158893a6abab60cbdd27e8 100644 --- a/src/redux/overlays/overlays.mock.ts +++ b/src/redux/overlays/overlays.mock.ts @@ -10,6 +10,19 @@ export const OVERLAYS_INITIAL_STATE_MOCK: OverlaysState = { loading: 'idle', error: DEFAULT_ERROR, }, + userOverlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + }, + updateOverlays: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + removeOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, }; export const PUBLIC_OVERLAYS_MOCK: MapOverlay[] = [ @@ -85,6 +98,19 @@ export const OVERLAYS_PUBLIC_FETCHED_STATE_MOCK: OverlaysState = { loading: 'idle', error: DEFAULT_ERROR, }, + userOverlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + }, + updateOverlays: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + removeOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, }; export const ADD_OVERLAY_MOCK = { diff --git a/src/redux/overlays/overlays.reducers.test.ts b/src/redux/overlays/overlays.reducers.test.ts index 2fe92673346f59194fc6f78a5a85443d31d0b273..90ee7771f73590345e165b792ac9ad5cfd2385f8 100644 --- a/src/redux/overlays/overlays.reducers.test.ts +++ b/src/redux/overlays/overlays.reducers.test.ts @@ -3,6 +3,7 @@ import { PROJECT_ID } from '@/constants'; import { createdOverlayFileFixture, createdOverlayFixture, + overlayFixture, overlaysFixture, uploadedOverlayFileContentFixture, } from '@/models/fixtures/overlaysFixture'; @@ -15,7 +16,13 @@ import { HttpStatusCode } from 'axios'; import { waitFor } from '@testing-library/react'; import { apiPath } from '../apiPath'; import overlaysReducer from './overlays.slice'; -import { addOverlay, getAllPublicOverlaysByProjectId } from './overlays.thunks'; +import { + addOverlay, + getAllPublicOverlaysByProjectId, + getAllUserOverlaysByCreator, + removeOverlay, + updateOverlays, +} from './overlays.thunks'; import { OverlaysState } from './overlays.types'; import { ADD_OVERLAY_MOCK } from './overlays.mock'; @@ -29,6 +36,19 @@ const INITIAL_STATE: OverlaysState = { loading: 'idle', error: { name: '', message: '' }, }, + userOverlays: { + data: [], + loading: 'idle', + error: { name: '', message: '' }, + }, + updateOverlays: { + loading: 'idle', + error: { name: '', message: '' }, + }, + removeOverlay: { + loading: 'idle', + error: { name: '', message: '' }, + }, }; describe('overlays reducer', () => { @@ -142,4 +162,133 @@ describe('overlays reducer', () => { expect(loading).toEqual('failed'); }); + + it('should update store when getAllUserOverlaysByCreator is pending', async () => { + mockedAxiosClient + .onGet(apiPath.getAllUserOverlaysByCreatorQuery({ creator: 'test', publicOverlay: false })) + .reply(HttpStatusCode.Ok, overlaysFixture); + + await store.dispatch(getAllUserOverlaysByCreator('test')); + const { loading } = store.getState().overlays.userOverlays; + + waitFor(() => { + expect(loading).toEqual('pending'); + }); + }); + + it('should update store after successful getAllUserOverlaysByCreator', async () => { + mockedAxiosClient + .onGet(apiPath.getAllUserOverlaysByCreatorQuery({ creator: 'test', publicOverlay: false })) + .reply(HttpStatusCode.Ok, overlaysFixture); + + const getUserOverlaysPromise = store.dispatch(getAllUserOverlaysByCreator('test')); + const { loading } = store.getState().overlays.userOverlays; + expect(loading).toBe('pending'); + + await getUserOverlaysPromise; + + const { loading: loadingFulfilled, error } = store.getState().overlays.userOverlays; + expect(loadingFulfilled).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + }); + it('should update store after failed getAllUserOverlaysByCreator', async () => { + mockedAxiosClient + .onGet(apiPath.getAllUserOverlaysByCreatorQuery({ creator: 'test', publicOverlay: false })) + .reply(HttpStatusCode.NotFound, {}); + + await store.dispatch(getAllUserOverlaysByCreator('test')); + const { loading } = store.getState().overlays.userOverlays; + expect(loading).toEqual('failed'); + }); + + it('should update store when updateOverlay is pending', async () => { + mockedAxiosClient + .onPatch(apiPath.updateOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, overlayFixture); + + store.dispatch(updateOverlays([overlayFixture])); + const { loading } = store.getState().overlays.updateOverlays; + expect(loading).toBe('pending'); + }); + + it('should update store after successful updateOverlay', async () => { + mockedAxiosClient + .onPatch(apiPath.updateOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, overlayFixture); + + const updateUserOverlaysPromise = store.dispatch(updateOverlays([overlayFixture])); + const { loading } = store.getState().overlays.updateOverlays; + expect(loading).toBe('pending'); + + await updateUserOverlaysPromise; + + const { loading: loadingFulfilled, error } = store.getState().overlays.updateOverlays; + expect(loadingFulfilled).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + }); + it('should update store after failed updateOverlay', async () => { + mockedAxiosClient + .onPatch(apiPath.updateOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.NotFound, {}); + + await store.dispatch(updateOverlays([overlayFixture])); + + const { loading } = store.getState().overlays.updateOverlays; + expect(loading).toEqual('failed'); + }); + + it('should update store when removeOverlay is pending', async () => { + mockedAxiosClient + .onDelete(apiPath.removeOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, {}); + + store.dispatch( + removeOverlay({ + login: 'test', + overlayId: overlayFixture.idObject, + }), + ); + const { loading } = store.getState().overlays.removeOverlay; + expect(loading).toBe('pending'); + }); + + it('should update store after successful removeOverlay', async () => { + mockedAxiosClient + .onDelete(apiPath.removeOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, {}); + + const removeUserOverlaysPromise = store.dispatch( + removeOverlay({ + login: 'test', + overlayId: overlayFixture.idObject, + }), + ); + const { loading } = store.getState().overlays.removeOverlay; + expect(loading).toBe('pending'); + + await removeUserOverlaysPromise; + + const { loading: loadingFulfilled, error } = store.getState().overlays.removeOverlay; + expect(loadingFulfilled).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + }); + it('should update store after failed removeOverlay', async () => { + mockedAxiosClient + .onDelete(apiPath.removeOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.NotFound, {}); + + const removeUserOverlaysPromise = store.dispatch( + removeOverlay({ + login: 'test', + overlayId: overlayFixture.idObject, + }), + ); + const { loading } = store.getState().overlays.removeOverlay; + expect(loading).toBe('pending'); + + await removeUserOverlaysPromise; + + const { loading: loadingRejected } = store.getState().overlays.removeOverlay; + expect(loadingRejected).toEqual('failed'); + }); }); diff --git a/src/redux/overlays/overlays.reducers.ts b/src/redux/overlays/overlays.reducers.ts index d8f12eef16e426ef9c7aef040030fd20963a1842..50e75caab382672abb4551a077cc93a2f49f2021 100644 --- a/src/redux/overlays/overlays.reducers.ts +++ b/src/redux/overlays/overlays.reducers.ts @@ -1,5 +1,11 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { addOverlay, getAllPublicOverlaysByProjectId } from './overlays.thunks'; +import { + addOverlay, + getAllPublicOverlaysByProjectId, + getAllUserOverlaysByCreator, + removeOverlay, + updateOverlays, +} from './overlays.thunks'; import { OverlaysState } from './overlays.types'; export const getAllPublicOverlaysByProjectIdReducer = ( @@ -30,3 +36,45 @@ export const addOverlayReducer = (builder: ActionReducerMapBuilder<OverlaysState // TODO to discuss manage state of failure }); }; + +export const getAllUserOverlaysByCreatorReducer = ( + builder: ActionReducerMapBuilder<OverlaysState>, +): void => { + builder.addCase(getAllUserOverlaysByCreator.pending, state => { + state.userOverlays.loading = 'pending'; + }); + builder.addCase(getAllUserOverlaysByCreator.fulfilled, (state, action) => { + state.userOverlays.data = action.payload; + state.userOverlays.loading = 'succeeded'; + }); + builder.addCase(getAllUserOverlaysByCreator.rejected, state => { + state.userOverlays.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; + +export const updateOverlaysReducer = (builder: ActionReducerMapBuilder<OverlaysState>): void => { + builder.addCase(updateOverlays.pending, state => { + state.updateOverlays.loading = 'pending'; + }); + builder.addCase(updateOverlays.fulfilled, state => { + state.updateOverlays.loading = 'succeeded'; + }); + builder.addCase(updateOverlays.rejected, state => { + state.updateOverlays.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; + +export const removeOverlayReducer = (builder: ActionReducerMapBuilder<OverlaysState>): void => { + builder.addCase(removeOverlay.pending, state => { + state.removeOverlay.loading = 'pending'; + }); + builder.addCase(removeOverlay.fulfilled, state => { + state.removeOverlay.loading = 'succeeded'; + }); + builder.addCase(removeOverlay.rejected, state => { + state.removeOverlay.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/overlays/overlays.selectors.ts b/src/redux/overlays/overlays.selectors.ts index 03ee76e6f318c3e65bba58f0b8902e972eda95a8..38ba856745080b3c00303ddb5d85eb881c1de478 100644 --- a/src/redux/overlays/overlays.selectors.ts +++ b/src/redux/overlays/overlays.selectors.ts @@ -16,3 +16,15 @@ export const loadingAddOverlay = createSelector( overlaysSelector, state => state.addOverlay.loading, ); + +const userOverlaysSelector = createSelector(overlaysSelector, overlays => overlays.userOverlays); + +export const loadingUserOverlaysSelector = createSelector( + userOverlaysSelector, + state => state.loading, +); + +export const userOverlaysDataSelector = createSelector( + userOverlaysSelector, + overlays => overlays.data, +); diff --git a/src/redux/overlays/overlays.slice.ts b/src/redux/overlays/overlays.slice.ts index 5f49156af3e1b54b2074c7ae9b653e80f7488027..edb5f38140a3522677e9e818f0e545411365450a 100644 --- a/src/redux/overlays/overlays.slice.ts +++ b/src/redux/overlays/overlays.slice.ts @@ -1,5 +1,11 @@ import { createSlice } from '@reduxjs/toolkit'; -import { addOverlayReducer, getAllPublicOverlaysByProjectIdReducer } from './overlays.reducers'; +import { + addOverlayReducer, + getAllPublicOverlaysByProjectIdReducer, + getAllUserOverlaysByCreatorReducer, + removeOverlayReducer, + updateOverlaysReducer, +} from './overlays.reducers'; import { OverlaysState } from './overlays.types'; const initialState: OverlaysState = { @@ -10,6 +16,19 @@ const initialState: OverlaysState = { loading: 'idle', error: { name: '', message: '' }, }, + userOverlays: { + data: [], + loading: 'idle', + error: { name: '', message: '' }, + }, + updateOverlays: { + loading: 'idle', + error: { name: '', message: '' }, + }, + removeOverlay: { + loading: 'idle', + error: { name: '', message: '' }, + }, }; const overlaysState = createSlice({ @@ -19,6 +38,9 @@ const overlaysState = createSlice({ extraReducers: builder => { getAllPublicOverlaysByProjectIdReducer(builder); addOverlayReducer(builder); + getAllUserOverlaysByCreatorReducer(builder); + updateOverlaysReducer(builder); + removeOverlayReducer(builder); }, }); diff --git a/src/redux/overlays/overlays.thunks.ts b/src/redux/overlays/overlays.thunks.ts index 330e5ee98aba5a26299f21b8d56c9b29e16d2d1e..5335fa4d505dab679eab4e86262746569708c582 100644 --- a/src/redux/overlays/overlays.thunks.ts +++ b/src/redux/overlays/overlays.thunks.ts @@ -12,6 +12,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; import { apiPath } from '../apiPath'; import { CHUNK_SIZE } from './overlays.constants'; +import { closeModal } from '../modal/modal.slice'; export const getAllPublicOverlaysByProjectId = createAsyncThunk( 'overlays/getAllPublicOverlaysByProjectId', @@ -158,3 +159,66 @@ export const addOverlay = createAsyncThunk( }); }, ); + +export const getAllUserOverlaysByCreator = createAsyncThunk( + 'overlays/getAllUserOverlaysByCreator', + async (creator: string): Promise<MapOverlay[]> => { + const response = await axiosInstance( + apiPath.getAllUserOverlaysByCreatorQuery({ + creator, + publicOverlay: false, + }), + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapOverlay)); + + const sortByOrder = (userOverlayA: MapOverlay, userOverlayB: MapOverlay): number => { + if (userOverlayA.order > userOverlayB.order) return 1; + return -1; + }; + + const sortedUserOverlays = response.data.sort(sortByOrder); + + return isDataValid ? sortedUserOverlays : []; + }, +); + +export const updateOverlays = createAsyncThunk( + 'overlays/updateOverlays', + async (userOverlays: MapOverlay[]): Promise<void> => { + const userOverlaysPromises = userOverlays.map(userOverlay => + axiosInstance.patch<MapOverlay>( + apiPath.updateOverlay(userOverlay.idObject), + { + overlay: userOverlay, + }, + { + withCredentials: true, + }, + ), + ); + + const userOverlaysResponses = await Promise.all(userOverlaysPromises); + + const updatedUserOverlays = userOverlaysResponses.map( + updatedUserOverlay => updatedUserOverlay.data, + ); + + validateDataUsingZodSchema(updatedUserOverlays, z.array(mapOverlay)); + }, +); + +export const removeOverlay = createAsyncThunk( + 'overlays/removeOverlay', + async ({ overlayId, login }: { overlayId: number; login: string }, thunkApi): Promise<void> => { + await axiosInstance.delete(apiPath.removeOverlay(overlayId), { + withCredentials: true, + }); + + await thunkApi.dispatch(getAllUserOverlaysByCreator(login)); + thunkApi.dispatch(closeModal()); + }, +); diff --git a/src/redux/overlays/overlays.types.ts b/src/redux/overlays/overlays.types.ts index 15d4d813a5879a8e5986e1daf411eceda9f5ef55..98aabe67dc58dad408fe904006907b8eddcd7eb1 100644 --- a/src/redux/overlays/overlays.types.ts +++ b/src/redux/overlays/overlays.types.ts @@ -9,4 +9,26 @@ export type AddOverlayState = { }; }; -export type OverlaysState = FetchDataState<MapOverlay[] | []> & AddOverlayState; +export type UpdateOverlaysState = { + updateOverlays: { + loading: Loading; + error: Error; + }; +}; + +export type RemoveOverlayState = { + removeOverlay: { + loading: Loading; + error: Error; + }; +}; + +export type UserOverlays = { + userOverlays: FetchDataState<MapOverlay[] | []>; +}; + +export type OverlaysState = FetchDataState<MapOverlay[] | []> & + AddOverlayState & + UserOverlays & + UpdateOverlaysState & + RemoveOverlayState; diff --git a/src/redux/plugins/plugins.constants.ts b/src/redux/plugins/plugins.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3365278b4f0cfc8eaedb8832ad14afbf2d3cad9 --- /dev/null +++ b/src/redux/plugins/plugins.constants.ts @@ -0,0 +1,13 @@ +import { PluginsState } from './plugins.types'; + +export const PLUGINS_INITIAL_STATE: PluginsState = { + list: { + data: [], + loading: 'idle', + error: { name: '', message: '' }, + }, + activePlugins: { + data: {}, + pluginsId: [], + }, +}; diff --git a/src/redux/plugins/plugins.mock.ts b/src/redux/plugins/plugins.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b6b9c8f12621dfa722ed20e45d6aec0c69408ba --- /dev/null +++ b/src/redux/plugins/plugins.mock.ts @@ -0,0 +1,18 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { ActivePlugins, PluginsList, PluginsState } from './plugins.types'; + +export const PLUGINS_INITIAL_STATE_ACTIVE_PLUGINS_MOCK: ActivePlugins = { + data: {}, + pluginsId: [], +}; + +export const PLUGINS_INITIAL_STATE_LIST_MOCK: PluginsList = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; + +export const PLUGINS_INITIAL_STATE_MOCK: PluginsState = { + list: PLUGINS_INITIAL_STATE_LIST_MOCK, + activePlugins: PLUGINS_INITIAL_STATE_ACTIVE_PLUGINS_MOCK, +}; diff --git a/src/redux/plugins/plugins.reducers.test.ts b/src/redux/plugins/plugins.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..edc59097d4d53fe239bace4d11948391b3857847 --- /dev/null +++ b/src/redux/plugins/plugins.reducers.test.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-magic-numbers */ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { apiPath } from '../apiPath'; +import { PluginsState } from './plugins.types'; +import pluginsReducer, { removePlugin } from './plugins.slice'; +import { registerPlugin } from './plugins.thunks'; +import { PLUGINS_INITIAL_STATE_MOCK } from './plugins.mock'; + +const mockedAxiosClient = mockNetworkResponse(); + +describe('plugins reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<PluginsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('plugins', pluginsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(pluginsReducer(undefined, action)).toEqual(PLUGINS_INITIAL_STATE_MOCK); + }); + it('should remove overlay from store properly', () => { + const { type, payload } = store.dispatch( + removePlugin({ + pluginId: 'hash1', + }), + ); + + expect(type).toBe('plugins/removePlugin'); + expect(payload).toEqual({ pluginId: 'hash1' }); + }); + it('should update store after successful registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture); + + const { type } = await store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + expect(type).toBe('plugins/registerPlugin/fulfilled'); + const { data, pluginsId } = store.getState().plugins.activePlugins; + + expect(data[pluginFixture.hash]).toEqual(pluginFixture); + expect(pluginsId).toContain(pluginFixture.hash); + }); + + it('should update store after failed registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, undefined); + + const { type, payload } = await store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + expect(type).toBe('plugins/registerPlugin/rejected'); + expect(payload).toEqual(undefined); + const { data, pluginsId } = store.getState().plugins.activePlugins; + + expect(data).toEqual({}); + + expect(pluginsId).not.toContain(pluginFixture.hash); + }); + + it('should update store on loading registerPlugin query', async () => { + mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, undefined); + + store.dispatch( + registerPlugin({ + hash: pluginFixture.hash, + isPublic: pluginFixture.isPublic, + pluginName: pluginFixture.name, + pluginUrl: pluginFixture.urls[0], + pluginVersion: pluginFixture.version, + }), + ); + + const { data, pluginsId } = store.getState().plugins.activePlugins; + + expect(data).toEqual({}); + expect(pluginsId).toContain(pluginFixture.hash); + }); +}); diff --git a/src/redux/plugins/plugins.reducers.ts b/src/redux/plugins/plugins.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..f046459c4c9e88879b6d7b9ee88a755e7e29ddc1 --- /dev/null +++ b/src/redux/plugins/plugins.reducers.ts @@ -0,0 +1,40 @@ +import type { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import type { PluginsState, RemovePluginAction } from './plugins.types'; +import { registerPlugin, getAllPlugins } from './plugins.thunks'; + +export const removePluginReducer = (state: PluginsState, action: RemovePluginAction): void => { + const { pluginId } = action.payload; + state.activePlugins.pluginsId = state.activePlugins.pluginsId.filter(id => id !== pluginId); + delete state.activePlugins.data[pluginId]; +}; + +export const registerPluginReducer = (builder: ActionReducerMapBuilder<PluginsState>): void => { + builder.addCase(registerPlugin.pending, (state, action) => { + const { hash } = action.meta.arg; + state.activePlugins.pluginsId.push(hash); + }); + builder.addCase(registerPlugin.fulfilled, (state, action) => { + if (action.payload) { + const { hash } = action.meta.arg; + + state.activePlugins.data[hash] = action.payload; + } + }); + builder.addCase(registerPlugin.rejected, state => { + state.activePlugins.pluginsId = []; + }); +}; + +export const getAllPluginsReducer = (builder: ActionReducerMapBuilder<PluginsState>): void => { + builder.addCase(getAllPlugins.pending, state => { + state.list.loading = 'pending'; + }); + builder.addCase(getAllPlugins.fulfilled, (state, action) => { + state.list.data = action.payload || []; + state.list.loading = 'succeeded'; + }); + builder.addCase(getAllPlugins.rejected, state => { + state.list.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/plugins/plugins.selectors.ts b/src/redux/plugins/plugins.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bf37c638ee768446fe26c5229f4e4a51e134f14 --- /dev/null +++ b/src/redux/plugins/plugins.selectors.ts @@ -0,0 +1,67 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { MinervaPlugin } from '@/types/models'; +import { rootSelector } from '../root/root.selectors'; + +export const pluginsSelector = createSelector(rootSelector, state => state.plugins); + +export const pluginsListSelector = createSelector(pluginsSelector, plugins => { + return plugins.list; +}); + +export const pluginsListDataSelector = createSelector(pluginsListSelector, pluginsList => { + return pluginsList.data; +}); + +export const publicPluginsListSelector = createSelector( + pluginsListDataSelector, + pluginsListData => { + return (pluginsListData || []).filter(plugin => plugin.isPublic); + }, +); + +export const activePluginsSelector = createSelector(pluginsSelector, state => state.activePlugins); + +export const activePluginsIdSelector = createSelector( + activePluginsSelector, + state => state.pluginsId, +); + +export const activePluginsDataSelector = createSelector( + activePluginsSelector, + plugins => plugins.data, +); + +export const allActivePluginsSelector = createSelector( + activePluginsDataSelector, + activePluginsIdSelector, + (data, pluginsId) => { + const result: MinervaPlugin[] = []; + + pluginsId.forEach(pluginId => { + const element = data[pluginId]; + if (element) { + result.push(element); + } + }); + + return result; + }, +); + +export const privateActivePluginsSelector = createSelector( + allActivePluginsSelector, + activePlugins => { + return (activePlugins || []).filter(plugin => !plugin.isPublic); + }, +); + +export const isPluginActiveSelector = createSelector( + [activePluginsIdSelector, (_, activePlugin: string): string => activePlugin], + (activePlugins, activePlugin) => activePlugins.includes(activePlugin), +); + +export const isPluginLoadingSelector = createSelector( + [activePluginsSelector, (_, activePlugins: string): string => activePlugins], + ({ data, pluginsId }, pluginId) => + pluginsId.includes(pluginId) && data[pluginId] && !Object.keys(data[pluginId]).length, +); diff --git a/src/redux/plugins/plugins.slice.ts b/src/redux/plugins/plugins.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..aeb408420001e991c41e1a060c280880450af4af --- /dev/null +++ b/src/redux/plugins/plugins.slice.ts @@ -0,0 +1,23 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { + registerPluginReducer, + removePluginReducer, + getAllPluginsReducer, +} from './plugins.reducers'; + +import { PLUGINS_INITIAL_STATE } from './plugins.constants'; + +const pluginsSlice = createSlice({ + name: 'plugins', + initialState: PLUGINS_INITIAL_STATE, + reducers: { + removePlugin: removePluginReducer, + }, + extraReducers: builder => { + registerPluginReducer(builder); + getAllPluginsReducer(builder); + }, +}); + +export const { removePlugin } = pluginsSlice.actions; +export default pluginsSlice.reducer; diff --git a/src/redux/plugins/plugins.thunks.test.ts b/src/redux/plugins/plugins.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7972222c206923167271607d9c4e844b56d55556 --- /dev/null +++ b/src/redux/plugins/plugins.thunks.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-magic-numbers */ +import axios, { HttpStatusCode } from 'axios'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import MockAdapter from 'axios-mock-adapter'; +import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { apiPath } from '../apiPath'; +import { PluginsState } from './plugins.types'; +import pluginsReducer from './plugins.slice'; +import { getInitPlugins } from './plugins.thunks'; + +const mockedAxiosApiClient = mockNetworkResponse(); +const mockedAxiosClient = new MockAdapter(axios); + +describe('plugins - thunks', () => { + describe('getInitPlugins', () => { + let store = {} as ToolkitStoreWithSingleSlice<PluginsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('plugins', pluginsReducer); + }); + const setHashedPluginMock = jest.fn(); + + beforeEach(() => { + setHashedPluginMock.mockClear(); + }); + + it('should fetch and load initial plugins', async () => { + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.Ok, pluginFixture); + mockedAxiosApiClient + .onGet(apiPath.getPlugin(pluginFixture.hash)) + .reply(HttpStatusCode.Ok, pluginFixture); + mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.Ok, ''); + + await store.dispatch( + getInitPlugins({ + pluginsId: [pluginFixture.hash], + setHashedPlugin: setHashedPluginMock, + }), + ); + + expect(setHashedPluginMock).toHaveBeenCalledTimes(1); + }); + it('should not load plugin if fetched plugin is not valid', async () => { + mockedAxiosApiClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, {}); + mockedAxiosApiClient + .onGet(apiPath.getPlugin(pluginFixture.hash)) + .reply(HttpStatusCode.NotFound, {}); + mockedAxiosClient.onGet(pluginFixture.urls[0]).reply(HttpStatusCode.NotFound, ''); + + await store.dispatch( + getInitPlugins({ + pluginsId: [pluginFixture.hash], + setHashedPlugin: setHashedPluginMock, + }), + ); + + expect(setHashedPluginMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/redux/plugins/plugins.thunks.ts b/src/redux/plugins/plugins.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..73c7341e07cfc2f9b93515487084fe26fade667d --- /dev/null +++ b/src/redux/plugins/plugins.thunks.ts @@ -0,0 +1,97 @@ +/* eslint-disable no-magic-numbers */ +import axios from 'axios'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { pluginSchema } from '@/models/pluginSchema'; +import type { MinervaPlugin } from '@/types/models'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { z } from 'zod'; +import { apiPath } from '../apiPath'; + +type RegisterPlugin = { + hash: string; + pluginUrl: string; + pluginName: string; + pluginVersion: string; + isPublic: boolean; +}; + +export const registerPlugin = createAsyncThunk( + 'plugins/registerPlugin', + async ({ + hash, + isPublic, + pluginName, + pluginUrl, + pluginVersion, + }: RegisterPlugin): Promise<MinervaPlugin | undefined> => { + const payload = { + hash, + url: pluginUrl, + name: pluginName, + version: pluginVersion, + isPublic: isPublic.toString(), + } as const; + + const response = await axiosInstance.post<MinervaPlugin>( + apiPath.registerPluign(), + new URLSearchParams(payload), + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, pluginSchema); + + if (isDataValid) { + return response.data; + } + + return undefined; + }, +); + +type GetInitPluginsProps = { + pluginsId: string[]; + setHashedPlugin: ({ + pluginUrl, + pluginScript, + }: { + pluginUrl: string; + pluginScript: string; + }) => void; +}; + +export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps>( + 'plugins/getInitPlugins', + async ({ pluginsId, setHashedPlugin }): Promise<void> => { + /* eslint-disable no-restricted-syntax, no-await-in-loop */ + for (const pluginId of pluginsId) { + const res = await axiosInstance<MinervaPlugin>(apiPath.getPlugin(pluginId)); + + const isDataValid = validateDataUsingZodSchema(res.data, pluginSchema); + + if (isDataValid) { + const { urls } = res.data; + const scriptRes = await axios(urls[0]); + const pluginScript = scriptRes.data; + setHashedPlugin({ pluginUrl: urls[0], pluginScript }); + + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + loadPlugin(); + } + } + }, +); + +export const getAllPlugins = createAsyncThunk( + 'plugins/getAllPlugins', + async (): Promise<MinervaPlugin[]> => { + const response = await axiosInstance.get<MinervaPlugin[]>(apiPath.getAllPlugins()); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(pluginSchema)); + + return isDataValid ? response.data : []; + }, +); diff --git a/src/redux/plugins/plugins.types.ts b/src/redux/plugins/plugins.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..2569f12845cc3394a7b8897e9133745a52cb9fa6 --- /dev/null +++ b/src/redux/plugins/plugins.types.ts @@ -0,0 +1,20 @@ +import { PayloadAction } from '@reduxjs/toolkit'; + +import { FetchDataState } from '@/types/fetchDataState'; +import { MinervaPlugin } from '@/types/models'; + +export type RemovePluginPayload = { pluginId: string }; +export type RemovePluginAction = PayloadAction<RemovePluginPayload>; + +export type PluginsList = FetchDataState<MinervaPlugin[]>; +export type ActivePlugins = { + pluginsId: string[]; + data: { + [pluginId: string]: MinervaPlugin; + }; +}; + +export type PluginsState = { + list: PluginsList; + activePlugins: ActivePlugins; +}; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index c4ac19274381cdffa49913eff2a4e40ed79960e7..557e87d96f6804aed9e108a81d9530a2cd93ccf2 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -1,25 +1,27 @@ -import { openSearchDrawerWithSelectedTab } from '@/redux/drawer/drawer.slice'; -import { createAsyncThunk } from '@reduxjs/toolkit'; import { PROJECT_ID } from '@/constants'; +import { openSearchDrawerWithSelectedTab } from '@/redux/drawer/drawer.slice'; import { AppDispatch } from '@/redux/store'; import { QueryData } from '@/types/query'; import { getDefaultSearchTab } from '@/components/FunctionalArea/TopBar/SearchBar/SearchBar.utils'; +import { PluginsManager } from '@/services/pluginsManager'; +import { createAsyncThunk } from '@reduxjs/toolkit'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; -import { getAllPublicOverlaysByProjectId } from '../overlays/overlays.thunks'; -import { getModels } from '../models/models.thunks'; -import { getProjectById } from '../project/project.thunks'; +import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { initMapBackground, initMapPosition, initMapSizeAndModelId, initOpenedMaps, } from '../map/map.thunks'; -import { getSearchData } from '../search/search.thunks'; -import { setPerfectMatch } from '../search/search.slice'; -import { getSessionValid } from '../user/user.thunks'; +import { getModels } from '../models/models.thunks'; import { getInitOverlays } from '../overlayBioEntity/overlayBioEntity.thunk'; -import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; +import { getAllPublicOverlaysByProjectId } from '../overlays/overlays.thunks'; +import { getAllPlugins, getInitPlugins } from '../plugins/plugins.thunks'; +import { getProjectById } from '../project/project.thunks'; +import { setPerfectMatch } from '../search/search.slice'; +import { getSearchData } from '../search/search.thunks'; import { getStatisticsById } from '../statistics/statistics.thunks'; +import { getSessionValid } from '../user/user.thunks'; interface InitializeAppParams { queryData: QueryData; @@ -31,6 +33,7 @@ export const fetchInitialAppData = createAsyncThunk< { dispatch: AppDispatch } >('appInit/fetchInitialAppData', async ({ queryData }, { dispatch }): Promise<void> => { /** Fetch all data required for rendering map */ + await Promise.all([ dispatch(getConfigurationOptions()), dispatch(getProjectById(PROJECT_ID)), @@ -54,6 +57,9 @@ export const fetchInitialAppData = createAsyncThunk< dispatch(getStatisticsById(PROJECT_ID)); dispatch(getConfiguration()); + // Fetch plugins list + dispatch(getAllPlugins()); + /** Trigger search */ if (queryData.searchValue) { dispatch(setPerfectMatch(queryData.perfectMatch)); @@ -70,4 +76,13 @@ export const fetchInitialAppData = createAsyncThunk< if (queryData.overlaysId) { dispatch(getInitOverlays({ overlaysId: queryData.overlaysId })); } + + if (queryData.pluginsId) { + dispatch( + getInitPlugins({ + pluginsId: queryData.pluginsId, + setHashedPlugin: PluginsManager.setHashedPlugin, + }), + ); + } }); diff --git a/src/redux/root/query.selectors.ts b/src/redux/root/query.selectors.ts index 3088b0dacb0ae0051fb11a60a658af927aff4667..b862ed429fdb885367f3f87755c47204903b08b7 100644 --- a/src/redux/root/query.selectors.ts +++ b/src/redux/root/query.selectors.ts @@ -4,22 +4,26 @@ import { ZERO } from '@/constants/common'; import { mapDataSelector } from '../map/map.selectors'; import { perfectMatchSelector, searchValueSelector } from '../search/search.selectors'; import { activeOverlaysIdSelector } from '../overlayBioEntity/overlayBioEntity.selector'; +import { activePluginsIdSelector } from '../plugins/plugins.selectors'; export const queryDataParamsSelector = createSelector( searchValueSelector, perfectMatchSelector, mapDataSelector, activeOverlaysIdSelector, + activePluginsIdSelector, ( searchValue, perfectMatch, { modelId, backgroundId, position }, activeOverlaysId, + activePluginsId, ): QueryDataParams => { const joinedSearchValue = searchValue.join(';'); const shouldIncludeSearchValue = searchValue.length > ZERO && joinedSearchValue; const shouldIncludeOverlaysId = activeOverlaysId.length > ZERO; + const shouldIncludePluginsId = activePluginsId.length > ZERO; const queryDataParams: QueryDataParams = { perfectMatch, @@ -28,6 +32,7 @@ export const queryDataParamsSelector = createSelector( ...position.last, ...(shouldIncludeSearchValue ? { searchValue: joinedSearchValue } : {}), ...(shouldIncludeOverlaysId ? { overlaysId: activeOverlaysId.join(',') } : {}), + ...(shouldIncludePluginsId ? { pluginsId: activePluginsId.join(',') } : {}), }; return queryDataParams; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 77d70737a030314a15c3b1322df0b1782ea365c9..b27b88cc9c1c96511ed626d5568b30d04999b1d3 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -6,12 +6,14 @@ import { CONTEXT_MENU_INITIAL_STATE } from '../contextMenu/contextMenu.constants import { COOKIE_BANNER_INITIAL_STATE_MOCK } from '../cookieBanner/cookieBanner.mock'; import { initialStateFixture as drawerInitialStateMock } from '../drawer/drawerFixture'; import { DRUGS_INITIAL_STATE_MOCK } from '../drugs/drugs.mock'; +import { EXPORT_INITIAL_STATE_MOCK } from '../export/export.mock'; import { LEGEND_INITIAL_STATE_MOCK } from '../legend/legend.mock'; import { initialMapStateFixture } from '../map/map.fixtures'; import { MODAL_INITIAL_STATE_MOCK } from '../modal/modal.mock'; import { MODELS_INITIAL_STATE_MOCK } from '../models/models.mock'; import { OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK } from '../overlayBioEntity/overlayBioEntity.mock'; import { OVERLAYS_INITIAL_STATE_MOCK } from '../overlays/overlays.mock'; +import { PLUGINS_INITIAL_STATE_MOCK } from '../plugins/plugins.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'; @@ -43,4 +45,6 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { statistics: STATISTICS_STATE_INITIAL_MOCK, compartmentPathways: COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK, publications: PUBLICATIONS_INITIAL_STATE_MOCK, + export: EXPORT_INITIAL_STATE_MOCK, + plugins: PLUGINS_INITIAL_STATE_MOCK, }; diff --git a/src/redux/statistics/statistics.selectors.ts b/src/redux/statistics/statistics.selectors.ts index e0bb325940adba73600a3378ffff6d5ae979df8b..847e042f7d22fff19581cff553e59faf396a54fc 100644 --- a/src/redux/statistics/statistics.selectors.ts +++ b/src/redux/statistics/statistics.selectors.ts @@ -9,8 +9,3 @@ export const statisticsDataSelector = createSelector( statisticsSelector, statistics => statistics?.data, ); - -export const elementAnnotationsSelector = createSelector( - statisticsDataSelector, - statistics => statistics?.elementAnnotations, -); diff --git a/src/redux/store.ts b/src/redux/store.ts index 32c4c738048dc5fd63bda60d4391003dc701f1fb..925fb4094256ffe3ccd792ba655e0d24e717e919 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -22,10 +22,12 @@ import { TypedStartListening, configureStore, } from '@reduxjs/toolkit'; +import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; +import exportReducer from './export/export.slice'; import legendReducer from './legend/legend.slice'; import { mapListenerMiddleware } from './map/middleware/map.middleware'; +import pluginsReducer from './plugins/plugins.slice'; import statisticsReducer from './statistics/statistics.slice'; -import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; import publicationsReducer from './publications/publications.slice'; export const reducers = { @@ -50,6 +52,8 @@ export const reducers = { statistics: statisticsReducer, compartmentPathways: compartmentPathwaysReducer, publications: publicationsReducer, + export: exportReducer, + plugins: pluginsReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/redux/user/user.reducers.ts b/src/redux/user/user.reducers.ts index 56c847b9497038441472a5afc8bbb7ae5f488bf0..83618692cf8f791128420d955c3f4ae4068dac5c 100644 --- a/src/redux/user/user.reducers.ts +++ b/src/redux/user/user.reducers.ts @@ -7,9 +7,10 @@ export const loginReducer = (builder: ActionReducerMapBuilder<UserState>): void .addCase(login.pending, state => { state.loading = 'pending'; }) - .addCase(login.fulfilled, state => { + .addCase(login.fulfilled, (state, action) => { state.authenticated = true; state.loading = 'succeeded'; + state.login = action.payload?.login || null; }) .addCase(login.rejected, state => { state.authenticated = false; diff --git a/src/redux/user/user.selectors.ts b/src/redux/user/user.selectors.ts index 5331af9df0173163e2383de42e0a2800a9414b15..026e5631ad9edff0fa196cc011273cf6fe410c45 100644 --- a/src/redux/user/user.selectors.ts +++ b/src/redux/user/user.selectors.ts @@ -5,3 +5,4 @@ export const userSelector = createSelector(rootSelector, state => state.user); export const authenticatedUserSelector = createSelector(userSelector, state => state.authenticated); export const loadingUserSelector = createSelector(userSelector, state => state.loading); +export const loginUserSelector = createSelector(userSelector, state => state.login); diff --git a/src/redux/user/user.thunks.ts b/src/redux/user/user.thunks.ts index 3f4d97c26e526b7bdd779099340f5adae02231ea..95c567c2af83b31f62eb05c30d219f6f17bdb6af 100644 --- a/src/redux/user/user.thunks.ts +++ b/src/redux/user/user.thunks.ts @@ -3,6 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { loginSchema } from '@/models/loginSchema'; import { sessionSchemaValid } from '@/models/sessionValidSchema'; +import { Login, SessionValid } from '@/types/models'; import { apiPath } from '../apiPath'; import { closeModal } from '../modal/modal.slice'; @@ -10,7 +11,7 @@ export const login = createAsyncThunk( 'user/login', async (credentials: { login: string; password: string }, { dispatch }) => { const searchParams = new URLSearchParams(credentials); - const response = await axiosInstance.post(apiPath.postLogin(), searchParams, { + const response = await axiosInstance.post<Login>(apiPath.postLogin(), searchParams, { withCredentials: true, }); @@ -22,11 +23,11 @@ export const login = createAsyncThunk( ); export const getSessionValid = createAsyncThunk('user/getSessionValid', async () => { - const response = await axiosInstance.get(apiPath.getSessionValid(), { + const response = await axiosInstance.get<SessionValid>(apiPath.getSessionValid(), { withCredentials: true, }); const isDataValid = validateDataUsingZodSchema(response.data, sessionSchemaValid); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data.login : null; }); diff --git a/src/services/pluginsManager/index.ts b/src/services/pluginsManager/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c77cb1f59759be88679e29c12d76613f1f61be64 --- /dev/null +++ b/src/services/pluginsManager/index.ts @@ -0,0 +1 @@ +export { PluginsManager } from './pluginsManager'; diff --git a/src/services/pluginsManager/pluginsManager.test.ts b/src/services/pluginsManager/pluginsManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9b55d2712e3cb23ec806e3440d373fd06bcd3d3 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable no-magic-numbers */ +import { store } from '@/redux/store'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { configurationMapper } from './pluginsManager.utils'; +import { PluginsManager } from './pluginsManager'; + +jest.mock('../../redux/store'); + +describe('PluginsManager', () => { + const originalWindow = { ...global.window }; + + beforeEach(() => { + global.window = { ...originalWindow }; + }); + + afterEach(() => { + global.window = originalWindow; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('setHashedPlugin correctly computes hash and updates hashedPlugins', () => { + const pluginUrl = 'https://example.com/plugin.js'; + const pluginScript = 'console.log("Hello, Plugin!");'; + + PluginsManager.setHashedPlugin({ pluginUrl, pluginScript }); + + expect(PluginsManager.hashedPlugins[pluginUrl]).toBe('edc7eeafccc9e1ab66f713298425947b'); + }); + + it('init subscribes to store changes and updates minerva configuration', () => { + (store.getState as jest.Mock).mockReturnValueOnce({ + configuration: { main: { data: configurationFixture } }, + }); + + PluginsManager.init(); + + expect(store.subscribe).toHaveBeenCalled(); + + // Simulate store change + (store.subscribe as jest.Mock).mock.calls[0][0](); + + expect(store.getState).toHaveBeenCalled(); + expect(window.minerva.configuration).toEqual(configurationMapper(configurationFixture)); + }); + it('init does not update minerva configuration when configuration is undefined', () => { + (store.getState as jest.Mock).mockReturnValueOnce({ + configuration: { main: { data: undefined } }, + }); + + PluginsManager.init(); + + expect(store.subscribe).toHaveBeenCalled(); + + // Simulate store change + (store.subscribe as jest.Mock).mock.calls[0][0](); + + expect(store.getState).toHaveBeenCalled(); + expect(window.minerva.configuration).toBeUndefined(); + }); + + it('registerPlugin dispatches action and returns element', () => { + const pluginName = 'TestPlugin'; + const pluginVersion = '1.0.0'; + const pluginUrl = 'https://example.com/test-plugin.js'; + + const result = PluginsManager.registerPlugin({ pluginName, pluginVersion, pluginUrl }); + + expect(store.dispatch).toHaveBeenCalled(); + + expect(result.element).toBeDefined(); + }); +}); diff --git a/src/services/pluginsManager/pluginsManager.ts b/src/services/pluginsManager/pluginsManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..a41a8ed5cad9d405bb07069425bf28f79b3112e5 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.ts @@ -0,0 +1,61 @@ +import md5 from 'crypto-js/md5'; +import { store } from '@/redux/store'; +import { registerPlugin } from '@/redux/plugins/plugins.thunks'; +import { configurationMapper } from './pluginsManager.utils'; +import type { PluginsManagerType } from './pluginsManager.types'; + +export const PluginsManager: PluginsManagerType = { + hashedPlugins: {}, + setHashedPlugin({ pluginUrl, pluginScript }) { + const hash = md5(pluginScript).toString(); + + PluginsManager.hashedPlugins[pluginUrl] = hash; + + return hash; + }, + init() { + window.minerva = { + plugins: { + registerPlugin: PluginsManager.registerPlugin, + }, + }; + + const unsubscribe = store.subscribe(() => { + const configurationStore = store.getState().configuration.main.data; + + if (configurationStore) { + const configuration = configurationMapper(configurationStore); + + window.minerva = { + ...window.minerva, + configuration, + }; + } + }); + + return unsubscribe; + }, + + registerPlugin({ pluginName, pluginVersion, pluginUrl }) { + const hash = PluginsManager.hashedPlugins[pluginUrl]; + + store.dispatch( + registerPlugin({ + hash, + isPublic: false, + pluginName, + pluginUrl, + pluginVersion, + }), + ); + + // TODO: replace when plugins drawer is implemented + const element = document.createElement('div'); + const wrapper = document.querySelector('#plugins'); + wrapper?.append(element); + + return { + element, + }; + }, +}; diff --git a/src/services/pluginsManager/pluginsManager.types.ts b/src/services/pluginsManager/pluginsManager.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..cedb9034b59df6bb3726b1f57e1d3109814be189 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.types.ts @@ -0,0 +1,21 @@ +import { Unsubscribe } from '@reduxjs/toolkit'; +import { configurationMapper } from './pluginsManager.utils'; + +export type RegisterPlugin = { + pluginName: string; + pluginVersion: string; + pluginUrl: string; +}; + +export type MinervaConfiguration = ReturnType<typeof configurationMapper>; + +export type PluginsManagerType = { + hashedPlugins: { + [url: string]: string; + }; + setHashedPlugin({ pluginUrl, pluginScript }: { pluginUrl: string; pluginScript: string }): string; + init(): Unsubscribe; + registerPlugin({ pluginName, pluginVersion, pluginUrl }: RegisterPlugin): { + element: HTMLDivElement; + }; +}; diff --git a/src/services/pluginsManager/pluginsManager.utils.ts b/src/services/pluginsManager/pluginsManager.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd7158a5d30ae742bbde630778dbcbb66e94a072 --- /dev/null +++ b/src/services/pluginsManager/pluginsManager.utils.ts @@ -0,0 +1,14 @@ +import { Configuration } from '@/types/models'; + +export const configurationMapper = (data: Configuration): unknown => ({ + annotators: data.annotators, + elementTypes: data.elementTypes, + miramiTypes: data.miriamTypes, + mapTypes: data.mapTypes, + modelConverters: data.modelFormats, + modificationStateTypes: data.modificationStateTypes, + options: data.options, + overlayTypes: data.overlayTypes, + privilegeTypes: data.privilegeTypes, + reactionTypes: data.reactionTypes, +}); diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index 0dae32d43b3d4e3848f0dcbce4bbaebc30e6dab6..dd6d4decfbbe023e069d0ef06b80b088b2e3458d 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -18,6 +18,7 @@ import type { IconTypes } from '@/types/iconTypes'; import { LocationIcon } from './Icons/LocationIcon'; import { MaginfierZoomInIcon } from './Icons/MagnifierZoomIn'; import { MaginfierZoomOutIcon } from './Icons/MagnifierZoomOut'; +import { ThreeDotsIcon } from './Icons/ThreeDotsIcon'; export interface IconProps { className?: string; @@ -43,6 +44,7 @@ const icons = { location: LocationIcon, 'magnifier-zoom-in': MaginfierZoomInIcon, 'magnifier-zoom-out': MaginfierZoomOutIcon, + 'three-dots': ThreeDotsIcon, } as const; export const Icon = ({ name, className = '', ...rest }: IconProps): JSX.Element => { diff --git a/src/shared/Icon/Icons/ThreeDotsIcon.tsx b/src/shared/Icon/Icons/ThreeDotsIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..156e41e298f91f041e52b9573c2a3a85cf0e5735 --- /dev/null +++ b/src/shared/Icon/Icons/ThreeDotsIcon.tsx @@ -0,0 +1,27 @@ +interface ThreeDotsIconProps { + className?: string; +} + +export const ThreeDotsIcon = ({ className }: ThreeDotsIconProps): JSX.Element => ( + <svg + width="4" + height="22" + viewBox="0 0 4 22" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className} + > + <path + d="M-8.74228e-08 11C-1.35705e-07 12.1046 0.89543 13 2 13C3.10457 13 4 12.1046 4 11C4 9.89543 3.10457 9 2 9C0.895431 9 -3.91405e-08 9.89543 -8.74228e-08 11Z" + fill="#070130" + /> + <path + d="M-8.74228e-08 2C-1.35705e-07 3.10457 0.89543 4 2 4C3.10457 4 4 3.10457 4 2C4 0.89543 3.10457 -3.91405e-08 2 -8.74228e-08C0.895431 -1.35705e-07 -3.91405e-08 0.89543 -8.74228e-08 2Z" + fill="#070130" + /> + <path + d="M-8.74228e-08 20C-1.35705e-07 21.1046 0.89543 22 2 22C3.10457 22 4 21.1046 4 20C4 18.8954 3.10457 18 2 18C0.895431 18 -3.91405e-08 18.8954 -8.74228e-08 20Z" + fill="#070130" + /> + </svg> +); diff --git a/src/types/OLrendering.ts b/src/types/OLrendering.ts index 11ab030c2ca0dd67874f94da6b29309e81f52e1c..6aee24d703ddc7ab5c39bf21644ad91895886891 100644 --- a/src/types/OLrendering.ts +++ b/src/types/OLrendering.ts @@ -1,5 +1,7 @@ import { Color } from './models'; +export type OverlayBioEntityRenderType = 'line' | 'rectangle'; + export type OverlayBioEntityRender = { id: number; modelId: number; @@ -16,4 +18,15 @@ export type OverlayBioEntityRender = { value: number | null; overlayId: number; color: Color | null; + type: OverlayBioEntityRenderType; }; + +export interface OverlayReactionCoords { + x1: number; + x2: number; + y1: number; + y2: number; + id: number; + height: number; + width: number; +} diff --git a/src/types/drawerName.ts b/src/types/drawerName.ts index a5f3e3d2ff2f0155c67bdb1a458fc2ce64c5764f..3715c57ddd45995b4c8194a917bf0ad8a129b5f4 100644 --- a/src/types/drawerName.ts +++ b/src/types/drawerName.ts @@ -8,4 +8,5 @@ export type DrawerName = | 'submaps' | 'reaction' | 'overlays' - | 'bio-entity'; + | 'bio-entity' + | 'available-plugins'; diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index c125f09c0ea0f1cb06bb15da4037fd4d1f8e56bf..3083f71695a3744b6638705a7ec79805bd8703c9 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -16,4 +16,5 @@ export type IconTypes = | 'location' | 'magnifier-zoom-in' | 'magnifier-zoom-out' - | 'pin'; + | 'pin' + | 'three-dots'; diff --git a/src/types/modal.ts b/src/types/modal.ts index 474c0f7aef86b9c6f5d0e83e6a924a7af323afbd..08b85048569414834c64481e4e8793335ffb0242 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -1 +1,7 @@ -export type ModalName = 'none' | 'overview-images' | 'mol-art' | 'login' | 'publications'; +export type ModalName = + | 'none' + | 'overview-images' + | 'mol-art' + | 'login' + | 'publications' + | 'edit-overlay'; diff --git a/src/types/models.ts b/src/types/models.ts index 22862e3aa5f3270cd99643d33cc250ef837a5047..3f9efc0e75c9c1260b4572e642240f24c7b471a2 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -9,10 +9,12 @@ import { compartmentPathwaySchema, } from '@/models/compartmentPathwaySchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; -import { configurationSchema, formatSchema } from '@/models/configurationSchema'; +import { configurationSchema, formatSchema, miriamTypesSchema } from '@/models/configurationSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; +import { exportElementsSchema, exportNetworkchema } from '@/models/exportSchema'; +import { lineSchema } from '@/models/lineSchema'; import { loginSchema } from '@/models/loginSchema'; import { mapBackground } from '@/models/mapBackground'; import { @@ -23,13 +25,20 @@ import { } from '@/models/mapOverlay'; import { mapModelSchema } from '@/models/modelSchema'; import { organism } from '@/models/organism'; -import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; +import { + overlayBioEntitySchema, + overlayElementWithBioEntitySchema, + overlayElementWithReactionSchema, +} from '@/models/overlayBioEntitySchema'; +import { overlayLeftBioEntitySchema } from '@/models/overlayLeftBioEntitySchema'; +import { overlayLeftReactionSchema } from '@/models/overlayLeftReactionSchema'; import { overviewImageLink, overviewImageLinkImage, overviewImageLinkModel, } from '@/models/overviewImageLink'; import { overviewImageView } from '@/models/overviewImageView'; +import { pluginSchema } from '@/models/pluginSchema'; import { projectSchema } from '@/models/projectSchema'; import { publicationSchema } from '@/models/publicationsSchema'; import { reactionSchema } from '@/models/reaction'; @@ -66,7 +75,13 @@ export type Login = z.infer<typeof loginSchema>; export type ConfigurationOption = z.infer<typeof configurationOptionSchema>; export type Configuration = z.infer<typeof configurationSchema>; export type ConfigurationFormatSchema = z.infer<typeof formatSchema>; +export type ConfigurationMiramiTypes = z.infer<typeof miriamTypesSchema>; export type OverlayBioEntity = z.infer<typeof overlayBioEntitySchema>; +export type OverlayElementWithReaction = z.infer<typeof overlayElementWithReactionSchema>; +export type OverlayElementWithBioEntity = z.infer<typeof overlayElementWithBioEntitySchema>; +export type OverlayLeftBioEntity = z.infer<typeof overlayLeftBioEntitySchema>; +export type OverlayLeftReaction = z.infer<typeof overlayLeftReactionSchema>; +export type Line = z.infer<typeof lineSchema>; export type CreatedOverlayFile = z.infer<typeof createdOverlayFileSchema>; export type UploadedOverlayFileContent = z.infer<typeof uploadedOverlayFileContentSchema>; export type CreatedOverlay = z.infer<typeof createdOverlaySchema>; @@ -76,3 +91,6 @@ export type CompartmentPathway = z.infer<typeof compartmentPathwaySchema>; export type CompartmentPathwayDetails = z.infer<typeof compartmentPathwayDetailsSchema>; export type PublicationsResponse = z.infer<typeof publicationsResponseSchema>; export type Publication = z.infer<typeof publicationSchema>; +export type ExportNetwork = z.infer<typeof exportNetworkchema>; +export type ExportElements = z.infer<typeof exportElementsSchema>; +export type MinervaPlugin = z.infer<typeof pluginSchema>; // Plugin type interfers with global Plugin type diff --git a/src/types/query.ts b/src/types/query.ts index 98309123aeea5a80626fca86870beb56c6561ec3..be3453f011b515a134cf5aff62e1549ae31553c1 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -7,6 +7,7 @@ export interface QueryData { backgroundId?: number; initialPosition?: Partial<Point>; overlaysId?: number[]; + pluginsId?: string[]; } export interface QueryDataParams { @@ -18,6 +19,7 @@ export interface QueryDataParams { y?: number; z?: number; overlaysId?: string; + pluginsId?: string; } export interface QueryDataRouterParams { @@ -29,4 +31,5 @@ export interface QueryDataRouterParams { y?: string; z?: string; overlaysId?: string; + pluginsId?: string; } diff --git a/src/utils/initialize/useInitializeStore.ts b/src/utils/initialize/useInitializeStore.ts index 9722dd6173ea1fe82164f422f0b7d6dc106a22de..c648112775ac249fb74cc4ab3b439f803430297f 100644 --- a/src/utils/initialize/useInitializeStore.ts +++ b/src/utils/initialize/useInitializeStore.ts @@ -25,6 +25,7 @@ export const useInitializeStore = (): void => { if (isInitialized || !isQueryReady) { return; } + dispatch(fetchInitialAppData({ queryData: parseQueryToTypes(query) })); }, [dispatch, isInitialized, query, isQueryReady, isInitDataLoadingFinished]); }; 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)); +}; diff --git a/src/utils/overlays/getOverlayReactionCoords.test.ts b/src/utils/overlays/getOverlayReactionCoords.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d5d2ea1b023364c6ad7d5e06b0a5f062d5cec1c --- /dev/null +++ b/src/utils/overlays/getOverlayReactionCoords.test.ts @@ -0,0 +1,85 @@ +import { OverlayReactionCoords } from '@/types/OLrendering'; +import { Line } from '@/types/models'; +import { getOverlayReactionCoordsFromLine } from './getOverlayReactionCoords'; + +const LINE_DATA_BASE: Line = { + id: 66141, + width: 1, + color: { + alpha: 255, + rgb: -16777216, + }, + z: 0, + segments: [ + { + x1: 4457.375604345491, + y1: 7111.933125147456, + x2: 4462.61826820353, + y2: 7105.89040426431, + }, + ], + startArrow: { + arrowType: 'NONE', + angle: 2.748893571891069, + lineType: 'SOLID', + length: 15, + }, + endArrow: { + arrowType: 'NONE', + angle: 2.748893571891069, + lineType: 'SOLID', + length: 15, + }, + lineType: 'SOLID', +}; + +describe('getOverlayReactionCoords - util', () => { + const cases: [Line, OverlayReactionCoords[]][] = [ + [ + { + ...LINE_DATA_BASE, + segments: [ + { + x1: 10, + y1: 10, + x2: 100, + y2: 100, + }, + ], + }, + [{ height: -90, id: 66141, width: 90, x1: 10, x2: 100, y1: 10, y2: 100 }], + ], + [ + { + ...LINE_DATA_BASE, + segments: [ + { + x1: 10, + y1: 10, + x2: 2000, + y2: 0, + }, + ], + }, + [{ height: 10, id: 66141, width: 1990, x1: 10, x2: 2000, y1: 10, y2: 0 }], + ], + [ + { + ...LINE_DATA_BASE, + segments: [ + { + x1: 0, + y1: 0, + x2: 0, + y2: 0, + }, + ], + }, + [{ height: 0, id: 66141, width: 0, x1: 0, x2: 0, y1: 0, y2: 0 }], + ], + ]; + + it.each(cases)('should return valid result', (line, result) => { + expect(getOverlayReactionCoordsFromLine(line)).toStrictEqual(result); + }); +}); diff --git a/src/utils/overlays/getOverlayReactionCoords.ts b/src/utils/overlays/getOverlayReactionCoords.ts new file mode 100644 index 0000000000000000000000000000000000000000..c607fcde6e01f08334f0a38122646ec145fe17cc --- /dev/null +++ b/src/utils/overlays/getOverlayReactionCoords.ts @@ -0,0 +1,14 @@ +import { OverlayReactionCoords } from '@/types/OLrendering'; +import { Line } from '@/types/models'; + +export const getOverlayReactionCoordsFromLine = (line: Line): OverlayReactionCoords[] => + line.segments.map(segment => { + const { x1, y1, x2, y2 } = segment; + + return { + ...segment, + id: line.id, + width: x2 - x1, + height: y1 - y2, + }; + }); diff --git a/src/utils/overlays/overlaysElementsTypeGuards.test.ts b/src/utils/overlays/overlaysElementsTypeGuards.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..19028e2c88aa16431ee269d267a531998004d189 --- /dev/null +++ b/src/utils/overlays/overlaysElementsTypeGuards.test.ts @@ -0,0 +1,35 @@ +import { + overlayElementWithBioEntityFixture, + overlayElementWithReactionFixture, +} from '@/models/fixtures/overlayBioEntityFixture'; +import { isBioEntity, isReaction } from './overlaysElementsTypeGuards'; + +describe('overlaysElementsTypeGruards - utils', () => { + describe('isReaction', () => { + describe('when is reaction', () => { + it('should return true', () => { + expect(isReaction(overlayElementWithReactionFixture)).toBe(true); + }); + }); + + describe('when is bioentity', () => { + it('should return false', () => { + expect(isReaction(overlayElementWithBioEntityFixture)).toBe(false); + }); + }); + }); + + describe('isBioEntity', () => { + describe('when is reaction', () => { + it('should return false', () => { + expect(isBioEntity(overlayElementWithReactionFixture)).toBe(false); + }); + }); + + describe('when is bioentity', () => { + it('should return true', () => { + expect(isBioEntity(overlayElementWithBioEntityFixture)).toBe(true); + }); + }); + }); +}); diff --git a/src/utils/overlays/overlaysElementsTypeGuards.ts b/src/utils/overlays/overlaysElementsTypeGuards.ts new file mode 100644 index 0000000000000000000000000000000000000000..6997b141f5d627b5d4641ee23c2f7b902f17a1af --- /dev/null +++ b/src/utils/overlays/overlaysElementsTypeGuards.ts @@ -0,0 +1,14 @@ +import { + OverlayBioEntity, + OverlayElementWithBioEntity, + OverlayElementWithReaction, + OverlayLeftBioEntity, + OverlayLeftReaction, +} from '@/types/models'; + +export const isReaction = (e: OverlayBioEntity): e is OverlayElementWithReaction => + (e.left as OverlayLeftReaction).line !== undefined; + +export const isBioEntity = (e: OverlayBioEntity): e is OverlayElementWithBioEntity => + (e.left as OverlayLeftBioEntity).x !== undefined && + (e.left as OverlayLeftBioEntity).y !== undefined; diff --git a/src/utils/parseQueryToTypes.ts b/src/utils/parseQueryToTypes.ts index ee7440375a4834e13cf217b9af5fd714889ec56b..f04abadfedebada9e8058ffa3d4eae08b9ffc731 100644 --- a/src/utils/parseQueryToTypes.ts +++ b/src/utils/parseQueryToTypes.ts @@ -11,4 +11,5 @@ export const parseQueryToTypes = (query: QueryDataRouterParams): QueryData => ({ z: Number(query.z) || undefined, }, overlaysId: query.overlaysId?.split(',').map(Number), + pluginsId: query.pluginsId?.split(',').map(String), }); diff --git a/src/utils/query-manager/useReduxBusQueryManager.test.ts b/src/utils/query-manager/useReduxBusQueryManager.test.ts index 77244318601a6d608b143772ca813e7e390e4eaf..c77f8f3fbccbb56fdf3ace53f190b29b6ca62598 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.test.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.test.ts @@ -29,6 +29,19 @@ describe('useReduxBusQueryManager - util', () => { loading: 'idle' as Loading, error: { name: '', message: '' }, }, + userOverlays: { + data: [], + loading: 'idle' as Loading, + error: { name: '', message: '' }, + }, + updateOverlays: { + loading: 'idle' as Loading, + error: { name: '', message: '' }, + }, + removeOverlay: { + loading: 'idle' as Loading, + error: { name: '', message: '' }, + }, }; const { Wrapper } = getReduxWrapperWithStore({ diff --git a/src/utils/query-manager/useReduxBusQueryManager.ts b/src/utils/query-manager/useReduxBusQueryManager.ts index 80d277dd03a6954af2085dbc98fe7c75cf169663..5a2a58772b7543ac5d79ced941ed15fb5b9e6834 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.ts @@ -11,10 +11,10 @@ export const useReduxBusQueryManager = (): void => { const handleChangeQuery = useCallback( () => + // eslint-disable-next-line react-hooks/exhaustive-deps router.replace( { query: { - ...router.query, ...queryData, }, }, @@ -23,7 +23,6 @@ export const useReduxBusQueryManager = (): void => { shallow: true, }, ), - // router is not an stable reference // eslint-disable-next-line react-hooks/exhaustive-deps [queryData], );