feature: add first time install process
This commit is contained in:
		| @@ -24,7 +24,7 @@ | |||||||
|     "plugin:@typescript-eslint/recommended" |     "plugin:@typescript-eslint/recommended" | ||||||
|   ], |   ], | ||||||
|   "rules": { |   "rules": { | ||||||
|     "prettier/prettier": "warning", |     "prettier/prettier": "error", | ||||||
|     "no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"], |     "no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"], | ||||||
|     "@typescript-eslint/explicit-function-return-type": "off", |     "@typescript-eslint/explicit-function-return-type": "off", | ||||||
|     "@typescript-eslint/no-explicit-any": "off", |     "@typescript-eslint/no-explicit-any": "off", | ||||||
|   | |||||||
| @@ -61,7 +61,7 @@ | |||||||
|     "react-beautiful-dnd": "^13.0.0", |     "react-beautiful-dnd": "^13.0.0", | ||||||
|     "react-datepicker": "^2.14.1", |     "react-datepicker": "^2.14.1", | ||||||
|     "react-dom": "^16.12.0", |     "react-dom": "^16.12.0", | ||||||
|     "react-hook-form": "^5.2.0", |     "react-hook-form": "^6.0.6", | ||||||
|     "react-markdown": "^4.3.1", |     "react-markdown": "^4.3.1", | ||||||
|     "react-router": "^5.1.2", |     "react-router": "^5.1.2", | ||||||
|     "react-router-dom": "^5.1.2", |     "react-router-dom": "^5.1.2", | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { Router, Switch, Route } from 'react-router-dom'; | import {Router, Switch, Route} from 'react-router-dom'; | ||||||
| import * as H from 'history'; | import * as H from 'history'; | ||||||
|  |  | ||||||
| import Dashboard from 'Dashboard'; | import Dashboard from 'Dashboard'; | ||||||
| @@ -8,6 +8,7 @@ import Projects from 'Projects'; | |||||||
| import Project from 'Projects/Project'; | import Project from 'Projects/Project'; | ||||||
| import Teams from 'Teams'; | import Teams from 'Teams'; | ||||||
| import Login from 'Auth'; | import Login from 'Auth'; | ||||||
|  | import Install from 'Install'; | ||||||
| import Profile from 'Profile'; | import Profile from 'Profile'; | ||||||
| import styled from 'styled-components'; | import styled from 'styled-components'; | ||||||
|  |  | ||||||
| @@ -23,9 +24,10 @@ type RoutesProps = { | |||||||
|   history: H.History; |   history: H.History; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const Routes = ({ history }: RoutesProps) => ( | const Routes = ({history}: RoutesProps) => ( | ||||||
|   <Switch> |   <Switch> | ||||||
|     <Route exact path="/login" component={Login} /> |     <Route exact path="/login" component={Login} /> | ||||||
|  |     <Route exact path="/install" component={Install} /> | ||||||
|     <MainContent> |     <MainContent> | ||||||
|       <Route exact path="/" component={Dashboard} /> |       <Route exact path="/" component={Dashboard} /> | ||||||
|       <Route exact path="/projects" component={Projects} /> |       <Route exact path="/projects" component={Projects} /> | ||||||
|   | |||||||
| @@ -196,7 +196,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({history, name, projec | |||||||
|       <Popup title={null} tab={0}> |       <Popup title={null} tab={0}> | ||||||
|         <ProjectSettings |         <ProjectSettings | ||||||
|           onDeleteProject={() => { |           onDeleteProject={() => { | ||||||
|             setTab(1); |             setTab(1, 300); | ||||||
|           }} |           }} | ||||||
|         /> |         /> | ||||||
|       </Popup> |       </Popup> | ||||||
|   | |||||||
| @@ -1,18 +1,22 @@ | |||||||
| import React, {useState, useEffect} from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import jwtDecode from 'jwt-decode'; | import jwtDecode from 'jwt-decode'; | ||||||
| import {createBrowserHistory} from 'history'; | import { createBrowserHistory } from 'history'; | ||||||
| import {setAccessToken} from 'shared/utils/accessToken'; | import { Router } from 'react-router'; | ||||||
| import styled, {ThemeProvider} from 'styled-components'; | import { PopupProvider } from 'shared/components/PopupMenu'; | ||||||
|  | import { setAccessToken } from 'shared/utils/accessToken'; | ||||||
|  | import styled, { ThemeProvider } from 'styled-components'; | ||||||
| import NormalizeStyles from './NormalizeStyles'; | import NormalizeStyles from './NormalizeStyles'; | ||||||
| import BaseStyles from './BaseStyles'; | import BaseStyles from './BaseStyles'; | ||||||
| import {theme} from './ThemeStyles'; | import { theme } from './ThemeStyles'; | ||||||
| import Routes from './Routes'; | import Routes from './Routes'; | ||||||
| import {UserIDContext} from './context'; | import { UserIDContext } from './context'; | ||||||
| import Navbar from './Navbar'; | import Navbar from './Navbar'; | ||||||
| import {Router} from 'react-router'; |  | ||||||
| import {PopupProvider} from 'shared/components/PopupMenu'; |  | ||||||
|  |  | ||||||
| const history = createBrowserHistory(); | const history = createBrowserHistory(); | ||||||
|  | type RefreshTokenResponse = { | ||||||
|  |   accessToken: string; | ||||||
|  |   isInstalled: boolean; | ||||||
|  | }; | ||||||
|  |  | ||||||
| const App = () => { | const App = () => { | ||||||
|   const [loading, setLoading] = useState(true); |   const [loading, setLoading] = useState(true); | ||||||
| @@ -23,15 +27,18 @@ const App = () => { | |||||||
|       method: 'POST', |       method: 'POST', | ||||||
|       credentials: 'include', |       credentials: 'include', | ||||||
|     }).then(async x => { |     }).then(async x => { | ||||||
|       const {status} = x; |       const { status } = x; | ||||||
|       if (status === 400) { |       if (status === 400) { | ||||||
|         history.replace('/login'); |         history.replace('/login'); | ||||||
|       } else { |       } else { | ||||||
|         const response: RefreshTokenResponse = await x.json(); |         const response: RefreshTokenResponse = await x.json(); | ||||||
|         const {accessToken} = response; |         const { accessToken, isInstalled } = response; | ||||||
|         const claims: JWTToken = jwtDecode(accessToken); |         const claims: JWTToken = jwtDecode(accessToken); | ||||||
|         setUserID(claims.userId); |         setUserID(claims.userId); | ||||||
|         setAccessToken(accessToken); |         setAccessToken(accessToken); | ||||||
|  |         if (!isInstalled) { | ||||||
|  |           history.replace('/install'); | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
|     }); |     }); | ||||||
| @@ -39,7 +46,7 @@ const App = () => { | |||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <UserIDContext.Provider value={{userID, setUserID}}> |       <UserIDContext.Provider value={{ userID, setUserID }}> | ||||||
|         <ThemeProvider theme={theme}> |         <ThemeProvider theme={theme}> | ||||||
|           <NormalizeStyles /> |           <NormalizeStyles /> | ||||||
|           <BaseStyles /> |           <BaseStyles /> | ||||||
| @@ -48,10 +55,10 @@ const App = () => { | |||||||
|               {loading ? ( |               {loading ? ( | ||||||
|                 <div>loading</div> |                 <div>loading</div> | ||||||
|               ) : ( |               ) : ( | ||||||
|                   <> |                 <> | ||||||
|                     <Routes history={history} /> |                   <Routes history={history} /> | ||||||
|                   </> |                 </> | ||||||
|                 )} |               )} | ||||||
|             </PopupProvider> |             </PopupProvider> | ||||||
|           </Router> |           </Router> | ||||||
|         </ThemeProvider> |         </ThemeProvider> | ||||||
|   | |||||||
| @@ -1,22 +1,22 @@ | |||||||
| import React, {useState, useEffect, useContext} from 'react'; | import React, { useState, useEffect, useContext } from 'react'; | ||||||
| import {useForm} from 'react-hook-form'; | import { useForm } from 'react-hook-form'; | ||||||
| import {useHistory} from 'react-router'; | import { useHistory } from 'react-router'; | ||||||
|  |  | ||||||
| import {setAccessToken} from 'shared/utils/accessToken'; | import { setAccessToken } from 'shared/utils/accessToken'; | ||||||
|  |  | ||||||
| import Login from 'shared/components/Login'; | import Login from 'shared/components/Login'; | ||||||
| import {Container, LoginWrapper} from './Styles'; | import { Container, LoginWrapper } from './Styles'; | ||||||
| import UserIDContext from 'App/context'; | import UserIDContext from 'App/context'; | ||||||
| import JwtDecode from 'jwt-decode'; | import JwtDecode from 'jwt-decode'; | ||||||
|  |  | ||||||
| const Auth = () => { | const Auth = () => { | ||||||
|   const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0); |   const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0); | ||||||
|   const history = useHistory(); |   const history = useHistory(); | ||||||
|   const {setUserID} = useContext(UserIDContext); |   const { setUserID } = useContext(UserIDContext); | ||||||
|   const login = ( |   const login = ( | ||||||
|     data: LoginFormData, |     data: LoginFormData, | ||||||
|     setComplete: (val: boolean) => void, |     setComplete: (val: boolean) => void, | ||||||
|     setError: (field: string, eType: string, message: string) => void, |     setError: (name: 'username' | 'password', error: ErrorOption) => void, | ||||||
|   ) => { |   ) => { | ||||||
|     fetch('/auth/login', { |     fetch('/auth/login', { | ||||||
|       credentials: 'include', |       credentials: 'include', | ||||||
| @@ -28,12 +28,12 @@ const Auth = () => { | |||||||
|     }).then(async x => { |     }).then(async x => { | ||||||
|       if (x.status === 401) { |       if (x.status === 401) { | ||||||
|         setInvalidLoginAttempt(invalidLoginAttempt + 1); |         setInvalidLoginAttempt(invalidLoginAttempt + 1); | ||||||
|         setError('username', 'invalid', 'Invalid username'); |         setError('username', { type: 'error', message: 'Invalid username' }); | ||||||
|         setError('password', 'invalid', 'Invalid password'); |         setError('password', { type: 'error', message: 'Invalid password' }); | ||||||
|         setComplete(true); |         setComplete(true); | ||||||
|       } else { |       } else { | ||||||
|         const response = await x.json(); |         const response = await x.json(); | ||||||
|         const {accessToken} = response; |         const { accessToken } = response; | ||||||
|         const claims: JWTToken = JwtDecode(accessToken); |         const claims: JWTToken = JwtDecode(accessToken); | ||||||
|         setUserID(claims.userId); |         setUserID(claims.userId); | ||||||
|         setComplete(true); |         setComplete(true); | ||||||
| @@ -49,7 +49,7 @@ const Auth = () => { | |||||||
|       method: 'POST', |       method: 'POST', | ||||||
|       credentials: 'include', |       credentials: 'include', | ||||||
|     }).then(async x => { |     }).then(async x => { | ||||||
|       const {status} = x; |       const { status } = x; | ||||||
|       if (status === 200) { |       if (status === 200) { | ||||||
|         history.replace('/projects'); |         history.replace('/projects'); | ||||||
|       } |       } | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								frontend/src/Install/Styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/Install/Styles.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | import styled from 'styled-components'; | ||||||
|  |  | ||||||
|  | export const Container = styled.div` | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   width: 100vw; | ||||||
|  |   height: 100vh; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const LoginWrapper = styled.div` | ||||||
|  |   width: 60%; | ||||||
|  | `; | ||||||
							
								
								
									
										85
									
								
								frontend/src/Install/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								frontend/src/Install/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | |||||||
|  | import React, { useEffect, useContext } from 'react'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import Register from 'shared/components/Register'; | ||||||
|  | import { Container, LoginWrapper } from './Styles'; | ||||||
|  | import { useCreateUserAccountMutation, useMeQuery, MeDocument, MeQuery } from 'shared/generated/graphql'; | ||||||
|  | import { useHistory } from 'react-router'; | ||||||
|  | import { getAccessToken, setAccessToken } from 'shared/utils/accessToken'; | ||||||
|  | import updateApolloCache from 'shared/utils/cache'; | ||||||
|  | import produce from 'immer'; | ||||||
|  | import { useApolloClient } from '@apollo/react-hooks'; | ||||||
|  | import UserIDContext from 'App/context'; | ||||||
|  | import jwtDecode from 'jwt-decode'; | ||||||
|  |  | ||||||
|  | const Install = () => { | ||||||
|  |   const client = useApolloClient(); | ||||||
|  |   const history = useHistory(); | ||||||
|  |   const { setUserID } = useContext(UserIDContext); | ||||||
|  |   useEffect(() => { | ||||||
|  |     fetch('/auth/refresh_token', { | ||||||
|  |       method: 'POST', | ||||||
|  |       credentials: 'include', | ||||||
|  |     }).then(async x => { | ||||||
|  |       const { status } = x; | ||||||
|  |       const response: RefreshTokenResponse = await x.json(); | ||||||
|  |       const { isInstalled } = response; | ||||||
|  |       if (status === 200 && isInstalled) { | ||||||
|  |         history.replace('/projects'); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   }, []); | ||||||
|  |   return ( | ||||||
|  |     <Container> | ||||||
|  |       <LoginWrapper> | ||||||
|  |         <Register | ||||||
|  |           onSubmit={(data, setComplete, setError) => { | ||||||
|  |             const accessToken = getAccessToken(); | ||||||
|  |             if (data.password !== data.password_confirm) { | ||||||
|  |               setError('password', { type: 'error', message: 'Passwords must match' }); | ||||||
|  |               setError('password_confirm', { type: 'error', message: 'Passwords must match' }); | ||||||
|  |             } else { | ||||||
|  |               axios | ||||||
|  |                 .post( | ||||||
|  |                   '/auth/install', | ||||||
|  |                   { | ||||||
|  |                     user: { | ||||||
|  |                       username: data.username, | ||||||
|  |                       roleCode: 'admin', | ||||||
|  |                       email: data.email, | ||||||
|  |                       password: data.password, | ||||||
|  |                       initials: data.initials, | ||||||
|  |                       fullname: data.fullname, | ||||||
|  |                     }, | ||||||
|  |                   }, | ||||||
|  |                   { | ||||||
|  |                     headers: { | ||||||
|  |                       Authorization: `Bearer ${accessToken}`, | ||||||
|  |                     }, | ||||||
|  |                   }, | ||||||
|  |                 ) | ||||||
|  |                 .then(async x => { | ||||||
|  |                   const { status } = x; | ||||||
|  |                   if (status === 400) { | ||||||
|  |                     history.replace('/login'); | ||||||
|  |                   } else { | ||||||
|  |                     const response: RefreshTokenResponse = await x.data; | ||||||
|  |                     const { accessToken, isInstalled } = response; | ||||||
|  |                     const claims: JWTToken = jwtDecode(accessToken); | ||||||
|  |                     setUserID(claims.userId); | ||||||
|  |                     setAccessToken(accessToken); | ||||||
|  |                     if (!isInstalled) { | ||||||
|  |                       history.replace('/install'); | ||||||
|  |                     } | ||||||
|  |                   } | ||||||
|  |                   history.push('/projects'); | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |             setComplete(true); | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |       </LoginWrapper> | ||||||
|  |     </Container> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Install; | ||||||
| @@ -1,12 +1,12 @@ | |||||||
| import React, {useState, useRef, useContext, useEffect} from 'react'; | import React, { useState, useRef, useContext, useEffect } from 'react'; | ||||||
| import {MENU_TYPES} from 'shared/components/TopNavbar'; | import { MENU_TYPES } from 'shared/components/TopNavbar'; | ||||||
| import updateApolloCache from 'shared/utils/cache'; | import updateApolloCache from 'shared/utils/cache'; | ||||||
| import GlobalTopNavbar, {ProjectPopup} from 'App/TopNavbar'; | import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar'; | ||||||
| import styled, {css} from 'styled-components/macro'; | import styled, { css } from 'styled-components/macro'; | ||||||
| import {Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter} from 'shared/icons'; | import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons'; | ||||||
| import LabelManagerEditor from '../LabelManagerEditor' | import LabelManagerEditor from '../LabelManagerEditor'; | ||||||
| import {usePopup, Popup} from 'shared/components/PopupMenu'; | import { usePopup, Popup } from 'shared/components/PopupMenu'; | ||||||
| import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation} from 'react-router-dom'; | import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom'; | ||||||
| import { | import { | ||||||
|   useSetProjectOwnerMutation, |   useSetProjectOwnerMutation, | ||||||
|   useUpdateProjectMemberRoleMutation, |   useUpdateProjectMemberRoleMutation, | ||||||
| @@ -61,7 +61,7 @@ const ProjectActions = styled.div` | |||||||
|   align-items: center; |   align-items: center; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const ProjectAction = styled.div<{disabled?: boolean}>` | const ProjectAction = styled.div<{ disabled?: boolean }>` | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
| @@ -103,18 +103,21 @@ const initialQuickCardEditorState: QuickCardEditorState = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| type ProjectBoardProps = { | type ProjectBoardProps = { | ||||||
|  |   onCardLabelClick: () => void; | ||||||
|  |   cardLabelVariant: CardLabelVariant; | ||||||
|   projectID: string; |   projectID: string; | ||||||
| }; | }; | ||||||
| const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { |  | ||||||
|  | const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => { | ||||||
|   const [assignTask] = useAssignTaskMutation(); |   const [assignTask] = useAssignTaskMutation(); | ||||||
|   const [unassignTask] = useUnassignTaskMutation(); |   const [unassignTask] = useUnassignTaskMutation(); | ||||||
|   const $labelsRef = useRef<HTMLDivElement>(null); |   const $labelsRef = useRef<HTMLDivElement>(null); | ||||||
|   const match = useRouteMatch(); |   const match = useRouteMatch(); | ||||||
|   const labelsRef = useRef<Array<ProjectLabel>>([]); |   const labelsRef = useRef<Array<ProjectLabel>>([]); | ||||||
|   const {showPopup, hidePopup} = usePopup(); |   const { showPopup, hidePopup } = usePopup(); | ||||||
|   const taskLabelsRef = useRef<Array<TaskLabel>>([]); |   const taskLabelsRef = useRef<Array<TaskLabel>>([]); | ||||||
|   const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState); |   const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState); | ||||||
|   const {userID} = useContext(UserIDContext); |   const { userID } = useContext(UserIDContext); | ||||||
|   const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({}); |   const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({}); | ||||||
|   const history = useHistory(); |   const history = useHistory(); | ||||||
|   const [deleteTaskGroup] = useDeleteTaskGroupMutation({ |   const [deleteTaskGroup] = useDeleteTaskGroupMutation({ | ||||||
| @@ -128,7 +131,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|               (taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id, |               (taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id, | ||||||
|             ); |             ); | ||||||
|           }), |           }), | ||||||
|         {projectId: projectID}, |         { projectId: projectID }, | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| @@ -140,13 +143,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|         FindProjectDocument, |         FindProjectDocument, | ||||||
|         cache => |         cache => | ||||||
|           produce(cache, draftCache => { |           produce(cache, draftCache => { | ||||||
|             const {taskGroups} = cache.findProject; |             const { taskGroups } = cache.findProject; | ||||||
|             const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id); |             const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id); | ||||||
|             if (idx !== -1) { |             if (idx !== -1) { | ||||||
|               draftCache.findProject.taskGroups[idx].tasks.push({...newTaskData.data.createTask}); |               draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask }); | ||||||
|             } |             } | ||||||
|           }), |           }), | ||||||
|         {projectId: projectID}, |         { projectId: projectID }, | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| @@ -156,17 +159,18 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|       updateApolloCache<FindProjectQuery>( |       updateApolloCache<FindProjectQuery>( | ||||||
|         client, |         client, | ||||||
|         FindProjectDocument, |         FindProjectDocument, | ||||||
|         cache => produce(cache, draftCache => { |         cache => | ||||||
|           draftCache.findProject.taskGroups.push({...newTaskGroupData.data.createTaskGroup, tasks: []}); |           produce(cache, draftCache => { | ||||||
|         }), |             draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] }); | ||||||
|         {projectId: projectID}, |           }), | ||||||
|  |         { projectId: projectID }, | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({}); |   const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({}); | ||||||
|   const {loading, data} = useFindProjectQuery({ |   const { loading, data } = useFindProjectQuery({ | ||||||
|     variables: {projectId: projectID}, |     variables: { projectId: projectID }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const [updateTaskDueDate] = useUpdateTaskDueDateMutation(); |   const [updateTaskDueDate] = useUpdateTaskDueDateMutation(); | ||||||
| @@ -178,9 +182,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|         FindProjectDocument, |         FindProjectDocument, | ||||||
|         cache => |         cache => | ||||||
|           produce(cache, draftCache => { |           produce(cache, draftCache => { | ||||||
|             const {previousTaskGroupID, task} = newTask.data.updateTaskLocation; |             const { previousTaskGroupID, task } = newTask.data.updateTaskLocation; | ||||||
|             if (previousTaskGroupID !== task.taskGroup.id) { |             if (previousTaskGroupID !== task.taskGroup.id) { | ||||||
|               const {taskGroups} = cache.findProject; |               const { taskGroups } = cache.findProject; | ||||||
|               const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID); |               const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID); | ||||||
|               const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id); |               const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id); | ||||||
|               if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) { |               if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) { | ||||||
| @@ -189,12 +193,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|                 ); |                 ); | ||||||
|                 draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [ |                 draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [ | ||||||
|                   ...taskGroups[newTaskGroupIdx].tasks, |                   ...taskGroups[newTaskGroupIdx].tasks, | ||||||
|                   {...task}, |                   { ...task }, | ||||||
|                 ]; |                 ]; | ||||||
|               } |               } | ||||||
|             } |             } | ||||||
|           }), |           }), | ||||||
|         {projectId: projectID}, |         { projectId: projectID }, | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| @@ -222,7 +226,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|  |  | ||||||
|         console.log(`position ${position}`); |         console.log(`position ${position}`); | ||||||
|         createTask({ |         createTask({ | ||||||
|           variables: {taskGroupID, name, position}, |           variables: { taskGroupID, name, position }, | ||||||
|           optimisticResponse: { |           optimisticResponse: { | ||||||
|             __typename: 'Mutation', |             __typename: 'Mutation', | ||||||
|             createTask: { |             createTask: { | ||||||
| @@ -258,7 +262,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|       if (lastColumn) { |       if (lastColumn) { | ||||||
|         position = lastColumn.position * 2 + 1; |         position = lastColumn.position * 2 + 1; | ||||||
|       } |       } | ||||||
|       createTaskGroup({variables: {projectID, name: listName, position}}); |       createTaskGroup({ variables: { projectID, name: listName, position } }); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -341,6 +345,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|           onTaskClick={task => { |           onTaskClick={task => { | ||||||
|             history.push(`${match.url}/c/${task.id}`); |             history.push(`${match.url}/c/${task.id}`); | ||||||
|           }} |           }} | ||||||
|  |           onCardLabelClick={onCardLabelClick} | ||||||
|  |           cardLabelVariant={cardLabelVariant} | ||||||
|           onTaskDrop={(droppedTask, previousTaskGroupID) => { |           onTaskDrop={(droppedTask, previousTaskGroupID) => { | ||||||
|             updateTaskLocation({ |             updateTaskLocation({ | ||||||
|               variables: { |               variables: { | ||||||
| @@ -370,7 +376,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|           }} |           }} | ||||||
|           onTaskGroupDrop={droppedTaskGroup => { |           onTaskGroupDrop={droppedTaskGroup => { | ||||||
|             updateTaskGroupLocation({ |             updateTaskGroupLocation({ | ||||||
|               variables: {taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position}, |               variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position }, | ||||||
|               optimisticResponse: { |               optimisticResponse: { | ||||||
|                 __typename: 'Mutation', |                 __typename: 'Mutation', | ||||||
|                 updateTaskGroupLocation: { |                 updateTaskGroupLocation: { | ||||||
| @@ -400,7 +406,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|             } |             } | ||||||
|           }} |           }} | ||||||
|           onChangeTaskGroupName={(taskGroupID, name) => { |           onChangeTaskGroupName={(taskGroupID, name) => { | ||||||
|             updateTaskGroupName({variables: {taskGroupID, name}}); |             updateTaskGroupName({ variables: { taskGroupID, name } }); | ||||||
|           }} |           }} | ||||||
|           onQuickEditorOpen={onQuickEditorOpen} |           onQuickEditorOpen={onQuickEditorOpen} | ||||||
|           onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => { |           onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => { | ||||||
| @@ -410,7 +416,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|                 <ListActions |                 <ListActions | ||||||
|                   taskGroupID={taskGroupID} |                   taskGroupID={taskGroupID} | ||||||
|                   onArchiveTaskGroup={tgID => { |                   onArchiveTaskGroup={tgID => { | ||||||
|                     deleteTaskGroup({variables: {taskGroupID: tgID}}); |                     deleteTaskGroup({ variables: { taskGroupID: tgID } }); | ||||||
|                     hidePopup(); |                     hidePopup(); | ||||||
|                   }} |                   }} | ||||||
|                 /> |                 /> | ||||||
| @@ -423,7 +429,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|             task={currentQuickTask} |             task={currentQuickTask} | ||||||
|             onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)} |             onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)} | ||||||
|             onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => { |             onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => { | ||||||
|               updateTaskName({variables: {taskID, name: cardName}}); |               updateTaskName({ variables: { taskID, name: cardName } }); | ||||||
|             }} |             }} | ||||||
|             onOpenMembersPopup={($targetRef, task) => { |             onOpenMembersPopup={($targetRef, task) => { | ||||||
|               showPopup( |               showPopup( | ||||||
| @@ -434,9 +440,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|                     activeMembers={task.assigned ?? []} |                     activeMembers={task.assigned ?? []} | ||||||
|                     onMemberChange={(member, isActive) => { |                     onMemberChange={(member, isActive) => { | ||||||
|                       if (isActive) { |                       if (isActive) { | ||||||
|                         assignTask({variables: {taskID: task.id, userID: userID ?? ''}}); |                         assignTask({ variables: { taskID: task.id, userID: userID ?? '' } }); | ||||||
|                       } else { |                       } else { | ||||||
|                         unassignTask({variables: {taskID: task.id, userID: userID ?? ''}}); |                         unassignTask({ variables: { taskID: task.id, userID: userID ?? '' } }); | ||||||
|                       } |                       } | ||||||
|                     }} |                     }} | ||||||
|                   /> |                   /> | ||||||
| @@ -464,7 +470,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|                 $targetRef, |                 $targetRef, | ||||||
|                 <LabelManagerEditor |                 <LabelManagerEditor | ||||||
|                   onLabelToggle={labelID => { |                   onLabelToggle={labelID => { | ||||||
|                     toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}}); |                     toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } }); | ||||||
|                   }} |                   }} | ||||||
|                   labelColors={data.labelColors} |                   labelColors={data.labelColors} | ||||||
|                   labels={labelsRef} |                   labels={labelsRef} | ||||||
| @@ -475,7 +481,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|             }} |             }} | ||||||
|             onArchiveCard={(_listId: string, cardId: string) => |             onArchiveCard={(_listId: string, cardId: string) => | ||||||
|               deleteTask({ |               deleteTask({ | ||||||
|                 variables: {taskID: cardId}, |                 variables: { taskID: cardId }, | ||||||
|                 update: client => { |                 update: client => { | ||||||
|                   updateApolloCache<FindProjectQuery>( |                   updateApolloCache<FindProjectQuery>( | ||||||
|                     client, |                     client, | ||||||
| @@ -487,7 +493,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|                           tasks: taskGroup.tasks.filter(t => t.id !== cardId), |                           tasks: taskGroup.tasks.filter(t => t.id !== cardId), | ||||||
|                         })); |                         })); | ||||||
|                       }), |                       }), | ||||||
|                     {projectId: projectID}, |                     { projectId: projectID }, | ||||||
|                   ); |                   ); | ||||||
|                 }, |                 }, | ||||||
|               }) |               }) | ||||||
| @@ -499,11 +505,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|                   <DueDateManager |                   <DueDateManager | ||||||
|                     task={task} |                     task={task} | ||||||
|                     onRemoveDueDate={t => { |                     onRemoveDueDate={t => { | ||||||
|                       updateTaskDueDate({variables: {taskID: t.id, dueDate: null}}); |                       updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } }); | ||||||
|                       hidePopup(); |                       hidePopup(); | ||||||
|                     }} |                     }} | ||||||
|                     onDueDateChange={(t, newDueDate) => { |                     onDueDateChange={(t, newDueDate) => { | ||||||
|                       updateTaskDueDate({variables: {taskID: t.id, dueDate: newDueDate}}); |                       updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } }); | ||||||
|                       hidePopup(); |                       hidePopup(); | ||||||
|                     }} |                     }} | ||||||
|                     onCancel={() => {}} |                     onCancel={() => {}} | ||||||
| @@ -512,7 +518,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => { | |||||||
|               ); |               ); | ||||||
|             }} |             }} | ||||||
|             onToggleComplete={task => { |             onToggleComplete={task => { | ||||||
|               setTaskComplete({variables: {taskID: task.id, complete: !task.complete}}); |               setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }); | ||||||
|             }} |             }} | ||||||
|             target={quickCardEditor.target} |             target={quickCardEditor.target} | ||||||
|           /> |           /> | ||||||
|   | |||||||
| @@ -5,22 +5,22 @@ import PopupMenu, { Popup, usePopup } from 'shared/components/PopupMenu'; | |||||||
| import MemberManager from 'shared/components/MemberManager'; | import MemberManager from 'shared/components/MemberManager'; | ||||||
| import { useRouteMatch, useHistory } from 'react-router'; | import { useRouteMatch, useHistory } from 'react-router'; | ||||||
| import { | import { | ||||||
|     useDeleteTaskChecklistMutation, |   useDeleteTaskChecklistMutation, | ||||||
|     useUpdateTaskChecklistNameMutation, |   useUpdateTaskChecklistNameMutation, | ||||||
|     useUpdateTaskChecklistItemLocationMutation, |   useUpdateTaskChecklistItemLocationMutation, | ||||||
|     useCreateTaskChecklistMutation, |   useCreateTaskChecklistMutation, | ||||||
|     useFindTaskQuery, |   useFindTaskQuery, | ||||||
|     useUpdateTaskDueDateMutation, |   useUpdateTaskDueDateMutation, | ||||||
|     useSetTaskCompleteMutation, |   useSetTaskCompleteMutation, | ||||||
|     useAssignTaskMutation, |   useAssignTaskMutation, | ||||||
|     useUnassignTaskMutation, |   useUnassignTaskMutation, | ||||||
|     useSetTaskChecklistItemCompleteMutation, |   useSetTaskChecklistItemCompleteMutation, | ||||||
|     useUpdateTaskChecklistLocationMutation, |   useUpdateTaskChecklistLocationMutation, | ||||||
|     useDeleteTaskChecklistItemMutation, |   useDeleteTaskChecklistItemMutation, | ||||||
|     useUpdateTaskChecklistItemNameMutation, |   useUpdateTaskChecklistItemNameMutation, | ||||||
|     useCreateTaskChecklistItemMutation, |   useCreateTaskChecklistItemMutation, | ||||||
|     FindTaskDocument, |   FindTaskDocument, | ||||||
|     FindTaskQuery, |   FindTaskQuery, | ||||||
| } from 'shared/generated/graphql'; | } from 'shared/generated/graphql'; | ||||||
| import UserIDContext from 'App/context'; | import UserIDContext from 'App/context'; | ||||||
| import MiniProfile from 'shared/components/MiniProfile'; | import MiniProfile from 'shared/components/MiniProfile'; | ||||||
| @@ -33,23 +33,23 @@ import { useForm } from 'react-hook-form'; | |||||||
| import updateApolloCache from 'shared/utils/cache'; | import updateApolloCache from 'shared/utils/cache'; | ||||||
|  |  | ||||||
| const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => { | const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => { | ||||||
|     const total = checklists.reduce((prev: any, next: any) => { |   const total = checklists.reduce((prev: any, next: any) => { | ||||||
|         return ( |     return ( | ||||||
|             prev + |       prev + | ||||||
|             next.items.reduce((innerPrev: any, _item: any) => { |       next.items.reduce((innerPrev: any, _item: any) => { | ||||||
|                 return innerPrev + 1; |         return innerPrev + 1; | ||||||
|             }, 0) |       }, 0) | ||||||
|         ); |  | ||||||
|     }, 0); |  | ||||||
|     const complete = checklists.reduce( |  | ||||||
|         (prev: any, next: any) => |  | ||||||
|             prev + |  | ||||||
|             next.items.reduce((innerPrev: any, item: any) => { |  | ||||||
|                 return innerPrev + (item.complete ? 1 : 0); |  | ||||||
|             }, 0), |  | ||||||
|         0, |  | ||||||
|     ); |     ); | ||||||
|     return { total, complete }; |   }, 0); | ||||||
|  |   const complete = checklists.reduce( | ||||||
|  |     (prev: any, next: any) => | ||||||
|  |       prev + | ||||||
|  |       next.items.reduce((innerPrev: any, item: any) => { | ||||||
|  |         return innerPrev + (item.complete ? 1 : 0); | ||||||
|  |       }, 0), | ||||||
|  |     0, | ||||||
|  |   ); | ||||||
|  |   return { total, complete }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const DeleteChecklistButton = styled(Button)` | const DeleteChecklistButton = styled(Button)` | ||||||
| @@ -58,7 +58,7 @@ const DeleteChecklistButton = styled(Button)` | |||||||
|   margin-top: 8px; |   margin-top: 8px; | ||||||
| `; | `; | ||||||
| type CreateChecklistData = { | type CreateChecklistData = { | ||||||
|     name: string; |   name: string; | ||||||
| }; | }; | ||||||
| const CreateChecklistForm = styled.form` | const CreateChecklistForm = styled.form` | ||||||
|   display: flex; |   display: flex; | ||||||
| @@ -80,437 +80,441 @@ const InputError = styled.span` | |||||||
|   font-size: 12px; |   font-size: 12px; | ||||||
| `; | `; | ||||||
| type CreateChecklistPopupProps = { | type CreateChecklistPopupProps = { | ||||||
|     onCreateChecklist: (data: CreateChecklistData) => void; |   onCreateChecklist: (data: CreateChecklistData) => void; | ||||||
| }; | }; | ||||||
| const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => { | const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => { | ||||||
|     const { register, handleSubmit, errors } = useForm<CreateChecklistData>(); |   const { register, handleSubmit, errors } = useForm<CreateChecklistData>(); | ||||||
|     const createUser = (data: CreateChecklistData) => { |   const createUser = (data: CreateChecklistData) => { | ||||||
|         onCreateChecklist(data); |     onCreateChecklist(data); | ||||||
|     }; |   }; | ||||||
|     console.log(errors); |   console.log(errors); | ||||||
|     return ( |   return ( | ||||||
|         <CreateChecklistForm onSubmit={handleSubmit(createUser)}> |     <CreateChecklistForm onSubmit={handleSubmit(createUser)}> | ||||||
|             <CreateChecklistInput |       <CreateChecklistInput | ||||||
|                 floatingLabel |         floatingLabel | ||||||
|                 value="Checklist" |         defaultValue="Checklist" | ||||||
|                 width="100%" |         width="100%" | ||||||
|                 label="Name" |         label="Name" | ||||||
|                 id="name" |         id="name" | ||||||
|                 name="name" |         name="name" | ||||||
|                 variant="alternate" |         variant="alternate" | ||||||
|                 ref={register({ required: 'Checklist name is required' })} |         ref={register({ required: 'Checklist name is required' })} | ||||||
|             /> |       /> | ||||||
|             <CreateChecklistButton type="submit">Create</CreateChecklistButton> |       <CreateChecklistButton type="submit">Create</CreateChecklistButton> | ||||||
|         </CreateChecklistForm> |     </CreateChecklistForm> | ||||||
|     ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type DetailsProps = { | type DetailsProps = { | ||||||
|     taskID: string; |   taskID: string; | ||||||
|     projectURL: string; |   projectURL: string; | ||||||
|     onTaskNameChange: (task: Task, newName: string) => void; |   onTaskNameChange: (task: Task, newName: string) => void; | ||||||
|     onTaskDescriptionChange: (task: Task, newDescription: string) => void; |   onTaskDescriptionChange: (task: Task, newDescription: string) => void; | ||||||
|     onDeleteTask: (task: Task) => void; |   onDeleteTask: (task: Task) => void; | ||||||
|     onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void; |   onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void; | ||||||
|     availableMembers: Array<TaskUser>; |   availableMembers: Array<TaskUser>; | ||||||
|     refreshCache: () => void; |   refreshCache: () => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 }; | const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 }; | ||||||
|  |  | ||||||
| const Details: React.FC<DetailsProps> = ({ | const Details: React.FC<DetailsProps> = ({ | ||||||
|     projectURL, |   projectURL, | ||||||
|     taskID, |   taskID, | ||||||
|     onTaskNameChange, |   onTaskNameChange, | ||||||
|     onTaskDescriptionChange, |   onTaskDescriptionChange, | ||||||
|     onDeleteTask, |   onDeleteTask, | ||||||
|     onOpenAddLabelPopup, |   onOpenAddLabelPopup, | ||||||
|     availableMembers, |   availableMembers, | ||||||
|     refreshCache, |   refreshCache, | ||||||
| }) => { | }) => { | ||||||
|     const { userID } = useContext(UserIDContext); |   const { userID } = useContext(UserIDContext); | ||||||
|     const { showPopup, hidePopup } = usePopup(); |   const { showPopup, hidePopup } = usePopup(); | ||||||
|     const history = useHistory(); |   const history = useHistory(); | ||||||
|     const match = useRouteMatch(); |   const match = useRouteMatch(); | ||||||
|     const [currentMemberTask, setCurrentMemberTask] = useState(''); |   const [currentMemberTask, setCurrentMemberTask] = useState(''); | ||||||
|     const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState); |   const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState); | ||||||
|     const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation(); |   const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation(); | ||||||
|     const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({ |   const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({ | ||||||
|         update: (client, response) => { |     update: (client, response) => { | ||||||
|             updateApolloCache<FindTaskQuery>( |       updateApolloCache<FindTaskQuery>( | ||||||
|                 client, |         client, | ||||||
|                 FindTaskDocument, |         FindTaskDocument, | ||||||
|                 cache => |         cache => | ||||||
|                     produce(cache, draftCache => { |           produce(cache, draftCache => { | ||||||
|                         const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation; |             const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation; | ||||||
|                         console.log(`${checklistID} !== ${prevChecklistID}`); |             console.log(`${checklistID} !== ${prevChecklistID}`); | ||||||
|                         if (checklistID !== prevChecklistID) { |             if (checklistID !== prevChecklistID) { | ||||||
|                             const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID); |               const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID); | ||||||
|                             const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID); |               const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID); | ||||||
|                             console.log(`oldIdx ${oldIdx} newIdx ${newIdx}`); |               console.log(`oldIdx ${oldIdx} newIdx ${newIdx}`); | ||||||
|                             if (oldIdx > -1 && newIdx > -1) { |               if (oldIdx > -1 && newIdx > -1) { | ||||||
|                                 const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id); |                 const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id); | ||||||
|                                 console.log(item); |                 console.log(item); | ||||||
|                                 if (item) { |                 if (item) { | ||||||
|                                     draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter( |                   draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter( | ||||||
|                                         i => i.id !== checklistItem.id, |                     i => i.id !== checklistItem.id, | ||||||
|                                     ); |                   ); | ||||||
|                                     draftCache.findTask.checklists[newIdx].items.push({ |                   draftCache.findTask.checklists[newIdx].items.push({ | ||||||
|                                         ...item, |                     ...item, | ||||||
|                                         position: checklistItem.position, |                     position: checklistItem.position, | ||||||
|                                         taskChecklistID: checklistID, |                     taskChecklistID: checklistID, | ||||||
|                                     }); |                   }); | ||||||
|                                 } |                 } | ||||||
|                             } |               } | ||||||
|                         } |             } | ||||||
|                     }), |           }), | ||||||
|                 { taskID }, |         { taskID }, | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |   const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({ | ||||||
|  |     update: client => { | ||||||
|  |       updateApolloCache<FindTaskQuery>( | ||||||
|  |         client, | ||||||
|  |         FindTaskDocument, | ||||||
|  |         cache => | ||||||
|  |           produce(cache, draftCache => { | ||||||
|  |             const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); | ||||||
|  |             draftCache.findTask.badges.checklist = { | ||||||
|  |               __typename: 'ChecklistBadge', | ||||||
|  |               complete, | ||||||
|  |               total, | ||||||
|  |             }; | ||||||
|  |           }), | ||||||
|  |         { taskID }, | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |   const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({ | ||||||
|  |     update: (client, deleteData) => { | ||||||
|  |       updateApolloCache<FindTaskQuery>( | ||||||
|  |         client, | ||||||
|  |         FindTaskDocument, | ||||||
|  |         cache => | ||||||
|  |           produce(cache, draftCache => { | ||||||
|  |             const { checklists } = cache.findTask; | ||||||
|  |             console.log(deleteData); | ||||||
|  |             draftCache.findTask.checklists = checklists.filter( | ||||||
|  |               c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id, | ||||||
|             ); |             ); | ||||||
|         }, |             const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); | ||||||
|     }); |             draftCache.findTask.badges.checklist = { | ||||||
|     const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({ |               __typename: 'ChecklistBadge', | ||||||
|         update: client => { |               complete, | ||||||
|             updateApolloCache<FindTaskQuery>( |               total, | ||||||
|                 client, |             }; | ||||||
|                 FindTaskDocument, |             if (complete === 0 && total === 0) { | ||||||
|                 cache => |               draftCache.findTask.badges.checklist = null; | ||||||
|                     produce(cache, draftCache => { |             } | ||||||
|                         const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); |           }), | ||||||
|                         draftCache.findTask.badges.checklist = { |         { taskID }, | ||||||
|                             __typename: 'ChecklistBadge', |       ); | ||||||
|                             complete, |     }, | ||||||
|                             total, |   }); | ||||||
|                         }; |   const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation(); | ||||||
|                     }), |   const [createTaskChecklist] = useCreateTaskChecklistMutation({ | ||||||
|                 { taskID }, |     update: (client, createData) => { | ||||||
|             ); |       updateApolloCache<FindTaskQuery>( | ||||||
|         }, |         client, | ||||||
|     }); |         FindTaskDocument, | ||||||
|     const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({ |         cache => | ||||||
|         update: (client, deleteData) => { |           produce(cache, draftCache => { | ||||||
|             updateApolloCache<FindTaskQuery>( |             const item = createData.data.createTaskChecklist; | ||||||
|                 client, |             draftCache.findTask.checklists.push({ ...item }); | ||||||
|                 FindTaskDocument, |           }), | ||||||
|                 cache => |         { taskID }, | ||||||
|                     produce(cache, draftCache => { |       ); | ||||||
|                         const { checklists } = cache.findTask; |     }, | ||||||
|                         console.log(deleteData) |   }); | ||||||
|                         draftCache.findTask.checklists = checklists.filter(c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id); |   const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation(); | ||||||
|                         const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); |   const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({ | ||||||
|                         draftCache.findTask.badges.checklist = { |     update: (client, deleteData) => { | ||||||
|                             __typename: 'ChecklistBadge', |       updateApolloCache<FindTaskQuery>( | ||||||
|                             complete, |         client, | ||||||
|                             total, |         FindTaskDocument, | ||||||
|                         }; |         cache => | ||||||
|                         if (complete === 0 && total === 0) { |           produce(cache, draftCache => { | ||||||
|                             draftCache.findTask.badges.checklist = null; |             const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem; | ||||||
|                         } |             const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID); | ||||||
|                     }), |             if (targetIdx > -1) { | ||||||
|                 { taskID }, |               draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter( | ||||||
|             ); |                 c => item.id !== c.id, | ||||||
|         }, |               ); | ||||||
|     }); |             } | ||||||
|     const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation(); |             const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); | ||||||
|     const [createTaskChecklist] = useCreateTaskChecklistMutation({ |             draftCache.findTask.badges.checklist = { | ||||||
|         update: (client, createData) => { |               __typename: 'ChecklistBadge', | ||||||
|             updateApolloCache<FindTaskQuery>( |               complete, | ||||||
|                 client, |               total, | ||||||
|                 FindTaskDocument, |             }; | ||||||
|                 cache => |           }), | ||||||
|                     produce(cache, draftCache => { |         { taskID }, | ||||||
|                         const item = createData.data.createTaskChecklist; |       ); | ||||||
|                         draftCache.findTask.checklists.push({ ...item }); |     }, | ||||||
|                     }), |   }); | ||||||
|                 { taskID }, |   const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({ | ||||||
|             ); |     update: (client, newTaskItem) => { | ||||||
|         }, |       updateApolloCache<FindTaskQuery>( | ||||||
|     }); |         client, | ||||||
|     const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation(); |         FindTaskDocument, | ||||||
|     const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({ |         cache => | ||||||
|         update: (client, deleteData) => { |           produce(cache, draftCache => { | ||||||
|             updateApolloCache<FindTaskQuery>( |             const item = newTaskItem.data.createTaskChecklistItem; | ||||||
|                 client, |             const { checklists } = cache.findTask; | ||||||
|                 FindTaskDocument, |             const idx = checklists.findIndex(c => c.id === item.taskChecklistID); | ||||||
|                 cache => |             if (idx !== -1) { | ||||||
|                     produce(cache, draftCache => { |               draftCache.findTask.checklists[idx].items.push({ ...item }); | ||||||
|                         const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem; |               const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); | ||||||
|                         const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID) |               draftCache.findTask.badges.checklist = { | ||||||
|                         if (targetIdx > -1) { |                 __typename: 'ChecklistBadge', | ||||||
|                             draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(c => item.id !== c.id); |                 complete, | ||||||
|                         } |                 total, | ||||||
|                         const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); |               }; | ||||||
|                         draftCache.findTask.badges.checklist = { |             } | ||||||
|                             __typename: 'ChecklistBadge', |           }), | ||||||
|                             complete, |         { taskID }, | ||||||
|                             total, |       ); | ||||||
|                         }; |     }, | ||||||
|                     }), |   }); | ||||||
|                 { taskID }, |   const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } }); | ||||||
|             ); |   const [setTaskComplete] = useSetTaskCompleteMutation(); | ||||||
|         }, |   const [updateTaskDueDate] = useUpdateTaskDueDateMutation({ | ||||||
|     }); |     onCompleted: () => { | ||||||
|     const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({ |       refetch(); | ||||||
|         update: (client, newTaskItem) => { |       refreshCache(); | ||||||
|             updateApolloCache<FindTaskQuery>( |     }, | ||||||
|                 client, |   }); | ||||||
|                 FindTaskDocument, |   const [assignTask] = useAssignTaskMutation({ | ||||||
|                 cache => |     onCompleted: () => { | ||||||
|                     produce(cache, draftCache => { |       refetch(); | ||||||
|                         const item = newTaskItem.data.createTaskChecklistItem; |       refreshCache(); | ||||||
|                         const { checklists } = cache.findTask; |     }, | ||||||
|                         const idx = checklists.findIndex(c => c.id === item.taskChecklistID); |   }); | ||||||
|                         if (idx !== -1) { |   const [unassignTask] = useUnassignTaskMutation({ | ||||||
|                             draftCache.findTask.checklists[idx].items.push({ ...item }); |     onCompleted: () => { | ||||||
|                             const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); |       refetch(); | ||||||
|                             draftCache.findTask.badges.checklist = { |       refreshCache(); | ||||||
|                                 __typename: 'ChecklistBadge', |     }, | ||||||
|                                 complete, |   }); | ||||||
|                                 total, |   if (loading) { | ||||||
|                             }; |     return <div>loading</div>; | ||||||
|                         } |   } | ||||||
|                     }), |   if (!data) { | ||||||
|                 { taskID }, |     return <div>loading</div>; | ||||||
|             ); |   } | ||||||
|         }, |   return ( | ||||||
|     }); |     <> | ||||||
|     const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } }); |       <Modal | ||||||
|     const [setTaskComplete] = useSetTaskCompleteMutation(); |         width={768} | ||||||
|     const [updateTaskDueDate] = useUpdateTaskDueDateMutation({ |         onClose={() => { | ||||||
|         onCompleted: () => { |           history.push(projectURL); | ||||||
|             refetch(); |         }} | ||||||
|             refreshCache(); |         renderContent={() => { | ||||||
|         }, |           return ( | ||||||
|     }); |             <TaskDetails | ||||||
|     const [assignTask] = useAssignTaskMutation({ |               task={data.findTask} | ||||||
|         onCompleted: () => { |               onChecklistDrop={checklist => { | ||||||
|             refetch(); |                 updateTaskChecklistLocation({ | ||||||
|             refreshCache(); |                   variables: { checklistID: checklist.id, position: checklist.position }, | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
|     const [unassignTask] = useUnassignTaskMutation({ |  | ||||||
|         onCompleted: () => { |  | ||||||
|             refetch(); |  | ||||||
|             refreshCache(); |  | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
|     if (loading) { |  | ||||||
|         return <div>loading</div>; |  | ||||||
|     } |  | ||||||
|     if (!data) { |  | ||||||
|         return <div>loading</div>; |  | ||||||
|     } |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <Modal |  | ||||||
|                 width={768} |  | ||||||
|                 onClose={() => { |  | ||||||
|                     history.push(projectURL); |  | ||||||
|                 }} |  | ||||||
|                 renderContent={() => { |  | ||||||
|                     return ( |  | ||||||
|                         <TaskDetails |  | ||||||
|                             task={data.findTask} |  | ||||||
|                             onChecklistDrop={checklist => { |  | ||||||
|                                 updateTaskChecklistLocation({ |  | ||||||
|                                     variables: { checklistID: checklist.id, position: checklist.position }, |  | ||||||
|  |  | ||||||
|                                     optimisticResponse: { |                   optimisticResponse: { | ||||||
|                                         __typename: 'Mutation', |                     __typename: 'Mutation', | ||||||
|                                         updateTaskChecklistLocation: { |                     updateTaskChecklistLocation: { | ||||||
|                                             __typename: 'UpdateTaskChecklistLocationPayload', |                       __typename: 'UpdateTaskChecklistLocationPayload', | ||||||
|                                             checklist: { |                       checklist: { | ||||||
|                                                 __typename: 'TaskChecklist', |                         __typename: 'TaskChecklist', | ||||||
|                                                 position: checklist.position, |                         position: checklist.position, | ||||||
|                                                 id: checklist.id, |                         id: checklist.id, | ||||||
|                                             }, |                       }, | ||||||
|                                         }, |                     }, | ||||||
|                                     }, |                   }, | ||||||
|                                 }); |                 }); | ||||||
|                             }} |               }} | ||||||
|                             onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => { |               onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => { | ||||||
|                                 updateTaskChecklistItemLocation({ |                 updateTaskChecklistItemLocation({ | ||||||
|                                     variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position }, |                   variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position }, | ||||||
|  |  | ||||||
|                                     optimisticResponse: { |                   optimisticResponse: { | ||||||
|                                         __typename: 'Mutation', |                     __typename: 'Mutation', | ||||||
|                                         updateTaskChecklistItemLocation: { |                     updateTaskChecklistItemLocation: { | ||||||
|                                             __typename: 'UpdateTaskChecklistItemLocationPayload', |                       __typename: 'UpdateTaskChecklistItemLocationPayload', | ||||||
|                                             prevChecklistID, |                       prevChecklistID, | ||||||
|                                             checklistID, |                       checklistID, | ||||||
|                                             checklistItem: { |                       checklistItem: { | ||||||
|                                                 __typename: 'TaskChecklistItem', |                         __typename: 'TaskChecklistItem', | ||||||
|                                                 position: checklistItem.position, |                         position: checklistItem.position, | ||||||
|                                                 id: checklistItem.id, |                         id: checklistItem.id, | ||||||
|                                                 taskChecklistID: checklistID, |                         taskChecklistID: checklistID, | ||||||
|                                             }, |                       }, | ||||||
|                                         }, |                     }, | ||||||
|                                     }, |                   }, | ||||||
|                                 }); |                 }); | ||||||
|                             }} |               }} | ||||||
|                             onTaskNameChange={onTaskNameChange} |               onTaskNameChange={onTaskNameChange} | ||||||
|                             onTaskDescriptionChange={onTaskDescriptionChange} |               onTaskDescriptionChange={onTaskDescriptionChange} | ||||||
|                             onToggleTaskComplete={task => { |               onToggleTaskComplete={task => { | ||||||
|                                 setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }); |                 setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }); | ||||||
|                             }} |               }} | ||||||
|                             onDeleteTask={onDeleteTask} |               onDeleteTask={onDeleteTask} | ||||||
|                             onChangeItemName={(itemID, itemName) => { |               onChangeItemName={(itemID, itemName) => { | ||||||
|                                 updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } }); |                 updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } }); | ||||||
|                             }} |               }} | ||||||
|                             onCloseModal={() => history.push(projectURL)} |               onCloseModal={() => history.push(projectURL)} | ||||||
|                             onChangeChecklistName={(checklistID, newName) => { |               onChangeChecklistName={(checklistID, newName) => { | ||||||
|                                 updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } }); |                 updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } }); | ||||||
|                             }} |               }} | ||||||
|                             onDeleteItem={(checklistID, itemID) => { |               onDeleteItem={(checklistID, itemID) => { | ||||||
|                                 deleteTaskChecklistItem({ |                 deleteTaskChecklistItem({ | ||||||
|                                     variables: { taskChecklistItemID: itemID }, |                   variables: { taskChecklistItemID: itemID }, | ||||||
|                                     optimisticResponse: { |                   optimisticResponse: { | ||||||
|                                         __typename: 'Mutation', |                     __typename: 'Mutation', | ||||||
|                                         deleteTaskChecklistItem: { |                     deleteTaskChecklistItem: { | ||||||
|                                             __typename: 'DeleteTaskChecklistItemPayload', |                       __typename: 'DeleteTaskChecklistItemPayload', | ||||||
|                                             ok: true, |                       ok: true, | ||||||
|                                             taskChecklistItem: { |                       taskChecklistItem: { | ||||||
|                                                 __typename: 'TaskChecklistItem', |                         __typename: 'TaskChecklistItem', | ||||||
|                                                 id: itemID, |                         id: itemID, | ||||||
|                                                 taskChecklistID: checklistID, |                         taskChecklistID: checklistID, | ||||||
|                                             } |                       }, | ||||||
|                                         } |                     }, | ||||||
|                                     } |                   }, | ||||||
|                                 }); |                 }); | ||||||
|                             }} |               }} | ||||||
|                             onToggleChecklistItem={(itemID, complete) => { |               onToggleChecklistItem={(itemID, complete) => { | ||||||
|                                 setTaskChecklistItemComplete({ |                 setTaskChecklistItemComplete({ | ||||||
|                                     variables: { taskChecklistItemID: itemID, complete }, |                   variables: { taskChecklistItemID: itemID, complete }, | ||||||
|                                     optimisticResponse: { |                   optimisticResponse: { | ||||||
|                                         __typename: 'Mutation', |                     __typename: 'Mutation', | ||||||
|                                         setTaskChecklistItemComplete: { |                     setTaskChecklistItemComplete: { | ||||||
|                                             __typename: 'TaskChecklistItem', |                       __typename: 'TaskChecklistItem', | ||||||
|                                             id: itemID, |                       id: itemID, | ||||||
|                                             complete, |                       complete, | ||||||
|                                         }, |                     }, | ||||||
|                                     }, |                   }, | ||||||
|                                 }); |                 }); | ||||||
|                             }} |               }} | ||||||
|                             onAddItem={(taskChecklistID, name, position) => { |               onAddItem={(taskChecklistID, name, position) => { | ||||||
|                                 createTaskChecklistItem({ variables: { taskChecklistID, name, position } }); |                 createTaskChecklistItem({ variables: { taskChecklistID, name, position } }); | ||||||
|                             }} |               }} | ||||||
|                             onMemberProfile={($targetRef, memberID) => { |               onMemberProfile={($targetRef, memberID) => { | ||||||
|                                 const member = data.findTask.assigned.find(m => m.id === memberID); |                 const member = data.findTask.assigned.find(m => m.id === memberID); | ||||||
|                                 if (member) { |                 if (member) { | ||||||
|                                     showPopup( |                   showPopup( | ||||||
|                                         $targetRef, |                     $targetRef, | ||||||
|                                         <Popup title={null} onClose={() => { }} tab={0}> |                     <Popup title={null} onClose={() => {}} tab={0}> | ||||||
|                                             <MiniProfile |                       <MiniProfile | ||||||
|                                                 user={member} |                         user={member} | ||||||
|                                                 bio="None" |                         bio="None" | ||||||
|                                                 onRemoveFromTask={() => { |                         onRemoveFromTask={() => { | ||||||
|                                                     unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); |                           unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); | ||||||
|                                                 }} |                         }} | ||||||
|                                             /> |                       /> | ||||||
|                                         </Popup>, |                     </Popup>, | ||||||
|                                     ); |                   ); | ||||||
|                                 } |                 } | ||||||
|                             }} |               }} | ||||||
|                             onOpenAddMemberPopup={(task, $targetRef) => { |               onOpenAddMemberPopup={(task, $targetRef) => { | ||||||
|                                 showPopup( |                 showPopup( | ||||||
|                                     $targetRef, |                   $targetRef, | ||||||
|                                     <Popup title="Members" tab={0} onClose={() => { }}> |                   <Popup title="Members" tab={0} onClose={() => {}}> | ||||||
|                                         <MemberManager |                     <MemberManager | ||||||
|                                             availableMembers={availableMembers} |                       availableMembers={availableMembers} | ||||||
|                                             activeMembers={data.findTask.assigned} |                       activeMembers={data.findTask.assigned} | ||||||
|                                             onMemberChange={(member, isActive) => { |                       onMemberChange={(member, isActive) => { | ||||||
|                                                 if (isActive) { |                         if (isActive) { | ||||||
|                                                     assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); |                           assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); | ||||||
|                                                 } else { |                         } else { | ||||||
|                                                     unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); |                           unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); | ||||||
|                                                 } |                         } | ||||||
|                                             }} |                       }} | ||||||
|                                         /> |                     /> | ||||||
|                                     </Popup>, |                   </Popup>, | ||||||
|                                 ); |                 ); | ||||||
|                             }} |               }} | ||||||
|                             onOpenAddLabelPopup={onOpenAddLabelPopup} |               onOpenAddLabelPopup={onOpenAddLabelPopup} | ||||||
|                             onOpenAddChecklistPopup={(_task, $target) => { |               onOpenAddChecklistPopup={(_task, $target) => { | ||||||
|                                 showPopup( |                 showPopup( | ||||||
|                                     $target, |                   $target, | ||||||
|                                     <Popup |                   <Popup | ||||||
|                                         title={'Add checklist'} |                     title={'Add checklist'} | ||||||
|                                         tab={0} |                     tab={0} | ||||||
|                                         onClose={() => { |                     onClose={() => { | ||||||
|                                             hidePopup(); |                       hidePopup(); | ||||||
|                                         }} |                     }} | ||||||
|                                     > |                   > | ||||||
|                                         <CreateChecklistPopup |                     <CreateChecklistPopup | ||||||
|                                             onCreateChecklist={checklistData => { |                       onCreateChecklist={checklistData => { | ||||||
|                                                 let position = 65535; |                         let position = 65535; | ||||||
|                                                 console.log(data.findTask.checklists); |                         console.log(data.findTask.checklists); | ||||||
|                                                 if (data.findTask.checklists) { |                         if (data.findTask.checklists) { | ||||||
|                                                     const [lastChecklist] = data.findTask.checklists.slice(-1); |                           const [lastChecklist] = data.findTask.checklists.slice(-1); | ||||||
|                                                     console.log(`lastCheclist ${lastChecklist}`); |                           console.log(`lastCheclist ${lastChecklist}`); | ||||||
|                                                     if (lastChecklist) { |                           if (lastChecklist) { | ||||||
|                                                         position = lastChecklist.position * 2 + 1; |                             position = lastChecklist.position * 2 + 1; | ||||||
|                                                     } |                           } | ||||||
|                                                 } |                         } | ||||||
|                                                 createTaskChecklist({ |                         createTaskChecklist({ | ||||||
|                                                     variables: { |                           variables: { | ||||||
|                                                         taskID: data.findTask.id, |                             taskID: data.findTask.id, | ||||||
|                                                         name: checklistData.name, |                             name: checklistData.name, | ||||||
|                                                         position, |                             position, | ||||||
|                                                     }, |                           }, | ||||||
|                                                 }); |                         }); | ||||||
|                                                 hidePopup(); |                         hidePopup(); | ||||||
|                                             }} |                       }} | ||||||
|                                         /> |                     /> | ||||||
|                                     </Popup>, |                   </Popup>, | ||||||
|                                 ); |                 ); | ||||||
|                             }} |               }} | ||||||
|                             onDeleteChecklist={($target, checklistID) => { |               onDeleteChecklist={($target, checklistID) => { | ||||||
|                                 showPopup( |                 showPopup( | ||||||
|                                     $target, |                   $target, | ||||||
|                                     <Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}> |                   <Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}> | ||||||
|                                         <p>Deleting a checklist is permanent and there is no way to get it back.</p> |                     <p>Deleting a checklist is permanent and there is no way to get it back.</p> | ||||||
|                                         <DeleteChecklistButton |                     <DeleteChecklistButton | ||||||
|                                             color="danger" |                       color="danger" | ||||||
|                                             onClick={() => { |                       onClick={() => { | ||||||
|                                                 deleteTaskChecklist({ variables: { taskChecklistID: checklistID } }); |                         deleteTaskChecklist({ variables: { taskChecklistID: checklistID } }); | ||||||
|                                                 hidePopup(); |                         hidePopup(); | ||||||
|                                             }} |                       }} | ||||||
|                                         > |                     > | ||||||
|                                             Delete Checklist |                       Delete Checklist | ||||||
|                     </DeleteChecklistButton> |                     </DeleteChecklistButton> | ||||||
|                                     </Popup>, |                   </Popup>, | ||||||
|                                 ); |                 ); | ||||||
|                             }} |               }} | ||||||
|                             onOpenDueDatePopop={(task, $targetRef) => { |               onOpenDueDatePopop={(task, $targetRef) => { | ||||||
|                                 showPopup( |                 showPopup( | ||||||
|                                     $targetRef, |                   $targetRef, | ||||||
|                                     <Popup |                   <Popup | ||||||
|                                         title={'Change Due Date'} |                     title={'Change Due Date'} | ||||||
|                                         tab={0} |                     tab={0} | ||||||
|                                         onClose={() => { |                     onClose={() => { | ||||||
|                                             hidePopup(); |                       hidePopup(); | ||||||
|                                         }} |                     }} | ||||||
|                                     > |                   > | ||||||
|                                         <DueDateManager |                     <DueDateManager | ||||||
|                                             task={task} |                       task={task} | ||||||
|                                             onRemoveDueDate={t => { |                       onRemoveDueDate={t => { | ||||||
|                                                 updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } }); |                         updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } }); | ||||||
|                                                 hidePopup(); |                         hidePopup(); | ||||||
|                                             }} |                       }} | ||||||
|                                             onDueDateChange={(t, newDueDate) => { |                       onDueDateChange={(t, newDueDate) => { | ||||||
|                                                 updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } }); |                         updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } }); | ||||||
|                                                 hidePopup(); |                         hidePopup(); | ||||||
|                                             }} |                       }} | ||||||
|                                             onCancel={() => { }} |                       onCancel={() => {}} | ||||||
|                                         /> |                     /> | ||||||
|                                     </Popup>, |                   </Popup>, | ||||||
|                                 ); |                 ); | ||||||
|                             }} |               }} | ||||||
|                         /> |  | ||||||
|                     ); |  | ||||||
|                 }} |  | ||||||
|             /> |             /> | ||||||
|         </> |           ); | ||||||
|     ); |         }} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default Details; | export default Details; | ||||||
|   | |||||||
| @@ -1,11 +1,19 @@ | |||||||
| // LOC830 | // LOC830 | ||||||
| import React, {useState, useRef, useEffect, useContext} from 'react'; | import React, { useState, useRef, useEffect, useContext } from 'react'; | ||||||
| import updateApolloCache from 'shared/utils/cache'; | import updateApolloCache from 'shared/utils/cache'; | ||||||
| import GlobalTopNavbar, {ProjectPopup} from 'App/TopNavbar'; | import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar'; | ||||||
| import styled from 'styled-components/macro'; | import styled from 'styled-components/macro'; | ||||||
| import {usePopup, Popup} from 'shared/components/PopupMenu'; | import { usePopup, Popup } from 'shared/components/PopupMenu'; | ||||||
| import LabelManagerEditor from './LabelManagerEditor' | import LabelManagerEditor from './LabelManagerEditor'; | ||||||
| import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation, Redirect} from 'react-router-dom'; | import { | ||||||
|  |   useParams, | ||||||
|  |   Route, | ||||||
|  |   useRouteMatch, | ||||||
|  |   useHistory, | ||||||
|  |   RouteComponentProps, | ||||||
|  |   useLocation, | ||||||
|  |   Redirect, | ||||||
|  | } from 'react-router-dom'; | ||||||
| import { | import { | ||||||
|   useSetProjectOwnerMutation, |   useSetProjectOwnerMutation, | ||||||
|   useUpdateProjectMemberRoleMutation, |   useUpdateProjectMemberRoleMutation, | ||||||
| @@ -29,8 +37,20 @@ import produce from 'immer'; | |||||||
| import UserIDContext from 'App/context'; | import UserIDContext from 'App/context'; | ||||||
| import Input from 'shared/components/Input'; | import Input from 'shared/components/Input'; | ||||||
| import Member from 'shared/components/Member'; | import Member from 'shared/components/Member'; | ||||||
| import Board from './Board' | import Board from './Board'; | ||||||
| import Details from './Details' | import Details from './Details'; | ||||||
|  |  | ||||||
|  | const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant'; | ||||||
|  |  | ||||||
|  | const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => { | ||||||
|  |   const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || ''); | ||||||
|  |  | ||||||
|  |   React.useEffect(() => { | ||||||
|  |     localStorage.setItem(localStorageKey, value); | ||||||
|  |   }, [value]); | ||||||
|  |  | ||||||
|  |   return [value, setValue]; | ||||||
|  | }; | ||||||
|  |  | ||||||
| const SearchInput = styled(Input)` | const SearchInput = styled(Input)` | ||||||
|   margin: 0; |   margin: 0; | ||||||
| @@ -55,7 +75,7 @@ type UserManagementPopupProps = { | |||||||
|   onAddProjectMember: (userID: string) => void; |   onAddProjectMember: (userID: string) => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const UserManagementPopup: React.FC<UserManagementPopupProps> = ({users, projectMembers, onAddProjectMember}) => { | const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => { | ||||||
|   return ( |   return ( | ||||||
|     <Popup tab={0} title="Invite a user"> |     <Popup tab={0} title="Invite a user"> | ||||||
|       <SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" /> |       <SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" /> | ||||||
| @@ -99,7 +119,7 @@ const initialQuickCardEditorState: QuickCardEditorState = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| const Project = () => { | const Project = () => { | ||||||
|   const {projectID} = useParams<ProjectParams>(); |   const { projectID } = useParams<ProjectParams>(); | ||||||
|   const history = useHistory(); |   const history = useHistory(); | ||||||
|   const match = useRouteMatch(); |   const match = useRouteMatch(); | ||||||
|  |  | ||||||
| @@ -110,14 +130,16 @@ const Project = () => { | |||||||
|       console.log(taskLabelsRef.current); |       console.log(taskLabelsRef.current); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY); | ||||||
|   const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation(); |   const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation(); | ||||||
|  |  | ||||||
|   const [deleteTask] = useDeleteTaskMutation(); |   const [deleteTask] = useDeleteTaskMutation(); | ||||||
|  |  | ||||||
|   const [updateTaskName] = useUpdateTaskNameMutation(); |   const [updateTaskName] = useUpdateTaskNameMutation(); | ||||||
|  |  | ||||||
|   const {loading, data} = useFindProjectQuery({ |   const { loading, data } = useFindProjectQuery({ | ||||||
|     variables: {projectId: projectID}, |     variables: { projectId: projectID }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const [updateProjectName] = useUpdateProjectNameMutation({ |   const [updateProjectName] = useUpdateProjectNameMutation({ | ||||||
| @@ -129,7 +151,7 @@ const Project = () => { | |||||||
|           produce(cache, draftCache => { |           produce(cache, draftCache => { | ||||||
|             draftCache.findProject.name = newName.data.updateProjectName.name; |             draftCache.findProject.name = newName.data.updateProjectName.name; | ||||||
|           }), |           }), | ||||||
|         {projectId: projectID}, |         { projectId: projectID }, | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| @@ -141,9 +163,9 @@ const Project = () => { | |||||||
|         FindProjectDocument, |         FindProjectDocument, | ||||||
|         cache => |         cache => | ||||||
|           produce(cache, draftCache => { |           produce(cache, draftCache => { | ||||||
|             draftCache.findProject.members.push({...response.data.createProjectMember.member}); |             draftCache.findProject.members.push({ ...response.data.createProjectMember.member }); | ||||||
|           }), |           }), | ||||||
|         {projectId: projectID}, |         { projectId: projectID }, | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| @@ -159,15 +181,15 @@ const Project = () => { | |||||||
|               m => m.id !== response.data.deleteProjectMember.member.id, |               m => m.id !== response.data.deleteProjectMember.member.id, | ||||||
|             ); |             ); | ||||||
|           }), |           }), | ||||||
|         {projectId: projectID}, |         { projectId: projectID }, | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const {userID} = useContext(UserIDContext); |   const { userID } = useContext(UserIDContext); | ||||||
|   const location = useLocation(); |   const location = useLocation(); | ||||||
|  |  | ||||||
|   const {showPopup, hidePopup} = usePopup(); |   const { showPopup, hidePopup } = usePopup(); | ||||||
|   const $labelsRef = useRef<HTMLDivElement>(null); |   const $labelsRef = useRef<HTMLDivElement>(null); | ||||||
|   const labelsRef = useRef<Array<ProjectLabel>>([]); |   const labelsRef = useRef<Array<ProjectLabel>>([]); | ||||||
|   const taskLabelsRef = useRef<Array<TaskLabel>>([]); |   const taskLabelsRef = useRef<Array<TaskLabel>>([]); | ||||||
| @@ -192,25 +214,25 @@ const Project = () => { | |||||||
|       <> |       <> | ||||||
|         <GlobalTopNavbar |         <GlobalTopNavbar | ||||||
|           onChangeRole={(userID, roleCode) => { |           onChangeRole={(userID, roleCode) => { | ||||||
|             updateProjectMemberRole({variables: {userID, roleCode, projectID}}); |             updateProjectMemberRole({ variables: { userID, roleCode, projectID } }); | ||||||
|           }} |           }} | ||||||
|           onChangeProjectOwner={uid => { |           onChangeProjectOwner={uid => { | ||||||
|             setProjectOwner({variables: {ownerID: uid, projectID}}); |             setProjectOwner({ variables: { ownerID: uid, projectID } }); | ||||||
|             hidePopup(); |             hidePopup(); | ||||||
|           }} |           }} | ||||||
|           onRemoveFromBoard={userID => { |           onRemoveFromBoard={userID => { | ||||||
|             deleteProjectMember({variables: {userID, projectID}}); |             deleteProjectMember({ variables: { userID, projectID } }); | ||||||
|             hidePopup(); |             hidePopup(); | ||||||
|           }} |           }} | ||||||
|           onSaveProjectName={projectName => { |           onSaveProjectName={projectName => { | ||||||
|             updateProjectName({variables: {projectID, name: projectName}}); |             updateProjectName({ variables: { projectID, name: projectName } }); | ||||||
|           }} |           }} | ||||||
|           onInviteUser={$target => { |           onInviteUser={$target => { | ||||||
|             showPopup( |             showPopup( | ||||||
|               $target, |               $target, | ||||||
|               <UserManagementPopup |               <UserManagementPopup | ||||||
|                 onAddProjectMember={userID => { |                 onAddProjectMember={userID => { | ||||||
|                   createProjectMember({variables: {userID, projectID}}); |                   createProjectMember({ variables: { userID, projectID } }); | ||||||
|                 }} |                 }} | ||||||
|                 users={data.users} |                 users={data.users} | ||||||
|                 projectMembers={data.findProject.members} |                 projectMembers={data.findProject.members} | ||||||
| @@ -218,23 +240,24 @@ const Project = () => { | |||||||
|             ); |             ); | ||||||
|           }} |           }} | ||||||
|           popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />} |           popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />} | ||||||
|           menuType={[{name: 'Board', link: location.pathname}]} |           menuType={[{ name: 'Board', link: location.pathname }]} | ||||||
|           currentTab={0} |           currentTab={0} | ||||||
|           projectMembers={data.findProject.members} |           projectMembers={data.findProject.members} | ||||||
|           projectID={projectID} |           projectID={projectID} | ||||||
|           name={data.findProject.name} |           name={data.findProject.name} | ||||||
|         /> |         /> | ||||||
|         <Route |         <Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} /> | ||||||
|           path={`${match.path}`} |  | ||||||
|           exact |  | ||||||
|           render={() => ( |  | ||||||
|             <Redirect to={`${match.url}/board`} /> |  | ||||||
|           )} |  | ||||||
|         /> |  | ||||||
|         <Route |         <Route | ||||||
|           path={`${match.path}/board`} |           path={`${match.path}/board`} | ||||||
|           render={() => ( |           render={() => ( | ||||||
|             <Board projectID={projectID} /> |             <Board | ||||||
|  |               cardLabelVariant={value === 'small' ? 'large' : 'small'} | ||||||
|  |               onCardLabelClick={() => { | ||||||
|  |                 const variant = value === 'small' ? 'large' : 'small'; | ||||||
|  |                 setValue(() => variant); | ||||||
|  |               }} | ||||||
|  |               projectID={projectID} | ||||||
|  |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|         <Route |         <Route | ||||||
| @@ -246,13 +269,13 @@ const Project = () => { | |||||||
|               projectURL={`${match.url}/board`} |               projectURL={`${match.url}/board`} | ||||||
|               taskID={routeProps.match.params.taskID} |               taskID={routeProps.match.params.taskID} | ||||||
|               onTaskNameChange={(updatedTask, newName) => { |               onTaskNameChange={(updatedTask, newName) => { | ||||||
|                 updateTaskName({variables: {taskID: updatedTask.id, name: newName}}); |                 updateTaskName({ variables: { taskID: updatedTask.id, name: newName } }); | ||||||
|               }} |               }} | ||||||
|               onTaskDescriptionChange={(updatedTask, newDescription) => { |               onTaskDescriptionChange={(updatedTask, newDescription) => { | ||||||
|                 updateTaskDescription({variables: {taskID: updatedTask.id, description: newDescription}}); |                 updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } }); | ||||||
|               }} |               }} | ||||||
|               onDeleteTask={deletedTask => { |               onDeleteTask={deletedTask => { | ||||||
|                 deleteTask({variables: {taskID: deletedTask.id}}); |                 deleteTask({ variables: { taskID: deletedTask.id } }); | ||||||
|               }} |               }} | ||||||
|               onOpenAddLabelPopup={(task, $targetRef) => { |               onOpenAddLabelPopup={(task, $targetRef) => { | ||||||
|                 taskLabelsRef.current = task.labels; |                 taskLabelsRef.current = task.labels; | ||||||
| @@ -260,7 +283,7 @@ const Project = () => { | |||||||
|                   $targetRef, |                   $targetRef, | ||||||
|                   <LabelManagerEditor |                   <LabelManagerEditor | ||||||
|                     onLabelToggle={labelID => { |                     onLabelToggle={labelID => { | ||||||
|                       toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}}); |                       toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } }); | ||||||
|                     }} |                     }} | ||||||
|                     labelColors={data.labelColors} |                     labelColors={data.labelColors} | ||||||
|                     labels={labelsRef} |                     labels={labelsRef} | ||||||
|   | |||||||
							
								
								
									
										30
									
								
								frontend/src/citadel.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										30
									
								
								frontend/src/citadel.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -47,6 +47,7 @@ type TaskUser = { | |||||||
|  |  | ||||||
| type RefreshTokenResponse = { | type RefreshTokenResponse = { | ||||||
|   accessToken: string; |   accessToken: string; | ||||||
|  |   isInstalled: boolean; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type LoginFormData = { | type LoginFormData = { | ||||||
| @@ -54,16 +55,41 @@ type LoginFormData = { | |||||||
|   password: string; |   password: string; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | type RegisterFormData = { | ||||||
|  |   username: string; | ||||||
|  |   fullname: string; | ||||||
|  |   email: string; | ||||||
|  |   password: string; | ||||||
|  |   password_confirm: string; | ||||||
|  |   initials: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
| type DueDateFormData = { | type DueDateFormData = { | ||||||
|   endDate: string; |   endDate: string; | ||||||
|   endTime: string; |   endTime: string; | ||||||
| }; | }; | ||||||
|  | type ErrorOption = | ||||||
|  |   | { | ||||||
|  |       types: MultipleFieldErrors; | ||||||
|  |     } | ||||||
|  |   | { | ||||||
|  |       message?: Message; | ||||||
|  |       type: string; | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  | type RegisterProps = { | ||||||
|  |   onSubmit: ( | ||||||
|  |     data: RegisterFormData, | ||||||
|  |     setComplete: (val: boolean) => void, | ||||||
|  |     setError: (name: 'username' | 'email' | 'password' | 'password_confirm' | 'initials', error: ErrorOption) => void, | ||||||
|  |   ) => void; | ||||||
|  | }; | ||||||
|  |  | ||||||
| type LoginProps = { | type LoginProps = { | ||||||
|   onSubmit: ( |   onSubmit: ( | ||||||
|     data: LoginFormData, |     data: LoginFormData, | ||||||
|     setComplete: (val: boolean) => void, |     setComplete: (val: boolean) => void, | ||||||
|     setError: (field: string, eType: string, message: string) => void, |     setError: (name: 'username' | 'password', error: ErrorOption) => void, | ||||||
|   ) => void; |   ) => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -85,3 +111,5 @@ type ElementBounds = { | |||||||
|   size: ElementSize; |   size: ElementSize; | ||||||
|   position: ElementPosition; |   position: ElementPosition; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | type CardLabelVariant = 'large' | 'small'; | ||||||
|   | |||||||
| @@ -2,13 +2,13 @@ import React from 'react'; | |||||||
| import ReactDOM from 'react-dom'; | import ReactDOM from 'react-dom'; | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import createAuthRefreshInterceptor from 'axios-auth-refresh'; | import createAuthRefreshInterceptor from 'axios-auth-refresh'; | ||||||
| import {ApolloProvider} from '@apollo/react-hooks'; | import { ApolloProvider } from '@apollo/react-hooks'; | ||||||
| import {ApolloClient} from 'apollo-client'; | import { ApolloClient } from 'apollo-client'; | ||||||
| import {InMemoryCache} from 'apollo-cache-inmemory'; | import { InMemoryCache } from 'apollo-cache-inmemory'; | ||||||
| import {HttpLink} from 'apollo-link-http'; | import { HttpLink } from 'apollo-link-http'; | ||||||
| import {onError} from 'apollo-link-error'; | import { onError } from 'apollo-link-error'; | ||||||
| import {ApolloLink, Observable, fromPromise} from 'apollo-link'; | import { ApolloLink, Observable, fromPromise } from 'apollo-link'; | ||||||
| import {getAccessToken, getNewToken, setAccessToken} from 'shared/utils/accessToken'; | import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken'; | ||||||
| import App from './App'; | import App from './App'; | ||||||
|  |  | ||||||
| // https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8 | // https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8 | ||||||
| @@ -18,7 +18,7 @@ let isRefreshing = false; | |||||||
| let pendingRequests: any = []; | let pendingRequests: any = []; | ||||||
|  |  | ||||||
| const refreshAuthLogic = (failedRequest: any) => | const refreshAuthLogic = (failedRequest: any) => | ||||||
|   axios.post('/auth/refresh_token', {}, {withCredentials: true}).then(tokenRefreshResponse => { |   axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => { | ||||||
|     setAccessToken(tokenRefreshResponse.data.accessToken); |     setAccessToken(tokenRefreshResponse.data.accessToken); | ||||||
|     failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`; |     failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`; | ||||||
|     return Promise.resolve(); |     return Promise.resolve(); | ||||||
| @@ -43,7 +43,7 @@ const setRefreshing = (newVal: boolean) => { | |||||||
|   isRefreshing = newVal; |   isRefreshing = newVal; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => { | const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { | ||||||
|   if (graphQLErrors) { |   if (graphQLErrors) { | ||||||
|     for (const err of graphQLErrors) { |     for (const err of graphQLErrors) { | ||||||
|       if (err.extensions && err.extensions.code) { |       if (err.extensions && err.extensions.code) { | ||||||
| @@ -118,9 +118,9 @@ const requestLink = new ApolloLink( | |||||||
|  |  | ||||||
| const client = new ApolloClient({ | const client = new ApolloClient({ | ||||||
|   link: ApolloLink.from([ |   link: ApolloLink.from([ | ||||||
|     onError(({graphQLErrors, networkError}) => { |     onError(({ graphQLErrors, networkError }) => { | ||||||
|       if (graphQLErrors) { |       if (graphQLErrors) { | ||||||
|         graphQLErrors.forEach(({message, locations, path}) => |         graphQLErrors.forEach(({ message, locations, path }) => | ||||||
|           console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), |           console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|   | |||||||
| @@ -19,20 +19,20 @@ export const RoleCheckmark = styled(Checkmark)` | |||||||
| `; | `; | ||||||
|  |  | ||||||
| const permissions = [ | const permissions = [ | ||||||
|     { |   { | ||||||
|         code: 'owner', |     code: 'owner', | ||||||
|         name: 'Owner', |     name: 'Owner', | ||||||
|         description: |     description: | ||||||
|             'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.', |       'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.', | ||||||
|     }, |   }, | ||||||
|     { |   { | ||||||
|         code: 'admin', |     code: 'admin', | ||||||
|         name: 'Admin', |     name: 'Admin', | ||||||
|         description: |     description: | ||||||
|             'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.', |       'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.', | ||||||
|     }, |   }, | ||||||
|  |  | ||||||
|     { code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' }, |   { code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| export const RoleName = styled.div` | export const RoleName = styled.div` | ||||||
| @@ -59,13 +59,13 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>` | |||||||
|   text-decoration: none; |   text-decoration: none; | ||||||
|  |  | ||||||
|   ${props => |   ${props => | ||||||
|         props.disabled |     props.disabled | ||||||
|             ? css` |       ? css` | ||||||
|           user-select: none; |           user-select: none; | ||||||
|           pointer-events: none; |           pointer-events: none; | ||||||
|           color: rgba(${props.theme.colors.text.primary}, 0.4); |           color: rgba(${props.theme.colors.text.primary}, 0.4); | ||||||
|         ` |         ` | ||||||
|             : css` |       : css` | ||||||
|           cursor: pointer; |           cursor: pointer; | ||||||
|           &:hover { |           &:hover { | ||||||
|             background: rgb(115, 103, 240); |             background: rgb(115, 103, 240); | ||||||
| @@ -104,178 +104,198 @@ export const RemoveMemberButton = styled(Button)` | |||||||
|   width: 100%; |   width: 100%; | ||||||
| `; | `; | ||||||
| type TeamRoleManagerPopupProps = { | type TeamRoleManagerPopupProps = { | ||||||
|     user: TaskUser; |   user: TaskUser; | ||||||
|     warning?: string | null; |   warning?: string | null; | ||||||
|     canChangeRole: boolean; |   canChangeRole: boolean; | ||||||
|     onChangeRole: (roleCode: RoleCode) => void; |   onChangeRole: (roleCode: RoleCode) => void; | ||||||
|     updateUserPassword?: (user: TaskUser, password: string) => void; |   updateUserPassword?: (user: TaskUser, password: string) => void; | ||||||
|     onRemoveFromTeam?: () => void; |   onRemoveFromTeam?: () => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({ | const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({ | ||||||
|     warning, |   warning, | ||||||
|     user, |   user, | ||||||
|     canChangeRole, |   canChangeRole, | ||||||
|     onRemoveFromTeam, |   onRemoveFromTeam, | ||||||
|     updateUserPassword, |   updateUserPassword, | ||||||
|     onChangeRole, |   onChangeRole, | ||||||
| }) => { | }) => { | ||||||
|     const { hidePopup, setTab } = usePopup(); |   const { hidePopup, setTab } = usePopup(); | ||||||
|     const [userPass, setUserPass] = useState({ pass: "", passConfirm: "" }); |   const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' }); | ||||||
|     return ( |   return ( | ||||||
|         <> |     <> | ||||||
|             <Popup title={null} tab={0}> |       <Popup title={null} tab={0}> | ||||||
|                 <MiniProfileActions> |         <MiniProfileActions> | ||||||
|                     <MiniProfileActionWrapper> |           <MiniProfileActionWrapper> | ||||||
|                         {user.role && ( |             {user.role && ( | ||||||
|                             <MiniProfileActionItem |               <MiniProfileActionItem | ||||||
|                                 onClick={() => { |                 onClick={() => { | ||||||
|                                     setTab(1); |                   setTab(1); | ||||||
|                                 }} |                 }} | ||||||
|                             > |               > | ||||||
|                                 Change permissions... |                 Change permissions... | ||||||
|                 <CurrentPermission>{`(${user.role.name})`}</CurrentPermission> |                 <CurrentPermission>{`(${user.role.name})`}</CurrentPermission> | ||||||
|                             </MiniProfileActionItem> |               </MiniProfileActionItem> | ||||||
|                         )} |             )} | ||||||
|                         <MiniProfileActionItem onClick={() => { |             <MiniProfileActionItem | ||||||
|                             setTab(3) |               onClick={() => { | ||||||
|                         }}>Reset password...</MiniProfileActionItem> |                 setTab(3); | ||||||
|                         <MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem> |               }} | ||||||
|                     </MiniProfileActionWrapper> |             > | ||||||
|                 </MiniProfileActions> |               Reset password... | ||||||
|                 {warning && ( |             </MiniProfileActionItem> | ||||||
|                     <> |             <MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem> | ||||||
|                         <Separator /> |           </MiniProfileActionWrapper> | ||||||
|                         <WarningText>{warning}</WarningText> |         </MiniProfileActions> | ||||||
|                     </> |         {warning && ( | ||||||
|                 )} |           <> | ||||||
|             </Popup> |             <Separator /> | ||||||
|             <Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}> |             <WarningText>{warning}</WarningText> | ||||||
|                 <MiniProfileActions> |           </> | ||||||
|                     <MiniProfileActionWrapper> |         )} | ||||||
|                         {permissions |       </Popup> | ||||||
|                             .filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner') |       <Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}> | ||||||
|                             .map(perm => ( |         <MiniProfileActions> | ||||||
|                                 <MiniProfileActionItem |           <MiniProfileActionWrapper> | ||||||
|                                     disabled={user.role && perm.code !== user.role.code && !canChangeRole} |             {permissions | ||||||
|                                     key={perm.code} |               .filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner') | ||||||
|                                     onClick={() => { |               .map(perm => ( | ||||||
|                                         if (onChangeRole && user.role && perm.code !== user.role.code) { |                 <MiniProfileActionItem | ||||||
|                                             switch (perm.code) { |                   disabled={user.role && perm.code !== user.role.code && !canChangeRole} | ||||||
|                                                 case 'owner': |                   key={perm.code} | ||||||
|                                                     onChangeRole(RoleCode.Owner); |                   onClick={() => { | ||||||
|                                                     break; |                     if (onChangeRole && user.role && perm.code !== user.role.code) { | ||||||
|                                                 case 'admin': |                       switch (perm.code) { | ||||||
|                                                     onChangeRole(RoleCode.Admin); |                         case 'owner': | ||||||
|                                                     break; |                           onChangeRole(RoleCode.Owner); | ||||||
|                                                 case 'member': |                           break; | ||||||
|                                                     onChangeRole(RoleCode.Member); |                         case 'admin': | ||||||
|                                                     break; |                           onChangeRole(RoleCode.Admin); | ||||||
|                                                 default: |                           break; | ||||||
|                                                     break; |                         case 'member': | ||||||
|                                             } |                           onChangeRole(RoleCode.Member); | ||||||
|                                             hidePopup(); |                           break; | ||||||
|                                         } |                         default: | ||||||
|                                     }} |                           break; | ||||||
|                                 > |                       } | ||||||
|                                     <RoleName> |                       hidePopup(); | ||||||
|                                         {perm.name} |                     } | ||||||
|                                         {user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />} |                   }} | ||||||
|                                     </RoleName> |                 > | ||||||
|                                     <RoleDescription>{perm.description}</RoleDescription> |                   <RoleName> | ||||||
|                                 </MiniProfileActionItem> |                     {perm.name} | ||||||
|                             ))} |                     {user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />} | ||||||
|                     </MiniProfileActionWrapper> |                   </RoleName> | ||||||
|                     {user.role && user.role.code === 'owner' && ( |                   <RoleDescription>{perm.description}</RoleDescription> | ||||||
|                         <> |                 </MiniProfileActionItem> | ||||||
|                             <Separator /> |               ))} | ||||||
|                             <WarningText>You can't change roles because there must be an owner.</WarningText> |           </MiniProfileActionWrapper> | ||||||
|                         </> |           {user.role && user.role.code === 'owner' && ( | ||||||
|                     )} |             <> | ||||||
|                 </MiniProfileActions> |               <Separator /> | ||||||
|             </Popup> |               <WarningText>You can't change roles because there must be an owner.</WarningText> | ||||||
|             <Popup title="Remove from Team?" onClose={() => hidePopup()} tab={2}> |             </> | ||||||
|                 <Content> |           )} | ||||||
|                     <DeleteDescription> |         </MiniProfileActions> | ||||||
|                         The member will be removed from all cards on this project. They will receive a notification. |       </Popup> | ||||||
|  |       <Popup title="Remove from Team?" onClose={() => hidePopup()} tab={2}> | ||||||
|  |         <Content> | ||||||
|  |           <DeleteDescription> | ||||||
|  |             The member will be removed from all cards on this project. They will receive a notification. | ||||||
|           </DeleteDescription> |           </DeleteDescription> | ||||||
|                     <RemoveMemberButton |           <RemoveMemberButton | ||||||
|                         color="danger" |             color="danger" | ||||||
|                         onClick={() => { |             onClick={() => { | ||||||
|                             if (onRemoveFromTeam) { |               if (onRemoveFromTeam) { | ||||||
|                                 onRemoveFromTeam(); |                 onRemoveFromTeam(); | ||||||
|                             } |               } | ||||||
|                         }} |             }} | ||||||
|                     > |           > | ||||||
|                         Remove Member |             Remove Member | ||||||
|           </RemoveMemberButton> |           </RemoveMemberButton> | ||||||
|                 </Content> |         </Content> | ||||||
|             </Popup> |       </Popup> | ||||||
|             <Popup title="Reset password?" onClose={() => hidePopup()} tab={3}> |       <Popup title="Reset password?" onClose={() => hidePopup()} tab={3}> | ||||||
|                 <Content> |         <Content> | ||||||
|                     <DeleteDescription> |           <DeleteDescription> | ||||||
|                         You can either set the user's new password directly or send the user an email allowing them to reset their own password. |             You can either set the user's new password directly or send the user an email allowing them to reset their | ||||||
|  |             own password. | ||||||
|           </DeleteDescription> |           </DeleteDescription> | ||||||
|                     <UserPassBar> |           <UserPassBar> | ||||||
|                         <UserPassButton onClick={() => setTab(4)} color="warning">Set password...</UserPassButton> |             <UserPassButton onClick={() => setTab(4)} color="warning"> | ||||||
|                         <UserPassButton color="warning" variant="outline">Send reset link</UserPassButton> |               Set password... | ||||||
|                     </UserPassBar> |             </UserPassButton> | ||||||
|                 </Content> |             <UserPassButton color="warning" variant="outline"> | ||||||
|             </Popup> |               Send reset link | ||||||
|             <Popup title="Reset password" onClose={() => hidePopup()} tab={4}> |             </UserPassButton> | ||||||
|                 <Content> |           </UserPassBar> | ||||||
|                     <NewUserPassInput onChange={e => setUserPass({ pass: e.currentTarget.value, passConfirm: userPass.passConfirm })} value={userPass.pass} width="100%" variant="alternate" placeholder="New password" /> |         </Content> | ||||||
|                     <NewUserPassInput onChange={e => setUserPass({ passConfirm: e.currentTarget.value, pass: userPass.pass })} value={userPass.passConfirm} width="100%" variant="alternate" placeholder="New password (confirm)" /> |       </Popup> | ||||||
|                     <UserPassConfirmButton disabled={userPass.pass === "" || userPass.passConfirm === ""} onClick={() => { |       <Popup title="Reset password" onClose={() => hidePopup()} tab={4}> | ||||||
|                         if (userPass.pass === userPass.passConfirm && updateUserPassword) { |         <Content> | ||||||
|                             updateUserPassword(user, userPass.pass) |           <NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" /> | ||||||
|                         } |           <NewUserPassInput | ||||||
|                     }} color="danger">Set password</UserPassConfirmButton> |             defaultValue={userPass.passConfirm} | ||||||
|                 </Content> |             width="100%" | ||||||
|             </Popup> |             variant="alternate" | ||||||
|             <Popup title="Remove user" onClose={() => hidePopup()} tab={5}> |             placeholder="New password (confirm)" | ||||||
|                 <Content> |           /> | ||||||
|                     <DeleteDescription> |           <UserPassConfirmButton | ||||||
|                         Removing this user from the organzation will remove them from assigned tasks, projects, and            teams. |             disabled={userPass.pass === '' || userPass.passConfirm === ''} | ||||||
|  |             onClick={() => { | ||||||
|  |               if (userPass.pass === userPass.passConfirm && updateUserPassword) { | ||||||
|  |                 updateUserPassword(user, userPass.pass); | ||||||
|  |               } | ||||||
|  |             }} | ||||||
|  |             color="danger" | ||||||
|  |           > | ||||||
|  |             Set password | ||||||
|  |           </UserPassConfirmButton> | ||||||
|  |         </Content> | ||||||
|  |       </Popup> | ||||||
|  |       <Popup title="Remove user" onClose={() => hidePopup()} tab={5}> | ||||||
|  |         <Content> | ||||||
|  |           <DeleteDescription> | ||||||
|  |             Removing this user from the organzation will remove them from assigned tasks, projects, and teams. | ||||||
|           </DeleteDescription> |           </DeleteDescription> | ||||||
|                     <DeleteDescription> |           <DeleteDescription>The user is the owner of 3 projects & 2 teams.</DeleteDescription> | ||||||
|                         The user is the owner of 3 projects & 2 teams. |           <UserSelect onChange={() => {}} value={null} options={[{ label: 'Jordan Knott', value: 'jordanknott' }]} /> | ||||||
|           </DeleteDescription> |           <UserPassConfirmButton onClick={() => {}} color="danger"> | ||||||
|                     <UserSelect onChange={() => { }} value={null} options={[{ label: 'Jordan Knott', value: "jordanknott" }]} /> |             Set password | ||||||
|                     <UserPassConfirmButton onClick={() => { }} color="danger">Set password</UserPassConfirmButton> |           </UserPassConfirmButton> | ||||||
|                 </Content> |         </Content> | ||||||
|             </Popup> |       </Popup> | ||||||
|         </> |     </> | ||||||
|     ); |   ); | ||||||
| }; | }; | ||||||
| const UserSelect = styled(Select)` | const UserSelect = styled(Select)` | ||||||
| margin: 8px 0; |   margin: 8px 0; | ||||||
| padding: 8px 0; |   padding: 8px 0; | ||||||
| ` | `; | ||||||
|  |  | ||||||
| const NewUserPassInput = styled(Input)` | const NewUserPassInput = styled(Input)` | ||||||
| margin: 8px 0; |   margin: 8px 0; | ||||||
| ` | `; | ||||||
| const InviteMemberButton = styled(Button)` | const InviteMemberButton = styled(Button)` | ||||||
|   padding: 7px 12px; |   padding: 7px 12px; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const UserPassBar = styled.div` | const UserPassBar = styled.div` | ||||||
| display: flex; |   display: flex; | ||||||
| padding-top: 8px; |   padding-top: 8px; | ||||||
| ` | `; | ||||||
| const UserPassConfirmButton = styled(Button)` | const UserPassConfirmButton = styled(Button)` | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   padding: 7px 12px; |   padding: 7px 12px; | ||||||
| ` | `; | ||||||
|  |  | ||||||
| const UserPassButton = styled(Button)` | const UserPassButton = styled(Button)` | ||||||
|   width: 50%; |   width: 50%; | ||||||
|   padding: 7px 12px; |   padding: 7px 12px; | ||||||
|   & ~ & { |   & ~ & { | ||||||
|   margin-left: 6px; |     margin-left: 6px; | ||||||
|   } |   } | ||||||
| ` | `; | ||||||
|  |  | ||||||
| const MemberItemOptions = styled.div``; | const MemberItemOptions = styled.div``; | ||||||
|  |  | ||||||
| @@ -413,7 +433,7 @@ const LockUserIcon = styled(Lock)``; | |||||||
| const DeleteUserIcon = styled(Trash)``; | const DeleteUserIcon = styled(Trash)``; | ||||||
|  |  | ||||||
| type ActionButtonProps = { | type ActionButtonProps = { | ||||||
|     onClick: ($target: React.RefObject<HTMLElement>) => void; |   onClick: ($target: React.RefObject<HTMLElement>) => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const ActionButtonWrapper = styled.div` | const ActionButtonWrapper = styled.div` | ||||||
| @@ -423,84 +443,84 @@ const ActionButtonWrapper = styled.div` | |||||||
| `; | `; | ||||||
|  |  | ||||||
| const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => { | const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => { | ||||||
|     const $wrapper = useRef<HTMLDivElement>(null); |   const $wrapper = useRef<HTMLDivElement>(null); | ||||||
|     return ( |   return ( | ||||||
|         <ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}> |     <ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}> | ||||||
|             {children} |       {children} | ||||||
|         </ActionButtonWrapper> |     </ActionButtonWrapper> | ||||||
|     ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const ActionButtons = (params: any) => { | const ActionButtons = (params: any) => { | ||||||
|     return ( |   return ( | ||||||
|         <> |     <> | ||||||
|             <ActionButton onClick={() => { }}> |       <ActionButton onClick={() => {}}> | ||||||
|                 <EditUserIcon width={16} height={16} /> |         <EditUserIcon width={16} height={16} /> | ||||||
|             </ActionButton> |       </ActionButton> | ||||||
|             <ActionButton onClick={$target => params.onDeleteUser($target, params.value)}> |       <ActionButton onClick={$target => params.onDeleteUser($target, params.value)}> | ||||||
|                 <DeleteUserIcon width={16} height={16} /> |         <DeleteUserIcon width={16} height={16} /> | ||||||
|             </ActionButton> |       </ActionButton> | ||||||
|         </> |     </> | ||||||
|     ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type ListTableProps = { | type ListTableProps = { | ||||||
|     users: Array<User>; |   users: Array<User>; | ||||||
|     onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void; |   onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const ListTable: React.FC<ListTableProps> = ({ users, onDeleteUser }) => { | const ListTable: React.FC<ListTableProps> = ({ users, onDeleteUser }) => { | ||||||
|     const data = { |   const data = { | ||||||
|         defaultColDef: { |     defaultColDef: { | ||||||
|             resizable: true, |       resizable: true, | ||||||
|             sortable: true, |       sortable: true, | ||||||
|  |     }, | ||||||
|  |     columnDefs: [ | ||||||
|  |       { | ||||||
|  |         minWidth: 55, | ||||||
|  |         width: 55, | ||||||
|  |         headerCheckboxSelection: true, | ||||||
|  |         checkboxSelection: true, | ||||||
|  |       }, | ||||||
|  |       { minWidth: 210, headerName: 'Username', editable: true, field: 'username' }, | ||||||
|  |       { minWidth: 225, headerName: 'Email', field: 'email' }, | ||||||
|  |       { minWidth: 200, headerName: 'Name', editable: true, field: 'fullName' }, | ||||||
|  |       { minWidth: 200, headerName: 'Role', editable: true, field: 'roleName' }, | ||||||
|  |       { | ||||||
|  |         minWidth: 200, | ||||||
|  |         headerName: 'Actions', | ||||||
|  |         field: 'id', | ||||||
|  |         cellRenderer: 'actionButtons', | ||||||
|  |         cellRendererParams: { | ||||||
|  |           onDeleteUser: (target: any, userID: any) => { | ||||||
|  |             onDeleteUser(target, userID); | ||||||
|  |           }, | ||||||
|         }, |         }, | ||||||
|         columnDefs: [ |       }, | ||||||
|             { |     ], | ||||||
|                 minWidth: 55, |     frameworkComponents: { | ||||||
|                 width: 55, |       actionButtons: ActionButtons, | ||||||
|                 headerCheckboxSelection: true, |     }, | ||||||
|                 checkboxSelection: true, |   }; | ||||||
|             }, |   return ( | ||||||
|             { minWidth: 210, headerName: 'Username', editable: true, field: 'username' }, |     <Root> | ||||||
|             { minWidth: 225, headerName: 'Email', field: 'email' }, |       <div className="ag-theme-material" style={{ height: '296px', width: '100%' }}> | ||||||
|             { minWidth: 200, headerName: 'Name', editable: true, field: 'fullName' }, |         <AgGridReact | ||||||
|             { minWidth: 200, headerName: 'Role', editable: true, field: 'roleName' }, |           rowSelection="multiple" | ||||||
|             { |           defaultColDef={data.defaultColDef} | ||||||
|                 minWidth: 200, |           columnDefs={data.columnDefs} | ||||||
|                 headerName: 'Actions', |           rowData={users.map(u => ({ ...u, roleName: u.role.name }))} | ||||||
|                 field: 'id', |           frameworkComponents={data.frameworkComponents} | ||||||
|                 cellRenderer: 'actionButtons', |           onFirstDataRendered={params => { | ||||||
|                 cellRendererParams: { |             params.api.sizeColumnsToFit(); | ||||||
|                     onDeleteUser: (target: any, userID: any) => { |           }} | ||||||
|                         onDeleteUser(target, userID); |           onGridSizeChanged={params => { | ||||||
|                     }, |             params.api.sizeColumnsToFit(); | ||||||
|                 }, |           }} | ||||||
|             }, |         /> | ||||||
|         ], |       </div> | ||||||
|         frameworkComponents: { |     </Root> | ||||||
|             actionButtons: ActionButtons, |   ); | ||||||
|         }, |  | ||||||
|     }; |  | ||||||
|     return ( |  | ||||||
|         <Root> |  | ||||||
|             <div className="ag-theme-material" style={{ height: '296px', width: '100%' }}> |  | ||||||
|                 <AgGridReact |  | ||||||
|                     rowSelection="multiple" |  | ||||||
|                     defaultColDef={data.defaultColDef} |  | ||||||
|                     columnDefs={data.columnDefs} |  | ||||||
|                     rowData={users.map(u => ({ ...u, roleName: u.role.name }))} |  | ||||||
|                     frameworkComponents={data.frameworkComponents} |  | ||||||
|                     onFirstDataRendered={params => { |  | ||||||
|                         params.api.sizeColumnsToFit(); |  | ||||||
|                     }} |  | ||||||
|                     onGridSizeChanged={params => { |  | ||||||
|                         params.api.sizeColumnsToFit(); |  | ||||||
|                     }} |  | ||||||
|                 /> |  | ||||||
|             </div> |  | ||||||
|         </Root> |  | ||||||
|     ); |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const Wrapper = styled.div` | const Wrapper = styled.div` | ||||||
| @@ -604,138 +624,145 @@ const TabContent = styled.div` | |||||||
| const items = [{ name: 'Members' }, { name: 'Settings' }]; | const items = [{ name: 'Members' }, { name: 'Settings' }]; | ||||||
|  |  | ||||||
| type NavItemProps = { | type NavItemProps = { | ||||||
|     active: boolean; |   active: boolean; | ||||||
|     name: string; |   name: string; | ||||||
|     tab: number; |   tab: number; | ||||||
|     onClick: (tab: number, top: number) => void; |   onClick: (tab: number, top: number) => void; | ||||||
| }; | }; | ||||||
| const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => { | const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => { | ||||||
|     const $item = useRef<HTMLLIElement>(null); |   const $item = useRef<HTMLLIElement>(null); | ||||||
|     return ( |   return ( | ||||||
|         <TabNavItem |     <TabNavItem | ||||||
|             key={name} |       key={name} | ||||||
|             ref={$item} |       ref={$item} | ||||||
|             onClick={() => { |       onClick={() => { | ||||||
|                 if ($item && $item.current) { |         if ($item && $item.current) { | ||||||
|                     const pos = $item.current.getBoundingClientRect(); |           const pos = $item.current.getBoundingClientRect(); | ||||||
|                     onClick(tab, pos.top); |           onClick(tab, pos.top); | ||||||
|                 } |         } | ||||||
|             }} |       }} | ||||||
|         > |     > | ||||||
|             <TabNavItemButton active={active}> |       <TabNavItemButton active={active}> | ||||||
|                 <User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} /> |         <User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} /> | ||||||
|                 <TabNavItemSpan>{name}</TabNavItemSpan> |         <TabNavItemSpan>{name}</TabNavItemSpan> | ||||||
|             </TabNavItemButton> |       </TabNavItemButton> | ||||||
|         </TabNavItem> |     </TabNavItem> | ||||||
|     ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type AdminProps = { | type AdminProps = { | ||||||
|     initialTab: number; |   initialTab: number; | ||||||
|     onAddUser: ($target: React.RefObject<HTMLElement>) => void; |   onAddUser: ($target: React.RefObject<HTMLElement>) => void; | ||||||
|     onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void; |   onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void; | ||||||
|     onInviteUser: ($target: React.RefObject<HTMLElement>) => void; |   onInviteUser: ($target: React.RefObject<HTMLElement>) => void; | ||||||
|     users: Array<User>; |   users: Array<User>; | ||||||
|     onUpdateUserPassword: (user: TaskUser, password: string) => void; |   onUpdateUserPassword: (user: TaskUser, password: string) => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPassword, onDeleteUser, onInviteUser, users }) => { | const Admin: React.FC<AdminProps> = ({ | ||||||
|     const warning = |   initialTab, | ||||||
|         'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.'; |   onAddUser, | ||||||
|     const [currentTop, setTop] = useState(initialTab * 48); |   onUpdateUserPassword, | ||||||
|     const [currentTab, setTab] = useState(initialTab); |   onDeleteUser, | ||||||
|     const { showPopup, hidePopup } = usePopup(); |   onInviteUser, | ||||||
|     const $tabNav = useRef<HTMLDivElement>(null); |   users, | ||||||
|  | }) => { | ||||||
|  |   const warning = | ||||||
|  |     'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.'; | ||||||
|  |   const [currentTop, setTop] = useState(initialTab * 48); | ||||||
|  |   const [currentTab, setTab] = useState(initialTab); | ||||||
|  |   const { showPopup, hidePopup } = usePopup(); | ||||||
|  |   const $tabNav = useRef<HTMLDivElement>(null); | ||||||
|  |  | ||||||
|     const [updateUserRole] = useUpdateUserRoleMutation() |   const [updateUserRole] = useUpdateUserRoleMutation(); | ||||||
|     return ( |   return ( | ||||||
|         <Container> |     <Container> | ||||||
|             <TabNav ref={$tabNav}> |       <TabNav ref={$tabNav}> | ||||||
|                 <TabNavContent> |         <TabNavContent> | ||||||
|                     {items.map((item, idx) => ( |           {items.map((item, idx) => ( | ||||||
|                         <NavItem |             <NavItem | ||||||
|                             onClick={(tab, top) => { |               onClick={(tab, top) => { | ||||||
|                                 if ($tabNav && $tabNav.current) { |                 if ($tabNav && $tabNav.current) { | ||||||
|                                     const pos = $tabNav.current.getBoundingClientRect(); |                   const pos = $tabNav.current.getBoundingClientRect(); | ||||||
|                                     setTab(tab); |                   setTab(tab); | ||||||
|                                     setTop(top - pos.top); |                   setTop(top - pos.top); | ||||||
|                                 } |                 } | ||||||
|                             }} |               }} | ||||||
|                             name={item.name} |               name={item.name} | ||||||
|                             tab={idx} |               tab={idx} | ||||||
|                             active={idx === currentTab} |               active={idx === currentTab} | ||||||
|                         /> |             /> | ||||||
|                     ))} |           ))} | ||||||
|                     <TabNavLine top={currentTop} /> |           <TabNavLine top={currentTop} /> | ||||||
|                 </TabNavContent> |         </TabNavContent> | ||||||
|             </TabNav> |       </TabNav> | ||||||
|             <TabContentWrapper> |       <TabContentWrapper> | ||||||
|                 <TabContent> |         <TabContent> | ||||||
|                     <MemberListWrapper> |           <MemberListWrapper> | ||||||
|                         <MemberListHeader> |             <MemberListHeader> | ||||||
|                             <ListTitle>{`Users (${users.length})`}</ListTitle> |               <ListTitle>{`Users (${users.length})`}</ListTitle> | ||||||
|                             <ListDesc> |               <ListDesc> | ||||||
|                                 Team members can view and join all Team Visible boards and create new boards in the team. |                 Team members can view and join all Team Visible boards and create new boards in the team. | ||||||
|               </ListDesc> |               </ListDesc> | ||||||
|                             <ListActions> |               <ListActions> | ||||||
|                                 <FilterSearch width="250px" variant="alternate" placeholder="Filter by name" /> |                 <FilterSearch width="250px" variant="alternate" placeholder="Filter by name" /> | ||||||
|                                 <InviteMemberButton |                 <InviteMemberButton | ||||||
|                                     onClick={$target => { |                   onClick={$target => { | ||||||
|                                         onAddUser($target); |                     onAddUser($target); | ||||||
|                                     }} |                   }} | ||||||
|                                 > |                 > | ||||||
|                                     <InviteIcon width={16} height={16} /> |                   <InviteIcon width={16} height={16} /> | ||||||
|                                     New Member |                   New Member | ||||||
|                 </InviteMemberButton> |                 </InviteMemberButton> | ||||||
|                             </ListActions> |               </ListActions> | ||||||
|                         </MemberListHeader> |             </MemberListHeader> | ||||||
|                         <MemberList> |             <MemberList> | ||||||
|                             {users.map(member => ( |               {users.map(member => ( | ||||||
|                                 <MemberListItem> |                 <MemberListItem> | ||||||
|                                     <MemberProfile showRoleIcons size={32} onMemberProfile={() => { }} member={member} /> |                   <MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} /> | ||||||
|                                     <MemberListItemDetails> |                   <MemberListItemDetails> | ||||||
|                                         <MemberItemName>{member.fullName}</MemberItemName> |                     <MemberItemName>{member.fullName}</MemberItemName> | ||||||
|                                         <MemberItemUsername>{`@${member.username}`}</MemberItemUsername> |                     <MemberItemUsername>{`@${member.username}`}</MemberItemUsername> | ||||||
|                                     </MemberListItemDetails> |                   </MemberListItemDetails> | ||||||
|                                     <MemberItemOptions> |                   <MemberItemOptions> | ||||||
|                                         <MemberItemOption variant="flat">On 6 projects</MemberItemOption> |                     <MemberItemOption variant="flat">On 6 projects</MemberItemOption> | ||||||
|                                         <MemberItemOption |                     <MemberItemOption | ||||||
|                                             variant="outline" |                       variant="outline" | ||||||
|                                             onClick={$target => { |                       onClick={$target => { | ||||||
|                                                 showPopup( |                         showPopup( | ||||||
|                                                     $target, |                           $target, | ||||||
|                                                     <TeamRoleManagerPopup |                           <TeamRoleManagerPopup | ||||||
|                                                         user={member} |                             user={member} | ||||||
|                                                         warning={member.role && member.role.code === 'owner' ? warning : null} |                             warning={member.role && member.role.code === 'owner' ? warning : null} | ||||||
|                                                         updateUserPassword={(user, password) => { |                             updateUserPassword={(user, password) => { | ||||||
|                                                             onUpdateUserPassword(user, password) |                               onUpdateUserPassword(user, password); | ||||||
|                                                         }} |                             }} | ||||||
|                                                         canChangeRole={member.role && member.role.code !== 'owner'} |                             canChangeRole={member.role && member.role.code !== 'owner'} | ||||||
|                                                         onChangeRole={roleCode => { |                             onChangeRole={roleCode => { | ||||||
|                                                             updateUserRole({ variables: { userID: member.id, roleCode } }) |                               updateUserRole({ variables: { userID: member.id, roleCode } }); | ||||||
|                                                         }} |                             }} | ||||||
|                                                         onRemoveFromTeam={ |                             onRemoveFromTeam={ | ||||||
|                                                             member.role && member.role.code === 'owner' |                               member.role && member.role.code === 'owner' | ||||||
|                                                                 ? undefined |                                 ? undefined | ||||||
|                                                                 : () => { |                                 : () => { | ||||||
|                                                                     hidePopup(); |                                     hidePopup(); | ||||||
|                                                                 } |                                   } | ||||||
|                                                         } |                             } | ||||||
|                                                     />, |                           />, | ||||||
|                                                 ); |                         ); | ||||||
|                                             }} |                       }} | ||||||
|                                         > |                     > | ||||||
|                                             Manage |                       Manage | ||||||
|                     </MemberItemOption> |                     </MemberItemOption> | ||||||
|                                     </MemberItemOptions> |                   </MemberItemOptions> | ||||||
|                                 </MemberListItem> |                 </MemberListItem> | ||||||
|                             ))} |               ))} | ||||||
|                         </MemberList> |             </MemberList> | ||||||
|                     </MemberListWrapper> |           </MemberListWrapper> | ||||||
|                 </TabContent> |         </TabContent> | ||||||
|             </TabContentWrapper> |       </TabContentWrapper> | ||||||
|         </Container> |     </Container> | ||||||
|     ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default Admin; | export default Admin; | ||||||
|   | |||||||
| @@ -1,9 +1,9 @@ | |||||||
| import styled, {css} from 'styled-components'; | import styled, { css, keyframes } from 'styled-components'; | ||||||
| import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; | ||||||
| import {mixin} from 'shared/utils/styles'; | import { mixin } from 'shared/utils/styles'; | ||||||
| import TextareaAutosize from 'react-autosize-textarea'; | import TextareaAutosize from 'react-autosize-textarea'; | ||||||
| import {CheckCircle} from 'shared/icons'; | import { CheckCircle } from 'shared/icons'; | ||||||
| import {RefObject} from 'react'; | import { RefObject } from 'react'; | ||||||
|  |  | ||||||
| export const ClockIcon = styled(FontAwesomeIcon)``; | export const ClockIcon = styled(FontAwesomeIcon)``; | ||||||
|  |  | ||||||
| @@ -57,7 +57,7 @@ export const DescriptionBadge = styled(ListCardBadge)` | |||||||
|   padding-right: 6px; |   padding-right: 6px; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export const DueDateCardBadge = styled(ListCardBadge) <{isPastDue: boolean}>` | export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>` | ||||||
|   font-size: 12px; |   font-size: 12px; | ||||||
|   ${props => |   ${props => | ||||||
|     props.isPastDue && |     props.isPastDue && | ||||||
| @@ -76,7 +76,7 @@ export const ListCardBadgeText = styled.span` | |||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export const ListCardContainer = styled.div<{isActive: boolean; editable: boolean}>` | export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>` | ||||||
|   max-width: 256px; |   max-width: 256px; | ||||||
|   margin-bottom: 8px; |   margin-bottom: 8px; | ||||||
|   border-radius: 3px; |   border-radius: 3px; | ||||||
| @@ -93,7 +93,7 @@ export const ListCardInnerContainer = styled.div` | |||||||
|   height: 100%; |   height: 100%; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export const ListCardDetails = styled.div<{complete: boolean}>` | export const ListCardDetails = styled.div<{ complete: boolean }>` | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   padding: 6px 8px 2px; |   padding: 6px 8px 2px; | ||||||
|   position: relative; |   position: relative; | ||||||
| @@ -102,28 +102,93 @@ export const ListCardDetails = styled.div<{complete: boolean}>` | |||||||
|   ${props => props.complete && 'opacity: 0.6;'} |   ${props => props.complete && 'opacity: 0.6;'} | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export const ListCardLabels = styled.div` | const labelVariantExpandAnimation = keyframes` | ||||||
|   overflow: auto; |   0%   {min-width: 40px; height: 8px;} | ||||||
|   position: relative; |   50%  {min-width: 56px; height: 8px;} | ||||||
|  |   100% {min-width: 56px; height: 16px;} | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export const ListCardLabel = styled.span` | const labelTextVariantExpandAnimation = keyframes` | ||||||
|   height: 16px; |   0%   {transform: scale(0); visibility: hidden; pointer-events: none;} | ||||||
|  |   75%   {transform: scale(0); visibility: hidden; pointer-events: none;} | ||||||
|  |   100%   {transform: scale(1); visibility: visible; pointer-events: all;} | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const labelVariantShrinkAnimation = keyframes` | ||||||
|  |   0% {min-width: 56px; height: 16px;} | ||||||
|  |   50%  {min-width: 56px; height: 8px;} | ||||||
|  |   100%   {min-width: 40px; height: 8px;} | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const labelTextVariantShrinkAnimation = keyframes` | ||||||
|  |   0%   {transform: scale(1); visibility: visible; pointer-events: all;} | ||||||
|  |   75%   {transform: scale(0); visibility: hidden; pointer-events: none;} | ||||||
|  |   100%   {transform: scale(0); visibility: hidden; pointer-events: none;} | ||||||
|  | `; | ||||||
|  | export const ListCardLabelText = styled.span` | ||||||
|  |   font-size: 12px; | ||||||
|  |   font-weight: 700; | ||||||
|   line-height: 16px; |   line-height: 16px; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>` | ||||||
|  |   ${props => | ||||||
|  |     props.variant === 'small' | ||||||
|  |       ? css` | ||||||
|  |           height: 8px; | ||||||
|  |           min-width: 40px; | ||||||
|  |           & ${ListCardLabelText} { | ||||||
|  |             transform: scale(0); | ||||||
|  |             visibility: hidden; | ||||||
|  |             pointer-events: none; | ||||||
|  |           } | ||||||
|  |         ` | ||||||
|  |       : css` | ||||||
|  |           height: 16px; | ||||||
|  |           min-width: 56px; | ||||||
|  |         `} | ||||||
|  |  | ||||||
|   padding: 0 8px; |   padding: 0 8px; | ||||||
|   max-width: 198px; |   max-width: 198px; | ||||||
|   float: left; |   float: left; | ||||||
|   font-size: 12px; |  | ||||||
|   font-weight: 700; |  | ||||||
|   margin: 0 4px 4px 0; |   margin: 0 4px 4px 0; | ||||||
|   width: auto; |   width: auto; | ||||||
|   border-radius: 4px; |   border-radius: 4px; | ||||||
|   color: #fff; |   color: #fff; | ||||||
|   display: block; |   display: flex; | ||||||
|   position: relative; |   position: relative; | ||||||
|   background-color: ${props => props.color}; |   background-color: ${props => props.color}; | ||||||
| `; | `; | ||||||
|  |  | ||||||
|  | export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>` | ||||||
|  |   overflow: auto; | ||||||
|  |   position: relative; | ||||||
|  |   &:hover { | ||||||
|  |     opacity: 0.8; | ||||||
|  |   } | ||||||
|  |   ${props => | ||||||
|  |     props.toggleLabels && | ||||||
|  |     props.toggleDirection === 'expand' && | ||||||
|  |     css` | ||||||
|  |       & ${ListCardLabel} { | ||||||
|  |         animation: ${labelVariantExpandAnimation} 0.45s ease-out; | ||||||
|  |       } | ||||||
|  |       & ${ListCardLabelText} { | ||||||
|  |         animation: ${labelTextVariantExpandAnimation} 0.45s ease-out; | ||||||
|  |       } | ||||||
|  |     `} | ||||||
|  |   ${props => | ||||||
|  |     props.toggleLabels && | ||||||
|  |     props.toggleDirection === 'shrink' && | ||||||
|  |     css` | ||||||
|  |       & ${ListCardLabel} { | ||||||
|  |         animation: ${labelVariantShrinkAnimation} 0.45s ease-out; | ||||||
|  |       } | ||||||
|  |       & ${ListCardLabelText} { | ||||||
|  |         animation: ${labelTextVariantShrinkAnimation} 0.45s ease-out; | ||||||
|  |       } | ||||||
|  |     `} | ||||||
|  | `; | ||||||
| export const ListCardOperation = styled.span` | export const ListCardOperation = styled.span` | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-content: center; |   align-content: center; | ||||||
| @@ -136,7 +201,7 @@ export const ListCardOperation = styled.span` | |||||||
|   top: 2px; |   top: 2px; | ||||||
|   z-index: 100; |   z-index: 100; | ||||||
|   &:hover { |   &:hover { | ||||||
|     background-color: ${props => mixin.darken('#262c49', .25)}; |     background-color: ${props => mixin.darken('#262c49', 0.25)}; | ||||||
|   } |   } | ||||||
| `; | `; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,8 +1,8 @@ | |||||||
| import React, {useState, useRef, useEffect} from 'react'; | import React, { useState, useRef, useEffect } from 'react'; | ||||||
| import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; | ||||||
| import TaskAssignee from 'shared/components/TaskAssignee'; | import TaskAssignee from 'shared/components/TaskAssignee'; | ||||||
| import {faPencilAlt, faList} from '@fortawesome/free-solid-svg-icons'; | import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons'; | ||||||
| import {faClock, faCheckSquare, faEye} from '@fortawesome/free-regular-svg-icons'; | import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons'; | ||||||
| import { | import { | ||||||
|   EditorTextarea, |   EditorTextarea, | ||||||
|   EditorContent, |   EditorContent, | ||||||
| @@ -18,6 +18,7 @@ import { | |||||||
|   ClockIcon, |   ClockIcon, | ||||||
|   ListCardLabels, |   ListCardLabels, | ||||||
|   ListCardLabel, |   ListCardLabel, | ||||||
|  |   ListCardLabelText, | ||||||
|   ListCardOperation, |   ListCardOperation, | ||||||
|   CardTitle, |   CardTitle, | ||||||
|   CardMembers, |   CardMembers, | ||||||
| @@ -47,10 +48,12 @@ type Props = { | |||||||
|   watched?: boolean; |   watched?: boolean; | ||||||
|   wrapperProps?: any; |   wrapperProps?: any; | ||||||
|   members?: Array<TaskUser> | null; |   members?: Array<TaskUser> | null; | ||||||
|  |   onCardLabelClick?: () => void; | ||||||
|   onCardMemberClick?: OnCardMemberClick; |   onCardMemberClick?: OnCardMemberClick; | ||||||
|   editable?: boolean; |   editable?: boolean; | ||||||
|   onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void; |   onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void; | ||||||
|   onCardTitleChange?: (name: string) => void; |   onCardTitleChange?: (name: string) => void; | ||||||
|  |   labelVariant?: CardLabelVariant; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const Card = React.forwardRef( | const Card = React.forwardRef( | ||||||
| @@ -69,14 +72,20 @@ const Card = React.forwardRef( | |||||||
|       checklists, |       checklists, | ||||||
|       watched, |       watched, | ||||||
|       members, |       members, | ||||||
|  |       labelVariant, | ||||||
|       onCardMemberClick, |       onCardMemberClick, | ||||||
|       editable, |       editable, | ||||||
|  |       onCardLabelClick, | ||||||
|       onEditCard, |       onEditCard, | ||||||
|       onCardTitleChange, |       onCardTitleChange, | ||||||
|     }: Props, |     }: Props, | ||||||
|     $cardRef: any, |     $cardRef: any, | ||||||
|   ) => { |   ) => { | ||||||
|     const [currentCardTitle, setCardTitle] = useState(title); |     const [currentCardTitle, setCardTitle] = useState(title); | ||||||
|  |     const [toggleLabels, setToggleLabels] = useState(false); | ||||||
|  |     const [toggleDirection, setToggleDirection] = useState<'shrink' | 'expand'>( | ||||||
|  |       labelVariant === 'large' ? 'shrink' : 'expand', | ||||||
|  |     ); | ||||||
|     const $editorRef: any = useRef(); |     const $editorRef: any = useRef(); | ||||||
|  |  | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
| @@ -132,21 +141,39 @@ const Card = React.forwardRef( | |||||||
|       > |       > | ||||||
|         <ListCardInnerContainer ref={$innerCardRef}> |         <ListCardInnerContainer ref={$innerCardRef}> | ||||||
|           {isActive && ( |           {isActive && ( | ||||||
|             <ListCardOperation onClick={e => { |             <ListCardOperation | ||||||
|               e.stopPropagation(); |               onClick={e => { | ||||||
|               if (onContextMenu) { |                 e.stopPropagation(); | ||||||
|                 onContextMenu($innerCardRef, taskID, taskGroupID); |                 if (onContextMenu) { | ||||||
|               } |                   onContextMenu($innerCardRef, taskID, taskGroupID); | ||||||
|             }}> |                 } | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|               <FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} /> |               <FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} /> | ||||||
|             </ListCardOperation> |             </ListCardOperation> | ||||||
|           )} |           )} | ||||||
|           <ListCardDetails complete={complete ?? false}> |           <ListCardDetails complete={complete ?? false}> | ||||||
|             <ListCardLabels> |             <ListCardLabels | ||||||
|  |               toggleLabels={toggleLabels} | ||||||
|  |               toggleDirection={toggleDirection} | ||||||
|  |               onClick={e => { | ||||||
|  |                 e.stopPropagation(); | ||||||
|  |                 if (onCardLabelClick) { | ||||||
|  |                   setToggleLabels(true); | ||||||
|  |                   setToggleDirection(labelVariant === 'large' ? 'shrink' : 'expand'); | ||||||
|  |                   onCardLabelClick(); | ||||||
|  |                 } | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|               {labels && |               {labels && | ||||||
|                 labels.map(label => ( |                 labels.map(label => ( | ||||||
|                   <ListCardLabel color={label.labelColor.colorHex} key={label.id}> |                   <ListCardLabel | ||||||
|                     {label.name} |                     onAnimationEnd={() => setToggleLabels(false)} | ||||||
|  |                     variant={labelVariant ?? 'large'} | ||||||
|  |                     color={label.labelColor.colorHex} | ||||||
|  |                     key={label.id} | ||||||
|  |                   > | ||||||
|  |                     <ListCardLabelText>{label.name}</ListCardLabelText> | ||||||
|                   </ListCardLabel> |                   </ListCardLabel> | ||||||
|                 ))} |                 ))} | ||||||
|             </ListCardLabels> |             </ListCardLabels> | ||||||
| @@ -169,11 +196,11 @@ const Card = React.forwardRef( | |||||||
|                 /> |                 /> | ||||||
|               </EditorContent> |               </EditorContent> | ||||||
|             ) : ( |             ) : ( | ||||||
|                 <CardTitle> |               <CardTitle> | ||||||
|                   {complete && <CompleteIcon width={16} height={16} />} |                 {complete && <CompleteIcon width={16} height={16} />} | ||||||
|                   {title} |                 {title} | ||||||
|                 </CardTitle> |               </CardTitle> | ||||||
|               )} |             )} | ||||||
|             <ListCardBadges> |             <ListCardBadges> | ||||||
|               {watched && ( |               {watched && ( | ||||||
|                 <ListCardBadge> |                 <ListCardBadge> | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ import { | |||||||
|  |  | ||||||
| import 'react-datepicker/dist/react-datepicker.css'; | import 'react-datepicker/dist/react-datepicker.css'; | ||||||
| import { getYear, getMonth } from 'date-fns'; | import { getYear, getMonth } from 'date-fns'; | ||||||
| import { useForm } from 'react-hook-form'; | import { useForm, Controller } from 'react-hook-form'; | ||||||
|  |  | ||||||
| type DueDateManagerProps = { | type DueDateManagerProps = { | ||||||
|   task: Task; |   task: Task; | ||||||
| @@ -147,7 +147,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, | |||||||
|     'November', |     'November', | ||||||
|     'December', |     'December', | ||||||
|   ]; |   ]; | ||||||
|   const { register, handleSubmit, errors, setValue, setError, formState } = useForm<DueDateFormData>(); |   const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>(); | ||||||
|   const saveDueDate = (data: any) => { |   const saveDueDate = (data: any) => { | ||||||
|     console.log(data); |     console.log(data); | ||||||
|     const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A'); |     const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A'); | ||||||
| @@ -155,27 +155,16 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, | |||||||
|       onDueDateChange(task, newDate.toDate()); |       onDueDateChange(task, newDate.toDate()); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|   console.log(errors); |  | ||||||
|   register({ name: 'endTime' }, { required: 'End time is required' }); |  | ||||||
|   useEffect(() => { |  | ||||||
|     setValue('endTime', now.format('h:mm A')); |  | ||||||
|   }, []); |  | ||||||
|   const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => { |   const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => { | ||||||
|     return ( |     return ( | ||||||
|       <DueDateInput |       <DueDateInput | ||||||
|         id="endTime" |         id="endTime" | ||||||
|         name="endTime" |         name="endTime" | ||||||
|         ref={$ref} |         ref={$ref} | ||||||
|         onChange={e => { |  | ||||||
|           console.log(`onCahnge ${e.currentTarget.value}`); |  | ||||||
|           setTextEndTime(e.currentTarget.value); |  | ||||||
|           setValue('endTime', e.currentTarget.value); |  | ||||||
|         }} |  | ||||||
|         width="100%" |         width="100%" | ||||||
|         variant="alternate" |         variant="alternate" | ||||||
|         label="Date" |         label="Date" | ||||||
|         onClick={onClick} |         onClick={onClick} | ||||||
|         value={value} |  | ||||||
|       /> |       /> | ||||||
|     ); |     ); | ||||||
|   }); |   }); | ||||||
| @@ -190,30 +179,29 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, | |||||||
|             width="100%" |             width="100%" | ||||||
|             variant="alternate" |             variant="alternate" | ||||||
|             label="Date" |             label="Date" | ||||||
|             onChange={e => { |             defaultValue={textStartDate} | ||||||
|               setTextStartDate(e.currentTarget.value); |  | ||||||
|             }} |  | ||||||
|             value={textStartDate} |  | ||||||
|             ref={register({ |             ref={register({ | ||||||
|               required: 'End date is required.', |               required: 'End date is required.', | ||||||
|             })} |             })} | ||||||
|           /> |           /> | ||||||
|         </FormField> |         </FormField> | ||||||
|         <FormField> |         <FormField> | ||||||
|           <DatePicker |           <Controller | ||||||
|             selected={endTime} |             control={control} | ||||||
|             onChange={date => { |             name="endTime" | ||||||
|               const changedDate = moment(date ?? new Date()); |             render={({ onChange, onBlur, value }) => ( | ||||||
|               console.log(`changed ${date}`); |               <DatePicker | ||||||
|               setEndTime(changedDate.toDate()); |                 onChange={onChange} | ||||||
|               setValue('endTime', changedDate.format('h:mm A')); |                 selected={value} | ||||||
|             }} |                 onBlur={onBlur} | ||||||
|             showTimeSelect |                 showTimeSelect | ||||||
|             showTimeSelectOnly |                 showTimeSelectOnly | ||||||
|             timeIntervals={15} |                 timeIntervals={15} | ||||||
|             timeCaption="Time" |                 timeCaption="Time" | ||||||
|             dateFormat="h:mm aa" |                 dateFormat="h:mm aa" | ||||||
|             customInput={<CustomTimeInput />} |                 customInput={<CustomTimeInput />} | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|           /> |           /> | ||||||
|         </FormField> |         </FormField> | ||||||
|         <DueDatePickerWrapper> |         <DueDatePickerWrapper> | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import React, {useState, useEffect} from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import styled, {css} from 'styled-components/macro'; | import styled, { css } from 'styled-components/macro'; | ||||||
|  |  | ||||||
| const InputWrapper = styled.div<{width: string}>` | const InputWrapper = styled.div<{ width: string }>` | ||||||
|   position: relative; |   position: relative; | ||||||
|   width: ${props => props.width}; |   width: ${props => props.width}; | ||||||
|   display: flex; |   display: flex; | ||||||
| @@ -14,7 +14,7 @@ const InputWrapper = styled.div<{width: string}>` | |||||||
|   margin-top: 24px; |   margin-top: 24px; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const InputLabel = styled.span<{width: string}>` | const InputLabel = styled.span<{ width: string }>` | ||||||
|   width: ${props => props.width}; |   width: ${props => props.width}; | ||||||
|   padding: 0.7rem !important; |   padding: 0.7rem !important; | ||||||
|   color: #c2c6dc; |   color: #c2c6dc; | ||||||
| @@ -87,9 +87,8 @@ type InputProps = { | |||||||
|   id?: string; |   id?: string; | ||||||
|   name?: string; |   name?: string; | ||||||
|   className?: string; |   className?: string; | ||||||
|   value?: string; |   defaultValue?: string; | ||||||
|   onClick?: (e: React.MouseEvent<HTMLInputElement>) => void; |   onClick?: (e: React.MouseEvent<HTMLInputElement>) => void; | ||||||
|   onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void; |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const Input = React.forwardRef( | const Input = React.forwardRef( | ||||||
| @@ -102,40 +101,34 @@ const Input = React.forwardRef( | |||||||
|       placeholder, |       placeholder, | ||||||
|       icon, |       icon, | ||||||
|       name, |       name, | ||||||
|       onChange, |  | ||||||
|       className, |       className, | ||||||
|       onClick, |       onClick, | ||||||
|       floatingLabel, |       floatingLabel, | ||||||
|       value: initialValue, |       defaultValue, | ||||||
|       id, |       id, | ||||||
|     }: InputProps, |     }: InputProps, | ||||||
|     $ref: any, |     $ref: any, | ||||||
|   ) => { |   ) => { | ||||||
|     const [value, setValue] = useState(initialValue ?? ''); |     const [hasValue, setHasValue] = useState(false); | ||||||
|     useEffect(() => { |  | ||||||
|       if (initialValue) { |  | ||||||
|         setValue(initialValue); |  | ||||||
|       } |  | ||||||
|     }, [initialValue]); |  | ||||||
|     const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561'; |     const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561'; | ||||||
|     const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)'; |     const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)'; | ||||||
|     const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { |  | ||||||
|       setValue(e.currentTarget.value); |  | ||||||
|       if (onChange) { |  | ||||||
|         onChange(e); |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
|     return ( |     return ( | ||||||
|       <InputWrapper className={className} width={width}> |       <InputWrapper className={className} width={width}> | ||||||
|         <InputInput |         <InputInput | ||||||
|           hasValue={floatingLabel || value !== ''} |           onChange={() => { | ||||||
|  |             console.log(`change ${$ref}!`); | ||||||
|  |             if ($ref && $ref.current) { | ||||||
|  |               console.log(`value : ${$ref.current.value}`); | ||||||
|  |               setHasValue(($ref.current.value !== '' || floatingLabel) ?? false); | ||||||
|  |             } | ||||||
|  |           }} | ||||||
|  |           hasValue={hasValue} | ||||||
|           ref={$ref} |           ref={$ref} | ||||||
|           id={id} |           id={id} | ||||||
|           name={name} |           name={name} | ||||||
|           onClick={onClick} |           onClick={onClick} | ||||||
|           onChange={handleChange} |  | ||||||
|           autoComplete={autocomplete ? 'on' : 'off'} |           autoComplete={autocomplete ? 'on' : 'off'} | ||||||
|           value={value} |           defaultValue={defaultValue} | ||||||
|           hasIcon={typeof icon !== 'undefined'} |           hasIcon={typeof icon !== 'undefined'} | ||||||
|           width={width} |           width={width} | ||||||
|           placeholder={placeholder} |           placeholder={placeholder} | ||||||
|   | |||||||
| @@ -93,6 +93,8 @@ export const Default = () => { | |||||||
|       onTaskDrop={onCardDrop} |       onTaskDrop={onCardDrop} | ||||||
|       onTaskGroupDrop={onListDrop} |       onTaskGroupDrop={onListDrop} | ||||||
|       onChangeTaskGroupName={action('change group name')} |       onChangeTaskGroupName={action('change group name')} | ||||||
|  |       cardLabelVariant="large" | ||||||
|  |       onCardLabelClick={action('label click')} | ||||||
|       onCreateTaskGroup={action('create list')} |       onCreateTaskGroup={action('create list')} | ||||||
|       onExtraMenuOpen={action('extra menu open')} |       onExtraMenuOpen={action('extra menu open')} | ||||||
|       onCardMemberClick={action('card member click')} |       onCardMemberClick={action('card member click')} | ||||||
|   | |||||||
| @@ -26,17 +26,21 @@ interface SimpleProps { | |||||||
|   onCreateTaskGroup: (listName: string) => void; |   onCreateTaskGroup: (listName: string) => void; | ||||||
|   onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void; |   onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void; | ||||||
|   onCardMemberClick: OnCardMemberClick; |   onCardMemberClick: OnCardMemberClick; | ||||||
|  |   onCardLabelClick: () => void; | ||||||
|  |   cardLabelVariant: CardLabelVariant; | ||||||
| } | } | ||||||
|  |  | ||||||
| const SimpleLists: React.FC<SimpleProps> = ({ | const SimpleLists: React.FC<SimpleProps> = ({ | ||||||
|   taskGroups, |   taskGroups, | ||||||
|   onTaskDrop, |   onTaskDrop, | ||||||
|   onChangeTaskGroupName, |   onChangeTaskGroupName, | ||||||
|  |   onCardLabelClick, | ||||||
|   onTaskGroupDrop, |   onTaskGroupDrop, | ||||||
|   onTaskClick, |   onTaskClick, | ||||||
|   onCreateTask, |   onCreateTask, | ||||||
|   onQuickEditorOpen, |   onQuickEditorOpen, | ||||||
|   onCreateTaskGroup, |   onCreateTaskGroup, | ||||||
|  |   cardLabelVariant, | ||||||
|   onExtraMenuOpen, |   onExtraMenuOpen, | ||||||
|   onCardMemberClick, |   onCardMemberClick, | ||||||
| }) => { | }) => { | ||||||
| @@ -158,10 +162,12 @@ const SimpleLists: React.FC<SimpleProps> = ({ | |||||||
|                                           {taskProvided => { |                                           {taskProvided => { | ||||||
|                                             return ( |                                             return ( | ||||||
|                                               <Card |                                               <Card | ||||||
|  |                                                 labelVariant={cardLabelVariant} | ||||||
|                                                 wrapperProps={{ |                                                 wrapperProps={{ | ||||||
|                                                   ...taskProvided.draggableProps, |                                                   ...taskProvided.draggableProps, | ||||||
|                                                   ...taskProvided.dragHandleProps, |                                                   ...taskProvided.dragHandleProps, | ||||||
|                                                 }} |                                                 }} | ||||||
|  |                                                 onCardLabelClick={onCardLabelClick} | ||||||
|                                                 ref={taskProvided.innerRef} |                                                 ref={taskProvided.innerRef} | ||||||
|                                                 taskID={task.id} |                                                 taskID={task.id} | ||||||
|                                                 complete={task.complete ?? false} |                                                 complete={task.complete ?? false} | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import React, { useState, useEffect, useRef } from 'react'; | import React, { useState, useEffect, useRef } from 'react'; | ||||||
| import { Checkmark } from 'shared/icons'; | import { Checkmark } from 'shared/icons'; | ||||||
| import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles'; |  | ||||||
| import styled from 'styled-components'; | import styled from 'styled-components'; | ||||||
|  | import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles'; | ||||||
|  |  | ||||||
| const WhiteCheckmark = styled(Checkmark)` | const WhiteCheckmark = styled(Checkmark)` | ||||||
|   fill: rgba(${props => props.theme.colors.text.secondary}); |   fill: rgba(${props => props.theme.colors.text.secondary}); | ||||||
| @@ -39,17 +39,19 @@ const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props) | |||||||
|       /> |       /> | ||||||
|       <FieldLabel>Select a color</FieldLabel> |       <FieldLabel>Select a color</FieldLabel> | ||||||
|       <div> |       <div> | ||||||
|         {labelColors.map((labelColor: LabelColor) => ( |         {labelColors | ||||||
|           <LabelBox |           .filter(l => l.name !== 'no_color') | ||||||
|             key={labelColor.id} |           .map((labelColor: LabelColor) => ( | ||||||
|             color={labelColor.colorHex} |             <LabelBox | ||||||
|             onClick={() => { |               key={labelColor.id} | ||||||
|               setCurrentColor(labelColor); |               color={labelColor.colorHex} | ||||||
|             }} |               onClick={() => { | ||||||
|           > |                 setCurrentColor(labelColor); | ||||||
|             {currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />} |               }} | ||||||
|           </LabelBox> |             > | ||||||
|         ))} |               {currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />} | ||||||
|  |             </LabelBox> | ||||||
|  |           ))} | ||||||
|       </div> |       </div> | ||||||
|       <div> |       <div> | ||||||
|         <SaveButton |         <SaveButton | ||||||
|   | |||||||
| @@ -40,7 +40,7 @@ export const ListSeparator = styled.hr` | |||||||
| type Props = { | type Props = { | ||||||
|   onDeleteProject: () => void; |   onDeleteProject: () => void; | ||||||
| }; | }; | ||||||
| const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => { | const ProjectSettings: React.FC<Props> = ({onDeleteProject}) => { | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <ListActionsWrapper> |       <ListActionsWrapper> | ||||||
| @@ -55,7 +55,7 @@ const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => { | |||||||
| type TeamSettingsProps = { | type TeamSettingsProps = { | ||||||
|   onDeleteTeam: () => void; |   onDeleteTeam: () => void; | ||||||
| }; | }; | ||||||
| export const TeamSettings: React.FC<TeamSettingsProps> = ({ onDeleteTeam }) => { | export const TeamSettings: React.FC<TeamSettingsProps> = ({onDeleteTeam}) => { | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <ListActionsWrapper> |       <ListActionsWrapper> | ||||||
| @@ -74,6 +74,7 @@ const ConfirmSubTitle = styled.h3` | |||||||
| `; | `; | ||||||
|  |  | ||||||
| const ConfirmDescription = styled.div` | const ConfirmDescription = styled.div` | ||||||
|  |   margin: 0 12px; | ||||||
|   font-size: 14px; |   font-size: 14px; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| @@ -83,7 +84,7 @@ const DeleteList = styled.ul` | |||||||
| const DeleteListItem = styled.li` | const DeleteListItem = styled.li` | ||||||
|   padding: 6px 0; |   padding: 6px 0; | ||||||
|   list-style: disc; |   list-style: disc; | ||||||
|   margin-left: 12px; |   margin-left: 16px; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const ConfirmDeleteButton = styled(Button)` | const ConfirmDeleteButton = styled(Button)` | ||||||
| @@ -108,7 +109,7 @@ export const DELETE_INFO = { | |||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems, onConfirmDelete }) => { | const DeleteConfirm: React.FC<DeleteConfirmProps> = ({description, deletedItems, onConfirmDelete}) => { | ||||||
|   return ( |   return ( | ||||||
|     <ConfirmWrapper> |     <ConfirmWrapper> | ||||||
|       <ConfirmDescription> |       <ConfirmDescription> | ||||||
| @@ -126,5 +127,5 @@ const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems | |||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export { DeleteConfirm }; | export {DeleteConfirm}; | ||||||
| export default ProjectSettings; | export default ProjectSettings; | ||||||
|   | |||||||
							
								
								
									
										103
									
								
								frontend/src/shared/components/Register/Styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								frontend/src/shared/components/Register/Styles.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | import styled from 'styled-components'; | ||||||
|  | import Button from 'shared/components/Button'; | ||||||
|  |  | ||||||
|  | export const Wrapper = styled.div` | ||||||
|  |   background: #eff2f7; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: center; | ||||||
|  |   align-items: center; | ||||||
|  |   flex-wrap: wrap; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const Column = styled.div` | ||||||
|  |   width: 50%; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: center; | ||||||
|  |   align-items: center; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const LoginFormWrapper = styled.div` | ||||||
|  |   background: #10163a; | ||||||
|  |   width: 100%; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const LoginFormContainer = styled.div` | ||||||
|  |   min-height: 505px; | ||||||
|  |   padding: 2rem; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const Title = styled.h1` | ||||||
|  |   color: #ebeefd; | ||||||
|  |   font-size: 18px; | ||||||
|  |   margin-bottom: 14px; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const SubTitle = styled.h2` | ||||||
|  |   color: #c2c6dc; | ||||||
|  |   font-size: 14px; | ||||||
|  |   margin-bottom: 14px; | ||||||
|  | `; | ||||||
|  | export const Form = styled.form` | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const FormLabel = styled.label` | ||||||
|  |   color: #c2c6dc; | ||||||
|  |   font-size: 12px; | ||||||
|  |   position: relative; | ||||||
|  |   margin-top: 14px; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const FormTextInput = styled.input` | ||||||
|  |   width: 100%; | ||||||
|  |   background: #262c49; | ||||||
|  |   border: 1px solid rgba(0, 0, 0, 0.2); | ||||||
|  |   margin-top: 4px; | ||||||
|  |   padding: 0.7rem 1rem 0.7rem 3rem; | ||||||
|  |   font-size: 1rem; | ||||||
|  |   color: #c2c6dc; | ||||||
|  |   border-radius: 5px; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const FormIcon = styled.div` | ||||||
|  |   top: 30px; | ||||||
|  |   left: 16px; | ||||||
|  |   position: absolute; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const FormError = styled.span` | ||||||
|  |   font-size: 0.875rem; | ||||||
|  |   color: rgb(234, 84, 85); | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const LoginButton = styled(Button)``; | ||||||
|  |  | ||||||
|  | export const ActionButtons = styled.div` | ||||||
|  |   margin-top: 17.5px; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const RegisterButton = styled(Button)``; | ||||||
|  |  | ||||||
|  | export const LogoTitle = styled.div` | ||||||
|  |   font-size: 24px; | ||||||
|  |   font-weight: 600; | ||||||
|  |   margin-left: 12px; | ||||||
|  |   transition: visibility, opacity, transform 0.25s ease; | ||||||
|  |   color: #7367f0; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const LogoWrapper = styled.div` | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   flex-direction: row; | ||||||
|  |   position: relative; | ||||||
|  |   width: 100%; | ||||||
|  |   padding-bottom: 16px; | ||||||
|  |   margin-bottom: 24px; | ||||||
|  |   color: rgb(222, 235, 255); | ||||||
|  |   border-bottom: 1px solid rgba(65, 69, 97, 0.65); | ||||||
|  | `; | ||||||
							
								
								
									
										150
									
								
								frontend/src/shared/components/Register/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								frontend/src/shared/components/Register/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,150 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import AccessAccount from 'shared/undraw/AccessAccount'; | ||||||
|  | import { User, Lock, Citadel } from 'shared/icons'; | ||||||
|  | import { useForm } from 'react-hook-form'; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   Form, | ||||||
|  |   LogoWrapper, | ||||||
|  |   LogoTitle, | ||||||
|  |   ActionButtons, | ||||||
|  |   RegisterButton, | ||||||
|  |   FormError, | ||||||
|  |   FormIcon, | ||||||
|  |   FormLabel, | ||||||
|  |   FormTextInput, | ||||||
|  |   Wrapper, | ||||||
|  |   Column, | ||||||
|  |   LoginFormWrapper, | ||||||
|  |   LoginFormContainer, | ||||||
|  |   Title, | ||||||
|  |   SubTitle, | ||||||
|  | } from './Styles'; | ||||||
|  |  | ||||||
|  | const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i; | ||||||
|  | const INITIALS_PATTERN = /[a-zA-Z]{2,3}/i; | ||||||
|  |  | ||||||
|  | const Register = ({ onSubmit }: RegisterProps) => { | ||||||
|  |   const [isComplete, setComplete] = useState(true); | ||||||
|  |   const { register, handleSubmit, errors, setError } = useForm<RegisterFormData>(); | ||||||
|  |   const loginSubmit = (data: RegisterFormData) => { | ||||||
|  |     setComplete(false); | ||||||
|  |     onSubmit(data, setComplete, setError); | ||||||
|  |   }; | ||||||
|  |   return ( | ||||||
|  |     <Wrapper> | ||||||
|  |       <Column> | ||||||
|  |         <AccessAccount width={275} height={250} /> | ||||||
|  |       </Column> | ||||||
|  |       <Column> | ||||||
|  |         <LoginFormWrapper> | ||||||
|  |           <LoginFormContainer> | ||||||
|  |             <LogoWrapper> | ||||||
|  |               <Citadel width={42} height={42} /> | ||||||
|  |               <LogoTitle>Citadel</LogoTitle> | ||||||
|  |             </LogoWrapper> | ||||||
|  |             <Title>Register</Title> | ||||||
|  |             <SubTitle>Please create the system admin user</SubTitle> | ||||||
|  |             <Form onSubmit={handleSubmit(loginSubmit)}> | ||||||
|  |               <FormLabel htmlFor="fullname"> | ||||||
|  |                 Full name | ||||||
|  |                 <FormTextInput | ||||||
|  |                   type="text" | ||||||
|  |                   id="fullname" | ||||||
|  |                   name="fullname" | ||||||
|  |                   ref={register({ required: 'Full name is required' })} | ||||||
|  |                 /> | ||||||
|  |                 <FormIcon> | ||||||
|  |                   <User color="#c2c6dc" size={20} /> | ||||||
|  |                 </FormIcon> | ||||||
|  |               </FormLabel> | ||||||
|  |               {errors.username && <FormError>{errors.username.message}</FormError>} | ||||||
|  |               <FormLabel htmlFor="username"> | ||||||
|  |                 Username | ||||||
|  |                 <FormTextInput | ||||||
|  |                   type="text" | ||||||
|  |                   id="username" | ||||||
|  |                   name="username" | ||||||
|  |                   ref={register({ required: 'Username is required' })} | ||||||
|  |                 /> | ||||||
|  |                 <FormIcon> | ||||||
|  |                   <User color="#c2c6dc" size={20} /> | ||||||
|  |                 </FormIcon> | ||||||
|  |               </FormLabel> | ||||||
|  |               {errors.username && <FormError>{errors.username.message}</FormError>} | ||||||
|  |               <FormLabel htmlFor="email"> | ||||||
|  |                 Email | ||||||
|  |                 <FormTextInput | ||||||
|  |                   type="text" | ||||||
|  |                   id="email" | ||||||
|  |                   name="email" | ||||||
|  |                   ref={register({ | ||||||
|  |                     required: 'Email is required', | ||||||
|  |                     pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' }, | ||||||
|  |                   })} | ||||||
|  |                 /> | ||||||
|  |                 <FormIcon> | ||||||
|  |                   <User color="#c2c6dc" size={20} /> | ||||||
|  |                 </FormIcon> | ||||||
|  |               </FormLabel> | ||||||
|  |               {errors.email && <FormError>{errors.email.message}</FormError>} | ||||||
|  |               <FormLabel htmlFor="initials"> | ||||||
|  |                 Initials | ||||||
|  |                 <FormTextInput | ||||||
|  |                   type="text" | ||||||
|  |                   id="initials" | ||||||
|  |                   name="initials" | ||||||
|  |                   ref={register({ | ||||||
|  |                     required: 'Initials is required', | ||||||
|  |                     pattern: { | ||||||
|  |                       value: INITIALS_PATTERN, | ||||||
|  |                       message: 'Initials must be between 2 to 3 characters.', | ||||||
|  |                     }, | ||||||
|  |                   })} | ||||||
|  |                 /> | ||||||
|  |                 <FormIcon> | ||||||
|  |                   <User color="#c2c6dc" size={20} /> | ||||||
|  |                 </FormIcon> | ||||||
|  |               </FormLabel> | ||||||
|  |               {errors.initials && <FormError>{errors.initials.message}</FormError>} | ||||||
|  |               <FormLabel htmlFor="password"> | ||||||
|  |                 Password | ||||||
|  |                 <FormTextInput | ||||||
|  |                   type="password" | ||||||
|  |                   id="password" | ||||||
|  |                   name="password" | ||||||
|  |                   ref={register({ required: 'Password is required' })} | ||||||
|  |                 /> | ||||||
|  |                 <FormIcon> | ||||||
|  |                   <Lock width={20} height={20} /> | ||||||
|  |                 </FormIcon> | ||||||
|  |               </FormLabel> | ||||||
|  |               {errors.password && <FormError>{errors.password.message}</FormError>} | ||||||
|  |               <FormLabel htmlFor="password_confirm"> | ||||||
|  |                 Password (Confirm) | ||||||
|  |                 <FormTextInput | ||||||
|  |                   type="password" | ||||||
|  |                   id="password_confirm" | ||||||
|  |                   name="password_confirm" | ||||||
|  |                   ref={register({ required: 'Password (confirm) is required' })} | ||||||
|  |                 /> | ||||||
|  |                 <FormIcon> | ||||||
|  |                   <Lock width={20} height={20} /> | ||||||
|  |                 </FormIcon> | ||||||
|  |               </FormLabel> | ||||||
|  |               {errors.password_confirm && <FormError>{errors.password_confirm.message}</FormError>} | ||||||
|  |  | ||||||
|  |               <ActionButtons> | ||||||
|  |                 <RegisterButton type="submit" disabled={!isComplete}> | ||||||
|  |                   Register | ||||||
|  |                 </RegisterButton> | ||||||
|  |               </ActionButtons> | ||||||
|  |             </Form> | ||||||
|  |           </LoginFormContainer> | ||||||
|  |         </LoginFormWrapper> | ||||||
|  |       </Column> | ||||||
|  |     </Wrapper> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Register; | ||||||
| @@ -263,13 +263,13 @@ const Settings: React.FC<SettingsProps> = ({ onProfileAvatarRemove, onProfileAva | |||||||
|             onProfileAvatarChange={onProfileAvatarChange} |             onProfileAvatarChange={onProfileAvatarChange} | ||||||
|             profile={profile.profileIcon} |             profile={profile.profileIcon} | ||||||
|           /> |           /> | ||||||
|           <Input value={profile.fullName} width="100%" label="Name" /> |           <Input defaultValue={profile.fullName} width="100%" label="Name" /> | ||||||
|           <Input |           <Input | ||||||
|             value={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''} |             defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''} | ||||||
|             width="100%" |             width="100%" | ||||||
|             label="Initials " |             label="Initials " | ||||||
|           /> |           /> | ||||||
|           <Input value={profile.username ?? ''} width="100%" label="Username " /> |           <Input defaultValue={profile.username ?? ''} width="100%" label="Username " /> | ||||||
|           <Input width="100%" label="Email" /> |           <Input width="100%" label="Email" /> | ||||||
|           <Input width="100%" label="Bio" /> |           <Input width="100%" label="Bio" /> | ||||||
|           <SettingActions> |           <SettingActions> | ||||||
|   | |||||||
| @@ -267,6 +267,7 @@ export type Mutation = { | |||||||
|   updateTaskLocation: UpdateTaskLocationPayload; |   updateTaskLocation: UpdateTaskLocationPayload; | ||||||
|   updateTaskName: Task; |   updateTaskName: Task; | ||||||
|   updateTeamMemberRole: UpdateTeamMemberRolePayload; |   updateTeamMemberRole: UpdateTeamMemberRolePayload; | ||||||
|  |   updateUserPassword: UpdateUserPasswordPayload; | ||||||
|   updateUserRole: UpdateUserRolePayload; |   updateUserRole: UpdateUserRolePayload; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -506,6 +507,11 @@ export type MutationUpdateTeamMemberRoleArgs = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationUpdateUserPasswordArgs = { | ||||||
|  |   input: UpdateUserPassword; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
| export type MutationUpdateUserRoleArgs = { | export type MutationUpdateUserRoleArgs = { | ||||||
|   input: UpdateUserRole; |   input: UpdateUserRole; | ||||||
| }; | }; | ||||||
| @@ -862,6 +868,17 @@ export type SetTeamOwnerPayload = { | |||||||
|   newOwner: Member; |   newOwner: Member; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export type UpdateUserPassword = { | ||||||
|  |   userID: Scalars['UUID']; | ||||||
|  |   password: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type UpdateUserPasswordPayload = { | ||||||
|  |    __typename?: 'UpdateUserPasswordPayload'; | ||||||
|  |   ok: Scalars['Boolean']; | ||||||
|  |   user: UserAccount; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export type UpdateUserRole = { | export type UpdateUserRole = { | ||||||
|   userID: Scalars['UUID']; |   userID: Scalars['UUID']; | ||||||
|   roleCode: RoleCode; |   roleCode: RoleCode; | ||||||
|   | |||||||
| @@ -13581,10 +13581,10 @@ react-helmet-async@^1.0.2: | |||||||
|     react-fast-compare "^2.0.4" |     react-fast-compare "^2.0.4" | ||||||
|     shallowequal "^1.1.0" |     shallowequal "^1.1.0" | ||||||
|  |  | ||||||
| react-hook-form@^5.2.0: | react-hook-form@^6.0.6: | ||||||
|   version "5.2.0" |   version "6.0.6" | ||||||
|   resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.2.0.tgz#b5b654516ee03d55d78b7b9e194c7f4632885426" |   resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.0.6.tgz#72ac1668aeaddfd642bcfb324cebe1ba237fb13e" | ||||||
|   integrity sha512-EqGCSl3DxSUBtL/9lFvrFQLJ7ICdVKrfjcMHay2SvmU4trR8aqrd7YuiLSojBKmZBRdBnCcxG+LzLWF9z474NA== |   integrity sha512-qxWhV++1V7SKKlr2hHFsessGwATCdexgVsByxOHltDyO9F0VWB1WN4ZvxnKuHTVGwjj6CLZo0mL+Hgy0QH1sAw== | ||||||
|  |  | ||||||
| react-hotkeys@2.0.0: | react-hotkeys@2.0.0: | ||||||
|   version "2.0.0" |   version "2.0.0" | ||||||
|   | |||||||
| @@ -9,8 +9,16 @@ import ( | |||||||
|  |  | ||||||
| var jwtKey = []byte("citadel_test_key") | var jwtKey = []byte("citadel_test_key") | ||||||
|  |  | ||||||
|  | type RestrictedMode string | ||||||
|  |  | ||||||
|  | const ( | ||||||
|  | 	Unrestricted RestrictedMode = "unrestricted" | ||||||
|  | 	InstallOnly                 = "install_only" | ||||||
|  | ) | ||||||
|  |  | ||||||
| type AccessTokenClaims struct { | type AccessTokenClaims struct { | ||||||
| 	UserID string `json:"userId"` | 	UserID     string         `json:"userId"` | ||||||
|  | 	Restricted RestrictedMode `json:"restricted"` | ||||||
| 	jwt.StandardClaims | 	jwt.StandardClaims | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -31,10 +39,11 @@ func (r *ErrMalformedToken) Error() string { | |||||||
| 	return "token is malformed" | 	return "token is malformed" | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewAccessToken(userID string) (string, error) { | func NewAccessToken(userID string, restrictedMode RestrictedMode) (string, error) { | ||||||
| 	accessExpirationTime := time.Now().Add(5 * time.Second) | 	accessExpirationTime := time.Now().Add(5 * time.Second) | ||||||
| 	accessClaims := &AccessTokenClaims{ | 	accessClaims := &AccessTokenClaims{ | ||||||
| 		UserID:         userID, | 		UserID:         userID, | ||||||
|  | 		Restricted:     restrictedMode, | ||||||
| 		StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()}, | 		StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()}, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -50,6 +59,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e | |||||||
| 	accessExpirationTime := time.Now().Add(dur) | 	accessExpirationTime := time.Now().Add(dur) | ||||||
| 	accessClaims := &AccessTokenClaims{ | 	accessClaims := &AccessTokenClaims{ | ||||||
| 		UserID:         userID, | 		UserID:         userID, | ||||||
|  | 		Restricted:     Unrestricted, | ||||||
| 		StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()}, | 		StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()}, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -58,6 +58,12 @@ type Role struct { | |||||||
| 	Name string `json:"name"` | 	Name string `json:"name"` | ||||||
| } | } | ||||||
|  |  | ||||||
|  | type SystemOption struct { | ||||||
|  | 	OptionID uuid.UUID      `json:"option_id"` | ||||||
|  | 	Key      string         `json:"key"` | ||||||
|  | 	Value    sql.NullString `json:"value"` | ||||||
|  | } | ||||||
|  |  | ||||||
| type Task struct { | type Task struct { | ||||||
| 	TaskID      uuid.UUID      `json:"task_id"` | 	TaskID      uuid.UUID      `json:"task_id"` | ||||||
| 	TaskGroupID uuid.UUID      `json:"task_group_id"` | 	TaskGroupID uuid.UUID      `json:"task_group_id"` | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ type Querier interface { | |||||||
| 	CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error) | 	CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error) | ||||||
| 	CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error) | 	CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error) | ||||||
| 	CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) | 	CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) | ||||||
|  | 	CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error) | ||||||
| 	CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) | 	CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) | ||||||
| 	CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error) | 	CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error) | ||||||
| 	CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error) | 	CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error) | ||||||
| @@ -61,6 +62,7 @@ type Querier interface { | |||||||
| 	GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error) | 	GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error) | ||||||
| 	GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error) | 	GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error) | ||||||
| 	GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error) | 	GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error) | ||||||
|  | 	GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error) | ||||||
| 	GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error) | 	GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error) | ||||||
| 	GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error) | 	GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error) | ||||||
| 	GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error) | 	GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error) | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								internal/db/query/system_options.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								internal/db/query/system_options.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | -- name: GetSystemOptionByKey :one | ||||||
|  | SELECT key, value FROM system_options WHERE key = $1; | ||||||
|  |  | ||||||
|  | -- name: CreateSystemOption :one | ||||||
|  | INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING *; | ||||||
							
								
								
									
										41
									
								
								internal/db/system_options.sql.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								internal/db/system_options.sql.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | // Code generated by sqlc. DO NOT EDIT. | ||||||
|  | // source: system_options.sql | ||||||
|  |  | ||||||
|  | package db | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"database/sql" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | const createSystemOption = `-- name: CreateSystemOption :one | ||||||
|  | INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING option_id, key, value | ||||||
|  | ` | ||||||
|  |  | ||||||
|  | type CreateSystemOptionParams struct { | ||||||
|  | 	Key   string         `json:"key"` | ||||||
|  | 	Value sql.NullString `json:"value"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (q *Queries) CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error) { | ||||||
|  | 	row := q.db.QueryRowContext(ctx, createSystemOption, arg.Key, arg.Value) | ||||||
|  | 	var i SystemOption | ||||||
|  | 	err := row.Scan(&i.OptionID, &i.Key, &i.Value) | ||||||
|  | 	return i, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const getSystemOptionByKey = `-- name: GetSystemOptionByKey :one | ||||||
|  | SELECT key, value FROM system_options WHERE key = $1 | ||||||
|  | ` | ||||||
|  |  | ||||||
|  | type GetSystemOptionByKeyRow struct { | ||||||
|  | 	Key   string         `json:"key"` | ||||||
|  | 	Value sql.NullString `json:"value"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (q *Queries) GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error) { | ||||||
|  | 	row := q.db.QueryRowContext(ctx, getSystemOptionByKey, key) | ||||||
|  | 	var i GetSystemOptionByKeyRow | ||||||
|  | 	err := row.Scan(&i.Key, &i.Value) | ||||||
|  | 	return i, err | ||||||
|  | } | ||||||
| @@ -12,6 +12,7 @@ import ( | |||||||
| 	"github.com/99designs/gqlgen/graphql/handler/transport" | 	"github.com/99designs/gqlgen/graphql/handler/transport" | ||||||
| 	"github.com/99designs/gqlgen/graphql/playground" | 	"github.com/99designs/gqlgen/graphql/playground" | ||||||
| 	"github.com/google/uuid" | 	"github.com/google/uuid" | ||||||
|  | 	"github.com/jordanknott/project-citadel/api/internal/auth" | ||||||
| 	"github.com/jordanknott/project-citadel/api/internal/config" | 	"github.com/jordanknott/project-citadel/api/internal/config" | ||||||
| 	"github.com/jordanknott/project-citadel/api/internal/db" | 	"github.com/jordanknott/project-citadel/api/internal/db" | ||||||
| ) | ) | ||||||
| @@ -49,7 +50,13 @@ func NewHandler(config config.AppConfig, repo db.Repository) http.Handler { | |||||||
| func NewPlaygroundHandler(endpoint string) http.Handler { | func NewPlaygroundHandler(endpoint string) http.Handler { | ||||||
| 	return playground.Handler("GraphQL Playground", endpoint) | 	return playground.Handler("GraphQL Playground", endpoint) | ||||||
| } | } | ||||||
|  |  | ||||||
| func GetUserID(ctx context.Context) (uuid.UUID, bool) { | func GetUserID(ctx context.Context) (uuid.UUID, bool) { | ||||||
| 	userID, ok := ctx.Value("userID").(uuid.UUID) | 	userID, ok := ctx.Value("userID").(uuid.UUID) | ||||||
| 	return userID, ok | 	return userID, ok | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) { | ||||||
|  | 	restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode) | ||||||
|  | 	return restricted, ok | ||||||
|  | } | ||||||
|   | |||||||
| @@ -706,6 +706,7 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea | |||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return &DeleteTeamMemberPayload{}, err | 		return &DeleteTeamMemberPayload{}, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID}) | 	_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID}) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return &DeleteTeamMemberPayload{}, err | 		return &DeleteTeamMemberPayload{}, err | ||||||
| @@ -992,7 +993,10 @@ func (r *queryResolver) Me(ctx context.Context) (*db.UserAccount, error) { | |||||||
| 		return &db.UserAccount{}, fmt.Errorf("internal server error") | 		return &db.UserAccount{}, fmt.Errorf("internal server error") | ||||||
| 	} | 	} | ||||||
| 	user, err := r.Repository.GetUserAccountByID(ctx, userID) | 	user, err := r.Repository.GetUserAccountByID(ctx, userID) | ||||||
| 	if err != nil { | 	if err == sql.ErrNoRows { | ||||||
|  | 		log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query") | ||||||
|  | 		return &db.UserAccount{}, nil | ||||||
|  | 	} else if err != nil { | ||||||
| 		return &db.UserAccount{}, err | 		return &db.UserAccount{}, err | ||||||
| 	} | 	} | ||||||
| 	return &user, err | 	return &user, err | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| package route | package route | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"database/sql" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| 	"github.com/dgrijalva/jwt-go" |  | ||||||
| 	"github.com/go-chi/chi" | 	"github.com/go-chi/chi" | ||||||
| 	"github.com/google/uuid" | 	"github.com/google/uuid" | ||||||
| 	"github.com/jordanknott/project-citadel/api/internal/auth" | 	"github.com/jordanknott/project-citadel/api/internal/auth" | ||||||
| @@ -18,23 +18,26 @@ var jwtKey = []byte("citadel_test_key") | |||||||
|  |  | ||||||
| type authResource struct{} | type authResource struct{} | ||||||
|  |  | ||||||
| type AccessTokenClaims struct { |  | ||||||
| 	UserID string `json:"userId"` |  | ||||||
| 	jwt.StandardClaims |  | ||||||
| } |  | ||||||
|  |  | ||||||
| type RefreshTokenClaims struct { |  | ||||||
| 	UserID string `json:"userId"` |  | ||||||
| 	jwt.StandardClaims |  | ||||||
| } |  | ||||||
|  |  | ||||||
| type LoginRequestData struct { | type LoginRequestData struct { | ||||||
| 	Username string | 	Username string | ||||||
| 	Password string | 	Password string | ||||||
| } | } | ||||||
|  |  | ||||||
|  | type NewUserAccount struct { | ||||||
|  | 	FullName string `json:"fullname"` | ||||||
|  | 	Username string | ||||||
|  | 	Password string | ||||||
|  | 	Initials string | ||||||
|  | 	Email    string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type InstallRequestData struct { | ||||||
|  | 	User NewUserAccount | ||||||
|  | } | ||||||
|  |  | ||||||
| type LoginResponseData struct { | type LoginResponseData struct { | ||||||
| 	AccessToken string `json:"accessToken"` | 	AccessToken string `json:"accessToken"` | ||||||
|  | 	IsInstalled bool   `json:"isInstalled"` | ||||||
| } | } | ||||||
|  |  | ||||||
| type LogoutResponseData struct { | type LogoutResponseData struct { | ||||||
| @@ -51,18 +54,48 @@ type AvatarUploadResponseData struct { | |||||||
| } | } | ||||||
|  |  | ||||||
| func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { | func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { | ||||||
|  |  | ||||||
|  | 	_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed") | ||||||
|  | 	if err == sql.ErrNoRows { | ||||||
|  | 		user, err := h.repo.GetUserAccountByUsername(r.Context(), "system") | ||||||
|  | 		if err != nil { | ||||||
|  | 			w.WriteHeader(http.StatusInternalServerError) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly) | ||||||
|  | 		if err != nil { | ||||||
|  | 			w.WriteHeader(http.StatusInternalServerError) | ||||||
|  | 		} | ||||||
|  | 		w.Header().Set("Content-type", "application/json") | ||||||
|  | 		json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: false}) | ||||||
|  |  | ||||||
|  | 		return | ||||||
|  | 	} else if err != nil { | ||||||
|  | 		log.WithError(err).Error("get system option") | ||||||
|  | 		w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	c, err := r.Cookie("refreshToken") | 	c, err := r.Cookie("refreshToken") | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if err == http.ErrNoCookie { | 		if err == http.ErrNoCookie { | ||||||
| 			w.WriteHeader(http.StatusBadRequest) | 			w.WriteHeader(http.StatusBadRequest) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  | 		log.WithError(err).Error("unknown error") | ||||||
| 		w.WriteHeader(http.StatusBadRequest) | 		w.WriteHeader(http.StatusBadRequest) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	refreshTokenID := uuid.MustParse(c.Value) | 	refreshTokenID := uuid.MustParse(c.Value) | ||||||
| 	token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID) | 	token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		if err == sql.ErrNoRows { | ||||||
|  |  | ||||||
|  | 			log.WithError(err).WithFields(log.Fields{"refreshTokenID": refreshTokenID.String()}).Error("no tokens found") | ||||||
|  | 			w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		log.WithError(err).Error("token retrieve failure") | ||||||
| 		w.WriteHeader(http.StatusBadRequest) | 		w.WriteHeader(http.StatusBadRequest) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| @@ -76,7 +109,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ | |||||||
| 		w.WriteHeader(http.StatusInternalServerError) | 		w.WriteHeader(http.StatusInternalServerError) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	accessTokenString, err := auth.NewAccessToken(token.UserID.String()) | 	accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		w.WriteHeader(http.StatusInternalServerError) | 		w.WriteHeader(http.StatusInternalServerError) | ||||||
| 	} | 	} | ||||||
| @@ -88,7 +121,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ | |||||||
| 		Expires:  refreshExpiresAt, | 		Expires:  refreshExpiresAt, | ||||||
| 		HttpOnly: true, | 		HttpOnly: true, | ||||||
| 	}) | 	}) | ||||||
| 	json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString}) | 	json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true}) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (h *CitadelHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { | func (h *CitadelHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { | ||||||
| @@ -142,7 +175,7 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { | |||||||
| 	refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) | 	refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) | ||||||
| 	refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) | 	refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) | ||||||
|  |  | ||||||
| 	accessTokenString, err := auth.NewAccessToken(user.UserID.String()) | 	accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		w.WriteHeader(http.StatusInternalServerError) | 		w.WriteHeader(http.StatusInternalServerError) | ||||||
| 	} | 	} | ||||||
| @@ -154,7 +187,68 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { | |||||||
| 		Expires:  refreshExpiresAt, | 		Expires:  refreshExpiresAt, | ||||||
| 		HttpOnly: true, | 		HttpOnly: true, | ||||||
| 	}) | 	}) | ||||||
| 	json.NewEncoder(w).Encode(LoginResponseData{accessTokenString}) | 	json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *CitadelHandler) InstallHandler(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok { | ||||||
|  | 		if restricted != auth.InstallOnly { | ||||||
|  | 			log.Warning("attempted to install without install only restriction") | ||||||
|  | 			w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed") | ||||||
|  | 	if err != sql.ErrNoRows { | ||||||
|  | 		log.WithError(err).Error("install handler called even though system is installed") | ||||||
|  | 		w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var requestData InstallRequestData | ||||||
|  | 	err = json.NewDecoder(r.Body).Decode(&requestData) | ||||||
|  | 	if err != nil { | ||||||
|  | 		w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	log.WithFields(log.Fields{"r": requestData}).Info("install") | ||||||
|  |  | ||||||
|  | 	createdAt := time.Now().UTC() | ||||||
|  | 	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14) | ||||||
|  | 	user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{ | ||||||
|  | 		Username:     requestData.User.Username, | ||||||
|  | 		Initials:     requestData.User.Initials, | ||||||
|  | 		Email:        requestData.User.Email, | ||||||
|  | 		PasswordHash: string(hashedPwd), | ||||||
|  | 		CreatedAt:    createdAt, | ||||||
|  | 		RoleCode:     "admin", | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	_, err = h.repo.CreateSystemOption(r.Context(), db.CreateSystemOptionParams{Key: "is_installed", Value: sql.NullString{Valid: true, String: "true"}}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	refreshCreatedAt := time.Now().UTC() | ||||||
|  | 	refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) | ||||||
|  | 	refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) | ||||||
|  |  | ||||||
|  | 	accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted) | ||||||
|  | 	if err != nil { | ||||||
|  | 		w.WriteHeader(http.StatusInternalServerError) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-type", "application/json") | ||||||
|  | 	http.SetCookie(w, &http.Cookie{ | ||||||
|  | 		Name:     "refreshToken", | ||||||
|  | 		Value:    refreshTokenString.TokenID.String(), | ||||||
|  | 		Expires:  refreshExpiresAt, | ||||||
|  | 		HttpOnly: true, | ||||||
|  | 	}) | ||||||
|  | 	json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false}) | ||||||
| } | } | ||||||
|  |  | ||||||
| func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router { | func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router { | ||||||
|   | |||||||
| @@ -40,13 +40,19 @@ func AuthenticationMiddleware(next http.Handler) http.Handler { | |||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		userID, err := uuid.Parse(accessClaims.UserID) | 		var userID uuid.UUID | ||||||
| 		if err != nil { | 		if accessClaims.Restricted == auth.InstallOnly { | ||||||
| 			log.Error(err) | 			userID = uuid.New() | ||||||
| 			w.WriteHeader(http.StatusBadRequest) | 		} else { | ||||||
| 			return | 			userID, err = uuid.Parse(accessClaims.UserID) | ||||||
|  | 			if err != nil { | ||||||
|  | 				log.WithError(err).Error("middleware access token userID parse") | ||||||
|  | 				w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 		ctx := context.WithValue(r.Context(), "userID", userID) | 		ctx := context.WithValue(r.Context(), "userID", userID) | ||||||
|  | 		ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted) | ||||||
|  |  | ||||||
| 		next.ServeHTTP(w, r.WithContext(ctx)) | 		next.ServeHTTP(w, r.WithContext(ctx)) | ||||||
| 	}) | 	}) | ||||||
|   | |||||||
| @@ -104,6 +104,7 @@ func NewRouter(config config.AppConfig, dbConnection *sqlx.DB) (chi.Router, erro | |||||||
| 	r.Group(func(mux chi.Router) { | 	r.Group(func(mux chi.Router) { | ||||||
| 		mux.Use(AuthenticationMiddleware) | 		mux.Use(AuthenticationMiddleware) | ||||||
| 		mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload) | 		mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload) | ||||||
|  | 		mux.Post("/auth/install", citadelHandler.InstallHandler) | ||||||
| 		mux.Handle("/graphql", graph.NewHandler(config, *repository)) | 		mux.Handle("/graphql", graph.NewHandler(config, *repository)) | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								migrations/0045_add-system_options-table.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								migrations/0045_add-system_options-table.up.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | CREATE TABLE system_options ( | ||||||
|  |   option_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), | ||||||
|  |   key text NOT NULL UNIQUE, | ||||||
|  |   value text | ||||||
|  | ); | ||||||
							
								
								
									
										2
									
								
								migrations/0046_add-system-user.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/0046_add-system-user.up.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | INSERT INTO user_account(created_at, email, initials, username, full_name, | ||||||
|  |   role_code, password_hash) VALUES (NOW(), '', 'SYS', 'system', 'System', 'owner', ''); | ||||||
							
								
								
									
										16
									
								
								migrations/0047_add-default-label_colors.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								migrations/0047_add-default-label_colors.up.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( 'transparent', 0.0, 'no_color' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#e8384f', 1.0, 'red' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fd612c', 2.0, 'orange' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fd9a00', 3.0, 'yellow_orange' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#eec300', 4.0, 'yellow' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#a4cf30', 5.0, 'yellow_green' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#62d26f', 6.0, 'green' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#37c5ab', 6.0, 'blue_green' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#20aaea', 6.0, 'aqua' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#4186e0', 6.0, 'blue' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#7a6ff0', 6.0, 'indigo' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#aa62e3', 6.0, 'purple' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#e362e3', 6.0, 'magenta' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#ea4e9d', 6.0, 'hot_pink' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fc91ad', 6.0, 'pink' ); | ||||||
|  | INSERT INTO label_color (color_hex, position, name ) VALUES ( '#8da3a6', 6.0, 'cool_gray' ); | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | ALTER TABLE task_group DROP CONSTRAINT task_group_project_id_fkey; | ||||||
|  | ALTER TABLE task_group | ||||||
|  |   ADD CONSTRAINT task_group_project_id_fkey | ||||||
|  |   FOREIGN KEY (project_id) | ||||||
|  |   REFERENCES project(project_id) | ||||||
|  |   ON DELETE CASCADE; | ||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | +--------------------+--------------------------+--------------------------------------+ | ||||||
|  | | [38;5;47;01mColumn[39;00m             | [38;5;47;01mType[39;00m                     | [38;5;47;01mModifiers[39;00m                            | | ||||||
|  | |--------------------+--------------------------+--------------------------------------| | ||||||
|  | | user_id            | uuid                     |  not null default uuid_generate_v4() | | ||||||
|  | | created_at         | timestamp with time zone |  not null                            | | ||||||
|  | | email              | text                     |  not null                            | | ||||||
|  | | username           | text                     |  not null                            | | ||||||
|  | | password_hash      | text                     |  not null                            | | ||||||
|  | | profile_bg_color   | text                     |  not null default '#7367F0'::text    | | ||||||
|  | | full_name          | text                     |  not null                            | | ||||||
|  | | initials           | text                     |  not null default ''::text           | | ||||||
|  | | profile_avatar_url | text                     |                                      | | ||||||
|  | | role_code          | text                     |  not null default 'member'::text     | | ||||||
|  | +--------------------+--------------------------+--------------------------------------+ | ||||||
|  | Indexes: | ||||||
|  |     "user_account_pkey" PRIMARY KEY, btree (user_id) | ||||||
|  |     "user_account_email_key" UNIQUE CONSTRAINT, btree (email) | ||||||
|  |     "user_account_username_key" UNIQUE CONSTRAINT, btree (username) | ||||||
|  | Foreign-key constraints: | ||||||
|  |     "user_account_role_code_fkey" FOREIGN KEY (role_code) REFERENCES role(code) ON DELETE CASCADE | ||||||
|  | Referenced by: | ||||||
|  |     TABLE "refresh_token" CONSTRAINT "refresh_token_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE | ||||||
|  |     TABLE "team" CONSTRAINT "team_owner_fkey" FOREIGN KEY (owner) REFERENCES user_account(user_id) ON DELETE CASCADE | ||||||
|  |     TABLE "task_assigned" CONSTRAINT "task_assigned_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE | ||||||
|  |     TABLE "team_member" CONSTRAINT "team_member_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE | ||||||
|  |     TABLE "project_member" CONSTRAINT "project_member_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE | ||||||
|  |  | ||||||
		Reference in New Issue
	
	Block a user