diff --git a/frontend/src/App/Routes.tsx b/frontend/src/App/Routes.tsx index 6e7a4b0..480c16e 100644 --- a/frontend/src/App/Routes.tsx +++ b/frontend/src/App/Routes.tsx @@ -4,6 +4,7 @@ import * as H from 'history'; import Dashboard from 'Dashboard'; import Admin from 'Admin'; +import MyTasks from 'MyTasks'; import Confirm from 'Confirm'; import Projects from 'Projects'; import Project from 'Projects/Project'; @@ -69,6 +70,7 @@ const AuthorizedRoutes = () => { + ); diff --git a/frontend/src/App/TopNavbar.tsx b/frontend/src/App/TopNavbar.tsx index 4186c53..e887a27 100644 --- a/frontend/src/App/TopNavbar.tsx +++ b/frontend/src/App/TopNavbar.tsx @@ -439,6 +439,9 @@ const GlobalTopNavbar: React.FC = ({ onDashboardClick={() => { history.push('/'); }} + onMyTasksClick={() => { + history.push('/tasks'); + }} projectMembers={projectMembers} projectInvitedMembers={projectInvitedMembers} onProfileClick={onProfileClick} diff --git a/frontend/src/MyTasks/MyTasksSort.tsx b/frontend/src/MyTasks/MyTasksSort.tsx new file mode 100644 index 0000000..bb9e72e --- /dev/null +++ b/frontend/src/MyTasks/MyTasksSort.tsx @@ -0,0 +1,145 @@ +import React, { useState, useEffect } from 'react'; +import styled, { css } from 'styled-components'; +import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons'; +import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists'; +import Input from 'shared/components/ControlledInput'; +import { Popup, usePopup } from 'shared/components/PopupMenu'; +import produce from 'immer'; +import { mixin } from 'shared/utils/styles'; +import Member from 'shared/components/Member'; +import { MyTasksSort } from 'shared/generated/graphql'; + +const FilterMember = styled(Member)` + margin: 2px 0; + &:hover { + cursor: pointer; + background: ${props => props.theme.colors.primary}; + } +`; + +export const Labels = styled.ul` + list-style: none; + margin: 0 8px; + padding: 0; + margin-bottom: 8px; +`; + +export const Label = styled.li` + position: relative; +`; + +export const CardLabel = styled.span<{ active: boolean; color: string }>` + ${props => + props.active && + css` + margin-left: 4px; + box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)}; + border-radius: 3px; + `} + + cursor: pointer; + font-weight: 700; + margin: 0 0 4px; + min-height: 20px; + padding: 6px 12px; + position: relative; + transition: padding 85ms, margin 85ms, box-shadow 85ms; + background-color: ${props => props.color}; + color: #fff; + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-height: 31px; +`; + +export const ActionsList = styled.ul` + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +`; + +export const ActionItem = styled.li` + position: relative; + padding-left: 4px; + padding-right: 4px; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + font-size: 14px; + &:hover { + background: ${props => props.theme.colors.primary}; + } +`; + +export const ActionTitle = styled.span` + margin-left: 20px; +`; + +const ActionItemSeparator = styled.li` + color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)}; + font-size: 12px; + padding-left: 4px; + padding-right: 4px; + padding-top: 0.75rem; + padding-bottom: 0.25rem; +`; + +const ActiveIcon = styled(Checkmark)` + position: absolute; + right: 4px; +`; + +const ItemIcon = styled.div` + position: absolute; +`; + +const TaskNameInput = styled(Input)` + margin: 0; +`; + +const ActionItemLine = styled.div` + height: 1px; + border-top: 1px solid #414561; + margin: 0.25rem !important; +`; + +type MyTasksSortProps = { + sort: MyTasksSort; + onChangeSort: (sort: MyTasksSort) => void; +}; + +const MyTasksSortPopup: React.FC = ({ sort: initialSort, onChangeSort }) => { + const [sort, setSort] = useState(initialSort); + const handleChangeSort = (f: MyTasksSort) => { + setSort(f); + onChangeSort(f); + }; + + return ( + <> + + + handleChangeSort(MyTasksSort.None)}> + {sort === MyTasksSort.None && } + None + + handleChangeSort(MyTasksSort.Project)}> + {sort === MyTasksSort.Project && } + Project + + handleChangeSort(MyTasksSort.DueDate)}> + {sort === MyTasksSort.DueDate && } + Due Date + + + + + ); +}; + +export default MyTasksSortPopup; diff --git a/frontend/src/MyTasks/TaskEntry.tsx b/frontend/src/MyTasks/TaskEntry.tsx new file mode 100644 index 0000000..7c6a7c7 --- /dev/null +++ b/frontend/src/MyTasks/TaskEntry.tsx @@ -0,0 +1,413 @@ +import React, { useState, useRef } from 'react'; +import styled, { css } from 'styled-components/macro'; +import dayjs from 'dayjs'; +import { CheckCircleOutline, CheckCircle, Cross, Briefcase, ChevronRight } from 'shared/icons'; +import { mixin } from 'shared/utils/styles'; + +const RIGHT_ROW_WIDTH = 327; +const TaskName = styled.div<{ focused: boolean }>` + flex: 0 1 auto; + min-width: 1px; + overflow: hidden; + margin-right: 4px; + background: transparent; + border: 1px solid transparent; + border-radius: 2px; + height: 20px; + padding: 0 1px; + + max-height: 100%; + position: relative; + &:hover { + ${props => + !props.focused && + css` + border-color: #9ca6af !important; + border: 1px solid ${props.theme.colors.primary} !important; + `} + } +`; + +const DueDateCell = styled.div` + align-items: center; + align-self: stretch; + display: flex; + flex-grow: 1; +`; + +const CellPlaceholder = styled.div<{ width: number }>` + min-width: ${p => p.width}px; + width: ${p => p.width}px; +`; +const DueDateCellDisplay = styled.div` + align-items: center; + cursor: pointer; + display: flex; + flex-grow: 1; + height: 100%; +`; + +const DueDateCellLabel = styled.div` + align-items: center; + color: ${props => props.theme.colors.text.primary}; + + font-size: 11px; + flex: 0 1 auto; + min-width: 1px; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + flex-flow: row wrap; + white-space: pre-wrap; +`; + +const DueDateRemoveButton = styled.div` + align-items: center; + bottom: 0; + cursor: pointer; + display: flex; + height: 100%; + padding-left: 4px; + padding-right: 8px; + position: absolute; + right: 0; + top: 0; + visibility: hidden; + svg { + fill: ${props => props.theme.colors.text.primary}; + } + &:hover svg { + fill: ${props => props.theme.colors.text.secondary}; + } +`; +const TaskGroupItemCell = styled.div<{ width: number; focused: boolean }>` + width: ${p => p.width}px; + background: transparent; + position: relative; + + border: 1px solid #414561; + justify-content: space-between; + margin-right: -1px; + z-index: 0; + padding: 0 8px; + align-items: center; + display: flex; + height: 37px; + overflow: hidden; + &:hover ${DueDateRemoveButton} { + visibility: visible; + } + &:hover ${TaskName} { + ${props => + !props.focused && + css` + background: ${props.theme.colors.bg.secondary}; + border: 1px solid ${mixin.darken(props.theme.colors.bg.secondary, 0.25)}; + border-radius: 2px; + cursor: text; + `} + } +`; + +const TaskGroupItem = styled.div` + padding-right: 24px; + contain: style; + display: flex; + margin-bottom: -1px; + margin-top: -1px; + height: 37px; + &:hover { + background-color: #161d31; + } + & ${TaskGroupItemCell}:first-child { + position: absolute; + padding: 0 4px 0 0; + margin-left: 24px; + left: 0; + flex: 1 1 auto; + min-width: 1px; + border-right: 0; + border-left: 0; + } + & ${TaskGroupItemCell}:last-child { + border-right: 0; + } +`; + +const TaskItemComplete = styled.div` + flex: 0 0 auto; + margin: 0 3px 0 0; + align-items: center; + box-sizing: border-box; + display: inline-flex; + height: 16px; + justify-content: center; + overflow: visible; + width: 16px; + cursor: pointer; + svg { + transition: all 0.2 ease; + } + &:hover svg { + fill: ${props => props.theme.colors.primary}; + } +`; + +const TaskDetailsButton = styled.div` + align-items: center; + cursor: pointer; + display: flex; + font-size: 12px; + height: 100%; + justify-content: flex-end; + margin-left: auto; + opacity: 0; + padding-left: 4px; + color: ${props => props.theme.colors.text.primary}; + svg { + fill: ${props => props.theme.colors.text.primary}; + } +`; + +const TaskDetailsArea = styled.div` + align-items: center; + cursor: pointer; + display: flex; + flex: 1 0 auto; + height: 100%; + margin-right: 24px; + &:hover ${TaskDetailsButton} { + opacity: 1; + } +`; + +const TaskDetailsWorkpace = styled(Briefcase)` + flex: 0 0 auto; + margin-right: 8px; +`; +const TaskDetailsLabel = styled.div` + display: flex; + align-items: center; +`; + +const TaskDetailsChevron = styled(ChevronRight)` + margin-left: 4px; + flex: 0 0 auto; +`; + +const TaskNameShadow = styled.div` + box-sizing: border-box; + min-height: 1em; + overflow: hidden; + visibility: hidden; + white-space: pre; + border: 0; + font-size: 13px; + line-height: 20px; + margin: 0; + min-width: 20px; + padding: 0 4px; + text-rendering: optimizeSpeed; +`; + +const TaskNameInput = styled.textarea` + white-space: pre; + background: transparent; + border-radius: 0; + display: block; + color: ${props => props.theme.colors.text.primary}; + height: 100%; + outline: 0; + overflow: hidden; + position: absolute; + resize: none; + top: 0; + width: 100%; + border: 0; + font-size: 13px; + line-height: 20px; + margin: 0; + min-width: 20px; + padding: 0 4px; + text-rendering: optimizeSpeed; +`; + +const ProjectPill = styled.div` + background-color: ${props => props.theme.colors.bg.primary}; + text-overflow: ellipsis; + border-radius: 10px; + box-sizing: border-box; + display: block; + font-size: 12px; + font-weight: 400; + height: 20px; + line-height: 20px; + overflow: hidden; + padding: 0 8px; + text-align: left; + white-space: nowrap; +`; + +const ProjectPillContents = styled.div` + align-items: center; + display: flex; +`; + +const ProjectPillName = styled.span` + flex: 0 1 auto; + min-width: 1px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: ${props => props.theme.colors.text.primary}; +`; + +const ProjectPillColor = styled.svg` + overflow: hidden; + flex: 0 0 auto; + margin-right: 4px; + fill: #0064fb; + height: 12px; + width: 12px; +`; + +type TaskEntryProps = { + name: string; + dueDate?: string | null; + onEditName: (name: string) => void; + project: string; + hasTime: boolean; + autoFocus?: boolean; + onEditProject: ($target: React.RefObject) => void; + onToggleComplete: (complete: boolean) => void; + complete: boolean; + onEditDueDate: ($target: React.RefObject) => void; + onTaskDetails: () => void; + onRemoveDueDate: () => void; +}; + +const TaskEntry: React.FC = ({ + autoFocus = false, + onToggleComplete, + onEditName, + onTaskDetails, + name: initialName, + complete, + project, + dueDate, + hasTime, + onEditProject, + onEditDueDate, + onRemoveDueDate, +}) => { + const leftRow = window.innerWidth - RIGHT_ROW_WIDTH; + const [focused, setFocused] = useState(autoFocus); + const [name, setName] = useState(initialName); + const $projects = useRef(null); + const $dueDate = useRef(null); + const $nameInput = useRef(null); + return ( + + + onToggleComplete(!complete)}> + {complete ? : } + + + {name} + setFocused(true)} + ref={$nameInput} + onBlur={() => { + setFocused(false); + onEditName(name); + }} + onKeyDown={e => { + if (e.keyCode === 13) { + e.preventDefault(); + if ($nameInput.current) { + $nameInput.current.blur(); + } + } + }} + onChange={e => setName(e.currentTarget.value)} + wrap="off" + rows={1} + > + {name} + + + onTaskDetails()}> + + + + Details + + + + + + + + onEditDueDate($dueDate)}> + + + {dueDate ? dayjs(dueDate).format(hasTime ? 'MMM D [at] h:mm A' : 'MMM D') : ''} + + + + {dueDate && ( + onRemoveDueDate()}> + + + )} + + + { + onEditProject($projects); + }} + > + + + + + {project} + + + + + + ); +}; +export default TaskEntry; +type NewTaskEntryProps = { + onClick: () => void; +}; +const AddTaskLabel = styled.span` + font-size: 14px; + position: relative; + + color: ${props => props.theme.colors.text.primary}; + + justify-content: space-between; + z-index: 0; + padding: 0 8px; + align-items: center; + display: flex; + height: 37px; + flex: 1 1; + cursor: pointer; + margin-left: 24px; +`; + +const NewTaskEntry: React.FC = ({ onClick }) => { + const leftRow = window.innerWidth - RIGHT_ROW_WIDTH; + return ( + + Add task... + + ); +}; + +export { NewTaskEntry }; diff --git a/frontend/src/MyTasks/index.tsx b/frontend/src/MyTasks/index.tsx new file mode 100644 index 0000000..b61e3b3 --- /dev/null +++ b/frontend/src/MyTasks/index.tsx @@ -0,0 +1,868 @@ +import React, { useState, useEffect, useRef } from 'react'; +import styled, { css } from 'styled-components/macro'; +import GlobalTopNavbar from 'App/TopNavbar'; +import Details from 'Projects/Project/Details'; +import { + useMyTasksQuery, + MyTasksSort, + MyTasksStatus, + useCreateTaskMutation, + MyTasksQuery, + MyTasksDocument, + useUpdateTaskNameMutation, + useSetTaskCompleteMutation, + useUpdateTaskDueDateMutation, +} from 'shared/generated/graphql'; + +import { Route, useRouteMatch, useHistory, RouteComponentProps } from 'react-router-dom'; +import { usePopup, Popup } from 'shared/components/PopupMenu'; +import updateApolloCache from 'shared/utils/cache'; +import produce from 'immer'; +import NOOP from 'shared/utils/noop'; +import { Sort, Cogs, CaretDown, CheckCircle, CaretRight } from 'shared/icons'; +import Select from 'react-select'; +import { editorColourStyles } from 'shared/components/Select'; +import useOnOutsideClick from 'shared/hooks/onOutsideClick'; +import DueDateManager from 'shared/components/DueDateManager'; +import dayjs from 'dayjs'; +import useStickyState from 'shared/hooks/useStickyState'; +import MyTasksSortPopup from './MyTasksSort'; +import TaskEntry from './TaskEntry'; + +type TaskRouteProps = { + taskID: string; +}; + +function prettySort(sort: MyTasksSort) { + if (sort === MyTasksSort.None) { + return 'Sort'; + } + return `Sort: ${sort.charAt(0) + + sort + .slice(1) + .toLowerCase() + .replace(/_/gi, ' ')}`; +} + +type Group = { + id: string; + name: string | null; + tasks: Array; +}; +const DueDateEditorLabel = styled.div` + align-items: center; + color: ${props => props.theme.colors.text.primary}; + + font-size: 11px; + padding: 0 8px; + flex: 0 1 auto; + min-width: 1px; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + flex-flow: row wrap; + white-space: pre-wrap; + height: 35px; +`; + +const ProjectBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding: 0 12px; +`; + +const ProjectActions = styled.div` + display: flex; + align-items: center; +`; + +const ProjectActionWrapper = styled.div<{ disabled?: boolean }>` + cursor: pointer; + display: flex; + align-items: center; + font-size: 15px; + color: ${props => props.theme.colors.text.primary}; + + &:not(:last-of-type) { + margin-right: 16px; + } + + &:hover { + color: ${props => props.theme.colors.text.secondary}; + } + ${props => + props.disabled && + css` + opacity: 0.5; + cursor: default; + pointer-events: none; + `} +`; + +const ProjectActionText = styled.span` + padding-left: 4px; +`; + +type ProjectActionProps = { + onClick?: (target: React.RefObject) => void; + disabled?: boolean; +}; + +const ProjectAction: React.FC = ({ onClick, disabled = false, children }) => { + const $container = useRef(null); + const handleClick = () => { + if (onClick) { + onClick($container); + } + }; + return ( + + {children} + + ); +}; + +const EditorPositioner = styled.div<{ top: number; left: number }>` + position: absolute; + top: ${p => p.top}px; + justify-content: flex-end; + margin-left: -100vw; + z-index: 10000; + align-items: flex-start; + display: flex; + font-size: 13px; + height: 0; + position: fixed; + width: 100vw; + left: ${p => p.left}px; +`; + +const EditorPositionerContents = styled.div` + position: relative; +`; + +const EditorContainer = styled.div<{ width: number }>` + border: 1px solid ${props => props.theme.colors.primary}; + background: ${props => props.theme.colors.bg.secondary}; + position: relative; + width: ${p => p.width}px; +`; + +const EditorCell = styled.div<{ width: number }>` + display: flex; + width: ${p => p.width}px; +`; + +// TABLE +const VerticalScoller = styled.div` + contain: strict; + flex: 1 1 auto; + overflow-x: hidden; + padding-bottom: 1px; + position: relative; + + min-height: 1px; + overflow-y: auto; +`; + +const VerticalScollerInner = styled.div` + min-height: 100%; + overflow-y: hidden; + min-width: 1px; + overflow-x: auto; +`; + +const VerticalScollerInnerBar = styled.div` + display: flex; + margin: 0 24px; + margin-bottom: 1px; + border-top: 1px solid #414561; +`; + +const TableContents = styled.div` + box-sizing: border-box; + display: inline-block; + margin-bottom: 32px; + min-width: 100%; +`; + +const TaskGroupContainer = styled.div``; + +const TaskGroupHeader = styled.div` + height: 50px; + width: 100%; +`; + +const TaskGroupItems = styled.div` + overflow: unset; +`; + +const ProjectPill = styled.div` + background-color: ${props => props.theme.colors.bg.primary}; + text-overflow: ellipsis; + border-radius: 10px; + box-sizing: border-box; + display: block; + font-size: 12px; + font-weight: 400; + height: 20px; + line-height: 20px; + overflow: hidden; + padding: 0 8px; + text-align: left; + white-space: nowrap; +`; + +const ProjectPillContents = styled.div` + align-items: center; + display: flex; +`; + +const ProjectPillName = styled.span` + flex: 0 1 auto; + min-width: 1px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: ${props => props.theme.colors.text.primary}; +`; + +const ProjectPillColor = styled.svg` + overflow: hidden; + flex: 0 0 auto; + margin-right: 4px; + fill: #0064fb; + height: 12px; + width: 12px; +`; + +const SingleValue = ({ children, ...props }: any) => { + return ( + + + + + + {children} + + + ); +}; + +const OptionWrapper = styled.div` + align-items: center; + display: flex; + height: 40px; + padding: 0 16px; + cursor: pointer; + &:hover { + background: #414561; + } +`; + +const OptionLabel = styled.div` + align-items: baseline; + display: flex; + min-width: 1px; +`; + +const OptionTitle = styled.div` + min-width: 50px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; +const OptionSubTitle = styled.div` + color: ${props => props.theme.colors.text.primary}; + font-size: 11px; + margin-left: 8px; + min-width: 50px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; +const Option = ({ innerProps, data }: any) => { + return ( + + + {data.label} + {data.label} + + + ); +}; + +const TaskGroupHeaderContents = styled.div<{ width: number }>` + width: ${p => p.width}px; + left: 0; + position: absolute; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 16px; + font-weight: 500; + margin-left: 24px; + line-height: 20px; + align-items: center; + box-sizing: border-box; + display: flex; + flex: 1 1 auto; + min-height: 30px; + padding-right: 32px; + position: relative; + border-bottom: 1px solid transparent; + border-top: 1px solid transparent; +`; + +const TaskGroupMinify = styled.div` + height: 28px; + min-height: 28px; + min-width: 28px; + width: 28px; + border-radius: 6px; + user-select: none; + + margin-right: 4px; + + align-items: center; + box-sizing: border-box; + display: inline-flex; + justify-content: center; + transition-duration: 0.2s; + transition-property: background, border, box-shadow, fill; + cursor: pointer; + svg { + 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}; + } +`; +const TaskGroupName = styled.div` + flex-grow: 1; + align-items: center; + display: flex; + height: 50px; + min-width: 1px; + color: ${props => props.theme.colors.text.secondary}; + font-weight: 400; +`; + +// HEADER +const ScrollContainer = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 1px; + position: relative; + width: 100%; +`; + +const Row = styled.div` + box-sizing: border-box; + flex: 0 0 auto; + height: 37px; + position: relative; +`; + +const RowHeaderLeft = styled.div<{ width: number }>` + width: ${p => p.width}px; + + align-items: stretch; + display: flex; + flex-direction: column; + height: 37px; + left: 0; + position: absolute; + z-index: 100; +`; +const RowHeaderLeftInner = styled.div` + align-items: stretch; + color: ${props => props.theme.colors.text.primary}; + display: flex; + flex: 1 0 auto; + font-size: 12px; + margin-right: -1px; + padding-left: 24px; +`; +const RowHeaderLeftName = styled.div` + position: relative; + align-items: center; + border-right: 1px solid #414561; + border-top: 1px solid #414561; + border-bottom: 1px solid #414561; + display: flex; + flex: 1 0 auto; + justify-content: space-between; +`; + +const RowHeaderLeftNameText = styled.div` + align-items: center; + display: flex; +`; + +const RowHeaderRight = styled.div<{ left: number }>` + left: ${p => p.left}px; + right: 0px; + height: 37px; + position: absolute; +`; + +const RowScrollable = styled.div` + min-width: 1px; + overflow-x: auto; + overflow-y: hidden; + width: 100%; +`; + +const RowScrollContent = styled.div` + align-items: center; + display: inline-flex; + height: 37px; + width: 100%; +`; + +const RowHeaderRightContainer = styled.div` + padding-right: 24px; + + align-items: stretch; + display: flex; + flex: 1 0 auto; + height: 37px; + justify-content: flex-end; + margin: -1px 0; +`; + +const ItemWrapper = styled.div<{ width: number }>` + width: ${p => p.width}px; + align-items: center; + border: 1px solid #414561; + border-bottom: 0; + box-sizing: border-box; + cursor: pointer; + display: inline-flex; + flex: 0 0 auto; + font-size: 12px; + justify-content: space-between; + margin-right: -1px; + padding: 0 8px; + position: relative; + color: ${props => props.theme.colors.text.primary}; + border-bottom: 1px solid #414561; + &:hover { + background: ${props => props.theme.colors.primary}; + color: ${props => props.theme.colors.text.secondary}; + } +`; +const ItemsContainer = styled.div` + display: flex; + flex-direction: row; + height: 100%; + width: 100%; + & ${ItemWrapper}:last-child { + border-right: 0; + } +`; + +const ItemName = styled.div` + align-items: center; + display: flex; + overflow: hidden; +`; +type DateEditorState = { + open: boolean; + pos: { top: number; left: number } | null; + task: null | Task; +}; + +type ProjectEditorState = { + open: boolean; + pos: { top: number; left: number } | null; + task: null | Task; +}; +const RIGHT_ROW_WIDTH = 327; + +const Projects = () => { + const leftRow = window.innerWidth - RIGHT_ROW_WIDTH; + const [menuOpen, setMenuOpen] = useState(false); + const [filters, setFilters] = useStickyState<{ sort: MyTasksSort; status: MyTasksStatus }>( + { sort: MyTasksSort.None, status: MyTasksStatus.All }, + 'my_tasks_filter', + ); + const { data } = useMyTasksQuery({ variables: { sort: filters.sort, status: filters.status } }); + const [dateEditor, setDateEditor] = useState({ open: false, pos: null, task: null }); + const onEditDueDate = (task: Task, $target: React.RefObject) => { + if ($target && $target.current && data) { + const pos = $target.current.getBoundingClientRect(); + setDateEditor({ + open: true, + pos: { + top: pos.top, + left: pos.right, + }, + task, + }); + } + }; + const [newTask, setNewTask] = useState<{ open: boolean }>({ open: false }); + const match = useRouteMatch(); + const history = useHistory(); + const [projectEditor, setProjectEditor] = useState({ open: false, pos: null, task: null }); + const onEditProject = ($target: React.RefObject) => { + if ($target && $target.current) { + const pos = $target.current.getBoundingClientRect(); + setProjectEditor({ + open: true, + pos: { + top: pos.top, + left: pos.right, + }, + task: null, + }); + } + }; + const { showPopup } = usePopup(); + const [updateTaskDueDate] = useUpdateTaskDueDateMutation(); + const $editorContents = useRef(null); + const $dateContents = useRef(null); + useEffect(() => { + if (dateEditor.open && $dateContents.current && dateEditor.task) { + showPopup( + $dateContents, + + 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 } })); + } + }} + onRemoveDueDate={task => { + if (dateEditor.task) { + updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate: null, hasTime: false } }); + setDateEditor(prev => ({ ...prev, task: { ...task, hasTime: false } })); + } + }} + /> + , + { onClose: () => setDateEditor({ open: false, task: null, pos: null }) }, + ); + } + }, [dateEditor]); + + const [createTask] = useCreateTaskMutation({ + update: (client, newTaskData) => { + updateApolloCache( + client, + MyTasksDocument, + cache => + produce(cache, draftCache => { + if (newTaskData.data) { + draftCache.myTasks.tasks.unshift(newTaskData.data.createTask); + } + }), + { status: MyTasksStatus.All, sort: MyTasksSort.None }, + ); + }, + }); + + const [setTaskComplete] = useSetTaskCompleteMutation(); + const [updateTaskName] = useUpdateTaskNameMutation(); + const [minified, setMinified] = useStickyState>([], 'my_tasks_minified'); + useOnOutsideClick( + $editorContents, + projectEditor.open, + () => + setProjectEditor({ + open: false, + task: null, + pos: null, + }), + null, + ); + if (data) { + const groups: Array = []; + if (filters.sort === MyTasksSort.None) { + groups.push({ + id: 'recently-assigned', + name: 'Recently Assigned', + tasks: data.myTasks.tasks.map(task => ({ + ...task, + labels: [], + position: 0, + })), + }); + } else { + let { tasks } = data.myTasks; + if (filters.sort === MyTasksSort.DueDate) { + const group: Group = { id: 'due_date', name: null, tasks: [] }; + data.myTasks.tasks.forEach(task => { + if (task.dueDate) { + group.tasks.push({ ...task, labels: [], position: 0 }); + } + }); + groups.push(group); + tasks = tasks.filter(t => t.dueDate === null); + } + const projects = new Map>(); + data.myTasks.projects.forEach(p => { + if (!projects.has(p.projectID)) { + projects.set(p.projectID, []); + } + const prev = projects.get(p.projectID); + const task = tasks.find(t => t.id === p.taskID); + if (prev && task) { + projects.set(p.projectID, [...prev, { ...task, labels: [], position: 0 }]); + } + }); + for (const [id, pTasks] of projects) { + const project = data.projects.find(c => c.id === id); + if (pTasks.length === 0) continue; + if (project) { + groups.push({ + id, + name: project.name, + tasks: pTasks.sort((a, b) => { + 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); + if (first.isSame(second, 'minute')) return 0; + if (first.isAfter(second)) return -1; + return 1; + }), + }); + } + } + groups.sort((a, b) => { + if (a.name === null && b.name === null) return 0; + if (a.name === null) return -1; + if (b.name === null) return 1; + return a.name.localeCompare(b.name); + }); + } + return ( + <> + + + + + + + All Tasks + + { + showPopup( + $target, + setFilters(prev => ({ ...prev, sort }))} + />, + ); + }} + > + + {prettySort(filters.sort)} + + + + Customize + + + + + + + + + Task name + + + + + + + + + + Due date + + + Project + + + + + + + + + + + + {groups.map(group => { + const isMinified = minified.find(m => m === group.id) ?? false; + return ( + + {group.name && ( + + + { + setMinified(prev => { + if (isMinified) { + return prev.filter(c => c !== group.id); + } + return [...prev, group.id]; + }); + }} + > + {isMinified ? ( + + ) : ( + + )} + + {group.name} + + + )} + + {!isMinified && + group.tasks.map(task => { + const projectID = data.myTasks.projects.find(t => t.taskID === task.id)?.projectID; + const projectName = data.projects.find(p => p.id === projectID)?.name; + return ( + { + setTaskComplete({ variables: { taskID: task.id, complete } }); + }} + onTaskDetails={() => { + history.push(`${match.url}/c/${task.id}`); + }} + onRemoveDueDate={() => { + updateTaskDueDate({ variables: { taskID: task.id, dueDate: null, hasTime: false } }); + }} + project={projectName ?? 'none'} + dueDate={task.dueDate} + hasTime={task.hasTime ?? false} + name={task.name} + onEditName={name => updateTaskName({ variables: { taskID: task.id, name } })} + onEditProject={onEditProject} + onEditDueDate={$target => onEditDueDate({ ...task, position: 0, labels: [] }, $target)} + /> + ); + })} + + + ); + })} + + + + + {dateEditor.open && dateEditor.pos !== null && dateEditor.task && ( + + + + + + {dateEditor.task.dueDate + ? dayjs(dateEditor.task.dueDate).format(dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D') + : ''} + + + + + + )} + {projectEditor.open && projectEditor.pos !== null && ( + + + + +