From 0d00fc75183022b5397cc373a1b71edb56057b2b Mon Sep 17 00:00:00 2001 From: Jordan Knott Date: Fri, 5 Nov 2021 22:35:57 -0500 Subject: [PATCH] feat: redesign due date manager --- frontend/src/App/Routes.tsx | 1 - frontend/src/Auth/index.tsx | 1 - frontend/src/MyTasks/index.tsx | 50 +- frontend/src/Projects/Project/Board/index.tsx | 28 +- .../src/Projects/Project/Details/index.tsx | 78 +- frontend/src/Register/index.tsx | 1 - .../components/DueDateManager/Styles.ts | 131 +- .../components/DueDateManager/index.tsx | 257 +++- .../src/shared/components/Lists/index.tsx | 4 +- .../src/shared/components/Lists/metaFilter.ts | 52 +- .../TaskDetails/ActivityMessage.tsx | 24 +- .../shared/components/TaskDetails/index.tsx | 7 +- .../shared/components/TaskDetails/remark.js | 4 - .../src/shared/components/TopNavbar/index.tsx | 2 +- frontend/src/shared/generated/graphql.tsx | 161 ++- frontend/src/shared/graphql/findTask.graphqls | 9 +- frontend/src/shared/graphql/fragments/task.ts | 4 +- frontend/src/shared/graphql/myTasks.graphqls | 4 +- .../shared/graphql/updateTaskDueDate.graphqls | 27 +- .../shared/hooks/useStateWithLocalStorage.ts | 1 - frontend/src/shared/icons/Bell.tsx | 12 +- frontend/src/shared/utils/sorting.ts | 2 +- frontend/src/types.d.ts | 2 +- internal/db/models.go | 11 + internal/db/querier.go | 4 + internal/db/query/task.sql | 13 + internal/db/task.sql.go | 85 ++ internal/graph/generated.go | 1079 ++++++++++++++++- internal/graph/models_gen.go | 84 ++ internal/graph/schema/task.gql | 57 +- internal/graph/schema/task/_model.gql | 20 +- internal/graph/schema/task/task.gql | 37 + internal/graph/task.resolvers.go | 133 +- ...0070_add-task_due_date_notification.up.sql | 15 + 34 files changed, 2204 insertions(+), 196 deletions(-) create mode 100644 migrations/0070_add-task_due_date_notification.up.sql diff --git a/frontend/src/App/Routes.tsx b/frontend/src/App/Routes.tsx index 6731fcd..cd7e31e 100644 --- a/frontend/src/App/Routes.tsx +++ b/frontend/src/App/Routes.tsx @@ -61,7 +61,6 @@ const Routes: React.FC = () => { setLoading(false); }); }, []); - console.log('loading', loading); if (loading) return null; return ( diff --git a/frontend/src/Auth/index.tsx b/frontend/src/Auth/index.tsx index 5d4ca19..4d00e79 100644 --- a/frontend/src/Auth/index.tsx +++ b/frontend/src/Auth/index.tsx @@ -9,7 +9,6 @@ const Auth = () => { const history = useHistory(); const location = useLocation<{ redirect: string } | undefined>(); const { setUser } = useContext(UserContext); - console.log('auth'); const login = ( data: LoginFormData, setComplete: (val: boolean) => void, diff --git a/frontend/src/MyTasks/index.tsx b/frontend/src/MyTasks/index.tsx index f4a304e..4eddaa0 100644 --- a/frontend/src/MyTasks/index.tsx +++ b/frontend/src/MyTasks/index.tsx @@ -562,13 +562,36 @@ const Projects = () => { onCancel={() => null} onDueDateChange={(task, dueDate, hasTime) => { if (dateEditor.task) { - updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate, hasTime } }); - setDateEditor((prev) => ({ ...prev, task: { ...task, dueDate: dueDate.toISOString(), hasTime } })); + hidePopup(); + updateTaskDueDate({ + variables: { + taskID: dateEditor.task.id, + dueDate, + hasTime, + deleteNotifications: [], + updateNotifications: [], + createNotifications: [], + }, + }); + setDateEditor((prev) => ({ + ...prev, + task: { ...task, dueDate: { at: dueDate.toISOString(), notifications: [] }, hasTime }, + })); } }} onRemoveDueDate={(task) => { if (dateEditor.task) { - updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate: null, hasTime: false } }); + hidePopup(); + updateTaskDueDate({ + variables: { + taskID: dateEditor.task.id, + dueDate: null, + hasTime: false, + deleteNotifications: [], + updateNotifications: [], + createNotifications: [], + }, + }); setDateEditor((prev) => ({ ...prev, task: { ...task, hasTime: false } })); } }} @@ -655,8 +678,8 @@ const Projects = () => { if (a.dueDate === null && b.dueDate === null) return 0; if (a.dueDate === null && b.dueDate !== null) return 1; if (a.dueDate !== null && b.dueDate === null) return -1; - const first = dayjs(a.dueDate); - const second = dayjs(b.dueDate); + const first = dayjs(a.dueDate.at); + const second = dayjs(b.dueDate.at); if (first.isSame(second, 'minute')) return 0; if (first.isAfter(second)) return -1; return 1; @@ -792,10 +815,19 @@ const Projects = () => { history.push(`${match.url}/c/${task.id}`); }} onRemoveDueDate={() => { - updateTaskDueDate({ variables: { taskID: task.id, dueDate: null, hasTime: false } }); + updateTaskDueDate({ + variables: { + taskID: task.id, + dueDate: null, + hasTime: false, + deleteNotifications: [], + updateNotifications: [], + createNotifications: [], + }, + }); }} project={projectName ?? 'none'} - dueDate={task.dueDate} + dueDate={task.dueDate.at} hasTime={task.hasTime ?? false} name={task.name} onEditName={(name) => updateTaskName({ variables: { taskID: task.id, name } })} @@ -821,7 +853,9 @@ const Projects = () => { {dateEditor.task.dueDate - ? dayjs(dateEditor.task.dueDate).format(dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D') + ? dayjs(dateEditor.task.dueDate.at).format( + dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D', + ) : ''} diff --git a/frontend/src/Projects/Project/Board/index.tsx b/frontend/src/Projects/Project/Board/index.tsx index 1c6cd69..3911860 100644 --- a/frontend/src/Projects/Project/Board/index.tsx +++ b/frontend/src/Projects/Project/Board/index.tsx @@ -446,7 +446,7 @@ const ProjectBoard: React.FC = ({ projectID, onCardLabelClick checklist: null, }, position, - dueDate: null, + dueDate: { at: null }, description: null, labels: [], assigned: [], @@ -801,12 +801,30 @@ const ProjectBoard: React.FC = ({ projectID, onCardLabelClick { - updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } }); - // hidePopup(); + hidePopup(); + updateTaskDueDate({ + variables: { + taskID: t.id, + dueDate: null, + hasTime: false, + deleteNotifications: [], + updateNotifications: [], + createNotifications: [], + }, + }); }} onDueDateChange={(t, newDueDate, hasTime) => { - updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } }); - // hidePopup(); + hidePopup(); + updateTaskDueDate({ + variables: { + taskID: t.id, + dueDate: newDueDate, + hasTime, + deleteNotifications: [], + updateNotifications: [], + createNotifications: [], + }, + }); }} onCancel={NOOP} /> diff --git a/frontend/src/Projects/Project/Details/index.tsx b/frontend/src/Projects/Project/Details/index.tsx index a6c698d..78678d6 100644 --- a/frontend/src/Projects/Project/Details/index.tsx +++ b/frontend/src/Projects/Project/Details/index.tsx @@ -12,6 +12,7 @@ import { useUpdateTaskChecklistItemLocationMutation, useCreateTaskChecklistMutation, useFindTaskQuery, + DueDateNotificationDuration, useUpdateTaskDueDateMutation, useSetTaskCompleteMutation, useAssignTaskMutation, @@ -647,12 +648,79 @@ const Details: React.FC = ({ { - updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } }); - // hidePopup(); + updateTaskDueDate({ + variables: { + taskID: t.id, + dueDate: null, + hasTime: false, + deleteNotifications: t.dueDate.notifications + ? t.dueDate.notifications.map((n) => ({ id: n.id })) + : [], + updateNotifications: [], + createNotifications: [], + }, + }); + hidePopup(); }} - onDueDateChange={(t, newDueDate, hasTime) => { - updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } }); - // hidePopup(); + onDueDateChange={(t, newDueDate, hasTime, notifications) => { + const updatedNotifications = notifications.current + .filter((c) => c.externalId !== null) + .map((c) => { + let duration = DueDateNotificationDuration.Minute; + switch (c.duration.value) { + case 'hour': + duration = DueDateNotificationDuration.Hour; + break; + case 'day': + duration = DueDateNotificationDuration.Day; + break; + case 'week': + duration = DueDateNotificationDuration.Week; + break; + default: + break; + } + return { + id: c.externalId ?? '', + period: c.period, + duration, + }; + }); + const newNotifications = notifications.current + .filter((c) => c.externalId === null) + .map((c) => { + let duration = DueDateNotificationDuration.Minute; + switch (c.duration.value) { + case 'hour': + duration = DueDateNotificationDuration.Hour; + break; + case 'day': + duration = DueDateNotificationDuration.Day; + break; + case 'week': + duration = DueDateNotificationDuration.Week; + break; + default: + break; + } + return { + taskID: task.id, + period: c.period, + duration, + }; + }); + // const updatedNotifications = notifications.filter(c => c.externalId === null); + updateTaskDueDate({ + variables: { + taskID: t.id, + dueDate: newDueDate, + hasTime, + createNotifications: newNotifications, + updateNotifications: updatedNotifications, + deleteNotifications: notifications.removed.map((n) => ({ id: n })), + }, + }); + hidePopup(); }} onCancel={NOOP} /> diff --git a/frontend/src/Register/index.tsx b/frontend/src/Register/index.tsx index 185655a..7416198 100644 --- a/frontend/src/Register/index.tsx +++ b/frontend/src/Register/index.tsx @@ -39,7 +39,6 @@ const UsersRegister = () => { .then(async (x) => { const response = await x.json(); const { setup } = response; - console.log(response); if (setup) { history.replace(`/confirm?confirmToken=xxxx`); isRedirected = true; diff --git a/frontend/src/shared/components/DueDateManager/Styles.ts b/frontend/src/shared/components/DueDateManager/Styles.ts index 9157d71..9dcbf73 100644 --- a/frontend/src/shared/components/DueDateManager/Styles.ts +++ b/frontend/src/shared/components/DueDateManager/Styles.ts @@ -1,9 +1,8 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import Button from 'shared/components/Button'; import { mixin } from 'shared/utils/styles'; -import Input from 'shared/components/Input'; import ControlledInput from 'shared/components/ControlledInput'; -import { Clock } from 'shared/icons'; +import { Bell, Clock } from 'shared/icons'; export const Wrapper = styled.div` display: flex @@ -22,27 +21,27 @@ display: flex & .react-datepicker__close-icon::after { background: none; font-size: 16px; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; } & .react-datepicker-time__header { - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; } & .react-datepicker__time-list-item { - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; } & .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover { - color: ${props => props.theme.colors.text.secondary}; - background: ${props => props.theme.colors.bg.secondary}; + color: ${(props) => props.theme.colors.text.secondary}; + background: ${(props) => props.theme.colors.bg.secondary}; } & .react-datepicker__time-container .react-datepicker__time { - background: ${props => props.theme.colors.bg.primary}; + background: ${(props) => props.theme.colors.bg.primary}; } & .react-datepicker--time-only { - background: ${props => props.theme.colors.bg.primary}; - border: 1px solid ${props => props.theme.colors.border}; + background: ${(props) => props.theme.colors.bg.primary}; + border: 1px solid ${(props) => props.theme.colors.border}; } & .react-datepicker * { @@ -82,12 +81,12 @@ display: flex } & .react-datepicker__day--selected { border-radius: 50%; - background: ${props => props.theme.colors.primary}; + background: ${(props) => props.theme.colors.primary}; color: #fff; } & .react-datepicker__day--selected:hover { border-radius: 50%; - background: ${props => props.theme.colors.primary}; + background: ${(props) => props.theme.colors.primary}; color: #fff; } & .react-datepicker__header { @@ -95,12 +94,12 @@ display: flex border: none; } & .react-datepicker__header--time { - border-bottom: 1px solid ${props => props.theme.colors.border}; + border-bottom: 1px solid ${(props) => props.theme.colors.border}; } & .react-datepicker__input-container input { border: 1px solid rgba(0, 0, 0, 0.2); - border-color: ${props => props.theme.colors.alternate}; + border-color: ${(props) => props.theme.colors.alternate}; background: #262c49; box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15); padding: 0.7rem; @@ -114,7 +113,7 @@ padding: 0.7rem; &:focus { box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15); border: 1px solid rgba(115, 103, 240); - background: ${props => props.theme.colors.bg.primary}; + background: ${(props) => props.theme.colors.bg.primary}; } `; @@ -142,9 +141,9 @@ export const AddDateRange = styled.div` width: 100%; font-size: 12px; line-height: 16px; - color: ${props => mixin.rgba(props.theme.colors.primary, 0.8)}; + color: ${(props) => mixin.rgba(props.theme.colors.primary, 0.8)}; &:hover { - color: ${props => mixin.rgba(props.theme.colors.primary, 1)}; + color: ${(props) => mixin.rgba(props.theme.colors.primary, 1)}; text-decoration: underline; } `; @@ -201,18 +200,62 @@ export const ActionsWrapper = styled.div` align-items: center; & .react-datepicker-wrapper { margin-left: auto; - width: 82px; + width: 86px; } & .react-datepicker__input-container input { padding-bottom: 4px; padding-top: 4px; width: 100%; } + + & .react-period-select__indicators { + display: none; + } + & .react-period { + width: 100%; + max-width: 86px; + } + + & .react-period-select__single-value { + color: #c2c6dc; + margin-left: 0; + margin-right: 0; + } + & .react-period-select__value-container { + padding-left: 0; + padding-right: 0; + } + & .react-period-select__control { + border: 1px solid rgba(0, 0, 0, 0.2); + min-height: 30px; + border-color: rgb(65, 69, 97); + background: #262c49; + box-shadow: 0 0 0 0 rgb(0 0 0 / 15%); + color: #c2c6dc; + padding-right: 12px; + padding-left: 12px; + padding-bottom: 4px; + padding-top: 4px; + width: 100%; + position: relative; + border-radius: 5px; + transition: all 0.3s ease; + font-size: 13px; + line-height: 20px; + padding: 0 12px; + } `; export const ActionClock = styled(Clock)` align-self: center; - fill: ${props => props.theme.colors.primary}; + fill: ${(props) => props.theme.colors.primary}; + margin: 0 8px; + flex: 0 0 auto; +`; + +export const ActionBell = styled(Bell)` + align-self: center; + fill: ${(props) => props.theme.colors.primary}; margin: 0 8px; flex: 0 0 auto; `; @@ -222,7 +265,7 @@ export const ActionLabel = styled.div` line-height: 14px; `; -export const ActionIcon = styled.div` +export const ActionIcon = styled.div<{ disabled?: boolean }>` height: 36px; min-height: 36px; min-width: 36px; @@ -232,17 +275,25 @@ export const ActionIcon = styled.div` cursor: pointer; margin-right: 8px; svg { - fill: ${props => props.theme.colors.text.primary}; + fill: ${(props) => props.theme.colors.text.primary}; transition-duration: 0.2s; transition-property: background, border, box-shadow, fill; } &:hover svg { - fill: ${props => props.theme.colors.text.secondary}; + fill: ${(props) => props.theme.colors.text.secondary}; } + ${(props) => + props.disabled && + css` + opacity: 0.8; + cursor: not-allowed; + `} + align-items: center; display: inline-flex; justify-content: center; + position: relative; `; export const ClearButton = styled.div` @@ -260,8 +311,38 @@ export const ClearButton = styled.div` justify-content: center; transition-duration: 0.2s; transition-property: background, border, box-shadow, color, fill; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; &:hover { - color: ${props => props.theme.colors.text.secondary}; + color: ${(props) => props.theme.colors.text.secondary}; } `; + +export const ControlWrapper = styled.div` + display: flex; + align-items: center; + margin-top: 8px; +`; + +export const RightWrapper = styled.div` + flex: 1 1 50%; + display: flex; + align-items: center; + flex-direction: row-reverse; +`; + +export const LeftWrapper = styled.div` + flex: 1 1 50%; + display: flex; + align-items: center; +`; + +export const SaveButton = styled(Button)` + padding: 6px 12px; + justify-content: center; + margin-right: 4px; +`; + +export const RemoveButton = styled.div` + width: 100%; + justify-content: center; +`; diff --git a/frontend/src/shared/components/DueDateManager/index.tsx b/frontend/src/shared/components/DueDateManager/index.tsx index 8dcfca0..2b1b38f 100644 --- a/frontend/src/shared/components/DueDateManager/index.tsx +++ b/frontend/src/shared/components/DueDateManager/index.tsx @@ -3,16 +3,21 @@ import dayjs from 'dayjs'; import styled from 'styled-components'; import DatePicker from 'react-datepicker'; import _ from 'lodash'; +import { colourStyles } from 'shared/components/Select'; +import produce from 'immer'; +import Select from 'react-select'; import 'react-datepicker/dist/react-datepicker.css'; import { getYear, getMonth } from 'date-fns'; import { useForm, Controller } from 'react-hook-form'; import NOOP from 'shared/utils/noop'; -import { Clock, Cross } from 'shared/icons'; -import Select from 'react-select/src/Select'; +import { Bell, Clock, Cross, Plus, Trash } from 'shared/icons'; import { Wrapper, RemoveDueDate, + SaveButton, + RightWrapper, + LeftWrapper, DueDateInput, DueDatePickerWrapper, ConfirmAddDueDate, @@ -24,11 +29,19 @@ import { ActionsSeparator, ActionClock, ActionLabel, + ControlWrapper, + RemoveButton, + ActionBell, } from './Styles'; type DueDateManagerProps = { task: Task; - onDueDateChange: (task: Task, newDueDate: Date, hasTime: boolean) => void; + onDueDateChange: ( + task: Task, + newDueDate: Date, + hasTime: boolean, + notifications: { current: Array; removed: Array }, + ) => void; onRemoveDueDate: (task: Task) => void; onCancel: () => void; }; @@ -41,6 +54,39 @@ const FormField = styled.div` width: 50%; display: inline-block; `; + +const NotificationCount = styled.input``; + +const ActionPlus = styled(Plus)` + position: absolute; + fill: ${(props) => props.theme.colors.bg.primary} !important; + stroke: ${(props) => props.theme.colors.bg.primary}; +`; + +const ActionInput = styled.input` + border: 1px solid rgba(0, 0, 0, 0.2); + margin-left: auto; + margin-right: 4px; + border-color: rgb(65, 69, 97); + background: #262c49; + box-shadow: 0 0 0 0 rgb(0 0 0 / 15%); + color: #c2c6dc; + position: relative; + border-radius: 5px; + transition: all 0.3s ease; + font-size: 13px; + line-height: 20px; + padding: 0 12px; + padding-bottom: 4px; + padding-top: 4px; + width: 100%; + max-width: 48px; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +`; const HeaderSelectLabel = styled.div` display: inline-block; position: relative; @@ -131,8 +177,69 @@ const HeaderActions = styled.div` } `; +const notificationPeriodOptions = [ + { value: 'minute', label: 'Minutes' }, + { value: 'hour', label: 'Hours' }, + { value: 'day', label: 'Days' }, + { value: 'week', label: 'Weeks' }, +]; + +type NotificationInternal = { + internalId: string; + externalId: string | null; + period: number; + duration: { value: string; label: string }; +}; + +type NotificationEntryProps = { + notification: NotificationInternal; + onChange: (period: number, duration: { value: string; label: string }) => void; + onRemove: () => void; +}; + +const NotificationEntry: React.FC = ({ notification, onChange, onRemove }) => { + return ( + <> + + Notification + { + onChange(parseInt(e.currentTarget.value, 10), notification.duration); + }} + onKeyPress={(e) => { + const isNumber = /^[0-9]$/i.test(e.key); + if (!isNumber && e.key !== 'Backspace') { + e.preventDefault(); + } + }} + dir="ltr" + autoComplete="off" + min="0" + type="number" + /> +