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:
committed by
Jordan Knott
parent
47782d6d86
commit
66583bb4fb
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) => {
|
||||
|
Reference in New Issue
Block a user