11 Commits

54 changed files with 2563 additions and 329 deletions

View File

@ -3,11 +3,10 @@ repos:
hooks:
- id: eslint
name: eslint
entry: go run cmd/mage/main.go frontend:lint
entry: scripts/lint.sh
language: system
files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx
types: [file]
pass_filenames: false
- hooks:
- id: check-yaml
- id: end-of-file-fixer

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Task sorting & filtering
- Redesigned the Task Details UI
- Implement task group actions (duplicate/delete all tasks/sort)
### Fixed
- removed CORS middleware to fix security issue

View File

@ -21,6 +21,8 @@ Was this project useful? Please consider <a href="https://www.buymeacoffee.com/j
**Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server**
![Taskcafe](./.github/taskcafe_preview.png)
## Features
Currently Taskcafe only offers basic task tracking through a Kanban board.

View File

@ -171,7 +171,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const AdminRoute = () => {
useEffect(() => {
document.title = 'Taskcafé | Admin';
document.title = 'Admin | Taskcafé';
}, []);
const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup();

View File

@ -3,11 +3,20 @@ import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings';
import { useMeQuery, useClearProfileAvatarMutation, useUpdateUserPasswordMutation } from 'shared/generated/graphql';
import {
useMeQuery,
useClearProfileAvatarMutation,
useUpdateUserPasswordMutation,
useUpdateUserInfoMutation,
MeQuery,
MeDocument,
} from 'shared/generated/graphql';
import axios from 'axios';
import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop';
import { toast } from 'react-toastify';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
const MainContent = styled.div`
padding: 0 0 50px 80px;
@ -19,6 +28,7 @@ const Projects = () => {
const $fileUpload = useRef<HTMLInputElement>(null);
const [clearProfileAvatar] = useClearProfileAvatarMutation();
const { user } = useCurrentUser();
const [updateUserInfo] = useUpdateUserInfoMutation();
const [updateUserPassword] = useUpdateUserPasswordMutation();
const { loading, data, refetch } = useMeQuery();
useEffect(() => {
@ -69,6 +79,13 @@ const Projects = () => {
toast('Password was changed!');
done();
}}
onChangeUserInfo={(d, done) => {
updateUserInfo({
variables: { name: d.full_name, bio: d.bio, email: d.email, initials: d.initials },
});
toast('User info was saved!');
done();
}}
onProfileAvatarRemove={() => {
clearProfileAvatar();
}}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/components/Lists';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
export const ActionsList = styled.ul`
margin: 0;

View File

@ -8,6 +8,7 @@ import {
useSetTaskCompleteMutation,
useToggleTaskLabelMutation,
useFindProjectQuery,
useSortTaskGroupMutation,
useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation,
useCreateTaskMutation,
@ -21,6 +22,10 @@ import {
useUnassignTaskMutation,
useUpdateTaskDueDateMutation,
FindProjectQuery,
useDuplicateTaskGroupMutation,
DuplicateTaskGroupMutation,
DuplicateTaskGroupDocument,
useDeleteTaskGroupTasksMutation,
} from 'shared/generated/graphql';
import QuickCardEditor from 'shared/components/QuickCardEditor';
@ -33,10 +38,8 @@ import SimpleLists, {
TaskMeta,
TaskMetaMatch,
TaskMetaFilters,
TaskSorting,
TaskSortingType,
TaskSortingDirection,
} from 'shared/components/Lists';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager';
@ -44,6 +47,7 @@ import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop';
import LabelManagerEditor from 'Projects/Project/LabelManagerEditor';
import Chip from 'shared/components/Chip';
import { toast } from 'react-toastify';
import { useCurrentUser } from 'App/context';
import FilterStatus from './FilterStatus';
import FilterMeta from './FilterMeta';
@ -263,6 +267,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters);
const [taskSorting, setTaskSorting] = useState(initTaskSorting);
const history = useHistory();
const [sortTaskGroup] = useSortTaskGroupMutation({
onCompleted: () => {
toast('List was sorted');
},
});
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
update: (client, deletedTaskGroupData) => {
updateApolloCache<FindProjectQuery>(
@ -314,6 +323,37 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
const { loading, data } = useFindProjectQuery({
variables: { projectID },
pollInterval: 5000,
});
const [deleteTaskGroupTasks] = useDeleteTaskGroupTasksMutation({
update: (client, resp) =>
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data.deleteTaskGroupTasks.taskGroupID,
);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = [];
}
}),
{ projectID },
),
});
const [duplicateTaskGroup] = useDuplicateTaskGroupMutation({
update: (client, resp) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}),
{ projectID },
);
},
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
@ -624,15 +664,44 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
showPopup(
$targetRef,
<Popup title="List actions" tab={0} onClose={() => hidePopup()}>
<ListActions
taskGroupID={taskGroupID}
onArchiveTaskGroup={tgID => {
deleteTaskGroup({ variables: { taskGroupID: tgID } });
<ListActions
taskGroupID={taskGroupID}
onDeleteTaskGroupTasks={() => {
deleteTaskGroupTasks({ variables: { taskGroupID } });
hidePopup();
}}
onSortTaskGroup={taskSort => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
if (taskGroup) {
const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks
.sort((a, b) => sortTasks(a, b, taskSort))
.reduce((prevTasks: Array<{ taskID: string; position: number }>, t, idx) => {
prevTasks.push({ taskID: t.id, position: (idx + 1) * 2048 });
return tasks;
}, []);
sortTaskGroup({ variables: { taskGroupID, tasks } });
hidePopup();
}}
/>
</Popup>,
}
}}
onDuplicateTaskGroup={newName => {
const idx = data.findProject.taskGroups.findIndex(t => t.id === taskGroupID);
if (idx !== -1) {
const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position);
const prevPos = taskGroups[idx].position;
const next = taskGroups[idx + 1];
let newPos = prevPos * 2;
if (next) {
newPos = (prevPos + next.position) / 2.0;
}
duplicateTaskGroup({ variables: { projectID, taskGroupID, name: newName, position: newPos } });
hidePopup();
}
}}
onArchiveTaskGroup={tgID => {
deleteTaskGroup({ variables: { taskGroupID: tgID } });
hidePopup();
}}
/>,
);
}}
/>

View File

@ -3,7 +3,7 @@ import Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router';
import { useRouteMatch, useHistory, Redirect } from 'react-router';
import {
useDeleteTaskChecklistMutation,
useUpdateTaskChecklistNameMutation,
@ -32,6 +32,7 @@ import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop';
import hasNotFoundError from 'shared/utils/error';
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => {
@ -269,8 +270,8 @@ const Details: React.FC<DetailsProps> = ({
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
const [setTaskComplete] = useSetTaskCompleteMutation();
const { loading, data, refetch, error } = useFindTaskQuery({ variables: { taskID }, pollInterval: 5000 });
const [setTaskComplete, { error: setTaskCompleteError }] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => {
refetch();
@ -289,9 +290,13 @@ const Details: React.FC<DetailsProps> = ({
refreshCache();
},
});
if (loading) {
return null;
if (hasNotFoundError(error, setTaskCompleteError)) {
return <Redirect to={projectURL} />;
}
if (setTaskCompleteError && setTaskCompleteError)
if (loading) {
return null;
}
if (!data) {
return null;
}
@ -346,7 +351,11 @@ const Details: React.FC<DetailsProps> = ({
onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={task => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }).catch(r => {
if (hasNotFoundError(r)) {
history.push(projectURL);
}
});
}}
onDeleteTask={onDeleteTask}
onChangeItemName={(itemID, itemName) => {

View File

@ -234,7 +234,7 @@ type ShowNewProject = {
const Projects = () => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only', pollInterval: 5000 });
useEffect(() => {
document.title = 'Taskcafé';
}, []);
@ -260,11 +260,7 @@ const Projects = () => {
},
});
if (loading) {
return (
<>
<span>loading</span>
</>
);
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
}
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];

View File

@ -154,7 +154,7 @@ type TeamProjectsProps = {
};
const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { loading, data } = useGetTeamQuery({ variables: { teamID }, pollInterval: 5000 });
if (loading) {
return <span>loading</span>;
}

View File

@ -85,18 +85,30 @@ type TeamsRouteProps = {
const Teams = () => {
const { teamID } = useParams<TeamsRouteProps>();
const history = useHistory();
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { loading, data } = useGetTeamQuery({
variables: { teamID },
onCompleted: resp => {
document.title = `${resp.findTeam.name} | Taskcafé`;
},
});
const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch();
useEffect(() => {
document.title = 'Teams | Taskcafé';
}, []);
if (loading) {
return (
<>
<span>loading</span>
</>
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
);
}
if (data && user) {

View File

@ -16,11 +16,12 @@ import {
} from './Styles';
type NameEditorProps = {
buttonLabel?: string;
onSave: (listName: string) => void;
onCancel: () => void;
};
const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCancel, buttonLabel = 'Save' }) => {
const $editorRef = useRef<HTMLTextAreaElement>(null);
const [listName, setListName] = useState('');
useEffect(() => {
@ -28,6 +29,11 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
$editorRef.current.focus();
}
});
const onSave = (newName: string) => {
if (newName.replace(/\s+/g, '') !== '') {
handleSave(newName);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
@ -60,7 +66,7 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
}
}}
>
Save
{buttonLabel}
</AddListButton>
<CancelAdd onClick={() => onCancel()}>
<Cross width={16} height={16} />

View File

@ -557,6 +557,7 @@ const Admin: React.FC<AdminProps> = ({
<TabNavContent>
{items.map((item, idx) => (
<NavItem
key={item.name}
onClick={(tab, top) => {
if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect();

View File

@ -147,6 +147,11 @@ export const ListCardLabelText = styled.span`
line-height: 16px;
`;
export const ListCardLabelsWrapper = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
${props =>
props.variant === 'small'
@ -178,8 +183,6 @@ export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
`;
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
overflow: auto;
position: relative;
&:hover {
opacity: 0.8;
}

View File

@ -20,6 +20,7 @@ import {
ListCardLabels,
ListCardLabel,
ListCardLabelText,
ListCardLabelsWrapper,
ListCardOperation,
CardTitle,
CardMembers,
@ -158,35 +159,38 @@ const Card = React.forwardRef(
</ListCardOperation>
)}
<ListCardDetails complete={complete ?? false}>
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
onClick={e => {
e.stopPropagation();
if (onCardLabelClick) {
onCardLabelClick();
}
}}
>
{labels &&
labels
.slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => (
<ListCardLabel
onAnimationEnd={() => {
if (setToggleLabels) {
setToggleLabels(false);
}
}}
variant={labelVariant ?? 'large'}
color={label.labelColor.colorHex}
key={label.id}
>
<ListCardLabelText>{label.name}</ListCardLabelText>
</ListCardLabel>
))}
</ListCardLabels>
{labels && labels.length !== 0 && (
<ListCardLabelsWrapper>
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
onClick={e => {
e.stopPropagation();
if (onCardLabelClick) {
onCardLabelClick();
}
}}
>
{labels
.slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => (
<ListCardLabel
onAnimationEnd={() => {
if (setToggleLabels) {
setToggleLabels(false);
}
}}
variant={labelVariant ?? 'large'}
color={label.labelColor.colorHex}
key={label.id}
>
<ListCardLabelText>{label.name}</ListCardLabelText>
</ListCardLabel>
))}
</ListCardLabels>
</ListCardLabelsWrapper>
)}
{editable ? (
<EditorContent>
{complete && <CompleteIcon width={16} height={16} />}

View File

@ -78,6 +78,7 @@ const Icon = styled.div`
type InputProps = {
variant?: 'normal' | 'alternate';
disabled?: boolean;
label?: string;
width?: string;
floatingLabel?: boolean;
@ -116,6 +117,7 @@ function useCombinedRefs(...refs: any) {
const Input = React.forwardRef(
(
{
disabled = false,
width = 'auto',
variant = 'normal',
type = 'text',
@ -160,6 +162,7 @@ const Input = React.forwardRef(
onChange={e => {
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
}}
disabled={disabled}
hasValue={hasValue}
ref={combinedRef}
id={id}

View File

@ -1,18 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import ListActions from '.';
export default {
component: ListActions,
title: 'ListActions',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
export const Default = () => {
return <ListActions taskGroupID="1" onArchiveTaskGroup={action('on archive task group')} />;
};

View File

@ -1,50 +1,100 @@
import React from 'react';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { NameEditor } from 'shared/components/AddList';
import NOOP from 'shared/utils/noop';
import styled from 'styled-components';
import { TaskSorting, TaskSortingDirection, TaskSortingType } from 'shared/utils/sorting';
import { InnerContent, ListActionsWrapper, ListActionItemWrapper, ListActionItem, ListSeparator } from './Styles';
const CopyWrapper = styled.div`
margin: 0 12px;
`;
type Props = {
taskGroupID: string;
onDuplicateTaskGroup: (newTaskGroupName: string) => void;
onDeleteTaskGroupTasks: () => void;
onArchiveTaskGroup: (taskGroupID: string) => void;
onSortTaskGroup: (taskSorting: TaskSorting) => void;
};
const LabelManager: React.FC<Props> = ({ taskGroupID, onArchiveTaskGroup }) => {
const LabelManager: React.FC<Props> = ({
taskGroupID,
onDeleteTaskGroupTasks,
onDuplicateTaskGroup,
onArchiveTaskGroup,
onSortTaskGroup,
}) => {
const { setTab } = usePopup();
return (
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper>
<ListActionItem>Add card...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Copy List...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Move card...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Watch</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper>
<ListActionItem>Sort By...</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper>
<ListActionItem>Move All Cards in This List...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Archive All Cards in This List...</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
<ListActionItem>Archive This List</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</InnerContent>
<>
<Popup tab={0} title={null}>
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => setTab(1)}>
<ListActionItem>Duplicate</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper onClick={() => setTab(2)}>
<ListActionItem>Sort</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onDeleteTaskGroupTasks()}>
<ListActionItem>Delete All Tasks</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
<ListActionItem>Delete</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</InnerContent>
</Popup>
<Popup tab={1} title="Copy list" onClose={NOOP}>
<CopyWrapper>
<NameEditor
onCancel={NOOP}
onSave={listName => {
onDuplicateTaskGroup(listName);
}}
buttonLabel="Duplicate"
/>
</CopyWrapper>
</Popup>
<Popup tab={2} title="Sort list" onClose={NOOP}>
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Task title</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Due date</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Complete</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Labels</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Members</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</InnerContent>
</Popup>
</>
);
};
export default LabelManager;

View File

@ -11,6 +11,7 @@ import {
getAfterDropDraggableList,
} from 'shared/utils/draggables';
import moment from 'moment';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import { Container, BoardContainer, BoardWrapper } from './Styles';
import shouldMetaFilter from './metaFilter';
@ -94,127 +95,6 @@ export type TaskMetaFilters = {
labels: Array<LabelMetaFilter>;
};
export enum TaskSortingType {
NONE,
DUE_DATE,
MEMBERS,
LABELS,
TASK_TITLE,
}
export enum TaskSortingDirection {
ASC,
DESC,
}
export type TaskSorting = {
type: TaskSortingType;
direction: TaskSortingDirection;
};
function sortString(a: string, b: string) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
function sortTasks(a: Task, b: Task, taskSorting: TaskSorting) {
if (taskSorting.type === TaskSortingType.TASK_TITLE) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.DUE_DATE) {
if (a.dueDate && !b.dueDate) {
return -1;
}
if (b.dueDate && !a.dueDate) {
return 1;
}
return moment(a.dueDate).diff(moment(b.dueDate));
}
if (taskSorting.type === TaskSortingType.LABELS) {
// sorts non-empty labels by name, then by empty label color name
let aLabels = [];
let bLabels = [];
let aLabelsEmpty = [];
let bLabelsEmpty = [];
if (a.labels) {
for (const aLabel of a.labels) {
if (aLabel.projectLabel.name && aLabel.projectLabel.name !== '') {
aLabels.push(aLabel.projectLabel.name);
} else {
aLabelsEmpty.push(aLabel.projectLabel.labelColor.name);
}
}
}
if (b.labels) {
for (const bLabel of b.labels) {
if (bLabel.projectLabel.name && bLabel.projectLabel.name !== '') {
bLabels.push(bLabel.projectLabel.name);
} else {
bLabelsEmpty.push(bLabel.projectLabel.labelColor.name);
}
}
}
aLabels = aLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabels = bLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
aLabelsEmpty = aLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabelsEmpty = bLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
if (aLabelsEmpty.length !== 0 || bLabelsEmpty.length !== 0) {
if (aLabelsEmpty.length > bLabelsEmpty.length) {
if (bLabels.length !== 0) {
return 1;
}
return -1;
}
}
if (aLabels.length < bLabels.length) {
return 1;
}
if (aLabels.length > bLabels.length) {
return -1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.MEMBERS) {
let aMembers = [];
let bMembers = [];
if (a.assigned) {
for (const aMember of a.assigned) {
if (aMember.fullName) {
aMembers.push(aMember.fullName);
}
}
}
if (b.assigned) {
for (const bMember of b.assigned) {
if (bMember.fullName) {
bMembers.push(bMember.fullName);
}
}
}
aMembers = aMembers.sort((aMember, bMember) => sortString(aMember, bMember));
bMembers = bMembers.sort((aMember, bMember) => sortString(aMember, bMember));
if (aMembers.length < bMembers.length) {
return 1;
}
if (aMembers.length > bMembers.length) {
return -1;
}
return 0;
}
return 0;
}
function shouldStatusFilter(task: Task, filter: TaskStatusFilter) {
if (filter.status === TaskStatus.ALL) {
return true;

View File

@ -27,6 +27,7 @@ export const Default = () => {
<BaseStyles />
<Settings
profile={profile}
onChangeUserInfo={action('change user info')}
onResetPassword={action('reset password')}
onProfileAvatarRemove={action('remove')}
onProfileAvatarChange={action('profile avatar change')}

View File

@ -10,6 +10,11 @@ const PasswordInput = styled(Input)`
margin-bottom: 0;
`;
const UserInfoInput = styled(Input)`
margin-top: 30px;
margin-bottom: 0;
`;
const FormError = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.warning});
@ -240,6 +245,7 @@ const SaveButton = styled(Button)`
type SettingsProps = {
onProfileAvatarChange: () => void;
onProfileAvatarRemove: () => void;
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
onResetPassword: (password: string, done: () => void) => void;
profile: TaskUser;
};
@ -300,9 +306,93 @@ const ResetPasswordTab: React.FC<ResetPasswordTabProps> = ({ onResetPassword })
);
};
type UserInfoData = {
full_name: string;
bio: string;
initials: string;
email: string;
};
type UserInfoTabProps = {
profile: TaskUser;
onProfileAvatarChange: () => void;
onProfileAvatarRemove: () => void;
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
};
const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
const INITIALS_PATTERN = /^[a-zA-Z]{2,3}$/i;
const UserInfoTab: React.FC<UserInfoTabProps> = ({
profile,
onProfileAvatarRemove,
onProfileAvatarChange,
onChangeUserInfo,
}) => {
const [active, setActive] = useState(true);
const { register, handleSubmit, errors } = useForm<UserInfoData>();
const done = () => {
setActive(true);
};
return (
<>
<AvatarSettings
onProfileAvatarRemove={onProfileAvatarRemove}
onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon}
/>
<form
onSubmit={handleSubmit(data => {
setActive(false);
onChangeUserInfo(data, done);
})}
>
<UserInfoInput
ref={register({ required: 'Full name is required' })}
name="full_name"
defaultValue={profile.fullName}
width="100%"
label="Name"
/>
{errors.full_name && <FormError>{errors.full_name.message}</FormError>}
<UserInfoInput
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
ref={register({
required: 'Initials is required',
pattern: { value: INITIALS_PATTERN, message: 'Intials must be between two to four characters' },
})}
name="initials"
width="100%"
label="Initials "
/>
{errors.initials && <FormError>{errors.initials.message}</FormError>}
<UserInfoInput disabled defaultValue={profile.username ?? ''} width="100%" label="Username " />
<UserInfoInput
width="100%"
name="email"
ref={register({
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
defaultValue={profile.email ?? ''}
label="Email"
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
<UserInfoInput width="100%" name="bio" ref={register()} defaultValue={profile.bio ?? ''} label="Bio" />
{errors.bio && <FormError>{errors.bio.message}</FormError>}
<SettingActions>
<SaveButton disabled={!active} type="submit">
Save Change
</SaveButton>
</SettingActions>
</form>
</>
);
};
const Settings: React.FC<SettingsProps> = ({
onProfileAvatarRemove,
onProfileAvatarChange,
onChangeUserInfo,
onResetPassword,
profile,
}) => {
@ -315,6 +405,7 @@ const Settings: React.FC<SettingsProps> = ({
<TabNavContent>
{items.map((item, idx) => (
<NavItem
key={item.name}
onClick={(tab, top) => {
if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect();
@ -332,23 +423,12 @@ const Settings: React.FC<SettingsProps> = ({
</TabNav>
<TabContentWrapper>
<Tab tab={0} currentTab={currentTab}>
<AvatarSettings
onProfileAvatarRemove={onProfileAvatarRemove}
<UserInfoTab
onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon}
onProfileAvatarRemove={onProfileAvatarRemove}
profile={profile}
onChangeUserInfo={onChangeUserInfo}
/>
<Input defaultValue={profile.fullName} width="100%" label="Name" />
<Input
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
width="100%"
label="Initials "
/>
<Input defaultValue={profile.username ?? ''} width="100%" label="Username " />
<Input width="100%" label="Email" />
<Input width="100%" label="Bio" />
<SettingActions>
<SaveButton>Save Change</SaveButton>
</SettingActions>
</Tab>
<Tab tab={1} currentTab={currentTab}>
<ResetPasswordTab onResetPassword={onResetPassword} />

View File

@ -169,7 +169,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<LeftSidebarSection>
<SidebarTitle>TASK GROUP</SidebarTitle>
<SidebarButton>
<SidebarButtonText>Release 0.1.0</SidebarButtonText>
<SidebarButtonText>{task.taskGroup.name}</SidebarButtonText>
</SidebarButton>
<DueDateTitle>DUE DATE</DueDateTitle>
<SidebarButton

View File

@ -251,8 +251,8 @@ export const NavSeparator = styled.div`
export const LogoContainer = styled(Link)`
display: block;
left: 50%;
right: 50%;
position: absolute;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;

View File

@ -105,6 +105,7 @@ export type UserAccount = {
createdAt: Scalars['Time'];
fullName: Scalars['String'];
initials: Scalars['String'];
bio: Scalars['String'];
role: Role;
username: Scalars['String'];
profileIcon: ProfileIcon;
@ -208,7 +209,8 @@ export enum ObjectType {
Org = 'ORG',
Team = 'TEAM',
Project = 'PROJECT',
Task = 'TASK'
Task = 'TASK',
TaskGroup = 'TASK_GROUP'
}
export type Query = {
@ -275,13 +277,16 @@ export type Mutation = {
deleteTaskChecklist: DeleteTaskChecklistPayload;
deleteTaskChecklistItem: DeleteTaskChecklistItemPayload;
deleteTaskGroup: DeleteTaskGroupPayload;
deleteTaskGroupTasks: DeleteTaskGroupTasksPayload;
deleteTeam: DeleteTeamPayload;
deleteTeamMember: DeleteTeamMemberPayload;
deleteUserAccount: DeleteUserAccountPayload;
duplicateTaskGroup: DuplicateTaskGroupPayload;
logoutUser: Scalars['Boolean'];
removeTaskLabel: Task;
setTaskChecklistItemComplete: TaskChecklistItem;
setTaskComplete: Task;
sortTaskGroup: SortTaskGroupPayload;
toggleTaskLabel: ToggleTaskLabelPayload;
unassignTask: Task;
updateProjectLabel: ProjectLabel;
@ -300,6 +305,7 @@ export type Mutation = {
updateTaskLocation: UpdateTaskLocationPayload;
updateTaskName: Task;
updateTeamMemberRole: UpdateTeamMemberRolePayload;
updateUserInfo: UpdateUserInfoPayload;
updateUserPassword: UpdateUserPasswordPayload;
updateUserRole: UpdateUserRolePayload;
};
@ -405,6 +411,11 @@ export type MutationDeleteTaskGroupArgs = {
};
export type MutationDeleteTaskGroupTasksArgs = {
input: DeleteTaskGroupTasks;
};
export type MutationDeleteTeamArgs = {
input: DeleteTeam;
};
@ -420,6 +431,11 @@ export type MutationDeleteUserAccountArgs = {
};
export type MutationDuplicateTaskGroupArgs = {
input: DuplicateTaskGroup;
};
export type MutationLogoutUserArgs = {
input: LogoutUser;
};
@ -440,6 +456,11 @@ export type MutationSetTaskCompleteArgs = {
};
export type MutationSortTaskGroupArgs = {
input: SortTaskGroup;
};
export type MutationToggleTaskLabelArgs = {
input: ToggleTaskLabelInput;
};
@ -530,6 +551,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
};
export type MutationUpdateUserInfoArgs = {
input: UpdateUserInfo;
};
export type MutationUpdateUserPasswordArgs = {
input: UpdateUserPassword;
};
@ -697,7 +723,7 @@ export type UpdateProjectMemberRolePayload = {
};
export type NewTask = {
taskGroupID: Scalars['String'];
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
};
@ -823,6 +849,44 @@ export type DeleteTaskChecklistPayload = {
taskChecklist: TaskChecklist;
};
export type DeleteTaskGroupTasks = {
taskGroupID: Scalars['UUID'];
};
export type DeleteTaskGroupTasksPayload = {
__typename?: 'DeleteTaskGroupTasksPayload';
taskGroupID: Scalars['UUID'];
tasks: Array<Scalars['UUID']>;
};
export type TaskPositionUpdate = {
taskID: Scalars['UUID'];
position: Scalars['Float'];
};
export type SortTaskGroupPayload = {
__typename?: 'SortTaskGroupPayload';
taskGroupID: Scalars['UUID'];
tasks: Array<Task>;
};
export type SortTaskGroup = {
taskGroupID: Scalars['UUID'];
tasks: Array<TaskPositionUpdate>;
};
export type DuplicateTaskGroup = {
projectID: Scalars['UUID'];
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
};
export type DuplicateTaskGroupPayload = {
__typename?: 'DuplicateTaskGroupPayload';
taskGroup: TaskGroup;
};
export type NewTaskGroupLocation = {
taskGroupID: Scalars['UUID'];
position: Scalars['Float'];
@ -923,6 +987,18 @@ export type UpdateTeamMemberRolePayload = {
member: Member;
};
export type UpdateUserInfoPayload = {
__typename?: 'UpdateUserInfoPayload';
user: UserAccount;
};
export type UpdateUserInfo = {
name: Scalars['String'];
initials: Scalars['String'];
email: Scalars['String'];
bio: Scalars['String'];
};
export type UpdateUserPassword = {
userID: Scalars['UUID'];
password: Scalars['String'];
@ -1189,7 +1265,7 @@ export type FindTaskQuery = (
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete'>
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id'>
& Pick<TaskGroup, 'id' | 'name'>
), badges: (
{ __typename?: 'TaskBadges' }
& { checklist?: Maybe<(
@ -1298,7 +1374,7 @@ export type MeQuery = (
{ __typename?: 'MePayload' }
& { user: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'fullName'>
& Pick<UserAccount, 'id' | 'fullName' | 'username' | 'email' | 'bio'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
@ -1397,7 +1473,7 @@ export type UpdateProjectMemberRoleMutation = (
);
export type CreateTaskMutationVariables = {
taskGroupID: Scalars['String'];
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
};
@ -1575,6 +1651,60 @@ export type UpdateTaskChecklistNameMutation = (
) }
);
export type DeleteTaskGroupTasksMutationVariables = {
taskGroupID: Scalars['UUID'];
};
export type DeleteTaskGroupTasksMutation = (
{ __typename?: 'Mutation' }
& { deleteTaskGroupTasks: (
{ __typename?: 'DeleteTaskGroupTasksPayload' }
& Pick<DeleteTaskGroupTasksPayload, 'tasks' | 'taskGroupID'>
) }
);
export type DuplicateTaskGroupMutationVariables = {
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
projectID: Scalars['UUID'];
};
export type DuplicateTaskGroupMutation = (
{ __typename?: 'Mutation' }
& { duplicateTaskGroup: (
{ __typename?: 'DuplicateTaskGroupPayload' }
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id' | 'name' | 'position'>
& { tasks: Array<(
{ __typename?: 'Task' }
& TaskFieldsFragment
)> }
) }
) }
);
export type SortTaskGroupMutationVariables = {
tasks: Array<TaskPositionUpdate>;
taskGroupID: Scalars['UUID'];
};
export type SortTaskGroupMutation = (
{ __typename?: 'Mutation' }
& { sortTaskGroup: (
{ __typename?: 'SortTaskGroupPayload' }
& Pick<SortTaskGroupPayload, 'taskGroupID'>
& { tasks: Array<(
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'position'>
)> }
) }
);
export type UpdateTaskGroupNameMutationVariables = {
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
@ -1970,7 +2100,7 @@ export type CreateUserAccountMutation = (
{ __typename?: 'Mutation' }
& { createUserAccount: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username'>
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username' | 'bio'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
@ -2017,6 +2147,29 @@ export type DeleteUserAccountMutation = (
) }
);
export type UpdateUserInfoMutationVariables = {
name: Scalars['String'];
initials: Scalars['String'];
email: Scalars['String'];
bio: Scalars['String'];
};
export type UpdateUserInfoMutation = (
{ __typename?: 'Mutation' }
& { updateUserInfo: (
{ __typename?: 'UpdateUserInfoPayload' }
& { user: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'bio'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials'>
) }
) }
) }
);
export type UpdateUserPasswordMutationVariables = {
userID: Scalars['UUID'];
password: Scalars['String'];
@ -2550,6 +2703,7 @@ export const FindTaskDocument = gql`
complete
taskGroup {
id
name
}
badges {
checklist {
@ -2685,6 +2839,9 @@ export const MeDocument = gql`
user {
id
fullName
username
email
bio
profileIcon {
initials
bgColor
@ -2888,7 +3045,7 @@ export type UpdateProjectMemberRoleMutationHookResult = ReturnType<typeof useUpd
export type UpdateProjectMemberRoleMutationResult = ApolloReactCommon.MutationResult<UpdateProjectMemberRoleMutation>;
export type UpdateProjectMemberRoleMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateProjectMemberRoleMutation, UpdateProjectMemberRoleMutationVariables>;
export const CreateTaskDocument = gql`
mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!) {
createTask(input: {taskGroupID: $taskGroupID, name: $name, position: $position}) {
...TaskFields
}
@ -3292,6 +3449,118 @@ export function useUpdateTaskChecklistNameMutation(baseOptions?: ApolloReactHook
export type UpdateTaskChecklistNameMutationHookResult = ReturnType<typeof useUpdateTaskChecklistNameMutation>;
export type UpdateTaskChecklistNameMutationResult = ApolloReactCommon.MutationResult<UpdateTaskChecklistNameMutation>;
export type UpdateTaskChecklistNameMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateTaskChecklistNameMutation, UpdateTaskChecklistNameMutationVariables>;
export const DeleteTaskGroupTasksDocument = gql`
mutation deleteTaskGroupTasks($taskGroupID: UUID!) {
deleteTaskGroupTasks(input: {taskGroupID: $taskGroupID}) {
tasks
taskGroupID
}
}
`;
export type DeleteTaskGroupTasksMutationFn = ApolloReactCommon.MutationFunction<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>;
/**
* __useDeleteTaskGroupTasksMutation__
*
* To run a mutation, you first call `useDeleteTaskGroupTasksMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteTaskGroupTasksMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteTaskGroupTasksMutation, { data, loading, error }] = useDeleteTaskGroupTasksMutation({
* variables: {
* taskGroupID: // value for 'taskGroupID'
* },
* });
*/
export function useDeleteTaskGroupTasksMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>(DeleteTaskGroupTasksDocument, baseOptions);
}
export type DeleteTaskGroupTasksMutationHookResult = ReturnType<typeof useDeleteTaskGroupTasksMutation>;
export type DeleteTaskGroupTasksMutationResult = ApolloReactCommon.MutationResult<DeleteTaskGroupTasksMutation>;
export type DeleteTaskGroupTasksMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>;
export const DuplicateTaskGroupDocument = gql`
mutation duplicateTaskGroup($taskGroupID: UUID!, $name: String!, $position: Float!, $projectID: UUID!) {
duplicateTaskGroup(input: {projectID: $projectID, taskGroupID: $taskGroupID, name: $name, position: $position}) {
taskGroup {
id
name
position
tasks {
...TaskFields
}
}
}
}
${TaskFieldsFragmentDoc}`;
export type DuplicateTaskGroupMutationFn = ApolloReactCommon.MutationFunction<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>;
/**
* __useDuplicateTaskGroupMutation__
*
* To run a mutation, you first call `useDuplicateTaskGroupMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDuplicateTaskGroupMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [duplicateTaskGroupMutation, { data, loading, error }] = useDuplicateTaskGroupMutation({
* variables: {
* taskGroupID: // value for 'taskGroupID'
* name: // value for 'name'
* position: // value for 'position'
* projectID: // value for 'projectID'
* },
* });
*/
export function useDuplicateTaskGroupMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>) {
return ApolloReactHooks.useMutation<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>(DuplicateTaskGroupDocument, baseOptions);
}
export type DuplicateTaskGroupMutationHookResult = ReturnType<typeof useDuplicateTaskGroupMutation>;
export type DuplicateTaskGroupMutationResult = ApolloReactCommon.MutationResult<DuplicateTaskGroupMutation>;
export type DuplicateTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>;
export const SortTaskGroupDocument = gql`
mutation sortTaskGroup($tasks: [TaskPositionUpdate!]!, $taskGroupID: UUID!) {
sortTaskGroup(input: {taskGroupID: $taskGroupID, tasks: $tasks}) {
taskGroupID
tasks {
id
position
}
}
}
`;
export type SortTaskGroupMutationFn = ApolloReactCommon.MutationFunction<SortTaskGroupMutation, SortTaskGroupMutationVariables>;
/**
* __useSortTaskGroupMutation__
*
* To run a mutation, you first call `useSortTaskGroupMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSortTaskGroupMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [sortTaskGroupMutation, { data, loading, error }] = useSortTaskGroupMutation({
* variables: {
* tasks: // value for 'tasks'
* taskGroupID: // value for 'taskGroupID'
* },
* });
*/
export function useSortTaskGroupMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<SortTaskGroupMutation, SortTaskGroupMutationVariables>) {
return ApolloReactHooks.useMutation<SortTaskGroupMutation, SortTaskGroupMutationVariables>(SortTaskGroupDocument, baseOptions);
}
export type SortTaskGroupMutationHookResult = ReturnType<typeof useSortTaskGroupMutation>;
export type SortTaskGroupMutationResult = ApolloReactCommon.MutationResult<SortTaskGroupMutation>;
export type SortTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<SortTaskGroupMutation, SortTaskGroupMutationVariables>;
export const UpdateTaskGroupNameDocument = gql`
mutation updateTaskGroupName($taskGroupID: UUID!, $name: String!) {
updateTaskGroupName(input: {taskGroupID: $taskGroupID, name: $name}) {
@ -4049,6 +4318,7 @@ export const CreateUserAccountDocument = gql`
fullName
initials
username
bio
profileIcon {
url
initials
@ -4147,6 +4417,49 @@ export function useDeleteUserAccountMutation(baseOptions?: ApolloReactHooks.Muta
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
export type DeleteUserAccountMutationResult = ApolloReactCommon.MutationResult<DeleteUserAccountMutation>;
export type DeleteUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
export const UpdateUserInfoDocument = gql`
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
updateUserInfo(input: {name: $name, initials: $initials, email: $email, bio: $bio}) {
user {
id
email
fullName
bio
profileIcon {
initials
}
}
}
}
`;
export type UpdateUserInfoMutationFn = ApolloReactCommon.MutationFunction<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
/**
* __useUpdateUserInfoMutation__
*
* To run a mutation, you first call `useUpdateUserInfoMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateUserInfoMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateUserInfoMutation, { data, loading, error }] = useUpdateUserInfoMutation({
* variables: {
* name: // value for 'name'
* initials: // value for 'initials'
* email: // value for 'email'
* bio: // value for 'bio'
* },
* });
*/
export function useUpdateUserInfoMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>) {
return ApolloReactHooks.useMutation<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>(UpdateUserInfoDocument, baseOptions);
}
export type UpdateUserInfoMutationHookResult = ReturnType<typeof useUpdateUserInfoMutation>;
export type UpdateUserInfoMutationResult = ApolloReactCommon.MutationResult<UpdateUserInfoMutation>;
export type UpdateUserInfoMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
export const UpdateUserPasswordDocument = gql`
mutation updateUserPassword($userID: UUID!, $password: String!) {
updateUserPassword(input: {userID: $userID, password: $password}) {

View File

@ -8,6 +8,7 @@ query findTask($taskID: UUID!) {
complete
taskGroup {
id
name
}
badges {
checklist {

View File

@ -3,6 +3,9 @@ query me {
user {
id
fullName
username
email
bio
profileIcon {
initials
bgColor

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag';
import TASK_FRAGMENT from '../fragments/task';
const CREATE_TASK_MUTATION = gql`
mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!) {
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position }) {
...TaskFields
}

View File

@ -0,0 +1,10 @@
import gql from 'graphql-tag';
const DELETE_TASK_GROUP_TASKS_MUTATION = gql`
mutation deleteTaskGroupTasks($taskGroupID: UUID!) {
deleteTaskGroupTasks(input: { taskGroupID: $taskGroupID }) {
tasks
taskGroupID
}
}
`;

View File

@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import TASK_FRAGMENT from '../fragments/task';
const DUPLICATE_TASK_GROUP_MUTATION = gql`
mutation duplicateTaskGroup($taskGroupID: UUID!, $name: String!, $position: Float!, $projectID: UUID!) {
duplicateTaskGroup(
input: {
projectID: $projectID
taskGroupID: $taskGroupID
name: $name
position: $position
}
) {
taskGroup {
id
name
position
tasks {
...TaskFields
}
}
}
${TASK_FRAGMENT}
}
`;

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
const SORT_TASK_GROUP_MUTATION = gql`
mutation sortTaskGroup($tasks: [TaskPositionUpdate!]!, $taskGroupID: UUID!) {
sortTaskGroup(input: { taskGroupID: $taskGroupID, tasks: $tasks }) {
taskGroupID
tasks {
id
position
}
}
}
`;

View File

@ -24,6 +24,7 @@ export const CREATE_USER_MUTATION = gql`
fullName
initials
username
bio
profileIcon {
url
initials

View File

@ -0,0 +1,19 @@
import gql from 'graphql-tag';
export const UPDATE_USER_INFO_MUTATION = gql`
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
updateUserInfo(input: { name: $name, initials: $initials, email: $email, bio: $bio }) {
user {
id
email
fullName
bio
profileIcon {
initials
}
}
}
}
`;
export default UPDATE_USER_INFO_MUTATION;

View File

@ -0,0 +1,13 @@
import { ApolloError } from '@apollo/client';
export default function hasNotFoundError(...errors: Array<ApolloError | undefined>) {
for (const error of errors) {
if (error && error.graphQLErrors.length !== 0) {
const notFound = error.graphQLErrors.find(e => e.extensions && e.extensions.code === '404');
if (notFound) {
return true;
}
}
}
return false;
}

View File

@ -0,0 +1,132 @@
import moment from 'moment';
export enum TaskSortingType {
NONE,
COMPLETE,
DUE_DATE,
MEMBERS,
LABELS,
TASK_TITLE,
}
export enum TaskSortingDirection {
ASC,
DESC,
}
export type TaskSorting = {
type: TaskSortingType;
direction: TaskSortingDirection;
};
export function sortString(a: string, b: string) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
export function sortTasks(a: Task, b: Task, taskSorting: TaskSorting) {
if (taskSorting.type === TaskSortingType.TASK_TITLE) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.DUE_DATE) {
if (a.dueDate && !b.dueDate) {
return -1;
}
if (b.dueDate && !a.dueDate) {
return 1;
}
return moment(a.dueDate).diff(moment(b.dueDate));
}
if (taskSorting.type === TaskSortingType.COMPLETE) {
if (a.complete && !b.complete) {
return -1;
}
if (b.complete && !a.complete) {
return 1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.LABELS) {
// sorts non-empty labels by name, then by empty label color name
let aLabels = [];
let bLabels = [];
let aLabelsEmpty = [];
let bLabelsEmpty = [];
if (a.labels) {
for (const aLabel of a.labels) {
if (aLabel.projectLabel.name && aLabel.projectLabel.name !== '') {
aLabels.push(aLabel.projectLabel.name);
} else {
aLabelsEmpty.push(aLabel.projectLabel.labelColor.name);
}
}
}
if (b.labels) {
for (const bLabel of b.labels) {
if (bLabel.projectLabel.name && bLabel.projectLabel.name !== '') {
bLabels.push(bLabel.projectLabel.name);
} else {
bLabelsEmpty.push(bLabel.projectLabel.labelColor.name);
}
}
}
aLabels = aLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabels = bLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
aLabelsEmpty = aLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabelsEmpty = bLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
if (aLabelsEmpty.length !== 0 || bLabelsEmpty.length !== 0) {
if (aLabelsEmpty.length > bLabelsEmpty.length) {
if (bLabels.length !== 0) {
return 1;
}
return -1;
}
}
if (aLabels.length < bLabels.length) {
return 1;
}
if (aLabels.length > bLabels.length) {
return -1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.MEMBERS) {
let aMembers = [];
let bMembers = [];
if (a.assigned) {
for (const aMember of a.assigned) {
if (aMember.fullName) {
aMembers.push(aMember.fullName);
}
}
}
if (b.assigned) {
for (const bMember of b.assigned) {
if (bMember.fullName) {
bMembers.push(bMember.fullName);
}
}
}
aMembers = aMembers.sort((aMember, bMember) => sortString(aMember, bMember));
bMembers = bMembers.sort((aMember, bMember) => sortString(aMember, bMember));
if (aMembers.length < bMembers.length) {
return 1;
}
if (aMembers.length > bMembers.length) {
return -1;
}
return 0;
}
return 0;
}

View File

@ -46,6 +46,8 @@ type OwnedList = {
type TaskUser = {
id: string;
fullName: string;
email?: string;
bio?: string;
profileIcon: ProfileIcon;
username?: string;
role?: Role;

View File

@ -157,4 +157,5 @@ type UserAccount struct {
Initials string `json:"initials"`
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
RoleCode string `json:"role_code"`
Bio string `json:"bio"`
}

View File

@ -19,6 +19,7 @@ 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)
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)
@ -111,7 +112,9 @@ type Querier interface {
UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error)
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
UpdateTaskPosition(ctx context.Context, arg UpdateTaskPositionParams) (Task, error)
UpdateTeamMemberRole(ctx context.Context, arg UpdateTeamMemberRoleParams) (TeamMember, error)
UpdateUserAccountInfo(ctx context.Context, arg UpdateUserAccountInfoParams) (UserAccount, error)
UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error)
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UserAccount, error)
}

View File

@ -2,6 +2,10 @@
INSERT INTO task (task_group_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING *;
-- name: CreateTaskAll :one
INSERT INTO task (task_group_id, created_at, name, position, description, complete, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *;
-- name: UpdateTaskDescription :one
UPDATE task SET description = $2 WHERE task_id = $1 RETURNING *;
@ -17,6 +21,9 @@ SELECT * FROM task;
-- name: UpdateTaskLocation :one
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING *;
-- name: UpdateTaskPosition :one
UPDATE task SET position = $2 WHERE task_id = $1 RETURNING *;
-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1;

View File

@ -15,6 +15,10 @@ INSERT INTO user_account(full_name, initials, email, username, created_at, passw
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1
RETURNING *;
-- name: UpdateUserAccountInfo :one
UPDATE user_account SET bio = $2, full_name = $3, initials = $4, email = $5
WHERE user_id = $1 RETURNING *;
-- name: DeleteUserAccountByID :exec
DELETE FROM user_account WHERE user_id = $1;

View File

@ -45,6 +45,46 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, e
return i, err
}
const createTaskAll = `-- name: CreateTaskAll :one
INSERT INTO task (task_group_id, created_at, name, position, description, complete, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
`
type CreateTaskAllParams struct {
TaskGroupID uuid.UUID `json:"task_group_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Position float64 `json:"position"`
Description sql.NullString `json:"description"`
Complete bool `json:"complete"`
DueDate sql.NullTime `json:"due_date"`
}
func (q *Queries) CreateTaskAll(ctx context.Context, arg CreateTaskAllParams) (Task, error) {
row := q.db.QueryRowContext(ctx, createTaskAll,
arg.TaskGroupID,
arg.CreatedAt,
arg.Name,
arg.Position,
arg.Description,
arg.Complete,
arg.DueDate,
)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
)
return i, err
}
const deleteTaskByID = `-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1
`
@ -305,3 +345,29 @@ func (q *Queries) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams)
)
return i, err
}
const updateTaskPosition = `-- name: UpdateTaskPosition :one
UPDATE task SET position = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
`
type UpdateTaskPositionParams struct {
TaskID uuid.UUID `json:"task_id"`
Position float64 `json:"position"`
}
func (q *Queries) UpdateTaskPosition(ctx context.Context, arg UpdateTaskPositionParams) (Task, error) {
row := q.db.QueryRowContext(ctx, updateTaskPosition, arg.TaskID, arg.Position)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
)
return i, err
}

View File

@ -13,7 +13,7 @@ import (
const createUserAccount = `-- name: CreateUserAccount :one
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
`
type CreateUserAccountParams struct {
@ -48,6 +48,7 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
)
return i, err
}
@ -62,7 +63,7 @@ func (q *Queries) DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) e
}
const getAllUserAccounts = `-- name: GetAllUserAccounts :many
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code FROM user_account WHERE username != 'system'
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account WHERE username != 'system'
`
func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) {
@ -85,6 +86,7 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
); err != nil {
return nil, err
}
@ -119,7 +121,7 @@ func (q *Queries) GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRo
}
const getUserAccountByID = `-- name: GetUserAccountByID :one
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code FROM user_account WHERE user_id = $1
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account WHERE user_id = $1
`
func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) {
@ -136,12 +138,13 @@ func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (Use
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
)
return i, err
}
const getUserAccountByUsername = `-- name: GetUserAccountByUsername :one
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code FROM user_account WHERE username = $1
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account WHERE username = $1
`
func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) {
@ -158,12 +161,13 @@ func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string)
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
)
return i, err
}
const setUserPassword = `-- name: SetUserPassword :one
UPDATE user_account SET password_hash = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code
UPDATE user_account SET password_hash = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
`
type SetUserPasswordParams struct {
@ -185,13 +189,52 @@ func (q *Queries) SetUserPassword(ctx context.Context, arg SetUserPasswordParams
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
)
return i, err
}
const updateUserAccountInfo = `-- name: UpdateUserAccountInfo :one
UPDATE user_account SET bio = $2, full_name = $3, initials = $4, email = $5
WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
`
type UpdateUserAccountInfoParams struct {
UserID uuid.UUID `json:"user_id"`
Bio string `json:"bio"`
FullName string `json:"full_name"`
Initials string `json:"initials"`
Email string `json:"email"`
}
func (q *Queries) UpdateUserAccountInfo(ctx context.Context, arg UpdateUserAccountInfoParams) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, updateUserAccountInfo,
arg.UserID,
arg.Bio,
arg.FullName,
arg.Initials,
arg.Email,
)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
)
return i, err
}
const updateUserAccountProfileAvatarURL = `-- name: UpdateUserAccountProfileAvatarURL :one
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1
RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code
RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
`
type UpdateUserAccountProfileAvatarURLParams struct {
@ -213,12 +256,13 @@ func (q *Queries) UpdateUserAccountProfileAvatarURL(ctx context.Context, arg Upd
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
)
return i, err
}
const updateUserRole = `-- name: UpdateUserRole :one
UPDATE user_account SET role_code = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code
UPDATE user_account SET role_code = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
`
type UpdateUserRoleParams struct {
@ -240,6 +284,7 @@ func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams)
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
)
return i, err
}

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ import (
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
)
// NewHandler returns a new graphql endpoint handler.
@ -51,6 +52,8 @@ func NewHandler(repo db.Repository) http.Handler {
fieldName = "TeamID"
case ObjectTypeTask:
fieldName = "TaskID"
case ObjectTypeTaskGroup:
fieldName = "TaskGroupID"
default:
fieldName = "ProjectID"
}
@ -68,6 +71,13 @@ func NewHandler(repo db.Repository) http.Handler {
if err != nil {
return nil, err
}
} else if typeArg == ObjectTypeTaskGroup {
log.WithFields(log.Fields{"subjectID": subjectID}).Info("fetching project ID using task group ID")
taskGroup, err := repo.GetTaskGroupByID(ctx, subjectID)
if err != nil {
return nil, err
}
subjectID = taskGroup.ProjectID
}
roles, err := GetProjectRoles(ctx, repo, subjectID)
if err != nil {
@ -186,3 +196,13 @@ func GetActionType(actionType int32) ActionType {
panic("Not a valid entity type!")
}
}
// NotFoundError creates a 404 gqlerror
func NotFoundError(message string) error {
return &gqlerror.Error{
Message: message,
Extensions: map[string]interface{}{
"code": "404",
},
}
}

View File

@ -111,6 +111,15 @@ type DeleteTaskGroupPayload struct {
TaskGroup *db.TaskGroup `json:"taskGroup"`
}
type DeleteTaskGroupTasks struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
}
type DeleteTaskGroupTasksPayload struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Tasks []uuid.UUID `json:"tasks"`
}
type DeleteTaskInput struct {
TaskID string `json:"taskID"`
}
@ -151,6 +160,17 @@ type DeleteUserAccountPayload struct {
UserAccount *db.UserAccount `json:"userAccount"`
}
type DuplicateTaskGroup struct {
ProjectID uuid.UUID `json:"projectID"`
TaskGroupID uuid.UUID `json:"taskGroupID"`
Name string `json:"name"`
Position float64 `json:"position"`
}
type DuplicateTaskGroupPayload struct {
TaskGroup *db.TaskGroup `json:"taskGroup"`
}
type FindProject struct {
ProjectID uuid.UUID `json:"projectID"`
}
@ -209,9 +229,9 @@ type NewRefreshToken struct {
}
type NewTask struct {
TaskGroupID string `json:"taskGroupID"`
Name string `json:"name"`
Position float64 `json:"position"`
TaskGroupID uuid.UUID `json:"taskGroupID"`
Name string `json:"name"`
Position float64 `json:"position"`
}
type NewTaskGroup struct {
@ -296,10 +316,25 @@ type SetTaskComplete struct {
Complete bool `json:"complete"`
}
type SortTaskGroup struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Tasks []TaskPositionUpdate `json:"tasks"`
}
type SortTaskGroupPayload struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Tasks []db.Task `json:"tasks"`
}
type TaskBadges struct {
Checklist *ChecklistBadge `json:"checklist"`
}
type TaskPositionUpdate struct {
TaskID uuid.UUID `json:"taskID"`
Position float64 `json:"position"`
}
type TeamRole struct {
TeamID uuid.UUID `json:"teamID"`
RoleCode RoleCode `json:"roleCode"`
@ -420,6 +455,17 @@ type UpdateTeamMemberRolePayload struct {
Member *Member `json:"member"`
}
type UpdateUserInfo struct {
Name string `json:"name"`
Initials string `json:"initials"`
Email string `json:"email"`
Bio string `json:"bio"`
}
type UpdateUserInfoPayload struct {
User *db.UserAccount `json:"user"`
}
type UpdateUserPassword struct {
UserID uuid.UUID `json:"userID"`
Password string `json:"password"`
@ -602,10 +648,11 @@ func (e EntityType) MarshalGQL(w io.Writer) {
type ObjectType string
const (
ObjectTypeOrg ObjectType = "ORG"
ObjectTypeTeam ObjectType = "TEAM"
ObjectTypeProject ObjectType = "PROJECT"
ObjectTypeTask ObjectType = "TASK"
ObjectTypeOrg ObjectType = "ORG"
ObjectTypeTeam ObjectType = "TEAM"
ObjectTypeProject ObjectType = "PROJECT"
ObjectTypeTask ObjectType = "TASK"
ObjectTypeTaskGroup ObjectType = "TASK_GROUP"
)
var AllObjectType = []ObjectType{
@ -613,11 +660,12 @@ var AllObjectType = []ObjectType{
ObjectTypeTeam,
ObjectTypeProject,
ObjectTypeTask,
ObjectTypeTaskGroup,
}
func (e ObjectType) IsValid() bool {
switch e {
case ObjectTypeOrg, ObjectTypeTeam, ObjectTypeProject, ObjectTypeTask:
case ObjectTypeOrg, ObjectTypeTeam, ObjectTypeProject, ObjectTypeTask, ObjectTypeTaskGroup:
return true
}
return false

View File

@ -78,6 +78,7 @@ type UserAccount {
createdAt: Time!
fullName: String!
initials: String!
bio: String!
role: Role!
username: String!
profileIcon: ProfileIcon!
@ -173,6 +174,7 @@ enum ObjectType {
TEAM
PROJECT
TASK
TASK_GROUP
}
directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!) on FIELD_DEFINITION
@ -376,20 +378,20 @@ type UpdateProjectMemberRolePayload {
extend type Mutation {
createTask(input: NewTask!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK_GROUP)
deleteTask(input: DeleteTaskInput!):
DeleteTaskPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
DeleteTaskPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskDescription(input: UpdateTaskDescriptionInput!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskLocation(input: NewTaskLocation!):
UpdateTaskLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
UpdateTaskLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskName(input: UpdateTaskName!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
setTaskComplete(input: SetTaskComplete!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskDueDate(input: UpdateTaskDueDate!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
assignTask(input: AssignTaskInput):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
@ -398,7 +400,7 @@ extend type Mutation {
}
input NewTask {
taskGroupID: String!
taskGroupID: UUID!
name: String!
position: Float!
}
@ -547,6 +549,47 @@ extend type Mutation {
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroup(input: DeleteTaskGroupInput!):
DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
duplicateTaskGroup(input: DuplicateTaskGroup!):
DuplicateTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
sortTaskGroup(input: SortTaskGroup!):
SortTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroupTasks(input: DeleteTaskGroupTasks!):
DeleteTaskGroupTasksPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input DeleteTaskGroupTasks {
taskGroupID: UUID!
}
type DeleteTaskGroupTasksPayload {
taskGroupID: UUID!
tasks: [UUID!]!
}
input TaskPositionUpdate {
taskID: UUID!
position: Float!
}
type SortTaskGroupPayload {
taskGroupID: UUID!
tasks: [Task!]!
}
input SortTaskGroup {
taskGroupID: UUID!
tasks: [TaskPositionUpdate!]!
}
input DuplicateTaskGroup {
projectID: UUID!
taskGroupID: UUID!
name: String!
position: Float!
}
type DuplicateTaskGroupPayload {
taskGroup: TaskGroup!
}
input NewTaskGroupLocation {
@ -681,6 +724,19 @@ extend type Mutation {
updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload!
updateUserRole(input: UpdateUserRole!):
UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
updateUserInfo(input: UpdateUserInfo!):
UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
}
type UpdateUserInfoPayload {
user: UserAccount!
}
input UpdateUserInfo {
name: String!
initials: String!
email: String!
bio: String!
}
input UpdateUserPassword {

View File

@ -179,14 +179,9 @@ func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input Up
}
func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.Task, error) {
taskGroupID, err := uuid.Parse(input.TaskGroupID)
if err != nil {
log.WithError(err).Error("issue while parsing task group ID")
return &db.Task{}, err
}
createdAt := time.Now().UTC()
log.WithFields(log.Fields{"positon": input.Position, "taskGroupID": taskGroupID}).Info("creating task")
task, err := r.Repository.CreateTask(ctx, db.CreateTaskParams{taskGroupID, createdAt, input.Name, input.Position})
log.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 {
log.WithError(err).Error("issue while creating task")
return &db.Task{}, err
@ -238,6 +233,9 @@ func (r *mutationResolver) SetTaskComplete(ctx context.Context, input SetTaskCom
completedAt := time.Now().UTC()
task, err := r.Repository.SetTaskComplete(ctx, db.SetTaskCompleteParams{TaskID: input.TaskID, Complete: input.Complete, CompletedAt: sql.NullTime{Time: completedAt, Valid: true}})
if err != nil {
if err == sql.ErrNoRows {
return &db.Task{}, NotFoundError("task does not exist")
}
return &db.Task{}, err
}
return &task, nil
@ -438,6 +436,111 @@ func (r *mutationResolver) DeleteTaskGroup(ctx context.Context, input DeleteTask
return &DeleteTaskGroupPayload{true, int(deletedTasks + deletedTaskGroups), &taskGroup}, nil
}
func (r *mutationResolver) DuplicateTaskGroup(ctx context.Context, input DuplicateTaskGroup) (*DuplicateTaskGroupPayload, error) {
createdAt := time.Now().UTC()
taskGroup, err := r.Repository.CreateTaskGroup(ctx, db.CreateTaskGroupParams{ProjectID: input.ProjectID, Position: input.Position, Name: input.Name, CreatedAt: createdAt})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
originalTasks, err := r.Repository.GetTasksForTaskGroupID(ctx, input.TaskGroupID)
if err != nil && err != sql.ErrNoRows {
return &DuplicateTaskGroupPayload{}, err
}
for _, originalTask := range originalTasks {
task, err := r.Repository.CreateTaskAll(ctx, db.CreateTaskAllParams{
TaskGroupID: taskGroup.TaskGroupID, CreatedAt: createdAt, Name: originalTask.Name, Position: originalTask.Position,
Complete: originalTask.Complete, DueDate: originalTask.DueDate, Description: originalTask.Description})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
members, err := r.Repository.GetAssignedMembersForTask(ctx, originalTask.TaskID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, member := range members {
_, err := r.Repository.CreateTaskAssigned(ctx, db.CreateTaskAssignedParams{
TaskID: task.TaskID, UserID: member.UserID, AssignedDate: member.AssignedDate})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
}
labels, err := r.Repository.GetTaskLabelsForTaskID(ctx, originalTask.TaskID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, label := range labels {
_, err := r.Repository.CreateTaskLabelForTask(ctx, db.CreateTaskLabelForTaskParams{
TaskID: task.TaskID, ProjectLabelID: label.ProjectLabelID, AssignedDate: label.AssignedDate})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
}
checklists, err := r.Repository.GetTaskChecklistsForTask(ctx, originalTask.TaskID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, checklist := range checklists {
newChecklist, err := r.Repository.CreateTaskChecklist(ctx, db.CreateTaskChecklistParams{
TaskID: task.TaskID, Name: checklist.Name, CreatedAt: createdAt, Position: checklist.Position})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
checklistItems, err := r.Repository.GetTaskChecklistItemsForTaskChecklist(ctx, checklist.TaskChecklistID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, checklistItem := range checklistItems {
item, err := r.Repository.CreateTaskChecklistItem(ctx, db.CreateTaskChecklistItemParams{
TaskChecklistID: newChecklist.TaskChecklistID,
CreatedAt: createdAt,
Name: checklistItem.Name,
Position: checklist.Position,
})
if checklistItem.Complete {
r.Repository.SetTaskChecklistItemComplete(ctx, db.SetTaskChecklistItemCompleteParams{TaskChecklistItemID: item.TaskChecklistItemID, Complete: true})
}
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
}
}
}
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
return &DuplicateTaskGroupPayload{TaskGroup: &taskGroup}, err
}
func (r *mutationResolver) SortTaskGroup(ctx context.Context, input SortTaskGroup) (*SortTaskGroupPayload, error) {
tasks := []db.Task{}
for _, task := range input.Tasks {
t, err := r.Repository.UpdateTaskPosition(ctx, db.UpdateTaskPositionParams{TaskID: task.TaskID, Position: task.Position})
if err != nil {
return &SortTaskGroupPayload{}, err
}
tasks = append(tasks, t)
}
return &SortTaskGroupPayload{Tasks: tasks, TaskGroupID: input.TaskGroupID}, nil
}
func (r *mutationResolver) DeleteTaskGroupTasks(ctx context.Context, input DeleteTaskGroupTasks) (*DeleteTaskGroupTasksPayload, error) {
tasks, err := r.Repository.GetTasksForTaskGroupID(ctx, input.TaskGroupID)
if err != nil && err != sql.ErrNoRows {
return &DeleteTaskGroupTasksPayload{}, err
}
removedTasks := []uuid.UUID{}
for _, task := range tasks {
err = r.Repository.DeleteTaskByID(ctx, task.TaskID)
if err != nil {
return &DeleteTaskGroupTasksPayload{}, err
}
removedTasks = append(removedTasks, task.TaskID)
}
return &DeleteTaskGroupTasksPayload{TaskGroupID: input.TaskGroupID, Tasks: removedTasks}, nil
}
func (r *mutationResolver) AddTaskLabel(ctx context.Context, input *AddTaskLabelInput) (*db.Task, error) {
assignedDate := time.Now().UTC()
_, err := r.Repository.CreateTaskLabelForTask(ctx, db.CreateTaskLabelForTaskParams{input.TaskID, input.ProjectLabelID, assignedDate})
@ -721,6 +824,17 @@ func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserR
return &UpdateUserRolePayload{User: &user}, nil
}
func (r *mutationResolver) UpdateUserInfo(ctx context.Context, input UpdateUserInfo) (*UpdateUserInfoPayload, error) {
userID, ok := GetUserID(ctx)
if !ok {
return &UpdateUserInfoPayload{}, errors.New("invalid user ID")
}
user, err := r.Repository.UpdateUserAccountInfo(ctx, db.UpdateUserAccountInfoParams{
Bio: input.Bio, FullName: input.Name, Initials: input.Initials, Email: input.Email, UserID: userID,
})
return &UpdateUserInfoPayload{User: &user}, err
}
func (r *notificationResolver) ID(ctx context.Context, obj *db.Notification) (uuid.UUID, error) {
return obj.NotificationID, nil
}
@ -917,6 +1031,14 @@ func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db
func (r *queryResolver) FindTask(ctx context.Context, input FindTask) (*db.Task, error) {
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
if err == sql.ErrNoRows {
return &db.Task{}, &gqlerror.Error{
Message: "Task does not exist",
Extensions: map[string]interface{}{
"code": "404",
},
}
}
return &task, err
}
@ -1124,6 +1246,9 @@ func (r *taskResolver) Assigned(ctx context.Context, obj *db.Task) ([]Member, er
taskMemberLinks, err := r.Repository.GetAssignedMembersForTask(ctx, obj.TaskID)
taskMembers := []Member{}
if err != nil {
if err == sql.ErrNoRows {
return taskMembers, nil
}
return taskMembers, err
}
for _, taskMemberLink := range taskMemberLinks {
@ -1158,11 +1283,19 @@ func (r *taskResolver) Assigned(ctx context.Context, obj *db.Task) ([]Member, er
}
func (r *taskResolver) Labels(ctx context.Context, obj *db.Task) ([]db.TaskLabel, error) {
return r.Repository.GetTaskLabelsForTaskID(ctx, obj.TaskID)
labels, err := r.Repository.GetTaskLabelsForTaskID(ctx, obj.TaskID)
if err != nil && err != sql.ErrNoRows {
return []db.TaskLabel{}, err
}
return labels, nil
}
func (r *taskResolver) Checklists(ctx context.Context, obj *db.Task) ([]db.TaskChecklist, error) {
return r.Repository.GetTaskChecklistsForTask(ctx, obj.TaskID)
checklists, err := r.Repository.GetTaskChecklistsForTask(ctx, obj.TaskID)
if err != nil && err != sql.ErrNoRows {
return []db.TaskChecklist{}, err
}
return checklists, nil
}
func (r *taskResolver) Badges(ctx context.Context, obj *db.Task) (*TaskBadges, error) {

View File

@ -78,6 +78,7 @@ type UserAccount {
createdAt: Time!
fullName: String!
initials: String!
bio: String!
role: Role!
username: String!
profileIcon: ProfileIcon!

View File

@ -14,6 +14,7 @@ enum ObjectType {
TEAM
PROJECT
TASK
TASK_GROUP
}
directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!) on FIELD_DEFINITION

View File

@ -1,19 +1,19 @@
extend type Mutation {
createTask(input: NewTask!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK_GROUP)
deleteTask(input: DeleteTaskInput!):
DeleteTaskPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
DeleteTaskPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskDescription(input: UpdateTaskDescriptionInput!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskLocation(input: NewTaskLocation!):
UpdateTaskLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
UpdateTaskLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskName(input: UpdateTaskName!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
setTaskComplete(input: SetTaskComplete!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
updateTaskDueDate(input: UpdateTaskDueDate!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
assignTask(input: AssignTaskInput):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: TASK)
@ -22,7 +22,7 @@ extend type Mutation {
}
input NewTask {
taskGroupID: String!
taskGroupID: UUID!
name: String!
position: Float!
}

View File

@ -7,6 +7,47 @@ extend type Mutation {
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroup(input: DeleteTaskGroupInput!):
DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
duplicateTaskGroup(input: DuplicateTaskGroup!):
DuplicateTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
sortTaskGroup(input: SortTaskGroup!):
SortTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroupTasks(input: DeleteTaskGroupTasks!):
DeleteTaskGroupTasksPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input DeleteTaskGroupTasks {
taskGroupID: UUID!
}
type DeleteTaskGroupTasksPayload {
taskGroupID: UUID!
tasks: [UUID!]!
}
input TaskPositionUpdate {
taskID: UUID!
position: Float!
}
type SortTaskGroupPayload {
taskGroupID: UUID!
tasks: [Task!]!
}
input SortTaskGroup {
taskGroupID: UUID!
tasks: [TaskPositionUpdate!]!
}
input DuplicateTaskGroup {
projectID: UUID!
taskGroupID: UUID!
name: String!
position: Float!
}
type DuplicateTaskGroupPayload {
taskGroup: TaskGroup!
}
input NewTaskGroupLocation {

View File

@ -11,6 +11,19 @@ extend type Mutation {
updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload!
updateUserRole(input: UpdateUserRole!):
UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
updateUserInfo(input: UpdateUserInfo!):
UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
}
type UpdateUserInfoPayload {
user: UserAccount!
}
input UpdateUserInfo {
name: String!
initials: String!
email: String!
bio: String!
}
input UpdateUserPassword {

View File

@ -14,6 +14,7 @@ import (
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/frontend"
"github.com/jordanknott/taskcafe/internal/utils"
)
// Frontend serves the index.html file
@ -30,7 +31,7 @@ func (h *TaskcafeHandler) Frontend(w http.ResponseWriter, r *http.Request) {
// ProfileImageUpload handles a user uploading a new avatar profile image
func (h *TaskcafeHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Request) {
log.Info("preparing to upload file")
userID, ok := r.Context().Value("userID").(uuid.UUID)
userID, ok := r.Context().Value(utils.UserIDKey).(uuid.UUID)
if !ok {
log.Error("not a valid uuid")
w.WriteHeader(http.StatusInternalServerError)

View File

@ -0,0 +1 @@
ALTER TABLE user_account ADD COLUMN bio text NOT NULL DEFAULT '';

2
scripts/lint.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
yarn --cwd frontend eslint $(echo $1 | sed 's/frontend\///g')