Feat/filter activity inbox (#1032)

* Move files

* Add filtering for tasks inbox

* Add filter dropdown for single entity

* Minor

* Fill empty button

* Refine logic for filter dropdown

* remove log

* Fix unwanted change

* Set current user as default filter

* Add avatar on filter

* Improve initialization of assignee filter

* Add story for Tasks page

* Add more stories

* Add sotry with no tasks

* Improve dates

* Enh tests

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Emilien Chauvet
2023-08-02 21:36:16 +02:00
committed by GitHub
parent 2128d44212
commit 4252a0a2c3
28 changed files with 601 additions and 189 deletions

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { Avatar } from '@/users/components/Avatar';
import {
beautifyExactDate,
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
@ -64,7 +64,7 @@ const StyledTooltip = styled(Tooltip)`
export function CommentHeader({ comment, actionBar }: OwnProps) {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(comment.createdAt);
const exactCreatedAt = beautifyExactDate(comment.createdAt);
const exactCreatedAt = beautifyExactDateTime(comment.createdAt);
const showDate = beautifiedCreatedAt !== '';
const author = comment.author;

View File

@ -33,7 +33,7 @@ export function ActivityAssigneePicker({
);
const [updateActivity] = useUpdateActivityMutation();
const companies = useFilteredSearchEntityQuery({
const users = useFilteredSearchEntityQuery({
queryHook: useSearchUserQuery,
selectedIds: activity?.accountOwner?.id ? [activity?.accountOwner?.id] : [],
searchFilter: searchFilter,
@ -70,9 +70,9 @@ export function ActivityAssigneePicker({
onEntitySelected={handleEntitySelected}
onCancel={onCancel}
entities={{
loading: companies.loading,
entitiesToSelect: companies.entitiesToSelect,
selectedEntity: companies.selectedEntities[0],
loading: users.loading,
entitiesToSelect: users.entitiesToSelect,
selectedEntity: users.selectedEntities[0],
}}
/>
);

View File

@ -0,0 +1,13 @@
import { useTasks } from '../hooks/useTasks';
import { TaskList } from './TaskList';
export function TaskGroups() {
const { todayOrPreviousTasks, upcomingTasks } = useTasks();
return (
<>
<TaskList title="Today" tasks={todayOrPreviousTasks ?? []} />
<TaskList title="Upcoming" tasks={upcomingTasks ?? []} />
</>
);
}

View File

@ -0,0 +1,61 @@
import styled from '@emotion/styled';
import { TaskForList } from '../types/TaskForList';
import { TaskRow } from './TaskRow';
type OwnProps = {
title: string;
tasks: TaskForList[];
};
const StyledContainer = styled.div`
align-items: flex-start;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
padding: 8px 24px;
`;
const StyledTitle = styled.h3`
color: ${({ theme }) => theme.font.color.primary};
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledCount = styled.span`
color: ${({ theme }) => theme.font.color.light};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledTaskRows = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
width: 100%;
`;
const StyledEmptyListMessage = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
padding: ${({ theme }) => theme.spacing(4)};
`;
export function TaskList({ title, tasks }: OwnProps) {
return (
<StyledContainer>
<StyledTitle>
{title} <StyledCount>{tasks ? tasks.length : 0}</StyledCount>
</StyledTitle>
{tasks && tasks.length > 0 ? (
<StyledTaskRows>
{tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</StyledTaskRows>
) : (
<StyledEmptyListMessage>No task in this section</StyledEmptyListMessage>
)}
</StyledContainer>
);
}

View File

@ -0,0 +1,132 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { IconCalendar, IconComment } from '@/ui/icon';
import {
Checkbox,
CheckboxShape,
} from '@/ui/input/checkbox/components/Checkbox';
import { OverflowingTextWithTooltip } from '@/ui/tooltip/OverflowingTextWithTooltip';
import { useGetCompaniesQuery, useGetPeopleQuery } from '~/generated/graphql';
import { beautifyExactDate } from '~/utils/date-utils';
import { useCompleteTask } from '../hooks/useCompleteTask';
import { TaskForList } from '../types/TaskForList';
const StyledContainer = styled.div`
align-items: center;
align-self: stretch;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
cursor: pointer;
display: inline-flex;
height: ${({ theme }) => theme.spacing(12)};
min-width: calc(100% - ${({ theme }) => theme.spacing(8)});
padding: 0 ${({ theme }) => theme.spacing(4)};
`;
const StyledTaskBody = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex-direction: row;
flex-grow: 1;
width: 1px;
`;
const StyledTaskTitle = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledCommentIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledDueDate = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledFieldsContainer = styled.div`
display: flex;
`;
export function TaskRow({ task }: { task: TaskForList }) {
const theme = useTheme();
const openActivityRightDrawer = useOpenActivityRightDrawer();
const { data: targetPeople } = useGetPeopleQuery({
variables: {
where: {
id: {
in: task?.activityTargets
? task?.activityTargets
.filter((target) => target.commentableType === 'Person')
.map((target) => target.commentableId ?? '')
: [],
},
},
},
});
const { data: targetCompanies } = useGetCompaniesQuery({
variables: {
where: {
id: {
in: task?.activityTargets
? task?.activityTargets
.filter((target) => target.commentableType === 'Company')
.map((target) => target.commentableId ?? '')
: [],
},
},
},
});
const body = JSON.parse(task.body ?? '{}')[0]?.content[0]?.text;
const { completeTask } = useCompleteTask(task);
return (
<StyledContainer
onClick={() => {
openActivityRightDrawer(task.id);
}}
>
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<Checkbox
checked={!!task.completedAt}
shape={CheckboxShape.Rounded}
onChange={completeTask}
/>
</div>
<StyledTaskTitle>{task.title ?? '(No title)'}</StyledTaskTitle>
<StyledTaskBody>
<OverflowingTextWithTooltip text={body} />
{task.comments && task.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
</StyledCommentIcon>
)}
</StyledTaskBody>
<StyledFieldsContainer>
<ActivityTargetChips
targetCompanies={targetCompanies}
targetPeople={targetPeople}
/>
<StyledDueDate>
<IconCalendar size={theme.icon.size.md} />
{task.dueAt && beautifyExactDate(task.dueAt)}
</StyledDueDate>
</StyledFieldsContainer>
</StyledContainer>
);
}

View File

@ -0,0 +1,45 @@
import { MemoryRouter } from 'react-router-dom';
import type { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedActivities } from '~/testing/mock-data/activities';
import { TaskList } from '../TaskList';
const meta: Meta<typeof TaskList> = {
title: 'Modules/Activity/TaskList',
component: TaskList,
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
ComponentDecorator,
],
args: {
title: 'Tasks',
tasks: mockedActivities,
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof TaskList>;
export const Default: Story = {
args: {
title: 'Tasks',
tasks: mockedActivities,
},
};
export const Empty: Story = {
args: {
title: 'No tasks',
tasks: [],
},
};

View File

@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import {
GET_ACTIVITIES,
GET_ACTIVITIES_BY_TARGETS,
} from '@/activities/queries';
import { Activity, useUpdateActivityMutation } from '~/generated/graphql';
type Task = Pick<Activity, 'id'>;
export function useCompleteTask(task: Task) {
const [updateActivityMutation] = useUpdateActivityMutation();
const completeTask = useCallback(
(value: boolean) => {
updateActivityMutation({
variables: {
where: { id: task.id },
data: {
completedAt: value ? new Date().toISOString() : null,
},
},
refetchQueries: [
getOperationName(GET_ACTIVITIES_BY_TARGETS) ?? '',
getOperationName(GET_ACTIVITIES) ?? '',
],
});
},
[task, updateActivityMutation],
);
return {
completeTask,
};
}

View File

@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { availableFiltersScopedState } from '@/ui/filter-n-sort/states/availableFiltersScopedState';
import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { TasksContext } from '../states/TasksContext';
export function useInitializeTasksFilters({
availableFilters,
}: {
availableFilters: FilterDefinition[];
}) {
const [, setAvailableFilters] = useRecoilScopedState(
availableFiltersScopedState,
TasksContext,
);
useEffect(() => {
setAvailableFilters(availableFilters);
}, [setAvailableFilters, availableFilters]);
}

View File

@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { DateTime } from 'luxon';
import { useRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause';
import { activeTabIdScopedState } from '@/ui/tab/states/activeTabIdScopedState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { ActivityType, useGetActivitiesQuery } from '~/generated/graphql';
import { tasksFilters } from '~/pages/tasks/tasks-filters';
import { parseDate } from '~/utils/date-utils';
import { TasksContext } from '../states/TasksContext';
import { useInitializeTasksFilters } from './useInitializeTasksFilters';
export function useTasks() {
useInitializeTasksFilters({
availableFilters: tasksFilters,
});
const [activeTabId] = useRecoilScopedState(
activeTabIdScopedState,
TasksContext,
);
const [filters, setFilters] = useRecoilScopedState(
filtersScopedState,
TasksContext,
);
// If there is no filter, we set the default filter to the current user
const [currentUser] = useRecoilState(currentUserState);
useEffect(() => {
if (currentUser && !filters.length) {
setFilters([
{
field: 'assigneeId',
type: 'entity',
value: currentUser.id,
operand: 'is',
displayValue: currentUser.displayName,
displayAvatarUrl: currentUser.avatarUrl ?? undefined,
},
]);
}
}, [currentUser, filters, setFilters]);
const whereFilters = Object.assign(
{},
...filters.map((filter) => {
return turnFilterIntoWhereClause(filter);
}),
);
const { data: completeTasksData } = useGetActivitiesQuery({
variables: {
where: {
type: { equals: ActivityType.Task },
completedAt: { not: { equals: null } },
...whereFilters,
},
},
});
const { data: incompleteTaskData } = useGetActivitiesQuery({
variables: {
where: {
type: { equals: ActivityType.Task },
completedAt: { equals: null },
...whereFilters,
},
},
});
const tasksData =
activeTabId === 'done' ? completeTasksData : incompleteTaskData;
const todayOrPreviousTasks = tasksData?.findManyActivities.filter((task) => {
if (!task.dueAt) {
return false;
}
const dueDate = parseDate(task.dueAt).toJSDate();
const today = DateTime.now().endOf('day').toJSDate();
return dueDate <= today;
});
const upcomingTasks = tasksData?.findManyActivities.filter((task) => {
if (!task.dueAt) {
return false;
}
const dueDate = parseDate(task.dueAt).toJSDate();
const today = DateTime.now().endOf('day').toJSDate();
return dueDate > today;
});
return {
todayOrPreviousTasks,
upcomingTasks,
};
}

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const TasksContext = createContext<string | null>(null);

View File

@ -1,13 +1,13 @@
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { useCompleteTask } from '@/activities/hooks/useCompleteTask';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { useCompleteTask } from '@/tasks/hooks/useCompleteTask';
import { IconNotes } from '@/ui/icon';
import { OverflowingTextWithTooltip } from '@/ui/tooltip/OverflowingTextWithTooltip';
import { Activity, User } from '~/generated/graphql';
import {
beautifyExactDate,
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
@ -126,7 +126,7 @@ type OwnProps = {
export function TimelineActivity({ activity }: OwnProps) {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(activity.createdAt);
const exactCreatedAt = beautifyExactDate(activity.createdAt);
const exactCreatedAt = beautifyExactDateTime(activity.createdAt);
const body = JSON.parse(activity.body ?? '{}')[0]?.content[0]?.text;
const openActivityRightDrawer = useOpenActivityRightDrawer();

View File

@ -0,0 +1,3 @@
import { GetActivitiesQuery } from '~/generated/graphql';
export type TaskForList = GetActivitiesQuery['findManyActivities'][0];