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 ( + +
Page not found
+
+ ) +} 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) && + + + + } +
Create project
+ + dispatch(createProject(values))} + > + {({ values, errors, isValid, touched, setFieldTouched, setFieldValue }) => ( +
+ + + + + {!!errors.name && touched.name && ( + + {errors.name} + + )} + + + + + + + {!!errors.description && touched.description && ( + + {errors.description} + + )} + + + + + value === values.timezone)} + touched={touched.timezone} + onChange={(name, { value }) => setFieldValue(name, value)} + onBlur={(name, { value }) => setFieldTouched(name, value)} + error={errors.timezone} + /> + + + + + values.availableMLModels.includes(value))} + touched={touched.availableMLModels} + onChange={(name, value) => { + setFieldValue(name, value.map((model) => model.value)) + }} + onBlur={(name, { value }) => setFieldTouched(name, value)} + error={errors.availableMLModels} + isMulti + /> + + + + + + {stateMsg && ( + <> + dispatch(dismissStateMsg())} + > + + {stateMsg} + + + + + + + + + + )} + + )} +
+
+ +
+ ); +} + +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;