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
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
|
import List, { ListCards } from 'shared/components/List';
|
|
import Card from 'shared/components/Card';
|
|
import CardComposer from 'shared/components/CardComposer';
|
|
import AddList from 'shared/components/AddList';
|
|
import {
|
|
isPositionChanged,
|
|
getSortedDraggables,
|
|
getNewDraggablePosition,
|
|
getAfterDropDraggableList,
|
|
} from 'shared/utils/draggables';
|
|
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>;
|
|
onTaskDrop: (task: Task, previousTaskGroupID: string) => void;
|
|
onTaskGroupDrop: (taskGroup: TaskGroup) => void;
|
|
|
|
onTaskClick: (task: Task) => void;
|
|
onCreateTask: (taskGroupID: string, name: string) => void;
|
|
onChangeTaskGroupName: (taskGroupID: string, name: string) => void;
|
|
onQuickEditorOpen: ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => void;
|
|
onCreateTaskGroup: (listName: string) => void;
|
|
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
|
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,
|
|
onChangeTaskGroupName,
|
|
onCardLabelClick,
|
|
onTaskGroupDrop,
|
|
onTaskClick,
|
|
onCreateTask,
|
|
onQuickEditorOpen,
|
|
onCreateTaskGroup,
|
|
cardLabelVariant,
|
|
onExtraMenuOpen,
|
|
onCardMemberClick,
|
|
taskStatusFilter = initTaskStatusFilter,
|
|
taskMetaFilters = initTaskMetaFilters,
|
|
taskSorting = initTaskSorting,
|
|
}) => {
|
|
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
|
|
if (typeof destination === 'undefined') return;
|
|
if (!isPositionChanged(source, destination)) return;
|
|
|
|
const isList = type === 'column';
|
|
const isSameList = destination.droppableId === source.droppableId;
|
|
let droppedDraggable: DraggableElement | null = null;
|
|
let beforeDropDraggables: Array<DraggableElement> | null = null;
|
|
|
|
if (isList) {
|
|
const droppedGroup = taskGroups.find(taskGroup => taskGroup.id === draggableId);
|
|
if (droppedGroup) {
|
|
droppedDraggable = {
|
|
id: draggableId,
|
|
position: droppedGroup.position,
|
|
};
|
|
beforeDropDraggables = getSortedDraggables(
|
|
taskGroups.map(taskGroup => {
|
|
return { id: taskGroup.id, position: taskGroup.position };
|
|
}),
|
|
);
|
|
if (droppedDraggable === null || beforeDropDraggables === null) {
|
|
throw new Error('before drop draggables is null');
|
|
}
|
|
const afterDropDraggables = getAfterDropDraggableList(
|
|
beforeDropDraggables,
|
|
droppedDraggable,
|
|
isList,
|
|
isSameList,
|
|
destination,
|
|
);
|
|
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
|
|
onTaskGroupDrop({
|
|
...droppedGroup,
|
|
position: newPosition,
|
|
});
|
|
} else {
|
|
throw new Error('task group can not be found');
|
|
}
|
|
} else {
|
|
const curTaskGroup = taskGroups.findIndex(
|
|
taskGroup => taskGroup.tasks.findIndex(task => task.id === draggableId) !== -1,
|
|
);
|
|
let targetTaskGroup = curTaskGroup;
|
|
if (!isSameList) {
|
|
targetTaskGroup = taskGroups.findIndex(taskGroup => taskGroup.id === destination.droppableId);
|
|
}
|
|
const droppedTask = taskGroups[curTaskGroup].tasks.find(task => task.id === draggableId);
|
|
|
|
if (droppedTask) {
|
|
droppedDraggable = {
|
|
id: draggableId,
|
|
position: droppedTask.position,
|
|
};
|
|
beforeDropDraggables = getSortedDraggables(
|
|
taskGroups[targetTaskGroup].tasks.map(task => {
|
|
return { id: task.id, position: task.position };
|
|
}),
|
|
);
|
|
if (droppedDraggable === null || beforeDropDraggables === null) {
|
|
throw new Error('before drop draggables is null');
|
|
}
|
|
const afterDropDraggables = getAfterDropDraggableList(
|
|
beforeDropDraggables,
|
|
droppedDraggable,
|
|
isList,
|
|
isSameList,
|
|
destination,
|
|
);
|
|
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
|
|
const newTask = {
|
|
...droppedTask,
|
|
position: newPosition,
|
|
taskGroup: {
|
|
id: destination.droppableId,
|
|
},
|
|
};
|
|
onTaskDrop(newTask, droppedTask.taskGroup.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
const [currentComposer, setCurrentComposer] = useState('');
|
|
const [toggleLabels, setToggleLabels] = useState(false);
|
|
const [toggleDirection, setToggleDirection] = useState<'shrink' | 'expand'>(
|
|
cardLabelVariant === 'large' ? 'shrink' : 'expand',
|
|
);
|
|
|
|
return (
|
|
<BoardContainer>
|
|
<BoardWrapper>
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<Droppable direction="horizontal" type="column" droppableId="root">
|
|
{provided => (
|
|
<Container {...provided.droppableProps} ref={provided.innerRef}>
|
|
{taskGroups
|
|
.slice()
|
|
.sort((a: any, b: any) => a.position - b.position)
|
|
.map((taskGroup: TaskGroup, index: number) => {
|
|
return (
|
|
<Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}>
|
|
{columnDragProvided => (
|
|
<Droppable type="tasks" droppableId={taskGroup.id}>
|
|
{(columnDropProvided, snapshot) => (
|
|
<List
|
|
name={taskGroup.name}
|
|
onOpenComposer={id => setCurrentComposer(id)}
|
|
isComposerOpen={currentComposer === taskGroup.id}
|
|
onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)}
|
|
ref={columnDragProvided.innerRef}
|
|
wrapperProps={columnDragProvided.draggableProps}
|
|
headerProps={columnDragProvided.dragHandleProps}
|
|
onExtraMenuOpen={onExtraMenuOpen}
|
|
id={taskGroup.id}
|
|
key={taskGroup.id}
|
|
index={index}
|
|
>
|
|
<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}
|
|
isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
|
|
>
|
|
{taskProvided => {
|
|
return (
|
|
<Card
|
|
toggleDirection={toggleDirection}
|
|
toggleLabels={toggleLabels}
|
|
labelVariant={cardLabelVariant}
|
|
wrapperProps={{
|
|
...taskProvided.draggableProps,
|
|
...taskProvided.dragHandleProps,
|
|
}}
|
|
setToggleLabels={setToggleLabels}
|
|
onCardLabelClick={() => {
|
|
setToggleLabels(true);
|
|
setToggleDirection(
|
|
cardLabelVariant === 'large' ? 'shrink' : 'expand',
|
|
);
|
|
if (onCardLabelClick) {
|
|
onCardLabelClick();
|
|
}
|
|
}}
|
|
ref={taskProvided.innerRef}
|
|
taskID={task.id}
|
|
complete={task.complete ?? false}
|
|
taskGroupID={taskGroup.id}
|
|
description=""
|
|
labels={task.labels.map(label => label.projectLabel)}
|
|
dueDate={
|
|
task.dueDate
|
|
? {
|
|
isPastDue: false,
|
|
formattedDate: moment(task.dueDate).format('MMM D, YYYY'),
|
|
}
|
|
: undefined
|
|
}
|
|
title={task.name}
|
|
members={task.assigned}
|
|
onClick={() => {
|
|
onTaskClick(task);
|
|
}}
|
|
checklists={task.badges && task.badges.checklist}
|
|
onCardMemberClick={onCardMemberClick}
|
|
onContextMenu={onQuickEditorOpen}
|
|
/>
|
|
);
|
|
}}
|
|
</Draggable>
|
|
);
|
|
})}
|
|
{columnDropProvided.placeholder}
|
|
{currentComposer === taskGroup.id && (
|
|
<CardComposer
|
|
onClose={() => {
|
|
setCurrentComposer('');
|
|
}}
|
|
onCreateCard={name => {
|
|
onCreateTask(taskGroup.id, name);
|
|
}}
|
|
isOpen
|
|
/>
|
|
)}
|
|
</ListCards>
|
|
</List>
|
|
)}
|
|
</Droppable>
|
|
)}
|
|
</Draggable>
|
|
);
|
|
})}
|
|
<AddList
|
|
onSave={listName => {
|
|
onCreateTaskGroup(listName);
|
|
}}
|
|
/>
|
|
{provided.placeholder}
|
|
</Container>
|
|
)}
|
|
</Droppable>
|
|
</DragDropContext>
|
|
</BoardWrapper>
|
|
</BoardContainer>
|
|
);
|
|
};
|
|
|
|
export default SimpleLists;
|