Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,43 @@
import { Meta, StoryObj } from '@storybook/react';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedTasks } from '~/testing/mock-data/activities';
const meta: Meta<typeof TaskGroups> = {
title: 'Modules/Activity/TaskGroups',
component: TaskGroups,
decorators: [
(Story) => (
<ObjectFilterDropdownScope filterScopeId="entity-tasks-filter-scope">
<Story />
</ObjectFilterDropdownScope>
),
ComponentWithRouterDecorator,
ComponentWithRecoilScopeDecorator,
SnackBarDecorator,
],
parameters: {
msw: graphqlMocks,
customRecoilScopeContext: TasksRecoilScopeContext,
},
};
export default meta;
type Story = StoryObj<typeof TaskGroups>;
export const Empty: Story = {};
export const WithTasks: Story = {
args: {
entity: {
id: mockedTasks[0].authorId,
type: 'Person',
},
},
};

View File

@ -0,0 +1,39 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { TaskList } from '@/activities/tasks/components/TaskList';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
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,
SnackBarDecorator,
],
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,31 @@
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
export const AddTaskButton = ({
activityTargetEntity,
}: {
activityTargetEntity?: ActivityTargetableEntity;
}) => {
const openCreateActivity = useOpenCreateActivityDrawer();
if (!activityTargetEntity) {
return <></>;
}
return (
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add task"
onClick={() =>
openCreateActivity({
type: 'Task',
targetableEntities: [activityTargetEntity],
})
}
></Button>
);
};

View File

@ -0,0 +1,31 @@
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 { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
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 const EntityTasks = ({
entity,
}: {
entity: ActivityTargetableEntity;
}) => {
return (
<StyledContainer>
<RecoilScope CustomRecoilScopeContext={TasksRecoilScopeContext}>
<ObjectFilterDropdownScope filterScopeId="entity-tasks-filter-scope">
<TaskGroups entity={entity} showAddButton />
</ObjectFilterDropdownScope>
</RecoilScope>
</StyledContainer>
);
};

View File

@ -0,0 +1,29 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
type PageAddTaskButtonProps = {
filterDropdownId: string;
};
export const PageAddTaskButton = ({
filterDropdownId,
}: PageAddTaskButtonProps) => {
const { selectedFilter } = useFilterDropdown({
filterDropdownId: filterDropdownId,
});
const openCreateActivity = useOpenCreateActivityDrawer();
const handleClick = () => {
openCreateActivity({
type: 'Task',
assigneeId: isNonEmptyString(selectedFilter?.value)
? selectedFilter?.value
: undefined,
});
};
return <PageAddButton onClick={handleClick} />;
};

View File

@ -0,0 +1,147 @@
import styled from '@emotion/styled';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { useTasks } from '@/activities/tasks/hooks/useTasks';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
import { activeTabIdScopedState } from '@/ui/layout/tab/states/activeTabIdScopedState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList';
type TaskGroupsProps = {
filterDropdownId?: string;
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 const TaskGroups = ({
filterDropdownId,
entity,
showAddButton,
}: TaskGroupsProps) => {
const {
todayOrPreviousTasks,
upcomingTasks,
unscheduledTasks,
completedTasks,
} = useTasks({ filterDropdownId: filterDropdownId, entity });
const openCreateActivity = useOpenCreateActivityDrawer();
const [activeTabId] = useRecoilScopedState(
activeTabIdScopedState,
TasksRecoilScopeContext,
);
if (entity?.type === 'Custom') {
return <></>;
}
if (
(activeTabId !== 'done' &&
todayOrPreviousTasks?.length === 0 &&
upcomingTasks?.length === 0 &&
unscheduledTasks?.length === 0) ||
(activeTabId === 'done' && completedTasks?.length === 0)
) {
return (
<StyledTaskGroupEmptyContainer>
<StyledEmptyTaskGroupTitle>No task yet</StyledEmptyTaskGroupTitle>
<StyledEmptyTaskGroupSubTitle>Create one:</StyledEmptyTaskGroupSubTitle>
<Button
Icon={IconPlus}
title="New task"
variant={'secondary'}
onClick={() =>
openCreateActivity({
type: 'Task',
targetableEntities: entity ? [entity] : undefined,
})
}
/>
</StyledTaskGroupEmptyContainer>
);
}
return (
<StyledContainer>
{activeTabId === 'done' ? (
<TaskList
tasks={completedTasks ?? []}
button={
showAddButton && <AddTaskButton activityTargetEntity={entity} />
}
/>
) : (
<>
<TaskList
title="Today"
tasks={todayOrPreviousTasks ?? []}
button={
showAddButton && <AddTaskButton activityTargetEntity={entity} />
}
/>
<TaskList
title="Upcoming"
tasks={upcomingTasks ?? []}
button={
showAddButton &&
!todayOrPreviousTasks?.length && (
<AddTaskButton activityTargetEntity={entity} />
)
}
/>
<TaskList
title="Unscheduled"
tasks={unscheduledTasks ?? []}
button={
showAddButton &&
!todayOrPreviousTasks?.length &&
!upcomingTasks?.length && (
<AddTaskButton activityTargetEntity={entity} />
)
}
/>
</>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,70 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { Activity } from '@/activities/types/Activity';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { TaskRow } from './TaskRow';
type TaskListProps = {
title?: string;
tasks: Omit<Activity, 'assigneeId'>[];
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.div`
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 const TaskList = ({ title, tasks, button }: TaskListProps) => (
<>
{tasks && tasks.length > 0 && (
<StyledContainer>
<StyledTitleBar>
{title && (
<StyledTitle>
{title} <StyledCount>{tasks.length}</StyledCount>
</StyledTitle>
)}
{button}
</StyledTitleBar>
<StyledTaskRows>
{tasks.map((task) => (
<TaskRow key={task.id} task={task as unknown as GraphQLActivity} />
))}
</StyledTaskRows>
</StyledContainer>
)}
</>
);

View File

@ -0,0 +1,130 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { IconCalendar, IconComment } from '@/ui/display/icon';
import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip';
import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox';
import { beautifyExactDate, hasDatePassed } from '~/utils/date-utils';
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 const TaskRow = ({
task,
}: {
task: Omit<GraphQLActivity, 'assigneeId'>;
}) => {
const theme = useTheme();
const openActivityRightDrawer = useOpenActivityRightDrawer();
const body = JSON.parse(isNonEmptyString(task.body) ? task.body : '{}')[0]
?.content[0]?.text;
const { completeTask } = useCompleteTask(task);
const activityTargetIds =
task?.activityTargets?.edges?.map(
(activityTarget) => activityTarget.node.id,
) ?? [];
const { records: activityTargets } = useFindManyRecords<ActivityTarget>({
objectNameSingular: 'activityTarget',
filter: { id: { in: activityTargetIds } },
});
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={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,31 @@
import { useCallback } from 'react';
import { Activity } from '@/activities/types/Activity';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
type Task = Pick<Activity, 'id' | 'completedAt'>;
export const useCompleteTask = (task: Task) => {
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
const completeTask = useCallback(
(value: boolean) => {
const completedAt = value ? new Date().toISOString() : null;
updateOneActivity?.({
idToUpdate: task.id,
input: {
completedAt,
},
forceRefetch: true,
});
},
[task.id, updateOneActivity],
);
return {
completeTask,
};
};

View File

@ -0,0 +1,32 @@
import { DateTime } from 'luxon';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { parseDate } from '~/utils/date-utils';
export const useCurrentUserTaskCount = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: tasks } = useFindManyRecords({
objectNameSingular: 'activity',
filter: {
type: { eq: 'Task' },
completedAt: { is: 'NULL' },
assigneeId: { eq: currentWorkspaceMember?.id },
},
});
const currentUserDueTaskCount = tasks.filter((task) => {
if (!task.dueAt) {
return false;
}
const dueDate = parseDate(task.dueAt).toJSDate();
const today = DateTime.now().endOf('day').toJSDate();
return dueDate <= today;
}).length;
return {
currentUserDueTaskCount,
};
};

View File

@ -0,0 +1,113 @@
import { isNonEmptyString } from '@sniptt/guards';
import { DateTime } from 'luxon';
import { undefined } from 'zod';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { parseDate } from '~/utils/date-utils';
import { isDefined } from '~/utils/isDefined';
type UseTasksProps = {
filterDropdownId?: string;
entity?: ActivityTargetableEntity;
};
export const useTasks = (props?: UseTasksProps) => {
const { filterDropdownId, entity } = props ?? {};
const { selectedFilter } = useFilterDropdown({
filterDropdownId: filterDropdownId,
});
const { records: activityTargets } = useFindManyRecords({
objectNameSingular: 'activityTarget',
filter: isDefined(entity)
? {
[entity?.type === 'Company' ? 'companyId' : 'personId']: {
eq: entity?.id,
},
}
: undefined,
});
const { records: completeTasksData } = useFindManyRecords({
objectNameSingular: 'activity',
skip: !entity && !selectedFilter,
filter: {
completedAt: { is: 'NOT_NULL' },
id: isDefined(entity)
? {
in: activityTargets?.map(
(activityTarget) => activityTarget.activityId,
),
}
: undefined,
type: { eq: 'Task' },
assigneeId: isNonEmptyString(selectedFilter?.value)
? {
eq: selectedFilter?.value,
}
: undefined,
},
orderBy: {
createdAt: 'DescNullsFirst',
},
});
const { records: incompleteTaskData } = useFindManyRecords({
objectNameSingular: 'activity',
skip: !entity && !selectedFilter,
filter: {
completedAt: { is: 'NULL' },
id: isDefined(entity)
? {
in: activityTargets?.map(
(activityTarget) => activityTarget.activityId,
),
}
: undefined,
type: { eq: 'Task' },
assigneeId: isNonEmptyString(selectedFilter?.value)
? {
eq: selectedFilter?.value,
}
: undefined,
},
orderBy: {
createdAt: 'DescNullsFirst',
},
});
const todayOrPreviousTasks = incompleteTaskData?.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 = incompleteTaskData?.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 = incompleteTaskData?.filter((task) => {
return !task.dueAt;
});
const completedTasks = completeTasksData;
return {
todayOrPreviousTasks: (todayOrPreviousTasks ?? []) as Activity[],
upcomingTasks: (upcomingTasks ?? []) as Activity[],
unscheduledTasks: (unscheduledTasks ?? []) as Activity[],
completedTasks: (completedTasks ?? []) as Activity[],
};
};