From 119a4b28681a3e1110e8470bfc81782c6b4af437 Mon Sep 17 00:00:00 2001 From: Jordan Knott Date: Mon, 25 Oct 2021 15:14:24 -0500 Subject: [PATCH] feat: add comments badge to task card --- frontend/src/shared/components/Card/Styles.ts | 61 ++++-- frontend/src/shared/components/Card/index.tsx | 24 ++- .../src/shared/components/Lists/index.tsx | 49 +++-- .../shared/components/TaskDetails/Styles.ts | 85 +++++---- .../shared/components/TaskDetails/index.tsx | 1 + frontend/src/shared/generated/graphql.tsx | 16 +- frontend/src/shared/graphql/fragments/task.ts | 4 + frontend/src/shared/icons/Bubble.tsx | 12 ++ frontend/src/shared/icons/index.ts | 2 + frontend/src/types.d.ts | 5 + internal/db/querier.go | 1 + internal/db/query/task.sql | 3 + internal/db/task.sql.go | 11 ++ internal/graph/generated.go | 176 ++++++++++++++++++ internal/graph/models_gen.go | 6 + internal/graph/schema.graphqls | 7 +- internal/graph/schema.resolvers.go | 16 +- internal/graph/schema/_models.gql | 6 + 18 files changed, 385 insertions(+), 100 deletions(-) create mode 100644 frontend/src/shared/icons/Bubble.tsx diff --git a/frontend/src/shared/components/Card/Styles.ts b/frontend/src/shared/components/Card/Styles.ts index 23f0f91..a444838 100644 --- a/frontend/src/shared/components/Card/Styles.ts +++ b/frontend/src/shared/components/Card/Styles.ts @@ -1,18 +1,27 @@ import styled, { css, keyframes } from 'styled-components'; import { mixin } from 'shared/utils/styles'; import TextareaAutosize from 'react-autosize-textarea'; -import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons'; +import { CheckCircle, CheckSquareOutline, Clock, Bubble } from 'shared/icons'; import TaskAssignee from 'shared/components/TaskAssignee'; export const CardMember = styled(TaskAssignee)<{ zIndex: number }>` - box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.secondary}, - inset 0 0 0 1px ${props => mixin.rgba(props.theme.colors.bg.secondary, 0.07)}; - z-index: ${props => props.zIndex}; + box-shadow: 0 0 0 2px ${(props) => props.theme.colors.bg.secondary}, + inset 0 0 0 1px ${(props) => mixin.rgba(props.theme.colors.bg.secondary, 0.07)}; + z-index: ${(props) => props.zIndex}; position: relative; `; +export const CommentsIcon = styled(Bubble)<{ color: 'success' | 'normal' }>` + ${(props) => + props.color === 'success' && + css` + fill: ${props.theme.colors.success}; + stroke: ${props.theme.colors.success}; + `} +`; + export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>` - ${props => + ${(props) => props.color === 'success' && css` fill: ${props.theme.colors.success}; @@ -21,7 +30,7 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no `; export const ClockIcon = styled(Clock)<{ color: string }>` - fill: ${props => props.color}; + fill: ${(props) => props.color}; `; export const EditorTextarea = styled(TextareaAutosize)` @@ -40,7 +49,7 @@ export const EditorTextarea = styled(TextareaAutosize)` padding: 0; font-size: 14px; line-height: 18px; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; &:focus { border: none; outline: none; @@ -54,6 +63,22 @@ export const ListCardBadges = styled.div` margin-left: -2px; `; +export const CommentsBadge = styled.div` + color: #5e6c84; + display: flex; + align-items: center; + margin: 0 6px 4px 0; + font-size: 12px; + max-width: 100%; + min-height: 20px; + overflow: hidden; + position: relative; + padding: 2px; + text-decoration: none; + text-overflow: ellipsis; + vertical-align: top; +`; + export const ListCardBadge = styled.div` color: #5e6c84; display: flex; @@ -76,7 +101,7 @@ export const DescriptionBadge = styled(ListCardBadge)` export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>` font-size: 12px; - ${props => + ${(props) => props.isPastDue && css` padding-left: 4px; @@ -91,7 +116,7 @@ export const ListCardBadgeText = styled.span<{ color?: 'success' | 'normal' }>` padding: 0 4px 0 6px; vertical-align: top; white-space: nowrap; - ${props => props.color === 'success' && `color: ${props.theme.colors.success};`} + ${(props) => props.color === 'success' && `color: ${props.theme.colors.success};`} `; export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>` @@ -102,7 +127,7 @@ export const ListCardContainer = styled.div<{ isActive: boolean; editable: boole cursor: pointer !important; position: relative; - background-color: ${props => + background-color: ${(props) => props.isActive && !props.editable ? mixin.darken(props.theme.colors.bg.secondary, 0.1) : `${props.theme.colors.bg.secondary}`}; @@ -119,7 +144,7 @@ export const ListCardDetails = styled.div<{ complete: boolean }>` position: relative; z-index: 10; - ${props => props.complete && 'opacity: 0.6;'} + ${(props) => props.complete && 'opacity: 0.6;'} `; const labelVariantExpandAnimation = keyframes` @@ -157,7 +182,7 @@ export const ListCardLabelsWrapper = styled.div` `; export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>` - ${props => + ${(props) => props.variant === 'small' ? css` height: 8px; @@ -183,14 +208,14 @@ export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>` color: #fff; display: flex; position: relative; - background-color: ${props => props.color}; + background-color: ${(props) => props.color}; `; export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>` &:hover { opacity: 0.8; } - ${props => + ${(props) => props.toggleLabels && props.toggleDirection === 'expand' && css` @@ -201,7 +226,7 @@ export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirectio animation: ${labelTextVariantExpandAnimation} 0.45s ease-out; } `} - ${props => + ${(props) => props.toggleLabels && props.toggleDirection === 'shrink' && css` @@ -225,7 +250,7 @@ export const ListCardOperation = styled.span` top: 2px; z-index: 100; &:hover { - background-color: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.25)}; + background-color: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.25)}; } `; @@ -234,7 +259,7 @@ export const CardTitle = styled.div` margin: 0 0 4px; overflow: hidden; text-decoration: none; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; display: block; align-items: center; `; @@ -251,7 +276,7 @@ export const CardMembers = styled.div` `; export const CompleteIcon = styled(CheckCircle)` - fill: ${props => props.theme.colors.success}; + fill: ${(props) => props.theme.colors.success}; margin-right: 4px; flex-shrink: 0; margin-bottom: -2px; diff --git a/frontend/src/shared/components/Card/index.tsx b/frontend/src/shared/components/Card/index.tsx index a6c352f..ae4f8a6 100644 --- a/frontend/src/shared/components/Card/index.tsx +++ b/frontend/src/shared/components/Card/index.tsx @@ -23,6 +23,8 @@ import { CardTitle, CardMembers, CardTitleText, + CommentsIcon, + CommentsBadge, } from './Styles'; type DueDate = { @@ -47,6 +49,7 @@ type Props = { dueDate?: DueDate; checklists?: Checklist | null; labels?: Array; + comments?: { unread: boolean; total: number } | null; watched?: boolean; wrapperProps?: any; members?: Array | null; @@ -72,6 +75,7 @@ const Card = React.forwardRef( taskGroupID, complete, toggleLabels = false, + comments, toggleDirection = 'shrink', setToggleLabels, onClick, @@ -138,7 +142,7 @@ const Card = React.forwardRef( onMouseEnter={() => setActive(true)} onMouseLeave={() => setActive(false)} ref={$cardRef} - onClick={e => { + onClick={(e) => { if (onClick) { onClick(e); } @@ -151,7 +155,7 @@ const Card = React.forwardRef( {!isPublic && isActive && !editable && ( { + onClick={(e) => { e.stopPropagation(); if (onContextMenu) { onContextMenu($innerCardRef, taskID, taskGroupID); @@ -167,7 +171,7 @@ const Card = React.forwardRef( { + onClick={(e) => { e.stopPropagation(); if (onCardLabelClick) { onCardLabelClick(); @@ -177,7 +181,7 @@ const Card = React.forwardRef( {labels .slice() .sort((a, b) => a.labelColor.position - b.labelColor.position) - .map(label => ( + .map((label) => ( { if (setToggleLabels) { @@ -198,13 +202,13 @@ const Card = React.forwardRef( {complete && } { + onChange={(e) => { setCardTitle(e.currentTarget.value); if (onCardTitleChange) { onCardTitleChange(e.currentTarget.value); } }} - onClick={e => { + onClick={(e) => { e.stopPropagation(); }} onKeyDown={handleKeyDown} @@ -235,6 +239,12 @@ const Card = React.forwardRef( )} + {comments && ( + + + {comments.total} + + )} {checklists && ( { + onMemberProfile={($target) => { if (onCardMemberClick) { onCardMemberClick($target, taskID, member.id); } diff --git a/frontend/src/shared/components/Lists/index.tsx b/frontend/src/shared/components/Lists/index.tsx index b6c5507..718f2a5 100644 --- a/frontend/src/shared/components/Lists/index.tsx +++ b/frontend/src/shared/components/Lists/index.tsx @@ -111,24 +111,16 @@ function shouldStatusFilter(task: Task, filter: TaskStatusFilter) { const TODAY = REFERENCE.clone().startOf('day'); return completedAt.isSame(TODAY, 'd'); case TaskSince.YESTERDAY: - const YESTERDAY = REFERENCE.clone() - .subtract(1, 'day') - .startOf('day'); + const YESTERDAY = REFERENCE.clone().subtract(1, 'day').startOf('day'); return completedAt.isSameOrAfter(YESTERDAY, 'd'); case TaskSince.ONE_WEEK: - const ONE_WEEK = REFERENCE.clone() - .subtract(7, 'day') - .startOf('day'); + const ONE_WEEK = REFERENCE.clone().subtract(7, 'day').startOf('day'); return completedAt.isSameOrAfter(ONE_WEEK, 'd'); case TaskSince.TWO_WEEKS: - const TWO_WEEKS = REFERENCE.clone() - .subtract(14, 'day') - .startOf('day'); + const TWO_WEEKS = REFERENCE.clone().subtract(14, 'day').startOf('day'); return completedAt.isSameOrAfter(TWO_WEEKS, 'd'); case TaskSince.THREE_WEEKS: - const THREE_WEEKS = REFERENCE.clone() - .subtract(21, 'day') - .startOf('day'); + const THREE_WEEKS = REFERENCE.clone().subtract(21, 'day').startOf('day'); return completedAt.isSameOrAfter(THREE_WEEKS, 'd'); default: return true; @@ -203,14 +195,14 @@ const SimpleLists: React.FC = ({ let beforeDropDraggables: Array | null = null; if (isList) { - const droppedGroup = taskGroups.find(taskGroup => taskGroup.id === draggableId); + const droppedGroup = taskGroups.find((taskGroup) => taskGroup.id === draggableId); if (droppedGroup) { droppedDraggable = { id: draggableId, position: droppedGroup.position, }; beforeDropDraggables = getSortedDraggables( - taskGroups.map(taskGroup => { + taskGroups.map((taskGroup) => { return { id: taskGroup.id, position: taskGroup.position }; }), ); @@ -234,13 +226,13 @@ const SimpleLists: React.FC = ({ } } else { const curTaskGroup = taskGroups.findIndex( - taskGroup => taskGroup.tasks.findIndex(task => task.id === draggableId) !== -1, + (taskGroup) => taskGroup.tasks.findIndex((task) => task.id === draggableId) !== -1, ); let targetTaskGroup = curTaskGroup; if (!isSameList) { - targetTaskGroup = taskGroups.findIndex(taskGroup => taskGroup.id === destination.droppableId); + targetTaskGroup = taskGroups.findIndex((taskGroup) => taskGroup.id === destination.droppableId); } - const droppedTask = taskGroups[curTaskGroup].tasks.find(task => task.id === draggableId); + const droppedTask = taskGroups[curTaskGroup].tasks.find((task) => task.id === draggableId); if (droppedTask) { droppedDraggable = { @@ -248,7 +240,7 @@ const SimpleLists: React.FC = ({ position: droppedTask.position, }; beforeDropDraggables = getSortedDraggables( - taskGroups[targetTaskGroup].tasks.map(task => { + taskGroups[targetTaskGroup].tasks.map((task) => { return { id: task.id, position: task.position }; }), ); @@ -286,7 +278,7 @@ const SimpleLists: React.FC = ({ - {provided => ( + {(provided) => ( {taskGroups .slice() @@ -294,14 +286,14 @@ const SimpleLists: React.FC = ({ .map((taskGroup: TaskGroup, index: number) => { return ( - {columnDragProvided => ( + {(columnDragProvided) => ( {(columnDropProvided, snapshot) => ( setCurrentComposer(id)} + onOpenComposer={(id) => setCurrentComposer(id)} isComposerOpen={currentComposer === taskGroup.id} - onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)} + onSaveName={(name) => onChangeTaskGroupName(taskGroup.id, name)} isPublic={isPublic} ref={columnDragProvided.innerRef} wrapperProps={columnDragProvided.draggableProps} @@ -314,8 +306,8 @@ const SimpleLists: React.FC = ({ {taskGroup.tasks .slice() - .filter(t => shouldStatusFilter(t, taskStatusFilter)) - .filter(t => shouldMetaFilter(t, taskMetaFilters)) + .filter((t) => shouldStatusFilter(t, taskStatusFilter)) + .filter((t) => shouldMetaFilter(t, taskMetaFilters)) .sort((a: any, b: any) => a.position - b.position) .sort((a: any, b: any) => sortTasks(a, b, taskSorting)) .map((task: Task, taskIndex: any) => { @@ -326,7 +318,7 @@ const SimpleLists: React.FC = ({ index={taskIndex} isDragDisabled={taskSorting.type !== TaskSortingType.NONE} > - {taskProvided => { + {(taskProvided) => { return ( = ({ complete={task.complete ?? false} taskGroupID={taskGroup.id} description="" - labels={task.labels.map(label => label.projectLabel)} + labels={task.labels.map((label) => label.projectLabel)} dueDate={ task.dueDate ? { @@ -367,6 +359,7 @@ const SimpleLists: React.FC = ({ onTaskClick(task); }} checklists={task.badges && task.badges.checklist} + comments={task.badges && task.badges.comments} onCardMemberClick={onCardMemberClick} onContextMenu={onQuickEditorOpen} /> @@ -381,7 +374,7 @@ const SimpleLists: React.FC = ({ onClose={() => { setCurrentComposer(''); }} - onCreateCard={name => { + onCreateCard={(name) => { onCreateTask(taskGroup.id, name); }} isOpen @@ -402,7 +395,7 @@ const SimpleLists: React.FC = ({ {!isPublic && ( { + onSave={(listName) => { onCreateTaskGroup(listName); }} /> diff --git a/frontend/src/shared/components/TaskDetails/Styles.ts b/frontend/src/shared/components/TaskDetails/Styles.ts index 39b47df..c8461ad 100644 --- a/frontend/src/shared/components/TaskDetails/Styles.ts +++ b/frontend/src/shared/components/TaskDetails/Styles.ts @@ -22,14 +22,14 @@ export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: bo position: relative; border: none; cursor: pointer; - border-radius: ${props => props.theme.borderRadius.alternate}; + border-radius: ${(props) => props.theme.borderRadius.alternate}; display: flex; align-items: center; background: transparent; & span { margin-left: 4px; } - ${props => + ${(props) => props.invert ? css` background: ${props.theme.colors.success}; @@ -63,7 +63,7 @@ export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: bo color: ${props.theme.colors.success}; } `} - ${props => + ${(props) => props.invert && css` opacity: 0.6; @@ -89,7 +89,7 @@ export const SidebarTitle = styled.div` font-size: 12px; min-height: 24px; margin-left: 8px; - color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)}; + color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)}; padding-top: 4px; letter-spacing: 0.5px; text-transform: uppercase; @@ -110,12 +110,12 @@ export const skeletonKeyframes = keyframes` export const SidebarButton = styled.div<{ $loading?: boolean }>` font-size: 14px; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; min-height: 32px; width: 100%; border-radius: 6px; - ${props => + ${(props) => props.$loading ? css` background: ${props.theme.colors.bg.primary}; @@ -183,7 +183,7 @@ export const TaskDetailsTitleWrapper = styled.div<{ $loading?: boolean }>` margin: 8px 0 4px 0; display: flex; border-radius: 6px; - ${props => props.$loading && `background: ${props.theme.colors.bg.primary};`} + ${(props) => props.$loading && `background: ${props.theme.colors.bg.primary};`} `; export const TaskDetailsTitle = styled(TextareaAutosize)<{ $loading?: boolean }>` @@ -201,7 +201,7 @@ export const TaskDetailsTitle = styled(TextareaAutosize)<{ $loading?: boolean }> &:disabled { opacity: 1; } - ${props => + ${(props) => props.$loading ? css` background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor}); @@ -226,7 +226,7 @@ export const DueDateTitle = styled.div` font-size: 12px; min-height: 24px; margin-left: 8px; - color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)}; + color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)}; padding-top: 8px; letter-spacing: 0.5px; text-transform: uppercase; @@ -237,7 +237,7 @@ export const AssignedUsersSection = styled.div` padding-right: 32px; padding-top: 24px; padding-bottom: 24px; - border-bottom: 1px solid ${props => props.theme.colors.alternate}; + border-bottom: 1px solid ${(props) => props.theme.colors.alternate}; display: flex; flex-direction: column; `; @@ -255,10 +255,10 @@ export const AssignUserIcon = styled.div` justify-content: center; align-items: center; &:hover { - border: 1px solid ${props => mixin.rgba(props.theme.colors.text.secondary, 0.75)}; + border: 1px solid ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.75)}; } &:hover svg { - fill: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.75)}; + fill: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.75)}; } `; @@ -273,17 +273,17 @@ export const AssignUsersButton = styled.div` align-items: center; border: 1px solid transparent; &:hover { - border: 1px solid ${props => mixin.darken(props.theme.colors.alternate, 0.15)}; + border: 1px solid ${(props) => mixin.darken(props.theme.colors.alternate, 0.15)}; } &:hover ${AssignUserIcon} { - border: 1px solid ${props => props.theme.colors.alternate}; + border: 1px solid ${(props) => props.theme.colors.alternate}; } `; export const AssignUserLabel = styled.span` flex: 1 1 auto; line-height: 15px; - color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)}; + color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)}; `; export const ExtraActionsSection = styled.div` @@ -295,7 +295,7 @@ export const ExtraActionsSection = styled.div` `; export const ActionButtonsTitle = styled.h3` - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; font-size: 12px; font-weight: 500; letter-spacing: 0.04em; @@ -305,7 +305,7 @@ export const ActionButton = styled(Button)` margin-top: 8px; margin-left: -10px; padding: 8px 16px; - background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.5)}; + background: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.5)}; text-align: left; transition: transform 0.2s ease; & span { @@ -314,7 +314,7 @@ export const ActionButton = styled(Button)` &:hover { box-shadow: none; transform: translateX(4px); - background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.75)}; + background: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.75)}; } `; @@ -333,10 +333,10 @@ export const HeaderActionIcon = styled.div` cursor: pointer; svg { - fill: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)}; + fill: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)}; } &:hover svg { - fill: ${props => mixin.rgba(props.theme.colors.primary, 0.75)}); + fill: ${(props) => mixin.rgba(props.theme.colors.primary, 0.75)}); } `; @@ -393,7 +393,7 @@ export const MetaDetail = styled.div` `; export const MetaDetailTitle = styled.h3` - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; font-size: 12px; font-weight: 500; letter-spacing: 0.04em; @@ -412,7 +412,7 @@ export const MetaDetailContent = styled.div` `; export const TaskDetailsAddLabel = styled.div` border-radius: 3px; - background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)}; + background: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.15)}; cursor: pointer; &:hover { opacity: 0.8; @@ -427,7 +427,7 @@ export const TaskDetailsAddLabelIcon = styled.div` align-items: center; justify-content: center; border-radius: 3px; - background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)}; + background: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.15)}; cursor: pointer; &:hover { opacity: 0.8; @@ -443,7 +443,7 @@ export const TaskDetailLabel = styled.div<{ color: string }>` &:hover { opacity: 0.8; } - background-color: ${props => props.color}; + background-color: ${(props) => props.color}; color: #fff; cursor: pointer; display: flex; @@ -496,17 +496,22 @@ export const TabBarSection = styled.div` margin-top: 2px; padding-left: 23px; display: flex; + justify-content: space-between; text-transform: uppercase; min-height: 35px; border-bottom: 1px solid #414561; `; export const TabBarItem = styled.div` - box-shadow: inset 0 -2px ${props => props.theme.colors.primary}; + box-shadow: inset 0 -2px ${(props) => props.theme.colors.primary}; padding: 12px 7px 14px 7px; margin-bottom: -1px; margin-right: 36px; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; +`; + +export const TabBarButton = styled(Button)` + padding: 6px 12px; `; export const CommentContainer = styled.div` @@ -542,13 +547,13 @@ export const CommentTextArea = styled(TextareaAutosize)<{ $showCommentActions: b line-height: 28px; padding: 4px 6px; border-radius: 6px; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; background: #1f243e; border: none; transition: max-height 200ms, height 200ms, min-height 200ms; min-height: 36px; max-height: 36px; - ${props => + ${(props) => props.$showCommentActions ? css` min-height: 80px; @@ -561,7 +566,7 @@ export const CommentTextArea = styled(TextareaAutosize)<{ $showCommentActions: b `; export const CommentEditorActions = styled.div<{ visible: boolean }>` - display: ${props => (props.visible ? 'flex' : 'none')}; + display: ${(props) => (props.visible ? 'flex' : 'none')}; align-items: center; padding: 5px 5px 5px 9px; border-top: 1px solid #414561; @@ -594,7 +599,7 @@ export const ActivityItemCommentAction = styled.div` justify-content: center; cursor: pointer; svg { - fill: ${props => props.theme.colors.text.primary} !important; + fill: ${(props) => props.theme.colors.text.primary} !important; } `; @@ -614,7 +619,7 @@ export const ActivityItemHeader = styled.div<{ editable?: boolean }>` display: flex; flex-direction: column; padding-left: 8px; - ${props => props.editable && 'width: 100%;'} + ${(props) => props.editable && 'width: 100%;'} `; export const ActivityItemHeaderUser = styled(TaskAssignee)` align-items: start; @@ -623,7 +628,7 @@ export const ActivityItemHeaderUser = styled(TaskAssignee)` export const ActivityItemHeaderTitle = styled.div` display: flex; align-items: center; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; padding-bottom: 2px; `; @@ -634,8 +639,8 @@ export const ActivityItemHeaderTitleName = styled.span` export const ActivityItemTimestamp = styled.span<{ margin: number }>` font-size: 12px; - color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.65)}; - margin-left: ${props => props.margin}px; + color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.65)}; + margin-left: ${(props) => props.margin}px; `; export const ActivityItemDetails = styled.div` @@ -649,11 +654,11 @@ export const ActivityItemComment = styled.div<{ editable: boolean }>` border-radius: 3px; ${mixin.boxShadowCard} position: relative; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; padding: 8px 12px; margin: 4px 0; - background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)}; - ${props => props.editable && 'width: 100%;'} + background-color: ${(props) => mixin.darken(props.theme.colors.alternate, 0.1)}; + ${(props) => props.editable && 'width: 100%;'} & span { display: inline-flex; @@ -683,7 +688,7 @@ export const ActivityItemCommentActions = styled.div` export const ActivityItemLog = styled.span` margin-left: 2px; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; `; export const ViewRawButton = styled.button` @@ -694,9 +699,9 @@ export const ViewRawButton = styled.button` right: 4px; bottom: -24px; cursor: pointer; - color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.25)}; + color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.25)}; &:hover { - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; } `; diff --git a/frontend/src/shared/components/TaskDetails/index.tsx b/frontend/src/shared/components/TaskDetails/index.tsx index 8203bee..c80599e 100644 --- a/frontend/src/shared/components/TaskDetails/index.tsx +++ b/frontend/src/shared/components/TaskDetails/index.tsx @@ -79,6 +79,7 @@ import { ActivityItemHeaderTitle, ActivityItemHeaderTitleName, ActivityItemComment, + TabBarButton, } from './Styles'; import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist'; import onDragEnd from './onDragEnd'; diff --git a/frontend/src/shared/generated/graphql.tsx b/frontend/src/shared/generated/graphql.tsx index 808bdf7..6f3b886 100644 --- a/frontend/src/shared/generated/graphql.tsx +++ b/frontend/src/shared/generated/graphql.tsx @@ -69,6 +69,12 @@ export type ChecklistBadge = { total: Scalars['Int']; }; +export type CommentsBadge = { + __typename?: 'CommentsBadge'; + total: Scalars['Int']; + unread: Scalars['Boolean']; +}; + export type CreateTaskChecklist = { taskID: Scalars['UUID']; name: Scalars['String']; @@ -1027,6 +1033,7 @@ export type TaskActivityData = { export type TaskBadges = { __typename?: 'TaskBadges'; checklist?: Maybe; + comments?: Maybe; }; export type TaskChecklist = { @@ -1592,6 +1599,9 @@ export type TaskFieldsFragment = ( & { checklist?: Maybe<( { __typename?: 'ChecklistBadge' } & Pick + )>, comments?: Maybe<( + { __typename?: 'CommentsBadge' } + & Pick )> } ), taskGroup: ( { __typename?: 'TaskGroup' } @@ -2693,6 +2703,10 @@ export const TaskFieldsFragmentDoc = gql` complete total } + comments { + unread + total + } } taskGroup { id @@ -5513,4 +5527,4 @@ export function useUsersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type UsersLazyQueryHookResult = ReturnType; -export type UsersQueryResult = Apollo.QueryResult; \ No newline at end of file +export type UsersQueryResult = Apollo.QueryResult; diff --git a/frontend/src/shared/graphql/fragments/task.ts b/frontend/src/shared/graphql/fragments/task.ts index d72aa36..6e56d64 100644 --- a/frontend/src/shared/graphql/fragments/task.ts +++ b/frontend/src/shared/graphql/fragments/task.ts @@ -15,6 +15,10 @@ const TASK_FRAGMENT = gql` complete total } + comments { + unread + total + } } taskGroup { id diff --git a/frontend/src/shared/icons/Bubble.tsx b/frontend/src/shared/icons/Bubble.tsx new file mode 100644 index 0000000..0d17677 --- /dev/null +++ b/frontend/src/shared/icons/Bubble.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const Bubble: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default Bubble; diff --git a/frontend/src/shared/icons/index.ts b/frontend/src/shared/icons/index.ts index 1644bb4..345c651 100644 --- a/frontend/src/shared/icons/index.ts +++ b/frontend/src/shared/icons/index.ts @@ -1,6 +1,7 @@ import Cross from './Cross'; import Cog from './Cog'; import Cogs from './Cogs'; +import Bubble from './Bubble'; import ArrowDown from './ArrowDown'; import CheckCircleOutline from './CheckCircleOutline'; import Briefcase from './Briefcase'; @@ -110,5 +111,6 @@ export { Briefcase, DotCircle, ChevronRight, + Bubble, Cogs, }; diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 0a59395..442cac5 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -59,8 +59,13 @@ type ChecklistBadge = { total: number; }; +type CommentsBadge = { + total: number; + unread: boolean; +}; type TaskBadges = { checklist?: ChecklistBadge | null; + comments?: CommentsBadge | null; }; type TaskActivityData = { diff --git a/internal/db/querier.go b/internal/db/querier.go index 156cce6..8c69764 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -74,6 +74,7 @@ type Querier interface { GetAssignedTasksDueDateForUserID(ctx context.Context, arg GetAssignedTasksDueDateForUserIDParams) ([]Task, error) GetAssignedTasksProjectForUserID(ctx context.Context, arg GetAssignedTasksProjectForUserIDParams) ([]Task, error) GetAuthTokenByID(ctx context.Context, tokenID uuid.UUID) (AuthToken, error) + GetCommentCountForTask(ctx context.Context, taskID uuid.UUID) (int64, error) GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error) GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error) GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error) diff --git a/internal/db/query/task.sql b/internal/db/query/task.sql index 359e8d8..712fd5d 100644 --- a/internal/db/query/task.sql +++ b/internal/db/query/task.sql @@ -95,3 +95,6 @@ SELECT task.* FROM task_assigned ) ) ORDER BY task.due_date DESC, task_group.project_id DESC; + +-- name: GetCommentCountForTask :one +SELECT COUNT(*) FROM task_comment WHERE task_id = $1; diff --git a/internal/db/task.sql.go b/internal/db/task.sql.go index 8cab7ef..b9cc2c3 100644 --- a/internal/db/task.sql.go +++ b/internal/db/task.sql.go @@ -316,6 +316,17 @@ func (q *Queries) GetAssignedTasksProjectForUserID(ctx context.Context, arg GetA return items, nil } +const getCommentCountForTask = `-- name: GetCommentCountForTask :one +SELECT COUNT(*) FROM task_comment WHERE task_id = $1 +` + +func (q *Queries) GetCommentCountForTask(ctx context.Context, taskID uuid.UUID) (int64, error) { + row := q.db.QueryRowContext(ctx, getCommentCountForTask, taskID) + var count int64 + err := row.Scan(&count) + return count, err +} + const getCommentsForTaskID = `-- name: GetCommentsForTaskID :many SELECT task_comment_id, task_id, created_at, updated_at, created_by, pinned, message FROM task_comment WHERE task_id = $1 ORDER BY created_at ` diff --git a/internal/graph/generated.go b/internal/graph/generated.go index b07dc5d..b2c3cd6 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -73,6 +73,11 @@ type ComplexityRoot struct { Total func(childComplexity int) int } + CommentsBadge struct { + Total func(childComplexity int) int + Unread func(childComplexity int) int + } + CreateTaskCommentPayload struct { Comment func(childComplexity int) int TaskID func(childComplexity int) int @@ -419,6 +424,7 @@ type ComplexityRoot struct { TaskBadges struct { Checklist func(childComplexity int) int + Comments func(childComplexity int) int } TaskChecklist struct { @@ -768,6 +774,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ChecklistBadge.Total(childComplexity), true + case "CommentsBadge.total": + if e.complexity.CommentsBadge.Total == nil { + break + } + + return e.complexity.CommentsBadge.Total(childComplexity), true + + case "CommentsBadge.unread": + if e.complexity.CommentsBadge.Unread == nil { + break + } + + return e.complexity.CommentsBadge.Unread(childComplexity), true + case "CreateTaskCommentPayload.comment": if e.complexity.CreateTaskCommentPayload.Comment == nil { break @@ -2553,6 +2573,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.TaskBadges.Checklist(childComplexity), true + case "TaskBadges.comments": + if e.complexity.TaskBadges.Comments == nil { + break + } + + return e.complexity.TaskBadges.Comments(childComplexity), true + case "TaskChecklist.id": if e.complexity.TaskChecklist.ID == nil { break @@ -3212,8 +3239,14 @@ type ChecklistBadge { total: Int! } +type CommentsBadge { + total: Int! + unread: Boolean! +} + type TaskBadges { checklist: ChecklistBadge + comments: CommentsBadge } type CausedBy { @@ -5280,6 +5313,76 @@ func (ec *executionContext) _ChecklistBadge_total(ctx context.Context, field gra return ec.marshalNInt2int(ctx, field.Selections, res) } +func (ec *executionContext) _CommentsBadge_total(ctx context.Context, field graphql.CollectedField, obj *CommentsBadge) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "CommentsBadge", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Total, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _CommentsBadge_unread(ctx context.Context, field graphql.CollectedField, obj *CommentsBadge) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "CommentsBadge", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Unread, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _CreateTaskCommentPayload_taskID(ctx context.Context, field graphql.CollectedField, obj *CreateTaskCommentPayload) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -14831,6 +14934,38 @@ func (ec *executionContext) _TaskBadges_checklist(ctx context.Context, field gra return ec.marshalOChecklistBadge2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐChecklistBadge(ctx, field.Selections, res) } +func (ec *executionContext) _TaskBadges_comments(ctx context.Context, field graphql.CollectedField, obj *TaskBadges) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "TaskBadges", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Comments, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*CommentsBadge) + fc.Result = res + return ec.marshalOCommentsBadge2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐCommentsBadge(ctx, field.Selections, res) +} + func (ec *executionContext) _TaskChecklist_id(ctx context.Context, field graphql.CollectedField, obj *db.TaskChecklist) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -20124,6 +20259,38 @@ func (ec *executionContext) _ChecklistBadge(ctx context.Context, sel ast.Selecti return out } +var commentsBadgeImplementors = []string{"CommentsBadge"} + +func (ec *executionContext) _CommentsBadge(ctx context.Context, sel ast.SelectionSet, obj *CommentsBadge) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, commentsBadgeImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CommentsBadge") + case "total": + out.Values[i] = ec._CommentsBadge_total(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "unread": + out.Values[i] = ec._CommentsBadge_unread(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var createTaskCommentPayloadImplementors = []string{"CreateTaskCommentPayload"} func (ec *executionContext) _CreateTaskCommentPayload(ctx context.Context, sel ast.SelectionSet, obj *CreateTaskCommentPayload) graphql.Marshaler { @@ -22580,6 +22747,8 @@ func (ec *executionContext) _TaskBadges(ctx context.Context, sel ast.SelectionSe out.Values[i] = graphql.MarshalString("TaskBadges") case "checklist": out.Values[i] = ec._TaskBadges_checklist(ctx, field, obj) + case "comments": + out.Values[i] = ec._TaskBadges_comments(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -26306,6 +26475,13 @@ func (ec *executionContext) marshalOChecklistBadge2ᚖgithubᚗcomᚋjordanknott return ec._ChecklistBadge(ctx, sel, v) } +func (ec *executionContext) marshalOCommentsBadge2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐCommentsBadge(ctx context.Context, sel ast.SelectionSet, v *CommentsBadge) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._CommentsBadge(ctx, sel, v) +} + func (ec *executionContext) unmarshalOCreateTaskComment2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐCreateTaskComment(ctx context.Context, v interface{}) (*CreateTaskComment, error) { if v == nil { return nil, nil diff --git a/internal/graph/models_gen.go b/internal/graph/models_gen.go index 5c2c98d..7440ea2 100644 --- a/internal/graph/models_gen.go +++ b/internal/graph/models_gen.go @@ -33,6 +33,11 @@ type ChecklistBadge struct { Total int `json:"total"` } +type CommentsBadge struct { + Total int `json:"total"` + Unread bool `json:"unread"` +} + type CreateTaskChecklist struct { TaskID uuid.UUID `json:"taskID"` Name string `json:"name"` @@ -431,6 +436,7 @@ type TaskActivityData struct { type TaskBadges struct { Checklist *ChecklistBadge `json:"checklist"` + Comments *CommentsBadge `json:"comments"` } type TaskPositionUpdate struct { diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 2533a59..185f440 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -138,8 +138,14 @@ type ChecklistBadge { total: Int! } +type CommentsBadge { + total: Int! + unread: Boolean! +} + type TaskBadges { checklist: ChecklistBadge + comments: CommentsBadge } type CausedBy { @@ -994,4 +1000,3 @@ type DeleteUserAccountPayload { ok: Boolean! userAccount: UserAccount! } - diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 207d7a7..01c7ba9 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -1721,8 +1721,9 @@ func (r *taskResolver) Badges(ctx context.Context, obj *db.Task) (*TaskBadges, e if err != nil { return &TaskBadges{}, err } - if len(checklists) == 0 { - return &TaskBadges{Checklist: nil}, err + comments, err := r.Repository.GetCommentCountForTask(ctx, obj.TaskID) + if err != nil { + return &TaskBadges{}, err } complete := 0 total := 0 @@ -1738,10 +1739,15 @@ func (r *taskResolver) Badges(ctx context.Context, obj *db.Task) (*TaskBadges, e } } } - if complete == 0 && total == 0 { - return &TaskBadges{Checklist: nil}, nil + var taskChecklist *ChecklistBadge + if total != 0 { + taskChecklist = &ChecklistBadge{Total: total, Complete: complete} } - return &TaskBadges{Checklist: &ChecklistBadge{Total: total, Complete: complete}}, nil + var taskComments *CommentsBadge + if comments != 0 { + taskComments = &CommentsBadge{Total: int(comments), Unread: false} + } + return &TaskBadges{Checklist: taskChecklist, Comments: taskComments}, nil } func (r *taskResolver) Activity(ctx context.Context, obj *db.Task) ([]db.TaskActivity, error) { diff --git a/internal/graph/schema/_models.gql b/internal/graph/schema/_models.gql index e41931b..5307c75 100644 --- a/internal/graph/schema/_models.gql +++ b/internal/graph/schema/_models.gql @@ -138,8 +138,14 @@ type ChecklistBadge { total: Int! } +type CommentsBadge { + total: Int! + unread: Boolean! +} + type TaskBadges { checklist: ChecklistBadge + comments: CommentsBadge } type CausedBy {