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:
@ -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;
|
||||
|
||||
@ -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],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
13
front/src/modules/activities/components/TaskGroups.tsx
Normal file
13
front/src/modules/activities/components/TaskGroups.tsx
Normal 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 ?? []} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
front/src/modules/activities/components/TaskList.tsx
Normal file
61
front/src/modules/activities/components/TaskList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
front/src/modules/activities/components/TaskRow.tsx
Normal file
132
front/src/modules/activities/components/TaskRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: [],
|
||||
},
|
||||
};
|
||||
35
front/src/modules/activities/hooks/useCompleteTask.ts
Normal file
35
front/src/modules/activities/hooks/useCompleteTask.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
103
front/src/modules/activities/hooks/useTasks.ts
Normal file
103
front/src/modules/activities/hooks/useTasks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
front/src/modules/activities/states/TasksContext.ts
Normal file
3
front/src/modules/activities/states/TasksContext.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const TasksContext = createContext<string | null>(null);
|
||||
@ -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();
|
||||
|
||||
3
front/src/modules/activities/types/TaskForList.ts
Normal file
3
front/src/modules/activities/types/TaskForList.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { GetActivitiesQuery } from '~/generated/graphql';
|
||||
|
||||
export type TaskForList = GetActivitiesQuery['findManyActivities'][0];
|
||||
Reference in New Issue
Block a user