Migrate to a monorepo structure (#2909)
This commit is contained in:
@ -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>
|
||||
);
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 },
|
||||
};
|
||||
@ -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 },
|
||||
};
|
||||
@ -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',
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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[],
|
||||
};
|
||||
};
|
||||
@ -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';
|
||||
@ -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);
|
||||
});
|
||||
};
|
||||
@ -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';
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
@ -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],
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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[],
|
||||
};
|
||||
};
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity';
|
||||
|
||||
export const activityTargetableEntityArrayState = atom<
|
||||
ActivityTargetableEntity[]
|
||||
>({
|
||||
key: 'activities/targetable-entity-array',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const TasksRecoilScopeContext = createContext<string | null>(null);
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const viewableActivityIdState = atom<string | null>({
|
||||
key: 'activities/viewable-activity-id',
|
||||
default: null,
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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[],
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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[];
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
import { Activity } from '@/activities/types/Activity';
|
||||
|
||||
export type ActivityForDrawer = Activity;
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
export type ActivityTargetableEntityType = 'Person' | 'Company' | 'Custom';
|
||||
|
||||
export type ActivityTargetableEntity = {
|
||||
id: string;
|
||||
type: ActivityTargetableEntityType;
|
||||
relatedEntities?: ActivityTargetableEntity[];
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
|
||||
|
||||
import { ActivityTargetableEntityType } from './ActivityTargetableEntity';
|
||||
|
||||
export type ActivityTargetableEntityForSelect = EntityForSelect & {
|
||||
entityType: ActivityTargetableEntityType;
|
||||
};
|
||||
@ -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'>;
|
||||
};
|
||||
@ -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[];
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { Activity } from '@/activities/types/Activity';
|
||||
|
||||
export type Note = Activity & {
|
||||
type: 'Note';
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { Activity } from '@/activities/types/Activity';
|
||||
|
||||
export type Task = Activity & {
|
||||
type: 'Task';
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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],
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { EventData, useEventTracker } from './useEventTracker';
|
||||
|
||||
export const useTrackEvent = (eventType: string, eventData: EventData) => {
|
||||
const eventTracker = useEventTracker();
|
||||
|
||||
return eventTracker(eventType, eventData);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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: {},
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export enum OperationType {
|
||||
Query = 'query',
|
||||
Mutation = 'mutation',
|
||||
Subscription = 'subscription',
|
||||
Error = 'error',
|
||||
}
|
||||
@ -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;
|
||||
105
packages/twenty-front/src/modules/apollo/utils/index.ts
Normal file
105
packages/twenty-front/src/modules/apollo/utils/index.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
67
packages/twenty-front/src/modules/auth/components/Logo.tsx
Normal file
67
packages/twenty-front/src/modules/auth/components/Logo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
packages/twenty-front/src/modules/auth/components/Modal.tsx
Normal file
17
packages/twenty-front/src/modules/auth/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
28
packages/twenty-front/src/modules/auth/components/Title.tsx
Normal file
28
packages/twenty-front/src/modules/auth/components/Title.tsx
Normal 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>;
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GENERATE_ONE_TRANSIENT_TOKEN = gql`
|
||||
mutation generateTransientToken {
|
||||
generateTransientToken {
|
||||
transientToken {
|
||||
token
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const RENEW_TOKEN = gql`
|
||||
mutation RenewToken($refreshToken: String!) {
|
||||
renewToken(refreshToken: $refreshToken) {
|
||||
tokens {
|
||||
...AuthTokensFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
Reference in New Issue
Block a user