diff --git a/package-lock.json b/package-lock.json index 4aad8440554ebbab3ac260423e29db2eb4097bbe..dabf49dce6380c53f944390a58b1d5a32e3ba10b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,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", @@ -1958,6 +1960,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", @@ -4897,6 +4914,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", @@ -11527,6 +11554,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", @@ -15293,6 +15357,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", @@ -17479,6 +17558,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", @@ -22160,6 +22249,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 4cf34d33a72916303aa6cf834ab8b7e4f1e2a1ba..4fb3ac58884eefadf31d01b6ad6a9b331d450875 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", @@ -41,6 +41,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", 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 0244ce42dd44e7f82fddf2d2454cb10e989e5679..a96fe3f58dd1ceed2671406345a871ed4c638671 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge'; import { LoginModal } from './LoginModal'; import { MODAL_ROLE } from './Modal.constants'; import { OverviewImagesModal } from './OverviewImagesModal'; +import { EditOverlayModal } from './EditOverlayModal'; const MolArtModal = dynamic( () => import('./MolArtModal/MolArtModal.component').then(mod => mod.MolArtModal), @@ -35,6 +36,7 @@ export const Modal = (): React.ReactNode => { className={twMerge( 'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg', modalName === 'login' && 'h-auto w-[400px]', + modalName === 'edit-overlay' && 'h-auto w-[450px]', )} > <div className="flex items-center justify-between bg-white p-[24px] text-xl"> @@ -46,6 +48,7 @@ export const Modal = (): React.ReactNode => { {isOpen && modalName === 'overview-images' && <OverviewImagesModal />} {isOpen && modalName === 'mol-art' && <MolArtModal />} {isOpen && modalName === 'login' && <LoginModal />} + {isOpen && modalName === 'edit-overlay' && <EditOverlayModal />} </div> </div> </div> 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 3c24e3caf889ed01daa9532da2ef576fd85819a5..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; 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 100% rename from src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts rename to src/components/Map/Drawer/OverlaysDrawer/hooks/useOverlay.ts diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index b5f89ef4c09a2eae9d23efe646a9f6838c073dd3..5275562485dd09a9d3982905371d61e2f834500e 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -44,6 +44,16 @@ export const apiPath = { getCompartmentPathwayDetails: (ids: number[]): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/?id=${ids.join(',')}`, sendCompartmentPathwaysIds: (): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/`, + 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 => 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 78d5adeaf1cdfce8da14de32fcb17db2828b51a3..9308ed4df46feef22d3fdf449dc9683e81fb12e5 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; @@ -50,3 +50,13 @@ export const setOverviewImageIdReducer = ( imageId: action.payload, }; }; + +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 84f1023141d74c975d7b2d879d9b51641439b344..c7b9feb6dbf9d8f7374ba547cd5277459356d7b5 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -7,6 +7,7 @@ import { openOverviewImagesModalByIdReducer, openMolArtModalByIdReducer, setOverviewImageIdReducer, + openEditOverlayModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -19,6 +20,7 @@ const modalSlice = createSlice({ openMolArtModalById: openMolArtModalByIdReducer, setOverviewImageId: setOverviewImageIdReducer, openLoginModal: openLoginModalReducer, + openEditOverlayModal: openEditOverlayModalReducer, }, }); @@ -29,6 +31,7 @@ export const { setOverviewImageId, openMolArtModalById, openLoginModal, + 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/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/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/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/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/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 763926937f9de43ed4ae16a957d75fc2a6069684..8afe715f0dbd3802fd30b3b4985721003c628230 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -1 +1 @@ -export type ModalName = 'none' | 'overview-images' | 'mol-art' | 'login'; +export type ModalName = 'none' | 'overview-images' | 'mol-art' | 'login' | 'edit-overlay'; 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({