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 }) => (
+
+ )}
+
+
+
+ );
+};
+
+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 {}