Activity as standard object (#6219)

In this PR I layout the first steps to migrate Activity to a traditional
Standard objects

Since this is a big transition, I'd rather split it into several
deployments / PRs

<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/012e2bbf-9d1b-4723-aaf6-269ef588b050">

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: bosiraphael <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: Faisal-imtiyaz123 <142205282+Faisal-imtiyaz123@users.noreply.github.com>
Co-authored-by: Prateek Jain <prateekj1171998@gmail.com>
This commit is contained in:
Félix Malfait
2024-07-31 15:36:11 +02:00
committed by GitHub
parent defcee2a02
commit 80c0fc7ff1
239 changed files with 18418 additions and 8671 deletions

View File

@ -6,6 +6,7 @@ module.exports = {
'mockServiceWorker.js',
'**/generated*/*',
'**/generated/standard-metadata-query-result.ts',
'**/getObjectMetadataItemsMock.ts',
'tsup.config.ts',
'build',
'coverage',

View File

@ -84,7 +84,6 @@ import { SettingsBilling } from '~/pages/settings/SettingsBilling';
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { Tasks } from '~/pages/tasks/Tasks';
import { getPageTitleFromPath } from '~/utils/title-utils';
const ProvidersThatNeedRouterContext = () => {
@ -158,7 +157,6 @@ const createRouter = (
element={<PaymentSuccess />}
/>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route path={AppPath.TasksPage} element={<Tasks />} />
<Route path={AppPath.Impersonate} element={<ImpersonateEffect />} />
<Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} />
<Route path={AppPath.RecordShowPage} element={<RecordShowPage />} />

View File

@ -9,6 +9,7 @@ import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCapt
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandType } from '@/command-menu/types/Command';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath';
@ -38,7 +39,9 @@ export const PageChangeEffect = () => {
const { addToCommandMenu, setToInitialCommandMenu } = useCommandMenu();
const openCreateActivity = useOpenCreateActivityDrawer();
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
useEffect(() => {
if (!previousLocation || previousLocation !== location.pathname) {
@ -143,8 +146,7 @@ export const PageChangeEffect = () => {
label: 'Create Task',
type: CommandType.Create,
Icon: IconCheckbox,
onCommandClick: () =>
openCreateActivity({ type: 'Task', targetableObjects: [] }),
onCommandClick: () => openCreateActivity({ targetableObjects: [] }),
},
]);
}, [addToCommandMenu, setToInitialCommandMenu, openCreateActivity]);

View File

@ -361,6 +361,7 @@ export enum FieldMetadataType {
Rating = 'RATING',
RawJson = 'RAW_JSON',
Relation = 'RELATION',
RichText = 'RICH_TEXT',
Select = 'SELECT',
Text = 'TEXT',
Uuid = 'UUID'

View File

@ -250,6 +250,7 @@ export enum FieldMetadataType {
Rating = 'RATING',
RawJson = 'RAW_JSON',
Relation = 'RELATION',
RichText = 'RICH_TEXT',
Select = 'SELECT',
Text = 'TEXT',
Uuid = 'UUID'

View File

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

View File

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

View File

@ -1,85 +0,0 @@
import styled from '@emotion/styled';
import { AppTooltip, Avatar } from 'twenty-ui';
import { Comment } from '@/activities/types/Comment';
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)};
`;
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"
placeholderColorSeed={author?.id}
placeholder={authorName}
/>
<StyledName>{authorName}</StyledName>
{showDate && (
<>
<StyledDate id={`id-${commentId}`}>
{beautifiedCreatedAt}
</StyledDate>
<AppTooltip
anchorSelect={`#id-${commentId}`}
content={exactCreatedAt}
clickable
noArrow
/>
</>
)}
</StyledLeftContainer>
<div>{actionBar}</div>
</StyledContainer>
);
};

View File

@ -1,54 +0,0 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { ActivityActionBar } from '../../right-drawer/components/ActivityActionBar';
import { Comment } from '../Comment';
import { mockComment, mockCommentWithLongValues } from './mock-comment';
const CommentSetterEffect = () => {
const setViewableRecord = useSetRecoilState(viewableRecordIdState);
useEffect(() => {
setViewableRecord('test-id');
}, [setViewableRecord]);
return null;
};
const meta: Meta<typeof Comment> = {
title: 'Modules/Activity/Comment/Comment',
component: Comment,
decorators: [
(Story) => (
<>
<CommentSetterEffect />
<Story />
</>
),
ComponentDecorator,
],
argTypes: {
actionBar: {
type: 'boolean',
mapping: {
true: <ActivityActionBar />,
false: undefined,
},
},
},
args: { comment: mockComment },
};
export default meta;
type Story = StoryObj<typeof Comment>;
export const Default: Story = {};
export const WithLongValues: Story = {
args: { comment: mockCommentWithLongValues },
};

View File

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

View File

@ -1,55 +0,0 @@
import { DateTime } from 'luxon';
import { Comment } from '@/activities/types/Comment';
export const mockComment: Pick<
Comment,
| 'id'
| 'author'
| 'createdAt'
| 'body'
| 'updatedAt'
| 'activityId'
| '__typename'
> = {
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',
__typename: 'Comment',
};
export const mockCommentWithLongValues: Pick<
Comment,
| 'id'
| 'author'
| 'createdAt'
| 'body'
| 'updatedAt'
| 'activityId'
| '__typename'
> = {
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',
__typename: 'Comment',
};

View File

@ -1,129 +0,0 @@
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 { Comment as CommentType } from '@/activities/types/Comment';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
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-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};
display: flex;
padding: 16px 24px 16px 48px;
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 = {
activityId: string;
scrollableContainerRef: React.RefObject<HTMLDivElement>;
};
export const ActivityComments = ({
activityId,
scrollableContainerRef,
}: ActivityCommentsProps) => {
const { createOneRecord: createOneComment } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Comment,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: comments } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Comment,
skip: !isNonEmptyString(activityId),
filter: {
activityId: {
eq: activityId,
},
},
});
if (!currentWorkspaceMember) {
return <></>;
}
const handleSendComment = (commentText: string) => {
if (!isNonEmptyString(commentText)) {
return;
}
createOneComment?.({
id: v4(),
authorId: currentWorkspaceMember?.id ?? '',
author: currentWorkspaceMember,
activityId: activityId,
body: commentText,
createdAt: new Date().toISOString(),
});
};
const handleFocus = () => {
const scrollableContainer = scrollableContainerRef.current;
scrollableContainer?.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth',
});
};
return (
<>
{comments.length > 0 && (
<>
<StyledThreadItemListContainer>
<StyledThreadCommentTitle>Comments</StyledThreadCommentTitle>
{comments?.map((comment) => (
<Comment key={comment.id} comment={comment as CommentType} />
))}
</StyledThreadItemListContainer>
</>
)}
<StyledCommentActionBar>
{currentWorkspaceMember && (
<AutosizeTextInput
onValidate={handleSendComment}
onFocus={handleFocus}
variant={AutosizeTextInputVariant.Button}
placeholder={comments.length > 0 ? 'Reply...' : undefined}
/>
)}
</StyledCommentActionBar>
</>
);
};

View File

@ -1,43 +0,0 @@
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { Activity } from '@/activities/types/Activity';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledCreationDisplay = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
user-select: none;
width: 100%;
`;
type ActivityCreationDateProps = {
activityId: string;
};
export const ActivityCreationDate = ({
activityId,
}: ActivityCreationDateProps) => {
const [activityInStore] = useRecoilState(recordStoreFamilyState(activityId));
const activity = activityInStore as Activity;
const beautifiedDate = activity.createdAt
? beautifyPastDateRelativeToNow(activity.createdAt)
: null;
const authorName = activity.author?.name
? `${activity.author.name.firstName} ${activity.author.name.lastName}`
: null;
if (!activity.createdAt || !authorName) {
return <></>;
}
return (
<StyledCreationDisplay>
Created {beautifiedDate} by {authorName}
</StyledCreationDisplay>
);
};

View File

@ -1,89 +0,0 @@
import styled from '@emotion/styled';
import { useRef } from 'react';
import { ActivityBodyEditor } from '@/activities/components/ActivityBodyEditor';
import { ActivityBodyEffect } from '@/activities/components/ActivityBodyEffect';
import { ActivityComments } from '@/activities/components/ActivityComments';
import { ActivityCreationDate } from '@/activities/components/ActivityCreationDate';
import { ActivityEditorFields } from '@/activities/components/ActivityEditorFields';
import { ActivityTitleEffect } from '@/activities/components/ActivityTitleEffect';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ActivityTitle } from './ActivityTitle';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledUpperPartContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
`;
const StyledTitleContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
gap: ${({ theme }) => theme.spacing(2)};
`;
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: ${({ theme }) => theme.spacing(6)};
`;
type ActivityEditorProps = {
activityId: string;
showComment?: boolean;
fillTitleFromBody?: boolean;
};
export const ActivityEditor = ({
activityId,
showComment = true,
fillTitleFromBody = false,
}: ActivityEditorProps) => {
const containerRef = useRef<HTMLDivElement>(null);
return (
<StyledContainer ref={containerRef}>
<StyledUpperPartContainer>
<StyledTopContainer>
<ActivityTitleEffect activityId={activityId} />
<StyledTitleContainer>
<ActivityTitle activityId={activityId} />
<ActivityCreationDate activityId={activityId} />
</StyledTitleContainer>
<ActivityEditorFields activityId={activityId} />
</StyledTopContainer>
</StyledUpperPartContainer>
<ActivityBodyEffect activityId={activityId} />
<ActivityBodyEditor
activityId={activityId}
fillTitleFromBody={fillTitleFromBody}
/>
{showComment && (
<ActivityComments
activityId={activityId}
scrollableContainerRef={containerRef}
/>
)}
</StyledContainer>
);
};

View File

@ -1,102 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityBodyFamilyState } from '@/activities/states/activityBodyFamilyState';
import { activityTitleFamilyState } from '@/activities/states/activityTitleFamilyState';
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState';
import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState';
import { Activity } from '@/activities/types/Activity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteRecordFromCache } from '@/object-record/cache/hooks/useDeleteRecordFromCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { isDefined } from '~/utils/isDefined';
export const ActivityEditorEffect = ({
activityId,
}: {
activityId: string;
}) => {
const { useRegisterClickOutsideListenerCallback } = useClickOutsideListener(
RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID,
);
const { upsertActivity } = useUpsertActivity();
const deleteRecordFromCache = useDeleteRecordFromCache({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const upsertActivityCallback = useRecoilCallback(
({ snapshot, set }) =>
() => {
const isUpsertingActivityInDB = snapshot
.getLoadable(isUpsertingActivityInDBState)
.getValue();
const canCreateActivity = snapshot
.getLoadable(canCreateActivityState)
.getValue();
const isActivityInCreateMode = snapshot
.getLoadable(isActivityInCreateModeState)
.getValue();
const activityFromStore = snapshot
.getLoadable(recordStoreFamilyState(activityId))
.getValue();
const activity = activityFromStore as Activity | null;
const activityTitle = snapshot
.getLoadable(activityTitleFamilyState({ activityId }))
.getValue();
const activityBody = snapshot
.getLoadable(activityBodyFamilyState({ activityId }))
.getValue();
if (isUpsertingActivityInDB || !activityFromStore) {
return;
}
if (isActivityInCreateMode && isDefined(activity)) {
if (canCreateActivity) {
upsertActivity({
activity,
input: {
title: activityFromStore.title,
body: activityFromStore.body,
},
});
} else {
deleteRecordFromCache(activity);
}
set(isActivityInCreateModeState, false);
} else if (isDefined(activity)) {
if (
activity.title !== activityTitle ||
activity.body !== activityBody
) {
upsertActivity({
activity,
input: {
title: activityTitle,
body: activityBody,
},
});
}
}
},
[activityId, deleteRecordFromCache, upsertActivity],
);
useRegisterClickOutsideListenerCallback({
callbackId: 'activity-editor',
callbackFunction: upsertActivityCallback,
});
return <></>;
};

View File

@ -1,115 +0,0 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Activity } from '@/activities/types/Activity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import {
RecordUpdateHook,
RecordUpdateHookParams,
} from '@/object-record/record-field/contexts/FieldContext';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isRightDrawerAnimationCompletedState } from '@/ui/layout/right-drawer/states/isRightDrawerAnimationCompletedState';
import { isDefined } from '~/utils/isDefined';
const StyledPropertyBox = styled(PropertyBox)`
padding: 0;
`;
export const ActivityEditorFields = ({
activityId,
}: {
activityId: string;
}) => {
const { upsertActivity } = useUpsertActivity();
const isRightDrawerAnimationCompleted = useRecoilValue(
isRightDrawerAnimationCompletedState,
);
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const activityFromCache = getRecordFromCache<Activity>(activityId);
const activityFromStore = useRecoilValue(recordStoreFamilyState(activityId));
const activity = activityFromStore as Activity;
const useUpsertOneActivityMutation: RecordUpdateHook = () => {
const upsertActivityMutation = async ({
variables,
}: RecordUpdateHookParams) => {
if (isDefined(activityFromStore)) {
await upsertActivity({
activity: activityFromStore as Activity,
input: variables.updateOneRecordInput,
});
}
};
return [upsertActivityMutation, { loading: false }];
};
const { FieldContextProvider: ReminderAtFieldContextProvider } =
useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
objectRecordId: activityId,
fieldMetadataName: 'reminderAt',
fieldPosition: 0,
clearable: true,
customUseUpdateOneObjectHook: useUpsertOneActivityMutation,
});
const { FieldContextProvider: DueAtFieldContextProvider } = useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
objectRecordId: activityId,
fieldMetadataName: 'dueAt',
fieldPosition: 1,
clearable: true,
customUseUpdateOneObjectHook: useUpsertOneActivityMutation,
});
const { FieldContextProvider: AssigneeFieldContextProvider } =
useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
objectRecordId: activityId,
fieldMetadataName: 'assignee',
fieldPosition: 2,
clearable: true,
customUseUpdateOneObjectHook: useUpsertOneActivityMutation,
});
return (
<StyledPropertyBox>
{activity.type === 'Task' &&
ReminderAtFieldContextProvider &&
DueAtFieldContextProvider &&
AssigneeFieldContextProvider && (
<>
<ReminderAtFieldContextProvider>
<RecordInlineCell />
</ReminderAtFieldContextProvider>
<DueAtFieldContextProvider>
<RecordInlineCell />
</DueAtFieldContextProvider>
<AssigneeFieldContextProvider>
<RecordInlineCell />
</AssigneeFieldContextProvider>
</>
)}
{isDefined(activityFromCache) && isRightDrawerAnimationCompleted && (
<ActivityTargetsInlineCell
activity={activityFromCache}
maxWidth={340}
/>
)}
</StyledPropertyBox>
);
};

View File

@ -1,199 +0,0 @@
import { useRef } from 'react';
import { useApolloClient } from '@apollo/client';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useDebouncedCallback } from 'use-debounce';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityTitleFamilyState } from '@/activities/states/activityTitleFamilyState';
import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState';
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
import { Activity } from '@/activities/types/Activity';
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import {
Checkbox,
CheckboxShape,
CheckboxSize,
} from '@/ui/input/components/Checkbox';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isDefined } from '~/utils/isDefined';
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 = {
activityId: string;
};
export const ActivityTitle = ({ activityId }: ActivityTitleProps) => {
const [activityInStore, setActivityInStore] = useRecoilState(
recordStoreFamilyState(activityId),
);
const cache = useApolloClient().cache;
const [activityTitle, setActivityTitle] = useRecoilState(
activityTitleFamilyState({ activityId }),
);
const activity = activityInStore as Activity;
const [canCreateActivity, setCanCreateActivity] = useRecoilState(
canCreateActivityState,
);
const { upsertActivity } = useUpsertActivity();
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const titleInputRef = useRef<HTMLInputElement>(null);
useScopedHotkeys(
Key.Escape,
() => {
handleBlur();
},
ActivityEditorHotkeyScope.ActivityTitle,
);
const handleBlur = () => {
goBackToPreviousHotkeyScope();
titleInputRef.current?.blur();
};
const handleFocus = () => {
setHotkeyScopeAndMemorizePreviousScope(
ActivityEditorHotkeyScope.ActivityTitle,
);
};
const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState(
activityTitleHasBeenSetFamilyState({
activityId: activityId,
}),
);
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const persistTitleDebounced = useDebouncedCallback((newTitle: string) => {
upsertActivity({
activity,
input: {
title: newTitle,
},
});
if (!activityTitleHasBeenSet) {
setActivityTitleHasBeenSet(true);
}
}, 500);
const setTitleDebounced = useDebouncedCallback((newTitle: string) => {
setActivityInStore((currentActivity) => {
return {
...currentActivity,
id: activity.id,
title: newTitle,
__typename: activity.__typename,
};
});
if (isNonEmptyString(newTitle) && !canCreateActivity) {
setCanCreateActivity(true);
}
modifyRecordFromCache({
recordId: activity.id,
fieldModifiers: {
title: () => {
return newTitle;
},
},
cache: cache,
objectMetadataItem: objectMetadataItemActivity,
});
}, 500);
const handleTitleChange = (newTitle: string) => {
setActivityTitle(newTitle);
setTitleDebounced(newTitle);
persistTitleDebounced(newTitle);
};
const handleActivityCompletionChange = (value: boolean) => {
upsertActivity({
activity,
input: {
completedAt: value ? new Date().toISOString() : null,
},
});
};
const completed = isDefined(activity.completedAt);
return (
<StyledContainer>
{activity.type === 'Task' && (
<Checkbox
size={CheckboxSize.Large}
shape={CheckboxShape.Rounded}
checked={completed}
onCheckedChange={(value) => handleActivityCompletionChange(value)}
/>
)}
<StyledEditableTitleInput
autoComplete="off"
autoFocus
ref={titleInputRef}
placeholder={`${activity.type} title`}
onChange={(event) => handleTitleChange(event.target.value)}
value={activityTitle}
completed={completed}
onBlur={handleBlur}
onFocus={handleFocus}
/>
</StyledContainer>
);
};

View File

@ -1,28 +0,0 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { activityTitleFamilyState } from '@/activities/states/activityTitleFamilyState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isDefined } from '~/utils/isDefined';
export const ActivityTitleEffect = ({ activityId }: { activityId: string }) => {
const [activityFromStore] = useRecoilState(
recordStoreFamilyState(activityId),
);
const [activityTitle, setActivityTitle] = useRecoilState(
activityTitleFamilyState({ activityId }),
);
useEffect(() => {
if (
activityTitle === '' &&
isDefined(activityFromStore) &&
activityTitle !== activityFromStore.title
) {
setActivityTitle(activityFromStore.title);
}
}, [activityFromStore, activityTitle, setActivityTitle]);
return <></>;
};

View File

@ -1,40 +0,0 @@
import { useTheme } from '@emotion/react';
import { useRecoilState } from 'recoil';
import {
Chip,
ChipAccent,
ChipSize,
ChipVariant,
IconCheckbox,
IconNotes,
} from 'twenty-ui';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
type ActivityTypeDropdownProps = {
activityId: string;
};
export const ActivityTypeDropdown = ({
activityId,
}: ActivityTypeDropdownProps) => {
const [activityInStore] = useRecoilState(recordStoreFamilyState(activityId));
const theme = useTheme();
return (
<Chip
label={activityInStore?.type}
leftComponent={
activityInStore?.type === 'Note' ? (
<IconNotes size={theme.icon.size.md} />
) : (
<IconCheckbox size={theme.icon.size.md} />
)
}
size={ChipSize.Large}
accent={ChipAccent.TextSecondary}
variant={ChipVariant.Highlighted}
/>
);
};

View File

@ -12,10 +12,8 @@ import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityBodyFamilyState } from '@/activities/states/activityBodyFamilyState';
import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState';
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
import { Activity } from '@/activities/types/Activity';
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
@ -29,22 +27,31 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { getFileType } from '../files/utils/getFileType';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
import '@blocknote/react/style.css';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
type ActivityBodyEditorProps = {
type RichTextEditorProps = {
activityId: string;
fillTitleFromBody: boolean;
activityObjectNameSingular:
| CoreObjectNameSingular.Task
| CoreObjectNameSingular.Note;
};
export const ActivityBodyEditor = ({
export const RichTextEditor = ({
activityId,
fillTitleFromBody,
}: ActivityBodyEditorProps) => {
activityObjectNameSingular,
}: RichTextEditorProps) => {
const [activityInStore] = useRecoilState(recordStoreFamilyState(activityId));
const cache = useApolloClient().cache;
const activity = activityInStore as Activity | null;
const activity = activityInStore as Task | Note | null;
const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState(
activityTitleHasBeenSetFamilyState({
@ -60,7 +67,7 @@ export const ActivityBodyEditor = ({
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Activity,
objectNameSingular: activityObjectNameSingular,
});
const {
@ -68,7 +75,9 @@ export const ActivityBodyEditor = ({
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const { upsertActivity } = useUpsertActivity();
const { upsertActivity } = useUpsertActivity({
activityObjectNameSingular: activityObjectNameSingular,
});
const persistBodyDebounced = useDebouncedCallback((newBody: string) => {
if (isDefined(activity)) {

View File

@ -1,34 +0,0 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
export const CREATE_ONE_ACTIVITY_OPERATION_SIGNATURE: RecordGqlOperationSignature =
{
objectNameSingular: CoreObjectNameSingular.Activity,
variables: {},
fields: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
author: {
id: true,
name: true,
__typename: true,
},
authorId: true,
assigneeId: true,
assignee: {
id: true,
name: true,
__typename: true,
},
comments: true,
attachments: true,
body: true,
title: true,
completedAt: true,
dueAt: true,
reminderAt: true,
type: true,
},
};

View File

@ -0,0 +1,40 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory';
export const createOneActivityOperationSignatureFactory: RecordGqlOperationSignatureFactory =
({ objectNameSingular }: { objectNameSingular: CoreObjectNameSingular }) =>
objectNameSingular === CoreObjectNameSingular.Note
? {
objectNameSingular: CoreObjectNameSingular.Note,
variables: {},
fields: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
attachments: true,
body: true,
title: true,
},
}
: {
objectNameSingular: CoreObjectNameSingular.Task,
variables: {},
fields: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
assigneeId: true,
assignee: {
id: true,
name: true,
__typename: true,
},
attachments: true,
body: true,
title: true,
status: true,
dueAt: true,
},
};

View File

@ -4,8 +4,14 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory';
export const findActivitiesOperationSignatureFactory: RecordGqlOperationSignatureFactory =
({ objectMetadataItems }: { objectMetadataItems: ObjectMetadataItem[] }) => ({
objectNameSingular: CoreObjectNameSingular.Activity,
({
objectMetadataItems,
objectNameSingular,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectNameSingular: CoreObjectNameSingular;
}) => ({
objectNameSingular: objectNameSingular,
variables: {},
fields: {
id: true,
@ -28,18 +34,32 @@ export const findActivitiesOperationSignatureFactory: RecordGqlOperationSignatur
attachments: true,
body: true,
title: true,
completedAt: true,
status: true,
dueAt: true,
reminderAt: true,
type: true,
activityTargets: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
activity: true,
activityId: true,
...generateActivityTargetMorphFieldKeys(objectMetadataItems),
},
...(objectNameSingular === CoreObjectNameSingular.Note
? {
noteTargets: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
note: true,
noteId: true,
...generateActivityTargetMorphFieldKeys(objectMetadataItems),
},
}
: {
taskTargets: {
id: true,
__typename: true,
createdAt: true,
updatedAt: true,
task: true,
taskId: true,
...generateActivityTargetMorphFieldKeys(objectMetadataItems),
},
}),
},
});

View File

@ -1,11 +1,18 @@
import { generateActivityTargetMorphFieldKeys } from '@/activities/utils/generateActivityTargetMorphFieldKeys';
import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory';
export const findActivityTargetsOperationSignatureFactory: RecordGqlOperationSignatureFactory =
({ objectMetadataItems }: { objectMetadataItems: ObjectMetadataItem[] }) => ({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
({
objectNameSingular,
objectMetadataItems,
}: {
objectNameSingular: CoreObjectNameSingular;
objectMetadataItems: ObjectMetadataItem[];
}) => ({
objectNameSingular: getJoinObjectNameSingular(objectNameSingular),
variables: {},
fields: {
id: true,

View File

@ -6,6 +6,7 @@ import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useActivities } from '@/activities/hooks/useActivities';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
@ -20,16 +21,14 @@ const mockActivityTarget = {
};
const mockActivity = {
__typename: 'Activity',
__typename: 'Note',
updatedAt: '2021-08-03T19:20:06.000Z',
createdAt: '2021-08-03T19:20:06.000Z',
completedAt: '2021-08-03T19:20:06.000Z',
status: 'DONE',
reminderAt: '2021-08-03T19:20:06.000Z',
title: 'title',
authorId: '1',
body: 'body',
dueAt: '2021-08-03T19:20:06.000Z',
type: 'type',
assigneeId: '1',
id: '234',
};
@ -102,13 +101,13 @@ const mocks: MockedResponse[] = [
{
request: {
query: gql`
query FindManyActivities(
$filter: ActivityFilterInput
$orderBy: [ActivityOrderByInput]
query FindManyTasks(
$filter: TaskFilterInput
$orderBy: [TaskOrderByInput]
$lastCursor: String
$limit: Int
) {
activities(
tasks(
filter: $filter
orderBy: $orderBy
first: $limit
@ -117,17 +116,13 @@ const mocks: MockedResponse[] = [
edges {
node {
__typename
createdAt
reminderAt
authorId
title
completedAt
updatedAt
body
dueAt
type
id
assigneeId
updatedAt
createdAt
body
status
dueAt
}
cursor
}
@ -178,6 +173,7 @@ describe('useActivities', () => {
const { result } = renderHook(
() =>
useActivities({
objectNameSingular: CoreObjectNameSingular.Task,
targetableObjects: [],
activitiesFilters: {},
activitiesOrderByVariables: [{}],
@ -200,6 +196,7 @@ describe('useActivities', () => {
);
const activities = useActivities({
objectNameSingular: CoreObjectNameSingular.Task,
targetableObjects: [
{ targetObjectNameSingular: 'company', id: '123' },
],

View File

@ -7,8 +7,8 @@ import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
@ -16,108 +16,95 @@ const mockObjectMetadataItems = getObjectMetadataItemsMock();
const cache = new InMemoryCache();
const activityNode = {
id: '3ecaa1be-aac7-463a-a38e-64078dd451d5',
const taskTarget = {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb300',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
reminderAt: null,
title: 'My very first note',
type: 'Note',
body: '',
dueAt: '2023-04-26T10:12:42.33625+00:00',
completedAt: null,
author: null,
assignee: null,
assigneeId: null,
authorId: null,
comments: {
edges: [],
companyId: null,
company: null,
personId: '89bb825c-171e-4bcc-9cf7-43448d6fb280',
person: {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb280',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
city: 'City',
name: {
firstName: 'John',
lastName: 'Doe',
},
__typename: 'Person',
},
activityTargets: {
edges: [
{
node: {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb300',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
personId: null,
companyId: '89bb825c-171e-4bcc-9cf7-43448d6fb280',
company: {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb280',
name: 'Airbnb',
domainName: {
primaryLinkUrl: 'https://www.airbnb.com',
primaryLinkLabel: '',
secondaryLinks: null,
},
},
person: null,
activityId: '89bb825c-171e-4bcc-9cf7-43448d6fb230',
activity: {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb230',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
},
__typename: 'ActivityTarget',
},
__typename: 'ActivityTargetEdge',
},
],
__typename: 'ActivityTargetConnection',
taskId: '89bb825c-171e-4bcc-9cf7-43448d6fb230',
task: {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb230',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
dueAt: null,
body: '{}',
title: 'Task title',
assigneeId: null,
__typename: 'Task',
},
__typename: 'Activity' as const,
__typename: 'TaskTarget',
};
cache.writeFragment({
fragment: gql`
fragment CreateOneActivityInCache on Activity {
id
createdAt
fragment TaskTargetFragment on TaskTarget {
__typename
updatedAt
reminderAt
title
body
dueAt
completedAt
author
assignee
assigneeId
authorId
activityTargets {
edges {
node {
id
createdAt
updatedAt
personId
companyId
company {
id
name
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
person
activityId
activity {
id
createdAt
updatedAt
}
__typename
}
createdAt
personId
taskId
companyId
id
task {
__typename
createdAt
title
updatedAt
body
dueAt
id
assigneeId
}
person {
__typename
id
createdAt
updatedAt
city
name {
firstName
lastName
}
}
__typename
company {
__typename
id
createdAt
updatedAt
}
}
`,
id: activityNode.id,
data: activityNode,
id: `TaskTarget:${taskTarget.id}`,
data: taskTarget,
});
const task = {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb230',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
title: 'Task title',
body: null,
assigneeId: null,
status: null,
dueAt: '2023-04-26T10:12:42.33625+00:00',
assignee: null,
__typename: 'Task' as any,
taskTargets: [taskTarget],
};
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider cache={cache}>
@ -142,7 +129,8 @@ describe('useActivityTargetObjectRecords', () => {
);
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(
getRecordFromRecordNode({ recordNode: activityNode as any }),
task,
CoreObjectNameSingular.Task,
);
return {
@ -158,18 +146,17 @@ describe('useActivityTargetObjectRecords', () => {
result.current.setCurrentWorkspaceMember(mockWorkspaceMembers[0]);
result.current.setObjectMetadataItems(mockObjectMetadataItems);
});
const activityTargetObjectRecords =
result.current.activityTargetObjectRecords;
expect(activityTargetObjectRecords).toHaveLength(1);
expect(activityTargetObjectRecords[0].activityTarget).toEqual(
activityNode.activityTargets.edges[0].node,
);
expect(activityTargetObjectRecords[0].activityTarget).toEqual(taskTarget);
expect(activityTargetObjectRecords[0].targetObject).toEqual(
activityNode.activityTargets.edges[0].node.company,
taskTarget.person,
);
expect(
activityTargetObjectRecords[0].targetObjectMetadataItem.nameSingular,
).toEqual('company');
).toEqual('person');
});
});

View File

@ -1,131 +0,0 @@
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import { act, renderHook, waitFor } from '@testing-library/react';
import gql from 'graphql-tag';
import { ReactNode } from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
const mockActivityTarget = {
__typename: 'ActivityTarget',
updatedAt: '2021-08-03T19:20:06.000Z',
createdAt: '2021-08-03T19:20:06.000Z',
personId: '1',
activityId: '234',
companyId: '1',
id: '123',
};
const defaultResponseData = {
pageInfo: {
hasNextPage: false,
startCursor: '',
endCursor: '',
},
totalCount: 1,
};
const mocks: MockedResponse[] = [
{
request: {
query: gql`
query FindManyActivityTargets(
$filter: ActivityTargetFilterInput
$orderBy: [ActivityTargetOrderByInput]
$lastCursor: String
$limit: Int
) {
activityTargets(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
) {
edges {
node {
__typename
updatedAt
createdAt
personId
activityId
companyId
id
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`,
variables: {
filter: { personId: { eq: '1234' } },
orderBy: undefined,
lastCursor: undefined,
limit: undefined,
},
},
result: jest.fn(() => ({
data: {
activityTargets: {
...defaultResponseData,
edges: [
{
node: mockActivityTarget,
cursor: '1',
},
],
},
},
})),
},
];
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={mocks} addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</MockedProvider>
</RecoilRoot>
);
describe('useActivityTargetsForTargetableObject', () => {
it('works as expected', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
const res = useActivityTargetsForTargetableObject({
targetableObject: {
id: '1234',
targetObjectNameSingular: 'person',
},
});
return { ...res, setCurrentWorkspaceMember };
},
{ wrapper: Wrapper },
);
act(() => {
result.current.setCurrentWorkspaceMember(mockWorkspaceMembers[0]);
});
expect(result.current.loadingActivityTargets).toBe(true);
await waitFor(() => !result.current.loadingActivityTargets);
expect(result.current.activityTargets).toEqual([mockActivityTarget]);
});
});

View File

@ -1,106 +0,0 @@
import { ReactNode } from 'react';
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import gql from 'graphql-tag';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useCreateActivityInCache } from '@/activities/hooks/useCreateActivityInCache';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
const mocks: MockedResponse[] = [
{
request: {
query: gql`
query FindOneWorkspaceMember($objectRecordId: ID!) {
workspaceMember(filter: { id: { eq: $objectRecordId } }) {
__typename
colorScheme
name {
firstName
lastName
}
locale
userId
avatarUrl
createdAt
updatedAt
id
}
}
`,
variables: { objectRecordId: '20202020-1553-45c6-a028-5a9064cce07f' },
},
result: jest.fn(() => ({
data: {
workspaceMember: mockWorkspaceMembers[0],
},
})),
},
];
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={mocks} addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</MockedProvider>
</RecoilRoot>
);
const mockObjectMetadataItems = getObjectMetadataItemsMock();
describe('useCreateActivityInCache', () => {
it('Should create activity in cache', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
const setRecordStore = useSetRecoilState(
recordStoreFamilyState('1234'),
);
const res = useCreateActivityInCache();
return {
...res,
setCurrentWorkspaceMember,
setObjectMetadataItems,
setRecordStore,
};
},
{ wrapper: Wrapper },
);
act(() => {
result.current.setRecordStore({
id: '1234',
__typename: 'Person',
});
result.current.setCurrentWorkspaceMember(mockWorkspaceMembers[0]);
result.current.setObjectMetadataItems(mockObjectMetadataItems);
});
act(() => {
const res = result.current.createActivityInCache({
type: 'Note',
targetObject: {
targetObjectNameSingular: 'person',
id: '1234',
},
});
expect(res.createdActivityInCache).toHaveProperty('id');
expect(res.createdActivityInCache).toHaveProperty('__typename');
expect(res.createdActivityInCache).toHaveProperty('activityTargets');
});
});
});

View File

@ -6,22 +6,16 @@ import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { mockedActivities } from '~/testing/mock-data/activities';
import { mockedTasks } from '~/testing/mock-data/tasks';
const mockedDate = '2024-03-15T12:00:00.000Z';
const toISOStringMock = jest.fn(() => mockedDate);
global.Date.prototype.toISOString = toISOStringMock;
const mockedActivity = {
...pick(mockedActivities[0], [
'id',
'title',
'body',
'type',
'completedAt',
'dueAt',
]),
...pick(mockedTasks[0], ['id', 'title', 'body', 'type', 'status', 'dueAt']),
updatedAt: mockedDate,
};
@ -36,7 +30,7 @@ const mocks: MockedResponse[] = [
reminderAt
authorId
title
completedAt
status
updatedAt
body
dueAt
@ -77,14 +71,19 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
describe('useCreateActivityInDB', () => {
it('Should create activity in DB', async () => {
const { result } = renderHook(() => useCreateActivityInDB(), {
wrapper: Wrapper,
});
const { result } = renderHook(
() =>
useCreateActivityInDB({
activityObjectNameSingular: CoreObjectNameSingular.Task,
}),
{
wrapper: Wrapper,
},
);
await act(async () => {
await result.current.createActivityInDB({
...mockedActivity,
__typename: 'Activity',
});
});

View File

@ -1,10 +1,10 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
const Wrapper = ({ children }: { children: ReactNode }) => (
@ -17,12 +17,12 @@ describe('useOpenActivityRightDrawer', () => {
it('works as expected', () => {
const { result } = renderHook(
() => {
const openActivityRightDrawer = useOpenActivityRightDrawer();
const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular: CoreObjectNameSingular.Task,
});
const viewableRecordId = useRecoilValue(viewableRecordIdState);
const activityIdInDrawer = useRecoilValue(activityIdInDrawerState);
return {
openActivityRightDrawer,
activityIdInDrawer,
viewableRecordId,
};
},
@ -31,12 +31,10 @@ describe('useOpenActivityRightDrawer', () => {
},
);
expect(result.current.activityIdInDrawer).toBeNull();
expect(result.current.viewableRecordId).toBeNull();
act(() => {
result.current.openActivityRightDrawer('123');
});
expect(result.current.activityIdInDrawer).toBe('123');
expect(result.current.viewableRecordId).toBe('123');
});
});

View File

@ -1,23 +1,71 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import gql from 'graphql-tag';
import pick from 'lodash.pick';
import { mockedTasks } from '~/testing/mock-data/tasks';
const mockUUID = '37873e04-2f83-4468-9ab7-3f87da6cafad';
const mockedDate = '2024-03-15T12:00:00.000Z';
const toISOStringMock = jest.fn(() => mockedDate);
global.Date.prototype.toISOString = toISOStringMock;
jest.mock('uuid', () => ({
v4: () => mockUUID,
}));
const mockedActivity = {
...pick(mockedTasks[0], ['id', 'title', 'body', 'type', 'status', 'dueAt']),
updatedAt: mockedDate,
};
const mocks: MockedResponse[] = [
{
request: {
query: gql`
mutation CreateOneActivity($input: ActivityCreateInput!) {
createActivity(data: $input) {
__typename
createdAt
reminderAt
authorId
title
status
updatedAt
body
dueAt
type
id
assigneeId
}
}
`,
variables: {
input: mockedActivity,
},
},
result: jest.fn(() => ({
data: {
createActivity: {
...mockedActivity,
__typename: 'Activity',
assigneeId: '',
authorId: '1',
reminderAt: null,
createdAt: mockedDate,
},
},
})),
},
];
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>{children}</MockedProvider>
<MockedProvider addTypename={false} mocks={mocks}>
{children}
</MockedProvider>
</RecoilRoot>
);
@ -27,15 +75,15 @@ describe('useOpenCreateActivityDrawer', () => {
it('works as expected', async () => {
const { result } = renderHook(
() => {
const openActivityRightDrawer = useOpenCreateActivityDrawer();
const openActivityRightDrawer = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Note,
});
const viewableRecordId = useRecoilValue(viewableRecordIdState);
const activityIdInDrawer = useRecoilValue(activityIdInDrawerState);
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
return {
openActivityRightDrawer,
activityIdInDrawer,
viewableRecordId,
setObjectMetadataItems,
};
@ -49,15 +97,11 @@ describe('useOpenCreateActivityDrawer', () => {
result.current.setObjectMetadataItems(mockObjectMetadataItems);
});
expect(result.current.activityIdInDrawer).toBeNull();
expect(result.current.viewableRecordId).toBeNull();
await act(async () => {
result.current.openActivityRightDrawer({
type: 'Note',
targetableObjects: [],
});
});
expect(result.current.activityIdInDrawer).toBe(mockUUID);
expect(result.current.viewableRecordId).toBe(mockUUID);
});
});

View File

@ -3,21 +3,25 @@ import { useRecoilCallback } from 'recoil';
import { findActivitiesOperationSignatureFactory } from '@/activities/graphql/operation-signatures/factories/findActivitiesOperationSignatureFactory';
import { useActivityTargetsForTargetableObjects } from '@/activities/hooks/useActivityTargetsForTargetableObjects';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { sortByAscString } from '~/utils/array/sortByAscString';
export const useActivities = ({
export const useActivities = <T extends Task | Note>({
objectNameSingular,
targetableObjects,
activitiesFilters,
activitiesOrderByVariables,
skip,
}: {
objectNameSingular: CoreObjectNameSingular;
targetableObjects: ActivityTargetableObject[];
activitiesFilters: RecordGqlOperationFilter;
activitiesOrderByVariables: RecordGqlOperationOrderBy;
@ -27,6 +31,7 @@ export const useActivities = ({
const { activityTargets, loadingActivityTargets } =
useActivityTargetsForTargetableObjects({
objectNameSingular,
targetableObjects,
skip: skip,
});
@ -36,7 +41,10 @@ export const useActivities = ({
activityTargets
? [
...activityTargets
.map((activityTarget) => activityTarget.activityId)
.map(
(activityTarget) =>
activityTarget.taskId ?? activityTarget.noteId,
)
.filter(isNonEmptyString),
].sort(sortByAscString)
: [],
@ -54,10 +62,13 @@ export const useActivities = ({
};
const FIND_ACTIVITIES_OPERATION_SIGNATURE =
findActivitiesOperationSignatureFactory({ objectMetadataItems });
findActivitiesOperationSignatureFactory({
objectMetadataItems,
objectNameSingular,
});
const { records: activities, loading: loadingActivities } =
useFindManyRecords<Activity>({
useFindManyRecords<Task | Note>({
skip: skip || loadingActivityTargets,
objectNameSingular:
FIND_ACTIVITIES_OPERATION_SIGNATURE.objectNameSingular,
@ -76,7 +87,7 @@ export const useActivities = ({
});
return {
activities,
activities: activities as T[],
loading: loadingActivities || loadingActivityTargets,
};
};

View File

@ -2,31 +2,41 @@ import { useApolloClient } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { Nullable } from 'twenty-ui';
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { Note } from '@/activities/types/Note';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { Task } from '@/activities/types/Task';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from '~/utils/isDefined';
export const useActivityTargetObjectRecords = (activity: Activity) => {
export const useActivityTargetObjectRecords = (
activity: Task | Note,
objectNameSingular: CoreObjectNameSingular,
) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const activityTargets = activity.activityTargets ?? [];
const activityTargets =
'noteTargets' in activity && activity.noteTargets
? activity.noteTargets
: 'taskTargets' in activity && activity.taskTargets
? activity.taskTargets
: [];
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
objectNameSingular: getJoinObjectNameSingular(objectNameSingular),
});
const apolloClient = useApolloClient();
const activityTargetObjectRecords = activityTargets
.map<Nullable<ActivityTargetWithTargetRecord>>((activityTarget) => {
const activityTargetFromCache = getRecordFromCache<ActivityTarget>(
activityTarget.id,
apolloClient.cache,
);
const activityTargetFromCache = getRecordFromCache<
NoteTarget | TaskTarget
>(activityTarget.id, apolloClient.cache);
if (!isDefined(activityTargetFromCache)) {
throw new Error(
@ -37,7 +47,9 @@ export const useActivityTargetObjectRecords = (activity: Activity) => {
const correspondingObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
isDefined(activityTargetFromCache[objectMetadataItem.nameSingular]) &&
!objectMetadataItem.isSystem,
![CoreObjectNameSingular.Note, CoreObjectNameSingular.Task].includes(
objectMetadataItem.nameSingular as CoreObjectNameSingular,
),
);
if (!correspondingObjectMetadataItem) {

View File

@ -1,40 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
export const useActivityTargetsForTargetableObject = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const targetObjectFieldName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const targetableObjectId = targetableObject.id;
const skipRequest = !isNonEmptyString(targetableObjectId);
// TODO: We want to optimistically remove from this request
// If we are on a show page and we remove the current show page object corresponding activity target
// See also if we need to update useTimelineActivities
const { records: activityTargets, loading: loadingActivityTargets } =
useFindManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
skip: skipRequest,
filter: {
[targetObjectFieldName]: {
eq: targetableObject.id,
},
},
});
return {
activityTargets,
loadingActivityTargets,
};
};

View File

@ -1,23 +1,27 @@
import { useRecoilValue } from 'recoil';
import { findActivityTargetsOperationSignatureFactory } from '@/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
export const useActivityTargetsForTargetableObjects = ({
objectNameSingular,
targetableObjects,
skip,
onCompleted,
}: {
objectNameSingular: CoreObjectNameSingular;
targetableObjects: Pick<
ActivityTargetableObject,
'id' | 'targetObjectNameSingular'
>[];
skip?: boolean;
onCompleted?: (activityTargets: ActivityTarget[]) => void;
onCompleted?: (activityTargets: (TaskTarget | NoteTarget)[]) => void;
}) => {
const activityTargetsFilter = getActivityTargetsFilter({
targetableObjects: targetableObjects,
@ -26,13 +30,16 @@ export const useActivityTargetsForTargetableObjects = ({
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const FIND_ACTIVITY_TARGETS_OPERATION_SIGNATURE =
findActivityTargetsOperationSignatureFactory({ objectMetadataItems });
findActivityTargetsOperationSignatureFactory({
objectNameSingular,
objectMetadataItems,
});
// TODO: We want to optimistically remove from this request
// If we are on a show page and we remove the current show page object corresponding activity target
// See also if we need to update useTimelineActivities
const { records: activityTargets, loading: loadingActivityTargets } =
useFindManyRecords<ActivityTarget>({
useFindManyRecords<TaskTarget | NoteTarget>({
skip,
objectNameSingular:
FIND_ACTIVITY_TARGETS_OPERATION_SIGNATURE.objectNameSingular,

View File

@ -1,188 +0,0 @@
import { Reference, useApolloClient } from '@apollo/client';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { Activity, ActivityType } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { makeActivityTargetsToCreateFromTargetableObjects } from '@/activities/utils/getActivityTargetsToCreateFromTargetableObjects';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useCreateActivityInCache = () => {
const { createManyRecordsInCache: createManyActivityTargetsInCache } =
useCreateManyRecordsInCache<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const cache = useApolloClient().cache;
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { record: currentWorkspaceMemberRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectRecordId: currentWorkspaceMember?.id,
});
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const { objectMetadataItem: objectMetadataItemActivityTarget } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const createOneActivityInCache = useCreateOneRecordInCache<Activity>({
objectMetadataItem: objectMetadataItemActivity,
});
const createActivityInCache = useRecoilCallback(
({ snapshot, set }) =>
({
type,
targetObject,
customAssignee,
}: {
type: ActivityType;
targetObject?: ActivityTargetableObject;
customAssignee?: WorkspaceMember;
}) => {
const activityId = v4();
const createdActivityInCache = createOneActivityInCache({
id: activityId,
__typename: 'Activity',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: currentWorkspaceMemberRecord,
authorId: currentWorkspaceMemberRecord?.id,
assignee: customAssignee ?? currentWorkspaceMemberRecord,
assigneeId: customAssignee?.id ?? currentWorkspaceMemberRecord?.id,
type,
});
if (isUndefinedOrNull(createdActivityInCache)) {
throw new Error('Failed to create activity in cache');
}
if (isUndefinedOrNull(targetObject)) {
set(recordStoreFamilyState(activityId), {
...createdActivityInCache,
activityTargets: [],
comments: [],
});
return {
createdActivityInCache: {
...createdActivityInCache,
activityTargets: [],
},
};
}
const targetObjectRecord = snapshot
.getLoadable(recordStoreFamilyState(targetObject.id))
.getValue();
if (isUndefinedOrNull(targetObjectRecord)) {
throw new Error('Failed to find target object record');
}
const activityTargetsToCreate =
makeActivityTargetsToCreateFromTargetableObjects({
activity: createdActivityInCache,
targetableObjects: [targetObject],
targetObjectRecords: [targetObjectRecord],
});
const createdActivityTargetsInCache = createManyActivityTargetsInCache(
activityTargetsToCreate,
);
const activityTargetsConnection = getRecordConnectionFromRecords({
objectMetadataItems: objectMetadataItems,
objectMetadataItem: objectMetadataItemActivityTarget,
records: createdActivityTargetsInCache,
withPageInfo: false,
computeReferences: true,
isRootLevel: false,
});
modifyRecordFromCache({
recordId: createdActivityInCache.id,
cache,
fieldModifiers: {
activityTargets: () => activityTargetsConnection,
},
objectMetadataItem: objectMetadataItemActivity,
});
const targetObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObject.targetObjectNameSingular,
);
if (isDefined(targetObjectMetadataItem)) {
modifyRecordFromCache({
cache,
objectMetadataItem: targetObjectMetadataItem,
recordId: targetObject.id,
fieldModifiers: {
activityTargets: (activityTargetsRef, { readField }) => {
const edges = readField<{ node: Reference }[]>(
'edges',
activityTargetsRef,
);
if (!edges) return activityTargetsRef;
return {
...activityTargetsRef,
edges: [...edges, ...activityTargetsConnection.edges],
};
},
},
});
}
set(recordStoreFamilyState(activityId), {
...createdActivityInCache,
activityTargets: createdActivityTargetsInCache,
comments: [],
});
return {
createdActivityInCache: {
...createdActivityInCache,
activityTargets: createdActivityTargetsInCache,
},
};
},
[
createOneActivityInCache,
currentWorkspaceMemberRecord,
createManyActivityTargetsInCache,
objectMetadataItems,
objectMetadataItemActivityTarget,
cache,
objectMetadataItemActivity,
],
);
return {
createActivityInCache,
};
};

View File

@ -1,8 +1,6 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { CREATE_ONE_ACTIVITY_OPERATION_SIGNATURE } from '@/activities/graphql/operation-signatures/CreateOneActivityOperationSignature';
import { ActivityForEditor } from '@/activities/types/ActivityForEditor';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -13,33 +11,48 @@ import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useApolloClient } from '@apollo/client';
import { createOneActivityOperationSignatureFactory } from '@/activities/graphql/operation-signatures/factories/createOneActivityOperationSignatureFactory';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular';
import { useRecoilCallback } from 'recoil';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateActivityInDB = () => {
export const useCreateActivityInDB = ({
activityObjectNameSingular,
}: {
activityObjectNameSingular:
| CoreObjectNameSingular.Task
| CoreObjectNameSingular.Note;
}) => {
const createOneActivityOperationSignature =
createOneActivityOperationSignatureFactory({
objectNameSingular: activityObjectNameSingular,
});
const { createOneRecord: createOneActivity } = useCreateOneRecord({
objectNameSingular:
CREATE_ONE_ACTIVITY_OPERATION_SIGNATURE.objectNameSingular,
recordGqlFields: CREATE_ONE_ACTIVITY_OPERATION_SIGNATURE.fields,
objectNameSingular: activityObjectNameSingular,
recordGqlFields: createOneActivityOperationSignature.fields,
shouldMatchRootQueryFilter: true,
});
const { createManyRecords: createManyActivityTargets } =
useCreateManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
shouldMatchRootQueryFilter: true,
});
const { createManyRecords: createManyActivityTargets } = useCreateManyRecords<
TaskTarget | NoteTarget
>({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
shouldMatchRootQueryFilter: true,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem: objectMetadataItemActivityTarget } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Activity,
objectNameSingular: activityObjectNameSingular,
});
const cache = useApolloClient().cache;
@ -52,7 +65,8 @@ export const useCreateActivityInDB = () => {
updatedAt: new Date().toISOString(),
});
const activityTargetsToCreate = activityToCreate.activityTargets ?? [];
const activityTargetsToCreate =
activityToCreate.noteTargets ?? activityToCreate.taskTargets ?? [];
if (isNonEmptyArray(activityTargetsToCreate)) {
await createManyActivityTargets(activityTargetsToCreate);

View File

@ -1,25 +1,34 @@
import { useRecoilState, useSetRecoilState } from 'recoil';
import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
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';
export const useOpenActivityRightDrawer = () => {
export const useOpenActivityRightDrawer = ({
objectNameSingular,
}: {
objectNameSingular: CoreObjectNameSingular;
}) => {
const { openRightDrawer, isRightDrawerOpen, rightDrawerPage } =
useRightDrawer();
const [viewableRecordId, setViewableRecordId] = useRecoilState(
viewableRecordIdState,
);
const setActivityIdInDrawer = useSetRecoilState(activityIdInDrawerState);
const setViewableRecordNameSingular = useSetRecoilState(
viewableRecordNameSingularState,
);
const setHotkeyScope = useSetHotkeyScope();
return (activityId: string) => {
if (
isRightDrawerOpen &&
rightDrawerPage === RightDrawerPages.EditActivity &&
rightDrawerPage === RightDrawerPages.ViewRecord &&
viewableRecordId === activityId
) {
return;
@ -27,7 +36,7 @@ export const useOpenActivityRightDrawer = () => {
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableRecordId(activityId);
setActivityIdInDrawer(activityId);
openRightDrawer(RightDrawerPages.EditActivity);
setViewableRecordNameSingular(objectNameSingular);
openRightDrawer(RightDrawerPages.ViewRecord);
};
};

View File

@ -1,12 +1,7 @@
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import { useCreateActivityInCache } from '@/activities/hooks/useCreateActivityInCache';
import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState';
import { activityTargetableEntityArrayState } from '@/activities/states/activityTargetableEntityArrayState';
import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState';
import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState';
import { temporaryActivityForEditorState } from '@/activities/states/temporaryActivityForEditorState';
import { ActivityType } from '@/activities/types/Activity';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
@ -14,54 +9,84 @@ import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPage
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { Note } from '@/activities/types/Note';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { Task } from '@/activities/types/Task';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { ActivityTargetableObject } from '../types/ActivityTargetableEntity';
export const useOpenCreateActivityDrawer = () => {
export const useOpenCreateActivityDrawer = ({
activityObjectNameSingular,
}: {
activityObjectNameSingular:
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
}) => {
const { openRightDrawer } = useRightDrawer();
const setHotkeyScope = useSetHotkeyScope();
const { createActivityInCache } = useCreateActivityInCache();
const { createOneRecord: createOneActivity } = useCreateOneRecord<
Task | Note
>({
objectNameSingular: activityObjectNameSingular,
});
const { createOneRecord: createOneActivityTarget } = useCreateOneRecord<
TaskTarget | NoteTarget
>({
objectNameSingular:
activityObjectNameSingular === CoreObjectNameSingular.Task
? CoreObjectNameSingular.TaskTarget
: CoreObjectNameSingular.NoteTarget,
});
const setActivityTargetableEntityArray = useSetRecoilState(
activityTargetableEntityArrayState,
);
const setViewableRecordId = useSetRecoilState(viewableRecordIdState);
const setIsCreatingActivity = useSetRecoilState(isActivityInCreateModeState);
const setTemporaryActivityForEditor = useSetRecoilState(
temporaryActivityForEditorState,
const setViewableRecordNameSingular = useSetRecoilState(
viewableRecordNameSingularState,
);
const setActivityIdInDrawer = useSetRecoilState(activityIdInDrawerState);
const [, setIsUpsertingActivityInDB] = useRecoilState(
const setIsUpsertingActivityInDB = useSetRecoilState(
isUpsertingActivityInDBState,
);
const openCreateActivityDrawer = async ({
type,
targetableObjects,
customAssignee,
}: {
type: ActivityType;
targetableObjects: ActivityTargetableObject[];
customAssignee?: WorkspaceMember;
}) => {
const { createdActivityInCache } = createActivityInCache({
type,
targetObject: targetableObjects[0],
customAssignee,
const activity = await createOneActivity({
assigneeId: customAssignee?.id,
});
const targetableObjectRelationIdName = `${targetableObjects[0].targetObjectNameSingular}Id`;
await createOneActivityTarget({
taskId:
activityObjectNameSingular === CoreObjectNameSingular.Task
? activity.id
: undefined,
noteId:
activityObjectNameSingular === CoreObjectNameSingular.Note
? activity.id
: undefined,
[targetableObjectRelationIdName]: targetableObjects[0].id,
});
setActivityIdInDrawer(createdActivityInCache.id);
setTemporaryActivityForEditor(createdActivityInCache);
setIsCreatingActivity(true);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableRecordId(createdActivityInCache.id);
setViewableRecordId(activity.id);
setViewableRecordNameSingular(activityObjectNameSingular);
setActivityTargetableEntityArray(targetableObjects ?? []);
openRightDrawer(RightDrawerPages.CreateActivity);
openRightDrawer(RightDrawerPages.ViewRecord);
setIsUpsertingActivityInDB(false);
};

View File

@ -1,9 +1,11 @@
import { useApolloClient } from '@apollo/client';
import { findActivitiesOperationSignatureFactory } from '@/activities/graphql/operation-signatures/factories/findActivitiesOperationSignatureFactory';
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { Note } from '@/activities/types/Note';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { Task } from '@/activities/types/Task';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -14,14 +16,18 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sortByAscString } from '~/utils/array/sortByAscString';
import { isDefined } from '~/utils/isDefined';
export const usePrepareFindManyActivitiesQuery = () => {
export const usePrepareFindManyActivitiesQuery = ({
activityObjectNameSingular,
}: {
activityObjectNameSingular: CoreObjectNameSingular;
}) => {
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Activity,
objectNameSingular: activityObjectNameSingular,
});
const getActivityFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.Activity,
objectNameSingular: activityObjectNameSingular,
});
const cache = useApolloClient().cache;
@ -39,7 +45,7 @@ export const usePrepareFindManyActivitiesQuery = () => {
}: {
additionalFilter?: Record<string, unknown>;
targetableObject: ActivityTargetableObject;
shouldActivityBeExcluded?: (activityTarget: Activity) => boolean;
shouldActivityBeExcluded?: (activityTarget: Task | Note) => boolean;
}) => {
const targetableObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
@ -60,8 +66,10 @@ export const usePrepareFindManyActivitiesQuery = () => {
cache,
});
const activityTargets: ActivityTarget[] =
targetableObjectRecord?.activityTargets ?? [];
const activityTargets: (TaskTarget | NoteTarget)[] =
targetableObjectRecord?.taskTargets ??
targetableObjectRecord?.noteTargets ??
[];
const activityTargetIds = [
...new Set(
@ -71,7 +79,7 @@ export const usePrepareFindManyActivitiesQuery = () => {
),
];
const activities: Activity[] = activityTargetIds
const activities: (Task | Note)[] = activityTargetIds
.map((activityTargetId) => {
const activityTarget = activityTargets.find(
(activityTarget) => activityTarget.id === activityTargetId,
@ -81,7 +89,7 @@ export const usePrepareFindManyActivitiesQuery = () => {
return undefined;
}
return getActivityFromCache<Activity>(activityTarget.activityId);
return getActivityFromCache<Task | Note>(activityTarget.activityId);
})
.filter(isDefined);
@ -103,7 +111,10 @@ export const usePrepareFindManyActivitiesQuery = () => {
});
const FIND_ACTIVITIES_OPERATION_SIGNATURE =
findActivitiesOperationSignatureFactory({ objectMetadataItems });
findActivitiesOperationSignatureFactory({
objectNameSingular: activityObjectNameSingular,
objectMetadataItems,
});
upsertFindManyActivitiesInCache({
objectRecordsToOverwrite: filteredActivities,

View File

@ -1,20 +1,25 @@
import { useRecoilValue } from 'recoil';
import { usePrepareFindManyActivitiesQuery } from '@/activities/hooks/usePrepareFindManyActivitiesQuery';
import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState';
import { Activity } from '@/activities/types/Activity';
import { objectShowPageTargetableObjectState } from '@/activities/timelineActivities/states/objectShowPageTargetableObjectIdState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { isDefined } from '~/utils/isDefined';
// This hook should only be executed if the normalized cache is up-to-date
// It will take a targetableObject and prepare the queries for the activities
// based on the activityTargets of the targetableObject
export const useRefreshShowPageFindManyActivitiesQueries = () => {
export const useRefreshShowPageFindManyActivitiesQueries = ({
activityObjectNameSingular,
}: {
activityObjectNameSingular: CoreObjectNameSingular;
}) => {
const objectShowPageTargetableObject = useRecoilValue(
objectShowPageTargetableObjectState,
);
const { prepareFindManyActivitiesQuery } =
usePrepareFindManyActivitiesQuery();
const { prepareFindManyActivitiesQuery } = usePrepareFindManyActivitiesQuery({
activityObjectNameSingular,
});
const refreshShowPageFindManyActivitiesQueries = () => {
if (isDefined(objectShowPageTargetableObject)) {
@ -24,21 +29,12 @@ export const useRefreshShowPageFindManyActivitiesQueries = () => {
prepareFindManyActivitiesQuery({
targetableObject: objectShowPageTargetableObject,
additionalFilter: {
completedAt: { is: 'NULL' },
type: { eq: 'Task' },
},
shouldActivityBeExcluded: (activity: Activity) => {
return activity.type !== 'Task';
status: { eq: 'TODO' },
},
});
prepareFindManyActivitiesQuery({
targetableObject: objectShowPageTargetableObject,
additionalFilter: {
type: { eq: 'Note' },
},
shouldActivityBeExcluded: (activity: Activity) => {
return activity.type !== 'Note';
},
additionalFilter: {},
});
}
};

View File

@ -1,50 +1,58 @@
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB';
import { useRefreshShowPageFindManyActivitiesQueries } from '@/activities/hooks/useRefreshShowPageFindManyActivitiesQueries';
import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState';
import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState';
import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState';
import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState';
import { Activity } from '@/activities/types/Activity';
import { objectShowPageTargetableObjectState } from '@/activities/timelineActivities/states/objectShowPageTargetableObjectIdState';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { isDefined } from '~/utils/isDefined';
export const useUpsertActivity = () => {
const [isActivityInCreateMode, setIsActivityInCreateMode] = useRecoilState(
isActivityInCreateModeState,
);
export const useUpsertActivity = ({
activityObjectNameSingular,
}: {
activityObjectNameSingular:
| CoreObjectNameSingular.Task
| CoreObjectNameSingular.Note;
}) => {
const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState);
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Activity>({
objectNameSingular: CoreObjectNameSingular.Activity,
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<
Task | Note
>({
objectNameSingular: activityObjectNameSingular,
});
const { createActivityInDB } = useCreateActivityInDB();
const { createActivityInDB } = useCreateActivityInDB({
activityObjectNameSingular,
});
const [, setIsUpsertingActivityInDB] = useRecoilState(
isUpsertingActivityInDBState,
);
const setActivityIdInDrawer = useSetRecoilState(activityIdInDrawerState);
const objectShowPageTargetableObject = useRecoilValue(
objectShowPageTargetableObjectState,
);
const { refreshShowPageFindManyActivitiesQueries } =
useRefreshShowPageFindManyActivitiesQueries();
useRefreshShowPageFindManyActivitiesQueries({
activityObjectNameSingular,
});
const upsertActivity = async ({
activity,
input,
}: {
activity: Activity;
input: Partial<Activity>;
activity: Task | Note;
input: Partial<Task | Note>;
}) => {
setIsUpsertingActivityInDB(true);
if (isActivityInCreateMode) {
const activityToCreate: Activity = {
const activityToCreate: Partial<Task | Note> = {
...activity,
...input,
};
@ -54,9 +62,6 @@ export const useUpsertActivity = () => {
}
await createActivityInDB(activityToCreate);
setActivityIdInDrawer(activityToCreate.id);
setIsActivityInCreateMode(false);
} else {
await updateOneActivity?.({
idToUpdate: activity.id,

View File

@ -6,10 +6,13 @@ import { v4 } from 'uuid';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { ActivityTargetObjectRecordEffect } from '@/activities/inline-cell/components/ActivityTargetObjectRecordEffect';
import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState';
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { Note } from '@/activities/types/Note';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { Task } from '@/activities/types/Task';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache';
@ -35,13 +38,17 @@ const StyledSelectContainer = styled.div`
`;
type ActivityTargetInlineCellEditModeProps = {
activity: Activity;
activity: Task | Note;
activityTargetWithTargetRecords: ActivityTargetWithTargetRecord[];
activityObjectNameSingular:
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
};
export const ActivityTargetInlineCellEditMode = ({
activity,
activityTargetWithTargetRecords,
activityObjectNameSingular,
}: ActivityTargetInlineCellEditModeProps) => {
const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState);
const relationPickerScopeId = `relation-picker-${activity.id}`;
@ -53,24 +60,27 @@ export const ActivityTargetInlineCellEditMode = ({
}),
);
const { createManyRecords: createManyActivityTargets } =
useCreateManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const { createManyRecords: createManyActivityTargets } = useCreateManyRecords<
NoteTarget | TaskTarget
>({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const { deleteManyRecords: deleteManyActivityTargets } = useDeleteManyRecords(
{
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
},
);
const { closeInlineCell: closeEditableField } = useInlineCell();
const { upsertActivity } = useUpsertActivity();
const { upsertActivity } = useUpsertActivity({
activityObjectNameSingular,
});
const { objectMetadataItem: objectMetadataItemActivityTarget } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const setActivityFromStore = useSetRecoilState(
@ -78,8 +88,8 @@ export const ActivityTargetInlineCellEditMode = ({
);
const { createManyRecordsInCache: createManyActivityTargetsInCache } =
useCreateManyRecordsInCache<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
useCreateManyRecordsInCache<NoteTarget | TaskTarget>({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const handleSubmit = useRecoilCallback(
@ -166,12 +176,19 @@ export const ActivityTargetInlineCellEditMode = ({
const fieldNameWithIdSuffix = getActivityTargetObjectFieldIdName({
nameSingular: record.objectMetadataItem.nameSingular,
});
const newActivityTarget = prefillRecord<ActivityTarget>({
const newActivityTarget = prefillRecord<NoteTarget | TaskTarget>({
objectMetadataItem: objectMetadataItemActivityTarget,
input: {
id: newActivityTargetId,
activityId: activity.id,
activity,
taskId:
activityObjectNameSingular === CoreObjectNameSingular.Task
? activity.id
: null,
noteId:
activityObjectNameSingular === CoreObjectNameSingular.Note
? activity.id
: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
[fieldName]: record.record,
@ -186,7 +203,11 @@ export const ActivityTargetInlineCellEditMode = ({
upsertActivity({
activity,
input: {
activityTargets: activityTargetsAfterUpdate,
[activityObjectNameSingular === CoreObjectNameSingular.Task
? 'taskTargets'
: activityObjectNameSingular === CoreObjectNameSingular.Note
? 'noteTargets'
: '']: activityTargetsAfterUpdate,
},
});
} else {
@ -219,7 +240,11 @@ export const ActivityTargetInlineCellEditMode = ({
upsertActivity({
activity,
input: {
activityTargets: activityTargetsAfterUpdate,
[activityObjectNameSingular === CoreObjectNameSingular.Task
? 'taskTargets'
: activityObjectNameSingular === CoreObjectNameSingular.Note
? 'noteTargets'
: '']: activityTargetsAfterUpdate,
},
});
} else {
@ -241,6 +266,7 @@ export const ActivityTargetInlineCellEditMode = ({
objectMetadataItemActivityTarget,
relationPickerScopeId,
upsertActivity,
activityObjectNameSingular,
],
);

View File

@ -5,8 +5,9 @@ import { IconArrowUpRight, IconPencil } from 'twenty-ui';
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode';
import { Activity } from '@/activities/types/Activity';
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
@ -17,10 +18,13 @@ import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlin
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
type ActivityTargetsInlineCellProps = {
activity: Activity;
activity: Task | Note;
showLabel?: boolean;
maxWidth?: number;
readonly?: boolean;
activityObjectNameSingular:
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
};
export const ActivityTargetsInlineCell = ({
@ -28,9 +32,13 @@ export const ActivityTargetsInlineCell = ({
showLabel = true,
maxWidth,
readonly,
activityObjectNameSingular,
}: ActivityTargetsInlineCellProps) => {
const { activityTargetObjectRecords } =
useActivityTargetObjectRecords(activity);
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(
activity,
activityObjectNameSingular,
);
const { closeInlineCell } = useInlineCell();
const { fieldDefinition } = useContext(FieldContext);
@ -45,9 +53,9 @@ export const ActivityTargetsInlineCell = ({
const { FieldContextProvider: ActivityTargetsContextProvider } =
useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
objectNameSingular: activityObjectNameSingular,
objectRecordId: activity.id,
fieldMetadataName: 'activityTargets',
fieldMetadataName: fieldDefinition.metadata.fieldName,
fieldPosition: 3,
overridenIsFieldEmpty: activityTargetObjectRecords.length === 0,
});
@ -70,6 +78,7 @@ export const ActivityTargetsInlineCell = ({
<ActivityTargetInlineCellEditMode
activity={activity}
activityTargetWithTargetRecords={activityTargetObjectRecords}
activityObjectNameSingular={activityObjectNameSingular}
/>
}
label="Relations"

View File

@ -1,16 +1,11 @@
import { useMemo } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComment } from 'twenty-ui';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Note } from '@/activities/types/Note';
import { getActivityPreview } from '@/activities/utils/getActivityPreview';
import {
FieldContext,
GenericFieldContextType,
} from '@/object-record/record-field/contexts/FieldContext';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
const StyledCard = styled.div<{ isSingleNote: boolean }>`
align-items: flex-start;
@ -66,14 +61,6 @@ const StyledFooter = styled.div`
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,
@ -81,34 +68,37 @@ export const NoteCard = ({
note: Note;
isSingleNote: boolean;
}) => {
const theme = useTheme();
const openActivityRightDrawer = useOpenActivityRightDrawer();
const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular: CoreObjectNameSingular.Note,
});
const body = getActivityPreview(note.body);
const fieldContext = useMemo(
() => ({ recoilScopeId: note?.id ?? '' }),
[note?.id],
);
const { FieldContextProvider: NoteTargetsContextProvider } = useFieldContext({
objectNameSingular: CoreObjectNameSingular.Note,
objectRecordId: note.id,
fieldMetadataName: 'noteTargets',
fieldPosition: 0,
});
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} readonly />
{note.comments && note.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
{note.comments.length}
</StyledCommentIcon>
)}
</StyledFooter>
</StyledCard>
</FieldContext.Provider>
<StyledCard isSingleNote={isSingleNote}>
<StyledCardDetailsContainer
onClick={() => openActivityRightDrawer(note.id)}
>
<StyledNoteTitle>{note.title ?? 'Task Title'}</StyledNoteTitle>
<StyledCardContent>{body}</StyledCardContent>
</StyledCardDetailsContainer>
<StyledFooter>
{NoteTargetsContextProvider && (
<NoteTargetsContextProvider>
<ActivityTargetsInlineCell
activity={note}
activityObjectNameSingular={CoreObjectNameSingular.Note}
readonly
/>
</NoteTargetsContextProvider>
)}
</StyledFooter>
</StyledCard>
);
};

View File

@ -6,6 +6,7 @@ import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateAct
import { NoteList } from '@/activities/notes/components/NoteList';
import { useNotes } from '@/activities/notes/hooks/useNotes';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { Button } from '@/ui/input/button/components/Button';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import {
@ -31,7 +32,9 @@ export const Notes = ({
}) => {
const { notes, loading } = useNotes(targetableObject);
const openCreateActivity = useOpenCreateActivityDrawer();
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Note,
});
const isNotesEmpty = !notes || notes.length === 0;
@ -60,7 +63,6 @@ export const Notes = ({
variant="secondary"
onClick={() =>
openCreateActivity({
type: 'Note',
targetableObjects: [targetableObject],
})
}
@ -82,7 +84,6 @@ export const Notes = ({
title="Add note"
onClick={() =>
openCreateActivity({
type: 'Note',
targetableObjects: [targetableObject],
})
}

View File

@ -16,9 +16,7 @@ jest.mock('recoil', () => {
...actualRecoil,
useRecoilState: jest.fn(() => {
const mockCurrentNotesQueryVariables = {
filter: {
type: { eq: 'Note' },
},
filter: {},
orderBy: 'mockOrderBy',
};
return [mockCurrentNotesQueryVariables, jest.fn()];

View File

@ -3,26 +3,26 @@ import { useRecoilState } from 'recoil';
import { useActivities } from '@/activities/hooks/useActivities';
import { currentNotesQueryVariablesState } from '@/activities/notes/states/currentNotesQueryVariablesState';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timelineActivities/constants/FindManyTimelineActivitiesOrderBy';
import { Note } from '@/activities/types/Note';
import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ActivityTargetableObject } from '../../types/ActivityTargetableEntity';
export const useNotes = (targetableObject: ActivityTargetableObject) => {
const notesQueryVariables = useMemo(
() =>
({
filter: {
type: { eq: 'Note' },
},
filter: {},
orderBy: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,
}) as RecordGqlOperationVariables,
[],
);
const { activities, loading } = useActivities({
const { activities, loading } = useActivities<Note>({
objectNameSingular: CoreObjectNameSingular.Note,
activitiesFilters: notesQueryVariables.filter ?? {},
activitiesOrderByVariables: notesQueryVariables.orderBy ?? [{}],
targetableObjects: [targetableObject],

View File

@ -1,133 +0,0 @@
import styled from '@emotion/styled';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { IconTrash } from 'twenty-ui';
import { useRefreshShowPageFindManyActivitiesQueries } from '@/activities/hooks/useRefreshShowPageFindManyActivitiesQueries';
import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState';
import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState';
import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState';
import { temporaryActivityForEditorState } from '@/activities/states/temporaryActivityForEditorState';
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteRecordFromCache } from '@/object-record/cache/hooks/useDeleteRecordFromCache';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { mapToRecordId } from '@/object-record/utils/mapToObjectId';
import { IconButton } from '@/ui/input/button/components/IconButton';
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
import { isDefined } from '~/utils/isDefined';
const StyledButtonContainer = styled.div`
display: inline-flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const ActivityActionBar = () => {
const viewableRecordId = useRecoilValue(viewableRecordIdState);
const activityIdInDrawer = useRecoilValue(activityIdInDrawerState);
const [, setIsRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
const { deleteOneRecord: deleteOneActivity } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const { deleteManyRecords: deleteManyActivityTargets } = useDeleteManyRecords(
{
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
},
);
const [temporaryActivityForEditor, setTemporaryActivityForEditor] =
useRecoilState(temporaryActivityForEditorState);
const deleteActivityFromCache = useDeleteRecordFromCache({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const deleteActivityTargetFromCache = useDeleteRecordFromCache({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState);
const [isUpsertingActivityInDB] = useRecoilState(
isUpsertingActivityInDBState,
);
const { refreshShowPageFindManyActivitiesQueries } =
useRefreshShowPageFindManyActivitiesQueries();
const deleteActivity = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!activityIdInDrawer) {
throw new Error(
'activityIdInDrawer is not defined, this should not happen',
);
}
const activity = snapshot
.getLoadable(recordStoreFamilyState(activityIdInDrawer))
.getValue() as Activity;
setIsRightDrawerOpen(false);
if (!isNonEmptyString(viewableRecordId)) {
return;
}
if (isActivityInCreateMode && isDefined(temporaryActivityForEditor)) {
deleteActivityFromCache(temporaryActivityForEditor);
setTemporaryActivityForEditor(null);
return;
}
if (isNonEmptyString(activityIdInDrawer)) {
const activityTargetIdsToDelete: string[] =
activity.activityTargets.map(mapToRecordId) ?? [];
deleteActivityFromCache(activity);
activity.activityTargets.forEach((activityTarget: ActivityTarget) => {
deleteActivityTargetFromCache(activityTarget);
});
refreshShowPageFindManyActivitiesQueries();
if (isNonEmptyArray(activityTargetIdsToDelete)) {
await deleteManyActivityTargets(activityTargetIdsToDelete);
}
await deleteOneActivity?.(viewableRecordId);
}
},
[
activityIdInDrawer,
setIsRightDrawerOpen,
viewableRecordId,
isActivityInCreateMode,
temporaryActivityForEditor,
deleteActivityFromCache,
setTemporaryActivityForEditor,
refreshShowPageFindManyActivitiesQueries,
deleteOneActivity,
deleteActivityTargetFromCache,
deleteManyActivityTargets,
],
);
const actionsAreDisabled = isUpsertingActivityInDB;
return (
<StyledButtonContainer>
<IconButton
Icon={IconTrash}
onClick={deleteActivity}
size="medium"
variant="secondary"
disabled={actionsAreDisabled}
/>
</StyledButtonContainer>
);
};

View File

@ -1,39 +0,0 @@
import styled from '@emotion/styled';
import { ActivityEditor } from '@/activities/components/ActivityEditor';
import { ActivityEditorEffect } from '@/activities/components/ActivityEditorEffect';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
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;
fillTitleFromBody?: boolean;
};
export const RightDrawerActivity = ({
activityId,
showComment = false,
fillTitleFromBody = false,
}: RightDrawerActivityProps) => {
return (
<StyledContainer>
<RecordValueSetterEffect recordId={activityId} />
<ActivityEditorEffect activityId={activityId} />
<ActivityEditor
activityId={activityId}
showComment={showComment}
fillTitleFromBody={fillTitleFromBody}
/>
</StyledContainer>
);
};

View File

@ -1,21 +0,0 @@
import { useRecoilValue } from 'recoil';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { RightDrawerActivity } from '../RightDrawerActivity';
export const RightDrawerCreateActivity = () => {
const viewableRecordId = useRecoilValue(viewableRecordIdState);
return (
<>
{viewableRecordId && (
<RightDrawerActivity
activityId={viewableRecordId}
showComment={false}
fillTitleFromBody={true}
/>
)}
</>
);
};

View File

@ -1,17 +0,0 @@
import { useRecoilValue } from 'recoil';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { RightDrawerActivity } from '../RightDrawerActivity';
export const RightDrawerEditActivity = () => {
const viewableRecordId = useRecoilValue(viewableRecordIdState);
return (
<>
{viewableRecordId && (
<RightDrawerActivity activityId={viewableRecordId} />
)}
</>
);
};

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const activityIdInDrawerState = createState<string | null>({
key: 'activityIdInDrawerState',
defaultValue: null,
});

View File

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

View File

@ -1,9 +0,0 @@
import { createState } from 'twenty-ui';
import { ActivityForEditor } from '@/activities/types/ActivityForEditor';
export const temporaryActivityForEditorState =
createState<ActivityForEditor | null>({
key: 'temporaryActivityForEditorState',
defaultValue: null,
});

View File

@ -1,6 +1,5 @@
import { Meta, StoryObj } from '@storybook/react';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
@ -9,7 +8,7 @@ import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWith
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedTasks } from '~/testing/mock-data/activities';
import { mockedTasks } from '~/testing/mock-data/tasks';
const meta: Meta<typeof TaskGroups> = {
title: 'Modules/Activity/TaskGroups',
@ -25,9 +24,6 @@ const meta: Meta<typeof TaskGroups> = {
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
parameters: {
customRecoilScopeContext: TasksRecoilScopeContext,
},
};
export default meta;
@ -39,7 +35,7 @@ export const WithTasks: Story = {
args: {
targetableObjects: [
{
id: mockedTasks[0].authorId,
id: mockedTasks[0].taskTargets?.[0].personId,
targetObjectNameSingular: 'person',
},
] as ActivityTargetableObject[],

View File

@ -2,168 +2,10 @@ import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { TaskList } from '@/activities/tasks/components/TaskList';
import { Activity } from '@/activities/types/Activity';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const workspaceMember: WorkspaceMember = {
__typename: 'WorkspaceMember',
id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e',
name: {
firstName: 'Charles',
lastName: 'Test',
},
avatarUrl: '',
locale: 'en',
createdAt: '2023-04-26T10:23:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
userId: 'e2409670-1088-46b4-858e-f20a598d9d0f',
userEmail: 'charles@test.com',
colorScheme: 'Light',
};
const mockedActivities: Array<Activity> = [
{
id: '3ecaa1be-aac7-463a-a38e-64078dd451d5',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
reminderAt: null,
title: 'My very first note',
type: 'Note',
body: '',
dueAt: '2023-04-26T10:12:42.33625+00:00',
completedAt: null,
author: workspaceMember,
assignee: workspaceMember,
assigneeId: workspaceMember.id,
authorId: workspaceMember.id,
comments: [],
activityTargets: [
{
id: '89bb825c-171e-4bcc-9cf7-43448d6fb300',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
targetObjectNameSingular: 'company',
personId: null,
companyId: '89bb825c-171e-4bcc-9cf7-43448d6fb280',
company: {
__typename: 'Company',
id: '89bb825c-171e-4bcc-9cf7-43448d6fb280',
name: 'Airbnb',
domainName: 'airbnb.com',
},
person: null,
activityId: '89bb825c-171e-4bcc-9cf7-43448d6fb230',
activity: {
__typename: 'Activity',
id: '89bb825c-171e-4bcc-9cf7-43448d6fb230',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
},
__typename: 'ActivityTarget',
},
{
id: '89bb825c-171e-4bcc-9cf7-43448d6fb301',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
targetObjectNameSingular: 'company',
personId: null,
companyId: 'b396e6b9-dc5c-4643-bcff-61b6cf7523ae',
company: {
__typename: 'Company',
id: '89bb825c-171e-4bcc-9cf7-43448d6fb278',
name: 'Aircall',
domainName: 'aircall.io',
},
person: null,
activityId: 'b396e6b9-dc5c-4643-bcff-61b6cf7523ae',
activity: {
__typename: 'Activity',
id: '89bb825c-171e-4bcc-9cf7-43448d6fb231',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
__typename: 'ActivityTarget',
},
],
__typename: 'Activity',
},
{
id: '89bb825c-171e-4bcc-9cf7-43448d6fb278a',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
reminderAt: null,
title: 'Another note',
body: '',
type: 'Note',
completedAt: null,
dueAt: '2029-08-26T10:12:42.33625+00:00',
author: {
...workspaceMember,
},
assignee: { ...workspaceMember },
assigneeId: workspaceMember.id,
authorId: workspaceMember.id,
comments: [],
activityTargets: [
{
id: '89bb825c-171e-4bcc-9cf7-43448d6fb278t',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
targetObjectNameSingular: 'person',
personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', // Alexandre
person: {
__typename: 'Person',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
name: {
firstName: 'Alexandre',
lastName: 'Test',
},
avatarUrl: '',
},
company: null,
companyId: null,
activityId: '89bb825c-171e-4bcc-9cf7-43448d6fb278a',
activity: {
__typename: 'Activity',
id: '89bb825c-171e-4bcc-9cf7-43448d6fb278a',
createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00',
},
__typename: 'ActivityTarget',
},
{
id: '89bb825c-171e-4bcc-9cf7-43448d6fb279t',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d', // Jean d'Eau
companyId: null,
targetObjectNameSingular: 'person',
company: null,
person: {
__typename: 'Person',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d',
name: {
firstName: 'Jean',
lastName: "d'Eau",
},
avatarUrl: '',
},
activityId: '89bb825c-171e-4bcc-9cf7-43448d6fb278a',
activity: {
__typename: 'Activity',
id: '89bb825c-171e-4bcc-9cf7-43448d6fb278a',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
__typename: 'ActivityTarget',
},
],
__typename: 'Activity',
},
];
import { mockedTasks } from '~/testing/mock-data/tasks';
const meta: Meta<typeof TaskList> = {
title: 'Modules/Activity/TaskList',
@ -171,7 +13,7 @@ const meta: Meta<typeof TaskList> = {
decorators: [MemoryRouterDecorator, ComponentDecorator, SnackBarDecorator],
args: {
title: 'Tasks',
tasks: mockedActivities,
tasks: mockedTasks,
},
parameters: {
msw: graphqlMocks,
@ -184,6 +26,6 @@ type Story = StoryObj<typeof TaskList>;
export const Default: Story = {
args: {
title: 'Tasks',
tasks: mockedActivities,
tasks: mockedTasks,
},
};

View File

@ -3,6 +3,7 @@ import { IconPlus } from 'twenty-ui';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { Button } from '@/ui/input/button/components/Button';
export const AddTaskButton = ({
@ -10,7 +11,9 @@ export const AddTaskButton = ({
}: {
activityTargetableObjects?: ActivityTargetableObject[];
}) => {
const openCreateActivity = useOpenCreateActivityDrawer();
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
if (!isNonEmptyArray(activityTargetableObjects)) {
return <></>;
@ -24,7 +27,6 @@ export const AddTaskButton = ({
title="Add task"
onClick={() =>
openCreateActivity({
type: 'Task',
targetableObjects: activityTargetableObjects,
})
}

View File

@ -1,48 +0,0 @@
import { useEffect } from 'react';
import { DateTime } from 'luxon';
import { useRecoilState, useRecoilValue } from 'recoil';
import { currentUserDueTaskCountState } from '@/activities/tasks/states/currentUserTaskCountState';
import { Activity } from '@/activities/types/Activity';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { parseDate } from '~/utils/date-utils';
export const CurrentUserDueTaskCountEffect = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const [currentUserDueTaskCount, setCurrentUserDueTaskCount] = useRecoilState(
currentUserDueTaskCountState,
);
const { records: tasks } = useFindManyRecords<Activity>({
objectNameSingular: CoreObjectNameSingular.Activity,
filter: {
type: { eq: 'Task' },
completedAt: { is: 'NULL' },
assigneeId: { eq: currentWorkspaceMember?.id },
},
});
const computedCurrentUserDueTaskCount = 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;
useEffect(() => {
if (currentUserDueTaskCount !== computedCurrentUserDueTaskCount) {
setCurrentUserDueTaskCount(computedCurrentUserDueTaskCount);
}
}, [
computedCurrentUserDueTaskCount,
currentUserDueTaskCount,
setCurrentUserDueTaskCount,
]);
return <></>;
};

View File

@ -1,10 +1,8 @@
import styled from '@emotion/styled';
import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ActivityTargetableObject } 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;
@ -21,11 +19,9 @@ export const ObjectTasks = ({
}) => {
return (
<StyledContainer>
<RecoilScope CustomRecoilScopeContext={TasksRecoilScopeContext}>
<ObjectFilterDropdownScope filterScopeId="entity-tasks-filter-scope">
<TaskGroups targetableObjects={[targetableObject]} showAddButton />
</ObjectFilterDropdownScope>
</RecoilScope>
<ObjectFilterDropdownScope filterScopeId="entity-tasks-filter-scope">
<TaskGroups targetableObjects={[targetableObject]} showAddButton />
</ObjectFilterDropdownScope>
</StyledContainer>
);
};

View File

@ -1,14 +1,16 @@
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
export const PageAddTaskButton = () => {
const openCreateActivity = useOpenCreateActivityDrawer();
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
// TODO: fetch workspace member from filter here
const handleClick = () => {
openCreateActivity({
type: 'Task',
targetableObjects: [],
});
};

View File

@ -18,6 +18,9 @@ import {
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import groupBy from 'lodash.groupby';
import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList';
@ -33,37 +36,27 @@ type TaskGroupsProps = {
};
export const TaskGroups = ({
filterDropdownId,
targetableObjects,
showAddButton,
}: TaskGroupsProps) => {
const {
todayOrPreviousTasks,
upcomingTasks,
unscheduledTasks,
completedTasks,
incompleteTasksLoading,
completeTasksLoading,
} = useTasks({
filterDropdownId: filterDropdownId,
const { tasks, tasksLoading } = useTasks({
targetableObjects: targetableObjects ?? [],
});
const openCreateActivity = useOpenCreateActivityDrawer();
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
const { activeTabIdState } = useTabList(TASKS_TAB_LIST_COMPONENT_ID);
const activeTabId = useRecoilValue(activeTabIdState);
const isLoading =
(activeTabId !== 'done' && incompleteTasksLoading) ||
(activeTabId === 'done' && completeTasksLoading);
(activeTabId !== 'done' && tasksLoading) ||
(activeTabId === 'done' && tasksLoading);
const isTasksEmpty =
(activeTabId !== 'done' &&
todayOrPreviousTasks?.length === 0 &&
upcomingTasks?.length === 0 &&
unscheduledTasks?.length === 0) ||
(activeTabId === 'done' && completedTasks?.length === 0);
(activeTabId !== 'done' && tasks?.length === 0) ||
(activeTabId === 'done' && tasks?.length === 0);
if (isLoading && isTasksEmpty) {
return <SkeletonLoader />;
@ -90,7 +83,6 @@ export const TaskGroups = ({
variant={'secondary'}
onClick={() =>
openCreateActivity({
type: 'Task',
targetableObjects: targetableObjects ?? [],
})
}
@ -101,48 +93,19 @@ export const TaskGroups = ({
return (
<StyledContainer>
{activeTabId === 'done' ? (
<TaskList
tasks={completedTasks ?? []}
button={
showAddButton && (
<AddTaskButton activityTargetableObjects={targetableObjects} />
)
}
/>
) : (
<>
{Object.entries(groupBy(tasks, ({ status }) => status)).map(
([status, tasksByStatus]: [string, Task[]]) => (
<TaskList
title="Today"
tasks={todayOrPreviousTasks ?? []}
key={status}
title={status}
tasks={tasksByStatus}
button={
showAddButton && (
<AddTaskButton activityTargetableObjects={targetableObjects} />
)
}
/>
<TaskList
title="Upcoming"
tasks={upcomingTasks ?? []}
button={
showAddButton &&
!todayOrPreviousTasks?.length && (
<AddTaskButton activityTargetableObjects={targetableObjects} />
)
}
/>
<TaskList
title="Unscheduled"
tasks={unscheduledTasks ?? []}
button={
showAddButton &&
!todayOrPreviousTasks?.length &&
!upcomingTasks?.length && (
<AddTaskButton activityTargetableObjects={targetableObjects} />
)
}
/>
</>
),
)}
</StyledContainer>
);

View File

@ -1,13 +1,12 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { ReactElement } from 'react';
import { Activity } from '@/activities/types/Activity';
import { Task } from '@/activities/types/Task';
import { TaskRow } from './TaskRow';
type TaskListProps = {
title?: string;
tasks: Activity[];
tasks: Task[];
button?: ReactElement | false;
};

View File

@ -1,18 +1,16 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
IconCalendar,
IconComment,
OverflowingTextWithTooltip,
} from 'twenty-ui';
import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Activity } from '@/activities/types/Activity';
import { getActivitySummary } from '@/activities/utils/getActivitySummary';
import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox';
import { beautifyExactDate, hasDatePassed } from '~/utils/date-utils';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { useCompleteTask } from '../hooks/useCompleteTask';
const StyledContainer = styled.div`
@ -52,13 +50,6 @@ const StyledTaskTitle = styled.div<{
text-overflow: ellipsis;
`;
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;
}>`
@ -89,13 +80,22 @@ const StyledCheckboxContainer = styled.div`
display: flex;
`;
export const TaskRow = ({ task }: { task: Activity }) => {
export const TaskRow = ({ task }: { task: Task }) => {
const theme = useTheme();
const openActivityRightDrawer = useOpenActivityRightDrawer();
const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular: CoreObjectNameSingular.Task,
});
const body = getActivitySummary(task.body);
const { completeTask } = useCompleteTask(task);
const { FieldContextProvider: TaskTargetsContextProvider } = useFieldContext({
objectNameSingular: CoreObjectNameSingular.Task,
objectRecordId: task.id,
fieldMetadataName: 'taskTargets',
fieldPosition: 0,
});
return (
<StyledContainer
onClick={() => {
@ -109,33 +109,33 @@ export const TaskRow = ({ task }: { task: Activity }) => {
}}
>
<Checkbox
checked={!!task.completedAt}
checked={task.status === 'DONE'}
shape={CheckboxShape.Rounded}
onCheckedChange={completeTask}
/>
</StyledCheckboxContainer>
<StyledTaskTitle completed={task.completedAt !== null}>
<StyledTaskTitle completed={task.status === 'DONE'}>
{task.title || <StyledPlaceholder>Task title</StyledPlaceholder>}
</StyledTaskTitle>
<StyledTaskBody>
<OverflowingTextWithTooltip text={body} />
{task.comments && task.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />
</StyledCommentIcon>
)}
</StyledTaskBody>
</StyledLeftSideContainer>
<StyledRightSideContainer>
<ActivityTargetsInlineCell
activity={task}
showLabel={false}
maxWidth={200}
readonly
/>
{TaskTargetsContextProvider && (
<TaskTargetsContextProvider>
<ActivityTargetsInlineCell
activityObjectNameSingular={CoreObjectNameSingular.Task}
activity={task}
showLabel={false}
maxWidth={200}
readonly
/>
</TaskTargetsContextProvider>
)}
<StyledDueDate
isPast={
!!task.dueAt && hasDatePassed(task.dueAt) && !task.completedAt
!!task.dueAt && hasDatePassed(task.dueAt) && task.status === 'TODO'
}
>
<IconCalendar size={theme.icon.size.md} />

View File

@ -1,16 +1,25 @@
import { ReactNode } from 'react';
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import gql from 'graphql-tag';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useCompleteTask } from '@/activities/tasks/hooks/useCompleteTask';
import { Task } from '@/activities/types/Task';
const task = { id: '123', completedAt: '2024-03-15T07:33:14.212Z' };
const mockedDate = task.completedAt;
const toISOStringMock = jest.fn(() => mockedDate);
global.Date.prototype.toISOString = toISOStringMock;
const task: Task = {
id: '123',
status: null,
title: 'Test',
body: 'Test',
dueAt: '2024-03-15T07:33:14.212Z',
createdAt: '2024-03-15T07:33:14.212Z',
updatedAt: '2024-03-15T07:33:14.212Z',
assignee: null,
assigneeId: null,
taskTargets: [],
__typename: 'Task',
};
const mocks: MockedResponse[] = [
{
@ -26,7 +35,7 @@ const mocks: MockedResponse[] = [
reminderAt
authorId
title
completedAt
status
updatedAt
body
dueAt
@ -38,7 +47,7 @@ const mocks: MockedResponse[] = [
`,
variables: {
idToUpdate: task.id,
input: { completedAt: task.completedAt },
input: { status: task.status },
},
},
result: jest.fn(() => ({
@ -49,11 +58,11 @@ const mocks: MockedResponse[] = [
reminderAt: null,
authorId: '123',
title: 'Test',
completedAt: '2024-03-15T07:33:14.212Z',
status: 'DONE',
updatedAt: '2024-03-15T07:33:14.212Z',
body: 'Test',
dueAt: '2024-03-15T07:33:14.212Z',
type: 'Task',
type: 'TASK',
id: '123',
assigneeId: '123',
},

View File

@ -1,31 +0,0 @@
import { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useCurrentUserTaskCount } from '@/activities/tasks/hooks/useCurrentUserDueTaskCount';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { mockedActivities } from '~/testing/mock-data/activities';
const useFindManyRecordsMock = jest.fn(() => ({
records: [...mockedActivities, { id: '2' }],
}));
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: jest.fn(),
}));
(useFindManyRecords as jest.Mock).mockImplementation(useFindManyRecordsMock);
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
describe('useCurrentUserTaskCount', () => {
it('should return the current user task count', async () => {
const { result } = renderHook(() => useCurrentUserTaskCount(), {
wrapper: Wrapper,
});
expect(result.current.currentUserDueTaskCount).toBe(1);
});
});

View File

@ -1,33 +1,27 @@
import { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useActivities } from '@/activities/hooks/useActivities';
import { useTasks } from '@/activities/tasks/hooks/useTasks';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
const completedTasks = [
const tasks = [
{
id: '1',
completedAt: '2024-03-15T07:33:14.212Z',
status: 'DONE',
},
{
id: '2',
completedAt: '2024-03-15T07:33:14.212Z',
status: 'DONE',
},
{
id: '3',
completedAt: '2024-03-15T07:33:14.212Z',
status: 'DONE',
},
];
const unscheduledTasks = [
{
id: '4',
},
];
const todayOrPreviousTasks = [
{
id: '5',
dueAt: '2024-03-15T07:33:14.212Z',
@ -38,20 +32,11 @@ const todayOrPreviousTasks = [
},
];
const useActivitiesMock = jest.fn(
({
activitiesFilters,
}: {
activitiesFilters: { completedAt: { is: 'NULL' | 'NOT_NULL' } };
}) => {
const isCompletedFilter = activitiesFilters.completedAt.is === 'NOT_NULL';
return {
activities: isCompletedFilter
? completedTasks
: [...todayOrPreviousTasks, ...unscheduledTasks],
};
},
);
const useActivitiesMock = jest.fn(() => {
return {
activities: tasks,
};
});
jest.mock('@/activities/hooks/useActivities', () => ({
useActivities: jest.fn(),
@ -74,10 +59,7 @@ describe('useTasks', () => {
});
expect(result.current).toEqual({
todayOrPreviousTasks,
upcomingTasks: [],
unscheduledTasks,
completedTasks,
tasks: tasks,
});
});
});

View File

@ -1,23 +1,21 @@
import { useCallback } from 'react';
import { Activity } from '@/activities/types/Activity';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
type Task = Pick<Activity, 'id' | 'completedAt'>;
export const useCompleteTask = (task: Task) => {
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Activity>({
objectNameSingular: CoreObjectNameSingular.Activity,
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Task>({
objectNameSingular: CoreObjectNameSingular.Task,
});
const completeTask = useCallback(
async (value: boolean) => {
const completedAt = value ? new Date().toISOString() : null;
const status = value ? 'DONE' : 'TODO';
await updateOneActivity?.({
idToUpdate: task.id,
updateOneRecordInput: {
completedAt,
status,
},
});
},

View File

@ -1,33 +0,0 @@
import { DateTime } from 'luxon';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
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: CoreObjectNameSingular.Activity,
filter: {
type: { eq: 'Task' },
completedAt: { is: 'NULL' },
assigneeId: { eq: currentWorkspaceMember?.id },
},
});
const currentUserDueTaskCount = tasks.filter((task) => {
if (!task.dueAt) {
return false;
}
const dueDate = parseDate(task.dueAt).toJSDate();
const today = DateTime.now().endOf('day').toJSDate();
return dueDate <= today;
}).length;
return {
currentUserDueTaskCount,
};
};

View File

@ -1,156 +1,23 @@
import { useEffect, useMemo } from 'react';
import { DateTime } from 'luxon';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useActivities } from '@/activities/hooks/useActivities';
import { currentCompletedTaskQueryVariablesState } from '@/activities/tasks/states/currentCompletedTaskQueryVariablesState';
import { currentIncompleteTaskQueryVariablesState } from '@/activities/tasks/states/currentIncompleteTaskQueryVariablesState';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy';
import { Activity } from '@/activities/types/Activity';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timelineActivities/constants/FindManyTimelineActivitiesOrderBy';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { parseDate } from '~/utils/date-utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
type UseTasksProps = {
filterDropdownId?: string;
targetableObjects: ActivityTargetableObject[];
};
export const useTasks = ({
targetableObjects,
filterDropdownId,
}: UseTasksProps) => {
const { selectedFilterState } = useFilterDropdown({
filterDropdownId,
export const useTasks = ({ targetableObjects }: UseTasksProps) => {
const { activities: tasks, loading: tasksLoading } = useActivities<Task>({
objectNameSingular: CoreObjectNameSingular.Task,
targetableObjects,
activitiesFilters: {},
activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,
});
const selectedFilter = useRecoilValue(selectedFilterState);
const assigneeIdFilter = useMemo(
() =>
selectedFilter
? {
assigneeId: {
in: JSON.parse(selectedFilter.value),
},
}
: undefined,
[selectedFilter],
);
const completedQueryVariables = useMemo(
() =>
({
filter: {
completedAt: { is: 'NOT_NULL' },
type: { eq: 'Task' },
...assigneeIdFilter,
},
orderBy: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,
}) as RecordGqlOperationVariables,
[assigneeIdFilter],
);
const incompleteQueryVariables = useMemo(
() =>
({
filter: {
completedAt: { is: 'NULL' },
type: { eq: 'Task' },
...assigneeIdFilter,
},
orderBy: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,
}) as RecordGqlOperationVariables,
[assigneeIdFilter],
);
const [
currentCompletedTaskQueryVariables,
setCurrentCompletedTaskQueryVariables,
] = useRecoilState(currentCompletedTaskQueryVariablesState);
const [
currentIncompleteTaskQueryVariables,
setCurrentIncompleteTaskQueryVariables,
] = useRecoilState(currentIncompleteTaskQueryVariablesState);
// TODO: fix useEffect, remove with better pattern
useEffect(() => {
if (
!isDeeplyEqual(
completedQueryVariables,
currentCompletedTaskQueryVariables,
)
) {
setCurrentCompletedTaskQueryVariables(completedQueryVariables);
}
}, [
completedQueryVariables,
currentCompletedTaskQueryVariables,
setCurrentCompletedTaskQueryVariables,
]);
useEffect(() => {
if (
!isDeeplyEqual(
incompleteQueryVariables,
currentIncompleteTaskQueryVariables,
)
) {
setCurrentIncompleteTaskQueryVariables(incompleteQueryVariables);
}
}, [
incompleteQueryVariables,
currentIncompleteTaskQueryVariables,
setCurrentIncompleteTaskQueryVariables,
]);
const { activities: completeTasksData, loading: completeTasksLoading } =
useActivities({
targetableObjects,
activitiesFilters: completedQueryVariables.filter ?? {},
activitiesOrderByVariables: completedQueryVariables.orderBy ?? [{}],
});
const { activities: incompleteTaskData, loading: incompleteTasksLoading } =
useActivities({
targetableObjects,
activitiesFilters: incompleteQueryVariables.filter ?? {},
activitiesOrderByVariables: incompleteQueryVariables.orderBy ?? [{}],
});
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[],
completeTasksLoading,
incompleteTasksLoading,
tasks: (tasks ?? []) as Task[],
tasksLoading,
};
};

View File

@ -1,218 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { AppTooltip, Avatar, IconCheckbox, IconNotes } from 'twenty-ui';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { timelineActivityWithoutTargetsFamilyState } from '@/activities/timeline/states/timelineActivityWithoutTargetsFamilyState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
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 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 = {
isLastActivity?: boolean;
activityId: string;
};
export const TimelineActivity = ({
isLastActivity,
activityId,
}: TimelineActivityProps) => {
const activityForTimeline = useRecoilValue(
timelineActivityWithoutTargetsFamilyState(activityId),
);
const beautifiedCreatedAt = activityForTimeline
? beautifyPastDateRelativeToNow(activityForTimeline.createdAt)
: '';
const exactCreatedAt = activityForTimeline
? beautifyExactDateTime(activityForTimeline.createdAt)
: '';
const openActivityRightDrawer = useOpenActivityRightDrawer();
const theme = useTheme();
const activityFromStore = useRecoilValue(
recordStoreFamilyState(activityForTimeline?.id ?? ''),
);
if (!activityForTimeline) {
return <></>;
}
return (
<>
<StyledTimelineItemContainer>
<StyledAvatarContainer>
<Avatar
avatarUrl={activityForTimeline.author?.avatarUrl}
placeholder={activityForTimeline.author?.name.firstName ?? ''}
size="sm"
type="rounded"
/>
</StyledAvatarContainer>
<StyledItemContainer>
<StyledItemTitleContainer>
<StyledItemAuthorText>
<span>
{activityForTimeline.author?.name.firstName}{' '}
{activityForTimeline.author?.name.lastName}
</span>
created a {activityForTimeline.type.toLowerCase()}
</StyledItemAuthorText>
<StyledItemTitle>
<StyledIconContainer>
{activityForTimeline.type === 'Note' && (
<IconNotes size={theme.icon.size.sm} />
)}
{activityForTimeline.type === 'Task' && (
<IconCheckbox size={theme.icon.size.sm} />
)}
</StyledIconContainer>
{(activityForTimeline.type === 'Note' ||
activityForTimeline.type === 'Task') && (
<StyledActivityTitle
onClick={() =>
openActivityRightDrawer(activityForTimeline.id)
}
>
<StyledActivityLink
title={activityFromStore?.title ?? '(No Title)'}
>
{activityFromStore?.title ?? '(No Title)'}
</StyledActivityLink>
</StyledActivityTitle>
)}
</StyledItemTitle>
</StyledItemTitleContainer>
<StyledItemTitleDate id={`id-${activityForTimeline.id}`}>
{beautifiedCreatedAt}
</StyledItemTitleDate>
<AppTooltip
anchorSelect={`#id-${activityForTimeline.id}`}
content={exactCreatedAt}
clickable
noArrow
/>
</StyledItemContainer>
</StyledTimelineItemContainer>
{!isLastActivity && (
<StyledTimelineItemContainer isGap>
<StyledVerticalLineContainer>
<StyledVerticalLine></StyledVerticalLine>
</StyledVerticalLineContainer>
</StyledTimelineItemContainer>
)}
</>
);
};

View File

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

View File

@ -1,55 +0,0 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { timelineActivitiesForGroupState } from '@/activities/timeline/states/timelineActivitiesForGroupState';
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 const TimelineItemsContainer = () => {
const timelineActivitiesForGroup = useRecoilValue(
timelineActivitiesForGroupState,
);
const groupedActivities = groupActivitiesByMonth(timelineActivitiesForGroup);
return (
<StyledScrollWrapper>
<StyledTimelineContainer>
{groupedActivities.map((group, index) => (
<TimelineActivityGroup
key={group.year.toString() + group.month}
group={group}
month={new Date(group.items[0].createdAt).toLocaleString(
'default',
{ month: 'long' },
)}
year={
index === 0 || group.year !== groupedActivities[index - 1].year
? group.year
: undefined
}
/>
))}
</StyledTimelineContainer>
</StyledScrollWrapper>
);
};

View File

@ -1,113 +0,0 @@
import { useEffect } from 'react';
import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil';
import { useActivities } from '@/activities/hooks/useActivities';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy';
import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState';
import { timelineActivitiesFamilyState } from '@/activities/timeline/states/timelineActivitiesFamilyState';
import { timelineActivitiesForGroupState } from '@/activities/timeline/states/timelineActivitiesForGroupState';
import { timelineActivityWithoutTargetsFamilyState } from '@/activities/timeline/states/timelineActivityWithoutTargetsFamilyState';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { sortObjectRecordByDateField } from '@/object-record/utils/sortObjectRecordByDateField';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
export const TimelineQueryEffect = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const setTimelineTargetableObject = useSetRecoilState(
objectShowPageTargetableObjectState,
);
useEffect(() => {
setTimelineTargetableObject(targetableObject);
}, [targetableObject, setTimelineTargetableObject]);
const { activities } = useActivities({
targetableObjects: [targetableObject],
activitiesFilters: {},
activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,
skip: !isDefined(targetableObject),
});
const [timelineActivitiesForGroup, setTimelineActivitiesForGroup] =
useRecoilState(timelineActivitiesForGroupState);
useEffect(() => {
if (!isDefined(targetableObject)) {
return;
}
const activitiesForGroup = [
...activities.map((activity) => ({
id: activity.id,
createdAt: activity.createdAt,
__typename: activity.__typename,
})),
].sort(sortObjectRecordByDateField('createdAt', 'DescNullsLast'));
const timelineActivitiesForGroupSorted = [
...timelineActivitiesForGroup,
].sort(sortObjectRecordByDateField('createdAt', 'DescNullsLast'));
if (!isDeeplyEqual(activitiesForGroup, timelineActivitiesForGroupSorted)) {
setTimelineActivitiesForGroup(activitiesForGroup);
}
}, [
activities,
targetableObject,
timelineActivitiesForGroup,
setTimelineActivitiesForGroup,
]);
const updateTimelineActivities = useRecoilCallback(
({ snapshot, set }) =>
(newActivities: Activity[]) => {
for (const newActivity of newActivities) {
const currentActivity = snapshot
.getLoadable(timelineActivitiesFamilyState(newActivity.id))
.getValue();
if (!isDeeplyEqual(newActivity, currentActivity)) {
set(timelineActivitiesFamilyState(newActivity.id), newActivity);
}
const currentActivityWithoutTarget = snapshot
.getLoadable(
timelineActivityWithoutTargetsFamilyState(newActivity.id),
)
.getValue();
const newActivityWithoutTarget = {
id: newActivity.id,
title: newActivity.title,
createdAt: newActivity.createdAt,
author: newActivity.author,
type: newActivity.type,
};
if (
!isDeeplyEqual(
newActivityWithoutTarget,
currentActivityWithoutTarget,
)
) {
set(
timelineActivityWithoutTargetsFamilyState(newActivity.id),
newActivityWithoutTarget,
);
}
}
},
[],
);
useEffect(() => {
updateTimelineActivities(activities);
}, [activities, updateTimelineActivities]);
return <></>;
};

View File

@ -1,78 +0,0 @@
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}
activityId={activity.id}
isLastActivity={index === group.items.length - 1}
/>
))}
</StyledActivityGroupContainer>
</StyledActivityGroup>
);
};

View File

@ -1,38 +0,0 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useTimelineActivities } from '@/activities/timeline/hooks/useTimelineActivities';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</MockedProvider>
</RecoilRoot>
);
// FIXME: The hook is re-rendering so many times that it's causing a maximum
// update depth exceeded error. We need to fix this before we can write a proper test.
describe('useTimelineActivities', () => {
it('works as expected', () => {
try {
renderHook(
() =>
useTimelineActivities({
targetableObject: {
id: '123',
targetObjectNameSingular: 'person',
},
}),
{ wrapper: Wrapper },
);
} catch (e) {
expect((e as Error).message).toMatch(/^Maximum update depth exceeded/);
}
});
});

View File

@ -1,77 +0,0 @@
import { useEffect } from 'react';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject';
import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState';
import { makeTimelineActivitiesQueryVariables } from '@/activities/timeline/utils/makeTimelineActivitiesQueryVariables';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { sortByAscString } from '~/utils/array/sortByAscString';
import { isDefined } from '~/utils/isDefined';
export const useTimelineActivities = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const [, setObjectShowPageTargetableObject] = useRecoilState(
objectShowPageTargetableObjectState,
);
useEffect(() => {
if (isDefined(targetableObject)) {
setObjectShowPageTargetableObject(targetableObject);
}
}, [targetableObject, setObjectShowPageTargetableObject]);
const { activityTargets, loadingActivityTargets } =
useActivityTargetsForTargetableObject({
targetableObject,
});
const activityIds = Array.from(
new Set(
activityTargets
? [
...activityTargets
.map((activityTarget) => activityTarget.activityId)
.filter(isNonEmptyString),
].sort(sortByAscString)
: [],
),
);
const timelineActivitiesQueryVariables = makeTimelineActivitiesQueryVariables(
{
activityIds,
},
);
const { records: activities, loading: loadingActivities } =
useFindManyRecords<Activity>({
skip: loadingActivityTargets || !isNonEmptyArray(activityTargets),
objectNameSingular: CoreObjectNameSingular.Activity,
filter: timelineActivitiesQueryVariables.filter,
orderBy: timelineActivitiesQueryVariables.orderBy,
onCompleted: useRecoilCallback(
({ set }) =>
(activities) => {
for (const activity of activities) {
set(recordStoreFamilyState(activity.id), activity);
}
},
[],
),
});
const loading = loadingActivities || loadingActivityTargets;
return {
activities,
loading,
};
};

View File

@ -1,10 +0,0 @@
import { Activity } from '@/activities/types/Activity';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export const timelineActivitiesFamilyState = createFamilyState<
Activity | null,
string
>({
key: 'timelineActivitiesFamilyState',
defaultValue: null,
});

View File

@ -1,10 +0,0 @@
import { createState } from 'twenty-ui';
import { ActivityForActivityGroup } from '@/activities/timeline/utils/groupActivitiesByMonth';
export const timelineActivitiesForGroupState = createState<
ActivityForActivityGroup[]
>({
key: 'timelineActivitiesForGroupState',
defaultValue: [],
});

View File

@ -1,10 +0,0 @@
import { Activity } from '@/activities/types/Activity';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export const timelineActivityWithoutTargetsFamilyState = createFamilyState<
Pick<Activity, 'id' | 'title' | 'createdAt' | 'author' | 'type'> | null,
string
>({
key: 'timelineActivityWithoutTargetsFamilyState',
defaultValue: null,
});

View File

@ -1,22 +0,0 @@
import { ActivityForDrawer } from '@/activities/types/ActivityForDrawer';
import { mockedActivities } from '~/testing/mock-data/activities';
import { groupActivitiesByMonth } from '../groupActivitiesByMonth';
describe('groupActivitiesByMonth', () => {
it('should group activities by month', () => {
const grouped = groupActivitiesByMonth(
mockedActivities as unknown as ActivityForDrawer[],
);
expect(grouped).toHaveLength(2);
expect(grouped[0].items).toHaveLength(1);
expect(grouped[1].items).toHaveLength(1);
expect(grouped[0].year).toBe(new Date().getFullYear());
expect(grouped[1].year).toBe(2023);
expect(grouped[0].month).toBe(new Date().getMonth());
expect(grouped[1].month).toBe(3);
});
});

View File

@ -1,40 +0,0 @@
import { Activity } from '@/activities/types/Activity';
import { isDefined } from '~/utils/isDefined';
export type ActivityForActivityGroup = Pick<
Activity,
'id' | 'createdAt' | '__typename'
>;
export type ActivityGroup = {
month: number;
year: number;
items: ActivityForActivityGroup[];
};
export const groupActivitiesByMonth = (
activities: ActivityForActivityGroup[],
) => {
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 (isDefined(matchingGroup)) {
matchingGroup.items.push(activity);
} else {
acitivityGroups.push({
year,
month,
items: [activity],
});
}
}
return acitivityGroups.sort((a, b) => b.year - a.year || b.month - a.month);
};

View File

@ -1,22 +0,0 @@
import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables';
import { sortByAscString } from '~/utils/array/sortByAscString';
// Todo: this should be replace by the operationSignatureFactory pattern
export const makeTimelineActivitiesQueryVariables = ({
activityIds,
}: {
activityIds: string[];
}): RecordGqlOperationVariables => {
return {
filter: {
id: {
in: [...activityIds].sort(sortByAscString),
},
},
orderBy: [
{
createdAt: 'DescNullsFirst',
},
],
};
};

View File

@ -1,9 +1,9 @@
import { useContext } from 'react';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useLinkedObject } from '@/activities/timeline/hooks/useLinkedObject';
import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext';
import { useLinkedObject } from '@/activities/timelineActivities/hooks/useLinkedObject';
import { EventIconDynamicComponent } from '@/activities/timelineActivities/rows/components/EventIconDynamicComponent';
import { EventRowDynamicComponent } from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';

View File

@ -2,8 +2,8 @@ import styled from '@emotion/styled';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup';
import { EventList } from '@/activities/timelineActivities/components/EventList';
import { TimelineCreateButtonGroup } from '@/activities/timelineActivities/components/TimelineCreateButtonGroup';
import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
@ -35,8 +35,10 @@ const StyledMainContainer = styled.div`
export const TimelineActivities = ({
targetableObject,
isInRightDrawer = false,
}: {
targetableObject: ActivityTargetableObject;
isInRightDrawer?: boolean;
}) => {
const { timelineActivities, loading, fetchMoreRecords } =
useTimelineActivities(targetableObject);
@ -63,7 +65,7 @@ export const TimelineActivities = ({
There are no activities associated with this record.{' '}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<TimelineCreateButtonGroup />
<TimelineCreateButtonGroup isInRightDrawer={isInRightDrawer} />
</AnimatedPlaceholderEmptyContainer>
);
}

View File

@ -1,6 +1,7 @@
import { useActivities } from '@/activities/hooks/useActivities';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timelineActivities/constants/FindManyTimelineActivitiesOrderBy';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { isDefined } from '~/utils/isDefined';
export const TimelineActivitiesQueryEffect = ({
@ -9,6 +10,8 @@ export const TimelineActivitiesQueryEffect = ({
targetableObject: ActivityTargetableObject;
}) => {
useActivities({
objectNameSingular:
targetableObject.targetObjectNameSingular as CoreObjectNameSingular,
targetableObjects: [targetableObject],
activitiesFilters: {},
activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,

View File

@ -6,8 +6,14 @@ import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageRightContainer';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
export const TimelineCreateButtonGroup = () => {
const { activeTabIdState } = useTabList(TAB_LIST_COMPONENT_ID);
export const TimelineCreateButtonGroup = ({
isInRightDrawer = false,
}: {
isInRightDrawer?: boolean;
}) => {
const { activeTabIdState } = useTabList(
`${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`,
);
const setActiveTabId = useSetRecoilState(activeTabIdState);
return (

View File

@ -1,7 +1,9 @@
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
// do we need to test this?
@ -12,6 +14,10 @@ export const useTimelineActivities = (
nameSingular: targetableObject.targetObjectNameSingular,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
});
const {
records: TimelineActivities,
loading,
@ -28,18 +34,7 @@ export const useTimelineActivities = (
createdAt: 'DescNullsFirst',
},
],
recordGqlFields: {
id: true,
createdAt: true,
linkedObjectMetadataId: true,
linkedRecordCachedName: true,
linkedRecordId: true,
name: true,
properties: true,
happensAt: true,
workspaceMember: true,
person: true,
},
recordGqlFields: generateDepthOneRecordGqlFields({ objectMetadataItem }),
fetchPolicy: 'cache-and-network',
});

View File

@ -7,6 +7,7 @@ import {
StyledEventRowItemAction,
StyledEventRowItemColumn,
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
type EventRowActivityProps = EventRowDynamicComponentProps;
@ -19,11 +20,10 @@ const StyledLinkedActivity = styled.span`
export const EventRowActivity = ({
event,
authorFullName,
}: EventRowActivityProps) => {
objectNameSingular,
}: EventRowActivityProps & { objectNameSingular: CoreObjectNameSingular }) => {
const [, eventAction] = event.name.split('.');
const openActivityRightDrawer = useOpenActivityRightDrawer();
if (!event.linkedRecordId) {
throw new Error('Could not find linked record id for event');
}
@ -32,6 +32,10 @@ export const EventRowActivity = ({
recordStoreFamilyState(event.linkedRecordId),
);
const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular,
});
return (
<>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>

View File

@ -5,6 +5,7 @@ import { EventRowCalendarEvent } from '@/activities/timelineActivities/rows/cale
import { EventRowMainObject } from '@/activities/timelineActivities/rows/main-object/components/EventRowMainObject';
import { EventRowMessage } from '@/activities/timelineActivities/rows/message/components/EventRowMessage';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export interface EventRowDynamicComponentProps {
@ -57,8 +58,7 @@ export const EventRowDynamicComponent = ({
authorFullName={authorFullName}
/>
);
case 'task':
case 'note':
case 'linked-task':
return (
<EventRowActivity
labelIdentifierValue={labelIdentifierValue}
@ -66,6 +66,18 @@ export const EventRowDynamicComponent = ({
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
objectNameSingular={CoreObjectNameSingular.Task}
/>
);
case 'linked-note':
return (
<EventRowActivity
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
objectNameSingular={CoreObjectNameSingular.Note}
/>
);
case mainObjectMetadataItem?.nameSingular:

View File

@ -13,4 +13,4 @@ export type TimelineActivity = {
linkedRecordId: string;
linkedObjectMetadataId: string;
__typename: 'TimelineActivity';
};
} & Record<string, any>;

View File

@ -1,24 +1,7 @@
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;
reminderAt: 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[];
body: string | null;
};

View File

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

View File

@ -1,27 +1,6 @@
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { WorkspaceMember } from '~/generated-metadata/graphql';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
export type ActivityForEditor = Pick<
Activity,
| 'id'
| 'title'
| 'body'
| 'type'
| 'completedAt'
| 'dueAt'
| 'updatedAt'
| '__typename'
> & {
comments?: Comment[];
} & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
} & {
activityTargets?: Array<
Pick<
ActivityTarget,
'id' | 'companyId' | 'personId' | 'createdAt' | 'updatedAt' | 'activity'
>
>;
};
export type ActivityForEditor = Partial<Task | Note> &
Partial<Pick<Task, 'status' | 'dueAt' | 'assignee' | 'taskTargets'>> &
Partial<Pick<Note, 'noteTargets'>>;

View File

@ -1,9 +1,10 @@
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ActivityTargetWithTargetRecord = {
targetObjectMetadataItem: ObjectMetadataItem;
activityTarget: ActivityTarget;
activityTarget: NoteTarget | TaskTarget;
targetObject: ObjectRecord;
};

View File

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

View File

@ -1,5 +1,7 @@
import { Activity } from '@/activities/types/Activity';
import { NoteTarget } from '@/activities/types/NoteTarget';
export type Note = Activity & {
type: 'Note';
noteTargets?: NoteTarget[];
__typename: 'Note';
};

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