diff --git a/src/api/buildQuery.js b/src/api/buildQuery.js
index 2dde8969..ebc2033a 100644
--- a/src/api/buildQuery.js
+++ b/src/api/buildQuery.js
@@ -165,6 +165,19 @@ const queries = {
variables: { input: input }
}),
+ createProject: (input) => ({
+ template: `
+ mutation CreateProject($input: CreateProjectInput!) {
+ createProject(input: $input) {
+ project {
+ ${projectFields}
+ }
+ }
+ }
+ `,
+ variables: { input: input }
+ }),
+
getViews: (input) => ({
template: `
{
diff --git a/src/app/App.jsx b/src/app/App.jsx
index 20e51315..278efa50 100644
--- a/src/app/App.jsx
+++ b/src/app/App.jsx
@@ -7,6 +7,7 @@ import NavBar from '../components/NavBar';
import HomePage from '../pages/HomePage';
import CaseStudiesPage from '../pages/CaseStudiesPage';
import AppPage from '../pages/AppPage';
+import CreateProjectPage from '../pages/CreateProjectPage';
import { Amplify } from 'aws-amplify';
import { useAuthenticator } from '@aws-amplify/ui-react';
import * as Tooltip from '@radix-ui/react-tooltip';
@@ -121,6 +122,7 @@ const App = () => {
+
{/**/}
diff --git a/src/components/ErrorAlerts.jsx b/src/components/ErrorAlerts.jsx
index c3ef3e77..9d9f8c3a 100644
--- a/src/components/ErrorAlerts.jsx
+++ b/src/components/ErrorAlerts.jsx
@@ -22,6 +22,8 @@ import {
dismissDeploymentsError,
selectModelsErrors,
dismissModelsError,
+ selectCreateProjectsErrors,
+ dismissCreateProjectError,
} from '../features/projects/projectsSlice';
import {
selectWirelessCamerasErrors,
@@ -55,6 +57,7 @@ const ErrorAlerts = () => {
const statsErrors = useSelector(selectStatsErrors);
const exportErrors = useSelector(selectExportErrors);
const manageUserErrors = useSelector(selectManageUserErrors);
+ const createProjectErrors = useSelector(selectCreateProjectsErrors);
const enrichedErrors = [
enrichErrors(labelsErrors, 'Label Error', 'labels'),
@@ -68,6 +71,7 @@ const ErrorAlerts = () => {
enrichErrors(statsErrors, 'Error Getting Stats', 'stats'),
enrichErrors(exportErrors, 'Error Exporting Data', 'data'),
enrichErrors(manageUserErrors, 'Manage user error', 'manageUsers'),
+ enrichErrors(createProjectErrors, 'Error Creating Project', 'createProject'),
];
const errors = enrichedErrors.reduce((acc, curr) => (
@@ -116,6 +120,7 @@ const ErrorAlerts = () => {
const dismissErrorActions = {
'labels': (i) => dismissLabelsError(i),
'projects': (i) => dismissProjectsError(i),
+ 'createProject': (i) => dismissCreateProjectError(i),
'views': (i) => dismissViewsError(i),
'deployments': (i) => dismissDeploymentsError(i),
'models': (i) => dismissModelsError(i),
diff --git a/src/components/NotFound.jsx b/src/components/NotFound.jsx
new file mode 100644
index 00000000..722700b5
--- /dev/null
+++ b/src/components/NotFound.jsx
@@ -0,0 +1,20 @@
+import { styled } from '../theme/stitches.config';
+import { Box } from './Box';
+
+const Header = styled('div', {
+ fontSize: '42px',
+ fontWeight: '$5',
+ fontFamily: '$roboto',
+ color: '$textDark',
+ textAlign: 'center',
+ paddingTop: '$8',
+ paddingBottom: '$8'
+});
+
+export const NotFound = () => {
+ return (
+
+
+
+ )
+}
diff --git a/src/components/SelectField.jsx b/src/components/SelectField.jsx
index f574bac4..81fc01bd 100644
--- a/src/components/SelectField.jsx
+++ b/src/components/SelectField.jsx
@@ -61,6 +61,7 @@ const SelectField = ({
error,
touched,
isSearchable,
+ isMulti
}) => {
const handleChange = (value) => {
@@ -85,6 +86,7 @@ const SelectField = ({
className='react-select'
classNamePrefix='react-select'
isSearchable={isSearchable}
+ isMulti={isMulti}
/>
{!!error &&
touched && (
diff --git a/src/features/projects/CreateProjectForm.jsx b/src/features/projects/CreateProjectForm.jsx
new file mode 100644
index 00000000..fa3e78cc
--- /dev/null
+++ b/src/features/projects/CreateProjectForm.jsx
@@ -0,0 +1,182 @@
+import { useEffect, useMemo } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { Formik, Form, Field } from 'formik';
+import * as Yup from 'yup';
+import { timeZonesNames } from '@vvo/tzdb';
+import _ from 'lodash';
+import { Cross2Icon } from '@radix-ui/react-icons';
+
+import { styled } from '../../theme/stitches.config.js';
+import { FormWrapper, FormFieldWrapper, FieldRow, ButtonRow, FormError } from '../../components/Form';
+import Button from '../../components/Button.jsx';
+import SelectField from '../../components/SelectField.jsx';
+import {
+ Toast,
+ ToastTitle,
+ ToastAction,
+ ToastViewport
+} from '../../components/Toast';
+import IconButton from '../../components/IconButton';
+import ErrorAlerts from '../../components/ErrorAlerts.jsx';
+import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner.jsx';
+
+import {
+ createProject,
+ selectCreateProjectState,
+ dismissStateMsg,
+ fetchModelOptions,
+ selectModelOptions,
+ selectCreateProjectLoading,
+ selectModelOptionsLoading
+} from './projectsSlice.js';
+
+const PageWrapper = styled('div', {
+ maxWidth: '600px',
+ padding: '0 $5',
+ width: '100%',
+ margin: '0 auto'
+});
+
+const Header = styled('div', {
+ fontSize: '24px',
+ fontWeight: '$5',
+ fontFamily: '$roboto',
+ paddingTop: '$8',
+ marginBottom: '$4'
+});
+
+const createProjectSchema = Yup.object().shape({
+ name: Yup.string().required('Enter a project name'),
+ description: Yup.string().required('Enter a short description'),
+ timezone: Yup.string().required('Select a timezone'),
+ availableMLModels: Yup.array().min(1, "Select at least one ML model").required('Select a ML model'),
+});
+
+const CreateProjectForm = () => {
+ const dispatch = useDispatch();
+ const stateMsg = useSelector(selectCreateProjectState);
+ const mlModels = useSelector(selectModelOptions);
+ const createProjectIsLoading = useSelector(selectCreateProjectLoading);
+ const mlModelsOptionsIsLoading = useSelector(selectModelOptionsLoading)
+
+ const tzOptions = timeZonesNames.map((tz) => ({ value: tz, label: tz }));
+ const mlModelOptions = useMemo(
+ () => mlModels.map(({ _id, description }) => ({
+ value: _id,
+ label: description
+ })),
+ [mlModels]
+ );
+
+ useEffect(() => {
+ dispatch(fetchModelOptions());
+ }, []);
+
+ return (
+
+ {(createProjectIsLoading || mlModelsOptionsIsLoading) &&
+
+
+
+ }
+
+
+ dispatch(createProject(values))}
+ >
+ {({ values, errors, isValid, touched, setFieldTouched, setFieldValue }) => (
+
+ )}
+
+
+
+
+ );
+}
+
+export default CreateProjectForm;
diff --git a/src/features/projects/projectsSlice.js b/src/features/projects/projectsSlice.js
index e339f587..a1d36b27 100644
--- a/src/features/projects/projectsSlice.js
+++ b/src/features/projects/projectsSlice.js
@@ -9,6 +9,7 @@ import {
const initialState = {
projects: [],
+ modelOptions: [],
loadingStates: {
projects: {
isLoading: false,
@@ -16,6 +17,12 @@ const initialState = {
errors: null,
noneFound: false,
},
+ createProject: {
+ isLoading: false,
+ operation: null,
+ errors: null,
+ stateMsg: null,
+ },
views: {
isLoading: false,
operation: null,
@@ -36,6 +43,11 @@ const initialState = {
operation: null,
errors: null,
},
+ modelOptions: {
+ isLoading: false,
+ operation: null,
+ errors: null,
+ },
uploads: {
isLoading: false,
operation: null,
@@ -52,6 +64,10 @@ export const projectsSlice = createSlice({
name: 'projects',
initialState,
reducers: {
+ /*
+ * Views CRUD
+ */
+
getProjectsStart: (state) => {
const ls = { isLoading: true, operation: 'fetching', errors: null };
state.loadingStates.projects = ls;
@@ -109,6 +125,42 @@ export const projectsSlice = createSlice({
state.unsavedViewChanges = payload;
},
+ createProjectStart: (state) => {
+ const ls = { isLoading: true, operation: 'fetching', errors: null, stateMsg: null };
+ state.loadingStates.createProject = ls;
+ },
+
+ createProjectSuccess: (state, { payload }) => {
+ const { project } = payload.createProject;
+ const ls = {
+ isLoading: false,
+ operation: null,
+ errors: null,
+ stateMsg: `Successfully created project ${project.name}`
+ };
+ state.loadingStates.createProject = ls;
+
+ state.projects = [
+ ...state.projects,
+ project
+ ];
+ },
+
+ createProjectFailure: (state, { payload }) => {
+ const ls = { isLoading: false, operation: null, errors: payload, stateMsg: null };
+ state.loadingStates.createProject = ls;
+ },
+
+ dismissCreateProjectError: (state, { payload }) => {
+ const index = payload;
+ state.loadingStates.createProject.errors.splice(index, 1);
+ },
+
+ dismissStateMsg: (state) => {
+ const ls = { isLoading: false, operation: null, errors: null, stateMsg: null };
+ state.loadingStates.createProject = ls;
+ },
+
/*
* Views CRUD
@@ -242,6 +294,24 @@ export const projectsSlice = createSlice({
});
},
+ getModelOptionsStart: (state) => {
+ console.log('getModelOptionsStart')
+ const ls = { isLoading: true, operation: 'fetching', errors: null };
+ state.loadingStates.modelOptions = ls;
+ },
+
+ getModelOptionsFailure: (state, { payload }) => {
+ const ls = { isLoading: false, operation: null, errors: payload };
+ state.loadingStates.modelOptions = ls;
+ },
+
+ getModelOptionsSuccess: (state, { payload }) => {
+ console.log('getModelOptionsSuccess')
+ const ls = { isLoading: false, operation: null, errors: null };
+ state.loadingStates.modelOptions = ls;
+ state.modelOptions = payload;
+ },
+
setModalOpen: (state, { payload }) => {
state.modalOpen = payload;
},
@@ -285,6 +355,11 @@ export const {
setSelectedProjAndView,
setUnsavedViewChanges,
dismissProjectsError,
+ createProjectStart,
+ createProjectSuccess,
+ createProjectFailure,
+ dismissCreateProjectError,
+ dismissStateMsg,
editViewStart,
saveViewSuccess,
@@ -306,6 +381,9 @@ export const {
getModelsFailure,
getModelsSuccess,
dismissModelsError,
+ getModelOptionsStart,
+ getModelOptionsFailure,
+ getModelOptionsSuccess,
setModalOpen,
setModalContent,
@@ -332,6 +410,25 @@ export const fetchProjects = (payload) => async dispatch => {
}
};
+export const createProject = (payload) => async dispatch => {
+ try {
+ const currentUser = await Auth.currentAuthenticatedUser();
+ const token = currentUser.getSignInUserSession().getIdToken().getJwtToken();
+ // TODO make this work
+ if (token) {
+ dispatch(createProjectStart());
+ const project = await call({
+ request: 'createProject',
+ input: payload
+ });
+ dispatch(createProjectSuccess(project));
+ }
+ } catch (err) {
+ console.log('err: ', err)
+ dispatch(createProjectFailure(err));
+ }
+}
+
// editView thunk
// TODO: maybe break this up into discrete thunks?
// or take the more consolodated approach in editLabels thunk (imagesSlice.js)
@@ -492,12 +589,31 @@ export const fetchModels = (payload) => {
};
}
+export const fetchModelOptions = () => {
+ return async (dispatch) => {
+ try {
+ dispatch(getModelOptionsStart());
+ const currentUser = await Auth.currentAuthenticatedUser();
+ const token = currentUser.getSignInUserSession().getIdToken().getJwtToken();
+
+ if (token) {
+ const res = await call({
+ request: 'getModels',
+ input: {},
+ });
+ dispatch(getModelOptionsSuccess(res.mlModels));
+ }
+
+ } catch (err) {
+ dispatch(getModelOptionsFailure(err));
+ }
+ };
+}
+
// Selectors
export const selectProjects = state => state.projects.projects;
-export const selectSelectedProject = state => (
- state.projects.projects.find((proj) => proj.selected)
-);
+export const selectSelectedProject = state => state.projects.projects.find((proj) => proj.selected);
export const selectSelectedProjectId = createSelector([selectSelectedProject],
(proj) => proj ? proj._id : null
);
@@ -523,6 +639,11 @@ export const selectProjectsErrors = state => state.projects.loadingStates.projec
export const selectViewsErrors = state => state.projects.loadingStates.views.errors;
export const selectDeploymentsErrors = state => state.projects.loadingStates.deployments.errors;
export const selectModelsErrors = state => state.projects.loadingStates.models.errors;
+export const selectCreateProjectState = state => state.projects.loadingStates.createProject.stateMsg;
+export const selectCreateProjectsErrors = state => state.projects.loadingStates.createProject.errors;
+export const selectCreateProjectLoading = state => state.projects.loadingStates.createProject.isLoading;
+export const selectModelOptions = state => state.projects.modelOptions;
+export const selectModelOptionsLoading = state => state.projects.loadingStates.modelOptions.isLoading;
export default projectsSlice.reducer;
diff --git a/src/features/user/userSlice.js b/src/features/user/userSlice.js
index f04ac7f5..a6c64bec 100644
--- a/src/features/user/userSlice.js
+++ b/src/features/user/userSlice.js
@@ -50,7 +50,7 @@ export const selectUserAuthStatus = state => state.user.authStatus;
export const selectUserGroups = state => state.user.groups;
export const selectUserUsername = state => state.user.username;
export const selectUserProjects = state => state.user.projects;
-export const selectUserIsSuperUser = state => state.user.groups.includes('animl_superuser');
+export const selectUserIsSuperUser = state => state.user.groups && state.user.groups.includes('animl_superuser');
export const selectUserHasBetaAccess = state => state.user.groups.includes('beta_access');
export const selectUserCurrentRoles = createSelector(
[selectSelectedProject, selectUserProjects, selectUserIsSuperUser],
diff --git a/src/pages/CreateProjectPage.jsx b/src/pages/CreateProjectPage.jsx
new file mode 100644
index 00000000..887182b8
--- /dev/null
+++ b/src/pages/CreateProjectPage.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+
+import { Page } from '../components/Page.jsx';
+import { NotFound } from '../components/NotFound.jsx'
+import { selectUserUsername, selectUserAuthStatus, selectUserIsSuperUser } from '../features/user/userSlice.js';
+import LoginForm from '../features/user/LoginForm.jsx';
+import CreateProjectForm from '../features/projects/CreateProjectForm.jsx';
+
+const CreateProjectPage = () => {
+ const authStatus = useSelector(selectUserAuthStatus);
+ const user = useSelector(selectUserUsername);
+ const isSuperUser = useSelector(selectUserIsSuperUser);
+ const signedIn = authStatus === 'authenticated' && user;
+
+ if (!signedIn) {
+ return ;
+ }
+
+ return (
+
+ {isSuperUser ? : }
+
+ )
+}
+
+export default CreateProjectPage;