diff --git a/client/src/components/forms/ProjectForm/index.tsx b/client/src/components/forms/ProjectForm/index.tsx index bf634ff99f09e55e0854b534fefed362da4728dd..9866602616467fe1a6dd1668218348e07fbaebf1 100644 --- a/client/src/components/forms/ProjectForm/index.tsx +++ b/client/src/components/forms/ProjectForm/index.tsx @@ -107,9 +107,9 @@ export default function ProjectForm({ project, onSubmit }: Props) { validateColor(color ?? '') === null && validateTeams(teams) === null ) { - onSubmit?.(teams, name ?? '', text ?? '', color ?? '', status ?? Status.OPEN, deadline); } else { + window.scrollTo(0, 0); setError('Please fill in the mandatory fields.'); } }, [onSubmit, setError, name, text, color, deadline, teams, status]); diff --git a/client/src/components/forms/TaskForm/index.tsx b/client/src/components/forms/TaskForm/index.tsx index bece907a0245c790be87ec838a40fab6fe6d880d..e3471986953c2fd2932682d7cef97ef9c65a2ccd 100644 --- a/client/src/components/forms/TaskForm/index.tsx +++ b/client/src/components/forms/TaskForm/index.tsx @@ -114,6 +114,7 @@ export default function TaskForm({ task, onSubmit, project }: Props) { ) { onSubmit?.(name ?? '', text ?? '', icon ?? '', priority ?? Priority.LOW, tasks ?? [], requirements, assignees, status); } else { + window.scrollTo(0, 0); setError('Please fill in the mandatory fields.'); } }, [onSubmit, setError, name, text, priority, icon, tasks, assignees, requirements, status]); diff --git a/client/src/components/forms/TeamForm/index.tsx b/client/src/components/forms/TeamForm/index.tsx index 68d5e7177a10b30f86288ddc7edecaf5e74a1b65..f3b9f2b918af3170d0e19c3faec576c392bfbf14 100644 --- a/client/src/components/forms/TeamForm/index.tsx +++ b/client/src/components/forms/TeamForm/index.tsx @@ -17,7 +17,7 @@ export function validateName(name: string): string | null { return 'The name is required'; } -export default function TeamCreateForm({ onSubmit, onBack, team }: Props) { +export default function TeamForm({ onSubmit, onBack, team }: Props) { const [name, setName] = useState(team?.name ?? ''); const handleSubmit = useCallback(async (e: FormEvent) => { e.preventDefault(); diff --git a/client/src/components/forms/UserForm/index.tsx b/client/src/components/forms/UserForm/index.tsx index aff4a4e0f53cf26024cff3f4c6243a270d82c156..24c639191168fb5797e2550913edfb80cbbd7cc8 100644 --- a/client/src/components/forms/UserForm/index.tsx +++ b/client/src/components/forms/UserForm/index.tsx @@ -4,31 +4,34 @@ import './user-form.scss'; import TextInput from 'components/ui/TextInput'; import Button from 'components/ui/Button'; import '../form.scss'; +import Callout from 'components/ui/Callout'; interface Props { onSubmit?: (name?: string, email?: string, avatar?: File) => void; user: User } +const validTypes = ['image/jpg', 'image/png', 'image/gif', 'image/svg'] + function validateEmail(email?: string): string | null { if (email && email.length > 0) { if (email.match('^[^\\s]+@[^\\s]+$')) { return null; } else { - return 'Please enter a valid email or let this field blank.' + return 'Please enter a valid email or leave this field blank.' } } else { + console.log(email); return null; } } function validateAvatar(avatar?: File): string | null { - const validTypes = ['image/jpg', 'image/png', 'image/gif'] if (avatar) { if (validTypes.find((type) => type === avatar.type)) { return null; } else { - return 'Only files from type jpg, png or gif are allowed' + return 'Only files from type jpg, png or gif are allowed'; } } else { return null; @@ -38,15 +41,20 @@ function validateAvatar(avatar?: File): string | null { export default function UserForm({ user, onSubmit }: Props) { const [name, setName] = useState(user.realname); const [email, setEmail] = useState(user.email); + const [error, setError] = useState(''); + const [avatarError, setAvatarError] = useState(''); const [avatar, setAvatar] = useState<File>(); const handleSubmit = useCallback(async (e: FormEvent) => { e.preventDefault(); - if (validateEmail(email) === null || validateAvatar(avatar) === null) { + if (validateEmail(email) === null && validateAvatar(avatar) === null) { onSubmit?.(name, email, avatar); + } else { + setError('Please fill in the mandatory fields.'); } }, [onSubmit, name, email, avatar]); return ( <form onSubmit={handleSubmit} className="user-form"> + {error && <Callout message={error} />} <div className="fields"> <div className="fields-row"> <div className="col"> @@ -68,16 +76,21 @@ export default function UserForm({ user, onSubmit }: Props) { /> </div> </div> - <div className="avatar-upload"> + <div className="avatar-upload input-element"> <div className="label">Avatar</div> <label htmlFor="avatar" className="avatar-field"> <input type="file" id="avatar" name="avatar" onChange={(e) => { if (e.target.files && e.target.files.length > 0) { - setAvatar(e.target.files[0]) + if (validateAvatar(e.target.files[0])) { + setAvatarError(validateAvatar(e.target.files[0]) ?? ''); + } else { + setAvatar(e.target.files[0]) + } } }} /> {avatar ? 'Selected file: ' + avatar.name : 'Select a file'} </label> + {avatarError && <div className="error">{avatarError}</div>} </div> </div> <Button type="submit" className="expanded"> diff --git a/client/src/components/forms/UserForm/user-form.scss b/client/src/components/forms/UserForm/user-form.scss index 9b53b87fa8c3e5bf4fa126da53846bbd90b85886..1bb6bd1ebd0bab8bad08900c9c1646d59beca5fd 100644 --- a/client/src/components/forms/UserForm/user-form.scss +++ b/client/src/components/forms/UserForm/user-form.scss @@ -12,8 +12,8 @@ .label { position: absolute; - top: -2px; - left: 30px; + top: -10px; + left: 20px; font-weight: s.$weight-bold; } @@ -28,9 +28,11 @@ margin-bottom: 20px; background: rgba(0, 0, 0, 0.025); font-size: 18px; - &:hover { - background: rgba(0, 0, 0, 0.04); + + &:hover { + background: rgba(0, 0, 0, 0.04); } + @include mx.breakpoint(large) { margin-top: 30px; } diff --git a/client/src/components/graphs/BarChart/bar-chart.scss b/client/src/components/graphs/BarChart/bar-chart.scss index da3f8c8e6763db7770cb841225f47b2b26c1e884..2258f99d8c01c58000e92a4d140b6c704a067937 100644 --- a/client/src/components/graphs/BarChart/bar-chart.scss +++ b/client/src/components/graphs/BarChart/bar-chart.scss @@ -6,7 +6,7 @@ padding: 50px; background: s.$white; border-radius: 10px; - + .error-screen { width: 100%; height: 100%; @@ -21,7 +21,9 @@ align-items: flex-end; height: 100%; position: relative; - &:before, &:after { + + &:before, + &:after { content: ' '; position: absolute; bottom: 0; @@ -32,10 +34,39 @@ z-index: 0; border-radius: 5px; } + &:after { bottom: 48px; } + .tooltip { + position: absolute; + top: -8px; + left: 50%; + transform: translate(-50%, -75%); + padding: 10px; + background: s.$white; + color: s.$body-color; + border-radius: 5px; + display: block; + visibility: hidden; + opacity: 0; + box-shadow: 0 0 10px rgba(s.$black, 0.15); + + &:before { + content: ' '; + position: absolute; + bottom: 0; + width: 0; + height: 0; + left: 50%; + transform: translate(-50%, 100%); + border-width: 8px 8px 0 8px; + border-color: s.$white transparent transparent transparent; + border-style: solid; + } + } + .bar { background: s.$linear-gradient; width: 24px; @@ -44,6 +75,16 @@ border-top-left-radius: 5px; border-top-right-radius: 5px; + &:hover { + opacity: 0.9; + + .tooltip { + transform: translate(-50%, -100%); + opacity: 1; + visibility: visible; + } + } + .label { position: absolute; bottom: -5px; diff --git a/client/src/components/graphs/BarChart/index.tsx b/client/src/components/graphs/BarChart/index.tsx index 03338c1f0c7121bca57120b1ccca1c482a3cb8c8..b047282f601545adca3626c5f1912f5c2979d661 100644 --- a/client/src/components/graphs/BarChart/index.tsx +++ b/client/src/components/graphs/BarChart/index.tsx @@ -7,9 +7,11 @@ export interface ChartItem { interface Props { data: ChartItem[]; + unit?: string; + multiplicator?: number; } -export default function BarChart({ data }: Props) { +export default function BarChart({ data, unit, multiplicator }: Props) { let maxValue = data.map(e => e.value).sort((a, b) => b - a)[0]; return ( <div className="bar-chart-container"> @@ -25,6 +27,9 @@ export default function BarChart({ data }: Props) { <div className="label"> {item.label} </div> + <div className="tooltip"> + {(item.value * (multiplicator ?? 1)).toFixed(2) + (unit ?? '')} + </div> </div> )) } diff --git a/client/src/components/layout/CommentList/index.tsx b/client/src/components/layout/CommentList/index.tsx index 6d92c08841255e73046f7d77948b124d55732189..1012bb571e225f7f52d8c1e302247d7eec0d89df 100644 --- a/client/src/components/layout/CommentList/index.tsx +++ b/client/src/components/layout/CommentList/index.tsx @@ -3,8 +3,7 @@ import { getCurrentUser, User } from 'adapters/user'; import Avatar from 'components/ui/Avatar'; import './comment-list.scss'; import { FormEvent, useCallback, useEffect, useState } from 'react'; -import { createComment } from 'adapters/comment'; -import { useHistory } from 'react-router'; +import { createComment, getComment } from 'adapters/comment'; interface Props { comments: CommentProps[] @@ -15,19 +14,24 @@ export default function CommentList({ comments, taskId }: Props) { const [user, setUser] = useState<User>(); const [comment, setComment] = useState<string>(''); - const history = useHistory(); + const [allComments, setComments] = useState<CommentProps[]>(comments); useEffect(() => { getCurrentUser().then((user) => setUser(user)); }, []); const handleSubmit = useCallback((e: FormEvent) => { e.preventDefault(); - if(comment.length > 0) { - if(createComment({task: taskId, text: comment})) { - history.go(0); - } + if (comment.length > 0) { + createComment({ task: taskId, text: comment }).then(id => { + getComment(id).then(comment => { + setComments(state => [...state, { + comment: comment + }]) + }) + }); + setComment(''); } - }, [comment, taskId, history]) + }, [comment, taskId]); return ( <div className="comment-list"> @@ -43,14 +47,14 @@ export default function CommentList({ comments, taskId }: Props) { </div> </div> <form onSubmit={handleSubmit}> - <textarea placeholder="Write a comment..." onChange={(e) => setComment(e.target.value)}></textarea> + <textarea value={comment} placeholder="Write a comment..." onChange={(e) => setComment(e.target.value)}></textarea> <button type="submit">Send</button> </form> </div> ) } - {comments.map(comment => ( + {allComments.map(comment => ( <Comment key={comment.comment.id} {...comment} /> ))} diff --git a/client/src/components/navigation/Sidebar/index.tsx b/client/src/components/navigation/Sidebar/index.tsx index 3787ff73caadb2f154bd7baa19258404f74326c2..3ac149f1a5de74e0c81d21c02d740e003a63e5f6 100644 --- a/client/src/components/navigation/Sidebar/index.tsx +++ b/client/src/components/navigation/Sidebar/index.tsx @@ -23,7 +23,7 @@ export default function Sidebar({ mobileShown, setMobileShown }: Props) { if (isLoggedIn()) { getCurrentUser().then((user) => { setUser(user); - getUserActivity(subtractTime(new Date(), 1, 'week'), new Date()).then((a) => + getUserActivity(subtractTime(new Date(), 1, 'week'), new Date()).then((a) => setActivity(parseActivity(a)) ); }).catch(() => { }); @@ -70,7 +70,7 @@ export default function Sidebar({ mobileShown, setMobileShown }: Props) { { activity ? ( <div className="stats"> - <BarChart data={activity} /> + <BarChart unit="h" multiplicator={1 / 60 / 60 / 1000} data={activity} /> <div className="comment">Recent activity</div> </div> ) : <LoadingScreen /> diff --git a/client/src/config.ts b/client/src/config.ts index a794e21e22ce31dfe62034bf3299d55bdc7f31fb..a54e8256377cc0d8818c4bd5a1f5c4305ddd50ab 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -1,8 +1,8 @@ export let apiRoot: string; if (process.env.NODE_ENV === 'production') { - apiRoot = `${window.location.origin}/v1`; + apiRoot = `${window.location.origin}/api/v1`; } else { - apiRoot = `http://localhost:8000/v1`; + apiRoot = `http://localhost:8000/api/v1`; } diff --git a/client/src/pages/AppWrapper.tsx b/client/src/pages/AppWrapper.tsx index a5dec26191f098d62f61e9d122b720fb099f5851..21a2bd98c4212ece5bc7f09e423eec9a8cad8a26 100644 --- a/client/src/pages/AppWrapper.tsx +++ b/client/src/pages/AppWrapper.tsx @@ -17,6 +17,7 @@ const Projects = lazy(() => import('pages/Projects')); const Stats = lazy(() => import('pages/Stats')); const TeamsEdit = lazy(() => import('pages/Teams/TeamsEdit')); const Teams = lazy(() => import('pages/Teams')); +const TeamsCreate = lazy(() => import('pages/Teams/TeamsCreate')); const Settings = lazy(() => import('pages/Settings')); @@ -36,6 +37,7 @@ export default function AppWrapper() { <ProtectedRoute path="/projects" component={Projects} /> <ProtectedRoute path="/stats" component={Stats} /> <ProtectedRoute path="/settings" component={Settings} /> + <ProtectedRoute path="/teams/create" exact component={TeamsCreate} /> <ProtectedRoute path="/teams/:teamId/edit" exact component={TeamsEdit} /> <ProtectedRoute path={['/teams/:teamId', '/teams']} component={Teams} /> </Switch> diff --git a/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx b/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx index 07e56120346f5f0dfd7163fa187c7c23419a621c..6b0aaebb3abb9365e9741a5dc6f292da4ae22ff3 100644 --- a/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx +++ b/client/src/pages/Projects/ProjectDetail/ProjectDetails/index.tsx @@ -43,8 +43,8 @@ export default function ProjectDetails({ project }: Props) { <DetailGrid details={details} /> { activity ? - <BarChart data={activity} /> : - <LoadingScreen /> + <BarChart unit="h" multiplicator={1 / 60 / 60 / 1000} data={activity} /> + : <LoadingScreen /> } <ButtonLink routing href={`/projects/${project.id}/edit`} className="expanded"> Edit diff --git a/client/src/pages/Settings/index.tsx b/client/src/pages/Settings/index.tsx index 5b8ca6fbfe76550c1abdea9529f5634eb4f0c7e9..65e823450f086766c97ec1532a9b0f668c8cffee 100644 --- a/client/src/pages/Settings/index.tsx +++ b/client/src/pages/Settings/index.tsx @@ -4,9 +4,11 @@ import { getCurrentUser, updateUser, updateUserImage, User } from 'adapters/user import LoadingScreen from 'components/ui/LoadingScreen'; import UserForm from 'components/forms/UserForm'; import { useHistory } from 'react-router'; +import Callout from 'components/ui/Callout'; export default function Settings() { const [user, setUser] = useState<User>(); + const [error, setError] = useState(''); const history = useHistory(); useEffect(() => { @@ -16,13 +18,14 @@ export default function Settings() { const handleSubmit = useCallback(async (name?: string, email?: string, avatar?: File) => { try { - if (user && updateUser({realname: name, email })) { - if(avatar) { + if (user && updateUser({ realname: name, email })) { + if (avatar) { updateUserImage(avatar); } history.push('/tasks'); } } catch (e) { + setError('There was an issue with saving your settings. Please try again!') } }, [history, user]); @@ -34,6 +37,7 @@ export default function Settings() { <div className="description-container"> Here you can edit your personal information. </div> + {error && <Callout message={error} />} <UserForm user={user} onSubmit={handleSubmit} /> </div> </div> diff --git a/client/src/pages/Stats/index.tsx b/client/src/pages/Stats/index.tsx index b21a848ff4802dc76aaa288142a427948e1b4d79..e54315666a2d04869393873fbc29c3f0b1fcd1f1 100644 --- a/client/src/pages/Stats/index.tsx +++ b/client/src/pages/Stats/index.tsx @@ -28,7 +28,7 @@ export default function Tasks() { Here are some of your recent statistics. </div> <h2>Activity</h2> - <BarChart data={activity} /> + <BarChart unit="h" multiplicator={1 / 60 / 60 / 1000} data={activity} /> <h2>Completion</h2> <CompletionGrid items={completions} /> </div> diff --git a/client/src/pages/Teams/TeamsCreate/index.tsx b/client/src/pages/Teams/TeamsCreate/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..466d7cb0970f18a29782bd1cf61061ed26416d59 --- /dev/null +++ b/client/src/pages/Teams/TeamsCreate/index.tsx @@ -0,0 +1,28 @@ +import { createTeam } from "adapters/team"; +import TeamForm from "components/forms/TeamForm"; +import Callout from "components/ui/Callout"; +import { useCallback, useState } from "react"; +import { useHistory } from "react-router"; + +export default function TeamsCreate() { + const [error, setError] = useState(''); + const history = useHistory(); + const handleCreateTeam = useCallback(async (name: string) => { + try { + const team = await createTeam(name); + history.push('/teams/' + team); + } catch (e) { + setError('There was an issue with creating your team. Please try again.') + } + }, [history]); + + return ( + <div className="teams-create-page"> + <div className="content-container"> + <h1>Create a new team</h1> + {error && <Callout message={error} />} + <TeamForm onSubmit={handleCreateTeam} /> + </div> + </div> + ); +} \ No newline at end of file diff --git a/client/src/pages/Teams/TeamsEdit/index.tsx b/client/src/pages/Teams/TeamsEdit/index.tsx index 13d58a6dc816382ce5b2e5139095bb838fb1c1cf..177ff2ea5a53b7d5b5fc151e236c32fab5451217 100644 --- a/client/src/pages/Teams/TeamsEdit/index.tsx +++ b/client/src/pages/Teams/TeamsEdit/index.tsx @@ -4,6 +4,7 @@ import { useHistory, useParams } from 'react-router'; import TeamForm from 'components/forms/TeamForm'; import './teams-edit.scss'; import LoadingScreen from 'components/ui/LoadingScreen'; +import Callout from 'components/ui/Callout'; interface Params { teamId: string; @@ -12,6 +13,7 @@ interface Params { export default function TeamsEdit() { const [team, setTeam] = useState<Team>(); const { teamId } = useParams<Params>(); + const [error, setError] = useState(''); const history = useHistory(); useEffect(() => { @@ -20,7 +22,7 @@ export default function TeamsEdit() { }).catch(() => { history.push('/teams'); }); - }); + }, [teamId, history]); const handleEditTeam = useCallback(async (name: string) => { try { @@ -28,14 +30,17 @@ export default function TeamsEdit() { await updateTeam(team.id, name); history.push('/teams/' + team.id) } - } catch (e) { } + } catch (e) { + setError('There was an issue with updating your team. Please try again.') + } }, [team, history]); if (team) { return ( <div className="team-edit-page"> <div className="content-container"> - <h1>Edit {team?.name}</h1> + <h1>Edit {team.name}</h1> + {error && <Callout message={error} />} <TeamForm team={team} onSubmit={handleEditTeam} /> </div> </div> diff --git a/client/src/pages/Teams/TeamsStats/index.tsx b/client/src/pages/Teams/TeamsStats/index.tsx index 87f6373956df89b464ca197aba54b3a68201740b..04e75d001be6ab19fe5ec00b1173db5a4950d8e7 100644 --- a/client/src/pages/Teams/TeamsStats/index.tsx +++ b/client/src/pages/Teams/TeamsStats/index.tsx @@ -68,7 +68,7 @@ export default function TeamsStats({ teamId }: Props) { <h3>Activities</h3> { activity ? - <BarChart data={activity} /> + <BarChart unit="h" multiplicator={1 / 60 / 60 / 1000} data={activity} /> : <LoadingScreen /> } <h3>Completion</h3> diff --git a/client/src/pages/Teams/index.tsx b/client/src/pages/Teams/index.tsx index 64e1be8ac6e0f04e19463f6f51d15c2c80dfeb7a..d18af396236ad47a695b5cb1a739710f0ad99617 100644 --- a/client/src/pages/Teams/index.tsx +++ b/client/src/pages/Teams/index.tsx @@ -124,6 +124,9 @@ export default function Teams() { Leave Team </Button>) } + <ButtonLink href={'/teams/create'} className="expanded dark"> + Create a new team + </ButtonLink> </div> { tabs ? ( diff --git a/server/src/index.ts b/server/src/index.ts index d9ea92232ddf28b66fcd5a8ce09919442be093a7..556f574a4f2d8ce98ca20f3e886b5161dd517339 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,11 +9,15 @@ migrate(); const app = express(); +app.use('/api', api); + if (web_serve) { app.use('/', express.static(web_serve)); -} -app.use(api); + app.get('/', (_, res) => { + res.sendFile('index.html', { root: web_serve }); + }); +} app.listen(port); diff --git a/server/src/v1/project.ts b/server/src/v1/project.ts index a9436ad27aff1602ec6aeb1fb4fdb7dce2174b1b..600a7170aa296854d32a92418ad5d1b39973ca1f 100644 --- a/server/src/v1/project.ts +++ b/server/src/v1/project.ts @@ -61,7 +61,8 @@ project.get('/:uuid', async (req, res) => { 'team_members.user_id': req.body.token.id, 'projects.id': id, }) - .groupBy('tms.team_id'); + .groupBy('tms.team_id') + .groupBy('projects.id'); if (projects.length >= 1) { res.status(200).json({ status: 'success', @@ -248,11 +249,12 @@ project.get('/:uuid/activity', async (req, res) => { .andWhere('workhours.started', '>=', since.getTime()) .andWhere('workhours.started', '<=', to.getTime()) .groupBy('workhours.id') + .as('activity') ) .select({ - day: database.raw('(workhours.started / 1000 / 60 / 60 / 24)'), + day: database.raw('(activity.started / 1000 / 60 / 60 / 24)'), }) - .sum({ time: database.raw('(workhours.finished - workhours.started)') }) + .sum({ time: database.raw('(activity.finished - activity.started)') }) .groupBy('day'); res.status(200).json({ status: 'success', @@ -325,7 +327,6 @@ project.get('/:uuid/completion', async (req, res) => { }); } } catch (e) { - console.log(e); res.status(400).json({ status: 'error', message: 'failed get completion',