diff --git a/pages/_document.tsx b/pages/_document.tsx index 94c6212c42688410055e35724e0b36852ff33ca3..c0d2fd720de69ef2881a040ff88fd8f9af85c952 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -8,6 +8,7 @@ const Document = (): React.ReactNode => ( </Head> <body> <Main /> + <div id="modal-root" /> <NextScript /> <Script src="./config.js" strategy="beforeInteractive" /> </body> diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx index b06a5fe7b5517c484fb9242e0d101157edc0f2b0..eab74a7e4fd30da041120e5731c0987b8720dc3e 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx @@ -49,6 +49,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -67,6 +68,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -97,6 +99,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -132,6 +135,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -168,6 +172,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -212,6 +217,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -241,6 +247,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts index 172a10260690e968f93774434a5223a1d57c0ccc..16f4307190235d6e03c4175b168b4b68fbc46e50 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts @@ -24,6 +24,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -60,6 +61,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -99,6 +101,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -134,6 +137,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -170,6 +174,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e8b7b95f30da6538a5ab3c25bd779fdbf849c9fa --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx @@ -0,0 +1,87 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { layerFixture } from '@/models/fixtures/layerFixture'; +import { layersFixture } from '@/models/fixtures/layersFixture'; +import { layerTextsFixture } from '@/models/fixtures/layerTextsFixture'; +import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; +import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; +import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; +import { act } from 'react-dom/test-utils'; +import { LayerFactoryModal } from './LayerFactoryModal.component'; + +const mockedAxiosNewClient = mockNetworkNewAPIResponse(); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <LayerFactoryModal /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('LayerFactoryModal - component', () => { + it('should render LayerFactoryModal component', () => { + renderComponent(); + + const name = screen.getByTestId('layer-factory-name'); + const visible = screen.getByTestId('layer-factory-visible'); + const locked = screen.getByTestId('layer-factory-locked'); + expect(name).toBeInTheDocument(); + expect(visible).toBeInTheDocument(); + expect(locked).toBeInTheDocument(); + }); + + it('should handles input change correctly', () => { + renderComponent(); + + const nameInput: HTMLInputElement = screen.getByTestId('layer-factory-name'); + + fireEvent.change(nameInput, { target: { value: 'test layer' } }); + + expect(nameInput.value).toBe('test layer'); + }); + + it('should fetch layers when the form is successfully submitted', async () => { + mockedAxiosNewClient.onPost(apiPath.storeLayer(0)).reply(HttpStatusCode.Ok, layerFixture); + mockedAxiosNewClient.onGet(apiPath.getLayers(0)).reply(HttpStatusCode.Ok, layersFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerTexts(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerTextsFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerRects(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerRectsFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerOvals(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerOvalsFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerLines(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerLinesFixture); + + const { store } = renderComponent(); + const nameInput: HTMLInputElement = screen.getByTestId('layer-factory-name'); + const submitButton = screen.getByTestId('submit'); + + fireEvent.change(nameInput, { target: { value: 'test layer' } }); + act(() => { + submitButton.click(); + }); + await waitFor(() => { + expect(store.getState().layers[0].loading).toBe('succeeded'); + }); + }); +}); diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d925951e04c0ed72650e50001e43f6b416d7038 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx @@ -0,0 +1,150 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { closeModal } from '@/redux/modal/modal.slice'; +import { showToast } from '@/utils/showToast'; +import { Switch } from '@/shared/Switch'; +import { LayerStoreInterface, LayerUpdateInterface } from '@/redux/layers/layers.types'; +import { + addLayerForModel, + getLayer, + getLayersForModel, + updateLayer, +} from '@/redux/layers/layers.thunks'; +import { SerializedError } from '@reduxjs/toolkit'; +import { layerFactoryStateSelector } from '@/redux/modal/modal.selector'; +import './LayerFactoryModal.styles.css'; +import { LoadingIndicator } from '@/shared/LoadingIndicator'; + +export const LayerFactoryModal: React.FC = () => { + const dispatch = useAppDispatch(); + const currentModelId = useAppSelector(currentModelIdSelector); + const layerFactoryState = useAppSelector(layerFactoryStateSelector); + const [loaded, setLoaded] = useState<boolean>(false); + + const [data, setData] = useState<LayerStoreInterface>({ + name: '', + visible: false, + locked: false, + modelId: currentModelId, + }); + + const fetchData = useMemo(() => { + return async (layerId: number): Promise<void> => { + const layer = await dispatch(getLayer({ modelId: currentModelId, layerId })).unwrap(); + if (layer) { + setData({ + name: layer.name, + visible: layer.visible, + locked: layer.locked, + modelId: currentModelId, + }); + } + setLoaded(true); + }; + }, [currentModelId, dispatch]); + + useEffect(() => { + if (layerFactoryState.id) { + fetchData(layerFactoryState.id); + } else { + setLoaded(true); + } + }, [fetchData, layerFactoryState.id]); + + const handleChange = (value: string | boolean, key: string): void => { + setData(prevData => ({ ...prevData, [key]: value })); + }; + + const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => { + try { + event.preventDefault(); + if (layerFactoryState.id) { + const payload = { + ...data, + layerId: layerFactoryState.id, + } as LayerUpdateInterface; + await dispatch(updateLayer(payload)).unwrap(); + showToast({ + type: 'success', + message: 'The layer has been successfully updated', + }); + } else { + await dispatch(addLayerForModel(data)).unwrap(); + showToast({ + type: 'success', + message: 'A new layer has been successfully added', + }); + } + dispatch(closeModal()); + dispatch(getLayersForModel(currentModelId)); + } catch (error) { + const typedError = error as SerializedError; + showToast({ + type: 'error', + message: typedError.message || 'An error occurred while adding a new layer', + }); + } + }; + + return ( + <div className="relative w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]"> + {!loaded && ( + <div className="c-layer-factory-loader"> + <LoadingIndicator width={44} height={44} /> + </div> + )} + <form onSubmit={handleSubmit}> + <label className="mb-6 block text-sm font-semibold" htmlFor="name"> + Name: + <Input + type="text" + id="name" + data-testid="layer-factory-name" + placeholder="Layer name here..." + value={data.name} + onChange={event => { + handleChange(event.target.value, 'name'); + }} + className="mt-2.5 text-sm font-medium text-font-400" + /> + </label> + <label + htmlFor="visible" + className="mb-6 flex items-center justify-between text-sm font-semibold" + > + Visible: + <Switch + id="visible" + data-testid="layer-factory-visible" + isChecked={data.visible} + onToggle={value => handleChange(value, 'visible')} + /> + </label> + <label + htmlFor="locked" + className="mb-6 flex items-center justify-between text-sm font-semibold" + > + Locked: + <Switch + id="locked" + data-testid="layer-factory-locked" + isChecked={data.locked} + onToggle={value => handleChange(value, 'locked')} + /> + </label> + <Button + type="submit" + className="w-full justify-center text-base font-medium" + data-testid="submit" + > + Submit + </Button> + </form> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.styles.css b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.styles.css new file mode 100644 index 0000000000000000000000000000000000000000..9178a2afb38e6e9ce79c9d41fef7f4df30347124 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.styles.css @@ -0,0 +1,12 @@ +.c-layer-factory-loader { + width: 100%; + height: 100%; + margin-left: -24px; + margin-top: -24px; + background: #f9f9f980; + z-index: 1; + position: absolute; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/index.ts b/src/components/FunctionalArea/Modal/LayerFactoryModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e9a93d6b02894c3adf8f457e960f5294142f17f --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/index.ts @@ -0,0 +1 @@ +export { LayerFactoryModal } from './LayerFactoryModal.component'; diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 2768dfa3c5a4a66d968a6a7c3ce5ec61c4ea2c2a..8419791dee19ccf04d34e3c87eb731862356838d 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -12,6 +12,7 @@ import { ModalLayout } from './ModalLayout'; import { OverviewImagesModal } from './OverviewImagesModal'; import { PublicationsModal } from './PublicationsModal'; import { LoggedInMenuModal } from './LoggedInMenuModal'; +import { LayerFactoryModal } from './LayerFactoryModal'; const MolArtModal = dynamic( () => import('./MolArtModal/MolArtModal.component').then(mod => mod.MolArtModal), @@ -79,6 +80,11 @@ export const Modal = (): React.ReactNode => { <AddCommentModal /> </ModalLayout> )} + {isOpen && modalName === 'layer-factory' && ( + <ModalLayout> + <LayerFactoryModal /> + </ModalLayout> + )} </> ); }; diff --git a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx index b202afcdc6c6088f8db048f00695235c4cbfe94d..493f2bc7a4be1a57cf5218ca7505075893746d29 100644 --- a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx @@ -33,6 +33,7 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { modalName === 'terms-of-service' && 'h-auto w-[400px]', modalName === 'add-comment' && 'h-auto w-[400px]', modalName === 'error-report' && 'h-auto w-[800px]', + modalName === 'layer-factory' && 'h-auto w-[400px]', ['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]', )} > diff --git a/src/components/FunctionalArea/Modal/QuestionModal/Question.styles.css b/src/components/FunctionalArea/Modal/QuestionModal/Question.styles.css new file mode 100644 index 0000000000000000000000000000000000000000..9d2ec888aac8c9e9d60262c8f3c5f50d368c85c6 --- /dev/null +++ b/src/components/FunctionalArea/Modal/QuestionModal/Question.styles.css @@ -0,0 +1,27 @@ +.c-question-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 11; +} + +.c-question-modal { + width: 400px; + height: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 2rem; + background-color: #fff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + text-align: center; +} diff --git a/src/components/FunctionalArea/Modal/QuestionModal/QuestionModal.component.test.tsx b/src/components/FunctionalArea/Modal/QuestionModal/QuestionModal.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..556cf577558a9ee69e730ebce89c151bfbc96579 --- /dev/null +++ b/src/components/FunctionalArea/Modal/QuestionModal/QuestionModal.component.test.tsx @@ -0,0 +1,58 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import QuestionModal from './QustionModal.component'; + +beforeEach(() => { + const modalRoot = document.createElement('div'); + modalRoot.setAttribute('id', 'modal-root'); + document.body.appendChild(modalRoot); +}); + +afterEach(() => { + const modalRoot = document.getElementById('modal-root'); + if (modalRoot) { + document.body.removeChild(modalRoot); + } +}); + +describe('QuestionModal', () => { + const defaultProps = { + isOpen: true, + onClose: jest.fn(), + onConfirm: jest.fn(), + question: 'Are you sure?', + }; + + it('should not render when isOpen is false', () => { + render(<QuestionModal {...defaultProps} isOpen={false} />); + const modalContent = screen.queryByText(defaultProps.question); + expect(modalContent).not.toBeInTheDocument(); + }); + + it('should render the question when isOpen is true', () => { + render(<QuestionModal {...defaultProps} />); + expect(screen.getByText('Are you sure?')).toBeInTheDocument(); + }); + + it('should call onClose when "No" button is clicked', () => { + render(<QuestionModal {...defaultProps} />); + const noButton = screen.getByText('No'); + fireEvent.click(noButton); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it('should call onConfirm when "Yes" button is clicked', () => { + render(<QuestionModal {...defaultProps} />); + const yesButton = screen.getByText('Yes'); + fireEvent.click(yesButton); + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + it('should render inside the modal-root portal', () => { + render(<QuestionModal {...defaultProps} />); + const modalRoot = document.getElementById('modal-root'); + expect(modalRoot).toContainElement(screen.getByText('Are you sure?')); + }); +}); diff --git a/src/components/FunctionalArea/Modal/QuestionModal/QustionModal.component.tsx b/src/components/FunctionalArea/Modal/QuestionModal/QustionModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f7f3db7df221087945e5c0c2bdedc0191b6e637 --- /dev/null +++ b/src/components/FunctionalArea/Modal/QuestionModal/QustionModal.component.tsx @@ -0,0 +1,56 @@ +import React, { ReactPortal } from 'react'; +import ReactDOM from 'react-dom'; +import { Button } from '@/shared/Button'; +import './Question.styles.css'; +import { QuestionIcon } from '@/shared/Icon/Icons/QuestionIcon'; + +type QuestionModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + question: string; +}; + +const QuestionModal = ({ + isOpen, + onClose, + onConfirm, + question, +}: QuestionModalProps): null | ReactPortal => { + if (!isOpen) return null; + + const domElement = document.getElementById('modal-root'); + + if (!domElement) { + return null; + } + + return ReactDOM.createPortal( + <div className="c-question-overlay"> + <div className="c-question-modal"> + <QuestionIcon size={94} /> + <h1 className="text-center text-2xl font-semibold">{question}</h1> + <div className="flex w-full justify-center gap-10"> + <Button + type="submit" + className="w-[100px] justify-center text-base font-medium" + variantStyles="remove" + onClick={onClose} + > + No + </Button> + <Button + type="submit" + className="w-[100px] justify-center text-base font-medium" + onClick={onConfirm} + > + Yes + </Button> + </div> + </div> + </div>, + domElement, + ); +}; + +export default QuestionModal; diff --git a/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx index 03adc8fd596b1a8890cdd2163b0adbb9c03c27bb..c40c8c87a405cfccfdfa1987727c5c4d02a96d5f 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx @@ -47,7 +47,7 @@ describe('ChemicalsList - component', () => { }); it('should show loading indicator', () => { - expect(screen.getByAltText('spinner icon')).toBeInTheDocument(); + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); }); }); diff --git a/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx index 0ec503c4738362835d851bfd95d0fe0e10f08c54..b04ddbab442fcb95a8999d7e9412c7e0b9362358 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx @@ -47,7 +47,7 @@ describe('DrugsList - component', () => { }); it('should show loading indicator', () => { - expect(screen.getByAltText('spinner icon')).toBeInTheDocument(); + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); }); }); diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx index e5f914e8c2dc8d5947b08817359eba144b923218..b431590cad9fd0aca469b6b22e6d1345a69fe8b5 100644 --- a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx @@ -8,32 +8,103 @@ import { import { Switch } from '@/shared/Switch'; import { setLayerVisibility } from '@/redux/layers/layers.slice'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { Button } from '@/shared/Button'; +import { openLayerFactoryModal } from '@/redux/modal/modal.slice'; +import QuestionModal from '@/components/FunctionalArea/Modal/QuestionModal/QustionModal.component'; +import { useState } from 'react'; +import { getLayersForModel, removeLayer } from '@/redux/layers/layers.thunks'; +import { showToast } from '@/utils/showToast'; +import { SerializedError } from '@reduxjs/toolkit'; export const LayersDrawer = (): JSX.Element => { const layersForCurrentModel = useAppSelector(layersForCurrentModelSelector); const layersVisibilityForCurrentModel = useAppSelector(layersVisibilityForCurrentModelSelector); const currentModelId = useAppSelector(currentModelIdSelector); const dispatch = useAppDispatch(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [layerId, setLayerId] = useState<number | null>(null); + + const addNewLayer = (): void => { + dispatch(openLayerFactoryModal()); + }; + + const editLayer = (layerIdToEdit: number): void => { + dispatch(openLayerFactoryModal(layerIdToEdit)); + }; + + const rejectRemove = (): void => { + setIsModalOpen(false); + }; + + const confirmRemove = async (): Promise<void> => { + if (!layerId) { + return; + } + try { + await dispatch(removeLayer({ modelId: currentModelId, layerId })).unwrap(); + showToast({ + type: 'success', + message: 'The layer has been successfully removed', + }); + setIsModalOpen(false); + dispatch(getLayersForModel(currentModelId)); + } catch (error) { + const typedError = error as SerializedError; + showToast({ + type: 'error', + message: typedError.message || 'An error occurred while removing the layer', + }); + } + }; + + const onRemoveLayer = (layerIdToRemove: number): void => { + setLayerId(layerIdToRemove); + setIsModalOpen(true); + }; return ( <div data-testid="layers-drawer" className="h-full max-h-full"> + <QuestionModal + isOpen={isModalOpen} + onClose={rejectRemove} + onConfirm={confirmRemove} + question="Are you sure you want to remove the layer?" + /> <DrawerHeading title="Layers" /> <div className="flex h-[calc(100%-93px)] max-h-[calc(100%-93px)] flex-col overflow-y-auto px-6"> + <div className="flex justify-start pt-2"> + <Button icon="plus" isIcon isFrontIcon onClick={addNewLayer}> + Add new layer + </Button> + </div> {layersForCurrentModel.map(layer => ( - <div key={layer.details.id} className="flex items-center justify-between border-b p-4"> - <h1>{layer.details.name}</h1> - <Switch - isChecked={layersVisibilityForCurrentModel[layer.details.id]} - onToggle={value => - dispatch( - setLayerVisibility({ - modelId: currentModelId, - visible: value, - layerId: layer.details.id, - }), - ) - } - /> + <div + key={layer.details.id} + className="flex items-center justify-between gap-3 border-b py-4" + > + <h1 className="truncate">{layer.details.name}</h1> + <div className="flex items-center gap-2"> + <Switch + isChecked={layersVisibilityForCurrentModel[layer.details.id]} + onToggle={value => + dispatch( + setLayerVisibility({ + modelId: currentModelId, + visible: value, + layerId: layer.details.id, + }), + ) + } + /> + <Button onClick={() => editLayer(layer.details.id)}>Edit</Button> + <Button + onClick={() => onRemoveLayer(layer.details.id)} + color="error" + variantStyles="remove" + > + Remove + </Button> + </div> </div> ))} </div> diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts index 46baee4385088e620a5170c6bde5521d95ee497f..4fd1b57f0e0bc4800ad6492d5f75ce917b684abf 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts @@ -44,10 +44,7 @@ describe('Layer', () => { width: 100, height: 100, fontSize: 12, - size: 12312, notes: 'XYZ', - glyph: null, - elementId: '34', verticalAlign: 'MIDDLE', horizontalAlign: 'CENTER', backgroundColor: WHITE_COLOR, diff --git a/src/models/fixtures/layerFixture.ts b/src/models/fixtures/layerFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f9d4843e675c49e81da406bb38ac67a083bd4c2 --- /dev/null +++ b/src/models/fixtures/layerFixture.ts @@ -0,0 +1,9 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { layerSchema } from '@/models/layerSchema'; + +export const layerFixture = createFixture(layerSchema, { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/models/layerTextSchema.ts b/src/models/layerTextSchema.ts index 3ad77ed0e59946a8a2a8586eb8ab8b237e67f618..6858da82cc89c2f05336b3cdfb7b5b49df1a34e7 100644 --- a/src/models/layerTextSchema.ts +++ b/src/models/layerTextSchema.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import { colorSchema } from '@/models/colorSchema'; -import { glyphSchema } from '@/models/glyphSchema'; export const layerTextSchema = z.object({ id: z.number(), @@ -10,10 +9,7 @@ export const layerTextSchema = z.object({ width: z.number(), height: z.number(), fontSize: z.number(), - size: z.number(), notes: z.string(), - glyph: glyphSchema.nullable(), - elementId: z.string(), verticalAlign: z.string(), horizontalAlign: z.string(), backgroundColor: colorSchema, diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 174debec841ae0734109a7fd667afa1a63eae971..760ccb7a5e8dc3391fed144af0cd6977205c3c20 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -58,6 +58,13 @@ export const apiPath = { `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/ovals/`, getLayerLines: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/lines/`, + storeLayer: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/`, + updateLayer: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, + removeLayer: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, + getLayer: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, getGlyphImage: (glyphId: number): string => `projects/${PROJECT_ID}/glyphs/${glyphId}/fileContent`, getNewReactionsForModel: (modelId: number): string => diff --git a/src/redux/layers/layers.thunks.test.ts b/src/redux/layers/layers.thunks.test.ts index 234d9950d59c21be0a60a830d4ce7e8cfc755db8..c8b9b2bcc44d00428e0b3adaf11150d159774c21 100644 --- a/src/redux/layers/layers.thunks.test.ts +++ b/src/redux/layers/layers.thunks.test.ts @@ -7,12 +7,19 @@ import { import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { LayersState } from '@/redux/layers/layers.types'; -import { getLayersForModel } from '@/redux/layers/layers.thunks'; +import { + addLayerForModel, + getLayer, + getLayersForModel, + removeLayer, + updateLayer, +} from '@/redux/layers/layers.thunks'; import { layersFixture } from '@/models/fixtures/layersFixture'; import { layerTextsFixture } from '@/models/fixtures/layerTextsFixture'; import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; +import { layerFixture } from '@/models/fixtures/layerFixture'; import layersReducer from './layers.slice'; const mockedAxiosClient = mockNetworkNewAPIResponse(); @@ -65,4 +72,85 @@ describe('layers thunks', () => { expect(payload).toEqual(undefined); }); }); + + describe('getLayer', () => { + it('should return a layer when data is valid', async () => { + mockedAxiosClient.onGet(apiPath.getLayer(1, 2)).reply(HttpStatusCode.Ok, layerFixture); + + const { payload } = await store.dispatch(getLayer({ modelId: 1, layerId: 2 })); + expect(payload).toEqual(layerFixture); + }); + + it('should return null when data is invalid', async () => { + mockedAxiosClient.onGet(apiPath.getLayer(1, 2)).reply(HttpStatusCode.Ok, { invalid: 'data' }); + + const { payload } = await store.dispatch(getLayer({ modelId: 1, layerId: 2 })); + expect(payload).toBeNull(); + }); + }); + + describe('addLayerForModel', () => { + it('should add a layer when data is valid', async () => { + mockedAxiosClient.onPost(apiPath.storeLayer(1)).reply(HttpStatusCode.Created, layerFixture); + + const { payload } = await store.dispatch( + addLayerForModel({ name: 'New Layer', visible: true, locked: false, modelId: 1 }), + ); + expect(payload).toEqual(layerFixture); + }); + + it('should return null when response data is invalid', async () => { + mockedAxiosClient + .onPost(apiPath.storeLayer(1)) + .reply(HttpStatusCode.Created, { invalid: 'data' }); + + const { payload } = await store.dispatch( + addLayerForModel({ name: 'New Layer', visible: true, locked: false, modelId: 1 }), + ); + expect(payload).toBeNull(); + }); + }); + + describe('updateLayer', () => { + it('should update a layer successfully', async () => { + mockedAxiosClient.onPut(apiPath.updateLayer(1, 2)).reply(HttpStatusCode.Ok, layerFixture); + + const { payload } = await store.dispatch( + updateLayer({ + name: 'Updated Layer', + visible: false, + locked: true, + modelId: 1, + layerId: 2, + }), + ); + expect(payload).toEqual(layerFixture); + }); + + it('should return null for invalid data', async () => { + mockedAxiosClient + .onPut(apiPath.updateLayer(1, 2)) + .reply(HttpStatusCode.Ok, { invalid: 'data' }); + + const { payload } = await store.dispatch( + updateLayer({ + name: 'Updated Layer', + visible: false, + locked: true, + modelId: 1, + layerId: 2, + }), + ); + expect(payload).toBeNull(); + }); + }); + + describe('removeLayer', () => { + it('should successfully remove a layer', async () => { + mockedAxiosClient.onDelete(apiPath.removeLayer(1, 2)).reply(HttpStatusCode.NoContent); + + const result = await store.dispatch(removeLayer({ modelId: 1, layerId: 2 })); + expect(result.meta.requestStatus).toBe('fulfilled'); + }); + }); }); diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index f1594875f0597b512a6e8d6c4b4becb063ac8425..c8ee4fd5abbac494634b2a881df5c5b6c4f2fb4c 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -8,13 +8,33 @@ import { getError } from '@/utils/error-report/getError'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { layerSchema } from '@/models/layerSchema'; import { LAYERS_FETCHING_ERROR_PREFIX } from '@/redux/layers/layers.constants'; -import { LayersVisibilitiesState } from '@/redux/layers/layers.types'; +import { + LayerStoreInterface, + LayersVisibilitiesState, + LayerUpdateInterface, +} from '@/redux/layers/layers.types'; import { layerTextSchema } from '@/models/layerTextSchema'; import { layerRectSchema } from '@/models/layerRectSchema'; import { pageableSchema } from '@/models/pageableSchema'; import { layerOvalSchema } from '@/models/layerOvalSchema'; import { layerLineSchema } from '@/models/layerLineSchema'; +export const getLayer = createAsyncThunk< + Layer | null, + { modelId: number; layerId: number }, + ThunkConfig +>('vectorMap/getLayer', async ({ modelId, layerId }) => { + try { + const { data } = await axiosInstanceNewAPI.get<Layer>(apiPath.getLayer(modelId, layerId)); + + const isDataValid = validateDataUsingZodSchema(data, layerSchema); + + return isDataValid ? data : null; + } catch (error) { + return Promise.reject(getError({ error })); + } +}); + export const getLayersForModel = createAsyncThunk< LayersVisibilitiesState | undefined, number, @@ -64,3 +84,54 @@ export const getLayersForModel = createAsyncThunk< return Promise.reject(getError({ error, prefix: LAYERS_FETCHING_ERROR_PREFIX })); } }); + +export const addLayerForModel = createAsyncThunk<Layer | null, LayerStoreInterface, ThunkConfig>( + 'vectorMap/addLayer', + async ({ name, visible, locked, modelId }) => { + try { + const { data } = await axiosInstanceNewAPI.post<Layer>(apiPath.storeLayer(modelId), { + name, + visible, + locked, + }); + + const isDataValid = validateDataUsingZodSchema(data, layerSchema); + + return isDataValid ? data : null; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); + +export const updateLayer = createAsyncThunk<Layer | null, LayerUpdateInterface, ThunkConfig>( + 'vectorMap/updateLayer', + async ({ name, visible, locked, modelId, layerId }) => { + try { + const { data } = await axiosInstanceNewAPI.put<Layer>(apiPath.updateLayer(modelId, layerId), { + name, + visible, + locked, + }); + + const isDataValid = validateDataUsingZodSchema(data, layerSchema); + + return isDataValid ? data : null; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); + +export const removeLayer = createAsyncThunk< + void, + { modelId: number; layerId: number }, + ThunkConfig + // eslint-disable-next-line consistent-return +>('vectorMap/removeLayer', async ({ modelId, layerId }) => { + try { + await axiosInstanceNewAPI.delete<void>(apiPath.removeLayer(modelId, layerId)); + } catch (error) { + return Promise.reject(getError({ error })); + } +}); diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts index 701a499abc9979cc38b900ce64d25943a58935ef..a5c1b30e1f7f1978e447831649ec8896c3b92eeb 100644 --- a/src/redux/layers/layers.types.ts +++ b/src/redux/layers/layers.types.ts @@ -1,6 +1,21 @@ import { KeyedFetchDataState } from '@/types/fetchDataState'; import { Layer, LayerLine, LayerOval, LayerRect, LayerText } from '@/types/models'; +export interface LayerStoreInterface { + name: string; + visible: boolean; + locked: boolean; + modelId: number; +} + +export interface LayerUpdateInterface { + layerId: number; + name: string; + visible: boolean; + locked: boolean; + modelId: number; +} + export type LayerState = { details: Layer; texts: LayerText[]; diff --git a/src/redux/modal/modal.constants.ts b/src/redux/modal/modal.constants.ts index d7dea45c423398a99180fcd4151255ad93489fdf..1184d4ed526038773f9bb90853e52d8f0912dece 100644 --- a/src/redux/modal/modal.constants.ts +++ b/src/redux/modal/modal.constants.ts @@ -13,4 +13,5 @@ export const MODAL_INITIAL_STATE: ModalState = { }, editOverlayState: null, errorReportState: {}, + layerFactoryState: { id: undefined }, }; diff --git a/src/redux/modal/modal.mock.ts b/src/redux/modal/modal.mock.ts index cde5fab5cc156a2783af5987006fc87e499f7477..1a7a519f3509c02ae667c7e310eeb58af77c3af2 100644 --- a/src/redux/modal/modal.mock.ts +++ b/src/redux/modal/modal.mock.ts @@ -13,4 +13,5 @@ export const MODAL_INITIAL_STATE_MOCK: ModalState = { }, editOverlayState: null, errorReportState: {}, + layerFactoryState: { id: undefined }, }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index a3704696a08a6a8531a19b5e0d23cecc1ea095ae..3371ed3c102a74f1f32e8b26807291c159ac751c 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -124,3 +124,17 @@ export const openToSModalReducer = (state: ModalState): void => { state.modalName = 'terms-of-service'; state.modalTitle = 'Terms of service!'; }; + +export const openLayerFactoryModalReducer = ( + state: ModalState, + action: PayloadAction<number | undefined>, +): void => { + state.layerFactoryState = { id: action.payload }; + state.isOpen = true; + state.modalName = 'layer-factory'; + if (action.payload) { + state.modalTitle = 'Edit layer'; + } else { + state.modalTitle = 'Add new layer'; + } +}; diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts index 654dfb7aeac4b0b43a89cb676a92b9bf7d09922d..7f7c444111a45551d76fa8b41f887e9ceb43fe4b 100644 --- a/src/redux/modal/modal.selector.ts +++ b/src/redux/modal/modal.selector.ts @@ -21,6 +21,11 @@ export const currentEditedOverlaySelector = createSelector( modal => modal.editOverlayState, ); +export const layerFactoryStateSelector = createSelector( + modalSelector, + modal => modal.layerFactoryState, +); + export const currentErrorDataSelector = createSelector( modalSelector, modal => modal?.errorReportState.errorData || undefined, diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index bb145852246d450ffde3acb6e935f77eaac280f6..8ed0421510457f64c783b7c0548d09977f7e8415 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -16,6 +16,7 @@ import { openSelectProjectModalReducer, openLicenseModalReducer, openToSModalReducer, + openLayerFactoryModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -37,6 +38,7 @@ const modalSlice = createSlice({ openSelectProjectModal: openSelectProjectModalReducer, openLicenseModal: openLicenseModalReducer, openToSModal: openToSModalReducer, + openLayerFactoryModal: openLayerFactoryModalReducer, }, }); @@ -56,6 +58,7 @@ export const { openSelectProjectModal, openLicenseModal, openToSModal, + openLayerFactoryModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts index ea77209610093378c152d5d114f41bc475bd97fb..1b544f5282525ca4e9b1d6efa3835c0e175313d4 100644 --- a/src/redux/modal/modal.types.ts +++ b/src/redux/modal/modal.types.ts @@ -17,6 +17,10 @@ export type ErrorRepostState = { export type EditOverlayState = MapOverlay | null; +export type LayerFactoryState = { + id: number | undefined; +}; + export interface ModalState { isOpen: boolean; modalName: ModalName; @@ -25,6 +29,7 @@ export interface ModalState { molArtState: MolArtModalState; errorReportState: ErrorRepostState; editOverlayState: EditOverlayState; + layerFactoryState: LayerFactoryState; } export type OpenEditOverlayModalPayload = MapOverlay; diff --git a/src/shared/Button/Button.component.tsx b/src/shared/Button/Button.component.tsx index a7f831e7ed22cdc6c8ad17dafd1563375b27144e..72833322b47bc752812ebca40b6e60f6d4f9ebe6 100644 --- a/src/shared/Button/Button.component.tsx +++ b/src/shared/Button/Button.component.tsx @@ -4,7 +4,7 @@ import { twMerge } from 'tailwind-merge'; import type { ButtonHTMLAttributes } from 'react'; import type { IconTypes } from '@/types/iconTypes'; -type VariantStyle = 'primary' | 'secondary' | 'ghost' | 'quiet'; +type VariantStyle = 'primary' | 'secondary' | 'ghost' | 'quiet' | 'remove'; export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variantStyles?: VariantStyle; @@ -34,6 +34,11 @@ const variants = { 'text-font-500 bg-white-pearl hover:bg-greyscale-500 active:bg-greyscale-600 disabled:text-font-400 disabled:bg-white-pearl', icon: 'fill-font-500 group-disabled:fill-font-400', }, + remove: { + button: + 'text-white-pearl bg-red-500 hover:bg-red-600 active:bg-red-700 disabled:bg-greyscale-700', + icon: 'fill-white-pearl', + }, } as const; export const Button = ({ diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index 634569549c9d5375d469e1e2c3208a63798986c2..f22e882b052efc6e3c7c5d63836d015d210cf88b 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -8,6 +8,7 @@ import { CloseIcon } from '@/shared/Icon/Icons/CloseIcon'; import { DotsIcon } from '@/shared/Icon/Icons/DotsIcon'; import { ExportIcon } from '@/shared/Icon/Icons/ExportIcon'; import { LayersIcon } from '@/shared/Icon/Icons/LayersIcon'; +import { QuestionIcon } from '@/shared/Icon/Icons/QuestionIcon'; import { InfoIcon } from '@/shared/Icon/Icons/InfoIcon'; import { LegendIcon } from '@/shared/Icon/Icons/LegendIcon'; import { PageIcon } from '@/shared/Icon/Icons/PageIcon'; @@ -43,6 +44,7 @@ const icons: Record<IconTypes, IconComponentType> = { admin: AdminIcon, export: ExportIcon, layers: LayersIcon, + question: QuestionIcon, info: InfoIcon, download: DownloadIcon, legend: LegendIcon, diff --git a/src/shared/Icon/Icons/QuestionIcon.tsx b/src/shared/Icon/Icons/QuestionIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9deb22425f73443b9177f2eb666992bb3199a595 --- /dev/null +++ b/src/shared/Icon/Icons/QuestionIcon.tsx @@ -0,0 +1,23 @@ +/* eslint-disable no-magic-numbers */ +interface QuestionIconProps { + className?: string; + size?: number; +} + +export const QuestionIcon = ({ className, size = 20 }: QuestionIconProps): JSX.Element => ( + <svg + width={size} + height={size} + viewBox="0 0 100 100" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <circle cx="50" cy="50" r="44" stroke="black" strokeWidth="2" fill="none" /> + <path + d="M50 80a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm1-20H47c0-6.6 3.6-8.8 6.6-10.9 3.4-2.3 5.4-4.5 5.4-8.1 0-5.5-4.5-10-10-10s-10 4.5-10 10H33c0-9.4 7.6-17 17-17s17 7.6 17 17c0 5.5-3.3 8.6-6.9 11.1-2.6 1.7-4.1 3.3-4.1 6.9z" + fill="black" + strokeWidth={1} + /> + </svg> +); diff --git a/src/shared/LoadingIndicator/LoadingIndicator.component.tsx b/src/shared/LoadingIndicator/LoadingIndicator.component.tsx index 39a2a4139dc58b06f7a71f810502a126e71d56f0..e63f02674e6e7b03a03cd51dc94454a714c59800 100644 --- a/src/shared/LoadingIndicator/LoadingIndicator.component.tsx +++ b/src/shared/LoadingIndicator/LoadingIndicator.component.tsx @@ -1,6 +1,3 @@ -import Image from 'next/image'; -import spinnerIcon from '@/assets/vectors/icons/spinner.svg'; - type LoadingIndicatorProps = { height?: number; width?: number; @@ -13,12 +10,19 @@ export const LoadingIndicator = ({ height = DEFAULT_HEIGHT, width = DEFAULT_WIDTH, }: LoadingIndicatorProps): JSX.Element => ( - <Image - src={spinnerIcon} - alt="spinner icon" - height={height} - width={width} - className="animate-spin" - data-testid="loading-indicator" - /> + <div style={{ width, height }} className="animate-spin" data-testid="loading-indicator"> + <svg width={width} height={height} viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"> + <circle + cx="25" + cy="25" + r="20" + fill="none" + stroke="currentColor" + strokeWidth="4" + strokeDasharray="90, 150" + strokeDashoffset="0" + strokeLinecap="round" + /> + </svg> + </div> ); diff --git a/src/shared/Switch/Switch.component.tsx b/src/shared/Switch/Switch.component.tsx index 355e84b92d9aaf3e49ab8f0c41d01e8ee28037b8..b519870e93b302b01a66a664aac321fe5d00706c 100644 --- a/src/shared/Switch/Switch.component.tsx +++ b/src/shared/Switch/Switch.component.tsx @@ -1,12 +1,13 @@ import { twMerge } from 'tailwind-merge'; -import { useEffect, useState } from 'react'; +import { type ButtonHTMLAttributes, useEffect, useState } from 'react'; type VariantStyle = 'primary' | 'secondary' | 'ghost' | 'quiet'; -export interface SwitchProps { +export interface SwitchProps extends ButtonHTMLAttributes<HTMLButtonElement> { variantStyles?: VariantStyle; isChecked?: boolean; onToggle?: (checked: boolean) => void; + id?: string; } const variants = { @@ -32,6 +33,8 @@ export const Switch = ({ variantStyles = 'primary', isChecked = false, onToggle, + id, + ...props }: SwitchProps): JSX.Element => { const [checked, setChecked] = useState(isChecked); @@ -49,6 +52,7 @@ export const Switch = ({ return ( <button + id={id} type="button" className={twMerge( 'relative inline-flex h-5 w-10 cursor-pointer rounded-full transition-colors duration-300 ease-in-out', @@ -56,6 +60,7 @@ export const Switch = ({ checked ? 'bg-primary-600' : '', )} onClick={handleToggle} + {...props} > <span className={twMerge( diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 5000d642307404c19565b28b421a461d6729e859..7681839461a42db984af5556529f30e0ed4a57b0 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -23,6 +23,7 @@ export type IconTypes = | 'clear' | 'user' | 'manage-user' - | 'download'; + | 'download' + | 'question'; export type IconComponentType = ({ className }: { className: string }) => JSX.Element; diff --git a/src/types/modal.ts b/src/types/modal.ts index eaf3a498c59005f9a2b5c2f2470305970f2e07dc..861bb29569581cb89fcc57b136a3ff7cd8c2dcfc 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -11,4 +11,5 @@ export type ModalName = | 'access-denied' | 'select-project' | 'terms-of-service' - | 'logged-in-menu'; + | 'logged-in-menu' + | 'layer-factory';