feat: add task activity

This commit is contained in:
Jordan Knott 2020-12-18 20:34:35 -06:00
parent f732b211c9
commit 19deab0515
48 changed files with 9359 additions and 4991 deletions

View File

@ -21,4 +21,4 @@ windows:
- database:
root: ./
panes:
- pgcli postgres://taskcafe:taskcafe_test@localhost:5432/taskcafe
- pgcli postgres://taskcafe:taskcafe_test@localhost:8855/taskcafe

View File

@ -9,6 +9,8 @@
"@types/axios": "^0.14.0",
"@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0",
"@types/dompurify": "^2.0.4",
"@types/emoji-mart": "^3.0.4",
"@types/jest": "^24.0.0",
"@types/jwt-decode": "^2.2.1",
"@types/lodash": "^4.14.149",
@ -35,12 +37,16 @@
"color": "^3.1.2",
"date-fns": "^2.14.0",
"dayjs": "^1.9.1",
"dompurify": "^2.2.6",
"emoji-mart": "^3.0.0",
"emoticon": "^3.2.0",
"graphql": "^15.0.0",
"graphql-tag": "^2.10.3",
"history": "^4.10.1",
"immer": "^6.0.3",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.20",
"node-emoji": "^1.10.0",
"prop-types": "^15.7.2",
"query-string": "^6.13.7",
"react": "^16.12.0",
@ -48,6 +54,7 @@
"react-beautiful-dnd": "^13.0.0",
"react-datepicker": "^2.14.1",
"react-dom": "^16.12.0",
"react-emoji-render": "^1.2.4",
"react-hook-form": "^6.0.6",
"react-markdown": "^4.3.1",
"react-router": "^5.1.2",

View File

@ -182,7 +182,7 @@ const AdminRoute = () => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.invitedUsers = cache.invitedUsers.filter(
u => u.id !== response.data.deleteInvitedUserAccount.invitedUser.id,
u => u.id !== response.data?.deleteInvitedUserAccount.invitedUser.id,
);
}),
);
@ -192,7 +192,7 @@ const AdminRoute = () => {
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.users = cache.users.filter(u => u.id !== response.data.deleteUserAccount.userAccount.id);
draftCache.users = cache.users.filter(u => u.id !== response.data?.deleteUserAccount.userAccount.id);
}),
);
},
@ -203,7 +203,7 @@ const AdminRoute = () => {
query: UsersDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.users = [...draftState.users, { ...createData.data.createUserAccount }];
draftState.users = [...draftState.users, { ...createData.data?.createUserAccount }];
});
client.writeQuery({

View File

@ -25,6 +25,11 @@ const MainContent = styled.div`
flex-grow: 1;
`;
type RefreshTokenResponse = {
accessToken: string;
setup?: null | { confirmToken: string };
};
const AuthorizedRoutes = () => {
const history = useHistory();
const [loading, setLoading] = useState(true);

View File

@ -167,7 +167,7 @@ const ProjectFinder = () => {
return <span>error</span>;
};
type ProjectPopupProps = {
history: History<History.PoorMansUnknown>;
history: any;
name: string;
projectID: string;
};
@ -182,7 +182,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data.deleteProject.project.id,
(project: any) => project.id !== deleteData.data?.deleteProject.project.id,
);
});

View File

@ -46,10 +46,6 @@ const StyledContainer = styled(ToastContainer).attrs({
`;
const history = createBrowserHistory();
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
const App = () => {
const [user, setUser] = useState<CurrentUserRaw | null>(null);

View File

@ -280,7 +280,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id,
);
}),
{ projectID },
@ -296,9 +296,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data?.createTask.taskGroup.id);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
if (newTaskData.data) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
}
}),
{ projectID },
@ -313,7 +315,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
if (newTaskGroupData.data) {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}
}),
{ projectID },
);
@ -332,7 +336,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data.deleteTaskGroupTasks.taskGroupID,
t => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID,
);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = [];
@ -348,7 +352,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
if (resp.data) {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}
}),
{ projectID },
);
@ -364,19 +370,24 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id,
);
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...task },
];
if (newTask.data) {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(t => t.id === task.id);
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id,
);
if (previousTask) {
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...previousTask },
];
}
}
}
}
}),

View File

@ -21,6 +21,9 @@ import {
useCreateTaskChecklistItemMutation,
FindTaskDocument,
FindTaskQuery,
useCreateTaskCommentMutation,
useDeleteTaskCommentMutation,
useUpdateTaskCommentMutation,
} from 'shared/generated/graphql';
import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile';
@ -33,6 +36,73 @@ import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop';
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${props => props.theme.colors.primary};
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const WarningLabel = styled.p`
font-size: 14px;
margin: 8px 12px;
`;
const DeleteConfirm = styled(Button)`
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
`;
type TaskCommentActionsProps = {
onDeleteComment: () => void;
onEditComment: () => void;
};
const TaskCommentActions: React.FC<TaskCommentActionsProps> = ({ onDeleteComment, onEditComment }) => {
const { setTab } = usePopup();
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem>
<ActionTitle>Pin to top</ActionTitle>
</ActionItem>
<ActionItem onClick={() => onEditComment()}>
<ActionTitle>Edit comment</ActionTitle>
</ActionItem>
<ActionItem onClick={() => setTab(1)}>
<ActionTitle>Delete comment</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
<Popup tab={1} title="Delete comment?">
<WarningLabel>Deleting a comment can not be undone.</WarningLabel>
<DeleteConfirm onClick={() => onDeleteComment()} color="danger">
Delete comment
</DeleteConfirm>
</Popup>
</>
);
};
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => {
return (
@ -130,6 +200,40 @@ const Details: React.FC<DetailsProps> = ({
const { user } = useCurrentUser();
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const [deleteTaskComment] = useDeleteTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findTask.comments = cache.findTask.comments.filter(
c => c.id !== response.data?.deleteTaskComment.commentID,
);
}
}),
{ taskID },
);
},
});
const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findTask.comments.push({
...response.data.createTaskComment.comment,
});
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => {
@ -138,21 +242,23 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (checklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
);
draftCache.findTask.checklists[newIdx].items.push({
...item,
position: checklistItem.position,
taskChecklistID: checklistID,
});
if (response.data) {
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (taskChecklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === taskChecklistID);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
);
draftCache.findTask.checklists[newIdx].items.push({
...item,
position: checklistItem.position,
taskChecklistID,
});
}
}
}
}
@ -188,7 +294,7 @@ const Details: React.FC<DetailsProps> = ({
produce(cache, draftCache => {
const { checklists } = cache.findTask;
draftCache.findTask.checklists = checklists.filter(
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
c => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
);
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
@ -212,8 +318,10 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
if (createData.data) {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
}
}),
{ taskID },
);
@ -227,36 +335,14 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
c => item.id !== c.id,
);
}
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}),
{ taskID },
);
},
});
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
if (deleteData.data) {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
c => item.id !== c.id,
);
}
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
@ -269,7 +355,33 @@ const Details: React.FC<DetailsProps> = ({
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (newTaskItem.data) {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID }, fetchPolicy: 'cache-and-network' });
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => {
@ -289,6 +401,8 @@ const Details: React.FC<DetailsProps> = ({
refreshCache();
},
});
const [updateTaskComment] = useUpdateTaskCommentMutation();
const [editableComment, setEditableComment] = useState<null | string>(null);
if (loading) {
return null;
}
@ -305,8 +419,31 @@ const Details: React.FC<DetailsProps> = ({
renderContent={() => {
return (
<TaskDetails
onCancelCommentEdit={() => setEditableComment(null)}
onUpdateComment={(commentID, message) => {
updateTaskComment({ variables: { commentID, message } });
}}
editableComment={editableComment}
me={data.me.user}
onCommentShowActions={(commentID, $targetRef) => {
showPopup(
$targetRef,
<TaskCommentActions
onDeleteComment={() => {
deleteTaskComment({ variables: { commentID } });
hidePopup();
}}
onEditComment={() => {
setEditableComment(commentID);
hidePopup();
}}
/>,
);
}}
task={data.findTask}
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={checklist => {
updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position },

View File

@ -36,7 +36,9 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
if (newLabelData.data) {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
}
}),
{
projectID,
@ -53,7 +55,7 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
cache =>
produce(cache, draftCache => {
draftCache.findProject.labels = cache.findProject.labels.filter(
label => label.id !== newLabelData.data.deleteProjectLabel.id,
label => label.id !== newLabelData.data?.deleteProjectLabel.id,
);
}),
{ projectID },

View File

@ -32,7 +32,6 @@ import {
FindProjectDocument,
FindProjectQuery,
} from 'shared/generated/graphql';
import produce from 'immer';
import UserContext, { useCurrentUser } from 'App/context';
import Input from 'shared/components/Input';
@ -135,7 +134,6 @@ type MemberFilterOptions = {
};
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
console.log(input.trim().length < 3);
if (input && input.trim().length < 3) {
return [];
}
@ -163,12 +161,10 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
let results: any = [];
const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') {
console.log(`${m.id} is added`);
return {
label: m.id,
value: {
@ -180,17 +176,15 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
},
},
};
} else {
console.log(`${m.user.email} is added`);
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}),
];
console.log(results);
}
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
@ -243,7 +237,6 @@ const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
`;
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? (
<OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee
@ -423,14 +416,16 @@ const Project = () => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
tg => tg.tasks.findIndex(t => t.id === resp.data.deleteTask.taskID) !== -1,
);
if (resp.data) {
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
tg => tg.tasks.findIndex(t => t.id === resp.data?.deleteTask.taskID) !== -1,
);
if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx
].tasks.filter(t => t.id !== resp.data.deleteTask.taskID);
if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx
].tasks.filter(t => t.id !== resp.data?.deleteTask.taskID);
}
}
}),
{ projectID },
@ -450,7 +445,7 @@ const Project = () => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.name = newName.data.updateProjectName.name;
draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
}),
{ projectID },
);
@ -464,14 +459,16 @@ const Project = () => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.members = [
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
];
draftCache.findProject.invitedMembers = [
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
if (response.data) {
draftCache.findProject.members = [
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
];
draftCache.findProject.invitedMembers = [
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
}
}),
{ projectID },
);
@ -485,7 +482,7 @@ const Project = () => {
cache =>
produce(cache, draftCache => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data.deleteInvitedProjectMember.invitedMember.email,
m => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '',
);
}),
{ projectID },
@ -500,7 +497,7 @@ const Project = () => {
cache =>
produce(cache, draftCache => {
draftCache.findProject.members = cache.findProject.members.filter(
m => m.id !== response.data.deleteProjectMember.member.id,
m => m.id !== response.data?.deleteProjectMember.member.id,
);
}),
{ projectID },

View File

@ -210,7 +210,9 @@ const Projects = () => {
update: (client, newProject) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.projects.push({ ...newProject.data.createProject });
if (newProject.data) {
draftCache.projects.push({ ...newProject.data.createProject });
}
}),
);
},
@ -222,7 +224,9 @@ const Projects = () => {
update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams.push({ ...createData.data.createTeam });
if (createData.data) {
draftCache.teams.push({ ...createData.data?.createTeam });
}
}),
);
},

View File

@ -430,11 +430,13 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
GetTeamDocument,
cache =>
produce(cache, draftCache => {
draftCache.findTeam.members.push({
...response.data.createTeamMember.teamMember,
member: { __typename: 'MemberList', projects: [], teams: [] },
owned: { __typename: 'OwnedList', projects: [], teams: [] },
});
if (response.data) {
draftCache.findTeam.members.push({
...response.data.createTeamMember.teamMember,
member: { __typename: 'MemberList', projects: [], teams: [] },
owned: { __typename: 'OwnedList', projects: [], teams: [] },
});
}
}),
{ teamID },
);
@ -459,7 +461,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
cache =>
produce(cache, draftCache => {
draftCache.findTeam.members = cache.findTeam.members.filter(
member => member.id !== response.data.deleteTeamMember.userID,
member => member.id !== response.data?.deleteTeamMember.userID,
);
}),
{ teamID },

View File

@ -33,7 +33,7 @@ const Wrapper = styled.div`
`;
type TeamPopupProps = {
history: History<History.PoorMansUnknown>;
history: History<any>;
name: string;
teamID: string;
};
@ -44,9 +44,9 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
update: (client, deleteData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data.deleteTeam.team.id);
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data?.deleteTeam.team.id);
draftCache.projects = cache.projects.filter(
(project: any) => project.team.id !== deleteData.data.deleteTeam.team.id,
(project: any) => project.team.id !== deleteData.data?.deleteTeam.team.id,
);
}),
);

View File

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

View File

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

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

View File

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

View File

@ -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')}
/>
);
}}
/>
</>
);
};

View File

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

View 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

View File

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

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

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

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

View File

@ -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();
}

View File

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

View File

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

View File

@ -1,3 +1,10 @@
type ProjectLabel = {
id: string;
createdDate: string;
name?: string | null;
labelColor: LabelColor;
};
type ProfileIcon = {
url?: string | null;
initials?: string | null;
@ -56,6 +63,39 @@ type TaskBadges = {
checklist?: ChecklistBadge | null;
};
type TaskActivityData = {
name: string;
value: string;
};
type CausedBy = {
id: string;
fullName: string;
profileIcon?: null | ProfileIcon;
};
type TaskActivity = {
id: string;
type: any;
data: Array<TaskActivityData>;
causedBy: CausedBy;
createdAt: string;
};
type CreatedBy = {
id: string;
fullName: string;
profileIcon: ProfileIcon;
};
type TaskComment = {
id: string;
createdBy: CreatedBy;
createdAt: string;
updatedAt?: string | null;
pinned: boolean;
message: string;
};
type Task = {
id: string;
taskGroup: InnerTaskGroup;
@ -69,6 +109,8 @@ type Task = {
description?: string | null;
assigned?: Array<TaskUser>;
checklists?: Array<TaskChecklist> | null;
activity?: Array<TaskActivity> | null;
comments?: Array<TaskComment> | null;
};
type Project = {
@ -89,10 +131,3 @@ type Team = {
name: string;
createdAt: string;
};
type ProjectLabel = {
id: string;
createdDate: string;
name?: string | null;
labelColor: LabelColor;
};

File diff suppressed because it is too large Load Diff

1
go.mod
View File

@ -23,5 +23,6 @@ require (
github.com/spf13/viper v1.4.0
github.com/vektah/gqlparser/v2 v2.0.1
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1
)

3
go.sum
View File

@ -388,7 +388,6 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/matcornic/hermes v1.2.0 h1:AuqZpYcTOtTB7cahdevLfnhIpfzmpqw5Czv8vpdnFDU=
github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc=
github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
@ -892,6 +891,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

View File

@ -4,6 +4,7 @@ package db
import (
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
@ -103,6 +104,22 @@ type Task struct {
CompletedAt sql.NullTime `json:"completed_at"`
}
type TaskActivity struct {
TaskActivityID uuid.UUID `json:"task_activity_id"`
Active bool `json:"active"`
TaskID uuid.UUID `json:"task_id"`
CreatedAt time.Time `json:"created_at"`
CausedBy uuid.UUID `json:"caused_by"`
ActivityTypeID int32 `json:"activity_type_id"`
Data json.RawMessage `json:"data"`
}
type TaskActivityType struct {
TaskActivityTypeID int32 `json:"task_activity_type_id"`
Code string `json:"code"`
Template string `json:"template"`
}
type TaskAssigned struct {
TaskAssignedID uuid.UUID `json:"task_assigned_id"`
TaskID uuid.UUID `json:"task_id"`
@ -128,6 +145,16 @@ type TaskChecklistItem struct {
DueDate sql.NullTime `json:"due_date"`
}
type TaskComment struct {
TaskCommentID uuid.UUID `json:"task_comment_id"`
TaskID uuid.UUID `json:"task_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
CreatedBy uuid.UUID `json:"created_by"`
Pinned bool `json:"pinned"`
Message string `json:"message"`
}
type TaskGroup struct {
TaskGroupID uuid.UUID `json:"task_group_id"`
ProjectID uuid.UUID `json:"project_id"`

View File

@ -23,10 +23,12 @@ type Querier interface {
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateTaskActivity(ctx context.Context, arg CreateTaskActivityParams) (TaskActivity, error)
CreateTaskAll(ctx context.Context, arg CreateTaskAllParams) (Task, error)
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
CreateTaskChecklistItem(ctx context.Context, arg CreateTaskChecklistItemParams) (TaskChecklistItem, error)
CreateTaskComment(ctx context.Context, arg CreateTaskCommentParams) (TaskComment, error)
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error)
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
@ -47,6 +49,7 @@ type Querier interface {
DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error
DeleteTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) error
DeleteTaskChecklistItem(ctx context.Context, taskChecklistItemID uuid.UUID) error
DeleteTaskCommentByID(ctx context.Context, taskCommentID uuid.UUID) (TaskComment, error)
DeleteTaskGroupByID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
DeleteTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) error
DeleteTaskLabelForTaskByProjectLabelID(ctx context.Context, arg DeleteTaskLabelForTaskByProjectLabelIDParams) error
@ -55,6 +58,7 @@ type Querier interface {
DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error
DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) error
DeleteUserAccountInvitedForEmail(ctx context.Context, email string) error
GetActivityForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskActivity, error)
GetAllNotificationsForUserID(ctx context.Context, notifierID uuid.UUID) ([]Notification, error)
GetAllOrganizations(ctx context.Context) ([]Organization, error)
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
@ -65,6 +69,7 @@ type Querier interface {
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
GetAllVisibleProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error)
GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, 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)
GetEntityForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetEntityForNotificationIDRow, error)
@ -74,6 +79,7 @@ type Querier interface {
GetInvitedUserByEmail(ctx context.Context, email string) (UserAccountInvited, error)
GetLabelColorByID(ctx context.Context, labelColorID uuid.UUID) (LabelColor, error)
GetLabelColors(ctx context.Context) ([]LabelColor, error)
GetLastMoveForTaskID(ctx context.Context, taskID uuid.UUID) (GetLastMoveForTaskIDRow, error)
GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error)
GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
@ -113,6 +119,7 @@ type Querier interface {
GetTeamRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetTeamRolesForUserIDRow, error)
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
GetTeamsForUserIDWhereAdmin(ctx context.Context, userID uuid.UUID) ([]Team, error)
GetTemplateForActivityID(ctx context.Context, taskActivityTypeID int32) (string, error)
GetUserAccountByEmail(ctx context.Context, email string) (UserAccount, error)
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)
@ -120,6 +127,7 @@ type Querier interface {
HasActiveUser(ctx context.Context) (bool, error)
HasAnyUser(ctx context.Context) (bool, error)
SetFirstUserActive(ctx context.Context) (UserAccount, error)
SetInactiveLastMoveForTaskID(ctx context.Context, taskID uuid.UUID) error
SetTaskChecklistItemComplete(ctx context.Context, arg SetTaskChecklistItemCompleteParams) (TaskChecklistItem, error)
SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams) (Task, error)
SetTaskGroupName(ctx context.Context, arg SetTaskGroupNameParams) (TaskGroup, error)
@ -134,6 +142,7 @@ type Querier interface {
UpdateTaskChecklistItemName(ctx context.Context, arg UpdateTaskChecklistItemNameParams) (TaskChecklistItem, error)
UpdateTaskChecklistName(ctx context.Context, arg UpdateTaskChecklistNameParams) (TaskChecklist, error)
UpdateTaskChecklistPosition(ctx context.Context, arg UpdateTaskChecklistPositionParams) (TaskChecklist, error)
UpdateTaskComment(ctx context.Context, arg UpdateTaskCommentParams) (TaskComment, error)
UpdateTaskDescription(ctx context.Context, arg UpdateTaskDescriptionParams) (Task, error)
UpdateTaskDueDate(ctx context.Context, arg UpdateTaskDueDateParams) (Task, error)
UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error)

View File

@ -43,3 +43,16 @@ UPDATE task SET complete = $2, completed_at = $3 WHERE task_id = $1 RETURNING *;
SELECT project_id FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE task_id = $1;
-- name: CreateTaskComment :one
INSERT INTO task_comment (task_id, message, created_at, created_by)
VALUES ($1, $2, $3, $4) RETURNING *;
-- name: GetCommentsForTaskID :many
SELECT * FROM task_comment WHERE task_id = $1 ORDER BY created_at;
-- name: DeleteTaskCommentByID :one
DELETE FROM task_comment WHERE task_comment_id = $1 RETURNING *;
-- name: UpdateTaskComment :one
UPDATE task_comment SET message = $2, updated_at = $3 WHERE task_comment_id = $1 RETURNING *;

View File

@ -0,0 +1,22 @@
-- name: CreateTaskActivity :one
INSERT INTO task_activity (task_id, caused_by, created_at, activity_type_id, data)
VALUES ($1, $2, $3, $4, $5) RETURNING *;
-- name: GetActivityForTaskID :many
SELECT * FROM task_activity WHERE task_id = $1 AND active = true;
-- name: GetTemplateForActivityID :one
SELECT template FROM task_activity_type WHERE task_activity_type_id = $1;
-- name: GetLastMoveForTaskID :one
SELECT active, created_at, data->>'CurTaskGroupID' AS cur_task_group_id, data->>'PrevTaskGroupID' AS prev_task_group_id FROM task_activity
WHERE task_id = $1 AND activity_type_id = 2 AND created_at >= NOW() - INTERVAL '5 minutes'
ORDER BY created_at DESC LIMIT 1;
-- name: SetInactiveLastMoveForTaskID :exec
UPDATE task_activity SET active = false WHERE task_activity_id = (
SELECT task_activity_id FROM task_activity AS ta
WHERE ta.activity_type_id = 2 AND ta.task_id = $1
AND ta.created_at >= NOW() - INTERVAL '5 minutes'
ORDER BY created_at DESC LIMIT 1
);

View File

@ -85,6 +85,38 @@ func (q *Queries) CreateTaskAll(ctx context.Context, arg CreateTaskAllParams) (T
return i, err
}
const createTaskComment = `-- name: CreateTaskComment :one
INSERT INTO task_comment (task_id, message, created_at, created_by)
VALUES ($1, $2, $3, $4) RETURNING task_comment_id, task_id, created_at, updated_at, created_by, pinned, message
`
type CreateTaskCommentParams struct {
TaskID uuid.UUID `json:"task_id"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
CreatedBy uuid.UUID `json:"created_by"`
}
func (q *Queries) CreateTaskComment(ctx context.Context, arg CreateTaskCommentParams) (TaskComment, error) {
row := q.db.QueryRowContext(ctx, createTaskComment,
arg.TaskID,
arg.Message,
arg.CreatedAt,
arg.CreatedBy,
)
var i TaskComment
err := row.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
)
return i, err
}
const deleteTaskByID = `-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1
`
@ -94,6 +126,25 @@ func (q *Queries) DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error {
return err
}
const deleteTaskCommentByID = `-- name: DeleteTaskCommentByID :one
DELETE FROM task_comment WHERE task_comment_id = $1 RETURNING task_comment_id, task_id, created_at, updated_at, created_by, pinned, message
`
func (q *Queries) DeleteTaskCommentByID(ctx context.Context, taskCommentID uuid.UUID) (TaskComment, error) {
row := q.db.QueryRowContext(ctx, deleteTaskCommentByID, taskCommentID)
var i TaskComment
err := row.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
)
return i, err
}
const deleteTasksByTaskGroupID = `-- name: DeleteTasksByTaskGroupID :execrows
DELETE FROM task where task_group_id = $1
`
@ -143,6 +194,41 @@ func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
return items, nil
}
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
`
func (q *Queries) GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error) {
rows, err := q.db.QueryContext(ctx, getCommentsForTaskID, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskComment
for rows.Next() {
var i TaskComment
if err := rows.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getProjectIDForTask = `-- name: GetProjectIDForTask :one
SELECT project_id FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
@ -241,6 +327,31 @@ func (q *Queries) SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams
return i, err
}
const updateTaskComment = `-- name: UpdateTaskComment :one
UPDATE task_comment SET message = $2, updated_at = $3 WHERE task_comment_id = $1 RETURNING task_comment_id, task_id, created_at, updated_at, created_by, pinned, message
`
type UpdateTaskCommentParams struct {
TaskCommentID uuid.UUID `json:"task_comment_id"`
Message string `json:"message"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) UpdateTaskComment(ctx context.Context, arg UpdateTaskCommentParams) (TaskComment, error) {
row := q.db.QueryRowContext(ctx, updateTaskComment, arg.TaskCommentID, arg.Message, arg.UpdatedAt)
var i TaskComment
err := row.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
)
return i, err
}
const updateTaskDescription = `-- name: UpdateTaskDescription :one
UPDATE task SET description = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
`

View File

@ -0,0 +1,131 @@
// Code generated by sqlc. DO NOT EDIT.
// source: task_activity.sql
package db
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
const createTaskActivity = `-- name: CreateTaskActivity :one
INSERT INTO task_activity (task_id, caused_by, created_at, activity_type_id, data)
VALUES ($1, $2, $3, $4, $5) RETURNING task_activity_id, active, task_id, created_at, caused_by, activity_type_id, data
`
type CreateTaskActivityParams struct {
TaskID uuid.UUID `json:"task_id"`
CausedBy uuid.UUID `json:"caused_by"`
CreatedAt time.Time `json:"created_at"`
ActivityTypeID int32 `json:"activity_type_id"`
Data json.RawMessage `json:"data"`
}
func (q *Queries) CreateTaskActivity(ctx context.Context, arg CreateTaskActivityParams) (TaskActivity, error) {
row := q.db.QueryRowContext(ctx, createTaskActivity,
arg.TaskID,
arg.CausedBy,
arg.CreatedAt,
arg.ActivityTypeID,
arg.Data,
)
var i TaskActivity
err := row.Scan(
&i.TaskActivityID,
&i.Active,
&i.TaskID,
&i.CreatedAt,
&i.CausedBy,
&i.ActivityTypeID,
&i.Data,
)
return i, err
}
const getActivityForTaskID = `-- name: GetActivityForTaskID :many
SELECT task_activity_id, active, task_id, created_at, caused_by, activity_type_id, data FROM task_activity WHERE task_id = $1 AND active = true
`
func (q *Queries) GetActivityForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskActivity, error) {
rows, err := q.db.QueryContext(ctx, getActivityForTaskID, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskActivity
for rows.Next() {
var i TaskActivity
if err := rows.Scan(
&i.TaskActivityID,
&i.Active,
&i.TaskID,
&i.CreatedAt,
&i.CausedBy,
&i.ActivityTypeID,
&i.Data,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLastMoveForTaskID = `-- name: GetLastMoveForTaskID :one
SELECT active, created_at, data->>'CurTaskGroupID' AS cur_task_group_id, data->>'PrevTaskGroupID' AS prev_task_group_id FROM task_activity
WHERE task_id = $1 AND activity_type_id = 2 AND created_at >= NOW() - INTERVAL '5 minutes'
ORDER BY created_at DESC LIMIT 1
`
type GetLastMoveForTaskIDRow struct {
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
CurTaskGroupID interface{} `json:"cur_task_group_id"`
PrevTaskGroupID interface{} `json:"prev_task_group_id"`
}
func (q *Queries) GetLastMoveForTaskID(ctx context.Context, taskID uuid.UUID) (GetLastMoveForTaskIDRow, error) {
row := q.db.QueryRowContext(ctx, getLastMoveForTaskID, taskID)
var i GetLastMoveForTaskIDRow
err := row.Scan(
&i.Active,
&i.CreatedAt,
&i.CurTaskGroupID,
&i.PrevTaskGroupID,
)
return i, err
}
const getTemplateForActivityID = `-- name: GetTemplateForActivityID :one
SELECT template FROM task_activity_type WHERE task_activity_type_id = $1
`
func (q *Queries) GetTemplateForActivityID(ctx context.Context, taskActivityTypeID int32) (string, error) {
row := q.db.QueryRowContext(ctx, getTemplateForActivityID, taskActivityTypeID)
var template string
err := row.Scan(&template)
return template, err
}
const setInactiveLastMoveForTaskID = `-- name: SetInactiveLastMoveForTaskID :exec
UPDATE task_activity SET active = false WHERE task_activity_id = (
SELECT task_activity_id FROM task_activity AS ta
WHERE ta.activity_type_id = 2 AND ta.task_id = $1
AND ta.created_at >= NOW() - INTERVAL '5 minutes'
ORDER BY created_at DESC LIMIT 1
)
`
func (q *Queries) SetInactiveLastMoveForTaskID(ctx context.Context, taskID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, setInactiveLastMoveForTaskID, taskID)
return err
}

File diff suppressed because it is too large Load Diff

View File

@ -255,3 +255,28 @@ func GetActionType(actionType int32) ActionType {
panic("Not a valid entity type!")
}
}
type MemberType string
const (
MemberTypeInvited MemberType = "INVITED"
MemberTypeJoined MemberType = "JOINED"
)
type MasterEntry struct {
MemberType MemberType
ID uuid.UUID
}
const (
TASK_ADDED_TO_TASK_GROUP int32 = 1
TASK_MOVED_TO_TASK_GROUP int32 = 2
TASK_MARK_COMPLETE int32 = 3
TASK_MARK_INCOMPLETE int32 = 4
TASK_DUE_DATE_CHANGED int32 = 5
TASK_DUE_DATE_ADDED int32 = 6
TASK_DUE_DATE_REMOVED int32 = 7
TASK_CHECKLIST_CHANGED int32 = 8
TASK_CHECKLIST_ADDED int32 = 9
TASK_CHECKLIST_REMOVED int32 = 10
)

View File

@ -41,3 +41,7 @@ func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*
return &MemberList{Teams: teams, Projects: projects}, nil
}
type ActivityData struct {
Data map[string]string
}

View File

@ -22,6 +22,12 @@ type AssignTaskInput struct {
UserID uuid.UUID `json:"userID"`
}
type CausedBy struct {
ID uuid.UUID `json:"id"`
FullName string `json:"fullName"`
ProfileIcon *ProfileIcon `json:"profileIcon"`
}
type ChecklistBadge struct {
Complete int `json:"complete"`
Total int `json:"total"`
@ -39,6 +45,16 @@ type CreateTaskChecklistItem struct {
Position float64 `json:"position"`
}
type CreateTaskComment struct {
TaskID uuid.UUID `json:"taskID"`
Message string `json:"message"`
}
type CreateTaskCommentPayload struct {
TaskID uuid.UUID `json:"taskID"`
Comment *db.TaskComment `json:"comment"`
}
type CreateTeamMember struct {
UserID uuid.UUID `json:"userID"`
TeamID uuid.UUID `json:"teamID"`
@ -49,6 +65,12 @@ type CreateTeamMemberPayload struct {
TeamMember *Member `json:"teamMember"`
}
type CreatedBy struct {
ID uuid.UUID `json:"id"`
FullName string `json:"fullName"`
ProfileIcon *ProfileIcon `json:"profileIcon"`
}
type DeleteInvitedProjectMember struct {
ProjectID uuid.UUID `json:"projectID"`
Email string `json:"email"`
@ -108,6 +130,15 @@ type DeleteTaskChecklistPayload struct {
TaskChecklist *db.TaskChecklist `json:"taskChecklist"`
}
type DeleteTaskComment struct {
CommentID uuid.UUID `json:"commentID"`
}
type DeleteTaskCommentPayload struct {
TaskID uuid.UUID `json:"taskID"`
CommentID uuid.UUID `json:"commentID"`
}
type DeleteTaskGroupInput struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
}
@ -374,6 +405,11 @@ type SortTaskGroupPayload struct {
Tasks []db.Task `json:"tasks"`
}
type TaskActivityData struct {
Name string `json:"name"`
Value string `json:"value"`
}
type TaskBadges struct {
Checklist *ChecklistBadge `json:"checklist"`
}
@ -466,6 +502,16 @@ type UpdateTaskChecklistName struct {
Name string `json:"name"`
}
type UpdateTaskComment struct {
CommentID uuid.UUID `json:"commentID"`
Message string `json:"message"`
}
type UpdateTaskCommentPayload struct {
TaskID uuid.UUID `json:"taskID"`
Comment *db.TaskComment `json:"comment"`
}
type UpdateTaskDescriptionInput struct {
TaskID uuid.UUID `json:"taskID"`
Description string `json:"description"`
@ -615,6 +661,63 @@ func (e ActionType) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type ActivityType string
const (
ActivityTypeTaskAdded ActivityType = "TASK_ADDED"
ActivityTypeTaskMoved ActivityType = "TASK_MOVED"
ActivityTypeTaskMarkedComplete ActivityType = "TASK_MARKED_COMPLETE"
ActivityTypeTaskMarkedIncomplete ActivityType = "TASK_MARKED_INCOMPLETE"
ActivityTypeTaskDueDateChanged ActivityType = "TASK_DUE_DATE_CHANGED"
ActivityTypeTaskDueDateAdded ActivityType = "TASK_DUE_DATE_ADDED"
ActivityTypeTaskDueDateRemoved ActivityType = "TASK_DUE_DATE_REMOVED"
ActivityTypeTaskChecklistChanged ActivityType = "TASK_CHECKLIST_CHANGED"
ActivityTypeTaskChecklistAdded ActivityType = "TASK_CHECKLIST_ADDED"
ActivityTypeTaskChecklistRemoved ActivityType = "TASK_CHECKLIST_REMOVED"
)
var AllActivityType = []ActivityType{
ActivityTypeTaskAdded,
ActivityTypeTaskMoved,
ActivityTypeTaskMarkedComplete,
ActivityTypeTaskMarkedIncomplete,
ActivityTypeTaskDueDateChanged,
ActivityTypeTaskDueDateAdded,
ActivityTypeTaskDueDateRemoved,
ActivityTypeTaskChecklistChanged,
ActivityTypeTaskChecklistAdded,
ActivityTypeTaskChecklistRemoved,
}
func (e ActivityType) IsValid() bool {
switch e {
case ActivityTypeTaskAdded, ActivityTypeTaskMoved, ActivityTypeTaskMarkedComplete, ActivityTypeTaskMarkedIncomplete, ActivityTypeTaskDueDateChanged, ActivityTypeTaskDueDateAdded, ActivityTypeTaskDueDateRemoved, ActivityTypeTaskChecklistChanged, ActivityTypeTaskChecklistAdded, ActivityTypeTaskChecklistRemoved:
return true
}
return false
}
func (e ActivityType) String() string {
return string(e)
}
func (e *ActivityType) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ActivityType(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ActivityType", str)
}
return nil
}
func (e ActivityType) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type ActorType string
const (

View File

@ -135,6 +135,38 @@ type TaskBadges {
checklist: ChecklistBadge
}
type CausedBy {
id: ID!
fullName: String!
profileIcon: ProfileIcon
}
type TaskActivityData {
name: String!
value: String!
}
enum ActivityType {
TASK_ADDED
TASK_MOVED
TASK_MARKED_COMPLETE
TASK_MARKED_INCOMPLETE
TASK_DUE_DATE_CHANGED
TASK_DUE_DATE_ADDED
TASK_DUE_DATE_REMOVED
TASK_CHECKLIST_CHANGED
TASK_CHECKLIST_ADDED
TASK_CHECKLIST_REMOVED
}
type TaskActivity {
id: ID!
type: ActivityType!
data: [TaskActivityData!]!
causedBy: CausedBy!
createdAt: Time!
}
type Task {
id: ID!
taskGroup: TaskGroup!
@ -149,6 +181,23 @@ type Task {
labels: [TaskLabel!]!
checklists: [TaskChecklist!]!
badges: TaskBadges!
activity: [TaskActivity!]!
comments: [TaskComment!]!
}
type CreatedBy {
id: ID!
fullName: String!
profileIcon: ProfileIcon!
}
type TaskComment {
id: ID!
createdAt: Time!
updatedAt: Time
message: String!
createdBy: CreatedBy!
pinned: Boolean!
}
type Organization {
@ -582,6 +631,44 @@ type DeleteTaskChecklistPayload {
taskChecklist: TaskChecklist!
}
extend type Mutation {
createTaskComment(input: CreateTaskComment):
CreateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
deleteTaskComment(input: DeleteTaskComment):
DeleteTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
updateTaskComment(input: UpdateTaskComment):
UpdateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
}
input CreateTaskComment {
taskID: UUID!
message: String!
}
type CreateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input UpdateTaskComment {
commentID: UUID!
message: String!
}
type UpdateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input DeleteTaskComment {
commentID: UUID!
}
type DeleteTaskCommentPayload {
taskID: UUID!
commentID: UUID!
}
extend type Mutation {
createTaskGroup(input: NewTaskGroup!):
TaskGroup! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)
@ -856,4 +943,3 @@ type DeleteUserAccountPayload {
ok: Boolean!
userAccount: UserAccount!
}

View File

@ -7,7 +7,7 @@ import (
"context"
"crypto/tls"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
@ -17,12 +17,11 @@ import (
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger"
"github.com/lithammer/fuzzysearch/fuzzy"
gomail "gopkg.in/mail.v2"
hermes "github.com/matcornic/hermes/v2"
log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
"golang.org/x/crypto/bcrypt"
gomail "gopkg.in/mail.v2"
)
func (r *labelColorResolver) ID(ctx context.Context, obj *db.LabelColor) (uuid.UUID, error) {
@ -363,6 +362,28 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.T
createdAt := time.Now().UTC()
logger.New(ctx).WithFields(log.Fields{"positon": input.Position, "taskGroupID": input.TaskGroupID}).Info("creating task")
task, err := r.Repository.CreateTask(ctx, db.CreateTaskParams{input.TaskGroupID, createdAt, input.Name, input.Position})
if err != nil {
logger.New(ctx).WithError(err).Error("issue while creating task")
return &db.Task{}, err
}
taskGroup, err := r.Repository.GetTaskGroupByID(ctx, input.TaskGroupID)
if err != nil {
logger.New(ctx).WithError(err).Error("issue while creating task")
return &db.Task{}, err
}
data := map[string]string{
"TaskGroup": taskGroup.Name,
}
userID, _ := GetUserID(ctx)
d, err := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: task.TaskID,
Data: d,
CreatedAt: createdAt,
CausedBy: userID,
ActivityTypeID: 1,
})
if err != nil {
logger.New(ctx).WithError(err).Error("issue while creating task")
return &db.Task{}, err
@ -387,12 +408,44 @@ func (r *mutationResolver) UpdateTaskDescription(ctx context.Context, input Upda
}
func (r *mutationResolver) UpdateTaskLocation(ctx context.Context, input NewTaskLocation) (*UpdateTaskLocationPayload, error) {
userID, _ := GetUserID(ctx)
previousTask, err := r.Repository.GetTaskByID(ctx, input.TaskID)
if err != nil {
return &UpdateTaskLocationPayload{}, err
}
task, err := r.Repository.UpdateTaskLocation(ctx, db.UpdateTaskLocationParams{input.TaskID, input.TaskGroupID, input.Position})
task, _ := r.Repository.UpdateTaskLocation(ctx, db.UpdateTaskLocationParams{TaskID: input.TaskID, TaskGroupID: input.TaskGroupID, Position: input.Position})
if previousTask.TaskGroupID != input.TaskGroupID {
skipAndDelete := false
lastMove, err := r.Repository.GetLastMoveForTaskID(ctx, input.TaskID)
if err == nil {
if lastMove.Active && lastMove.PrevTaskGroupID == input.TaskGroupID.String() {
skipAndDelete = true
}
}
if skipAndDelete {
_ = r.Repository.SetInactiveLastMoveForTaskID(ctx, input.TaskID)
} else {
prevTaskGroup, _ := r.Repository.GetTaskGroupByID(ctx, previousTask.TaskGroupID)
curTaskGroup, _ := r.Repository.GetTaskGroupByID(ctx, input.TaskGroupID)
data := map[string]string{
"PrevTaskGroup": prevTaskGroup.Name,
"PrevTaskGroupID": prevTaskGroup.TaskGroupID.String(),
"CurTaskGroup": curTaskGroup.Name,
"CurTaskGroupID": curTaskGroup.TaskGroupID.String(),
}
createdAt := time.Now().UTC()
d, _ := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: task.TaskID,
Data: d,
CausedBy: userID,
CreatedAt: createdAt,
ActivityTypeID: 2,
})
}
}
return &UpdateTaskLocationPayload{Task: &task, PreviousTaskGroupID: previousTask.TaskGroupID}, err
}
@ -403,6 +456,21 @@ func (r *mutationResolver) UpdateTaskName(ctx context.Context, input UpdateTaskN
func (r *mutationResolver) SetTaskComplete(ctx context.Context, input SetTaskComplete) (*db.Task, error) {
completedAt := time.Now().UTC()
data := map[string]string{}
activityType := TASK_MARK_INCOMPLETE
if input.Complete {
activityType = TASK_MARK_COMPLETE
}
createdAt := time.Now().UTC()
userID, _ := GetUserID(ctx)
d, err := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: input.TaskID,
Data: d,
CausedBy: userID,
CreatedAt: createdAt,
ActivityTypeID: activityType,
})
task, err := r.Repository.SetTaskComplete(ctx, db.SetTaskCompleteParams{TaskID: input.TaskID, Complete: input.Complete, CompletedAt: sql.NullTime{Time: completedAt, Valid: true}})
if err != nil {
return &db.Task{}, err
@ -411,6 +479,23 @@ func (r *mutationResolver) SetTaskComplete(ctx context.Context, input SetTaskCom
}
func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTaskDueDate) (*db.Task, error) {
userID, _ := GetUserID(ctx)
prevTask, err := r.Repository.GetTaskByID(ctx, input.TaskID)
if err != nil {
return &db.Task{}, err
}
data := map[string]string{}
var activityType = TASK_DUE_DATE_ADDED
if input.DueDate == nil && prevTask.DueDate.Valid {
activityType = TASK_DUE_DATE_REMOVED
data["PrevDueDate"] = prevTask.DueDate.Time.String()
} else if prevTask.DueDate.Valid {
activityType = TASK_DUE_DATE_CHANGED
data["PrevDueDate"] = prevTask.DueDate.Time.String()
data["CurDueDate"] = input.DueDate.String()
} else {
data["DueDate"] = input.DueDate.String()
}
var dueDate sql.NullTime
if input.DueDate == nil {
dueDate = sql.NullTime{Valid: false, Time: time.Now()}
@ -421,6 +506,15 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
TaskID: input.TaskID,
DueDate: dueDate,
})
createdAt := time.Now().UTC()
d, err := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: task.TaskID,
Data: d,
CausedBy: userID,
CreatedAt: createdAt,
ActivityTypeID: activityType,
})
return &task, err
}
@ -562,6 +656,33 @@ func (r *mutationResolver) UpdateTaskChecklistItemLocation(ctx context.Context,
return &UpdateTaskChecklistItemLocationPayload{PrevChecklistID: currentChecklistItem.TaskChecklistID, TaskChecklistID: input.TaskChecklistID, ChecklistItem: &checklistItem}, err
}
func (r *mutationResolver) CreateTaskComment(ctx context.Context, input *CreateTaskComment) (*CreateTaskCommentPayload, error) {
userID, _ := GetUserID(ctx)
createdAt := time.Now().UTC()
comment, err := r.Repository.CreateTaskComment(ctx, db.CreateTaskCommentParams{
TaskID: input.TaskID,
CreatedAt: createdAt,
CreatedBy: userID,
Message: input.Message,
})
return &CreateTaskCommentPayload{Comment: &comment, TaskID: input.TaskID}, err
}
func (r *mutationResolver) DeleteTaskComment(ctx context.Context, input *DeleteTaskComment) (*DeleteTaskCommentPayload, error) {
task, err := r.Repository.DeleteTaskCommentByID(ctx, input.CommentID)
return &DeleteTaskCommentPayload{TaskID: task.TaskID, CommentID: input.CommentID}, err
}
func (r *mutationResolver) UpdateTaskComment(ctx context.Context, input *UpdateTaskComment) (*UpdateTaskCommentPayload, error) {
updatedAt := time.Now().UTC()
comment, err := r.Repository.UpdateTaskComment(ctx, db.UpdateTaskCommentParams{
TaskCommentID: input.CommentID,
UpdatedAt: sql.NullTime{Valid: true, Time: updatedAt},
Message: input.Message,
})
return &UpdateTaskCommentPayload{Comment: &comment}, err
}
func (r *mutationResolver) CreateTaskGroup(ctx context.Context, input NewTaskGroup) (*db.TaskGroup, error) {
createdAt := time.Now().UTC()
project, err := r.Repository.CreateTaskGroup(ctx,
@ -1558,6 +1679,80 @@ func (r *taskResolver) Badges(ctx context.Context, obj *db.Task) (*TaskBadges, e
return &TaskBadges{Checklist: &ChecklistBadge{Total: total, Complete: complete}}, nil
}
func (r *taskResolver) Activity(ctx context.Context, obj *db.Task) ([]db.TaskActivity, error) {
activity, err := r.Repository.GetActivityForTaskID(ctx, obj.TaskID)
if err == sql.ErrNoRows {
return []db.TaskActivity{}, nil
}
return activity, err
}
func (r *taskResolver) Comments(ctx context.Context, obj *db.Task) ([]db.TaskComment, error) {
comments, err := r.Repository.GetCommentsForTaskID(ctx, obj.TaskID)
if err == sql.ErrNoRows {
return []db.TaskComment{}, nil
}
return comments, err
}
func (r *taskActivityResolver) ID(ctx context.Context, obj *db.TaskActivity) (uuid.UUID, error) {
return obj.TaskActivityID, nil
}
func (r *taskActivityResolver) Type(ctx context.Context, obj *db.TaskActivity) (ActivityType, error) {
switch obj.ActivityTypeID {
case 1:
return ActivityTypeTaskAdded, nil
case 2:
return ActivityTypeTaskMoved, nil
case 3:
return ActivityTypeTaskMarkedComplete, nil
case 4:
return ActivityTypeTaskMarkedIncomplete, nil
case 5:
return ActivityTypeTaskDueDateChanged, nil
case 6:
return ActivityTypeTaskDueDateAdded, nil
case 7:
return ActivityTypeTaskDueDateRemoved, nil
case 8:
return ActivityTypeTaskChecklistChanged, nil
case 9:
return ActivityTypeTaskChecklistAdded, nil
case 10:
return ActivityTypeTaskChecklistRemoved, nil
default:
return ActivityTypeTaskAdded, errors.New("unknown type")
}
}
func (r *taskActivityResolver) Data(ctx context.Context, obj *db.TaskActivity) ([]TaskActivityData, error) {
var data map[string]string
_ = json.Unmarshal(obj.Data, &data)
activity := []TaskActivityData{}
for name, value := range data {
activity = append(activity, TaskActivityData{
Name: name,
Value: value,
})
}
return activity, nil
}
func (r *taskActivityResolver) CausedBy(ctx context.Context, obj *db.TaskActivity) (*CausedBy, error) {
user, err := r.Repository.GetUserAccountByID(ctx, obj.CausedBy)
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
return &CausedBy{
ID: obj.CausedBy,
FullName: user.FullName,
ProfileIcon: profileIcon,
}, err
}
func (r *taskChecklistResolver) ID(ctx context.Context, obj *db.TaskChecklist) (uuid.UUID, error) {
return obj.TaskChecklistID, nil
}
@ -1574,6 +1769,31 @@ func (r *taskChecklistItemResolver) DueDate(ctx context.Context, obj *db.TaskChe
panic(fmt.Errorf("not implemented"))
}
func (r *taskCommentResolver) ID(ctx context.Context, obj *db.TaskComment) (uuid.UUID, error) {
return obj.TaskCommentID, nil
}
func (r *taskCommentResolver) UpdatedAt(ctx context.Context, obj *db.TaskComment) (*time.Time, error) {
if obj.UpdatedAt.Valid {
return &obj.UpdatedAt.Time, nil
}
return nil, nil
}
func (r *taskCommentResolver) CreatedBy(ctx context.Context, obj *db.TaskComment) (*CreatedBy, error) {
user, err := r.Repository.GetUserAccountByID(ctx, obj.CreatedBy)
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
return &CreatedBy{
ID: obj.CreatedBy,
FullName: user.FullName,
ProfileIcon: profileIcon,
}, err
}
func (r *taskGroupResolver) ID(ctx context.Context, obj *db.TaskGroup) (uuid.UUID, error) {
return obj.TaskGroupID, nil
}
@ -1619,6 +1839,7 @@ func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, err
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
role, err := r.Repository.GetRoleForTeamMember(ctx, db.GetRoleForTeamMemberParams{UserID: user.UserID, TeamID: obj.TeamID})
if err != nil {
logger.New(ctx).WithError(err).Error("get role for projet member by user ID")
@ -1634,7 +1855,6 @@ func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, err
return members, err
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
members = append(members, Member{ID: user.UserID, FullName: user.FullName, ProfileIcon: profileIcon,
Username: user.Username, Owned: ownedList, Member: memberList, Role: &db.Role{Code: role.Code, Name: role.Name},
})
@ -1724,6 +1944,9 @@ func (r *Resolver) RefreshToken() RefreshTokenResolver { return &refreshTokenRes
// Task returns TaskResolver implementation.
func (r *Resolver) Task() TaskResolver { return &taskResolver{r} }
// TaskActivity returns TaskActivityResolver implementation.
func (r *Resolver) TaskActivity() TaskActivityResolver { return &taskActivityResolver{r} }
// TaskChecklist returns TaskChecklistResolver implementation.
func (r *Resolver) TaskChecklist() TaskChecklistResolver { return &taskChecklistResolver{r} }
@ -1732,6 +1955,9 @@ func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver {
return &taskChecklistItemResolver{r}
}
// TaskComment returns TaskCommentResolver implementation.
func (r *Resolver) TaskComment() TaskCommentResolver { return &taskCommentResolver{r} }
// TaskGroup returns TaskGroupResolver implementation.
func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} }
@ -1753,27 +1979,11 @@ type projectLabelResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type refreshTokenResolver struct{ *Resolver }
type taskResolver struct{ *Resolver }
type taskActivityResolver struct{ *Resolver }
type taskChecklistResolver struct{ *Resolver }
type taskChecklistItemResolver struct{ *Resolver }
type taskCommentResolver struct{ *Resolver }
type taskGroupResolver struct{ *Resolver }
type taskLabelResolver struct{ *Resolver }
type teamResolver struct{ *Resolver }
type userAccountResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
type MemberType string
const (
MemberTypeInvited MemberType = "INVITED"
MemberTypeJoined MemberType = "JOINED"
)
type MasterEntry struct {
MemberType MemberType
ID uuid.UUID
}

View File

@ -135,6 +135,38 @@ type TaskBadges {
checklist: ChecklistBadge
}
type CausedBy {
id: ID!
fullName: String!
profileIcon: ProfileIcon
}
type TaskActivityData {
name: String!
value: String!
}
enum ActivityType {
TASK_ADDED
TASK_MOVED
TASK_MARKED_COMPLETE
TASK_MARKED_INCOMPLETE
TASK_DUE_DATE_CHANGED
TASK_DUE_DATE_ADDED
TASK_DUE_DATE_REMOVED
TASK_CHECKLIST_CHANGED
TASK_CHECKLIST_ADDED
TASK_CHECKLIST_REMOVED
}
type TaskActivity {
id: ID!
type: ActivityType!
data: [TaskActivityData!]!
causedBy: CausedBy!
createdAt: Time!
}
type Task {
id: ID!
taskGroup: TaskGroup!
@ -149,6 +181,23 @@ type Task {
labels: [TaskLabel!]!
checklists: [TaskChecklist!]!
badges: TaskBadges!
activity: [TaskActivity!]!
comments: [TaskComment!]!
}
type CreatedBy {
id: ID!
fullName: String!
profileIcon: ProfileIcon!
}
type TaskComment {
id: ID!
createdAt: Time!
updatedAt: Time
message: String!
createdBy: CreatedBy!
pinned: Boolean!
}
type Organization {

View File

@ -0,0 +1,37 @@
extend type Mutation {
createTaskComment(input: CreateTaskComment):
CreateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
deleteTaskComment(input: DeleteTaskComment):
DeleteTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
updateTaskComment(input: UpdateTaskComment):
UpdateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
}
input CreateTaskComment {
taskID: UUID!
message: String!
}
type CreateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input UpdateTaskComment {
commentID: UUID!
message: String!
}
type UpdateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input DeleteTaskComment {
commentID: UUID!
}
type DeleteTaskCommentPayload {
taskID: UUID!
commentID: UUID!
}

View File

@ -0,0 +1,27 @@
CREATE TABLE task_activity_type (
task_activity_type_id int PRIMARY KEY,
code text NOT NULL,
template text NOT NULL
);
INSERT INTO task_activity_type (task_activity_type_id, code, template) VALUES
(1, 'task_added_to_task_group', 'added this task to {{ index .Data "TaskGroup" }}'),
(2, 'task_moved_to_task_group', 'moved this task from {{ index .Data "PrevTaskGroup" }} to {{ index .Data "CurTaskGroup"}}'),
(3, 'task_mark_complete', 'marked this task complete'),
(4, 'task_mark_incomplete', 'marked this task incomplete'),
(5, 'task_due_date_changed', 'changed the due date to {{ index .Data "DueDate" }}'),
(6, 'task_due_date_added', 'moved this task from {{ index .Data "PrevTaskGroup" }} to {{ index .Data "CurTaskGroup"}}'),
(7, 'task_due_date_removed', 'moved this task from {{ index .Data "PrevTaskGroup" }} to {{ index .Data "CurTaskGroup"}}'),
(8, 'task_checklist_changed', 'moved this task from {{ index .Data "PrevTaskGroup" }} to {{ index .Data "CurTaskGroup"}}'),
(9, 'task_checklist_added', 'moved this task from {{ index .Data "PrevTaskGroup" }} to {{ index .Data "CurTaskGroup"}}'),
(10, 'task_checklist_removed', 'moved this task from {{ index .Data "PrevTaskGroup" }} to {{ index .Data "CurTaskGroup"}}');
CREATE TABLE task_activity (
task_activity_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
active boolean NOT NULL DEFAULT true,
task_id uuid NOT NULL REFERENCES task(task_id),
created_at timestamptz NOT NULL,
caused_by uuid NOT NULL,
activity_type_id int NOT NULL REFERENCES task_activity_type(task_activity_type_id),
data jsonb
);

View File

@ -0,0 +1,9 @@
CREATE TABLE task_comment (
task_comment_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id uuid NOT NULL REFERENCES task(task_id),
created_at timestamptz NOT NULL,
updated_at timestamptz,
created_by uuid NOT NULL REFERENCES user_account(user_id),
pinned boolean NOT NULL DEFAULT false,
message TEXT NOT NULL
);