feat: add comments badge to task card

This commit is contained in:
Jordan Knott 2021-10-25 15:14:24 -05:00
parent 3992e4c2de
commit 119a4b2868
18 changed files with 385 additions and 100 deletions

View File

@ -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;

View File

@ -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<ProjectLabel>;
comments?: { unread: boolean; total: number } | null;
watched?: boolean;
wrapperProps?: any;
members?: Array<TaskUser> | 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(
<ListCardInnerContainer ref={$innerCardRef}>
{!isPublic && isActive && !editable && (
<ListCardOperation
onClick={e => {
onClick={(e) => {
e.stopPropagation();
if (onContextMenu) {
onContextMenu($innerCardRef, taskID, taskGroupID);
@ -167,7 +171,7 @@ const Card = React.forwardRef(
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
onClick={e => {
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) => (
<ListCardLabel
onAnimationEnd={() => {
if (setToggleLabels) {
@ -198,13 +202,13 @@ const Card = React.forwardRef(
<EditorContent>
{complete && <CompleteIcon width={16} height={16} />}
<EditorTextarea
onChange={e => {
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(
<List width={8} height={8} />
</DescriptionBadge>
)}
{comments && (
<CommentsBadge>
<CommentsIcon color={comments.unread ? 'success' : 'normal'} width={8} height={8} />
<ListCardBadgeText color={comments.unread ? 'success' : 'normal'}>{comments.total}</ListCardBadgeText>
</CommentsBadge>
)}
{checklists && (
<ListCardBadge>
<ChecklistIcon
@ -256,7 +266,7 @@ const Card = React.forwardRef(
size={28}
zIndex={members.length - idx}
member={member}
onMemberProfile={$target => {
onMemberProfile={($target) => {
if (onCardMemberClick) {
onCardMemberClick($target, taskID, member.id);
}

View File

@ -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<SimpleProps> = ({
let beforeDropDraggables: Array<DraggableElement> | 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<SimpleProps> = ({
}
} 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<SimpleProps> = ({
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<SimpleProps> = ({
<BoardWrapper>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="horizontal" type="column" droppableId="root">
{provided => (
{(provided) => (
<Container {...provided.droppableProps} ref={provided.innerRef}>
{taskGroups
.slice()
@ -294,14 +286,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
.map((taskGroup: TaskGroup, index: number) => {
return (
<Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}>
{columnDragProvided => (
{(columnDragProvided) => (
<Droppable type="tasks" droppableId={taskGroup.id}>
{(columnDropProvided, snapshot) => (
<List
name={taskGroup.name}
onOpenComposer={id => 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<SimpleProps> = ({
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
{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<SimpleProps> = ({
index={taskIndex}
isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
>
{taskProvided => {
{(taskProvided) => {
return (
<Card
toggleDirection={toggleDirection}
@ -352,7 +344,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
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<SimpleProps> = ({
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<SimpleProps> = ({
onClose={() => {
setCurrentComposer('');
}}
onCreateCard={name => {
onCreateCard={(name) => {
onCreateTask(taskGroup.id, name);
}}
isOpen
@ -402,7 +395,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
</DragDropContext>
{!isPublic && (
<AddList
onSave={listName => {
onSave={(listName) => {
onCreateTaskGroup(listName);
}}
/>

View File

@ -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};
}
`;

View File

@ -79,6 +79,7 @@ import {
ActivityItemHeaderTitle,
ActivityItemHeaderTitleName,
ActivityItemComment,
TabBarButton,
} from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd';

View File

@ -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<ChecklistBadge>;
comments?: Maybe<CommentsBadge>;
};
export type TaskChecklist = {
@ -1592,6 +1599,9 @@ export type TaskFieldsFragment = (
& { checklist?: Maybe<(
{ __typename?: 'ChecklistBadge' }
& Pick<ChecklistBadge, 'complete' | 'total'>
)>, comments?: Maybe<(
{ __typename?: 'CommentsBadge' }
& Pick<CommentsBadge, 'unread' | 'total'>
)> }
), taskGroup: (
{ __typename?: 'TaskGroup' }
@ -2693,6 +2703,10 @@ export const TaskFieldsFragmentDoc = gql`
complete
total
}
comments {
unread
total
}
}
taskGroup {
id

View File

@ -15,6 +15,10 @@ const TASK_FRAGMENT = gql`
complete
total
}
comments {
unread
total
}
}
taskGroup {
id

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Bubble: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 576 512">
<path d="M416 192c0-88.4-93.1-160-208-160S0 103.6 0 192c0 34.3 14.1 65.9 38 92-13.4 30.2-35.5 54.2-35.8 54.5-2.2 2.3-2.8 5.7-1.5 8.7S4.8 352 8 352c36.6 0 66.9-12.3 88.7-25 32.2 15.7 70.3 25 111.3 25 114.9 0 208-71.6 208-160zm122 220c23.9-26 38-57.7 38-92 0-66.9-53.5-124.2-129.3-148.1.9 6.6 1.3 13.3 1.3 20.1 0 105.9-107.7 192-240 192-10.8 0-21.3-.8-31.7-1.9C207.8 439.6 281.8 480 368 480c41 0 79.1-9.2 111.3-25 21.8 12.7 52.1 25 88.7 25 3.2 0 6.1-1.9 7.3-4.8 1.3-2.9.7-6.3-1.5-8.7-.3-.3-22.4-24.2-35.8-54.5z" />
</Icon>
);
};
export default Bubble;

View File

@ -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,
};

View File

@ -59,8 +59,13 @@ type ChecklistBadge = {
total: number;
};
type CommentsBadge = {
total: number;
unread: boolean;
};
type TaskBadges = {
checklist?: ChecklistBadge | null;
comments?: CommentsBadge | null;
};
type TaskActivityData = {

View File

@ -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)

View File

@ -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;

View File

@ -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
`

View File

@ -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

View File

@ -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 {

View File

@ -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!
}

View File

@ -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) {

View File

@ -138,8 +138,14 @@ type ChecklistBadge {
total: Int!
}
type CommentsBadge {
total: Int!
unread: Boolean!
}
type TaskBadges {
checklist: ChecklistBadge
comments: CommentsBadge
}
type CausedBy {