feat: add task sorting & filtering
adds filtering by task status (completion date, incomplete, completion) adds filtering by task metadata (task name, labels, members, due date) adds sorting by task name, labels, members, and due date
This commit is contained in:
parent
47782d6d86
commit
becffc9e9b
@ -30,6 +30,7 @@
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
|
||||
"no-case-declarations": "off",
|
||||
"react/prop-types": 0,
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"no-param-reassign": "off",
|
||||
|
324
frontend/src/Projects/Project/Board/FilterMeta.tsx
Normal file
324
frontend/src/Projects/Project/Board/FilterMeta.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons';
|
||||
import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists';
|
||||
import Input from 'shared/components/ControlledInput';
|
||||
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
import moment from 'moment';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Member from 'shared/components/Member';
|
||||
|
||||
const FilterMember = styled(Member)`
|
||||
margin: 2px 0;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: rgba(${props => props.theme.colors.primary});
|
||||
}
|
||||
`;
|
||||
|
||||
export const Labels = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0 8px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
export const Label = styled.li`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const CardLabel = styled.span<{ active: boolean; color: string }>`
|
||||
${props =>
|
||||
props.active &&
|
||||
css`
|
||||
margin-left: 4px;
|
||||
box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)};
|
||||
border-radius: 3px;
|
||||
`}
|
||||
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
min-height: 20px;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
transition: padding 85ms, margin 85ms, box-shadow 85ms;
|
||||
background-color: ${props => props.color};
|
||||
color: #fff;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-height: 31px;
|
||||
`;
|
||||
|
||||
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: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
const ActionItemSeparator = styled.li`
|
||||
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const ActiveIcon = styled(Checkmark)`
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
`;
|
||||
|
||||
const ItemIcon = styled.div`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const TaskNameInput = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ActionItemLine = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
||||
|
||||
type FilterMetaProps = {
|
||||
filters: TaskMetaFilters;
|
||||
userID: string;
|
||||
labels: React.RefObject<Array<ProjectLabel>>;
|
||||
members: React.RefObject<Array<TaskUser>>;
|
||||
onChangeTaskMetaFilter: (filters: TaskMetaFilters) => void;
|
||||
};
|
||||
|
||||
const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter, userID, labels, members }) => {
|
||||
const [currentFilters, setFilters] = useState(filters);
|
||||
const [nameFilter, setNameFilter] = useState(filters.taskName ? filters.taskName.name : '');
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
|
||||
const handleSetFilters = (f: TaskMetaFilters) => {
|
||||
setFilters(f);
|
||||
onChangeTaskMetaFilter(f);
|
||||
};
|
||||
|
||||
const handleNameChange = (nFilter: string) => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, draftFilters => {
|
||||
draftFilters.taskName = nFilter !== '' ? { name: nFilter } : null;
|
||||
}),
|
||||
);
|
||||
setNameFilter(nFilter);
|
||||
};
|
||||
|
||||
const { setTab } = usePopup();
|
||||
|
||||
const handleSetDueDate = (filterType: DueDateFilterType, label: string) => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, draftFilters => {
|
||||
if (draftFilters.dueDate && draftFilters.dueDate.type === filterType) {
|
||||
draftFilters.dueDate = null;
|
||||
} else {
|
||||
draftFilters.dueDate = {
|
||||
label,
|
||||
type: filterType,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup tab={0} title={null}>
|
||||
<ActionsList>
|
||||
<TaskNameInput
|
||||
width="100%"
|
||||
onChange={e => handleNameChange(e.currentTarget.value)}
|
||||
value={nameFilter}
|
||||
variant="alternate"
|
||||
placeholder="Task name..."
|
||||
/>
|
||||
<ActionItemSeparator>QUICK ADD</ActionItemSeparator>
|
||||
<ActionItem
|
||||
onClick={() => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, draftFilters => {
|
||||
if (members.current) {
|
||||
const member = members.current.find(m => m.id === userID);
|
||||
const draftMember = draftFilters.members.find(m => m.id === userID);
|
||||
if (member && !draftMember) {
|
||||
draftFilters.members.push({ id: userID, username: member.username ? member.username : '' });
|
||||
} else {
|
||||
draftFilters.members = draftFilters.members.filter(m => m.id !== userID);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ItemIcon>
|
||||
<User width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>Just my tasks</ActionTitle>
|
||||
{currentFilters.members.find(m => m.id === userID) && <ActiveIcon width={12} height={12} />}
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}>
|
||||
<ItemIcon>
|
||||
<Calendar width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>Due this week</ActionTitle>
|
||||
{currentFilters.dueDate && currentFilters.dueDate.type === DueDateFilterType.THIS_WEEK && (
|
||||
<ActiveIcon width={12} height={12} />
|
||||
)}
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NEXT_WEEK, 'Due next week')}>
|
||||
<ItemIcon>
|
||||
<Calendar width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>Due next week</ActionTitle>
|
||||
{currentFilters.dueDate && currentFilters.dueDate.type === DueDateFilterType.NEXT_WEEK && (
|
||||
<ActiveIcon width={12} height={12} />
|
||||
)}
|
||||
</ActionItem>
|
||||
<ActionItemLine />
|
||||
<ActionItem onClick={() => setTab(1)}>
|
||||
<ItemIcon>
|
||||
<Tags width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>By Label</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => setTab(2)}>
|
||||
<ItemIcon>
|
||||
<User width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>By Member</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => setTab(3)}>
|
||||
<ItemIcon>
|
||||
<Clock width={12} height={12} />
|
||||
</ItemIcon>
|
||||
<ActionTitle>By Due Date</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
<Popup tab={1} title="By Labels">
|
||||
<Labels>
|
||||
{labels.current &&
|
||||
labels.current
|
||||
// .filter(label => '' === '' || (label.name && label.name.toLowerCase().startsWith(''.toLowerCase())))
|
||||
.map(label => (
|
||||
<Label key={label.id}>
|
||||
<CardLabel
|
||||
key={label.id}
|
||||
color={label.labelColor.colorHex}
|
||||
active={currentLabel === label.id}
|
||||
onMouseEnter={() => {
|
||||
setCurrentLabel(label.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, draftFilters => {
|
||||
if (draftFilters.labels.find(l => l.id === label.id)) {
|
||||
draftFilters.labels = draftFilters.labels.filter(l => l.id !== label.id);
|
||||
} else {
|
||||
draftFilters.labels.push({
|
||||
id: label.id,
|
||||
name: label.name ?? '',
|
||||
color: label.labelColor.colorHex,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</CardLabel>
|
||||
</Label>
|
||||
))}
|
||||
</Labels>
|
||||
</Popup>
|
||||
<Popup tab={2} title="By Member">
|
||||
<ActionsList>
|
||||
{members.current &&
|
||||
members.current.map(member => (
|
||||
<FilterMember
|
||||
key={member.id}
|
||||
member={member}
|
||||
showName
|
||||
onCardMemberClick={() => {
|
||||
handleSetFilters(
|
||||
produce(currentFilters, draftFilters => {
|
||||
if (draftFilters.members.find(m => m.id === member.id)) {
|
||||
draftFilters.members = draftFilters.members.filter(m => m.id !== member.id);
|
||||
} else {
|
||||
draftFilters.members.push({ id: member.id, username: member.username ?? '' });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
<Popup tab={3} title="By Due Date">
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TODAY, 'Today')}>
|
||||
<ActionTitle>Today</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}>
|
||||
<ActionTitle>This week</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NEXT_WEEK, 'Due next week')}>
|
||||
<ActionTitle>Next week</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.OVERDUE, 'Overdue')}>
|
||||
<ActionTitle>Overdue</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItemLine />
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TOMORROW, 'In the next day')}>
|
||||
<ActionTitle>In the next day</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.ONE_WEEK, 'In the next week')}>
|
||||
<ActionTitle>In the next week</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TWO_WEEKS, 'In the next two weeks')}>
|
||||
<ActionTitle>In the next two weeks</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THREE_WEEKS, 'In the next three weeks')}>
|
||||
<ActionTitle>In the next three weeks</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NO_DUE_DATE, 'Has no due date')}>
|
||||
<ActionTitle>Has no due date</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterMeta;
|
149
frontend/src/Projects/Project/Board/FilterStatus.tsx
Normal file
149
frontend/src/Projects/Project/Board/FilterStatus.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
import { TaskStatusFilter, TaskStatus, TaskSince } from 'shared/components/Lists';
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenuContainer = styled.div`
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: -4px;
|
||||
padding-left: 2px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
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: rgb(115, 103, 240);
|
||||
}
|
||||
&:hover ${ActionExtraMenuContainer} {
|
||||
visibility: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenu = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const ActionExtraMenuItem = 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: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
const ActionExtraMenuSeparator = styled.li`
|
||||
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
const ActiveIcon = styled(Checkmark)`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type FilterStatusProps = {
|
||||
filter: TaskStatusFilter;
|
||||
onChangeTaskStatusFilter: (filter: TaskStatusFilter) => void;
|
||||
};
|
||||
|
||||
const FilterStatus: React.FC<FilterStatusProps> = ({ filter, onChangeTaskStatusFilter }) => {
|
||||
const [currentFilter, setFilter] = useState(filter);
|
||||
const handleFilterChange = (f: TaskStatusFilter) => {
|
||||
setFilter(f);
|
||||
onChangeTaskStatusFilter(f);
|
||||
};
|
||||
const handleCompleteClick = (s: TaskSince) => {
|
||||
handleFilterChange({ status: TaskStatus.COMPLETE, since: s });
|
||||
};
|
||||
return (
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleFilterChange({ status: TaskStatus.INCOMPLETE, since: TaskSince.ALL })}>
|
||||
{currentFilter.status === TaskStatus.INCOMPLETE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Incomplete Tasks</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem>
|
||||
{currentFilter.status === TaskStatus.COMPLETE && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Compelete Tasks</ActionTitle>
|
||||
<ActionExtraMenuContainer>
|
||||
<ActionExtraMenu>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.ALL)}>
|
||||
{currentFilter.since === TaskSince.ALL && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>All completed tasks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuSeparator>Marked complete since</ActionExtraMenuSeparator>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.TODAY)}>
|
||||
{currentFilter.since === TaskSince.TODAY && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Today</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.YESTERDAY)}>
|
||||
{currentFilter.since === TaskSince.YESTERDAY && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>Yesterday</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.ONE_WEEK)}>
|
||||
{currentFilter.since === TaskSince.ONE_WEEK && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>1 week</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.TWO_WEEKS)}>
|
||||
{currentFilter.since === TaskSince.TWO_WEEKS && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>2 weeks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.THREE_WEEKS)}>
|
||||
{currentFilter.since === TaskSince.THREE_WEEKS && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>3 weeks</ActionTitle>
|
||||
</ActionExtraMenuItem>
|
||||
</ActionExtraMenu>
|
||||
</ActionExtraMenuContainer>
|
||||
</ActionItem>
|
||||
<ActionItem onClick={() => handleFilterChange({ status: TaskStatus.ALL, since: TaskSince.ALL })}>
|
||||
{currentFilter.status === TaskStatus.ALL && <ActiveIcon width={12} height={12} />}
|
||||
<ActionTitle>All Tasks</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterStatus;
|
80
frontend/src/Projects/Project/Board/SortPopup.tsx
Normal file
80
frontend/src/Projects/Project/Board/SortPopup.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/components/Lists';
|
||||
|
||||
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: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
const ActionItemSeparator = styled.li`
|
||||
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.25rem;
|
||||
`;
|
||||
|
||||
type SortPopupProps = {
|
||||
sorting: TaskSorting;
|
||||
onChangeTaskSorting: (taskSorting: TaskSorting) => void;
|
||||
};
|
||||
|
||||
const SortPopup: React.FC<SortPopupProps> = ({ sorting, onChangeTaskSorting }) => {
|
||||
const [currentSorting, setSorting] = useState(sorting);
|
||||
const handleSetSorting = (s: TaskSorting) => {
|
||||
setSorting(s);
|
||||
onChangeTaskSorting(s);
|
||||
};
|
||||
return (
|
||||
<ActionsList>
|
||||
<ActionItem onClick={() => handleSetSorting({ type: TaskSortingType.NONE, direction: TaskSortingDirection.ASC })}>
|
||||
<ActionTitle>None</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.DUE_DATE, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
<ActionTitle>Due date</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
<ActionTitle>Members</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
<ActionTitle>Labels</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
onClick={() => handleSetSorting({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
|
||||
>
|
||||
<ActionTitle>Task title</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortPopup;
|
@ -26,13 +26,85 @@ import {
|
||||
import QuickCardEditor from 'shared/components/QuickCardEditor';
|
||||
import ListActions from 'shared/components/ListActions';
|
||||
import MemberManager from 'shared/components/MemberManager';
|
||||
import SimpleLists from 'shared/components/Lists';
|
||||
import SimpleLists, {
|
||||
TaskStatus,
|
||||
TaskSince,
|
||||
TaskStatusFilter,
|
||||
TaskMeta,
|
||||
TaskMetaMatch,
|
||||
TaskMetaFilters,
|
||||
TaskSorting,
|
||||
TaskSortingType,
|
||||
TaskSortingDirection,
|
||||
} from 'shared/components/Lists';
|
||||
import produce from 'immer';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
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 { useCurrentUser } from 'App/context';
|
||||
import FilterStatus from './FilterStatus';
|
||||
import FilterMeta from './FilterMeta';
|
||||
import SortPopup from './SortPopup';
|
||||
|
||||
type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void;
|
||||
|
||||
const renderTaskSortingLabel = (sorting: TaskSorting) => {
|
||||
if (sorting.type === TaskSortingType.TASK_TITLE) {
|
||||
return 'Sort: Card title';
|
||||
}
|
||||
if (sorting.type === TaskSortingType.MEMBERS) {
|
||||
return 'Sort: Members';
|
||||
}
|
||||
if (sorting.type === TaskSortingType.DUE_DATE) {
|
||||
return 'Sort: Due Date';
|
||||
}
|
||||
if (sorting.type === TaskSortingType.LABELS) {
|
||||
return 'Sort: Labels';
|
||||
}
|
||||
return 'Sort';
|
||||
};
|
||||
|
||||
const renderMetaFilters = (filters: TaskMetaFilters, onClose: MetaFilterCloseFn) => {
|
||||
const filterChips = [];
|
||||
if (filters.taskName) {
|
||||
filterChips.push(
|
||||
<Chip
|
||||
key="task-name"
|
||||
label={`Title: ${filters.taskName.name}`}
|
||||
onClose={() => onClose(TaskMeta.TITLE, 'task-name')}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.dueDate) {
|
||||
filterChips.push(
|
||||
<Chip key="due-date" label={filters.dueDate.label} onClose={() => onClose(TaskMeta.DUE_DATE, 'due-date')} />,
|
||||
);
|
||||
}
|
||||
for (const memberFilter of filters.members) {
|
||||
filterChips.push(
|
||||
<Chip
|
||||
key={`member-${memberFilter.id}`}
|
||||
label={`Member: ${memberFilter.username}`}
|
||||
onClose={() => onClose(TaskMeta.MEMBER, memberFilter.id)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
for (const labelFilter of filters.labels) {
|
||||
filterChips.push(
|
||||
<Chip
|
||||
key={`label-${labelFilter.id}`}
|
||||
label={labelFilter.name === '' ? 'Label' : `Label: ${labelFilter.name}`}
|
||||
color={labelFilter.color}
|
||||
onClose={() => onClose(TaskMeta.LABEL, labelFilter.id)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return filterChips;
|
||||
};
|
||||
|
||||
const ProjectBar = styled.div`
|
||||
display: flex;
|
||||
@ -47,7 +119,7 @@ const ProjectActions = styled.div`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ProjectAction = styled.div<{ disabled?: boolean }>`
|
||||
const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -74,6 +146,25 @@ const ProjectActionText = styled.span`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
type ProjectActionProps = {
|
||||
onClick?: (target: React.RefObject<HTMLElement>) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ProjectAction: React.FC<ProjectActionProps> = ({ onClick, disabled = false, children }) => {
|
||||
const $container = useRef<HTMLDivElement>(null);
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick($container);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ProjectActionWrapper ref={$container} onClick={handleClick} disabled={disabled}>
|
||||
{children}
|
||||
</ProjectActionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuickCardEditorState {
|
||||
isOpen: boolean;
|
||||
target: React.RefObject<HTMLElement> | null;
|
||||
@ -99,18 +190,18 @@ export const BoardLoading = () => {
|
||||
<>
|
||||
<ProjectBar>
|
||||
<ProjectActions>
|
||||
<ProjectAction disabled>
|
||||
<ProjectAction>
|
||||
<CheckCircle width={13} height={13} />
|
||||
<ProjectActionText>All Tasks</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Filter width={13} height={13} />
|
||||
<ProjectActionText>Filter</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<ProjectAction>
|
||||
<Sort width={13} height={13} />
|
||||
<ProjectActionText>Sort</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction>
|
||||
<Filter width={13} height={13} />
|
||||
<ProjectActionText>Filter</ProjectActionText>
|
||||
</ProjectAction>
|
||||
</ProjectActions>
|
||||
<ProjectActions>
|
||||
<ProjectAction>
|
||||
@ -132,16 +223,37 @@ export const BoardLoading = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const initTaskStatusFilter: TaskStatusFilter = {
|
||||
status: TaskStatus.ALL,
|
||||
since: TaskSince.ALL,
|
||||
};
|
||||
|
||||
const initTaskMetaFilters: TaskMetaFilters = {
|
||||
match: TaskMetaMatch.MATCH_ANY,
|
||||
dueDate: null,
|
||||
taskName: null,
|
||||
labels: [],
|
||||
members: [],
|
||||
};
|
||||
|
||||
const initTaskSorting: TaskSorting = {
|
||||
type: TaskSortingType.NONE,
|
||||
direction: TaskSortingDirection.ASC,
|
||||
};
|
||||
|
||||
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
|
||||
const [assignTask] = useAssignTaskMutation();
|
||||
const [unassignTask] = useUnassignTaskMutation();
|
||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||
const match = useRouteMatch();
|
||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||
const membersRef = useRef<Array<TaskUser>>([]);
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
||||
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
|
||||
const [taskStatusFilter, setTaskStatusFilter] = useState(initTaskStatusFilter);
|
||||
const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters);
|
||||
const [taskSorting, setTaskSorting] = useState(initTaskSorting);
|
||||
const history = useHistory();
|
||||
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
|
||||
update: (client, deletedTaskGroupData) => {
|
||||
@ -225,6 +337,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
||||
);
|
||||
},
|
||||
});
|
||||
const { user } = useCurrentUser();
|
||||
const [deleteTask] = useDeleteTaskMutation();
|
||||
const [toggleTaskLabel] = useToggleTaskLabelMutation({
|
||||
onCompleted: newTaskLabel => {
|
||||
@ -254,6 +367,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
||||
id: `${Math.round(Math.random() * -1000000)}`,
|
||||
name,
|
||||
complete: false,
|
||||
completedAt: null,
|
||||
taskGroup: {
|
||||
__typename: 'TaskGroup',
|
||||
id: taskGroup.id,
|
||||
@ -290,8 +404,18 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
||||
if (loading) {
|
||||
return <BoardLoading />;
|
||||
}
|
||||
if (data) {
|
||||
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
|
||||
if (filter.status === TaskStatus.COMPLETE) {
|
||||
return 'Complete';
|
||||
}
|
||||
if (filter.status === TaskStatus.INCOMPLETE) {
|
||||
return 'Incomplete';
|
||||
}
|
||||
return 'All Tasks';
|
||||
};
|
||||
if (data && user) {
|
||||
labelsRef.current = data.findProject.labels;
|
||||
membersRef.current = data.findProject.members;
|
||||
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
|
||||
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
|
||||
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null;
|
||||
@ -315,23 +439,84 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
||||
<>
|
||||
<ProjectBar>
|
||||
<ProjectActions>
|
||||
<ProjectAction disabled>
|
||||
<ProjectAction
|
||||
onClick={target => {
|
||||
showPopup(
|
||||
target,
|
||||
<Popup tab={0} title={null}>
|
||||
<FilterStatus
|
||||
filter={taskStatusFilter}
|
||||
onChangeTaskStatusFilter={filter => {
|
||||
setTaskStatusFilter(filter);
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
185,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircle width={13} height={13} />
|
||||
<ProjectActionText>All Tasks</ProjectActionText>
|
||||
<ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<ProjectAction
|
||||
onClick={target => {
|
||||
showPopup(
|
||||
target,
|
||||
<Popup tab={0} title={null}>
|
||||
<SortPopup
|
||||
sorting={taskSorting}
|
||||
onChangeTaskSorting={sorting => {
|
||||
setTaskSorting(sorting);
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
185,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Sort width={13} height={13} />
|
||||
<ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction
|
||||
onClick={target => {
|
||||
showPopup(
|
||||
target,
|
||||
<FilterMeta
|
||||
filters={taskMetaFilters}
|
||||
onChangeTaskMetaFilter={filter => {
|
||||
setTaskMetaFilters(filter);
|
||||
}}
|
||||
userID={user?.id}
|
||||
labels={labelsRef}
|
||||
members={membersRef}
|
||||
/>,
|
||||
200,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Filter width={13} height={13} />
|
||||
<ProjectActionText>Filter</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Sort width={13} height={13} />
|
||||
<ProjectActionText>Sort</ProjectActionText>
|
||||
</ProjectAction>
|
||||
{renderMetaFilters(taskMetaFilters, (meta, id) => {
|
||||
setTaskMetaFilters(
|
||||
produce(taskMetaFilters, draftFilters => {
|
||||
if (meta === TaskMeta.MEMBER) {
|
||||
draftFilters.members = draftFilters.members.filter(m => m.id !== id);
|
||||
} else if (meta === TaskMeta.LABEL) {
|
||||
draftFilters.labels = draftFilters.labels.filter(m => m.id !== id);
|
||||
} else if (meta === TaskMeta.TITLE) {
|
||||
draftFilters.taskName = null;
|
||||
} else if (meta === TaskMeta.DUE_DATE) {
|
||||
draftFilters.dueDate = null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
})}
|
||||
</ProjectActions>
|
||||
<ProjectActions>
|
||||
<ProjectAction
|
||||
ref={$labelsRef}
|
||||
onClick={() => {
|
||||
onClick={$labelsRef => {
|
||||
showPopup(
|
||||
$labelsRef,
|
||||
<LabelManagerEditor
|
||||
@ -404,6 +589,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
||||
});
|
||||
}}
|
||||
taskGroups={data.findProject.taskGroups}
|
||||
taskStatusFilter={taskStatusFilter}
|
||||
taskMetaFilters={taskMetaFilters}
|
||||
taskSorting={taskSorting}
|
||||
onCreateTask={onCreateTask}
|
||||
onCreateTaskGroup={onCreateList}
|
||||
onCardMemberClick={($targetRef, _taskID, memberID) => {
|
||||
|
@ -8,6 +8,7 @@ import { HttpLink } from 'apollo-link-http';
|
||||
import { onError } from 'apollo-link-error';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
||||
import moment from 'moment';
|
||||
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
||||
import cache from './App/cache';
|
||||
import App from './App';
|
||||
@ -15,6 +16,13 @@ import App from './App';
|
||||
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
||||
enableMapSet();
|
||||
|
||||
moment.updateLocale('en', {
|
||||
week: {
|
||||
dow: 1, // First day of week is Monday
|
||||
doy: 7, // First week of year must contain 1 January (7 + 1 - 1)
|
||||
},
|
||||
});
|
||||
|
||||
let forward$;
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: any = [];
|
||||
|
@ -430,6 +430,7 @@ const TabNavItem = styled.li`
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNavItemButton = styled.button<{ active: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@ -450,6 +451,10 @@ const TabNavItemButton = styled.button<{ active: boolean }>`
|
||||
fill: rgba(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
const TabItemUser = styled(User)<{ active: boolean }>`
|
||||
fill: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}
|
||||
stroke: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}
|
||||
`;
|
||||
|
||||
const TabNavItemSpan = styled.span`
|
||||
text-align: left;
|
||||
@ -512,7 +517,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||
}}
|
||||
>
|
||||
<TabNavItemButton active={active}>
|
||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
||||
<TabItemUser width={14} height={14} active={active} />
|
||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||
</TabNavItemButton>
|
||||
</TabNavItem>
|
||||
|
71
frontend/src/shared/components/Chip/index.tsx
Normal file
71
frontend/src/shared/components/Chip/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Cross } from 'shared/icons';
|
||||
|
||||
const LabelText = styled.span`
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
|
||||
const Container = styled.div<{ color?: string }>`
|
||||
margin: 0.75rem;
|
||||
min-height: 26px;
|
||||
min-width: 26px;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
${props =>
|
||||
props.color
|
||||
? css`
|
||||
background: ${props.color};
|
||||
& ${LabelText} {
|
||||
color: rgba(${props.theme.colors.text.secondary});
|
||||
}
|
||||
`
|
||||
: css`
|
||||
background: rgba(${props.theme.colors.bg.primary});
|
||||
`}
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
`;
|
||||
|
||||
type ChipProps = {
|
||||
label: string;
|
||||
onClose?: () => void;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const Chip: React.FC<ChipProps> = ({ label, onClose, color }) => {
|
||||
return (
|
||||
<Container color={color}>
|
||||
<LabelText>{label}</LabelText>
|
||||
{onClose && (
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={12} height={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chip;
|
@ -35,7 +35,7 @@ export const Default = () => {
|
||||
<Wrapper>
|
||||
<Input label="Label placeholder" />
|
||||
<Input width="100%" placeholder="Placeholder" />
|
||||
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
|
||||
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
|
||||
</Wrapper>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
|
@ -18,7 +18,7 @@ const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top, onLogout, onClos
|
||||
<Container ref={$containerRef} left={left} top={top}>
|
||||
<Wrapper>
|
||||
<ActionItem onClick={onAdminConsole}>
|
||||
<User size={16} color="#c2c6dc" />
|
||||
<User width={16} height={16} />
|
||||
<ActionTitle>Profile</ActionTitle>
|
||||
</ActionItem>
|
||||
<Separator />
|
||||
@ -54,7 +54,7 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ showAdminConsole, onAdminCons
|
||||
</>
|
||||
)}
|
||||
<ActionItem onClick={onProfile}>
|
||||
<User size={16} color="#c2c6dc" />
|
||||
<User width={16} height={16} />
|
||||
<ActionTitle>Profile</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionsList>
|
||||
|
@ -35,7 +35,7 @@ export const Default = () => {
|
||||
<Wrapper>
|
||||
<Input label="Label placeholder" />
|
||||
<Input width="100%" placeholder="Placeholder" />
|
||||
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
|
||||
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
|
||||
</Wrapper>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
|
@ -13,6 +13,249 @@ import {
|
||||
import moment from 'moment';
|
||||
|
||||
import { Container, BoardContainer, BoardWrapper } from './Styles';
|
||||
import shouldMetaFilter from './metaFilter';
|
||||
|
||||
export enum TaskMeta {
|
||||
NONE,
|
||||
TITLE,
|
||||
MEMBER,
|
||||
LABEL,
|
||||
DUE_DATE,
|
||||
}
|
||||
|
||||
export enum TaskMetaMatch {
|
||||
MATCH_ANY,
|
||||
MATCH_ALL,
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
ALL,
|
||||
COMPLETE,
|
||||
INCOMPLETE,
|
||||
}
|
||||
|
||||
export enum TaskSince {
|
||||
ALL,
|
||||
TODAY,
|
||||
YESTERDAY,
|
||||
ONE_WEEK,
|
||||
TWO_WEEKS,
|
||||
THREE_WEEKS,
|
||||
}
|
||||
|
||||
export type TaskStatusFilter = {
|
||||
status: TaskStatus;
|
||||
since: TaskSince;
|
||||
};
|
||||
|
||||
export interface TaskMetaFilterName {
|
||||
meta: TaskMeta;
|
||||
value?: string | moment.Moment | null;
|
||||
id?: string | null;
|
||||
}
|
||||
|
||||
export type TaskNameMetaFilter = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export enum DueDateFilterType {
|
||||
TODAY,
|
||||
TOMORROW,
|
||||
THIS_WEEK,
|
||||
NEXT_WEEK,
|
||||
ONE_WEEK,
|
||||
TWO_WEEKS,
|
||||
THREE_WEEKS,
|
||||
OVERDUE,
|
||||
NO_DUE_DATE,
|
||||
}
|
||||
|
||||
export type DueDateMetaFilter = {
|
||||
type: DueDateFilterType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type MemberMetaFilter = {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type LabelMetaFilter = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type TaskMetaFilters = {
|
||||
match: TaskMetaMatch;
|
||||
dueDate: DueDateMetaFilter | null;
|
||||
taskName: TaskNameMetaFilter | null;
|
||||
members: Array<MemberMetaFilter>;
|
||||
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;
|
||||
}
|
||||
|
||||
if (filter.status === TaskStatus.INCOMPLETE && task.complete === false) {
|
||||
return true;
|
||||
}
|
||||
if (filter.status === TaskStatus.COMPLETE && task.completedAt && task.complete === true) {
|
||||
const completedAt = moment(task.completedAt);
|
||||
const REFERENCE = moment(); // fixed just for testing, use moment();
|
||||
switch (filter.since) {
|
||||
case TaskSince.TODAY:
|
||||
const TODAY = REFERENCE.clone().startOf('day');
|
||||
return completedAt.isSame(TODAY, 'd');
|
||||
case TaskSince.YESTERDAY:
|
||||
const YESTERDAY = REFERENCE.clone()
|
||||
.subtract(1, 'days')
|
||||
.startOf('day');
|
||||
return completedAt.isSameOrAfter(YESTERDAY, 'd');
|
||||
case TaskSince.ONE_WEEK:
|
||||
const ONE_WEEK = REFERENCE.clone()
|
||||
.subtract(7, 'days')
|
||||
.startOf('day');
|
||||
return completedAt.isSameOrAfter(ONE_WEEK, 'd');
|
||||
case TaskSince.TWO_WEEKS:
|
||||
const TWO_WEEKS = REFERENCE.clone()
|
||||
.subtract(14, 'days')
|
||||
.startOf('day');
|
||||
return completedAt.isSameOrAfter(TWO_WEEKS, 'd');
|
||||
case TaskSince.THREE_WEEKS:
|
||||
const THREE_WEEKS = REFERENCE.clone()
|
||||
.subtract(21, 'days')
|
||||
.startOf('day');
|
||||
return completedAt.isSameOrAfter(THREE_WEEKS, 'd');
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface SimpleProps {
|
||||
taskGroups: Array<TaskGroup>;
|
||||
@ -28,8 +271,29 @@ interface SimpleProps {
|
||||
onCardMemberClick: OnCardMemberClick;
|
||||
onCardLabelClick: () => void;
|
||||
cardLabelVariant: CardLabelVariant;
|
||||
taskStatusFilter?: TaskStatusFilter;
|
||||
taskMetaFilters?: TaskMetaFilters;
|
||||
taskSorting?: TaskSorting;
|
||||
}
|
||||
|
||||
const initTaskStatusFilter: TaskStatusFilter = {
|
||||
status: TaskStatus.ALL,
|
||||
since: TaskSince.ALL,
|
||||
};
|
||||
|
||||
const initTaskMetaFilters: TaskMetaFilters = {
|
||||
match: TaskMetaMatch.MATCH_ANY,
|
||||
dueDate: null,
|
||||
taskName: null,
|
||||
labels: [],
|
||||
members: [],
|
||||
};
|
||||
|
||||
const initTaskSorting: TaskSorting = {
|
||||
type: TaskSortingType.NONE,
|
||||
direction: TaskSortingDirection.ASC,
|
||||
};
|
||||
|
||||
const SimpleLists: React.FC<SimpleProps> = ({
|
||||
taskGroups,
|
||||
onTaskDrop,
|
||||
@ -43,6 +307,9 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
||||
cardLabelVariant,
|
||||
onExtraMenuOpen,
|
||||
onCardMemberClick,
|
||||
taskStatusFilter = initTaskStatusFilter,
|
||||
taskMetaFilters = initTaskMetaFilters,
|
||||
taskSorting = initTaskSorting,
|
||||
}) => {
|
||||
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
|
||||
if (typeof destination === 'undefined') return;
|
||||
@ -164,10 +431,18 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
||||
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
|
||||
{taskGroup.tasks
|
||||
.slice()
|
||||
.filter(t => shouldStatusFilter(t, taskStatusFilter))
|
||||
.filter(t => shouldMetaFilter(t, taskMetaFilters))
|
||||
.sort((a: any, b: any) => a.position - b.position)
|
||||
.sort((a: any, b: any) => sortTasks(a, b, taskSorting))
|
||||
.map((task: Task, taskIndex: any) => {
|
||||
return (
|
||||
<Draggable key={task.id} draggableId={task.id} index={taskIndex}>
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id}
|
||||
index={taskIndex}
|
||||
isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
|
||||
>
|
||||
{taskProvided => {
|
||||
return (
|
||||
<Card
|
||||
|
132
frontend/src/shared/components/Lists/metaFilter.ts
Normal file
132
frontend/src/shared/components/Lists/metaFilter.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { TaskMetaFilters, DueDateFilterType } from 'shared/components/Lists';
|
||||
import moment from 'moment';
|
||||
|
||||
enum ShouldFilter {
|
||||
NO_FILTER,
|
||||
VALID,
|
||||
NOT_VALID,
|
||||
}
|
||||
|
||||
function shouldFilter(cond: boolean) {
|
||||
return cond ? ShouldFilter.VALID : ShouldFilter.NOT_VALID;
|
||||
}
|
||||
|
||||
export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
|
||||
let isFiltered = ShouldFilter.NO_FILTER;
|
||||
if (filters.taskName) {
|
||||
isFiltered = shouldFilter(task.name.toLowerCase().startsWith(filters.taskName.name.toLowerCase()));
|
||||
}
|
||||
if (filters.dueDate) {
|
||||
if (isFiltered === ShouldFilter.NO_FILTER) {
|
||||
isFiltered = ShouldFilter.NOT_VALID;
|
||||
}
|
||||
if (filters.dueDate.type === DueDateFilterType.NO_DUE_DATE) {
|
||||
isFiltered = shouldFilter(!(task.dueDate && task.dueDate !== null));
|
||||
}
|
||||
if (task.dueDate) {
|
||||
const taskDueDate = moment(task.dueDate);
|
||||
const today = moment();
|
||||
let start;
|
||||
let end;
|
||||
switch (filters.dueDate.type) {
|
||||
case DueDateFilterType.OVERDUE:
|
||||
isFiltered = shouldFilter(taskDueDate.isBefore(today));
|
||||
break;
|
||||
case DueDateFilterType.TODAY:
|
||||
isFiltered = shouldFilter(taskDueDate.isSame(today, 'day'));
|
||||
break;
|
||||
case DueDateFilterType.TOMORROW:
|
||||
isFiltered = shouldFilter(
|
||||
taskDueDate.isBefore(
|
||||
today
|
||||
.clone()
|
||||
.add(1, 'days')
|
||||
.endOf('day'),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case DueDateFilterType.THIS_WEEK:
|
||||
start = today
|
||||
.clone()
|
||||
.weekday(0)
|
||||
.startOf('day');
|
||||
end = today
|
||||
.clone()
|
||||
.weekday(6)
|
||||
.endOf('day');
|
||||
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
|
||||
break;
|
||||
case DueDateFilterType.NEXT_WEEK:
|
||||
start = today
|
||||
.clone()
|
||||
.weekday(0)
|
||||
.add(7, 'days')
|
||||
.startOf('day');
|
||||
end = today
|
||||
.clone()
|
||||
.weekday(6)
|
||||
.add(7, 'days')
|
||||
.endOf('day');
|
||||
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
|
||||
break;
|
||||
case DueDateFilterType.ONE_WEEK:
|
||||
start = today.clone().startOf('day');
|
||||
end = today
|
||||
.clone()
|
||||
.add(7, 'days')
|
||||
.endOf('day');
|
||||
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
|
||||
break;
|
||||
case DueDateFilterType.TWO_WEEKS:
|
||||
start = today.clone().startOf('day');
|
||||
end = today
|
||||
.clone()
|
||||
.add(14, 'days')
|
||||
.endOf('day');
|
||||
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
|
||||
break;
|
||||
case DueDateFilterType.THREE_WEEKS:
|
||||
start = today.clone().startOf('day');
|
||||
end = today
|
||||
.clone()
|
||||
.add(21, 'days')
|
||||
.endOf('day');
|
||||
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
|
||||
break;
|
||||
default:
|
||||
isFiltered = ShouldFilter.NOT_VALID;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filters.members.length !== 0) {
|
||||
if (isFiltered === ShouldFilter.NO_FILTER) {
|
||||
isFiltered = ShouldFilter.NOT_VALID;
|
||||
}
|
||||
for (const member of filters.members) {
|
||||
if (task.assigned) {
|
||||
if (task.assigned.findIndex(m => m.id === member.id) !== -1) {
|
||||
isFiltered = ShouldFilter.VALID;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filters.labels.length !== 0) {
|
||||
if (isFiltered === ShouldFilter.NO_FILTER) {
|
||||
isFiltered = ShouldFilter.NOT_VALID;
|
||||
}
|
||||
for (const label of filters.labels) {
|
||||
if (task.labels) {
|
||||
if (task.labels.findIndex(m => m.projectLabel.id === label.id) !== -1) {
|
||||
isFiltered = ShouldFilter.VALID;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isFiltered === ShouldFilter.NO_FILTER) {
|
||||
return true;
|
||||
}
|
||||
if (isFiltered === ShouldFilter.VALID) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
@ -53,7 +53,7 @@ const Login = ({ onSubmit }: LoginProps) => {
|
||||
ref={register({ required: 'Username is required' })}
|
||||
/>
|
||||
<FormIcon>
|
||||
<User color="#c2c6dc" size={20} />
|
||||
<User width={20} height={20} />
|
||||
</FormIcon>
|
||||
</FormLabel>
|
||||
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||
|
@ -55,7 +55,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
|
||||
ref={register({ required: 'Full name is required' })}
|
||||
/>
|
||||
<FormIcon>
|
||||
<User color="#c2c6dc" size={20} />
|
||||
<User width={20} height={20} />
|
||||
</FormIcon>
|
||||
</FormLabel>
|
||||
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||
@ -68,7 +68,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
|
||||
ref={register({ required: 'Username is required' })}
|
||||
/>
|
||||
<FormIcon>
|
||||
<User color="#c2c6dc" size={20} />
|
||||
<User width={20} height={20} />
|
||||
</FormIcon>
|
||||
</FormLabel>
|
||||
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||
@ -84,7 +84,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
|
||||
})}
|
||||
/>
|
||||
<FormIcon>
|
||||
<User color="#c2c6dc" size={20} />
|
||||
<User width={20} height={20} />
|
||||
</FormIcon>
|
||||
</FormLabel>
|
||||
{errors.email && <FormError>{errors.email.message}</FormError>}
|
||||
@ -103,7 +103,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
|
||||
})}
|
||||
/>
|
||||
<FormIcon>
|
||||
<User color="#c2c6dc" size={20} />
|
||||
<User width={20} height={20} />
|
||||
</FormIcon>
|
||||
</FormLabel>
|
||||
{errors.initials && <FormError>{errors.initials.message}</FormError>}
|
||||
|
@ -218,7 +218,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||
}}
|
||||
>
|
||||
<TabNavItemButton active={active}>
|
||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
||||
<User width={20} height={20} />
|
||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||
</TabNavItemButton>
|
||||
</TabNavItem>
|
||||
|
@ -162,6 +162,7 @@ export type Task = {
|
||||
description?: Maybe<Scalars['String']>;
|
||||
dueDate?: Maybe<Scalars['Time']>;
|
||||
complete: Scalars['Boolean'];
|
||||
completedAt?: Maybe<Scalars['Time']>;
|
||||
assigned: Array<Member>;
|
||||
labels: Array<TaskLabel>;
|
||||
checklists: Array<TaskChecklist>;
|
||||
@ -1189,7 +1190,7 @@ export type FindTaskQuery = (
|
||||
|
||||
export type TaskFieldsFragment = (
|
||||
{ __typename?: 'Task' }
|
||||
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'complete' | 'position'>
|
||||
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'complete' | 'completedAt' | 'position'>
|
||||
& { badges: (
|
||||
{ __typename?: 'TaskBadges' }
|
||||
& { checklist?: Maybe<(
|
||||
@ -2013,6 +2014,7 @@ export const TaskFieldsFragmentDoc = gql`
|
||||
description
|
||||
dueDate
|
||||
complete
|
||||
completedAt
|
||||
position
|
||||
badges {
|
||||
checklist {
|
||||
|
@ -7,6 +7,7 @@ const TASK_FRAGMENT = gql`
|
||||
description
|
||||
dueDate
|
||||
complete
|
||||
completedAt
|
||||
position
|
||||
badges {
|
||||
checklist {
|
||||
|
12
frontend/src/shared/icons/Calendar.tsx
Normal file
12
frontend/src/shared/icons/Calendar.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from './Icon';
|
||||
|
||||
const Calender: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
||||
return (
|
||||
<Icon width={width} height={height} className={className} viewBox="0 0 448 512">
|
||||
<path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H160V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zm-6 400H54c-3.3 0-6-2.7-6-6V160h352v298c0 3.3-2.7 6-6 6z" />
|
||||
</Icon>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calender;
|
@ -1,21 +1,12 @@
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from './Icon';
|
||||
|
||||
type Props = {
|
||||
size: number | string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const User = ({ size, color }: Props) => {
|
||||
const User: React.FC<IconProps> = ({ width = '16px', height = '16px', className, onClick }) => {
|
||||
return (
|
||||
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
|
||||
<path d="M9 11.041v-0.825c1.102-0.621 2-2.168 2-3.716 0-2.485 0-4.5-3-4.5s-3 2.015-3 4.5c0 1.548 0.898 3.095 2 3.716v0.825c-3.392 0.277-6 1.944-6 3.959h14c0-2.015-2.608-3.682-6-3.959z" />
|
||||
</svg>
|
||||
<Icon onClick={onClick} width={width} height={height} className={className} viewBox="0 0 448 512">
|
||||
<path d="M313.6 304c-28.7 0-42.5 16-89.6 16-47.1 0-60.8-16-89.6-16C60.2 304 0 364.2 0 438.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-25.6c0-74.2-60.2-134.4-134.4-134.4zM400 464H48v-25.6c0-47.6 38.8-86.4 86.4-86.4 14.6 0 38.3 16 89.6 16 51.7 0 74.9-16 89.6-16 47.6 0 86.4 38.8 86.4 86.4V464zM224 288c79.5 0 144-64.5 144-144S303.5 0 224 0 80 64.5 80 144s64.5 144 144 144zm0-240c52.9 0 96 43.1 96 96s-43.1 96-96 96-96-43.1-96-96 43.1-96 96-96z" />
|
||||
</Icon>
|
||||
);
|
||||
};
|
||||
|
||||
User.defaultProps = {
|
||||
size: 16,
|
||||
color: '#000',
|
||||
};
|
||||
|
||||
export default User;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Cross from './Cross';
|
||||
import Cog from './Cog';
|
||||
import Calendar from './Calendar';
|
||||
import Sort from './Sort';
|
||||
import Filter from './Filter';
|
||||
import DoubleChevronUp from './DoubleChevronUp';
|
||||
@ -72,4 +73,5 @@ export {
|
||||
UserPlus,
|
||||
Crown,
|
||||
ToggleOn,
|
||||
Calendar,
|
||||
};
|
||||
|
1
frontend/src/types.d.ts
vendored
1
frontend/src/types.d.ts
vendored
@ -64,6 +64,7 @@ type Task = {
|
||||
position: number;
|
||||
dueDate?: string;
|
||||
complete?: boolean;
|
||||
completedAt?: string | null;
|
||||
labels: TaskLabel[];
|
||||
description?: string | null;
|
||||
assigned?: Array<TaskUser>;
|
||||
|
@ -12,6 +12,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"downlevelIteration": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
|
@ -72,6 +72,7 @@ type Task struct {
|
||||
Description sql.NullString `json:"description"`
|
||||
DueDate sql.NullTime `json:"due_date"`
|
||||
Complete bool `json:"complete"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
}
|
||||
|
||||
type TaskAssigned struct {
|
||||
|
@ -30,7 +30,7 @@ DELETE FROM task where task_group_id = $1;
|
||||
UPDATE task SET due_date = $2 WHERE task_id = $1 RETURNING *;
|
||||
|
||||
-- name: SetTaskComplete :one
|
||||
UPDATE task SET complete = $2 WHERE task_id = $1 RETURNING *;
|
||||
UPDATE task SET complete = $2, completed_at = $3 WHERE task_id = $1 RETURNING *;
|
||||
|
||||
-- name: GetProjectIDForTask :one
|
||||
SELECT project_id FROM task
|
||||
|
@ -13,7 +13,7 @@ import (
|
||||
|
||||
const createTask = `-- name: CreateTask :one
|
||||
INSERT INTO task (task_group_id, created_at, name, position)
|
||||
VALUES($1, $2, $3, $4) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete
|
||||
VALUES($1, $2, $3, $4) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
|
||||
`
|
||||
|
||||
type CreateTaskParams struct {
|
||||
@ -40,6 +40,7 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, e
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -66,7 +67,7 @@ func (q *Queries) DeleteTasksByTaskGroupID(ctx context.Context, taskGroupID uuid
|
||||
}
|
||||
|
||||
const getAllTasks = `-- name: GetAllTasks :many
|
||||
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete FROM task
|
||||
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at FROM task
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
|
||||
@ -87,6 +88,7 @@ func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -115,7 +117,7 @@ func (q *Queries) GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uu
|
||||
}
|
||||
|
||||
const getTaskByID = `-- name: GetTaskByID :one
|
||||
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete FROM task WHERE task_id = $1
|
||||
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at FROM task WHERE task_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error) {
|
||||
@ -130,12 +132,13 @@ func (q *Queries) GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, erro
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTasksForTaskGroupID = `-- name: GetTasksForTaskGroupID :many
|
||||
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete FROM task WHERE task_group_id = $1
|
||||
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at FROM task WHERE task_group_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error) {
|
||||
@ -156,6 +159,7 @@ func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.U
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -171,16 +175,17 @@ func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.U
|
||||
}
|
||||
|
||||
const setTaskComplete = `-- name: SetTaskComplete :one
|
||||
UPDATE task SET complete = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete
|
||||
UPDATE task SET complete = $2, completed_at = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
|
||||
`
|
||||
|
||||
type SetTaskCompleteParams struct {
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
Complete bool `json:"complete"`
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
Complete bool `json:"complete"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams) (Task, error) {
|
||||
row := q.db.QueryRowContext(ctx, setTaskComplete, arg.TaskID, arg.Complete)
|
||||
row := q.db.QueryRowContext(ctx, setTaskComplete, arg.TaskID, arg.Complete, arg.CompletedAt)
|
||||
var i Task
|
||||
err := row.Scan(
|
||||
&i.TaskID,
|
||||
@ -191,12 +196,13 @@ func (q *Queries) SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type UpdateTaskDescriptionParams struct {
|
||||
@ -216,12 +222,13 @@ func (q *Queries) UpdateTaskDescription(ctx context.Context, arg UpdateTaskDescr
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateTaskDueDate = `-- name: UpdateTaskDueDate :one
|
||||
UPDATE task SET due_date = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete
|
||||
UPDATE task SET due_date = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
|
||||
`
|
||||
|
||||
type UpdateTaskDueDateParams struct {
|
||||
@ -241,12 +248,13 @@ func (q *Queries) UpdateTaskDueDate(ctx context.Context, arg UpdateTaskDueDatePa
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateTaskLocation = `-- name: UpdateTaskLocation :one
|
||||
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete
|
||||
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
|
||||
`
|
||||
|
||||
type UpdateTaskLocationParams struct {
|
||||
@ -267,12 +275,13 @@ func (q *Queries) UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocation
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateTaskName = `-- name: UpdateTaskName :one
|
||||
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete
|
||||
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
|
||||
`
|
||||
|
||||
type UpdateTaskNameParams struct {
|
||||
@ -292,6 +301,7 @@ func (q *Queries) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams)
|
||||
&i.Description,
|
||||
&i.DueDate,
|
||||
&i.Complete,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -129,6 +129,7 @@ type Task {
|
||||
description: String
|
||||
dueDate: Time
|
||||
complete: Boolean!
|
||||
completedAt: Time
|
||||
assigned: [Member!]!
|
||||
labels: [TaskLabel!]!
|
||||
checklists: [TaskChecklist!]!
|
||||
@ -256,7 +257,6 @@ type DeleteProjectPayload {
|
||||
project: Project!
|
||||
}
|
||||
|
||||
|
||||
extend type Mutation {
|
||||
createProjectLabel(input: NewProjectLabel!):
|
||||
ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
@ -338,17 +338,26 @@ type UpdateProjectMemberRolePayload {
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createTask(input: NewTask!): Task!
|
||||
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload!
|
||||
createTask(input: NewTask!):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
deleteTask(input: DeleteTaskInput!):
|
||||
DeleteTaskPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
|
||||
updateTaskDescription(input: UpdateTaskDescriptionInput!): Task!
|
||||
updateTaskLocation(input: NewTaskLocation!): UpdateTaskLocationPayload!
|
||||
updateTaskName(input: UpdateTaskName!): Task!
|
||||
setTaskComplete(input: SetTaskComplete!): Task!
|
||||
updateTaskDueDate(input: UpdateTaskDueDate!): Task!
|
||||
updateTaskDescription(input: UpdateTaskDescriptionInput!):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskLocation(input: NewTaskLocation!):
|
||||
UpdateTaskLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskName(input: UpdateTaskName!):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
setTaskComplete(input: SetTaskComplete!):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskDueDate(input: UpdateTaskDueDate!):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
|
||||
assignTask(input: AssignTaskInput): Task!
|
||||
unassignTask(input: UnassignTaskInput): Task!
|
||||
assignTask(input: AssignTaskInput):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
unassignTask(input: UnassignTaskInput):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
}
|
||||
|
||||
input NewTask {
|
||||
@ -407,16 +416,25 @@ input UpdateTaskName {
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createTaskChecklist(input: CreateTaskChecklist!): TaskChecklist!
|
||||
deleteTaskChecklist(input: DeleteTaskChecklist!): DeleteTaskChecklistPayload!
|
||||
updateTaskChecklistName(input: UpdateTaskChecklistName!): TaskChecklist!
|
||||
createTaskChecklistItem(input: CreateTaskChecklistItem!): TaskChecklistItem!
|
||||
updateTaskChecklistItemName(input: UpdateTaskChecklistItemName!): TaskChecklistItem!
|
||||
setTaskChecklistItemComplete(input: SetTaskChecklistItemComplete!): TaskChecklistItem!
|
||||
deleteTaskChecklistItem(input: DeleteTaskChecklistItem!): DeleteTaskChecklistItemPayload!
|
||||
createTaskChecklist(input: CreateTaskChecklist!):
|
||||
TaskChecklist! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
deleteTaskChecklist(input: DeleteTaskChecklist!):
|
||||
DeleteTaskChecklistPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskChecklistName(input: UpdateTaskChecklistName!):
|
||||
TaskChecklist! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
createTaskChecklistItem(input: CreateTaskChecklistItem!):
|
||||
TaskChecklistItem! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskChecklistItemName(input: UpdateTaskChecklistItemName!):
|
||||
TaskChecklistItem! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
setTaskChecklistItemComplete(input: SetTaskChecklistItemComplete!):
|
||||
TaskChecklistItem! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
deleteTaskChecklistItem(input: DeleteTaskChecklistItem!):
|
||||
DeleteTaskChecklistItemPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskChecklistLocation(input: UpdateTaskChecklistLocation!):
|
||||
UpdateTaskChecklistLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskChecklistItemLocation(input: UpdateTaskChecklistItemLocation!):
|
||||
UpdateTaskChecklistItemLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
|
||||
updateTaskChecklistLocation(input: UpdateTaskChecklistLocation!): UpdateTaskChecklistLocationPayload!
|
||||
updateTaskChecklistItemLocation(input: UpdateTaskChecklistItemLocation!): UpdateTaskChecklistItemLocationPayload!
|
||||
}
|
||||
|
||||
input UpdateTaskChecklistItemLocation {
|
||||
@ -484,10 +502,14 @@ type DeleteTaskChecklistPayload {
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createTaskGroup(input: NewTaskGroup!): TaskGroup!
|
||||
updateTaskGroupLocation(input: NewTaskGroupLocation!): TaskGroup!
|
||||
updateTaskGroupName(input: UpdateTaskGroupName!): TaskGroup!
|
||||
deleteTaskGroup(input: DeleteTaskGroupInput!): DeleteTaskGroupPayload!
|
||||
createTaskGroup(input: NewTaskGroup!):
|
||||
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskGroupLocation(input: NewTaskGroupLocation!):
|
||||
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
updateTaskGroupName(input: UpdateTaskGroupName!):
|
||||
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
deleteTaskGroup(input: DeleteTaskGroupInput!):
|
||||
DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
}
|
||||
|
||||
input NewTaskGroupLocation {
|
||||
@ -534,9 +556,13 @@ type ToggleTaskLabelPayload {
|
||||
task: Task!
|
||||
}
|
||||
extend type Mutation {
|
||||
addTaskLabel(input: AddTaskLabelInput): Task!
|
||||
removeTaskLabel(input: RemoveTaskLabelInput): Task!
|
||||
toggleTaskLabel(input: ToggleTaskLabelInput!): ToggleTaskLabelPayload!
|
||||
addTaskLabel(input: AddTaskLabelInput):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
removeTaskLabel(input: RemoveTaskLabelInput):
|
||||
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
toggleTaskLabel(input: ToggleTaskLabelInput!):
|
||||
ToggleTaskLabelPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
|
||||
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
@ -562,10 +588,12 @@ type DeleteTeamPayload {
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createTeamMember(input: CreateTeamMember!): CreateTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
|
||||
createTeamMember(input: CreateTeamMember!):
|
||||
CreateTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
|
||||
updateTeamMemberRole(input: UpdateTeamMemberRole!):
|
||||
UpdateTeamMemberRolePayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
|
||||
deleteTeamMember(input: DeleteTeamMember!): DeleteTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
|
||||
deleteTeamMember(input: DeleteTeamMember!):
|
||||
DeleteTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
|
||||
|
||||
}
|
||||
|
||||
|
@ -235,7 +235,8 @@ func (r *mutationResolver) UpdateTaskName(ctx context.Context, input UpdateTaskN
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SetTaskComplete(ctx context.Context, input SetTaskComplete) (*db.Task, error) {
|
||||
task, err := r.Repository.SetTaskComplete(ctx, db.SetTaskCompleteParams{TaskID: input.TaskID, Complete: input.Complete})
|
||||
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 {
|
||||
return &db.Task{}, err
|
||||
}
|
||||
@ -1041,6 +1042,13 @@ func (r *taskResolver) DueDate(ctx context.Context, obj *db.Task) (*time.Time, e
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *taskResolver) CompletedAt(ctx context.Context, obj *db.Task) (*time.Time, error) {
|
||||
if obj.CompletedAt.Valid {
|
||||
return &obj.CompletedAt.Time, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *taskResolver) Assigned(ctx context.Context, obj *db.Task) ([]Member, error) {
|
||||
taskMemberLinks, err := r.Repository.GetAssignedMembersForTask(ctx, obj.TaskID)
|
||||
taskMembers := []Member{}
|
||||
|
@ -129,6 +129,7 @@ type Task {
|
||||
description: String
|
||||
dueDate: Time
|
||||
complete: Boolean!
|
||||
completedAt: Time
|
||||
assigned: [Member!]!
|
||||
labels: [TaskLabel!]!
|
||||
checklists: [TaskChecklist!]!
|
||||
|
4
migrations/0051_add-completed_at-to-task-table.up.sql
Normal file
4
migrations/0051_add-completed_at-to-task-table.up.sql
Normal file
@ -0,0 +1,4 @@
|
||||
ALTER TABLE task ADD COLUMN completed_at timestamptz;
|
||||
UPDATE task as t1 SET completed_at = NOW()
|
||||
FROM task as t2
|
||||
WHERE t1.task_id = t2.task_id AND t1.complete = true;
|
Loading…
Reference in New Issue
Block a user