feat: add task activity
This commit is contained in:
@ -263,7 +263,7 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
|
||||
onChange={(e: any) => {
|
||||
setTeam(e.value);
|
||||
}}
|
||||
value={options.filter(d => d.value === team)}
|
||||
value={options.find(d => d.value === team)}
|
||||
styles={colourStyles}
|
||||
classNamePrefix="teamSelect"
|
||||
options={options}
|
||||
|
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { TaskActivityData, ActivityType } from 'shared/generated/graphql';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
type ActivityMessageProps = {
|
||||
type: ActivityType;
|
||||
data: Array<TaskActivityData>;
|
||||
};
|
||||
|
||||
function getVariable(data: Array<TaskActivityData>, name: string) {
|
||||
const target = data.find(d => d.name === name);
|
||||
return target ? target.value : null;
|
||||
}
|
||||
|
||||
function renderDate(timestamp: string | null) {
|
||||
if (timestamp) {
|
||||
return dayjs(timestamp).format('MMM D [at] h:mm A');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ActivityMessage: React.FC<ActivityMessageProps> = ({ type, data }) => {
|
||||
let message = '';
|
||||
switch (type) {
|
||||
case ActivityType.TaskAdded:
|
||||
message = `added this task to ${getVariable(data, 'TaskGroup')}`;
|
||||
break;
|
||||
case ActivityType.TaskMoved:
|
||||
message = `moved this task from ${getVariable(data, 'PrevTaskGroup')} to ${getVariable(data, 'CurTaskGroup')}`;
|
||||
break;
|
||||
case ActivityType.TaskDueDateAdded:
|
||||
message = `set this task to be due ${renderDate(getVariable(data, 'DueDate'))}`;
|
||||
break;
|
||||
case ActivityType.TaskDueDateRemoved:
|
||||
message = `removed the due date from this task`;
|
||||
break;
|
||||
case ActivityType.TaskDueDateChanged:
|
||||
message = `changed the due date of this task to ${renderDate(getVariable(data, 'CurDueDate'))}`;
|
||||
break;
|
||||
case ActivityType.TaskMarkedComplete:
|
||||
message = `marked this task complete`;
|
||||
break;
|
||||
case ActivityType.TaskMarkedIncomplete:
|
||||
message = `marked this task incomplete`;
|
||||
break;
|
||||
default:
|
||||
message = '<unknown type>';
|
||||
}
|
||||
return <>{message}</>;
|
||||
};
|
||||
|
||||
export default ActivityMessage;
|
133
frontend/src/shared/components/TaskDetails/CommentCreator.tsx
Normal file
133
frontend/src/shared/components/TaskDetails/CommentCreator.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
CommentTextArea,
|
||||
CommentEditorContainer,
|
||||
CommentEditorActions,
|
||||
CommentEditorActionIcon,
|
||||
CommentEditorSaveButton,
|
||||
CommentProfile,
|
||||
CommentInnerWrapper,
|
||||
} from './Styles';
|
||||
import { usePopup } from 'shared/components/PopupMenu';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { At, Paperclip, Smile } from 'shared/icons';
|
||||
import { Picker, Emoji } from 'emoji-mart';
|
||||
import Task from 'shared/icons/Task';
|
||||
|
||||
type CommentCreatorProps = {
|
||||
me?: TaskUser;
|
||||
autoFocus?: boolean;
|
||||
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
||||
message?: string | null;
|
||||
onCreateComment: (message: string) => void;
|
||||
onCancelEdit?: () => void;
|
||||
};
|
||||
|
||||
const CommentCreator: React.FC<CommentCreatorProps> = ({
|
||||
me,
|
||||
message,
|
||||
onMemberProfile,
|
||||
onCreateComment,
|
||||
onCancelEdit,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const $commentWrapper = useRef<HTMLDivElement>(null);
|
||||
const $comment = useRef<HTMLTextAreaElement>(null);
|
||||
const $emoji = useRef<HTMLDivElement>(null);
|
||||
const $emojiCart = useRef<HTMLDivElement>(null);
|
||||
const [comment, setComment] = useState(message ?? '');
|
||||
const [showCommentActions, setShowCommentActions] = useState(autoFocus);
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
useEffect(() => {
|
||||
if (autoFocus && $comment && $comment.current) {
|
||||
$comment.current.select();
|
||||
}
|
||||
}, []);
|
||||
useOnOutsideClick(
|
||||
[$commentWrapper, $emojiCart],
|
||||
showCommentActions,
|
||||
() => {
|
||||
if (onCancelEdit) {
|
||||
onCancelEdit();
|
||||
}
|
||||
setShowCommentActions(false);
|
||||
},
|
||||
null,
|
||||
);
|
||||
return (
|
||||
<CommentInnerWrapper ref={$commentWrapper}>
|
||||
{me && onMemberProfile && (
|
||||
<CommentProfile
|
||||
member={me}
|
||||
size={32}
|
||||
onMemberProfile={$target => {
|
||||
onMemberProfile($target, me.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CommentEditorContainer>
|
||||
<CommentTextArea
|
||||
showCommentActions={showCommentActions}
|
||||
placeholder="Write a comment..."
|
||||
ref={$comment}
|
||||
value={comment}
|
||||
onChange={e => setComment(e.currentTarget.value)}
|
||||
onFocus={() => {
|
||||
setShowCommentActions(true);
|
||||
}}
|
||||
/>
|
||||
<CommentEditorActions visible={showCommentActions}>
|
||||
<CommentEditorActionIcon>
|
||||
<Paperclip width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorActionIcon>
|
||||
<At width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorActionIcon
|
||||
ref={$emoji}
|
||||
onClick={() => {
|
||||
showPopup(
|
||||
$emoji,
|
||||
<div ref={$emojiCart}>
|
||||
<Picker
|
||||
onClick={emoji => {
|
||||
console.log(emoji);
|
||||
if ($comment && $comment.current) {
|
||||
let textToInsert = `${emoji.colons} `;
|
||||
let cursorPosition = $comment.current.selectionStart;
|
||||
let textBeforeCursorPosition = $comment.current.value.substring(0, cursorPosition);
|
||||
let textAfterCursorPosition = $comment.current.value.substring(
|
||||
cursorPosition,
|
||||
$comment.current.value.length,
|
||||
);
|
||||
setComment(textBeforeCursorPosition + textToInsert + textAfterCursorPosition);
|
||||
}
|
||||
hidePopup();
|
||||
}}
|
||||
set="google"
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Smile width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorActionIcon>
|
||||
<Task width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorSaveButton
|
||||
onClick={() => {
|
||||
setShowCommentActions(false);
|
||||
onCreateComment(comment);
|
||||
setComment('');
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</CommentEditorSaveButton>
|
||||
</CommentEditorActions>
|
||||
</CommentEditorContainer>
|
||||
</CommentInnerWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentCreator;
|
@ -475,6 +475,7 @@ export const CommentEditorContainer = styled.div`
|
||||
border: 1px solid #414561;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1f243e;
|
||||
`;
|
||||
export const CommentProfile = styled(TaskAssignee)`
|
||||
margin-right: 8px;
|
||||
@ -484,7 +485,7 @@ export const CommentProfile = styled(TaskAssignee)`
|
||||
align-items: normal;
|
||||
`;
|
||||
|
||||
export const CommentTextArea = styled(TextareaAutosize)`
|
||||
export const CommentTextArea = styled(TextareaAutosize)<{ showCommentActions: boolean }>`
|
||||
width: 100%;
|
||||
line-height: 28px;
|
||||
padding: 4px 6px;
|
||||
@ -495,14 +496,16 @@ export const CommentTextArea = styled(TextareaAutosize)`
|
||||
transition: max-height 200ms, height 200ms, min-height 200ms;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
&:not(:focus) {
|
||||
height: 36px;
|
||||
}
|
||||
&:focus {
|
||||
min-height: 80px;
|
||||
max-height: none;
|
||||
line-height: 20px;
|
||||
}
|
||||
${props =>
|
||||
props.showCommentActions
|
||||
? css`
|
||||
min-height: 80px;
|
||||
max-height: none;
|
||||
line-height: 20px;
|
||||
`
|
||||
: css`
|
||||
height: 36px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const CommentEditorActions = styled.div<{ visible: boolean }>`
|
||||
@ -529,6 +532,18 @@ export const ActivitySection = styled.div`
|
||||
overflow-x: hidden;
|
||||
|
||||
padding: 8px 26px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
`;
|
||||
|
||||
export const ActivityItemCommentAction = styled.div`
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
fill: ${props => props.theme.colors.text.primary} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActivityItem = styled.div`
|
||||
@ -537,25 +552,32 @@ export const ActivityItem = styled.div`
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
&:hover ${ActivityItemCommentAction} {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActivityItemHeader = styled.div`
|
||||
export const ActivityItemHeader = styled.div<{ editable?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 8px;
|
||||
${props => props.editable && 'width: 100%;'}
|
||||
`;
|
||||
export const ActivityItemHeaderUser = styled(TaskAssignee)`
|
||||
margin-right: 4px;
|
||||
align-items: start;
|
||||
`;
|
||||
|
||||
export const ActivityItemHeaderTitle = styled.div`
|
||||
margin-left: 4px;
|
||||
line-height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
padding-bottom: 2px;
|
||||
`;
|
||||
|
||||
export const ActivityItemHeaderTitleName = styled.span`
|
||||
color: ${props => props.theme.colors.text.primary};
|
||||
font-weight: 500;
|
||||
padding-right: 3px;
|
||||
`;
|
||||
|
||||
export const ActivityItemTimestamp = styled.span<{ margin: number }>`
|
||||
@ -568,8 +590,10 @@ export const ActivityItemDetails = styled.div`
|
||||
margin-left: 32px;
|
||||
`;
|
||||
|
||||
export const ActivityItemComment = styled.div`
|
||||
export const ActivityItemCommentContainer = styled.div``;
|
||||
export const ActivityItemComment = styled.div<{ editable: boolean }>`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
border-radius: 3px;
|
||||
${mixin.boxShadowCard}
|
||||
position: relative;
|
||||
@ -577,6 +601,32 @@ export const ActivityItemComment = styled.div`
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)};
|
||||
${props => props.editable && 'width: 100%;'}
|
||||
|
||||
& span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
& ul {
|
||||
list-style-type: disc;
|
||||
margin: 8px 0;
|
||||
}
|
||||
& ul > li {
|
||||
margin: 8px 8px 8px 24px;
|
||||
margin-inline-start: 24px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
& ul > li ul > li {
|
||||
list-style: circle;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActivityItemCommentActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
export const ActivityItemLog = styled.span`
|
||||
|
@ -1,86 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import Modal from 'shared/components/Modal';
|
||||
import TaskDetails from '.';
|
||||
|
||||
export default {
|
||||
component: TaskDetails,
|
||||
title: 'TaskDetails',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const [description, setDescription] = useState('');
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Modal
|
||||
width={1040}
|
||||
onClose={action('on close')}
|
||||
renderContent={() => {
|
||||
return (
|
||||
<TaskDetails
|
||||
onDeleteItem={action('delete item')}
|
||||
onChangeItemName={action('change item name')}
|
||||
task={{
|
||||
id: '1',
|
||||
taskGroup: { name: 'General', id: '1' },
|
||||
name: 'Hello, world',
|
||||
position: 1,
|
||||
labels: [
|
||||
{
|
||||
id: 'soft-skills',
|
||||
assignedDate: new Date().toString(),
|
||||
projectLabel: {
|
||||
createdDate: new Date().toString(),
|
||||
id: 'label-soft-skills',
|
||||
name: 'Soft Skills',
|
||||
labelColor: {
|
||||
id: '1',
|
||||
name: 'white',
|
||||
colorHex: '#fff',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
description,
|
||||
assigned: [
|
||||
{
|
||||
id: '1',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onTaskNameChange={action('task name change')}
|
||||
onTaskDescriptionChange={(_task, desc) => setDescription(desc)}
|
||||
onDeleteTask={action('delete task')}
|
||||
onCloseModal={action('close modal')}
|
||||
onMemberProfile={action('profile')}
|
||||
onOpenAddMemberPopup={action('open add member popup')}
|
||||
onAddItem={action('add item')}
|
||||
onToggleTaskComplete={action('toggle task complete')}
|
||||
onToggleChecklistItem={action('toggle checklist item')}
|
||||
onOpenAddLabelPopup={action('open add label popup')}
|
||||
onChangeChecklistName={action('change checklist name')}
|
||||
onDeleteChecklist={action('delete checklist')}
|
||||
onOpenAddChecklistPopup={action(' open checklist')}
|
||||
onOpenDueDatePopop={action('open due date popup')}
|
||||
onChecklistDrop={action('on checklist drop')}
|
||||
onChecklistItemDrop={action('on checklist item drop')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -12,17 +12,32 @@ import {
|
||||
At,
|
||||
Smile,
|
||||
} from 'shared/icons';
|
||||
import { toArray } from 'react-emoji-render';
|
||||
import DOMPurify from 'dompurify';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { usePopup } from 'shared/components/PopupMenu';
|
||||
import CommentCreator from 'shared/components/TaskDetails/CommentCreator';
|
||||
import { AngleDown } from 'shared/icons/AngleDown';
|
||||
import Editor from 'rich-markdown-editor';
|
||||
import dark from 'shared/utils/editorTheme';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Picker, Emoji } from 'emoji-mart';
|
||||
import 'emoji-mart/css/emoji-mart.css';
|
||||
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import ActivityMessage from './ActivityMessage';
|
||||
import Task from 'shared/icons/Task';
|
||||
import {
|
||||
ActivityItemHeader,
|
||||
ActivityItemTimestamp,
|
||||
ActivityItem,
|
||||
ActivityItemCommentAction,
|
||||
ActivityItemCommentActions,
|
||||
TaskDetailLabel,
|
||||
CommentContainer,
|
||||
ActivityItemCommentContainer,
|
||||
MetaDetailContent,
|
||||
TaskDetailsAddLabelIcon,
|
||||
ActionButton,
|
||||
@ -58,18 +73,126 @@ import {
|
||||
TaskMember,
|
||||
TabBarSection,
|
||||
TabBarItem,
|
||||
CommentTextArea,
|
||||
CommentEditorContainer,
|
||||
CommentEditorActions,
|
||||
CommentEditorActionIcon,
|
||||
CommentEditorSaveButton,
|
||||
CommentProfile,
|
||||
CommentInnerWrapper,
|
||||
ActivitySection,
|
||||
TaskDetailsEditor,
|
||||
ActivityItemHeaderUser,
|
||||
ActivityItemHeaderTitle,
|
||||
ActivityItemHeaderTitleName,
|
||||
ActivityItemComment,
|
||||
} from './Styles';
|
||||
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
|
||||
import onDragEnd from './onDragEnd';
|
||||
import { plugin as em } from './remark';
|
||||
|
||||
const parseEmojis = (value: string) => {
|
||||
const emojisArray = toArray(value);
|
||||
|
||||
// toArray outputs React elements for emojis and strings for other
|
||||
const newValue = emojisArray.reduce((previous: any, current: any) => {
|
||||
if (typeof current === 'string') {
|
||||
return previous + current;
|
||||
}
|
||||
return previous + current.props.children;
|
||||
}, '');
|
||||
|
||||
return newValue;
|
||||
};
|
||||
|
||||
type StreamCommentProps = {
|
||||
comment?: TaskComment | null;
|
||||
onUpdateComment: (message: string) => void;
|
||||
onExtraActions: (commentID: string, $target: React.RefObject<HTMLElement>) => void;
|
||||
onCancelCommentEdit: () => void;
|
||||
editable: boolean;
|
||||
};
|
||||
const StreamComment: React.FC<StreamCommentProps> = ({
|
||||
comment,
|
||||
onExtraActions,
|
||||
editable,
|
||||
onUpdateComment,
|
||||
onCancelCommentEdit,
|
||||
}) => {
|
||||
const $actions = useRef<HTMLDivElement>(null);
|
||||
if (comment) {
|
||||
return (
|
||||
<ActivityItem>
|
||||
<ActivityItemHeaderUser size={32} member={comment.createdBy} />
|
||||
<ActivityItemHeader editable={editable}>
|
||||
<ActivityItemHeaderTitle>
|
||||
<ActivityItemHeaderTitleName>{comment.createdBy.fullName}</ActivityItemHeaderTitleName>
|
||||
<ActivityItemTimestamp margin={8}>
|
||||
{dayjs(comment.createdAt).format('MMM D [at] h:mm A')}
|
||||
{comment.updatedAt && ' (edited)'}
|
||||
</ActivityItemTimestamp>
|
||||
</ActivityItemHeaderTitle>
|
||||
<ActivityItemCommentContainer>
|
||||
<ActivityItemComment editable={editable}>
|
||||
{editable ? (
|
||||
<CommentCreator
|
||||
message={comment.message}
|
||||
autoFocus
|
||||
onCancelEdit={onCancelCommentEdit}
|
||||
onCreateComment={onUpdateComment}
|
||||
/>
|
||||
) : (
|
||||
<ReactMarkdown escapeHtml={false} plugins={[em]}>
|
||||
{DOMPurify.sanitize(comment.message, { FORBID_TAGS: ['style', 'img'] })}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</ActivityItemComment>
|
||||
<ActivityItemCommentActions>
|
||||
<ActivityItemCommentAction
|
||||
ref={$actions}
|
||||
onClick={() => {
|
||||
onExtraActions(comment.id, $actions);
|
||||
}}
|
||||
>
|
||||
<AngleDown width={18} height={18} />
|
||||
</ActivityItemCommentAction>
|
||||
</ActivityItemCommentActions>
|
||||
</ActivityItemCommentContainer>
|
||||
</ActivityItemHeader>
|
||||
</ActivityItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type StreamActivityProps = {
|
||||
activity?: TaskActivity | null;
|
||||
};
|
||||
const StreamActivity: React.FC<StreamActivityProps> = ({ activity }) => {
|
||||
if (activity) {
|
||||
return (
|
||||
<ActivityItem>
|
||||
<ActivityItemHeaderUser
|
||||
size={32}
|
||||
member={{
|
||||
id: activity.causedBy.id,
|
||||
fullName: activity.causedBy.fullName,
|
||||
profileIcon: activity.causedBy.profileIcon
|
||||
? activity.causedBy.profileIcon
|
||||
: {
|
||||
url: null,
|
||||
initials: activity.causedBy.fullName.charAt(0),
|
||||
bgColor: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ActivityItemHeader>
|
||||
<ActivityItemHeaderTitle>
|
||||
<ActivityItemHeaderTitleName>{activity.causedBy.fullName}</ActivityItemHeaderTitleName>
|
||||
<ActivityMessage type={activity.type} data={activity.data} />
|
||||
</ActivityItemHeaderTitle>
|
||||
<ActivityItemTimestamp margin={0}>
|
||||
{dayjs(activity.createdAt).format('MMM D [at] h:mm A')}
|
||||
</ActivityItemTimestamp>
|
||||
</ActivityItemHeader>
|
||||
</ActivityItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const ChecklistContainer = styled.div``;
|
||||
|
||||
@ -114,8 +237,13 @@ type TaskDetailsProps = {
|
||||
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onCreateComment: (task: Task, message: string) => void;
|
||||
onCommentShowActions: (commentID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
||||
onCancelCommentEdit: () => void;
|
||||
onUpdateComment: (commentID: string, message: string) => void;
|
||||
onChangeChecklistName: (checklistID: string, name: string) => void;
|
||||
editableComment?: string | null;
|
||||
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
|
||||
onCloseModal: () => void;
|
||||
onChecklistDrop: (checklist: TaskChecklist) => void;
|
||||
@ -124,11 +252,15 @@ type TaskDetailsProps = {
|
||||
|
||||
const TaskDetails: React.FC<TaskDetailsProps> = ({
|
||||
me,
|
||||
onCancelCommentEdit,
|
||||
task,
|
||||
editableComment = null,
|
||||
onDeleteChecklist,
|
||||
onTaskNameChange,
|
||||
onCommentShowActions,
|
||||
onOpenAddChecklistPopup,
|
||||
onChangeChecklistName,
|
||||
onCreateComment,
|
||||
onChecklistDrop,
|
||||
onChecklistItemDrop,
|
||||
onToggleTaskComplete,
|
||||
@ -137,6 +269,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
||||
onDeleteItem,
|
||||
onDeleteTask,
|
||||
onCloseModal,
|
||||
onUpdateComment,
|
||||
onOpenAddMemberPopup,
|
||||
onOpenAddLabelPopup,
|
||||
onOpenDueDatePopop,
|
||||
@ -156,12 +289,38 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
||||
});
|
||||
const [saveTimeout, setSaveTimeout] = useState<any>(null);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [showCommentActions, setShowCommentActions] = useState(false);
|
||||
const taskDescriptionRef = useRef(task.description ?? '');
|
||||
const $noMemberBtn = useRef<HTMLDivElement>(null);
|
||||
const $addMemberBtn = useRef<HTMLDivElement>(null);
|
||||
const $dueDateBtn = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activityStream: Array<{ id: string; data: { time: string; type: 'comment' | 'activity' } }> = [];
|
||||
|
||||
if (task.activity) {
|
||||
task.activity.forEach(activity => {
|
||||
activityStream.push({
|
||||
id: activity.id,
|
||||
data: {
|
||||
time: activity.createdAt,
|
||||
type: 'activity',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (task.comments) {
|
||||
task.comments.forEach(comment => {
|
||||
activityStream.push({
|
||||
id: comment.id,
|
||||
data: {
|
||||
time: comment.createdAt,
|
||||
type: 'comment',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
activityStream.sort((a, b) => (dayjs(a.data.time).isAfter(dayjs(b.data.time)) ? 1 : -1));
|
||||
|
||||
const saveDescription = () => {
|
||||
onTaskDescriptionChange(task, taskDescriptionRef.current);
|
||||
};
|
||||
@ -425,46 +584,29 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
||||
<TabBarSection>
|
||||
<TabBarItem>Activity</TabBarItem>
|
||||
</TabBarSection>
|
||||
<ActivitySection />
|
||||
<ActivitySection>
|
||||
{activityStream.map(stream =>
|
||||
stream.data.type === 'comment' ? (
|
||||
<StreamComment
|
||||
onExtraActions={onCommentShowActions}
|
||||
onCancelCommentEdit={onCancelCommentEdit}
|
||||
onUpdateComment={message => onUpdateComment(stream.id, message)}
|
||||
editable={stream.id === editableComment}
|
||||
comment={task.comments && task.comments.find(comment => comment.id === stream.id)}
|
||||
/>
|
||||
) : (
|
||||
<StreamActivity activity={task.activity && task.activity.find(activity => activity.id === stream.id)} />
|
||||
),
|
||||
)}
|
||||
</ActivitySection>
|
||||
</InnerContentContainer>
|
||||
<CommentContainer>
|
||||
{me && (
|
||||
<CommentInnerWrapper>
|
||||
<CommentProfile
|
||||
member={me}
|
||||
size={32}
|
||||
onMemberProfile={$target => {
|
||||
onMemberProfile($target, me.id);
|
||||
}}
|
||||
/>
|
||||
<CommentEditorContainer>
|
||||
<CommentTextArea
|
||||
disabled
|
||||
placeholder="Write a comment..."
|
||||
onFocus={() => {
|
||||
setShowCommentActions(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setShowCommentActions(false);
|
||||
}}
|
||||
/>
|
||||
<CommentEditorActions visible={showCommentActions}>
|
||||
<CommentEditorActionIcon>
|
||||
<Paperclip width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorActionIcon>
|
||||
<At width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorActionIcon>
|
||||
<Smile width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorActionIcon>
|
||||
<Task width={12} height={12} />
|
||||
</CommentEditorActionIcon>
|
||||
<CommentEditorSaveButton>Save</CommentEditorSaveButton>
|
||||
</CommentEditorActions>
|
||||
</CommentEditorContainer>
|
||||
</CommentInnerWrapper>
|
||||
<CommentCreator
|
||||
me={me}
|
||||
onCreateComment={message => onCreateComment(task, message)}
|
||||
onMemberProfile={onMemberProfile}
|
||||
/>
|
||||
)}
|
||||
</CommentContainer>
|
||||
</ContentContainer>
|
||||
|
61
frontend/src/shared/components/TaskDetails/remark.js
Normal file
61
frontend/src/shared/components/TaskDetails/remark.js
Normal file
@ -0,0 +1,61 @@
|
||||
import visit from 'unist-util-visit';
|
||||
import emoji from 'node-emoji';
|
||||
import emoticon from 'emoticon';
|
||||
import { Emoji } from 'emoji-mart';
|
||||
import React from 'react';
|
||||
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
|
||||
const RE_EMOJI = /:\+1:|:-1:|:[\w-]+:/g;
|
||||
const RE_SHORT = /[$@|*'",;.=:\-)([\]\\/<>038BOopPsSdDxXzZ]{2,5}/g;
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
padSpaceAfter: false,
|
||||
emoticon: false,
|
||||
};
|
||||
|
||||
function plugin(options) {
|
||||
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
|
||||
const pad = !!settings.padSpaceAfter;
|
||||
const emoticonEnable = !!settings.emoticon;
|
||||
|
||||
function getEmojiByShortCode(match) {
|
||||
// find emoji by shortcode - full match or with-out last char as it could be from text e.g. :-),
|
||||
const iconFull = emoticon.find(e => e.emoticons.includes(match)); // full match
|
||||
const iconPart = emoticon.find(e => e.emoticons.includes(match.slice(0, -1))); // second search pattern
|
||||
const trimmedChar = iconPart ? match.slice(-1) : '';
|
||||
const addPad = pad ? ' ' : '';
|
||||
let icon = iconFull ? iconFull.emoji + addPad : iconPart && iconPart.emoji + addPad + trimmedChar;
|
||||
return icon || match;
|
||||
}
|
||||
|
||||
function getEmoji(match) {
|
||||
console.log(match);
|
||||
const got = emoji.get(match);
|
||||
if (pad && got !== match) {
|
||||
return got + ' ';
|
||||
}
|
||||
|
||||
console.log(got);
|
||||
return ReactDOMServer.renderToStaticMarkup(<Emoji set="google" emoji={match} size={16} />);
|
||||
}
|
||||
|
||||
function transformer(tree) {
|
||||
visit(tree, 'paragraph', function(node) {
|
||||
console.log(tree);
|
||||
// node.value = node.value.replace(RE_EMOJI, getEmoji);
|
||||
node.type = 'html';
|
||||
node.tagName = 'div';
|
||||
node.value = node.children[0].value.replace(RE_EMOJI, getEmoji);
|
||||
|
||||
if (emoticonEnable) {
|
||||
// node.value = node.value.replace(RE_SHORT, getEmojiByShortCode);
|
||||
}
|
||||
console.log(node);
|
||||
});
|
||||
}
|
||||
|
||||
return transformer;
|
||||
}
|
||||
|
||||
export { plugin };
|
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,40 @@ query findTask($taskID: UUID!) {
|
||||
id
|
||||
name
|
||||
}
|
||||
comments {
|
||||
id
|
||||
pinned
|
||||
message
|
||||
createdAt
|
||||
updatedAt
|
||||
createdBy {
|
||||
id
|
||||
fullName
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
activity {
|
||||
id
|
||||
type
|
||||
causedBy {
|
||||
id
|
||||
fullName
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
url
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
data {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
badges {
|
||||
checklist {
|
||||
total
|
||||
|
27
frontend/src/shared/graphql/task/createTaskComment.ts
Normal file
27
frontend/src/shared/graphql/task/createTaskComment.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
const CREATE_TASK_MUTATION = gql`
|
||||
mutation createTaskComment($taskID: UUID!, $message: String!) {
|
||||
createTaskComment(input: { taskID: $taskID, message: $message }) {
|
||||
taskID
|
||||
comment {
|
||||
id
|
||||
message
|
||||
pinned
|
||||
createdAt
|
||||
updatedAt
|
||||
createdBy {
|
||||
id
|
||||
fullName
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default CREATE_TASK_MUTATION;
|
11
frontend/src/shared/graphql/task/deleteTaskComment.ts
Normal file
11
frontend/src/shared/graphql/task/deleteTaskComment.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
const CREATE_TASK_MUTATION = gql`
|
||||
mutation deleteTaskComment($commentID: UUID!) {
|
||||
deleteTaskComment(input: { commentID: $commentID }) {
|
||||
commentID
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default CREATE_TASK_MUTATION;
|
15
frontend/src/shared/graphql/task/updateTaskComment.ts
Normal file
15
frontend/src/shared/graphql/task/updateTaskComment.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
const CREATE_TASK_MUTATION = gql`
|
||||
mutation updateTaskComment($commentID: UUID!, $message: String!) {
|
||||
updateTaskComment(input: { commentID: $commentID, message: $message }) {
|
||||
comment {
|
||||
id
|
||||
updatedAt
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default CREATE_TASK_MUTATION;
|
@ -16,10 +16,14 @@ const useOnOutsideClick = (
|
||||
|
||||
const handleMouseUp = (event: any) => {
|
||||
if (typeof $ignoredElementRefsMemoized !== 'undefined') {
|
||||
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(
|
||||
($elementRef: any) =>
|
||||
$elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains(event.target),
|
||||
);
|
||||
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(($elementRef: any) => {
|
||||
if ($elementRef && $elementRef.current) {
|
||||
return (
|
||||
$elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains(event.target)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
|
||||
onOutsideClick();
|
||||
}
|
||||
|
@ -1,12 +1,21 @@
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from './Icon';
|
||||
|
||||
type Props = {
|
||||
width: number | string;
|
||||
height: number | string;
|
||||
color: string;
|
||||
export const AngleDown: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
||||
return (
|
||||
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
|
||||
<path d="M143 352.3L7 216.3c-9.4-9.4-9.4-24.6 0-33.9l22.6-22.6c9.4-9.4 24.6-9.4 33.9 0l96.4 96.4 96.4-96.4c9.4-9.4 24.6-9.4 33.9 0l22.6 22.6c9.4 9.4 9.4 24.6 0 33.9l-136 136c-9.2 9.4-24.4 9.4-33.8 0z" />
|
||||
</Icon>
|
||||
);
|
||||
};
|
||||
|
||||
const AngleDown = ({ width, height, color }: Props) => {
|
||||
type Props = {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const AngleDownOld = ({ width, height, color }: Props) => {
|
||||
return (
|
||||
<svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
|
||||
<path
|
||||
@ -17,10 +26,10 @@ const AngleDown = ({ width, height, color }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
AngleDown.defaultProps = {
|
||||
AngleDownOld.defaultProps = {
|
||||
width: 24,
|
||||
height: 16,
|
||||
color: '#000',
|
||||
};
|
||||
|
||||
export default AngleDown;
|
||||
export default AngleDownOld;
|
||||
|
@ -9,7 +9,7 @@ export function updateApolloCache<T>(
|
||||
update: UpdateCacheFn<T>,
|
||||
variables?: object,
|
||||
) {
|
||||
let queryArgs: DataProxy.Query<any>;
|
||||
let queryArgs: DataProxy.Query<any, any>;
|
||||
if (variables) {
|
||||
queryArgs = {
|
||||
query: document,
|
||||
|
Reference in New Issue
Block a user