Compare commits
	
		
			1 Commits
		
	
	
		
			0.2.2
			...
			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;
 | 
			
		||||
		Reference in New Issue
	
	Block a user