diff --git a/.eslintrc.json b/.eslintrc.json index 3ed8a0dac26c9f37e4753346fdf8856d5351cabe..8712576ff44b991298e686588f6cb343c1bf9ab6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -89,7 +89,14 @@ "config": "./tailwind.config.ts" } ], - "prettier/prettier": "error" + "prettier/prettier": "error", + "jsx-a11y/label-has-associated-control": [ + 2, + { + "controlComponents": ["Input"], + "depth": 3 + } + ] }, "overrides": [ { diff --git a/package-lock.json b/package-lock.json index 268c6f7edcedbd8e8de2ffda5d8b728c8abbd8d2..4fb5b060db68de2f2175f44a2f25267d63c091dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", @@ -3050,6 +3051,14 @@ "node": ">= 4.0.0" } }, + "node_modules/attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.15", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", @@ -6349,6 +6358,17 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-selector": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", + "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -11359,6 +11379,22 @@ "react": "^18.2.0" } }, + "node_modules/react-dropzone": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", + "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -15894,6 +15930,11 @@ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true }, + "attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" + }, "autoprefixer": { "version": "10.4.15", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", @@ -18287,6 +18328,14 @@ "flat-cache": "^3.0.4" } }, + "file-selector": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", + "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "requires": { + "tslib": "^2.4.0" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -21770,6 +21819,16 @@ "scheduler": "^0.23.0" } }, + "react-dropzone": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", + "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "requires": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "prop-types": "^15.8.1" + } + }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index deee43c5eda2c8e1f543cd52b919e81619ff3bf2..081b8f16e154a0fbdc12837c212cf97441ded897 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", diff --git a/setupTests.ts b/setupTests.ts index e8c65391c7967eb34c7359010aaef4e93a7dd653..1d944c81f3e4f47f19d599c4f702725886518791 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -10,6 +10,10 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ jest.mock('next/router', () => require('next-router-mock')); +global.TextEncoder = jest.fn().mockImplementation(() => ({ + encode: jest.fn(), +})); + const localStorageMock = (() => { let store: { [key: PropertyKey]: string; diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx index 354b53377e7fa13900bc8f38b872099caf622514..40ac94361133d1a08a9d31260dd277a28c7f35d6 100644 --- a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx @@ -3,6 +3,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { loadingUserSelector } from '@/redux/user/user.selectors'; import { login } from '@/redux/user/user.thunks'; import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; import Link from 'next/link'; import React from 'react'; @@ -27,26 +28,26 @@ export const LoginModal: React.FC = () => { <form onSubmit={handleSubmit}> <label className="mb-5 block text-sm font-semibold" htmlFor="login"> Login: - <input + <Input type="text" name="login" id="login" placeholder="Your login here.." value={credentials.login} onChange={handleChange} - className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + className="mt-2.5 text-sm font-medium text-font-400" /> </label> <label className="text-sm font-semibold" htmlFor="password"> Password: - <input + <Input type="password" name="password" id="password" placeholder="Your password here.." value={credentials.password} onChange={handleChange} - className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + className="mt-2.5 text-sm font-medium text-font-400" /> </label> <div className="mb-10 text-right"> diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx index c64ecf7408b301eb3f5b77ed21ee963d748d5fb3..15b84a160d4112d107fbdee6027183c6bf375fdb 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx @@ -1,15 +1,26 @@ import { DrawerHeading } from '@/shared/DrawerHeading'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { currentStepOverlayDrawerStateSelector } from '@/redux/drawer/drawer.selectors'; +import { STEP } from '@/constants/searchDrawer'; import { GeneralOverlays } from './GeneralOverlays'; import { UserOverlays } from './UserOverlays'; +import { UserOverlayForm } from './UserOverlayForm'; export const OverlaysDrawer = (): JSX.Element => { + const currentStep = useAppSelector(currentStepOverlayDrawerStateSelector); + return ( <div data-testid="overlays-drawer" className="h-full max-h-full"> - <DrawerHeading title="Overlays" /> - <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto"> - <GeneralOverlays /> - <UserOverlays /> - </div> + {currentStep === STEP.FIRST && ( + <> + <DrawerHeading title="Overlays" /> + <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto"> + <GeneralOverlays /> + <UserOverlays /> + </div> + </> + )} + {currentStep === STEP.SECOND && <UserOverlayForm />} </div> ); }; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83a68bfa1714e773b550a7c46552e14963b77832 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FileUpload } from './FileUpload.component'; + +describe('FileUpload component', () => { + const handleChangeFile = jest.fn(); + const handleChangeOverlayContent = jest.fn(); + const handleOverlayChange = jest.fn(); + const uploadedFile = new File(['file content'], 'test.txt', { + type: 'text/plain', + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly with default state', () => { + render( + <FileUpload + handleChangeFile={handleChangeFile} + handleChangeOverlayContent={handleChangeOverlayContent} + updateUserOverlayForm={handleOverlayChange} + uploadedFile={null} + />, + ); + + expect(screen.getByText(/drag and drop here or/i)).toBeInTheDocument(); + }); + + it('renders filename when file type is correct', () => { + render( + <FileUpload + handleChangeFile={handleChangeFile} + handleChangeOverlayContent={handleChangeOverlayContent} + updateUserOverlayForm={handleOverlayChange} + uploadedFile={uploadedFile} + />, + ); + + expect(screen.getByText(/test.txt/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4370a451657d8e912a245669f31b93877346e7d2 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.tsx @@ -0,0 +1,62 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { useDropzone } from 'react-dropzone'; +import { processOverlayContentChange } from '../UserOverlayForm.utils'; + +type FileUploadProps = { + updateUserOverlayForm: (nameType: string, value: string) => void; + handleChangeOverlayContent: (value: string) => void; + handleChangeFile: (value: File) => void; + uploadedFile: File | null; +}; + +export const FileUpload = ({ + handleChangeFile, + handleChangeOverlayContent, + updateUserOverlayForm, + uploadedFile, +}: FileUploadProps): React.ReactNode => { + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ + accept: { + 'text/plain': ['.txt'], + }, + onDrop: acceptedFiles => { + handleChangeFile(acceptedFiles[0]); + + const file = acceptedFiles[0]; + if (file) { + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = (e): void => { + if (e.target) { + const content = e.target?.result as string; + handleChangeOverlayContent(content); + processOverlayContentChange(content, updateUserOverlayForm); + } + }; + } + }, + }); + + return ( + <div + {...getRootProps()} + className="flex h-16 items-center justify-center rounded-lg bg-cultured" + data-testid="dropzone" + > + <input {...getInputProps()} data-testid="dropzone-input" /> + <p className="text-xs font-semibold"> + {uploadedFile && uploadedFile.name} + + {isDragActive && !isDragReject && 'Drop the file here ...'} + + {!isDragActive && !uploadedFile && ( + <> + Drag and drop here or <span className="text-[#004DE2]">browse</span> + </> + )} + {isDragReject && 'Invalid file type. Please choose a supported format .txt'} + </p> + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..07a87a8db76e41abacdb0f0b31bdd5f2402de7a2 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/index.ts @@ -0,0 +1 @@ +export { FileUpload } from './FileUpload.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..acc140ebd165aa26853145692ff6bdaa17eced0f --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.test.tsx @@ -0,0 +1,48 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { OverlaySelector } from './OverlaySelector.component'; +import { SelectorItem } from '../UserOverlayForm.types'; + +const items: SelectorItem[] = [ + { id: '1', label: 'Item 1' }, + { id: '2', label: 'Item 2' }, + { id: '3', label: 'Item 3' }, +]; + +const onChangeMock = jest.fn(); + +describe('OverlaySelector component', () => { + it('renders the component with initial values', () => { + const label = 'Select an item'; + const value = items[0]; + + render(<OverlaySelector items={items} value={value} onChange={onChangeMock} label={label} />); + + expect(screen.getByText(label)).toBeInTheDocument(); + + expect(screen.getByTestId('selector-dropdown-button-name')).toHaveTextContent(value.label); + }); + + it('opens the dropdown and selects an item', () => { + const label = 'Select an item'; + const value = items[0]; + + render(<OverlaySelector items={items} value={value} onChange={onChangeMock} label={label} />); + + fireEvent.click(screen.getByTestId('selector-dropdown-button-name')); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + const selectedItem = items[1]; + const firstItem = screen.getByText(selectedItem.label); + + fireEvent.click(firstItem); + + waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + expect(onChangeMock).toHaveBeenCalledWith(selectedItem); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e29ed5fdd9870377c00ac323caf421e0fc597d0 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.tsx @@ -0,0 +1,84 @@ +/* eslint-disable no-magic-numbers */ +import { useSelect } from 'downshift'; + +import { twMerge } from 'tailwind-merge'; +import { Icon } from '@/shared/Icon'; +import { SelectorItem } from '../UserOverlayForm.types'; + +type OverlaySelectorProps = { + items: SelectorItem[]; + value: SelectorItem; + onChange: (item: SelectorItem) => void; + label: string; +}; + +export const OverlaySelector = ({ + items, + value, + onChange, + label, +}: OverlaySelectorProps): JSX.Element => { + const onItemSelect = (item: SelectorItem | undefined | null): void => { + if (item) { + onChange(item); + } + }; + + const { + isOpen, + getToggleButtonProps, + getMenuProps, + highlightedIndex, + getItemProps, + selectedItem, + } = useSelect({ + items, + defaultSelectedItem: items[0], + selectedItem: value, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => onItemSelect(newSelectedItem), + }); + + return ( + <div className="mb-2.5"> + <p className="my-2.5 text-sm">{label}</p> + + <div className={twMerge('relative rounded-t bg-cultured text-xs', !isOpen && 'rounded-b')}> + <div className={twMerge('flex w-full flex-col rounded-t py-2 pl-4 pr-3')}> + <div + {...getToggleButtonProps()} + className="flex cursor-pointer flex-row items-center justify-between bg-cultured" + > + <span data-testid="selector-dropdown-button-name" className="font-medium"> + {selectedItem?.label} + </span> + <Icon + name="chevron-down" + className={twMerge('arrow-button h-6 w-6 fill-primary-500', isOpen && 'rotate-180')} + /> + </div> + </div> + <ul + {...getMenuProps()} + className={`absolute inset-x-0 z-10 max-h-80 w-full overflow-scroll rounded-b bg-cultured p-0 ${ + !isOpen && 'hidden' + }`} + > + {isOpen && + items.map((item, index) => ( + <li + className={twMerge( + 'border-t', + highlightedIndex === index && 'text-primary-500', + 'flex flex-col px-4 py-2', + )} + key={item.id} + {...getItemProps({ item, index })} + > + <span>{item.label}</span> + </li> + ))} + </ul> + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..147e48942ee3dbd6730acc1ad69fe13bc1f5057a --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/index.ts @@ -0,0 +1 @@ +export { OverlaySelector } from './OverlaySelector.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b7c754c8f76d782d67e55a0beec1f0c2f1a3021c --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx @@ -0,0 +1,240 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { projectFixture } from '@/models/fixtures/projectFixture'; +import { + InitialStoreState, + 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'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { apiPath } from '@/redux/apiPath'; +import { + createdOverlayFileFixture, + createdOverlayFixture, + uploadedOverlayFileContentFixture, +} from '@/models/fixtures/overlaysFixture'; +import { UserOverlayForm } from './UserOverlayForm.component'; + +const mockedAxiosClient = mockNetworkResponse(); + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <UserOverlayForm /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const renderComponentWithActionListener = ( + initialStoreState: InitialStoreState = {}, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStoreState); + + return ( + render( + <Wrapper> + <UserOverlayForm /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('UserOverlayForm - Component', () => { + it('renders the UserOverlayForm component', () => { + renderComponent(); + + expect(screen.getByTestId('overlay-name')).toBeInTheDocument(); + expect(screen.getByLabelText('upload overlay')).toBeInTheDocument(); + }); + + it('should submit the form with elements list when upload button is clicked', async () => { + mockedAxiosClient + .onPost(apiPath.createOverlayFile()) + .reply(HttpStatusCode.Ok, createdOverlayFileFixture); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture); + + mockedAxiosClient + .onPost(apiPath.createOverlay('pd')) + .reply(HttpStatusCode.Ok, createdOverlayFixture); + + renderComponent({ + project: { + data: projectFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); + fireEvent.change(screen.getByTestId('overlay-description'), { + target: { value: 'Description Overlay' }, + }); + fireEvent.change(screen.getByTestId('overlay-elements-list'), { + target: { value: 'Elements List Overlay' }, + }); + + fireEvent.click(screen.getByLabelText('upload overlay')); + + expect(screen.getByLabelText('upload overlay')).toBeDisabled(); + }); + + it('should create correct name for file which contains elements list as content', async () => { + const { store } = renderComponentWithActionListener({ + project: { + data: projectFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); + fireEvent.change(screen.getByTestId('overlay-description'), { + target: { value: 'Description Overlay' }, + }); + fireEvent.change(screen.getByTestId('overlay-elements-list'), { + target: { value: 'Elements List Overlay' }, + }); + + const actions = store.getActions(); + fireEvent.click(screen.getByLabelText('upload overlay')); + + expect(actions[0].meta.arg.filename).toBe('unknown.txt'); + }); + + 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, + }, + }, + }); + + fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); + fireEvent.change(screen.getByTestId('overlay-description'), { + target: { value: 'Description Overlay' }, + }); + + fireEvent.change(screen.getByTestId('overlay-elements-list'), { + target: { + value: '#NAME = John\n# DESCRIPTION = Some description\n# TYPE = GENETIC_VARIANT\n', + }, + }); + + expect(screen.getByTestId('overlay-name')).toHaveValue('John'); + expect(screen.getByTestId('overlay-description')).toHaveValue('Some description'); + expect(screen.getByText('GENETIC_VARIANT')).toBeVisible(); + }); + + it('should display correct filename', async () => { + const uploadedFile = new File(['file content'], 'test.txt', { + type: 'text/plain', + }); + renderComponent({ + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + fireEvent.change(screen.getByTestId('dropzone-input'), { + target: { files: [uploadedFile] }, + }); + + const dropzone: HTMLInputElement = screen.getByTestId('dropzone-input'); + expect(dropzone?.files?.[0].name).toBe('test.txt'); + }); + + it('should not submit when form is not filled', async () => { + renderComponent({ + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + expect(screen.getByTestId('overlay-description')).toHaveValue(''); + fireEvent.click(screen.getByLabelText('upload overlay')); + expect(screen.getByLabelText('upload overlay')).not.toBeDisabled(); + }); + it('should navigate to overlays after clicking backward button', async () => { + const { store } = renderComponent({ + drawer: drawerOverlaysStepOneFixture, + project: { + data: projectFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const backButton = screen.getByRole('back-button'); + + backButton.click(); + + const { + drawer: { + overlayDrawerState: { currentStep }, + }, + } = store.getState(); + + expect(currentStep).toBe(1); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed8f04b70e8c38a2107665c1a3957c9c862aa83c --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.tsx @@ -0,0 +1,121 @@ +/* eslint-disable no-magic-numbers */ +import { DrawerHeadingBackwardButton } from '@/shared/DrawerHeadingBackwardButton'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { openOverlaysDrawer } from '@/redux/drawer/drawer.slice'; +import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; +import { Textarea } from '@/shared/Textarea'; +import { OverlaySelector } from './OverlaySelector'; +import { OVERLAY_GROUPS, OVERLAY_TYPES } from './UserOverlayForm.constants'; +import { FileUpload } from './FileUpload'; +import { useUserOverlayForm } from './hooks/useUserOverlayForm'; + +export const UserOverlayForm = (): React.ReactNode => { + const dispatch = useAppDispatch(); + const { + name, + type, + group, + description, + uploadedFile, + elementsList, + isPending, + handleChangeName, + handleChangeDescription, + handleChangeType, + handleChangeGroup, + handleChangeElementsList, + handleSubmit, + updateUserOverlayForm, + handleChangeUploadedFile, + handleChangeOverlayContent, + } = useUserOverlayForm(); + + const navigateToOverlays = (): void => { + dispatch(openOverlaysDrawer()); + }; + + return ( + <> + <DrawerHeadingBackwardButton backwardFunction={navigateToOverlays}> + Add overlay + </DrawerHeadingBackwardButton> + <form className="flex h-[calc(100%-93px)] max-h-[calc(100%-93px)] flex-col overflow-y-auto p-6"> + <div className="mb-2.5"> + <p className="mb-2.5 text-sm">Upload file</p> + <FileUpload + uploadedFile={uploadedFile} + updateUserOverlayForm={updateUserOverlayForm} + handleChangeFile={handleChangeUploadedFile} + handleChangeOverlayContent={handleChangeOverlayContent} + /> + <p className="my-5 text-center">or</p> + <label className="text-sm" htmlFor="elementsList"> + Provide list of elements here + <Textarea + id="elementsList" + name="elementsList" + data-testid="overlay-elements-list" + value={elementsList} + onChange={handleChangeElementsList} + rows={6} + placeholder="Type here" + className="mt-2.5" + /> + </label> + </div> + + <label className="mb-2.5 text-sm" htmlFor="name"> + Name + <Input + type="text" + name="name" + id="name" + data-testid="overlay-name" + value={name} + onChange={handleChangeName} + placeholder="Overlays 11/07/2022" + sizeVariant="medium" + className="mt-2.5 text-xs" + /> + </label> + + <OverlaySelector + value={type} + onChange={handleChangeType} + items={OVERLAY_TYPES} + label="Type" + /> + + <OverlaySelector + value={group} + onChange={handleChangeGroup} + items={OVERLAY_GROUPS} + label="Select group" + /> + + <label className="mt-2.5 text-sm" htmlFor="description"> + Description + <Textarea + id="description" + name="description" + value={description} + data-testid="overlay-description" + onChange={handleChangeDescription} + rows={4} + placeholder="Type Description" + className="mt-2.5" + /> + </label> + <Button + className="mt-2.5 items-center justify-center self-start" + onClick={handleSubmit} + disabled={isPending} + aria-label="upload overlay" + > + Upload + </Button> + </form> + </> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.constants.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b4622dbcb86e2a818d40f7c2a29cf3556f19fa6 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.constants.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-magic-numbers */ +export const OVERLAY_TYPES = [ + { + id: 'GENERIC', + label: 'GENERIC', + }, + { + id: 'GENETIC_VARIANT', + label: 'GENETIC_VARIANT', + }, +]; + +export const OVERLAY_GROUPS = [ + { + id: 'WITHOUT_GROUP', + label: 'Without group', + }, +]; + +export const DEFAULT_GROUP = OVERLAY_GROUPS[0]; + +export const DEFAULT_TYPE = OVERLAY_TYPES[0]; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.types.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..531a4e204a62d2bdb9742ce950ee84041c9cd92f --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.types.ts @@ -0,0 +1 @@ +export type SelectorItem = { id: string; label: string }; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f3baf2ac99c9e6a9cca8831cbe4ce35bdbf2ac8 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.test.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-magic-numbers */ +import { processOverlayContentChange } from './UserOverlayForm.utils'; + +const handleOverlayChange = jest.fn(); + +describe('processOverlayContentChange', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should parse overlay file content and invoke the handleOverlayChange callback for valid lines', () => { + const fileContent = `#NAME = John\n# DESCRIPTION = Some description\n# TYPE = Type1\n`; + + processOverlayContentChange(fileContent, handleOverlayChange); + + expect(handleOverlayChange).toHaveBeenCalledTimes(3); + expect(handleOverlayChange).toHaveBeenCalledWith('NAME', 'John'); + expect(handleOverlayChange).toHaveBeenCalledWith('DESCRIPTION', 'Some description'); + expect(handleOverlayChange).toHaveBeenCalledWith('TYPE', 'Type1'); + }); + + it('should handle lines with invalid format without calling handleOverlayChange', () => { + const fileContent = `InvalidLine1\n#InvalidLine2\n=InvalidLine3\n`; + + processOverlayContentChange(fileContent, handleOverlayChange); + + expect(handleOverlayChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3c499996a57dc078523f9177b6a58799d2198ac --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-magic-numbers */ + +type OverlayDataCallback = { + (nameType: string, value: string): void; +}; + +const OVERLAY_INFO_INDICATOR = '#'; +const ASSIGNMENT_OPERATOR = '='; + +export const processOverlayContentChange = ( + fileContent: string, + callback: OverlayDataCallback, +): void => { + const content = fileContent.trim(); + const lines = content.split('\n'); + + lines.forEach(line => { + const isOverlayInfoLine = line.indexOf(OVERLAY_INFO_INDICATOR) === 0; + const hasAssignment = line.indexOf(ASSIGNMENT_OPERATOR) > 0; + + if (isOverlayInfoLine && hasAssignment) { + const nameType = line.substring(1, line.indexOf(ASSIGNMENT_OPERATOR)).trim(); + const value = line.substring(line.indexOf(ASSIGNMENT_OPERATOR) + 1).trim(); + callback(nameType, value); + } + }); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..988e9ea0989902028959116c91971b6dcf26e984 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.test.ts @@ -0,0 +1,59 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { ChangeEvent } from 'react'; +import { useUserOverlayForm } from './useUserOverlayForm'; + +describe('useUserOverlayForm', () => { + it('should update state when form fields are changed', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.handleChangeType({ id: '1', label: 'Test Type' }); + result.current.handleChangeGroup({ id: '1', label: 'Test Group' }); + }); + + expect(result.current.type).toEqual({ id: '1', label: 'Test Type' }); + expect(result.current.group).toEqual({ id: '1', label: 'Test Group' }); + }); + + it('should update overlayContent when handleChangeOverlayContent is called', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.handleChangeOverlayContent('Test Overlay Content'); + }); + + expect(result.current.overlayContent).toBe('Test Overlay Content'); + }); + it('should update elementsList and overlayContent when handleChangeElementsList is called', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.handleChangeElementsList({ + target: { value: 'Test Elements List' }, + } as ChangeEvent<HTMLTextAreaElement>); + }); + + expect(result.current.elementsList).toBe('Test Elements List'); + expect(result.current.overlayContent).toBe('Test Elements List'); + }); + it('should update state variables based on updateUserOverlayForm', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.updateUserOverlayForm('NAME', 'Test Name'); + }); + + expect(result.current.name).toBe('Test Name'); + + act(() => { + result.current.updateUserOverlayForm('DESCRIPTION', 'Test Description'); + }); + expect(result.current.description).toBe('Test Description'); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.ts new file mode 100644 index 0000000000000000000000000000000000000000..dda281bf7dc92145f1902d283f40a7521a34ee25 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.ts @@ -0,0 +1,143 @@ +import { useState, ChangeEvent } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { projectIdSelector } from '@/redux/project/project.selectors'; +import { addOverlay } from '@/redux/overlays/overlays.thunks'; +import { loadingAddOverlay } from '@/redux/overlays/overlays.selectors'; +import { DEFAULT_GROUP, DEFAULT_TYPE, OVERLAY_TYPES } from '../UserOverlayForm.constants'; +import { SelectorItem } from '../UserOverlayForm.types'; +import { processOverlayContentChange } from '../UserOverlayForm.utils'; + +type ReturnType = { + name: string; + type: SelectorItem; + group: SelectorItem; + description: string; + uploadedFile: File | null; + elementsList: string; + overlayContent: string; + projectId?: string; + isPending: boolean; + handleChangeName: (e: ChangeEvent<HTMLInputElement>) => void; + handleChangeDescription: (e: ChangeEvent<HTMLTextAreaElement>) => void; + handleChangeType: (value: SelectorItem) => void; + handleChangeGroup: (value: SelectorItem) => void; + handleChangeUploadedFile: (value: File) => void; + handleChangeOverlayContent: (value: string) => void; + handleChangeElementsList: (e: ChangeEvent<HTMLTextAreaElement>) => void; + handleSubmit: () => Promise<void>; + updateUserOverlayForm: (nameType: string, value: string) => void; +}; + +export const useUserOverlayForm = (): ReturnType => { + const dispatch = useAppDispatch(); + const projectId = useAppSelector(projectIdSelector); + const loadingAddOverlayStatus = useAppSelector(loadingAddOverlay); + const isPending = loadingAddOverlayStatus === 'pending'; + + const [name, setName] = useState(''); + const [type, setType] = useState<SelectorItem>(DEFAULT_TYPE); + const [group, setGroup] = useState<SelectorItem>(DEFAULT_GROUP); + const [description, setDescription] = useState(''); + const [uploadedFile, setUploadedFile] = useState<File | null>(null); + const [elementsList, setElementsList] = useState(''); + const [overlayContent, setOverlayContent] = useState(''); + + const handleChangeName = (e: ChangeEvent<HTMLInputElement>): void => { + setName(e.target.value); + }; + + const handleChangeDescription = (e: ChangeEvent<HTMLTextAreaElement>): void => { + setDescription(e.target.value); + }; + + const handleChangeType = (value: SelectorItem): void => { + setType(value); + }; + + const handleChangeGroup = (value: SelectorItem): void => { + setGroup(value); + }; + + const handleChangeUploadedFile = (value: File): void => { + setUploadedFile(value); + }; + + const handleChangeOverlayContent = (value: string): void => { + setOverlayContent(value); + }; + + const updateUserOverlayForm = (nameType: string, value: string): void => { + switch (nameType) { + case 'NAME': + setName(value); + break; + case 'DESCRIPTION': + setDescription(value); + break; + case 'TYPE': { + const foundType = OVERLAY_TYPES.find(el => el.id === value); + if (foundType) { + setType(foundType); + } + break; + } + default: + break; + } + }; + + const handleChangeElementsList = (e: ChangeEvent<HTMLTextAreaElement>): void => { + processOverlayContentChange(e.target.value, updateUserOverlayForm); // When user change elements list we have to analyze content. If it contains overlay info like e.g NAME we need to update field NAME in form + setOverlayContent(e.target.value); + setElementsList(e.target.value); + }; + + const handleSubmit = async (): Promise<void> => { + let filename = uploadedFile?.name; + + if (!filename) { + filename = 'unknown.txt'; // Elements list is sent to the backend as a file, so we need to create a filename for the elements list. + } + + if (!overlayContent || !projectId || !description || !name) return; + + dispatch( + addOverlay({ + content: overlayContent, + description, + filename, + name, + projectId, + type: type.id, + }), + ); + + setName(''); + setDescription(''); + setElementsList(''); + setOverlayContent(''); + setUploadedFile(null); + }; + + return { + name, + type, + group, + description, + uploadedFile, + elementsList, + overlayContent, + projectId, + isPending, + handleChangeName, + handleChangeDescription, + handleChangeType, + handleChangeGroup, + handleChangeElementsList, + handleSubmit, + updateUserOverlayForm, + handleChangeUploadedFile, + handleChangeOverlayContent, + }; +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e51db0f06b59bd7e0dec1b5460263e3f47e5efc2 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/index.ts @@ -0,0 +1 @@ +export { UserOverlayForm } from './UserOverlayForm.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fbf7dc2eadb067a97bc4d4ecf389cf5389ee3dbb --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx @@ -0,0 +1,83 @@ +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { UserOverlays } from './UserOverlays.component'; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <UserOverlays /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('UserOverlays component', () => { + it('renders loading message when user is loading', () => { + renderComponent({ + user: { + loading: 'pending', + authenticated: false, + error: { name: '', message: '' }, + login: null, + }, + }); + + expect(screen.getByText('Loading')).toBeInTheDocument(); + }); + + it('renders login button when user is not authenticated', () => { + renderComponent({ + user: { + loading: 'failed', + authenticated: false, + error: { name: '', message: '' }, + login: null, + }, + }); + + expect(screen.getByLabelText('login button')).toBeInTheDocument(); + }); + + it('dispatches openLoginModal action when Login button is clicked', () => { + const { store } = renderComponent({ + user: { + loading: 'failed', + authenticated: false, + error: { name: '', message: '' }, + login: null, + }, + modal: { + isOpen: false, + modalName: 'none', + modalTitle: '', + overviewImagesState: {}, + }, + }); + screen.getByLabelText('login button').click(); + const state = store.getState().modal; + expect(state.isOpen).toEqual(true); + expect(state.modalName).toEqual('login'); + }); + + it('renders add overlay button when user is authenticated', () => { + renderComponent({ + user: { + loading: 'succeeded', + authenticated: true, + error: { name: '', message: '' }, + login: 'test', + }, + }); + + expect(screen.getByLabelText('add overlay button')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx index d594f01698bd6bc04fc19d67b02dc196a7e22221..2db18b4201bb8ce15a36ea59994a5238e011cb79 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx @@ -1,3 +1,4 @@ +import { displayAddOverlaysDrawer } from '@/redux/drawer/drawer.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { openLoginModal } from '@/redux/modal/modal.slice'; @@ -8,27 +9,40 @@ export const UserOverlays = (): JSX.Element => { const dispatch = useAppDispatch(); const loadingUser = useAppSelector(loadingUserSelector); const authenticatedUser = useAppSelector(authenticatedUserSelector); + const isPending = loadingUser === 'pending'; const handleLoginClick = (): void => { dispatch(openLoginModal()); }; + const handleAddOverlay = (): void => { + dispatch(displayAddOverlaysDrawer()); + }; + return ( <div className="p-6"> - {loadingUser === 'pending' && <h1>Loading</h1>} + {isPending && <h1>Loading</h1>} - {loadingUser !== 'pending' && !authenticatedUser && ( + {!isPending && !authenticatedUser && ( <> <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 </p> - <Button onClick={handleLoginClick}>Login</Button> + <Button onClick={handleLoginClick} aria-label="login button"> + Login + </Button> </> )} - {/* TODO: Implement user overlays */} - {authenticatedUser && <h1>Authenticated</h1>} + {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> ); }; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx index a533a5b83bc7ca1eb96e6d1210df63868420d05d..94bc618d7d632fd681c45a9f96b2523fd6b8dffc 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx @@ -26,6 +26,9 @@ const INITIAL_STATE: InitialStoreState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }, drugs: { data: [ diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx index 9477d59d8d24cfd5d3af9bec571e13694a482409..c36eb3a97ab71c8c936439e223f01849b459a275 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx @@ -45,6 +45,9 @@ describe('SearchDrawerWrapper - component', () => { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }, }); @@ -65,6 +68,9 @@ describe('SearchDrawerWrapper - component', () => { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }, }); diff --git a/src/models/fixtures/overlaysFixture.ts b/src/models/fixtures/overlaysFixture.ts index c0a26efd4daccf2dbd062e7b37a67ef6e2d1033a..d981dba341420fbd50d17cd5d20b2d51af3189b5 100644 --- a/src/models/fixtures/overlaysFixture.ts +++ b/src/models/fixtures/overlaysFixture.ts @@ -2,9 +2,25 @@ import { ZOD_SEED } from '@/constants'; import { z } from 'zod'; // eslint-disable-next-line import/no-extraneous-dependencies import { createFixture } from 'zod-fixture'; -import { mapOverlay } from '../mapOverlay'; +import { + createdOverlayFileSchema, + createdOverlaySchema, + mapOverlay, + uploadedOverlayFileContentSchema, +} from '../mapOverlay'; export const overlaysFixture = createFixture(z.array(mapOverlay), { seed: ZOD_SEED, array: { min: 2, max: 2 }, }); + +export const createdOverlayFileFixture = createFixture(createdOverlayFileSchema, { + seed: ZOD_SEED, +}); + +export const uploadedOverlayFileContentFixture = createFixture(uploadedOverlayFileContentSchema, { + seed: ZOD_SEED, +}); +export const createdOverlayFixture = createFixture(createdOverlaySchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/mapOverlay.ts b/src/models/mapOverlay.ts index a22b65aa5eed7751bc3033c30998972dadadd9c5..16da571736fd815bb63a9c6bef833c5ab2102c3a 100644 --- a/src/models/mapOverlay.ts +++ b/src/models/mapOverlay.ts @@ -12,3 +12,26 @@ export const mapOverlay = z.object({ type: z.string(), order: z.number(), }); + +export const createdOverlayFileSchema = z.object({ + id: z.number(), + filename: z.string(), + length: z.number(), + owner: z.string(), + uploadedDataLength: z.number(), +}); + +export const uploadedOverlayFileContentSchema = createdOverlayFileSchema.extend({}); + +export const createdOverlaySchema = z.object({ + name: z.string(), + googleLicenseConsent: z.boolean(), + creator: z.string(), + description: z.string(), + genomeType: z.string().nullable(), + genomeVersion: z.string().nullable(), + idObject: z.number(), + publicOverlay: z.boolean(), + type: z.string(), + order: z.number(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 283a420a57d9f49509d4f43785b9ac9dda4b6ad3..433b6c499e2a2cd01c02670873976f46dd40d3db 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -35,5 +35,8 @@ export const apiPath = { getConfiguration: (): string => 'configuration/', getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string => `projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`, + createOverlay: (projectId: string): string => `projects/${projectId}/overlays/`, + createOverlayFile: (): string => `files/`, + uploadOverlayFileContent: (fileId: number): string => `files/${fileId}:uploadContent`, getStatisticsById: (projectId: string): string => `projects/${projectId}/statistics/`, }; diff --git a/src/redux/drawer/drawer.constants.ts b/src/redux/drawer/drawer.constants.ts index c04c02963c448309b61fa0d854c93875012d8514..f1035f976654ddf96e73684f3e23181e1b2722e4 100644 --- a/src/redux/drawer/drawer.constants.ts +++ b/src/redux/drawer/drawer.constants.ts @@ -12,4 +12,7 @@ export const DRAWER_INITIAL_STATE: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; diff --git a/src/redux/drawer/drawer.reducers.ts b/src/redux/drawer/drawer.reducers.ts index 8ecb83281c39220a3909308e0c902e518f68842b..8a60b60fab8b52fdd93845ff79470c2f480d794d 100644 --- a/src/redux/drawer/drawer.reducers.ts +++ b/src/redux/drawer/drawer.reducers.ts @@ -31,6 +31,13 @@ export const openSubmapsDrawerReducer = (state: DrawerState): void => { export const openOverlaysDrawerReducer = (state: DrawerState): void => { state.isOpen = true; state.drawerName = 'overlays'; + state.overlayDrawerState.currentStep = STEP.FIRST; +}; + +export const displayAddOverlaysDrawerReducer = (state: DrawerState): void => { + state.isOpen = true; + state.drawerName = 'overlays'; + state.overlayDrawerState.currentStep = STEP.SECOND; }; export const selectTabReducer = ( diff --git a/src/redux/drawer/drawer.selectors.ts b/src/redux/drawer/drawer.selectors.ts index 060a652a62b8ffb78d487d9cc81d0fc530169368..26e9a90b8f01ba8f2e353c8aa8310ff9917f9fcd 100644 --- a/src/redux/drawer/drawer.selectors.ts +++ b/src/redux/drawer/drawer.selectors.ts @@ -98,3 +98,13 @@ export const currentDrawerReactionIdSelector = createSelector( reactionDrawerStateSelector, state => state?.reactionId, ); + +export const overlayDrawerStateSelector = createSelector( + drawerSelector, + state => state.overlayDrawerState, +); + +export const currentStepOverlayDrawerStateSelector = createSelector( + overlayDrawerStateSelector, + state => state.currentStep, +); diff --git a/src/redux/drawer/drawer.slice.ts b/src/redux/drawer/drawer.slice.ts index 769b5cf0a23560b03b607ccfff67beff81e429f4..98e073aea596150c61886bba0af7636abb6af68e 100644 --- a/src/redux/drawer/drawer.slice.ts +++ b/src/redux/drawer/drawer.slice.ts @@ -13,6 +13,7 @@ import { openSearchDrawerWithSelectedTabReducer, openSubmapsDrawerReducer, selectTabReducer, + displayAddOverlaysDrawerReducer, } from './drawer.reducers'; import { DRAWER_INITIAL_STATE } from './drawer.constants'; @@ -24,6 +25,7 @@ const drawerSlice = createSlice({ openSearchDrawerWithSelectedTab: openSearchDrawerWithSelectedTabReducer, openSubmapsDrawer: openSubmapsDrawerReducer, openOverlaysDrawer: openOverlaysDrawerReducer, + displayAddOverlaysDrawer: displayAddOverlaysDrawerReducer, selectTab: selectTabReducer, closeDrawer: closeDrawerReducer, displayDrugsList: displayDrugsListReducer, @@ -41,6 +43,7 @@ export const { openSearchDrawerWithSelectedTab, openSubmapsDrawer, openOverlaysDrawer, + displayAddOverlaysDrawer, selectTab, closeDrawer, displayDrugsList, diff --git a/src/redux/drawer/drawer.types.ts b/src/redux/drawer/drawer.types.ts index 44ed65164ace31db7a1ca07561382e8c6d61ffa7..075fcd1961a96c79e242b41a9a81273e4f56f9a5 100644 --- a/src/redux/drawer/drawer.types.ts +++ b/src/redux/drawer/drawer.types.ts @@ -10,6 +10,10 @@ export type SearchDrawerState = { selectedSearchElement: string; }; +export type OverlayDrawerState = { + currentStep: number; +}; + export type ReactionDrawerState = { reactionId?: number; }; @@ -24,6 +28,7 @@ export type DrawerState = { searchDrawerState: SearchDrawerState; reactionDrawerState: ReactionDrawerState; bioEntityDrawerState: BioEntityDrawerState; + overlayDrawerState: OverlayDrawerState; }; export type OpenSearchDrawerWithSelectedTabReducerPayload = string; diff --git a/src/redux/drawer/drawerFixture.ts b/src/redux/drawer/drawerFixture.ts index f96232938b74978fd097d7a13cc23ea4a4f89914..29b32d3aeb1d5f53ff6ee34abd86d325d6a71e8b 100644 --- a/src/redux/drawer/drawerFixture.ts +++ b/src/redux/drawer/drawerFixture.ts @@ -12,6 +12,9 @@ export const initialStateFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const openedDrawerSubmapsFixture: DrawerState = { @@ -26,6 +29,9 @@ export const openedDrawerSubmapsFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const drawerSearchStepOneFixture: DrawerState = { @@ -40,6 +46,9 @@ export const drawerSearchStepOneFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const drawerSearchDrugsStepTwoFixture: DrawerState = { @@ -54,6 +63,9 @@ export const drawerSearchDrugsStepTwoFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const drawerSearchChemicalsStepTwoFixture: DrawerState = { @@ -68,6 +80,26 @@ export const drawerSearchChemicalsStepTwoFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, +}; + +export const drawerOverlaysStepOneFixture: DrawerState = { + isOpen: true, + drawerName: 'overlays', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + listOfBioEnitites: [], + selectedSearchElement: '', + }, + reactionDrawerState: {}, + bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 2, + }, }; export const openedExportDrawerFixture: DrawerState = { @@ -82,4 +114,7 @@ export const openedExportDrawerFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; diff --git a/src/redux/overlays/overlays.constants.ts b/src/redux/overlays/overlays.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..dda564cda0098e449d5adadb219d86b8320378e4 --- /dev/null +++ b/src/redux/overlays/overlays.constants.ts @@ -0,0 +1,2 @@ +/* eslint-disable no-magic-numbers */ +export const CHUNK_SIZE = 65535 * 8; diff --git a/src/redux/overlays/overlays.mock.ts b/src/redux/overlays/overlays.mock.ts index cdb5593cba6857acbbcafbb9d1886f93457e33cf..3e1557ba4e7604dd58aa294eeba2bd13ebced06e 100644 --- a/src/redux/overlays/overlays.mock.ts +++ b/src/redux/overlays/overlays.mock.ts @@ -6,6 +6,10 @@ export const OVERLAYS_INITIAL_STATE_MOCK: OverlaysState = { data: [], loading: 'idle', error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, }; export const PUBLIC_OVERLAYS_MOCK: MapOverlay[] = [ @@ -77,4 +81,17 @@ export const OVERLAYS_PUBLIC_FETCHED_STATE_MOCK: OverlaysState = { data: PUBLIC_OVERLAYS_MOCK, loading: 'succeeded', error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, +}; + +export const ADD_OVERLAY_MOCK = { + content: 'test', + description: 'test', + filename: 'unknown.txt', + name: 'test', + projectId: 'pd', + type: 'GENERIC', }; diff --git a/src/redux/overlays/overlays.reducers.test.ts b/src/redux/overlays/overlays.reducers.test.ts index d0116134dfce094433f2fd9a9948ee98541cd1eb..2fe92673346f59194fc6f78a5a85443d31d0b273 100644 --- a/src/redux/overlays/overlays.reducers.test.ts +++ b/src/redux/overlays/overlays.reducers.test.ts @@ -1,15 +1,23 @@ +/* eslint-disable no-magic-numbers */ import { PROJECT_ID } from '@/constants'; -import { overlaysFixture } from '@/models/fixtures/overlaysFixture'; +import { + createdOverlayFileFixture, + createdOverlayFixture, + overlaysFixture, + uploadedOverlayFileContentFixture, +} from '@/models/fixtures/overlaysFixture'; import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { waitFor } from '@testing-library/react'; import { apiPath } from '../apiPath'; import overlaysReducer from './overlays.slice'; -import { getAllPublicOverlaysByProjectId } from './overlays.thunks'; +import { addOverlay, getAllPublicOverlaysByProjectId } from './overlays.thunks'; import { OverlaysState } from './overlays.types'; +import { ADD_OVERLAY_MOCK } from './overlays.mock'; const mockedAxiosClient = mockNetworkResponse(); @@ -17,6 +25,10 @@ const INITIAL_STATE: OverlaysState = { data: [], loading: 'idle', error: { name: '', message: '' }, + addOverlay: { + loading: 'idle', + error: { name: '', message: '' }, + }, }; describe('overlays reducer', () => { @@ -30,7 +42,7 @@ describe('overlays reducer', () => { expect(overlaysReducer(undefined, action)).toEqual(INITIAL_STATE); }); - it('should update store after succesfull getAllPublicOverlaysByProjectId query', async () => { + it('should update store after successful getAllPublicOverlaysByProjectId query', async () => { mockedAxiosClient .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) .reply(HttpStatusCode.Ok, overlaysFixture); @@ -76,4 +88,58 @@ describe('overlays reducer', () => { expect(promiseFulfilled).toEqual('succeeded'); }); }); + it('should update store when addOverlay is pending', async () => { + mockedAxiosClient + .onPost(apiPath.createOverlayFile()) + .reply(HttpStatusCode.Ok, createdOverlayFileFixture); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture); + + mockedAxiosClient + .onPost(apiPath.createOverlay('pd')) + .reply(HttpStatusCode.Ok, createdOverlayFixture); + + await store.dispatch(addOverlay(ADD_OVERLAY_MOCK)); + const { loading } = store.getState().overlays.addOverlay; + + waitFor(() => { + expect(loading).toEqual('pending'); + }); + }); + + it('should update store after successful addOverlay', async () => { + mockedAxiosClient + .onPost(apiPath.createOverlayFile()) + .reply(HttpStatusCode.Ok, createdOverlayFileFixture); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture); + + mockedAxiosClient + .onPost(apiPath.createOverlay('pd')) + .reply(HttpStatusCode.Ok, createdOverlayFixture); + + await store.dispatch(addOverlay(ADD_OVERLAY_MOCK)); + const { loading, error } = store.getState().overlays.addOverlay; + + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + }); + it('should update store after failed addOverlay', async () => { + mockedAxiosClient.onPost(apiPath.createOverlayFile()).reply(HttpStatusCode.NotFound, undefined); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.NotFound, undefined); + + mockedAxiosClient.onPost(apiPath.createOverlay('pd')).reply(HttpStatusCode.NotFound, undefined); + + await store.dispatch(addOverlay(ADD_OVERLAY_MOCK)); + const { loading } = store.getState().overlays.addOverlay; + + expect(loading).toEqual('failed'); + }); }); diff --git a/src/redux/overlays/overlays.reducers.ts b/src/redux/overlays/overlays.reducers.ts index 99e493ea4f4f7b6d9288b0c28cfb9234057e9bc9..d8f12eef16e426ef9c7aef040030fd20963a1842 100644 --- a/src/redux/overlays/overlays.reducers.ts +++ b/src/redux/overlays/overlays.reducers.ts @@ -1,5 +1,5 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { getAllPublicOverlaysByProjectId } from './overlays.thunks'; +import { addOverlay, getAllPublicOverlaysByProjectId } from './overlays.thunks'; import { OverlaysState } from './overlays.types'; export const getAllPublicOverlaysByProjectIdReducer = ( @@ -17,3 +17,16 @@ export const getAllPublicOverlaysByProjectIdReducer = ( // TODO to discuss manage state of failure }); }; + +export const addOverlayReducer = (builder: ActionReducerMapBuilder<OverlaysState>): void => { + builder.addCase(addOverlay.pending, state => { + state.addOverlay.loading = 'pending'; + }); + builder.addCase(addOverlay.fulfilled, state => { + state.addOverlay.loading = 'succeeded'; + }); + builder.addCase(addOverlay.rejected, state => { + state.addOverlay.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 f9d36cad0a63c93ae55565e795b897c6bbd4db3e..b1035c5c7e6a359fdfe44884adbcaf49568d12b8 100644 --- a/src/redux/overlays/overlays.selectors.ts +++ b/src/redux/overlays/overlays.selectors.ts @@ -7,3 +7,8 @@ export const overlaysDataSelector = createSelector( overlaysSelector, overlays => overlays?.data || [], ); + +export const loadingAddOverlay = createSelector( + overlaysSelector, + state => state.addOverlay.loading, +); diff --git a/src/redux/overlays/overlays.slice.ts b/src/redux/overlays/overlays.slice.ts index 8d259288d5d8eb69d15d48a8408d4e83d6342573..5f49156af3e1b54b2074c7ae9b653e80f7488027 100644 --- a/src/redux/overlays/overlays.slice.ts +++ b/src/redux/overlays/overlays.slice.ts @@ -1,11 +1,15 @@ import { createSlice } from '@reduxjs/toolkit'; -import { getAllPublicOverlaysByProjectIdReducer } from './overlays.reducers'; +import { addOverlayReducer, getAllPublicOverlaysByProjectIdReducer } from './overlays.reducers'; import { OverlaysState } from './overlays.types'; const initialState: OverlaysState = { data: [], loading: 'idle', error: { name: '', message: '' }, + addOverlay: { + loading: 'idle', + error: { name: '', message: '' }, + }, }; const overlaysState = createSlice({ @@ -14,6 +18,7 @@ const overlaysState = createSlice({ reducers: {}, extraReducers: builder => { getAllPublicOverlaysByProjectIdReducer(builder); + addOverlayReducer(builder); }, }); diff --git a/src/redux/overlays/overlays.thunks.ts b/src/redux/overlays/overlays.thunks.ts index 6a01933378559ff68685808752a45c4cc11ce9aa..330e5ee98aba5a26299f21b8d56c9b29e16d2d1e 100644 --- a/src/redux/overlays/overlays.thunks.ts +++ b/src/redux/overlays/overlays.thunks.ts @@ -1,10 +1,17 @@ -import { mapOverlay } from '@/models/mapOverlay'; +/* eslint-disable no-magic-numbers */ +import { + createdOverlayFileSchema, + createdOverlaySchema, + mapOverlay, + uploadedOverlayFileContentSchema, +} from '@/models/mapOverlay'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; -import { MapOverlay } from '@/types/models'; +import { CreatedOverlay, CreatedOverlayFile, MapOverlay } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; import { apiPath } from '../apiPath'; +import { CHUNK_SIZE } from './overlays.constants'; export const getAllPublicOverlaysByProjectId = createAsyncThunk( 'overlays/getAllPublicOverlaysByProjectId', @@ -18,3 +25,136 @@ export const getAllPublicOverlaysByProjectId = createAsyncThunk( return isDataValid ? response.data : []; }, ); + +/** UTILS */ + +type CreateFileArgs = { + filename: string; + content: string; +}; + +const createFile = async ({ filename, content }: CreateFileArgs): Promise<CreatedOverlayFile> => { + const fileParams = { + filename: `C:\\fakepath\\${filename}`, + length: content.length.toString(), + }; + + const response = await axiosInstance.post( + apiPath.createOverlayFile(), + new URLSearchParams(fileParams), + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, createdOverlayFileSchema); + return isDataValid ? response.data : undefined; +}; + +type UploadContentArgs = { + createdFile: CreatedOverlayFile; + overlayContent: string; +}; +const uploadContent = async ({ createdFile, overlayContent }: UploadContentArgs): Promise<void> => { + const data = new Uint8Array(new TextEncoder().encode(overlayContent)); + let uploadedLength = 0; + + const sendChunk = async (): Promise<void> => { + if (uploadedLength >= data.length) { + return; + } + + const chunk = data.slice(uploadedLength, uploadedLength + CHUNK_SIZE); + + const responeJSON = await fetch( + `${process.env.NEXT_PUBLIC_BASE_API_URL}/${apiPath.uploadOverlayFileContent(createdFile.id)}`, + { + method: 'POST', + credentials: 'include', + body: chunk, + }, + ); + + const response = await responeJSON.json(); + validateDataUsingZodSchema(response, uploadedOverlayFileContentSchema); + + uploadedLength += chunk.length; + sendChunk(); + }; + + await sendChunk(); +}; + +type CreatedOverlayArgs = { + createdFile: CreatedOverlayFile; + description: string; + name: string; + type: string; + projectId: string; +}; + +const creteOverlay = async ({ + createdFile, + description, + type, + name, + projectId, +}: CreatedOverlayArgs): Promise<CreatedOverlay> => { + const data = { + name, + description, + filename: createdFile.filename, + googleLicenseConsent: false.toString(), + type, + fileId: createdFile.id.toString(), + }; + + const overlay = new URLSearchParams(data); + + const response = await axiosInstance.post(apiPath.createOverlay(projectId), overlay, { + withCredentials: true, + }); + + const isDataValid = validateDataUsingZodSchema(response.data, createdOverlaySchema); + + return isDataValid ? response.data : undefined; +}; + +type AddOverlayArgs = { + filename: string; + content: string; + description: string; + type: string; + name: string; + projectId: string; +}; + +export const addOverlay = createAsyncThunk( + 'overlays/addOverlay', + async ({ + filename, + content, + description, + name, + type, + projectId, + }: AddOverlayArgs): Promise<void> => { + const createdFile = await createFile({ + filename, + content, + }); + + await uploadContent({ + createdFile, + overlayContent: content, + }); + + await creteOverlay({ + createdFile, + description, + name, + type, + projectId, + }); + }, +); diff --git a/src/redux/overlays/overlays.types.ts b/src/redux/overlays/overlays.types.ts index ee00e94527ebcf6d68a453c26e0808f94874b1fa..15d4d813a5879a8e5986e1daf411eceda9f5ef55 100644 --- a/src/redux/overlays/overlays.types.ts +++ b/src/redux/overlays/overlays.types.ts @@ -1,4 +1,12 @@ import { FetchDataState } from '@/types/fetchDataState'; +import { Loading } from '@/types/loadingState'; import { MapOverlay } from '@/types/models'; -export type OverlaysState = FetchDataState<MapOverlay[] | []>; +export type AddOverlayState = { + addOverlay: { + loading: Loading; + error: Error; + }; +}; + +export type OverlaysState = FetchDataState<MapOverlay[] | []> & AddOverlayState; diff --git a/src/redux/project/project.selectors.ts b/src/redux/project/project.selectors.ts index 610a6cce94495eb86214761dc84c5bc185a56609..c5ac340314f157036c59a0cc3cd648ae701582bd 100644 --- a/src/redux/project/project.selectors.ts +++ b/src/redux/project/project.selectors.ts @@ -31,3 +31,8 @@ export const projectDirectorySelector = createSelector( projectDataSelector, projectData => projectData?.directory, ); + +export const projectIdSelector = createSelector( + projectDataSelector, + projectData => projectData?.projectId, +); diff --git a/src/shared/Input/Input.component.test.tsx b/src/shared/Input/Input.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a26f46fcb939eb0c8e25756218715db9b95d4af8 --- /dev/null +++ b/src/shared/Input/Input.component.test.tsx @@ -0,0 +1,70 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { Input } from './Input.component'; + +describe('Input - component', () => { + it('should render with proper testid', () => { + render(<Input data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toBeInTheDocument(); + }); + + it('should apply the default style and size variants when none are provided', () => { + render(<Input data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('bg-cultured'); + expect(inputElement).toHaveClass('rounded-s'); + }); + + it('should apply the specified style variant', () => { + render(<Input styleVariant="primary" data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('bg-cultured'); + }); + + it('should apply the specified size variant', () => { + render(<Input sizeVariant="medium" data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('rounded-lg'); + expect(inputElement).toHaveClass('h-12'); + expect(inputElement).toHaveClass('text-sm'); + }); + + it('should merge custom class with style and size variant classes', () => { + render( + <Input + className="text-red-500" + styleVariant="primary" + sizeVariant="medium" + data-testid="input" + />, + ); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('text-red-500'); + expect(inputElement).toHaveClass('h-12'); + expect(inputElement).toHaveClass('bg-cultured'); + }); + + it(' should handle onChange event', () => { + const handleChange = jest.fn(); + render(<Input onChange={handleChange} data-testid="input" />); + const inputElement = screen.getByTestId('input'); + fireEvent.change(inputElement, { target: { value: 'Hello, World!' } }); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('should render with a placeholder', () => { + const placeholderText = 'Type here...'; + render(<Input placeholder={placeholderText} data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveAttribute('placeholder', placeholderText); + }); + + it('should render with a default value', () => { + const defaultValue = 'Initial value'; + render(<Input defaultValue={defaultValue} data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveValue(defaultValue); + }); +}); diff --git a/src/shared/Input/Input.component.tsx b/src/shared/Input/Input.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..68cf9097dbb05b652a539d52cb0ccdefdc9901d1 --- /dev/null +++ b/src/shared/Input/Input.component.tsx @@ -0,0 +1,33 @@ +import React, { InputHTMLAttributes } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type StyleVariant = 'primary'; +type SizeVariant = 'small' | 'medium'; + +type InputProps = { + className?: string; + styleVariant?: StyleVariant; + sizeVariant?: SizeVariant; +} & InputHTMLAttributes<HTMLInputElement>; + +const styleVariants = { + primary: + 'w-full border border-transparent bg-cultured px-2 py-2.5 font-semibold outline-none hover:border-greyscale-600 focus:border-greyscale-600', +} as const; + +const sizeVariants = { + small: 'rounded-s h-10 text-xs', + medium: 'rounded-lg h-12 text-sm', +} as const; + +export const Input = ({ + className = '', + sizeVariant = 'small', + styleVariant = 'primary', + ...props +}: InputProps): React.ReactNode => ( + <input + {...props} + className={twMerge(styleVariants[styleVariant], sizeVariants[sizeVariant], className)} + /> +); diff --git a/src/shared/Input/index.ts b/src/shared/Input/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfcccdc632e6f3970118276e0d157d0b4cef2d7b --- /dev/null +++ b/src/shared/Input/index.ts @@ -0,0 +1 @@ +export { Input } from './Input.component'; diff --git a/src/shared/Textarea/Textarea.component.test.tsx b/src/shared/Textarea/Textarea.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e337f637ad4575a1b3d334b1d7f13d7abfaa3759 --- /dev/null +++ b/src/shared/Textarea/Textarea.component.test.tsx @@ -0,0 +1,53 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { Textarea } from './Textarea.component'; + +describe('Textarea - Component', () => { + it('should render with proper testid', () => { + render(<Textarea data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toBeInTheDocument(); + }); + + it('should apply the default style variant when none is provided', () => { + render(<Textarea data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveClass('bg-cultured'); + }); + + it('should apply the specified style variant', () => { + render(<Textarea styleVariant="primary" data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveClass('bg-cultured'); + }); + + it('should merge custom class with style variant classes', () => { + render(<Textarea className="text-red-500" styleVariant="primary" data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveClass('text-red-500'); + expect(textareaElement).toHaveClass('bg-cultured'); + }); + + it('should handle onChange event', () => { + const handleChange = jest.fn(); + render(<Textarea onChange={handleChange} data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + fireEvent.change(textareaElement, { target: { value: 'Hello, World!' } }); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('should render with a placeholder', () => { + const placeholderText = 'Type here...'; + render(<Textarea placeholder={placeholderText} data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveAttribute('placeholder', placeholderText); + }); + + it('should render with a default value', () => { + const defaultValue = 'Initial value'; + render(<Textarea defaultValue={defaultValue} data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveValue(defaultValue); + }); +}); diff --git a/src/shared/Textarea/Textarea.component.tsx b/src/shared/Textarea/Textarea.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b300f21a1f13b750181fa14b32def0fa24802853 --- /dev/null +++ b/src/shared/Textarea/Textarea.component.tsx @@ -0,0 +1,22 @@ +import React, { TextareaHTMLAttributes } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type StyleVariant = 'primary'; + +type TextareaProps = { + className?: string; + styleVariant?: StyleVariant; +} & TextareaHTMLAttributes<HTMLTextAreaElement>; + +const styleVariants = { + primary: + 'w-full resize-none rounded-lg border border-transparent bg-cultured px-2 py-2.5 text-xs font-semibold outline-none hover:border-greyscale-600 focus:border-greyscale-600', +} as const; + +export const Textarea = ({ + className = '', + styleVariant = 'primary', + ...props +}: TextareaProps): React.ReactNode => ( + <textarea {...props} className={twMerge(styleVariants[styleVariant], className)} /> +); diff --git a/src/shared/Textarea/index.ts b/src/shared/Textarea/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..11d2fe00cef1e12fbe84e4fe247fc6cf436b4fcd --- /dev/null +++ b/src/shared/Textarea/index.ts @@ -0,0 +1 @@ +export { Textarea } from './Textarea.component'; diff --git a/src/types/models.ts b/src/types/models.ts index bdf8fd63d5d5b94b5051c84a34f3bbf358291153..8f4582e557ce84606857b048b60bb9d7518ca18a 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -10,7 +10,12 @@ import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; import { loginSchema } from '@/models/loginSchema'; import { mapBackground } from '@/models/mapBackground'; -import { mapOverlay } from '@/models/mapOverlay'; +import { + createdOverlayFileSchema, + createdOverlaySchema, + mapOverlay, + uploadedOverlayFileContentSchema, +} from '@/models/mapOverlay'; import { mapModelSchema } from '@/models/modelSchema'; import { organism } from '@/models/organism'; import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; @@ -55,5 +60,8 @@ export type Login = z.infer<typeof loginSchema>; export type ConfigurationOption = z.infer<typeof configurationOptionSchema>; export type Configuration = z.infer<typeof configurationSchema>; export type OverlayBioEntity = z.infer<typeof overlayBioEntitySchema>; +export type CreatedOverlayFile = z.infer<typeof createdOverlayFileSchema>; +export type UploadedOverlayFileContent = z.infer<typeof uploadedOverlayFileContentSchema>; +export type CreatedOverlay = z.infer<typeof createdOverlaySchema>; export type Color = z.infer<typeof colorSchema>; export type Statistics = z.infer<typeof statisticsSchema>; diff --git a/src/utils/query-manager/useReduxBusQueryManager.test.ts b/src/utils/query-manager/useReduxBusQueryManager.test.ts index dc4bc4b6595a1966de2cabb7c3a54143e033ef42..77244318601a6d608b143772ca813e7e390e4eaf 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.test.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.test.ts @@ -25,6 +25,10 @@ describe('useReduxBusQueryManager - util', () => { data: [], loading: 'succeeded' as Loading, error: { name: '', message: '' }, + addOverlay: { + loading: 'idle' as Loading, + error: { name: '', message: '' }, + }, }; const { Wrapper } = getReduxWrapperWithStore({