diff --git a/react/index.html b/react/index.html index bd61da09..9cefdacb 100644 --- a/react/index.html +++ b/react/index.html @@ -5,28 +5,41 @@ Hazmapper + +
diff --git a/react/package-lock.json b/react/package-lock.json index 03ecdb3f..a8c1ff4f 100644 --- a/react/package-lock.json +++ b/react/package-lock.json @@ -36,7 +36,8 @@ "react-step-wizard": "^5.3.11", "react-table": "^7.8.0", "reactstrap": "^9.2.1", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "yup": "^1.3.3" }, "devDependencies": { "@redux-devtools/core": "^3.13.1", @@ -11954,6 +11955,11 @@ "react-is": "^16.13.1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -13004,6 +13010,11 @@ "integrity": "sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==", "peer": true }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -13034,6 +13045,11 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -13803,6 +13819,28 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.3.3.tgz", + "integrity": "sha512-v8QwZSsHH2K3/G9WSkp6mZKO+hugKT1EmnMqLNUcfu51HU9MDyhlETT/JgtzprnrnQHPWsjc6MUDMBp/l9fNnw==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/react/package.json b/react/package.json index 36908545..7a28a9c8 100644 --- a/react/package.json +++ b/react/package.json @@ -59,7 +59,8 @@ "react-step-wizard": "^5.3.11", "react-table": "^7.8.0", "reactstrap": "^9.2.1", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "yup": "^1.3.3" }, "devDependencies": { "@redux-devtools/core": "^3.13.1", diff --git a/react/src/components/CreateMapModal/CreateMapModal.module.css b/react/src/components/CreateMapModal/CreateMapModal.module.css new file mode 100644 index 00000000..7c2b4aaf --- /dev/null +++ b/react/src/components/CreateMapModal/CreateMapModal.module.css @@ -0,0 +1,20 @@ +:global(.btn-close) { + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; +} + +:global(.btn-close::after) { + content: '\00D7'; /* Unicode character for 'X' */ +} + +.custom-error-message { + margin-top: 0.25rem; + font-size: 0.8em; + color: #dc3545; +} + +.form-check-label { + font-style: italic; +} diff --git a/react/src/components/CreateMapModal/CreateMapModal.test.tsx b/react/src/components/CreateMapModal/CreateMapModal.test.tsx new file mode 100644 index 00000000..cfa9a6ff --- /dev/null +++ b/react/src/components/CreateMapModal/CreateMapModal.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + render, + cleanup, + fireEvent, + screen, + waitFor, +} from '@testing-library/react'; +import CreateMapModal from './CreateMapModal'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +jest.mock('../../hooks/user/useAuthenticatedUser', () => ({ + __esModule: true, + default: () => ({ + data: { username: 'mockUser', email: 'mockUser@example.com' }, + isLoading: false, + error: null, + }), +})); + +jest.mock('../../hooks/projects/useCreateProject', () => ({ + __esModule: true, + default: () => ({ + mutate: jest.fn((data, { onSuccess, onError }) => { + if (data.project.name === 'Error Map') { + // Simulate a submission error with a 500 status code + onError({ response: { status: 500 } }); + } else { + // Simulate successful project creation + onSuccess({ uuid: '123' }); + } + }), + isLoading: false, + }), +})); + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const toggleMock = jest.fn(); +const queryClient = new QueryClient(); + +const renderComponent = (isOpen = true) => { + render( + + + + + + ); +}; + +describe('CreateMapModal', () => { + afterEach(() => { + cleanup(); + }); + + test('renders the modal when open', () => { + renderComponent(); + expect(screen.getByText(/Create a New Map/)).toBeTruthy(); + }); + + test('submits form data successfully', async () => { + renderComponent(); + fireEvent.change(screen.getByTestId('name-input'), { + target: { value: 'Success Map' }, + }); + fireEvent.change(screen.getByLabelText(/Description/), { + target: { value: 'A successful map' }, + }); + fireEvent.change(screen.getByLabelText(/Custom File Name/), { + target: { value: 'success-file' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Create/ })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/project/123'); + }); + }); + + test('displays error message on submission error', async () => { + renderComponent(); + fireEvent.change(screen.getByTestId('name-input'), { + target: { value: 'Error Map' }, + }); + fireEvent.change(screen.getByLabelText(/Description/), { + target: { value: 'A map with an error' }, + }); + fireEvent.change(screen.getByLabelText(/Custom File Name/), { + target: { value: 'error-file' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Create/ })); + + await waitFor(() => { + expect( + screen.getByText( + 'An error occurred while creating the project. Please contact support.' + ) + ).toBeTruthy(); + }); + }); +}); diff --git a/react/src/components/CreateMapModal/CreateMapModal.tsx b/react/src/components/CreateMapModal/CreateMapModal.tsx new file mode 100644 index 00000000..2d284668 --- /dev/null +++ b/react/src/components/CreateMapModal/CreateMapModal.tsx @@ -0,0 +1,210 @@ +import React, { useState } from 'react'; +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + FormGroup, + Label, + Input, +} from 'reactstrap'; +import { Button } from '../../core-components'; +import styles from './CreateMapModal.module.css'; +import { Formik, Form, Field, ErrorMessage } from 'formik'; +import * as Yup from 'yup'; +import useCreateProject from '../../hooks/projects/useCreateProject'; +import useAuthenticatedUser from '../../hooks/user/useAuthenticatedUser'; +import { useNavigate } from 'react-router-dom'; +import { ProjectRequest } from '../../types'; + +type CreateMapModalProps = { + isOpen: boolean; + toggle: () => void; +}; + +// Yup validation schema +const validationSchema = Yup.object({ + name: Yup.string().required('Name is required'), + description: Yup.string().required('Description is required'), + system_file: Yup.string() + .matches( + /^[A-Za-z0-9-_]+$/, + 'Only letters, numbers, hyphens, and underscores are allowed' + ) + .required(' file name is required'), +}); + +const CreateMapModal = ({ isOpen, toggle }: CreateMapModalProps) => { + const [errorMessage, setErrorMessage] = useState(''); + const { data: userData } = useAuthenticatedUser(); + const { mutate: createProject, isLoading: isCreatingProject } = + useCreateProject(); + const navigate = useNavigate(); + + const handleCreateProject = (projectData: ProjectRequest) => { + createProject(projectData, { + onSuccess: (newProject) => { + navigate(`/project/${newProject.uuid}`); + }, + onError: (err) => { + // Handle error messages while creating new project + if (err?.response?.status === 409) { + setErrorMessage( + 'That folder is already syncing with a different map.' + ); + } else { + setErrorMessage( + 'An error occurred while creating the project. Please contact support.' + ); + } + }, + }); + }; + + const handleSubmit = (values) => { + if (!userData) { + setErrorMessage('User information is not available'); + return; + } + const projectData = { + observable: values.syncFolder, + watch_content: values.syncFolder, + project: { + name: values.name, + description: values.description, + system_file: values.system_file, + system_id: values.system_id, + system_path: `/${userData.username}`, + }, + }; + handleCreateProject(projectData); + }; + return ( + + Create a New Map + + + {/*TODO_REACT: Will change to core-wrapper's FieldWrapperFormik instead. https://tacc-main.atlassian.net/browse/WG-246 */} + {({ errors, touched, values, handleChange }) => ( +
+ + + + + + + + + + + + +
+ + .hazmapper +
+ {/* Alt solution to render error message bc input group was causing text to not display properly */} + {touched.system_file && errors.system_file && ( +
+ {errors.system_file} +
+ )} +
+ {/* TODO_REACT: This part will change once the FileBrowser component is added. https://tacc-main.atlassian.net/browse/WG-208*/} + + +
+ {userData ? `/${userData.username}` : 'Loading...'} +
+
+ + +
+
+ +
+
+
+ +
+
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + + + + +
+ )} +
+
+
+ ); +}; + +export default CreateMapModal; diff --git a/react/src/components/CreateMapModal/index.ts b/react/src/components/CreateMapModal/index.ts new file mode 100644 index 00000000..6322e662 --- /dev/null +++ b/react/src/components/CreateMapModal/index.ts @@ -0,0 +1 @@ +export { default } from './CreateMapModal'; diff --git a/react/src/hooks/projects/useCreateProject.ts b/react/src/hooks/projects/useCreateProject.ts new file mode 100644 index 00000000..8e134cc8 --- /dev/null +++ b/react/src/hooks/projects/useCreateProject.ts @@ -0,0 +1,13 @@ +import { usePost } from '../../requests'; +import { ApiService, Project, ProjectRequest } from '../../types'; + +const useCreateProject = () => { + const endpoint = '/projects/'; + + return usePost({ + endpoint, + apiService: ApiService.Geoapi, + }); +}; + +export default useCreateProject; diff --git a/react/src/pages/MainMenu/MainMenu.test.tsx b/react/src/pages/MainMenu/MainMenu.test.tsx index 8f373c71..8a8a6c05 100644 --- a/react/src/pages/MainMenu/MainMenu.test.tsx +++ b/react/src/pages/MainMenu/MainMenu.test.tsx @@ -5,13 +5,16 @@ import { QueryClientProvider } from 'react-query'; import { testQueryClient } from '../../testUtil'; import { Provider } from 'react-redux'; import store from '../../redux/store'; +import { BrowserRouter as Router } from 'react-router-dom'; test('renders menu', () => { const { getByText } = render( - - - + + + + + ); expect(getByText(/Main Menu/)).toBeDefined(); diff --git a/react/src/pages/MainMenu/MainMenu.tsx b/react/src/pages/MainMenu/MainMenu.tsx index 14313907..d42d7c0b 100644 --- a/react/src/pages/MainMenu/MainMenu.tsx +++ b/react/src/pages/MainMenu/MainMenu.tsx @@ -1,12 +1,14 @@ -import React from 'react'; +import React, { useState } from 'react'; import { LoadingSpinner, InlineMessage, SectionHeader, Icon, + Button, } from '../../core-components'; import { useProjects } from '../../hooks'; import useAuthenticatedUser from '../../hooks/user/useAuthenticatedUser'; +import CreateMapModal from '../../components/CreateMapModal/CreateMapModal'; function MainMenu() { const { data, isLoading, error } = useProjects(); @@ -15,6 +17,12 @@ function MainMenu() { isLoading: isUserLoading, error: userError, } = useAuthenticatedUser(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const toggleModal = () => { + setIsModalOpen(!isModalOpen); + }; + if (isLoading || isUserLoading) { return ( <> @@ -32,10 +40,15 @@ function MainMenu() { return ( <> Main Menu - +
+ +
+ Welcome, {userData?.username || 'User'} - + diff --git a/react/src/requests.ts b/react/src/requests.ts index df87dc84..1a56a719 100644 --- a/react/src/requests.ts +++ b/react/src/requests.ts @@ -1,7 +1,13 @@ import axios from 'axios'; import store from './redux/store'; import { AxiosError } from 'axios'; -import { useQuery, UseQueryOptions, QueryKey } from 'react-query'; +import { + useQuery, + useMutation, + UseQueryOptions, + UseMutationOptions, + QueryKey, +} from 'react-query'; import { useAppConfiguration } from './hooks'; import { ApiService, @@ -58,6 +64,12 @@ type UseGetParams = { apiService?: ApiService; }; +type UsePostParams = { + endpoint: string; + options?: UseMutationOptions; + apiService?: ApiService; +}; + export function useGet({ endpoint, key, @@ -81,3 +93,29 @@ export function useGet({ return useQuery(key, getUtil, options); } + +export function usePost({ + endpoint, + options = {}, + apiService = ApiService.Geoapi, +}: UsePostParams) { + const client = axios; + const state = store.getState(); + const configuration = useAppConfiguration(); + + const baseUrl = getBaseApiUrl(apiService, configuration); + const headers = getHeaders(apiService, configuration, state.auth); + + const postUtil = async (requestData: RequestType) => { + const response = await client.post( + `${baseUrl}${endpoint}`, + requestData, + { + headers: headers, + } + ); + return response.data; + }; + + return useMutation(postUtil, options); +} diff --git a/react/src/types/index.ts b/react/src/types/index.ts index 8967d955..4bf02bb6 100644 --- a/react/src/types/index.ts +++ b/react/src/types/index.ts @@ -5,6 +5,6 @@ export type { FeatureClass, FeatureCollection, } from './feature'; -export type { Project } from './projects'; +export type { Project, ProjectRequest } from './projects'; export type { AuthState, AuthenticatedUser, AuthToken } from './auth'; export * from './environment'; diff --git a/react/src/types/projects.ts b/react/src/types/projects.ts index 14fca1fe..38f9620b 100644 --- a/react/src/types/projects.ts +++ b/react/src/types/projects.ts @@ -12,3 +12,11 @@ export interface Project { streetview_instances?: any; } export class Project implements Project {} + +export interface ProjectRequest { + project: Project; + observable: boolean; + watch_content: boolean; +} + +export class ProjectRequest implements ProjectRequest {}