Show Entity task/note tabs (#1282)

* - show task tab
- tab bar

* - add notes tab

* - fixed unused style

* - add button
- fixed company edit note test

* - fixed merge & dropdown

* - added Tests
- refactored directory structure activities
- moved Task/Note Pages to corresponding modules
- fixed TabList

* lint
This commit is contained in:
brendanlaschke
2023-08-25 22:44:13 +02:00
committed by GitHub
parent f8e3dd3f6b
commit 7e264565ef
34 changed files with 957 additions and 188 deletions

View File

@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<typeof TaskGroups> = {
title: 'Modules/Activity/TaskGroups',
component: TaskGroups,
decorators: [ComponentWithRouterDecorator, ComponentWithRecoilScopeDecorator],
parameters: {
msw: graphqlMocks,
recoilScopeContext: TasksRecoilScopeContext,
},
};
export default meta;
type Story = StoryObj<typeof TaskGroups>;
export const Default: Story = {};

View File

@ -0,0 +1,46 @@
import { getOperationName } from '@apollo/client/utilities';
import type { Meta, StoryObj } from '@storybook/react';
import { graphql } from 'msw';
import { GET_ACTIVITIES } from '@/activities/graphql/queries/getActivities';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<typeof TaskGroups> = {
title: 'Modules/Activity/TaskGroupsWithoutTasks',
component: TaskGroups,
decorators: [ComponentWithRouterDecorator, ComponentWithRecoilScopeDecorator],
parameters: {
msw: graphqlMocks,
recoilScopeContext: TasksRecoilScopeContext,
},
};
export default meta;
type Story = StoryObj<typeof TaskGroups>;
export const Default: Story = {
parameters: {
msw: [
...graphqlMocks.filter(
(graphqlMock) =>
graphqlMock.info.operationName !== getOperationName(GET_ACTIVITIES),
),
...[
graphql.query(
getOperationName(GET_ACTIVITIES) ?? '',
(_req, res, ctx) => {
return res(
ctx.data({
findManyActivities: [],
}),
);
},
),
],
],
},
};

View File

@ -0,0 +1,37 @@
import { MemoryRouter } from 'react-router-dom';
import type { Meta, StoryObj } from '@storybook/react';
import { TaskList } from '@/activities/tasks/components/TaskList';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedActivities } from '~/testing/mock-data/activities';
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,
},
};

View File

@ -0,0 +1,34 @@
import { useTheme } from '@emotion/react';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import {
Button,
ButtonSize,
ButtonVariant,
} from '@/ui/button/components/Button';
import { IconPlus } from '@/ui/icon';
import { ActivityType } from '~/generated/graphql';
export function AddTaskButton({
entity,
}: {
entity?: ActivityTargetableEntity;
}) {
const theme = useTheme();
const openCreateActivity = useOpenCreateActivityDrawer();
if (!entity) {
return <></>;
}
return (
<Button
icon={<IconPlus size={theme.icon.size.md} />}
size={ButtonSize.Small}
variant={ButtonVariant.Secondary}
title="Add task"
onClick={() => openCreateActivity(ActivityType.Task, [entity])}
></Button>
);
}

View File

@ -0,0 +1,24 @@
import styled from '@emotion/styled';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
const StyledContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
overflow: auto;
`;
export function EntityTasks({ entity }: { entity: ActivityTargetableEntity }) {
return (
<StyledContainer>
<RecoilScope SpecificContext={TasksRecoilScopeContext}>
<TaskGroups entity={entity} showAddButton />
</RecoilScope>
</StyledContainer>
);
}

View File

@ -0,0 +1,107 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheckbox } from '@tabler/icons-react';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { useTasks } from '@/activities/tasks/hooks/useTasks';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { Button, ButtonVariant } from '@/ui/button/components/Button';
import { ActivityType } from '~/generated/graphql';
import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList';
type OwnProps = {
entity?: ActivityTargetableEntity;
showAddButton?: boolean;
};
const StyledTaskGroupEmptyContainer = styled.div`
align-items: center;
align-self: stretch;
display: flex;
flex: 1 0 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
padding-bottom: ${({ theme }) => theme.spacing(16)};
padding-left: ${({ theme }) => theme.spacing(4)};
padding-right: ${({ theme }) => theme.spacing(4)};
padding-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledEmptyTaskGroupTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.xxl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.md};
`;
const StyledEmptyTaskGroupSubTitle = styled.div`
color: ${({ theme }) => theme.font.color.extraLight};
font-size: ${({ theme }) => theme.font.size.xxl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.md};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
export function TaskGroups({ entity, showAddButton }: OwnProps) {
const { todayOrPreviousTasks, upcomingTasks, unscheduledTasks } =
useTasks(entity);
const theme = useTheme();
const openCreateActivity = useOpenCreateActivityDrawer();
if (
todayOrPreviousTasks?.length === 0 &&
upcomingTasks?.length === 0 &&
unscheduledTasks?.length === 0
) {
return (
<StyledTaskGroupEmptyContainer>
<StyledEmptyTaskGroupTitle>No task yet</StyledEmptyTaskGroupTitle>
<StyledEmptyTaskGroupSubTitle>Create one:</StyledEmptyTaskGroupSubTitle>
<Button
icon={<IconCheckbox size={theme.icon.size.sm} />}
title="New task"
variant={ButtonVariant.Secondary}
onClick={() =>
openCreateActivity(ActivityType.Task, entity ? [entity] : undefined)
}
/>
</StyledTaskGroupEmptyContainer>
);
}
return (
<StyledContainer>
<TaskList
title="Today"
tasks={todayOrPreviousTasks ?? []}
button={showAddButton && <AddTaskButton entity={entity} />}
/>
<TaskList
title="Upcoming"
tasks={upcomingTasks ?? []}
button={
showAddButton &&
!todayOrPreviousTasks?.length && <AddTaskButton entity={entity} />
}
/>
<TaskList
title="Unscheduled"
tasks={unscheduledTasks ?? []}
button={
showAddButton &&
!todayOrPreviousTasks?.length &&
!upcomingTasks?.length && <AddTaskButton entity={entity} />
}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,69 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { TaskForList } from '@/activities/types/TaskForList';
import { TaskRow } from './TaskRow';
type OwnProps = {
title: string;
tasks: TaskForList[];
button?: ReactElement | false;
};
const StyledContainer = styled.div`
align-items: flex-start;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
padding: 8px 24px;
`;
const StyledTitleBar = styled.h3`
display: flex;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(4)};
place-items: center;
width: 100%;
`;
const StyledTitle = styled.h3`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
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%;
`;
export function TaskList({ title, tasks, button }: OwnProps) {
return (
<>
{tasks && tasks.length > 0 && (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{title} <StyledCount>{tasks.length}</StyledCount>
</StyledTitle>
{button}
</StyledTitleBar>
<StyledTaskRows>
{tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</StyledTaskRows>
</StyledContainer>
)}
</>
);
}

View File

@ -0,0 +1,115 @@
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 { beautifyExactDate, hasDatePassed } from '~/utils/date-utils';
import { TaskForList } from '../../types/TaskForList';
import { useCompleteTask } from '../hooks/useCompleteTask';
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<{
completed: boolean;
}>`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: 0 ${({ theme }) => theme.spacing(2)};
text-decoration: ${({ completed }) => (completed ? 'line-through' : 'none')};
`;
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<{
isPast: boolean;
}>`
align-items: center;
color: ${({ theme, isPast }) =>
isPast ? theme.font.color.danger : 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 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}
onCheckedChange={completeTask}
/>
</div>
<StyledTaskTitle completed={task.completedAt !== null}>
{task.title ?? 'Task Title'}
</StyledTaskTitle>
<StyledTaskBody>
<OverflowingTextWithTooltip text={body} />
{task.comments && task.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
</StyledCommentIcon>
)}
</StyledTaskBody>
<StyledFieldsContainer>
<ActivityTargetChips targets={task.activityTargets} />
<StyledDueDate
isPast={
!!task.dueAt && hasDatePassed(task.dueAt) && !task.completedAt
}
>
<IconCalendar size={theme.icon.size.md} />
{task.dueAt && beautifyExactDate(task.dueAt)}
</StyledDueDate>
</StyledFieldsContainer>
</StyledContainer>
);
}

View File

@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { Activity, useUpdateActivityMutation } from '~/generated/graphql';
import { ACTIVITY_UPDATE_FRAGMENT } from '../../graphql/fragments/activityUpdateFragment';
import { GET_ACTIVITIES } from '../../graphql/queries/getActivities';
type Task = Pick<Activity, 'id' | 'completedAt'>;
export function useCompleteTask(task: Task) {
const [updateActivityMutation] = useUpdateActivityMutation();
const client = useApolloClient();
const cachedTask = client.readFragment({
id: `Activity:${task.id}`,
fragment: ACTIVITY_UPDATE_FRAGMENT,
});
const completeTask = useCallback(
(value: boolean) => {
const completedAt = value ? new Date().toISOString() : null;
updateActivityMutation({
variables: {
where: { id: task.id },
data: {
completedAt,
},
},
optimisticResponse: {
__typename: 'Mutation',
updateOneActivity: {
...cachedTask,
completedAt,
},
},
refetchQueries: [getOperationName(GET_ACTIVITIES) ?? ''],
});
},
[cachedTask, task.id, 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 { TasksRecoilScopeContext } from '../../states/recoil-scope-contexts/TasksRecoilScopeContext';
export function useInitializeTasksFilters({
availableFilters,
}: {
availableFilters: FilterDefinition[];
}) {
const [, setAvailableFilters] = useRecoilScopedState(
availableFiltersScopedState,
TasksRecoilScopeContext,
);
useEffect(() => {
setAvailableFilters(availableFilters);
}, [setAvailableFilters, availableFilters]);
}

View File

@ -0,0 +1,119 @@
import { useEffect } from 'react';
import { DateTime } from 'luxon';
import { useRecoilState } from 'recoil';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { useInitializeTasksFilters } from '@/activities/tasks/hooks/useInitializeTasksFilters';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { currentUserState } from '@/auth/states/currentUserState';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { FilterOperand } from '@/ui/filter-n-sort/types/FilterOperand';
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';
export function useTasks(entity?: ActivityTargetableEntity) {
useInitializeTasksFilters({
availableFilters: tasksFilters,
});
const [activeTabId] = useRecoilScopedState(
activeTabIdScopedState,
TasksRecoilScopeContext,
);
const [filters, setFilters] = useRecoilScopedState(
filtersScopedState,
TasksRecoilScopeContext,
);
// If there is no filter, we set the default filter to the current user
const [currentUser] = useRecoilState(currentUserState);
useEffect(() => {
if (currentUser && !filters.length && !entity) {
setFilters([
{
key: 'assigneeId',
type: 'entity',
value: currentUser.id,
operand: FilterOperand.Is,
displayValue: currentUser.displayName,
displayAvatarUrl: currentUser.avatarUrl ?? undefined,
},
]);
}
}, [currentUser, filters, setFilters, entity]);
const whereFilters = entity
? {
activityTargets: {
some: {
OR: [
{ companyId: { equals: entity.id } },
{ personId: { equals: entity.id } },
],
},
},
}
: 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' || !entity ? 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;
});
const unscheduledTasks = tasksData?.findManyActivities.filter((task) => {
return !task.dueAt;
});
return {
todayOrPreviousTasks,
upcomingTasks,
unscheduledTasks,
};
}