Compare commits
1 Commits
master
...
feat/task-
Author | SHA1 | Date | |
---|---|---|---|
|
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