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, CheckCircleOutline } 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 MyTasksStatusPopup from './MyTasksStatus'; import TaskEntry from './TaskEntry'; import { StaticContext } from 'react-router'; type TaskRouteProps = { taskID: string; }; function prettyStatus(status: MyTasksStatus) { switch (status) { case MyTasksStatus.All: return 'All tasks'; case MyTasksStatus.Incomplete: return 'Incomplete tasks'; case MyTasksStatus.CompleteAll: return 'All completed tasks'; case MyTasksStatus.CompleteToday: return 'Completed tasks: today'; case MyTasksStatus.CompleteYesterday: return 'Completed tasks: yesterday'; case MyTasksStatus.CompleteOneWeek: return 'Completed tasks: 1 week'; case MyTasksStatus.CompleteTwoWeek: return 'Completed tasks: 2 weeks'; case MyTasksStatus.CompleteThreeWeek: return 'Completed tasks: 3 weeks'; default: return 'unknown tasks'; } } 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 }, fetchPolicy: 'cache-and-network', }); 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, hidePopup } = 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 ( <> { showPopup( $target, { setFilters((prev) => ({ ...prev, status })); hidePopup(); }} />, { width: 185 }, ); }} > {prettyStatus(filters.status)} { showPopup( $target, { setFilters((prev) => ({ ...prev, sort })); hidePopup(); }} />, { width: 185 }, ); }} > {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 && (