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,38 @@
import styled from '@emotion/styled';
import { Comment as CommentType } from '@/activities/types/Comment';
import { CommentHeader } from './CommentHeader';
type CommentProps = {
comment: CommentType;
actionBar?: React.ReactNode;
};
const StyledContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-start;
width: 100%;
`;
const StyledCommentBody = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
line-height: ${({ theme }) => theme.text.lineHeight.md};
overflow-wrap: anywhere;
padding-left: 24px;
text-align: left;
`;
export const Comment = ({ comment, actionBar }: CommentProps) => (
<StyledContainer>
<CommentHeader comment={comment} actionBar={actionBar} />
<StyledCommentBody>{comment.body}</StyledCommentBody>
</StyledContainer>
);

View File

@ -0,0 +1,28 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComment } from '@/ui/display/icon';
const StyledCommentIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const CommentCounter = ({ commentCount }: { commentCount: number }) => {
const theme = useTheme();
return (
<div>
{commentCount > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
{commentCount}
</StyledCommentIcon>
)}
</div>
);
};
export default CommentCounter;

View File

@ -0,0 +1,101 @@
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { Comment } from '@/activities/types/Comment';
import { Avatar } from '@/users/components/Avatar';
import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
const StyledContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(1)};
width: calc(100% - ${({ theme }) => theme.spacing(1)});
`;
const StyledLeftContainer = styled.div`
align-items: end;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledName = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledDate = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary};
box-shadow: 0px 2px 4px 3px
${({ theme }) => theme.background.transparent.light};
box-shadow: 2px 4px 16px 6px
${({ theme }) => theme.background.transparent.light};
color: ${({ theme }) => theme.font.color.primary};
opacity: 1;
padding: 8px;
`;
type CommentHeaderProps = {
comment: Pick<Comment, 'id' | 'author' | 'createdAt'>;
actionBar?: React.ReactNode;
};
export const CommentHeader = ({ comment, actionBar }: CommentHeaderProps) => {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(comment.createdAt);
const exactCreatedAt = beautifyExactDateTime(comment.createdAt);
const showDate = beautifiedCreatedAt !== '';
const author = comment.author;
const authorName = author.name.firstName + ' ' + author.name.lastName;
const avatarUrl = author.avatarUrl;
const commentId = comment.id;
return (
<StyledContainer>
<StyledLeftContainer>
<Avatar
avatarUrl={avatarUrl}
size="md"
colorId={author.id}
placeholder={authorName}
/>
<StyledName>{authorName}</StyledName>
{showDate && (
<>
<StyledDate id={`id-${commentId}`}>
{beautifiedCreatedAt}
</StyledDate>
<StyledTooltip
anchorSelect={`#id-${commentId}`}
content={exactCreatedAt}
clickable
noArrow
/>
</>
)}
</StyledLeftContainer>
<div>{actionBar}</div>
</StyledContainer>
);
};

View File

@ -0,0 +1,33 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ActivityActionBar } from '../../right-drawer/components/ActivityActionBar';
import { Comment } from '../Comment';
import { mockComment, mockCommentWithLongValues } from './mock-comment';
const meta: Meta<typeof Comment> = {
title: 'Modules/Activity/Comment/Comment',
component: Comment,
decorators: [ComponentDecorator],
argTypes: {
actionBar: {
type: 'boolean',
mapping: {
true: <ActivityActionBar activityId="test-id" />,
false: undefined,
},
},
},
args: { comment: mockComment },
};
export default meta;
type Story = StoryObj<typeof Comment>;
export const Default: Story = {};
export const WithLongValues: Story = {
args: { comment: mockCommentWithLongValues },
};

View File

@ -0,0 +1,87 @@
import { Meta, StoryObj } from '@storybook/react';
import { DateTime } from 'luxon';
import { ActivityActionBar } from '@/activities/right-drawer/components/ActivityActionBar';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { avatarUrl } from '~/testing/mock-data/users';
import { CommentHeader } from '../CommentHeader';
import { mockComment, mockCommentWithLongValues } from './mock-comment';
const meta: Meta<typeof CommentHeader> = {
title: 'Modules/Activity/Comment/CommentHeader',
component: CommentHeader,
decorators: [ComponentDecorator],
argTypes: {
actionBar: {
type: 'boolean',
mapping: {
true: <ActivityActionBar activityId="test-id" />,
false: undefined,
},
},
},
args: { comment: mockComment },
};
export default meta;
type Story = StoryObj<typeof CommentHeader>;
export const Default: Story = {};
export const FewHoursAgo: Story = {
args: {
comment: {
...mockComment,
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
},
},
};
export const FewDaysAgo: Story = {
args: {
comment: {
...mockComment,
createdAt: DateTime.now().minus({ days: 2 }).toISO() ?? '',
},
},
};
export const FewMonthsAgo: Story = {
args: {
comment: {
...mockComment,
createdAt: DateTime.now().minus({ months: 2 }).toISO() ?? '',
},
},
};
export const FewYearsAgo: Story = {
args: {
comment: {
...mockComment,
createdAt: DateTime.now().minus({ years: 2 }).toISO() ?? '',
},
},
};
export const WithAvatar: Story = {
args: {
comment: {
...mockComment,
author: {
...mockComment.author,
avatarUrl,
},
},
},
};
export const WithLongUserName: Story = {
args: { comment: mockCommentWithLongValues },
};
export const WithActionBar: Story = {
args: { actionBar: true },
};

View File

@ -0,0 +1,41 @@
import { DateTime } from 'luxon';
import { Comment } from '@/activities/types/Comment';
export const mockComment: Pick<
Comment,
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt' | 'activityId'
> = {
id: 'fake_comment_1_uuid',
body: 'Hello, this is a comment.',
author: {
id: 'fake_comment_1_author_uuid',
name: {
firstName: 'Jony' ?? '',
lastName: 'Ive' ?? '',
},
avatarUrl: null,
},
createdAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
updatedAt: DateTime.fromFormat('2021-03-13', 'yyyy-MM-dd').toISO() ?? '',
activityId: 'fake_activity_1_uuid',
};
export const mockCommentWithLongValues: Pick<
Comment,
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt' | 'activityId'
> = {
id: 'fake_comment_2_uuid',
body: 'Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment.',
author: {
id: 'fake_comment_1_author_uuid',
name: {
firstName: 'Jony' ?? '',
lastName: 'Ive' ?? '',
},
avatarUrl: null,
},
createdAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
updatedAt: DateTime.fromFormat('2021-03-13', 'yyyy-MM-dd').toISO() ?? '',
activityId: 'fake_activity_1_uuid',
};

View File

@ -0,0 +1,76 @@
import { useEffect, useMemo, useState } from 'react';
import { BlockNoteEditor } from '@blocknote/core';
import { getDefaultReactSlashMenuItems, useBlockNote } from '@blocknote/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import debounce from 'lodash.debounce';
import { Activity } from '@/activities/types/Activity';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
const StyledBlockNoteStyledContainer = styled.div`
width: 100%;
`;
type ActivityBodyEditorProps = {
activity: Pick<Activity, 'id' | 'body'>;
onChange?: (activityBody: string) => void;
};
export const ActivityBodyEditor = ({
activity,
onChange,
}: ActivityBodyEditorProps) => {
const [body, setBody] = useState<string | null>(null);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
useEffect(() => {
if (body) {
onChange?.(body);
}
}, [body, onChange]);
const debounceOnChange = useMemo(() => {
const onInternalChange = (activityBody: string) => {
setBody(activityBody);
updateOneRecord?.({
idToUpdate: activity.id,
input: {
body: activityBody,
},
});
};
return debounce(onInternalChange, 200);
}, [updateOneRecord, activity.id]);
let slashMenuItems = [...getDefaultReactSlashMenuItems()];
const imagesActivated = useIsFeatureEnabled('IS_NOTE_CREATE_IMAGES_ENABLED');
if (!imagesActivated) {
slashMenuItems = slashMenuItems.filter((x) => x.name != 'Image');
}
const editor: BlockNoteEditor | null = useBlockNote({
initialContent:
isNonEmptyString(activity.body) && activity.body !== '{}'
? JSON.parse(activity.body)
: undefined,
domAttributes: { editor: { class: 'editor' } },
onEditorContentChange: (editor) => {
debounceOnChange(JSON.stringify(editor.topLevelBlocks) ?? '');
},
slashMenuItems,
});
return (
<StyledBlockNoteStyledContainer>
<BlockEditor editor={editor} />
</StyledBlockNoteStyledContainer>
);
};

View File

@ -0,0 +1,130 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { Comment } from '@/activities/comment/Comment';
import { Activity } from '@/activities/types/Activity';
import { Comment as CommentType } from '@/activities/types/Comment';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import {
AutosizeTextInput,
AutosizeTextInputVariant,
} from '@/ui/input/components/AutosizeTextInput';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
const StyledThreadItemListContainer = styled.div`
align-items: flex-start;
border-top: 1px solid ${({ theme }) => theme.border.color.light};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(8)};
padding-bottom: ${({ theme }) => theme.spacing(32)};
padding-left: ${({ theme }) => theme.spacing(12)};
width: 100%;
`;
const StyledCommentActionBar = styled.div`
background: ${({ theme }) => theme.background.primary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
bottom: 0;
display: flex;
padding: 16px 24px 16px 48px;
position: absolute;
width: calc(
${({ theme }) => (useIsMobile() ? '100%' : theme.rightDrawerWidth)} - 72px
);
`;
const StyledThreadCommentTitle = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
text-transform: uppercase;
`;
type ActivityCommentsProps = {
activity: Pick<Activity, 'id'>;
scrollableContainerRef: React.RefObject<HTMLDivElement>;
};
export const ActivityComments = ({
activity,
scrollableContainerRef,
}: ActivityCommentsProps) => {
const { createOneRecord: createOneComment } = useCreateOneRecord({
objectNameSingular: 'comment',
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: comments } = useFindManyRecords({
objectNameSingular: 'comment',
filter: {
activityId: {
eq: activity?.id ?? '',
},
},
});
if (!currentWorkspaceMember) {
return <></>;
}
const handleSendComment = (commentText: string) => {
if (!isNonEmptyString(commentText)) {
return;
}
createOneComment?.({
id: v4(),
authorId: currentWorkspaceMember?.id ?? '',
activityId: activity?.id ?? '',
body: commentText,
createdAt: new Date().toISOString(),
});
};
const handleFocus = () => {
const scrollableContainer = scrollableContainerRef.current;
scrollableContainer?.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth',
});
};
return (
<>
{comments.length > 0 && (
<>
<StyledThreadItemListContainer>
<StyledThreadCommentTitle>Comments</StyledThreadCommentTitle>
{comments?.map((comment) => (
<Comment key={comment.id} comment={comment as CommentType} />
))}
</StyledThreadItemListContainer>
</>
)}
<StyledCommentActionBar>
{currentWorkspaceMember && (
<AutosizeTextInput
onValidate={handleSendComment}
onFocus={handleFocus}
variant={AutosizeTextInputVariant.Button}
placeholder={comments.length > 0 ? 'Reply...' : undefined}
/>
)}
</StyledCommentActionBar>
</>
);
};

View File

@ -0,0 +1,30 @@
import {
IconCheckbox,
IconNotes,
IconTimelineEvent,
} from '@/ui/display/icon/index';
import { Button } from '@/ui/input/button/components/Button';
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
type ActivityCreateButtonProps = {
onNoteClick?: () => void;
onTaskClick?: () => void;
onActivityClick?: () => void;
};
export const ActivityCreateButton = ({
onNoteClick,
onTaskClick,
onActivityClick,
}: ActivityCreateButtonProps) => (
<ButtonGroup variant={'secondary'}>
<Button Icon={IconNotes} title="Note" onClick={onNoteClick} />
<Button Icon={IconCheckbox} title="Task" onClick={onTaskClick} />
<Button
Icon={IconTimelineEvent}
title="Activity"
soon={true}
onClick={onActivityClick}
/>
</ButtonGroup>
);

View File

@ -0,0 +1,191 @@
import React, { useCallback, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { ActivityBodyEditor } from '@/activities/components/ActivityBodyEditor';
import { ActivityComments } from '@/activities/components/ActivityComments';
import { ActivityTypeDropdown } from '@/activities/components/ActivityTypeDropdown';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { debounce } from '~/utils/debounce';
import { ActivityTitle } from './ActivityTitle';
import '@blocknote/core/style.css';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
overflow-y: auto;
`;
const StyledUpperPartContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
`;
const StyledTopContainer = styled.div`
align-items: flex-start;
align-self: stretch;
background: ${({ theme }) => theme.background.secondary};
border-bottom: ${({ theme }) =>
useIsMobile() ? 'none' : `1px solid ${theme.border.color.medium}`};
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 24px 24px 48px;
`;
type ActivityEditorProps = {
activity: Pick<
Activity,
'id' | 'title' | 'body' | 'type' | 'completedAt' | 'dueAt'
> & {
comments?: Array<Comment> | null;
} & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
} & {
activityTargets?: Array<
Pick<ActivityTarget, 'id' | 'companyId' | 'personId'>
> | null;
};
showComment?: boolean;
autoFillTitle?: boolean;
};
export const ActivityEditor = ({
activity,
showComment = true,
autoFillTitle = false,
}: ActivityEditorProps) => {
const [hasUserManuallySetTitle, setHasUserManuallySetTitle] =
useState<boolean>(false);
const [title, setTitle] = useState<string | null>(activity.title ?? '');
const containerRef = useRef<HTMLDivElement>(null);
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Activity>({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
const { FieldContextProvider: DueAtFieldContextProvider } = useFieldContext({
objectNameSingular: 'activity',
objectRecordId: activity.id,
fieldMetadataName: 'dueAt',
fieldPosition: 0,
forceRefetch: true,
});
const { FieldContextProvider: AssigneeFieldContextProvider } =
useFieldContext({
objectNameSingular: 'activity',
objectRecordId: activity.id,
fieldMetadataName: 'assignee',
fieldPosition: 1,
forceRefetch: true,
});
const updateTitle = useCallback(
(newTitle: string) => {
updateOneActivity?.({
idToUpdate: activity.id,
input: {
title: newTitle ?? '',
},
});
},
[activity.id, updateOneActivity],
);
const handleActivityCompletionChange = useCallback(
(value: boolean) => {
updateOneActivity?.({
idToUpdate: activity.id,
input: {
completedAt: value ? new Date().toISOString() : null,
},
forceRefetch: true,
});
},
[activity.id, updateOneActivity],
);
const debouncedUpdateTitle = debounce(updateTitle, 200);
const updateTitleFromBody = (body: string) => {
const blockBody = JSON.parse(body);
const parsedTitle = blockBody[0]?.content?.[0]?.text;
if (!hasUserManuallySetTitle && autoFillTitle) {
setTitle(parsedTitle);
debouncedUpdateTitle(parsedTitle);
}
};
if (!activity) {
return <></>;
}
return (
<StyledContainer ref={containerRef}>
<StyledUpperPartContainer>
<StyledTopContainer>
<ActivityTypeDropdown activity={activity} />
<ActivityTitle
title={title ?? ''}
completed={!!activity.completedAt}
type={activity.type}
onTitleChange={(newTitle) => {
setTitle(newTitle);
setHasUserManuallySetTitle(true);
debouncedUpdateTitle(newTitle);
}}
onCompletionChange={handleActivityCompletionChange}
/>
<PropertyBox>
{activity.type === 'Task' &&
DueAtFieldContextProvider &&
AssigneeFieldContextProvider && (
<>
<DueAtFieldContextProvider>
<RecordInlineCell />
</DueAtFieldContextProvider>
<AssigneeFieldContextProvider>
<RecordInlineCell />
</AssigneeFieldContextProvider>
</>
)}
<ActivityTargetsInlineCell
activity={activity as unknown as GraphQLActivity}
/>
</PropertyBox>
</StyledTopContainer>
<ActivityBodyEditor
activity={activity}
onChange={updateTitleFromBody}
/>
</StyledUpperPartContainer>
{showComment && (
<ActivityComments
activity={activity}
scrollableContainerRef={containerRef}
/>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { CompanyChip } from '@/companies/components/CompanyChip';
import { PersonChip } from '@/people/components/PersonChip';
import { getLogoUrlFromDomainName } from '~/utils';
const StyledContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
`;
// TODO: fix edges pagination formatting on n+N
export const ActivityTargetChips = ({ targets }: { targets?: any }) => {
if (!targets) {
return null;
}
return (
<StyledContainer>
{targets?.map(({ company, person }: any) => {
if (company) {
return (
<CompanyChip
key={company.id}
id={company.id}
name={company.name}
avatarUrl={getLogoUrlFromDomainName(company.domainName)}
/>
);
}
if (person) {
return (
<PersonChip
key={person.id}
id={person.id}
name={person.name.firstName + ' ' + person.name.lastName}
avatarUrl={person.avatarUrl ?? undefined}
/>
);
}
return <></>;
})}
</StyledContainer>
);
};

View File

@ -0,0 +1,74 @@
import styled from '@emotion/styled';
import { ActivityType } from '@/activities/types/Activity';
import {
Checkbox,
CheckboxShape,
CheckboxSize,
} from '@/ui/input/components/Checkbox';
const StyledEditableTitleInput = styled.input<{
completed: boolean;
value: string;
}>`
background: transparent;
border: none;
color: ${({ theme, value }) =>
value ? theme.font.color.primary : theme.font.color.light};
display: flex;
flex-direction: column;
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.md};
outline: none;
text-decoration: ${({ completed }) => (completed ? 'line-through' : 'none')};
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
}
width: calc(100% - ${({ theme }) => theme.spacing(2)});
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
type ActivityTitleProps = {
title: string;
type: ActivityType;
completed: boolean;
onTitleChange: (title: string) => void;
onCompletionChange: (value: boolean) => void;
};
export const ActivityTitle = ({
title,
completed,
type,
onTitleChange,
onCompletionChange,
}: ActivityTitleProps) => (
<StyledContainer>
{type === 'Task' && (
<Checkbox
size={CheckboxSize.Large}
shape={CheckboxShape.Rounded}
checked={completed}
onCheckedChange={(value) => onCompletionChange(value)}
/>
)}
<StyledEditableTitleInput
autoComplete="off"
autoFocus
placeholder={`${type} title`}
onChange={(event) => onTitleChange(event.target.value)}
value={title}
completed={completed}
/>
</StyledContainer>
);

View File

@ -0,0 +1,35 @@
import { useTheme } from '@emotion/react';
import { Activity } from '@/activities/types/Activity';
import {
Chip,
ChipAccent,
ChipSize,
ChipVariant,
} from '@/ui/display/chip/components/Chip';
import { IconCheckbox, IconNotes } from '@/ui/display/icon';
type ActivityTypeDropdownProps = {
activity: Pick<Activity, 'type'>;
};
export const ActivityTypeDropdown = ({
activity,
}: ActivityTypeDropdownProps) => {
const theme = useTheme();
return (
<Chip
label={activity.type}
leftComponent={
activity.type === 'Note' ? (
<IconNotes size={theme.icon.size.md} />
) : (
<IconCheckbox size={theme.icon.size.md} />
)
}
size={ChipSize.Large}
accent={ChipAccent.TextSecondary}
variant={ChipVariant.Highlighted}
/>
);
};

View File

@ -0,0 +1,64 @@
import { IconDotsVertical, IconDownload, IconTrash } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type AttachmentDropdownProps = {
onDownload: () => void;
onDelete: () => void;
scopeKey: string;
};
export const AttachmentDropdown = ({
onDownload,
onDelete,
scopeKey,
}: AttachmentDropdownProps) => {
const dropdownScopeId = `${scopeKey}-settings-field-active-action-dropdown`;
const { closeDropdown } = useDropdown({ dropdownScopeId });
const handleDownload = () => {
onDownload();
closeDropdown();
};
const handleDelete = () => {
onDelete();
closeDropdown();
};
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text="Download"
LeftIcon={IconDownload}
onClick={handleDownload}
/>
<MenuItem
text="Delete"
accent="danger"
LeftIcon={IconTrash}
onClick={handleDelete}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownHotkeyScope={{
scope: dropdownScopeId,
}}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,64 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
Attachment,
AttachmentType,
} from '@/activities/files/types/Attachment';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import {
IconFile,
IconFileText,
IconFileZip,
IconHeadphones,
IconPhoto,
IconPresentation,
IconTable,
IconVideo,
} from '@/ui/input/constants/icons';
const StyledIconContainer = styled.div<{ background: string }>`
align-items: center;
background: ${({ background }) => background};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.grayScale.gray0};
display: flex;
flex-shrink: 0;
height: 20px;
justify-content: center;
width: 20px;
`;
const IconMapping: { [key in AttachmentType]: IconComponent } = {
Archive: IconFileZip,
Audio: IconHeadphones,
Image: IconPhoto,
Presentation: IconPresentation,
Spreadsheet: IconTable,
TextDocument: IconFileText,
Video: IconVideo,
Other: IconFile,
};
export const AttachmentIcon = ({ attachment }: { attachment: Attachment }) => {
const theme = useTheme();
const IconColors: { [key in AttachmentType]: string } = {
Archive: theme.color.gray,
Audio: theme.color.pink,
Image: theme.color.yellow,
Presentation: theme.color.orange,
Spreadsheet: theme.color.turquoise,
TextDocument: theme.color.blue,
Video: theme.color.purple,
Other: theme.color.gray,
};
const Icon = IconMapping[attachment.type];
return (
<StyledIconContainer background={IconColors[attachment.type]}>
{Icon && <Icon size={theme.icon.size.sm} />}
</StyledIconContainer>
);
};

View File

@ -0,0 +1,76 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { Attachment } from '@/activities/files/types/Attachment';
import { AttachmentRow } from './AttachmentRow';
type AttachmentListProps = {
title: string;
attachments: Attachment[];
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 StyledAttachmentContainer = styled.div`
align-items: flex-start;
align-self: stretch;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
disply: flex;
flex-flow: column nowrap;
justify-content: center;
width: 100%;
`;
export const AttachmentList = ({
title,
attachments,
button,
}: AttachmentListProps) => (
<>
{attachments && attachments.length > 0 && (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{title} <StyledCount>{attachments.length}</StyledCount>
</StyledTitle>
{button}
</StyledTitleBar>
<StyledAttachmentContainer>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
</StyledAttachmentContainer>
</StyledContainer>
)}
</>
);

View File

@ -0,0 +1,104 @@
import { useMemo } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AttachmentDropdown } from '@/activities/files/components/AttachmentDropdown';
import { AttachmentIcon } from '@/activities/files/components/AttachmentIcon';
import { Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import {
FieldContext,
GenericFieldContextType,
} from '@/object-record/field/contexts/FieldContext';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { IconCalendar } from '@/ui/display/icon';
import { formatToHumanReadableDate } from '~/utils';
const StyledRow = styled.div`
align-items: center;
align-self: stretch;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledLeftContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledRightContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledCalendarIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
`;
const StyledLink = styled.a`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
text-decoration: none;
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
const theme = useTheme();
const fieldContext = useMemo(
() => ({ recoilScopeId: attachment?.id ?? '' }),
[attachment?.id],
);
const { deleteOneRecord: deleteOneAttachment } =
useDeleteOneRecord<Attachment>({
objectNameSingular: 'attachment',
});
const handleDelete = () => {
deleteOneAttachment(attachment.id);
};
return (
<FieldContext.Provider value={fieldContext as GenericFieldContextType}>
<StyledRow>
<StyledLeftContent>
<AttachmentIcon attachment={attachment} />
<StyledLink
href={
process.env.REACT_APP_SERVER_BASE_URL +
'/files/' +
attachment.fullPath
}
target="__blank"
>
{attachment.name}
</StyledLink>
</StyledLeftContent>
<StyledRightContent>
<StyledCalendarIconContainer>
<IconCalendar size={theme.icon.size.md} />
</StyledCalendarIconContainer>
{formatToHumanReadableDate(attachment.createdAt)}
<AttachmentDropdown
scopeKey={attachment.id}
onDelete={handleDelete}
onDownload={() => {
downloadFile(attachment.fullPath, attachment.name);
}}
/>
</StyledRightContent>
</StyledRow>
</FieldContext.Provider>
);
};

View File

@ -0,0 +1,152 @@
import { ChangeEvent, useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { AttachmentList } from '@/activities/files/components/AttachmentList';
import { useAttachments } from '@/activities/files/hooks/useAttachments';
import { Attachment } from '@/activities/files/types/Attachment';
import { getFileType } from '@/activities/files/utils/getFileType';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
import { FileFolder, useUploadFileMutation } from '~/generated/graphql';
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 StyledAttachmentsContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
overflow: auto;
`;
const StyledFileInput = styled.input`
display: none;
`;
export const Attachments = ({
targetableEntity,
}: {
targetableEntity: ActivityTargetableEntity;
}) => {
const inputFileRef = useRef<HTMLInputElement>(null);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { attachments } = useAttachments(targetableEntity);
const [uploadFile] = useUploadFileMutation();
const { createOneRecord: createOneAttachment } =
useCreateOneRecord<Attachment>({
objectNameSingular: 'attachment',
});
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) onUploadFile?.(e.target.files[0]);
};
const handleUploadFileClick = () => {
inputFileRef?.current?.click?.();
};
const onUploadFile = async (file: File) => {
const result = await uploadFile({
variables: {
file,
fileFolder: FileFolder.Attachment,
},
});
const attachmentUrl = result?.data?.uploadFile;
if (!attachmentUrl) {
return;
}
if (!createOneAttachment) {
return;
}
await createOneAttachment({
authorId: currentWorkspaceMember?.id,
name: file.name,
fullPath: attachmentUrl,
type: getFileType(file.name),
companyId:
targetableEntity.type == 'Company' ? targetableEntity.id : null,
personId: targetableEntity.type == 'Person' ? targetableEntity.id : null,
});
};
if (attachments?.length === 0 && targetableEntity.type !== 'Custom') {
return (
<StyledTaskGroupEmptyContainer>
<StyledFileInput
ref={inputFileRef}
onChange={handleFileChange}
type="file"
/>
<StyledEmptyTaskGroupTitle>No files yet</StyledEmptyTaskGroupTitle>
<StyledEmptyTaskGroupSubTitle>Upload one:</StyledEmptyTaskGroupSubTitle>
<Button
Icon={IconPlus}
title="Add file"
variant="secondary"
onClick={handleUploadFileClick}
/>
</StyledTaskGroupEmptyContainer>
);
}
return (
<StyledAttachmentsContainer>
<StyledFileInput
ref={inputFileRef}
onChange={handleFileChange}
type="file"
/>
<AttachmentList
title="All"
attachments={attachments ?? []}
button={
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add file"
onClick={handleUploadFileClick}
></Button>
}
/>
</StyledAttachmentsContainer>
);
};

View File

@ -0,0 +1,20 @@
import { Attachment } from '@/activities/files/types/Attachment';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { ActivityTargetableEntity } from '../../types/ActivityTargetableEntity';
export const useAttachments = (entity: ActivityTargetableEntity) => {
const { records: attachments } = useFindManyRecords({
objectNameSingular: 'attachment',
filter: {
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id },
},
orderBy: {
createdAt: 'DescNullsFirst',
},
});
return {
attachments: attachments as Attachment[],
};
};

View File

@ -0,0 +1,20 @@
export type Attachment = {
id: string;
name: string;
fullPath: string;
type: AttachmentType;
companyId: string;
personId: string;
activityId: string;
authorId: string;
createdAt: string;
};
export type AttachmentType =
| 'Archive'
| 'Audio'
| 'Image'
| 'Presentation'
| 'Spreadsheet'
| 'TextDocument'
| 'Video'
| 'Other';

View File

@ -0,0 +1,18 @@
export const downloadFile = (fullPath: string, fileName: string) => {
fetch(process.env.REACT_APP_SERVER_BASE_URL + '/files/' + fullPath)
.then((resp) =>
resp.status === 200
? resp.blob()
: Promise.reject('Failed downloading file'),
)
.then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
});
};

View File

@ -0,0 +1,67 @@
import { AttachmentType } from '@/activities/files/types/Attachment';
const FileExtensionMapping: { [key: string]: AttachmentType } = {
doc: 'TextDocument',
docm: 'TextDocument',
docx: 'TextDocument',
dot: 'TextDocument',
dotx: 'TextDocument',
odt: 'TextDocument',
pdf: 'TextDocument',
txt: 'TextDocument',
rtf: 'TextDocument',
ps: 'TextDocument',
tex: 'TextDocument',
pages: 'TextDocument',
xls: 'Spreadsheet',
xlsb: 'Spreadsheet',
xlsm: 'Spreadsheet',
xlsx: 'Spreadsheet',
xltx: 'Spreadsheet',
csv: 'Spreadsheet',
tsv: 'Spreadsheet',
ods: 'Spreadsheet',
numbers: 'Spreadsheet',
ppt: 'Presentation',
pptx: 'Presentation',
potx: 'Presentation',
odp: 'Presentation',
html: 'Presentation',
key: 'Presentation',
kth: 'Presentation',
png: 'Image',
jpg: 'Image',
jpeg: 'Image',
svg: 'Image',
gif: 'Image',
webp: 'Image',
heif: 'Image',
tif: 'Image',
tiff: 'Image',
bmp: 'Image',
ico: 'Image',
mp4: 'Video',
avi: 'Video',
mov: 'Video',
wmv: 'Video',
mpg: 'Video',
mpeg: 'Video',
mp3: 'Audio',
wav: 'Audio',
ogg: 'Audio',
wma: 'Audio',
zip: 'Archive',
tar: 'Archive',
iso: 'Archive',
gz: 'Archive',
rar: 'Archive',
'7z': 'Archive',
};
export const getFileType = (fileName: string): AttachmentType => {
const fileExtension = fileName.split('.').at(-1);
if (!fileExtension) {
return 'Other';
}
return FileExtensionMapping[fileExtension.toLowerCase()] ?? 'Other';
};

View File

@ -0,0 +1,75 @@
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
export const useHandleCheckableActivityTargetChange = ({
activityId,
currentActivityTargets,
}: {
activityId: string;
currentActivityTargets: any[];
}) => {
const { createOneRecord: createOneActivityTarget } =
useCreateOneRecord<ActivityTarget>({
objectNameSingular: 'activityTarget',
});
const { deleteOneRecord: deleteOneActivityTarget } = useDeleteOneRecord({
objectNameSingular: 'activityTarget',
});
return async (
entityValues: Record<string, boolean>,
entitiesToSelect: any,
selectedEntities: any,
) => {
if (!activityId) {
return;
}
const currentActivityTargetRecordIds = currentActivityTargets.map(
({ companyId, personId }) => companyId ?? personId ?? '',
);
const idsToAdd = Object.entries(entityValues)
.filter(
([recordId, value]) =>
value && !currentActivityTargetRecordIds.includes(recordId),
)
.map(([id, _]) => id);
const idsToDelete = Object.entries(entityValues)
.filter(([_, value]) => !value)
.map(([id, _]) => id);
if (idsToAdd.length) {
idsToAdd.map((id) => {
const entityFromToSelect = entitiesToSelect.filter(
(entity: any) => entity.id === id,
).length
? entitiesToSelect.filter((entity: any) => entity.id === id)[0]
: null;
const entityFromSelected = selectedEntities.filter(
(entity: any) => entity.id === id,
).length
? selectedEntities.filter((entity: any) => entity.id === id)[0]
: null;
const entity = entityFromToSelect ?? entityFromSelected;
createOneActivityTarget?.({
activityId: activityId,
companyId: entity.record.__typename === 'Company' ? entity.id : null,
personId: entity.record.__typename === 'Person' ? entity.id : null,
});
});
}
if (idsToDelete.length) {
idsToDelete.map((id) => {
const currentActivityTargetId = currentActivityTargets.filter(
({ companyId, personId }) => companyId === id || personId === id,
)[0].id;
deleteOneActivityTarget?.(currentActivityTargetId);
});
}
};
};

View File

@ -0,0 +1,20 @@
import { useRecoilState } from 'recoil';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { viewableActivityIdState } from '../states/viewableActivityIdState';
export const useOpenActivityRightDrawer = () => {
const { openRightDrawer } = useRightDrawer();
const [, setViewableActivityId] = useRecoilState(viewableActivityIdState);
const setHotkeyScope = useSetHotkeyScope();
return (activityId: string) => {
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableActivityId(activityId);
openRightDrawer(RightDrawerPages.EditActivity);
};
};

View File

@ -0,0 +1,91 @@
import { useCallback } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Activity, ActivityType } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { activityTargetableEntityArrayState } from '../states/activityTargetableEntityArrayState';
import { viewableActivityIdState } from '../states/viewableActivityIdState';
import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity';
import { getTargetableEntitiesWithParents } from '../utils/getTargetableEntitiesWithParents';
export const useOpenCreateActivityDrawer = () => {
const { openRightDrawer } = useRightDrawer();
const { createOneRecord: createOneActivityTarget } =
useCreateOneRecord<ActivityTarget>({
objectNameSingular: 'activityTarget',
});
const { createOneRecord: createOneActivity } = useCreateOneRecord<Activity>({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const setHotkeyScope = useSetHotkeyScope();
const [, setActivityTargetableEntityArray] = useRecoilState(
activityTargetableEntityArrayState,
);
const [, setViewableActivityId] = useRecoilState(viewableActivityIdState);
return useCallback(
async ({
type,
targetableEntities,
assigneeId,
}: {
type: ActivityType;
targetableEntities?: ActivityTargetableEntity[];
assigneeId?: string;
}) => {
const targetableEntitiesWithRelations = targetableEntities
? getTargetableEntitiesWithParents(targetableEntities)
: [];
const createdActivity = await createOneActivity?.({
authorId: currentWorkspaceMember?.id,
assigneeId:
assigneeId ?? isNonEmptyString(currentWorkspaceMember?.id)
? currentWorkspaceMember?.id
: undefined,
type: type,
});
if (!createdActivity) {
return;
}
await Promise.all(
targetableEntitiesWithRelations.map(async (targetableEntity) => {
await createOneActivityTarget?.({
companyId:
targetableEntity.type === 'Company' ? targetableEntity.id : null,
personId:
targetableEntity.type === 'Person' ? targetableEntity.id : null,
activityId: createdActivity.id,
});
}),
);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableActivityId(createdActivity.id);
setActivityTargetableEntityArray(targetableEntities ?? []);
openRightDrawer(RightDrawerPages.CreateActivity);
},
[
openRightDrawer,
setActivityTargetableEntityArray,
setHotkeyScope,
setViewableActivityId,
createOneActivity,
createOneActivityTarget,
currentWorkspaceMember,
],
);
};

View File

@ -0,0 +1,42 @@
import { useRecoilCallback } from 'recoil';
import { ActivityType } from '@/activities/types/Activity';
import { selectedRowIdsSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsSelector';
import {
ActivityTargetableEntity,
ActivityTargetableEntityType,
} from '../types/ActivityTargetableEntity';
import { useOpenCreateActivityDrawer } from './useOpenCreateActivityDrawer';
export const useOpenCreateActivityDrawerForSelectedRowIds = () => {
const openCreateActivityDrawer = useOpenCreateActivityDrawer();
return useRecoilCallback(
({ snapshot }) =>
(
type: ActivityType,
entityType: ActivityTargetableEntityType,
relatedEntities?: ActivityTargetableEntity[],
) => {
const selectedRowIds = Object.keys(
snapshot.getLoadable(selectedRowIdsSelector).getValue(),
);
let activityTargetableEntityArray: ActivityTargetableEntity[] =
selectedRowIds.map((id) => ({
type: entityType,
id,
}));
if (relatedEntities) {
activityTargetableEntityArray =
activityTargetableEntityArray.concat(relatedEntities);
}
openCreateActivityDrawer({
type,
targetableEntities: activityTargetableEntityArray,
});
},
[openCreateActivityDrawer],
);
};

View File

@ -0,0 +1,168 @@
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from '@apollo/client';
import styled from '@emotion/styled';
import { useHandleCheckableActivityTargetChange } from '@/activities/hooks/useHandleCheckableActivityTargetChange';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '@/activities/utils/flatMapAndSortEntityForSelectArrayByName';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { MultipleEntitySelect } from '@/object-record/relation-picker/components/MultipleEntitySelect';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { assertNotNull } from '~/utils/assert';
type ActivityTargetInlineCellEditModeProps = {
activityId: string;
activityTargets: Array<Pick<ActivityTarget, 'id' | 'personId' | 'companyId'>>;
};
const StyledSelectContainer = styled.div`
left: 0px;
position: absolute;
top: -8px;
`;
export const ActivityTargetInlineCellEditMode = ({
activityId,
activityTargets,
}: ActivityTargetInlineCellEditModeProps) => {
const [searchFilter, setSearchFilter] = useState('');
const initialPeopleIds = useMemo(
() =>
activityTargets
?.filter(({ personId }) => personId !== null)
.map(({ personId }) => personId)
.filter(assertNotNull) ?? [],
[activityTargets],
);
const initialCompanyIds = useMemo(
() =>
activityTargets
?.filter(({ companyId }) => companyId !== null)
.map(({ companyId }) => companyId)
.filter(assertNotNull) ?? [],
[activityTargets],
);
const initialSelectedEntityIds = useMemo(
() =>
[...initialPeopleIds, ...initialCompanyIds].reduce<
Record<string, boolean>
>((result, entityId) => ({ ...result, [entityId]: true }), {}),
[initialPeopleIds, initialCompanyIds],
);
const { findManyRecordsQuery: findManyPeopleQuery } = useObjectMetadataItem({
objectNameSingular: 'person',
});
const { findManyRecordsQuery: findManyCompaniesQuery } =
useObjectMetadataItem({
objectNameSingular: 'company',
});
const useFindManyPeopleQuery = (options: any) =>
useQuery(findManyPeopleQuery, options);
const useFindManyCompaniesQuery = (options: any) =>
useQuery(findManyCompaniesQuery, options);
const [selectedEntityIds, setSelectedEntityIds] = useState<
Record<string, boolean>
>(initialSelectedEntityIds);
const { identifiersMapper, searchQuery } = useRelationPicker();
const people = useFilteredSearchEntityQuery({
queryHook: useFindManyPeopleQuery,
filters: [
{
fieldNames: searchQuery?.computeFilterFields?.('person') ?? [],
filter: searchFilter,
},
],
orderByField: 'createdAt',
mappingFunction: (record: any) => identifiersMapper?.(record, 'person'),
selectedIds: initialPeopleIds,
objectNameSingular: 'person',
limit: 3,
});
const companies = useFilteredSearchEntityQuery({
queryHook: useFindManyCompaniesQuery,
filters: [
{
fieldNames: searchQuery?.computeFilterFields?.('company') ?? [],
filter: searchFilter,
},
],
orderByField: 'createdAt',
mappingFunction: (record: any) => identifiersMapper?.(record, 'company'),
selectedIds: initialCompanyIds,
objectNameSingular: 'company',
limit: 3,
});
const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([
people.selectedEntities,
companies.selectedEntities,
]);
const filteredSelectedEntities =
flatMapAndSortEntityForSelectArrayOfArrayByName([
people.filteredSelectedEntities,
companies.filteredSelectedEntities,
]);
const entitiesToSelect = flatMapAndSortEntityForSelectArrayOfArrayByName([
people.entitiesToSelect,
companies.entitiesToSelect,
]);
const handleCheckItemsChange = useHandleCheckableActivityTargetChange({
activityId,
currentActivityTargets: activityTargets,
});
const { closeInlineCell: closeEditableField } = useInlineCell();
const handleSubmit = useCallback(() => {
handleCheckItemsChange(
selectedEntityIds,
entitiesToSelect,
selectedEntities,
);
closeEditableField();
}, [
closeEditableField,
entitiesToSelect,
handleCheckItemsChange,
selectedEntities,
selectedEntityIds,
]);
const handleCancel = () => {
closeEditableField();
};
return (
<StyledSelectContainer>
<MultipleEntitySelect
entities={{
entitiesToSelect,
filteredSelectedEntities,
selectedEntities,
loading: false,
}}
onChange={setSelectedEntityIds}
onSearchFilterChange={setSearchFilter}
searchFilter={searchFilter}
value={selectedEntityIds}
onCancel={handleCancel}
onSubmit={handleSubmit}
/>
</StyledSelectContainer>
);
};

View File

@ -0,0 +1,57 @@
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { RecordInlineCellContainer } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer';
import { FieldRecoilScopeContext } from '@/object-record/record-inline-cell/states/recoil-scope-contexts/FieldRecoilScopeContext';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { IconArrowUpRight, IconPencil } from '@/ui/display/icon';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
type ActivityTargetsInlineCellProps = {
activity?: Pick<GraphQLActivity, 'id'> & {
activityTargets?: {
edges: Array<{
node: Pick<ActivityTarget, 'id'>;
}> | null;
};
};
};
export const ActivityTargetsInlineCell = ({
activity,
}: ActivityTargetsInlineCellProps) => {
const activityTargetIds =
activity?.activityTargets?.edges?.map(
(activityTarget) => activityTarget.node.id,
) ?? [];
const { records: activityTargets } = useFindManyRecords<ActivityTarget>({
objectNameSingular: 'activityTarget',
filter: { id: { in: activityTargetIds } },
});
return (
<RecoilScope CustomRecoilScopeContext={FieldRecoilScopeContext}>
<RecordInlineCellContainer
buttonIcon={IconPencil}
customEditHotkeyScope={{
scope: RelationPickerHotkeyScope.RelationPicker,
}}
IconLabel={IconArrowUpRight}
editModeContent={
<ActivityTargetInlineCellEditMode
activityId={activity?.id ?? ''}
activityTargets={activityTargets as any}
/>
}
label="Relations"
displayModeContent={<ActivityTargetChips targets={activityTargets} />}
isDisplayModeContentEmpty={
activity?.activityTargets?.edges?.length === 0
}
/>
</RecoilScope>
);
};

View File

@ -0,0 +1,128 @@
import { useMemo } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { Note } from '@/activities/types/Note';
import {
FieldContext,
GenericFieldContextType,
} from '@/object-record/field/contexts/FieldContext';
import { IconComment } from '@/ui/display/icon';
const StyledCard = styled.div<{ isSingleNote: boolean }>`
align-items: flex-start;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
height: 300px;
justify-content: space-between;
max-width: ${({ isSingleNote }) => (isSingleNote ? '300px' : 'unset')};
`;
const StyledCardDetailsContainer = styled.div`
align-items: flex-start;
align-self: stretch;
cursor: pointer;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
height: calc(100% - 45px);
justify-content: start;
padding: ${({ theme }) => theme.spacing(2)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledNoteTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledCardContent = styled.div`
align-self: stretch;
color: ${({ theme }) => theme.font.color.secondary};
line-break: anywhere;
margin-top: ${({ theme }) => theme.spacing(2)};
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-line;
width: 100%;
`;
const StyledFooter = styled.div`
align-items: center;
align-self: stretch;
border-top: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledCommentIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
export const NoteCard = ({
note,
isSingleNote,
}: {
note: Note;
isSingleNote: boolean;
}) => {
const theme = useTheme();
const openActivityRightDrawer = useOpenActivityRightDrawer();
const noteBody = JSON.parse(note.body ?? '[]');
const body = noteBody.length
? noteBody
.map((x: any) =>
Array.isArray(x.content)
? x.content?.map((content: any) => content?.text).join(' ')
: x.content?.text,
)
.filter((x: string) => x)
.join('\n')
: '';
const fieldContext = useMemo(
() => ({ recoilScopeId: note?.id ?? '' }),
[note?.id],
);
return (
<FieldContext.Provider value={fieldContext as GenericFieldContextType}>
<StyledCard isSingleNote={isSingleNote}>
<StyledCardDetailsContainer
onClick={() => openActivityRightDrawer(note.id)}
>
<StyledNoteTitle>{note.title ?? 'Task Title'}</StyledNoteTitle>
<StyledCardContent>{body}</StyledCardContent>
</StyledCardDetailsContainer>
<StyledFooter>
<ActivityTargetsInlineCell
activity={note as unknown as GraphQLActivity}
/>
{note.comments && note.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
{note.comments.length}
</StyledCommentIcon>
)}
</StyledFooter>
</StyledCard>
</FieldContext.Provider>
);
};

View File

@ -0,0 +1,72 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { Note } from '@/activities/types/Note';
import { NoteCard } from './NoteCard';
type NoteListProps = {
title: string;
notes: Note[];
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 StyledNoteContainer = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing(4)};
grid-auto-rows: 1fr;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
width: 100%;
`;
export const NoteList = ({ title, notes, button }: NoteListProps) => (
<>
{notes && notes.length > 0 && (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{title} <StyledCount>{notes.length}</StyledCount>
</StyledTitle>
{button}
</StyledTitleBar>
<StyledNoteContainer>
{notes.map((note) => (
<NoteCard
key={note.id}
note={note}
isSingleNote={notes.length == 1}
/>
))}
</StyledNoteContainer>
</StyledContainer>
)}
</>
);

View File

@ -0,0 +1,94 @@
import styled from '@emotion/styled';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { NoteList } from '@/activities/notes/components/NoteList';
import { useNotes } from '@/activities/notes/hooks/useNotes';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
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 StyledNotesContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
overflow: auto;
`;
export const Notes = ({ entity }: { entity: ActivityTargetableEntity }) => {
const { notes } = useNotes(entity);
const openCreateActivity = useOpenCreateActivityDrawer();
if (notes?.length === 0 && entity.type !== 'Custom') {
return (
<StyledTaskGroupEmptyContainer>
<StyledEmptyTaskGroupTitle>No note yet</StyledEmptyTaskGroupTitle>
<StyledEmptyTaskGroupSubTitle>Create one:</StyledEmptyTaskGroupSubTitle>
<Button
Icon={IconPlus}
title="New note"
variant="secondary"
onClick={() =>
openCreateActivity({
type: 'Note',
targetableEntities: [entity],
})
}
/>
</StyledTaskGroupEmptyContainer>
);
}
return (
<StyledNotesContainer>
<NoteList
title="All"
notes={notes ?? []}
button={
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add note"
onClick={() =>
openCreateActivity({
type: 'Note',
targetableEntities: [entity],
})
}
></Button>
}
/>
</StyledNotesContainer>
);
};

View File

@ -0,0 +1,34 @@
import { Note } from '@/activities/types/Note';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { ActivityTargetableEntity } from '../../types/ActivityTargetableEntity';
export const useNotes = (entity: ActivityTargetableEntity) => {
const { records: activityTargets } = useFindManyRecords({
objectNameSingular: 'activityTarget',
filter: {
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id },
},
});
const filter = {
id: {
in: activityTargets?.map((activityTarget) => activityTarget.activityId),
},
type: { eq: 'Note' },
};
const orderBy = {
createdAt: 'AscNullsFirst',
} as any; // TODO: finish typing
const { records: notes } = useFindManyRecords({
skip: !activityTargets?.length,
objectNameSingular: 'activity',
filter,
orderBy,
});
return {
notes: notes as Note[],
};
};

View File

@ -0,0 +1,33 @@
import { useRecoilState } from 'recoil';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { IconTrash } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
type ActivityActionBarProps = {
activityId: string;
};
export const ActivityActionBar = ({ activityId }: ActivityActionBarProps) => {
const [, setIsRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
const { deleteOneRecord: deleteOneActivity } = useDeleteOneRecord({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
const deleteActivity = () => {
deleteOneActivity?.(activityId);
setIsRightDrawerOpen(false);
};
return (
<LightIconButton
Icon={IconTrash}
onClick={deleteActivity}
accent="tertiary"
size="medium"
/>
);
};

View File

@ -0,0 +1,59 @@
import React from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { ActivityEditor } from '@/activities/components/ActivityEditor';
import { Activity } from '@/activities/types/Activity';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import '@blocknote/core/style.css';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
overflow-y: auto;
position: relative;
`;
type RightDrawerActivityProps = {
activityId: string;
showComment?: boolean;
autoFillTitle?: boolean;
};
export const RightDrawerActivity = ({
activityId,
showComment = true,
autoFillTitle = false,
}: RightDrawerActivityProps) => {
const [, setEntityFields] = useRecoilState(
entityFieldsFamilyState(activityId),
);
const { record: activity } = useFindOneRecord({
objectNameSingular: 'activity',
objectRecordId: activityId,
skip: !activityId,
onCompleted: (activity: Activity) => {
setEntityFields(activity ?? {});
},
});
if (!activity) {
return <></>;
}
return (
<StyledContainer>
<ActivityEditor
activity={activity}
showComment={showComment}
autoFillTitle={autoFillTitle}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,21 @@
import { useRecoilValue } from 'recoil';
import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState';
import { RightDrawerActivity } from '../RightDrawerActivity';
export const RightDrawerCreateActivity = () => {
const viewableActivityId = useRecoilValue(viewableActivityIdState);
return (
<>
{viewableActivityId && (
<RightDrawerActivity
activityId={viewableActivityId}
showComment={false}
autoFillTitle={true}
/>
)}
</>
);
};

View File

@ -0,0 +1,17 @@
import { useRecoilValue } from 'recoil';
import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState';
import { RightDrawerActivity } from '../RightDrawerActivity';
export const RightDrawerEditActivity = () => {
const viewableActivityId = useRecoilValue(viewableActivityIdState);
return (
<>
{viewableActivityId && (
<RightDrawerActivity activityId={viewableActivityId} />
)}
</>
);
};

View File

@ -0,0 +1,10 @@
import { atom } from 'recoil';
import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity';
export const activityTargetableEntityArrayState = atom<
ActivityTargetableEntity[]
>({
key: 'activities/targetable-entity-array',
default: [],
});

View File

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

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const viewableActivityIdState = atom<string | null>({
key: 'activities/viewable-activity-id',
default: null,
});

View File

@ -0,0 +1,18 @@
import styled from '@emotion/styled';
import { CommentChip, CommentChipProps } from './CommentChip';
type CellCommentChipProps = CommentChipProps;
// TODO: tie those fixed values to the other components in the cell
const StyledCellWrapper = styled.div``;
export const CellCommentChip = ({ count, onClick }: CellCommentChipProps) => {
if (count === 0) return null;
return (
<StyledCellWrapper>
<CommentChip count={count} onClick={onClick} />
</StyledCellWrapper>
);
};

View File

@ -0,0 +1,61 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComment } from '@/ui/display/icon';
export type CommentChipProps = {
count: number;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
};
const StyledChip = styled.div`
align-items: center;
backdrop-filter: blur(6px);
background: ${({ theme }) => theme.background.transparent.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.light};
cursor: pointer;
display: flex;
flex-direction: row;
gap: 4px;
height: 26px;
justify-content: center;
max-width: 42px;
padding-left: 4px;
padding-right: 4px;
&:hover {
background: ${({ theme }) => theme.background.tertiary};
color: ${({ theme }) => theme.font.color.tertiary};
}
user-select: none;
`;
const StyledCount = styled.div`
align-items: center;
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
justify-content: center;
`;
export const CommentChip = ({ count, onClick }: CommentChipProps) => {
const theme = useTheme();
if (count === 0) return null;
const formattedCount = count > 99 ? '99+' : count;
return (
<StyledChip data-testid="comment-chip" onClick={onClick}>
<StyledCount>{formattedCount}</StyledCount>
<IconComment size={theme.icon.size.md} />
</StyledChip>
);
};

View File

@ -0,0 +1,74 @@
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CommentChip } from '../CommentChip';
const meta: Meta<typeof CommentChip> = {
title: 'Modules/Comments/CommentChip',
component: CommentChip,
decorators: [ComponentDecorator],
args: { count: 1 },
};
export default meta;
type Story = StoryObj<typeof CommentChip>;
const StyledTestCellContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.primary};
display: flex;
height: fit-content;
justify-content: space-between;
max-width: 250px;
min-width: 250px;
overflow: hidden;
text-wrap: nowrap;
`;
const StyledFakeCellText = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const OneComment: Story = {};
export const TenComments: Story = {
args: { count: 10 },
};
export const TooManyComments: Story = {
args: { count: 1000 },
};
export const InCellDefault: Story = {
args: { count: 12 },
decorators: [
(Story) => (
<StyledTestCellContainer>
<StyledFakeCellText>Fake short text</StyledFakeCellText>
<Story />
</StyledTestCellContainer>
),
],
};
export const InCellOverlappingBlur: Story = {
...InCellDefault,
decorators: [
(Story) => (
<StyledTestCellContainer>
<StyledFakeCellText>
Fake long text to demonstrate ellipsis
</StyledFakeCellText>
<Story />
</StyledTestCellContainer>
),
],
};

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[],
};
};

View File

@ -0,0 +1,105 @@
import React from 'react';
import styled from '@emotion/styled';
import { ActivityCreateButton } from '@/activities/components/ActivityCreateButton';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { TimelineItemsContainer } from './TimelineItemsContainer';
const StyledMainContainer = styled.div`
align-items: flex-start;
align-self: stretch;
border-top: ${({ theme }) =>
useIsMobile() ? `1px solid ${theme.border.color.medium}` : 'none'};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
`;
const StyledTimelineEmptyContainer = 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;
`;
const StyledEmptyTimelineTitle = 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 StyledEmptyTimelineSubTitle = 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)};
`;
export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => {
const { records: activityTargets, loading } = useFindManyRecords({
objectNameSingular: 'activityTarget',
filter: {
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id },
},
});
const { records: activities } = useFindManyRecords({
skip: !activityTargets?.length,
objectNameSingular: 'activity',
filter: {
id: {
in: activityTargets?.map((activityTarget) => activityTarget.activityId),
},
},
orderBy: {
createdAt: 'AscNullsFirst',
},
});
const openCreateActivity = useOpenCreateActivityDrawer();
if (loading || entity.type === 'Custom') {
return <></>;
}
if (!activities.length) {
return (
<StyledTimelineEmptyContainer>
<StyledEmptyTimelineTitle>No activity yet</StyledEmptyTimelineTitle>
<StyledEmptyTimelineSubTitle>Create one:</StyledEmptyTimelineSubTitle>
<ActivityCreateButton
onNoteClick={() =>
openCreateActivity({
type: 'Note',
targetableEntities: [entity],
})
}
onTaskClick={() =>
openCreateActivity({
type: 'Task',
targetableEntities: [entity],
})
}
/>
</StyledTimelineEmptyContainer>
);
}
return (
<StyledMainContainer>
<TimelineItemsContainer activities={activities as Activity[]} />
</StyledMainContainer>
);
};

View File

@ -0,0 +1,224 @@
import { Tooltip } from 'react-tooltip';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { Activity } from '@/activities/types/Activity';
import { IconCheckbox, IconNotes } from '@/ui/display/icon';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { Avatar } from '@/users/components/Avatar';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
const StyledAvatarContainer = styled.div`
align-items: center;
display: flex;
height: 26px;
justify-content: center;
user-select: none;
width: 26px;
z-index: 2;
`;
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
height: 16px;
justify-content: center;
text-decoration-line: underline;
width: 16px;
`;
const StyledActivityTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
flex: 1;
font-weight: ${({ theme }) => theme.font.weight.regular};
overflow: hidden;
`;
const StyledActivityLink = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-weight: ${({ theme }) => theme.font.weight.regular};
overflow: hidden;
text-decoration-line: underline;
text-overflow: ellipsis;
`;
const StyledItemContainer = styled.div`
align-content: center;
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
span {
color: ${({ theme }) => theme.font.color.secondary};
}
overflow: hidden;
`;
const StyledItemTitleContainer = styled.div`
display: flex;
flex: 1;
flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')};
gap: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
`;
const StyledItemAuthorText = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledItemTitle = styled.div`
display: flex;
flex-flow: row nowrap;
overflow: hidden;
`;
const StyledItemTitleDate = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-end;
margin-left: auto;
`;
const StyledVerticalLineContainer = styled.div`
align-items: center;
align-self: stretch;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
width: 26px;
z-index: 2;
`;
const StyledVerticalLine = styled.div`
align-self: stretch;
background: ${({ theme }) => theme.border.color.light};
flex-shrink: 0;
width: 2px;
`;
const StyledTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary};
box-shadow: 0px 2px 4px 3px
${({ theme }) => theme.background.transparent.light};
box-shadow: 2px 4px 16px 6px
${({ theme }) => theme.background.transparent.light};
color: ${({ theme }) => theme.font.color.primary};
opacity: 1;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
align-items: center;
align-self: stretch;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
height: ${({ isGap, theme }) =>
isGap ? (useIsMobile() ? theme.spacing(6) : theme.spacing(3)) : 'auto'};
overflow: hidden;
white-space: nowrap;
`;
type TimelineActivityProps = {
activity: Pick<
Activity,
| 'id'
| 'title'
| 'body'
| 'createdAt'
| 'completedAt'
| 'type'
| 'comments'
| 'dueAt'
> & { author: Pick<WorkspaceMember, 'name' | 'avatarUrl'> } & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
};
isLastActivity?: boolean;
};
export const TimelineActivity = ({
activity,
isLastActivity,
}: TimelineActivityProps) => {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(activity.createdAt);
const exactCreatedAt = beautifyExactDateTime(activity.createdAt);
const openActivityRightDrawer = useOpenActivityRightDrawer();
const theme = useTheme();
return (
<>
<StyledTimelineItemContainer>
<StyledAvatarContainer>
<Avatar
avatarUrl={activity.author.avatarUrl}
placeholder={activity.author.name.firstName ?? ''}
size="sm"
type="rounded"
/>
</StyledAvatarContainer>
<StyledItemContainer>
<StyledItemTitleContainer>
<StyledItemAuthorText>
<span>
{activity.author.name.firstName} {activity.author.name.lastName}
</span>
created a {activity.type.toLowerCase()}
</StyledItemAuthorText>
<StyledItemTitle>
<StyledIconContainer>
{activity.type === 'Note' && (
<IconNotes size={theme.icon.size.sm} />
)}
{activity.type === 'Task' && (
<IconCheckbox size={theme.icon.size.sm} />
)}
</StyledIconContainer>
{(activity.type === 'Note' || activity.type === 'Task') && (
<StyledActivityTitle
onClick={() => openActivityRightDrawer(activity.id)}
>
<StyledActivityLink title={activity.title ?? '(No Title)'}>
{activity.title ?? '(No Title)'}
</StyledActivityLink>
</StyledActivityTitle>
)}
</StyledItemTitle>
</StyledItemTitleContainer>
<StyledItemTitleDate id={`id-${activity.id}`}>
{beautifiedCreatedAt}
</StyledItemTitleDate>
<StyledTooltip
anchorSelect={`#id-${activity.id}`}
content={exactCreatedAt}
clickable
noArrow
/>
</StyledItemContainer>
</StyledTimelineItemContainer>
{!isLastActivity && (
<StyledTimelineItemContainer isGap>
<StyledVerticalLineContainer>
<StyledVerticalLine></StyledVerticalLine>
</StyledVerticalLineContainer>
</StyledTimelineItemContainer>
)}
</>
);
};

View File

@ -0,0 +1,71 @@
import { isNonEmptyArray } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import CommentCounter from '@/activities/comment/CommentCounter';
import { Activity } from '@/activities/types/Activity';
import { UserChip } from '@/users/components/UserChip';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { beautifyExactDate } from '~/utils/date-utils';
type TimelineActivityCardFooterProps = {
activity: Pick<Activity, 'id' | 'dueAt' | 'comments'> & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
};
};
const StyledContainer = styled.div`
align-items: center;
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledVerticalSeparator = styled.div`
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
height: 24px;
`;
const StyledComment = styled.div`
margin-left: auto;
`;
export const TimelineActivityCardFooter = ({
activity,
}: TimelineActivityCardFooterProps) => {
const hasComments = isNonEmptyArray(activity.comments || []);
return (
<>
{(activity.assignee || activity.dueAt || hasComments) && (
<StyledContainer>
{activity.assignee && (
<UserChip
id={activity.assignee.id}
name={
activity.assignee.name.firstName +
' ' +
activity.assignee.name.lastName ?? ''
}
avatarUrl={activity.assignee.avatarUrl ?? ''}
/>
)}
{activity.dueAt && (
<>
{activity.assignee && <StyledVerticalSeparator />}
{beautifyExactDate(activity.dueAt)}
</>
)}
<StyledComment>
{hasComments && (
<CommentCounter commentCount={activity.comments?.length || 0} />
)}
</StyledComment>
</StyledContainer>
)}
</>
);
};

View File

@ -0,0 +1,62 @@
import styled from '@emotion/styled';
import { ActivityType } from '@/activities/types/Activity';
import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip';
import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox';
const StyledTitleContainer = styled.div`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(2)};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
width: 100%;
`;
const StyledTitleText = styled.div<{
completed?: boolean;
hasCheckbox?: boolean;
}>`
text-decoration: ${({ completed }) => (completed ? 'line-through' : 'none')};
width: ${({ hasCheckbox, theme }) =>
!hasCheckbox ? '100%;' : `calc(100% - ${theme.spacing(5)});`};
`;
const StyledCheckboxContainer = styled.div<{ hasCheckbox?: boolean }>`
align-items: center;
display: flex;
justify-content: center;
`;
type TimelineActivityTitleProps = {
title: string;
completed?: boolean;
type: ActivityType;
onCompletionChange?: (value: boolean) => void;
};
export const TimelineActivityTitle = ({
title,
completed,
type,
onCompletionChange,
}: TimelineActivityTitleProps) => (
<StyledTitleContainer>
{type === 'Task' && (
<StyledCheckboxContainer
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCompletionChange?.(!completed);
}}
>
<Checkbox checked={completed ?? false} shape={CheckboxShape.Rounded} />
</StyledCheckboxContainer>
)}
<StyledTitleText completed={completed} hasCheckbox={type === 'Task'}>
<OverflowingTextWithTooltip text={title ? title : 'Task title'} />
</StyledTitleText>
</StyledTitleContainer>
);

View File

@ -0,0 +1,56 @@
import styled from '@emotion/styled';
import { ActivityForDrawer } from '@/activities/types/ActivityForDrawer';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { groupActivitiesByMonth } from '../utils/groupActivitiesByMonth';
import { TimelineActivityGroup } from './TimelingeActivityGroup';
const StyledTimelineContainer = styled.div`
align-items: center;
align-self: stretch;
display: flex;
flex: 1 0 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(4)};
width: calc(100% - ${({ theme }) => theme.spacing(8)});
`;
const StyledScrollWrapper = styled(ScrollWrapper)``;
export type TimelineItemsContainerProps = {
activities: ActivityForDrawer[];
};
export const TimelineItemsContainer = ({
activities,
}: TimelineItemsContainerProps) => {
const groupedActivities = groupActivitiesByMonth(activities);
return (
<StyledScrollWrapper>
<StyledTimelineContainer>
{groupedActivities.map((group, index) => (
<TimelineActivityGroup
key={group.year.toString() + group.month}
group={group}
month={new Date(group.items[0].createdAt).toLocaleString(
'default',
{ month: 'long' },
)}
year={
index === 0 || group.year !== groupedActivities[index - 1].year
? group.year
: undefined
}
/>
))}
</StyledTimelineContainer>
</StyledScrollWrapper>
);
};

View File

@ -0,0 +1,78 @@
import styled from '@emotion/styled';
import { ActivityGroup } from '../utils/groupActivitiesByMonth';
import { TimelineActivity } from './TimelineActivity';
type TimelineActivityGroupProps = {
group: ActivityGroup;
month: string;
year?: number;
};
const StyledActivityGroup = styled.div`
display: flex;
flex-flow: column;
gap: ${({ theme }) => theme.spacing(4)};
margin-bottom: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const StyledActivityGroupContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
const StyledActivityGroupBar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.xl};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
position: absolute;
top: 0;
width: 24px;
`;
const StyledMonthSeperator = styled.div`
align-items: center;
align-self: stretch;
color: ${({ theme }) => theme.font.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledMonthSeperatorLine = styled.div`
background: ${({ theme }) => theme.border.color.light};
border-radius: 50px;
flex: 1 0 0;
height: 1px;
`;
export const TimelineActivityGroup = ({
group,
month,
year,
}: TimelineActivityGroupProps) => {
return (
<StyledActivityGroup>
<StyledMonthSeperator>
{month} {year}
<StyledMonthSeperatorLine />
</StyledMonthSeperator>
<StyledActivityGroupContainer>
<StyledActivityGroupBar />
{group.items.map((activity, index) => (
<TimelineActivity
key={activity.id}
activity={activity}
isLastActivity={index === group.items.length - 1}
/>
))}
</StyledActivityGroupContainer>
</StyledActivityGroup>
);
};

View File

@ -0,0 +1,31 @@
import { ActivityForDrawer } from '@/activities/types/ActivityForDrawer';
export interface ActivityGroup {
month: number;
year: number;
items: ActivityForDrawer[];
}
export const groupActivitiesByMonth = (activities: ActivityForDrawer[]) => {
const acitivityGroups: ActivityGroup[] = [];
for (const activity of activities) {
const d = new Date(activity.createdAt);
const month = d.getMonth();
const year = d.getFullYear();
const matchingGroup = acitivityGroups.find(
(x) => x.year === year && x.month === month,
);
if (matchingGroup) {
matchingGroup.items.push(activity);
} else {
acitivityGroups.push({
year,
month,
items: [activity],
});
}
}
return acitivityGroups.sort((a, b) => b.year - a.year || b.month - a.month);
};

View File

@ -0,0 +1,23 @@
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export type ActivityType = 'Task' | 'Note';
export type Activity = {
__typename: 'Activity';
id: string;
createdAt: string;
updatedAt: string;
completedAt: string | null;
dueAt: string | null;
activityTargets: ActivityTarget[];
type: ActivityType;
title: string;
body: string;
author: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'>;
authorId: string;
assignee: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
assigneeId: string | null;
comments: Comment[];
};

View File

@ -0,0 +1,3 @@
import { Activity } from '@/activities/types/Activity';
export type ActivityForDrawer = Activity;

View File

@ -0,0 +1,15 @@
import { Activity } from '@/activities/types/Activity';
import { Company } from '@/companies/types/Company';
import { Person } from '@/people/types/Person';
export type ActivityTarget = {
id: string;
createdAt: string;
updatedAt: string;
companyId: string | null;
personId: string | null;
activity: Pick<Activity, 'id' | 'createdAt' | 'updatedAt'>;
person?: Pick<Person, 'id' | 'name' | 'avatarUrl'> | null;
company?: Pick<Company, 'id' | 'name' | 'domainName'> | null;
[key: string]: any;
};

View File

@ -0,0 +1,7 @@
export type ActivityTargetableEntityType = 'Person' | 'Company' | 'Custom';
export type ActivityTargetableEntity = {
id: string;
type: ActivityTargetableEntityType;
relatedEntities?: ActivityTargetableEntity[];
};

View File

@ -0,0 +1,7 @@
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { ActivityTargetableEntityType } from './ActivityTargetableEntity';
export type ActivityTargetableEntityForSelect = EntityForSelect & {
entityType: ActivityTargetableEntityType;
};

View File

@ -0,0 +1,10 @@
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export type Comment = {
id: string;
createdAt: string;
body: string;
updatedAt: string;
activityId: string;
author: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'>;
};

View File

@ -0,0 +1,33 @@
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export type ActivityType = 'Task' | 'Note';
type ActivityTargetNode = {
node: ActivityTarget;
};
type CommentNode = {
node: Comment;
};
export type GraphQLActivity = {
__typename: 'Activity';
id: string;
createdAt: string;
updatedAt: string;
completedAt: string | null;
dueAt: string | null;
activityTargets: {
edges: ActivityTargetNode[];
};
type: ActivityType;
title: string;
body: string;
author: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'>;
authorId: string;
assignee: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
assigneeId: string | null;
comments: CommentNode[];
};

View File

@ -0,0 +1,5 @@
import { Activity } from '@/activities/types/Activity';
export type Note = Activity & {
type: 'Note';
};

View File

@ -0,0 +1,5 @@
import { Activity } from '@/activities/types/Activity';
export type Task = Activity & {
type: 'Task';
};

View File

@ -0,0 +1,11 @@
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
export const flatMapAndSortEntityForSelectArrayOfArrayByName = <
T extends EntityForSelect,
>(
entityForSelectArray: T[][],
) => {
const sortByName = (a: T, b: T) => a.name.localeCompare(b.name);
return entityForSelectArray.flatMap((entity) => entity).sort(sortByName);
};

View File

@ -0,0 +1,16 @@
import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity';
export const getTargetableEntitiesWithParents = (
entities: ActivityTargetableEntity[],
): ActivityTargetableEntity[] => {
const entitiesWithRelations: ActivityTargetableEntity[] = [];
for (const entity of entities ?? []) {
entitiesWithRelations.push(entity);
if (entity.relatedEntities) {
for (const relatedEntity of entity.relatedEntities ?? []) {
entitiesWithRelations.push(relatedEntity);
}
}
}
return entitiesWithRelations;
};

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const CREATE_EVENT = gql`
mutation CreateEvent($type: String!, $data: JSON!) {
createEvent(type: $type, data: $data) {
success
}
}
`;

View File

@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { telemetryState } from '@/client-config/states/telemetryState';
import { useCreateEventMutation } from '~/generated/graphql';
interface EventLocation {
pathname: string;
}
export interface EventData {
location: EventLocation;
}
export const useEventTracker = () => {
const [telemetry] = useRecoilState(telemetryState);
const [createEventMutation] = useCreateEventMutation();
return useCallback(
(eventType: string, eventData: EventData) => {
if (telemetry.enabled) {
createEventMutation({
variables: {
type: eventType,
data: eventData,
},
});
}
},
[createEventMutation, telemetry],
);
};

View File

@ -0,0 +1,7 @@
import { EventData, useEventTracker } from './useEventTracker';
export const useTrackEvent = (eventType: string, eventData: EventData) => {
const eventTracker = useEventTracker();
return eventTracker(eventType, eventData);
};

View File

@ -0,0 +1,14 @@
import { ApolloProvider as ApolloProviderBase } from '@apollo/client';
import { useApolloFactory } from '@/apollo/hooks/useApolloFactory';
export const ApolloProvider = ({ children }: React.PropsWithChildren) => {
const apolloClient = useApolloFactory();
// This will attach the right apollo client to Apollo Dev Tools
window.__APOLLO_CLIENT__ = apolloClient;
return (
<ApolloProviderBase client={apolloClient}>{children}</ApolloProviderBase>
);
};

View File

@ -0,0 +1,65 @@
import { useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { useRecoilState } from 'recoil';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { AppPath } from '@/types/AppPath';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { ApolloFactory } from '../services/apollo.factory';
export const useApolloFactory = () => {
// eslint-disable-next-line twenty/no-state-useref
const apolloRef = useRef<ApolloFactory<NormalizedCacheObject> | null>(null);
const [isDebugMode] = useRecoilState(isDebugModeState);
const navigate = useNavigate();
const isMatchingLocation = useIsMatchingLocation();
const [tokenPair, setTokenPair] = useRecoilState(tokenPairState);
const apolloClient = useMemo(() => {
apolloRef.current = new ApolloFactory({
uri: `${REACT_APP_SERVER_BASE_URL}/graphql`,
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'cache-first',
},
},
connectToDevTools: isDebugMode,
// We don't want to re-create the client on token change or it will cause infinite loop
initialTokenPair: tokenPair,
onTokenPairChange: (tokenPair) => {
setTokenPair(tokenPair);
},
onUnauthenticatedError: () => {
setTokenPair(null);
if (
!isMatchingLocation(AppPath.Verify) &&
!isMatchingLocation(AppPath.SignIn) &&
!isMatchingLocation(AppPath.SignUp) &&
!isMatchingLocation(AppPath.Invite)
) {
navigate(AppPath.SignIn);
}
},
extraLinks: [],
isDebugMode,
});
return apolloRef.current.getClient();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setTokenPair, isDebugMode]);
useUpdateEffect(() => {
if (apolloRef.current) {
apolloRef.current.updateTokenPair(tokenPair);
}
}, [tokenPair]);
return apolloClient;
};

View File

@ -0,0 +1,156 @@
import {
ApolloCache,
DocumentNode,
OperationVariables,
useApolloClient,
} from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import {
EMPTY_QUERY,
useObjectMetadataItem,
} from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { optimisticEffectState } from '../states/optimisticEffectState';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition';
export const useOptimisticEffect = ({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const apolloClient = useApolloClient();
const { findManyRecordsQuery } = useObjectMetadataItem({
objectNameSingular,
});
const registerOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
<T>({
variables,
definition,
}: {
variables: OperationVariables;
definition: OptimisticEffectDefinition;
}) => {
if (findManyRecordsQuery === EMPTY_QUERY) {
throw new Error(
`Trying to register an optimistic effect for unknown object ${objectNameSingular}`,
);
}
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
const optimisticEffectWriter = ({
cache,
newData,
deletedRecordIds,
query,
variables,
objectMetadataItem,
}: {
cache: ApolloCache<unknown>;
newData: unknown;
deletedRecordIds?: string[];
variables: OperationVariables;
query: DocumentNode;
isUsingFlexibleBackend?: boolean;
objectMetadataItem?: ObjectMetadataItem;
}) => {
if (objectMetadataItem) {
const existingData = cache.readQuery({
query: findManyRecordsQuery,
variables,
});
if (!existingData) {
return;
}
cache.writeQuery({
query: findManyRecordsQuery,
variables,
data: {
[objectMetadataItem.namePlural]: definition.resolver({
currentData: (existingData as any)?.[
objectMetadataItem.namePlural
],
newData,
deletedRecordIds,
variables,
}),
},
});
return;
}
const existingData = cache.readQuery({
query,
variables,
});
if (!existingData) {
return;
}
};
const optimisticEffect = {
key: definition.key,
variables,
typename: definition.typename,
query: definition.query,
writer: optimisticEffectWriter,
objectMetadataItem: definition.objectMetadataItem,
isUsingFlexibleBackend: definition.isUsingFlexibleBackend,
} satisfies OptimisticEffect<T>;
set(optimisticEffectState, {
...optimisticEffects,
[definition.key]: optimisticEffect,
});
},
);
const triggerOptimisticEffects = useRecoilCallback(
({ snapshot }) =>
(typename: string, newData: unknown, deletedRecordIds?: string[]) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
for (const optimisticEffect of Object.values(optimisticEffects)) {
// We need to update the typename when createObject type differs from listObject types
// It is the case for apiKey, where the creation route returns an ApiKeyToken type
const formattedNewData = isNonEmptyArray(newData)
? newData.map((data: any) => {
return { ...data, __typename: typename };
})
: newData;
if (optimisticEffect.typename === typename) {
optimisticEffect.writer({
cache: apolloClient.cache,
query: optimisticEffect.query ?? ({} as DocumentNode),
newData: formattedNewData,
deletedRecordIds,
variables: optimisticEffect.variables,
isUsingFlexibleBackend: optimisticEffect.isUsingFlexibleBackend,
objectMetadataItem: optimisticEffect.objectMetadataItem,
});
}
}
},
[apolloClient.cache],
);
return {
registerOptimisticEffect,
triggerOptimisticEffects,
};
};

View File

@ -0,0 +1,24 @@
import { useApolloClient } from '@apollo/client';
export const useOptimisticEvict = () => {
const cache = useApolloClient().cache;
const performOptimisticEvict = (
typename: string,
fieldName: string,
fieldValue: string,
) => {
const serializedCache = cache.extract();
Object.values(serializedCache)
.filter((item) => item.__typename === typename)
.forEach((item) => {
if (item[fieldName] === fieldValue) {
cache.evict({ id: cache.identify(item) });
}
});
};
return {
performOptimisticEvict,
};
};

View File

@ -0,0 +1,10 @@
import { atom } from 'recoil';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
export const optimisticEffectState = atom<
Record<string, OptimisticEffect<unknown>>
>({
key: 'optimisticEffectState',
default: {},
});

View File

@ -0,0 +1,14 @@
import { DocumentNode } from 'graphql';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OptimisticEffectResolver } from './OptimisticEffectResolver';
export type OptimisticEffectDefinition = {
key: string;
query?: DocumentNode;
typename: string;
resolver: OptimisticEffectResolver;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
};

View File

@ -0,0 +1,13 @@
import { OperationVariables } from '@apollo/client';
export type OptimisticEffectResolver = ({
currentData,
newData,
deletedRecordIds,
variables,
}: {
currentData: any; //TODO: Change when decommissioning v1
newData: any; //TODO: Change when decommissioning v1
deletedRecordIds?: string[];
variables: OperationVariables;
}) => void;

View File

@ -0,0 +1,28 @@
import { ApolloCache, DocumentNode, OperationVariables } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type OptimisticEffectWriter<T> = ({
cache,
newData,
variables,
query,
}: {
cache: ApolloCache<T>;
query: DocumentNode;
newData: T;
deletedRecordIds?: string[];
variables: OperationVariables;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
}) => void;
export type OptimisticEffect<T> = {
key: string;
query?: DocumentNode;
typename: string;
variables: OperationVariables;
writer: OptimisticEffectWriter<T>;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
};

View File

@ -0,0 +1,159 @@
import {
ApolloClient,
ApolloClientOptions,
ApolloLink,
fromPromise,
ServerError,
ServerParseError,
} from '@apollo/client';
import { GraphQLErrors } from '@apollo/client/errors';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { createUploadLink } from 'apollo-upload-client';
import { renewToken } from '@/auth/services/AuthService';
import { AuthTokenPair } from '~/generated/graphql';
import { assertNotNull } from '~/utils/assert';
import { logDebug } from '~/utils/logDebug';
import { ApolloManager } from '../types/apolloManager.interface';
import { loggerLink } from '../utils';
const logger = loggerLink(() => 'Twenty');
export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
onError?: (err: GraphQLErrors | undefined) => void;
onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
onUnauthenticatedError?: () => void;
initialTokenPair: AuthTokenPair | null;
extraLinks?: ApolloLink[];
isDebugMode?: boolean;
}
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
private client: ApolloClient<TCacheShape>;
private tokenPair: AuthTokenPair | null = null;
constructor(opts: Options<TCacheShape>) {
const {
uri,
onError: onErrorCb,
onNetworkError,
onTokenPairChange,
onUnauthenticatedError,
initialTokenPair,
extraLinks,
isDebugMode,
...options
} = opts;
this.tokenPair = initialTokenPair;
const buildApolloLink = (): ApolloLink => {
const httpLink = createUploadLink({
uri,
});
const authLink = setContext(async (_, { headers }) => {
return {
headers: {
...headers,
authorization: this.tokenPair?.accessToken.token
? `Bearer ${this.tokenPair?.accessToken.token}`
: '',
},
};
});
const retryLink = new RetryLink({
delay: {
initial: 3000,
},
attempts: {
max: 2,
retryIf: (error) => !!error,
},
});
const errorLink = onError(
({ graphQLErrors, networkError, forward, operation }) => {
if (graphQLErrors) {
onErrorCb?.(graphQLErrors);
for (const graphQLError of graphQLErrors) {
if (graphQLError.message === 'Unauthorized') {
return fromPromise(
renewToken(uri, this.tokenPair)
.then((tokens) => {
onTokenPairChange?.(tokens);
})
.catch(() => {
onUnauthenticatedError?.();
}),
).flatMap(() => forward(operation));
}
switch (graphQLError?.extensions?.code) {
case 'UNAUTHENTICATED': {
return fromPromise(
renewToken(uri, this.tokenPair)
.then((tokens) => {
onTokenPairChange?.(tokens);
})
.catch(() => {
onUnauthenticatedError?.();
}),
).flatMap(() => forward(operation));
}
default:
if (isDebugMode) {
logDebug(
`[GraphQL error]: Message: ${
graphQLError.message
}, Location: ${
graphQLError.locations
? JSON.stringify(graphQLError.locations)
: graphQLError.locations
}, Path: ${graphQLError.path}`,
);
}
}
}
}
if (networkError) {
if (isDebugMode) {
logDebug(`[Network error]: ${networkError}`);
}
onNetworkError?.(networkError);
}
},
);
return ApolloLink.from(
[
errorLink,
authLink,
...(extraLinks || []),
isDebugMode ? logger : null,
retryLink,
httpLink,
].filter(assertNotNull),
);
};
this.client = new ApolloClient({
...options,
link: buildApolloLink(),
});
}
updateTokenPair(tokenPair: AuthTokenPair | null) {
this.tokenPair = tokenPair;
}
getClient() {
return this.client;
}
}

View File

@ -0,0 +1,8 @@
import { ApolloClient } from '@apollo/client';
import { AuthTokenPair } from '~/generated/graphql';
export interface ApolloManager<TCacheShape> {
getClient(): ApolloClient<TCacheShape>;
updateTokenPair(tokenPair: AuthTokenPair | null): void;
}

View File

@ -0,0 +1,6 @@
export enum OperationType {
Query = 'query',
Mutation = 'mutation',
Subscription = 'subscription',
Error = 'error',
}

View File

@ -0,0 +1,50 @@
import { OperationType } from '../types/operation-type';
const operationTypeColors = {
// eslint-disable-next-line twenty/no-hardcoded-colors
query: '#03A9F4',
// eslint-disable-next-line twenty/no-hardcoded-colors
mutation: '#61A600',
// eslint-disable-next-line twenty/no-hardcoded-colors
subscription: '#61A600',
// eslint-disable-next-line twenty/no-hardcoded-colors
error: '#F51818',
// eslint-disable-next-line twenty/no-hardcoded-colors
default: '#61A600',
};
const getOperationColor = (operationType: OperationType) => {
return operationTypeColors[operationType] ?? operationTypeColors.default;
};
const formatTitle = (
operationType: OperationType,
schemaName: string,
queryName: string,
time: string | number,
) => {
const headerCss = [
'color: gray; font-weight: lighter', // title
`color: ${getOperationColor(operationType)}; font-weight: bold;`, // operationType
'color: gray; font-weight: lighter;', // schemaName
'color: black; font-weight: bold;', // queryName
];
const parts = [
'%c apollo',
`%c${operationType}`,
`%c${schemaName}::%c${queryName}`,
];
if (operationType !== OperationType.Subscription) {
parts.push(`%c(in ${time} ms)`);
headerCss.push('color: gray; font-weight: lighter;'); // time
} else {
parts.push(`%c(@ ${time})`);
headerCss.push('color: gray; font-weight: lighter;'); // time
}
return [parts.join(' '), ...headerCss];
};
export default formatTitle;

View File

@ -0,0 +1,105 @@
import { ApolloLink, gql, Operation } from '@apollo/client';
import { logDebug } from '~/utils/logDebug';
import { logError } from '~/utils/logError';
import formatTitle from './format-title';
const getGroup = (collapsed: boolean) =>
collapsed
? console.groupCollapsed.bind(console)
: console.group.bind(console);
const parseQuery = (queryString: string) => {
const queryObj = gql`
${queryString}
`;
const { name } = queryObj.definitions[0] as any;
return [name ? name.value : 'Generic', queryString.trim()];
};
export const loggerLink = (getSchemaName: (operation: Operation) => string) =>
new ApolloLink((operation, forward) => {
const schemaName = getSchemaName(operation);
operation.setContext({ start: Date.now() });
const { variables } = operation;
const operationType = (operation.query.definitions[0] as any).operation;
const headers = operation.getContext().headers;
const [queryName, query] = parseQuery(operation.query.loc!.source.body);
if (operationType === 'subscription') {
const date = new Date().toLocaleTimeString();
const titleArgs = formatTitle(operationType, schemaName, queryName, date);
console.groupCollapsed(...titleArgs);
if (variables && Object.keys(variables).length !== 0) {
logDebug('VARIABLES', variables);
}
logDebug('QUERY', query);
console.groupEnd();
return forward(operation);
}
return forward(operation).map((result) => {
const time = Date.now() - operation.getContext().start;
const errors = result.errors ?? result.data?.[queryName]?.errors;
const hasError = Boolean(errors);
try {
const titleArgs = formatTitle(
operationType,
schemaName,
queryName,
time,
);
getGroup(!hasError)(...titleArgs);
if (errors) {
errors.forEach((err: any) => {
logDebug(
`%c${err.message}`,
// eslint-disable-next-line twenty/no-hardcoded-colors
'color: #F51818; font-weight: lighter',
);
});
}
logDebug('HEADERS: ', headers);
if (variables && Object.keys(variables).length !== 0) {
logDebug('VARIABLES', variables);
}
logDebug('QUERY', query);
if (result.data) {
logDebug('RESULT', result.data);
}
if (errors) {
logDebug('ERRORS', errors);
}
console.groupEnd();
} catch {
// this may happen if console group is not supported
logDebug(
`${operationType} ${schemaName}::${queryName} (in ${time} ms)`,
);
if (errors) {
logError(errors);
}
}
return result;
});
});

View File

@ -0,0 +1,6 @@
export type Attachment = {
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
};

View File

@ -0,0 +1,67 @@
import styled from '@emotion/styled';
import { getImageAbsoluteURIOrBase64 } from '@/users/utils/getProfilePictureAbsoluteURI';
type LogoProps = {
workspaceLogo?: string | null;
};
const StyledContainer = styled.div`
height: 48px;
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(4)};
position: relative;
width: 48px;
`;
const StyledTwentyLogo = styled.img`
border-radius: ${({ theme }) => theme.border.radius.xs};
height: 24px;
width: 24px;
`;
const StyledTwentyLogoContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
bottom: ${({ theme }) => `-${theme.spacing(3)}`};
display: flex;
height: 28px;
justify-content: center;
position: absolute;
right: ${({ theme }) => `-${theme.spacing(3)}`};
width: 28px;
`;
type StyledMainLogoProps = {
logo?: string | null;
};
const StyledMainLogo = styled.div<StyledMainLogoProps>`
background: url(${(props) => props.logo});
background-size: cover;
height: 100%;
width: 100%;
`;
export const Logo = ({ workspaceLogo }: LogoProps) => {
if (!workspaceLogo) {
return (
<StyledContainer>
<StyledMainLogo logo="/icons/android/android-launchericon-192-192.png" />
</StyledContainer>
);
}
return (
<StyledContainer>
<StyledMainLogo logo={getImageAbsoluteURIOrBase64(workspaceLogo)} />
<StyledTwentyLogoContainer>
<StyledTwentyLogo src="/icons/android/android-launchericon-192-192.png" />
</StyledTwentyLogoContainer>
</StyledContainer>
);
};

View File

@ -0,0 +1,17 @@
import React from 'react';
import styled from '@emotion/styled';
import { Modal as UIModal } from '@/ui/layout/modal/components/Modal';
const StyledContent = styled(UIModal.Content)`
align-items: center;
width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)});
`;
type AuthModalProps = { children: React.ReactNode };
export const AuthModal = ({ children }: AuthModalProps) => (
<UIModal isOpen={true}>
<StyledContent>{children}</StyledContent>
</UIModal>
);

View File

@ -0,0 +1,14 @@
import { JSX, ReactNode } from 'react';
import styled from '@emotion/styled';
type SubTitleProps = {
children: ReactNode;
};
const StyledSubTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
`;
export const SubTitle = ({ children }: SubTitleProps): JSX.Element => (
<StyledSubTitle>{children}</StyledSubTitle>
);

View File

@ -0,0 +1,28 @@
import React from 'react';
import styled from '@emotion/styled';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
type TitleProps = React.PropsWithChildren & {
animate?: boolean;
};
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(4)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
export const Title = ({ children, animate = false }: TitleProps) => {
if (animate) {
return (
<StyledTitle>
<AnimatedEaseIn>{children}</AnimatedEaseIn>
</StyledTitle>
);
}
return <StyledTitle>{children}</StyledTitle>;
};

View File

@ -0,0 +1,19 @@
import { gql } from '@apollo/client';
export const AUTH_TOKEN = gql`
fragment AuthTokenFragment on AuthToken {
token
expiresAt
}
`;
export const AUTH_TOKENS = gql`
fragment AuthTokensFragment on AuthTokenPair {
accessToken {
...AuthTokenFragment
}
refreshToken {
...AuthTokenFragment
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const CHALLENGE = gql`
mutation Challenge($email: String!, $password: String!) {
challenge(email: $email, password: $password) {
loginToken {
...AuthTokenFragment
}
}
}
`;

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const GENERATE_ONE_API_KEY_TOKEN = gql`
mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) {
generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) {
token
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GENERATE_ONE_TRANSIENT_TOKEN = gql`
mutation generateTransientToken {
generateTransientToken {
transientToken {
token
}
}
}
`;

View File

@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
// TODO: Fragments should be used instead of duplicating the user fields !
export const IMPERSONATE = gql`
mutation Impersonate($userId: String!) {
impersonate(userId: $userId) {
user {
...UserQueryFragment
}
tokens {
...AuthTokensFragment
}
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const RENEW_TOKEN = gql`
mutation RenewToken($refreshToken: String!) {
renewToken(refreshToken: $refreshToken) {
tokens {
...AuthTokensFragment
}
}
}
`;

View File

@ -0,0 +1,19 @@
import { gql } from '@apollo/client';
export const SIGN_UP = gql`
mutation SignUp(
$email: String!
$password: String!
$workspaceInviteHash: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
) {
loginToken {
...AuthTokenFragment
}
}
}
`;

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const VERIFY = gql`
mutation Verify($loginToken: String!) {
verify(loginToken: $loginToken) {
user {
...UserQueryFragment
}
tokens {
...AuthTokensFragment
}
}
}
`;

Some files were not shown because too many files have changed in this diff Show More