POC timeline activity (#5697)
TODO: - remove WorkspaceIsNotAuditLogged decorators on activity/activityTarget to log task/note creations - handle attachments - fix css and remove unnecessary styled components or duplicates
This commit is contained in:
@ -44,9 +44,11 @@ export const EmailThreadHeader = ({
|
||||
<StyledContainer>
|
||||
<StyledHead>
|
||||
<StyledHeading>{subject}</StyledHeading>
|
||||
<StyledContent>
|
||||
Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)}
|
||||
</StyledContent>
|
||||
{lastMessageSentAt && (
|
||||
<StyledContent>
|
||||
Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)}
|
||||
</StyledContent>
|
||||
)}
|
||||
</StyledHead>
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
@ -43,11 +43,14 @@ export const RightDrawerEmailThread = () => {
|
||||
|
||||
useRegisterClickOutsideListenerCallback({
|
||||
callbackId:
|
||||
'EmailThreadClickOutsideCallBack-' + (thread.id ?? 'no-thread-id'),
|
||||
'EmailThreadClickOutsideCallBack-' + (thread?.id ?? 'no-thread-id'),
|
||||
callbackFunction: useRecoilCallback(
|
||||
({ set }) =>
|
||||
() => {
|
||||
set(emailThreadIdWhenEmailThreadWasClosedState, thread.id);
|
||||
set(
|
||||
emailThreadIdWhenEmailThreadWasClosedState,
|
||||
thread?.id ?? 'no-thread-id',
|
||||
);
|
||||
},
|
||||
[thread],
|
||||
),
|
||||
@ -71,14 +74,14 @@ export const RightDrawerEmailThread = () => {
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<EmailThreadHeader
|
||||
subject={thread.subject}
|
||||
lastMessageSentAt={thread.lastMessageReceivedAt}
|
||||
/>
|
||||
{loading ? (
|
||||
<EmailLoader loadingText="Loading thread" />
|
||||
) : (
|
||||
<>
|
||||
<EmailThreadHeader
|
||||
subject={thread.subject}
|
||||
lastMessageSentAt={lastMessage.receivedAt}
|
||||
/>
|
||||
{firstMessages.map((message) => (
|
||||
<EmailThreadMessage
|
||||
key={message.id}
|
||||
|
||||
@ -3,9 +3,15 @@ import { renderHook } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
|
||||
import { useRightDrawerEmailThread } from '../useRightDrawerEmailThread';
|
||||
|
||||
jest.mock('@/object-record/hooks/useFindOneRecord', () => ({
|
||||
__esModule: true,
|
||||
useFindOneRecord: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
|
||||
__esModule: true,
|
||||
useFindManyRecords: jest.fn(),
|
||||
@ -13,11 +19,21 @@ jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
|
||||
|
||||
describe('useRightDrawerEmailThread', () => {
|
||||
it('should return correct values', async () => {
|
||||
const mockThread = { id: '1' };
|
||||
|
||||
const mockMessages = [
|
||||
{ id: '1', text: 'Message 1' },
|
||||
{ id: '2', text: 'Message 2' },
|
||||
];
|
||||
|
||||
const mockFetchMoreRecords = jest.fn();
|
||||
|
||||
(useFindOneRecord as jest.Mock).mockReturnValue({
|
||||
record: mockThread,
|
||||
loading: false,
|
||||
fetchMoreRecords: mockFetchMoreRecords,
|
||||
});
|
||||
|
||||
(useFindManyRecords as jest.Mock).mockReturnValue({
|
||||
records: mockMessages,
|
||||
loading: false,
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import gql from 'graphql-tag';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
|
||||
import { EmailThread } from '@/activities/emails/types/EmailThread';
|
||||
import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
|
||||
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
|
||||
|
||||
export const useRightDrawerEmailThread = () => {
|
||||
const viewableRecordId = useRecoilValue(viewableRecordIdState);
|
||||
const { setRecords } = useSetRecordInStore();
|
||||
|
||||
const apolloClient = useApolloClient();
|
||||
const thread = apolloClient.readFragment({
|
||||
id: `TimelineThread:${viewableRecordId}`,
|
||||
fragment: gql`
|
||||
fragment timelineThread on TimelineThread {
|
||||
id
|
||||
subject
|
||||
lastMessageReceivedAt
|
||||
}
|
||||
`,
|
||||
const { record: thread } = useFindOneRecord<EmailThread>({
|
||||
objectNameSingular: CoreObjectNameSingular.MessageThread,
|
||||
objectRecordId: viewableRecordId ?? '',
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
},
|
||||
onCompleted: (record) => setRecords([record]),
|
||||
});
|
||||
|
||||
const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE =
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
|
||||
|
||||
export type EmailThread = {
|
||||
id: string;
|
||||
subject: string;
|
||||
messages: EmailThreadMessage[];
|
||||
__typename: 'EmailThread';
|
||||
};
|
||||
@ -4,6 +4,8 @@ export type EmailThreadMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
receivedAt: string;
|
||||
subject: string;
|
||||
messageThreadId: string;
|
||||
messageParticipants: EmailThreadMessageParticipant[];
|
||||
__typename: 'EmailThreadMessage';
|
||||
};
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { graphql, HttpResponse } from 'msw';
|
||||
import { ComponentDecorator } from 'twenty-ui';
|
||||
|
||||
import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities';
|
||||
import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities';
|
||||
|
||||
const meta: Meta<typeof TimelineActivities> = {
|
||||
title: 'Modules/TimelineActivities/TimelineActivities',
|
||||
component: TimelineActivities,
|
||||
decorators: [
|
||||
ComponentDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
(Story) => {
|
||||
return (
|
||||
<TimelineActivityContext.Provider
|
||||
value={{
|
||||
labelIdentifierValue: 'Mock',
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</TimelineActivityContext.Provider>
|
||||
);
|
||||
},
|
||||
],
|
||||
args: {
|
||||
targetableObject: {
|
||||
id: '1',
|
||||
targetObjectNameSingular: 'company',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
graphql.query('FindManyActivities', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
activities: {
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
graphql.query('FindManyTimelineActivities', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
timelineActivities: {
|
||||
edges: mockedTimelineActivities.map((activity) => ({
|
||||
node: activity,
|
||||
cursor: activity.id,
|
||||
})),
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TimelineActivities>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@ -25,7 +25,6 @@ const StyledTimelineContainer = styled.div`
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
justify-content: flex-start;
|
||||
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(8)});
|
||||
`;
|
||||
|
||||
|
||||
@ -1,77 +1,46 @@
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { useContext } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
IconCheckbox,
|
||||
IconCirclePlus,
|
||||
IconEditCircle,
|
||||
IconFocusCentered,
|
||||
IconNotes,
|
||||
useIcons,
|
||||
} from 'twenty-ui';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
|
||||
import { useLinkedObject } from '@/activities/timeline/hooks/useLinkedObject';
|
||||
import { EventUpdateProperty } from '@/activities/timelineActivities/components/EventUpdateProperty';
|
||||
import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext';
|
||||
import {
|
||||
EventIconDynamicComponent,
|
||||
EventRowDynamicComponent,
|
||||
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
|
||||
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
|
||||
import {
|
||||
CurrentWorkspaceMember,
|
||||
currentWorkspaceMemberState,
|
||||
} from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import {
|
||||
beautifyExactDateTime,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '~/utils/date-utils';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
user-select: none;
|
||||
height: 16px;
|
||||
margin: 5px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration-line: underline;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin: 5px;
|
||||
user-select: none;
|
||||
text-decoration-line: underline;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
const StyledActionName = styled.span`
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
align-self: normal;
|
||||
`;
|
||||
|
||||
const StyledItemContainer = styled.div`
|
||||
align-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
flex: 1;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
span {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledItemAuthorText = styled.span`
|
||||
display: flex;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledItemTitle = styled.span`
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledLinkedObject = styled.span`
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
`;
|
||||
|
||||
const StyledItemTitleDate = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
@ -98,25 +67,10 @@ const StyledVerticalLine = styled.div`
|
||||
width: 2px;
|
||||
`;
|
||||
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
|
||||
box-shadow: 0px 2px 4px 3px
|
||||
${({ theme }) => theme.background.transparent.light};
|
||||
|
||||
box-shadow: 2px 4px 16px 6px
|
||||
${({ theme }) => theme.background.transparent.light};
|
||||
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
|
||||
opacity: 1;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
height: ${({ isGap, theme }) =>
|
||||
isGap ? (useIsMobile() ? theme.spacing(6) : theme.spacing(3)) : 'auto'};
|
||||
@ -127,8 +81,9 @@ const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
|
||||
const StyledSummary = styled.summary`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')};
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
@ -138,135 +93,69 @@ type EventRowProps = {
|
||||
event: TimelineActivity;
|
||||
};
|
||||
|
||||
const getAuthorFullName = (
|
||||
event: TimelineActivity,
|
||||
currentWorkspaceMember: CurrentWorkspaceMember,
|
||||
) => {
|
||||
if (isDefined(event.workspaceMember)) {
|
||||
return currentWorkspaceMember.id === event.workspaceMember.id
|
||||
? 'You'
|
||||
: `${event.workspaceMember?.name.firstName} ${event.workspaceMember?.name.lastName}`;
|
||||
}
|
||||
return 'Twenty';
|
||||
};
|
||||
|
||||
export const EventRow = ({
|
||||
isLastEvent,
|
||||
event,
|
||||
mainObjectMetadataItem,
|
||||
}: EventRowProps) => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const { labelIdentifierValue } = useContext(TimelineActivityContext);
|
||||
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt);
|
||||
const exactCreatedAt = beautifyExactDateTime(event.createdAt);
|
||||
const linkedObjectMetadataItem = useLinkedObject(
|
||||
event.linkedObjectMetadataId,
|
||||
);
|
||||
|
||||
const properties = JSON.parse(event.properties);
|
||||
const diff: Record<string, { before: any; after: any }> = properties?.diff;
|
||||
|
||||
const isEventType = (type: 'created' | 'updated') => {
|
||||
if (event.name.includes('.')) {
|
||||
return event.name.split('.')[1] === type;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const linkedObjectMetadata = useLinkedObject(event.linkedObjectMetadataId);
|
||||
|
||||
const linkedObjectLabel = event.name.includes('note')
|
||||
? 'note'
|
||||
: event.name.includes('task')
|
||||
? 'task'
|
||||
: linkedObjectMetadata?.labelSingular;
|
||||
|
||||
const ActivityIcon = event.linkedObjectMetadataId
|
||||
? event.name.includes('note')
|
||||
? IconNotes
|
||||
: event.name.includes('task')
|
||||
? IconCheckbox
|
||||
: getIcon(linkedObjectMetadata?.icon)
|
||||
: isEventType('created')
|
||||
? IconCirclePlus
|
||||
: isEventType('updated')
|
||||
? IconEditCircle
|
||||
: IconFocusCentered;
|
||||
|
||||
const author =
|
||||
event.workspaceMember?.name.firstName +
|
||||
' ' +
|
||||
event.workspaceMember?.name.lastName;
|
||||
|
||||
const action = isEventType('created')
|
||||
? 'created'
|
||||
: isEventType('updated')
|
||||
? 'updated'
|
||||
: event.name;
|
||||
|
||||
let description;
|
||||
|
||||
if (!isUndefinedOrNull(linkedObjectMetadata)) {
|
||||
description = 'a ' + linkedObjectLabel;
|
||||
} else if (!event.linkedObjectMetadataId && isEventType('created')) {
|
||||
description = `a new ${mainObjectMetadataItem?.labelSingular}`;
|
||||
} else if (isEventType('updated')) {
|
||||
const diffKeys = Object.keys(diff);
|
||||
if (diffKeys.length === 0) {
|
||||
description = `a ${mainObjectMetadataItem?.labelSingular}`;
|
||||
} else if (diffKeys.length === 1) {
|
||||
const [key, value] = Object.entries(diff)[0];
|
||||
description = [
|
||||
<EventUpdateProperty
|
||||
propertyName={key}
|
||||
after={value?.after as string}
|
||||
/>,
|
||||
];
|
||||
} else if (diffKeys.length === 2) {
|
||||
description =
|
||||
mainObjectMetadataItem?.fields.find(
|
||||
(field) => diffKeys[0] === field.name,
|
||||
)?.label +
|
||||
' and ' +
|
||||
mainObjectMetadataItem?.fields.find(
|
||||
(field) => diffKeys[1] === field.name,
|
||||
)?.label;
|
||||
} else if (diffKeys.length > 2) {
|
||||
description =
|
||||
diffKeys[0] + ' and ' + (diffKeys.length - 1) + ' other fields';
|
||||
}
|
||||
} else if (!isEventType('created') && !isEventType('updated')) {
|
||||
description = JSON.stringify(diff);
|
||||
if (isUndefinedOrNull(currentWorkspaceMember)) {
|
||||
return null;
|
||||
}
|
||||
const details = JSON.stringify(diff);
|
||||
|
||||
const openActivityRightDrawer = useOpenActivityRightDrawer();
|
||||
const authorFullName = getAuthorFullName(event, currentWorkspaceMember);
|
||||
|
||||
if (isUndefinedOrNull(mainObjectMetadataItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTimelineItemContainer>
|
||||
<StyledIconContainer>
|
||||
<ActivityIcon />
|
||||
<EventIconDynamicComponent
|
||||
event={event}
|
||||
linkedObjectMetadataItem={linkedObjectMetadataItem}
|
||||
/>
|
||||
</StyledIconContainer>
|
||||
<StyledItemContainer>
|
||||
<details>
|
||||
<StyledSummary>
|
||||
<StyledItemAuthorText>{author}</StyledItemAuthorText>
|
||||
<StyledActionName>{action}</StyledActionName>
|
||||
<StyledItemTitle>{description}</StyledItemTitle>
|
||||
{isUndefinedOrNull(linkedObjectMetadata) ? (
|
||||
<></>
|
||||
) : (
|
||||
<StyledLinkedObject
|
||||
onClick={() => openActivityRightDrawer(event.linkedRecordId)}
|
||||
>
|
||||
{event.linkedRecordCachedName}
|
||||
</StyledLinkedObject>
|
||||
)}
|
||||
</StyledSummary>
|
||||
{details}
|
||||
</details>
|
||||
|
||||
<StyledSummary>
|
||||
<EventRowDynamicComponent
|
||||
authorFullName={authorFullName}
|
||||
labelIdentifierValue={labelIdentifierValue}
|
||||
event={event}
|
||||
mainObjectMetadataItem={mainObjectMetadataItem}
|
||||
linkedObjectMetadataItem={linkedObjectMetadataItem}
|
||||
/>
|
||||
</StyledSummary>
|
||||
<StyledItemTitleDate id={`id-${event.id}`}>
|
||||
{beautifiedCreatedAt}
|
||||
</StyledItemTitleDate>
|
||||
<StyledTooltip
|
||||
anchorSelect={`#id-${event.id}`}
|
||||
content={exactCreatedAt}
|
||||
clickable
|
||||
noArrow
|
||||
/>
|
||||
</StyledItemContainer>
|
||||
</StyledTimelineItemContainer>
|
||||
{!isLastEvent && (
|
||||
<StyledTimelineItemContainer isGap>
|
||||
<StyledVerticalLineContainer>
|
||||
<StyledVerticalLine></StyledVerticalLine>
|
||||
<StyledVerticalLine />
|
||||
</StyledVerticalLineContainer>
|
||||
</StyledTimelineItemContainer>
|
||||
)}
|
||||
|
||||
@ -45,6 +45,8 @@ const StyledMonthSeperator = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
`;
|
||||
const StyledMonthSeperatorLine = styled.div`
|
||||
background: ${({ theme }) => theme.border.color.light};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
|
||||
import { FetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
|
||||
import { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup';
|
||||
import { EventList } from '@/activities/timelineActivities/components/EventList';
|
||||
import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities';
|
||||
@ -24,6 +25,11 @@ const StyledMainContainer = styled.div`
|
||||
height: 100%;
|
||||
|
||||
justify-content: center;
|
||||
padding-top: ${({ theme }) => theme.spacing(6)};
|
||||
padding-right: ${({ theme }) => theme.spacing(6)};
|
||||
padding-bottom: ${({ theme }) => theme.spacing(16)};
|
||||
padding-left: ${({ theme }) => theme.spacing(6)};
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const TimelineActivities = ({
|
||||
@ -31,7 +37,8 @@ export const TimelineActivities = ({
|
||||
}: {
|
||||
targetableObject: ActivityTargetableObject;
|
||||
}) => {
|
||||
const { timelineActivities } = useTimelineActivities(targetableObject);
|
||||
const { timelineActivities, loading, fetchMoreRecords } =
|
||||
useTimelineActivities(targetableObject);
|
||||
|
||||
if (!isNonEmptyArray(timelineActivities)) {
|
||||
return (
|
||||
@ -57,6 +64,7 @@ export const TimelineActivities = ({
|
||||
title="All"
|
||||
events={timelineActivities ?? []}
|
||||
/>
|
||||
<FetchMoreLoader loading={loading} onLastRowVisible={fetchMoreRecords} />
|
||||
</StyledMainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { useActivities } from '@/activities/hooks/useActivities';
|
||||
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy';
|
||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const TimelineActivitiesQueryEffect = ({
|
||||
targetableObject,
|
||||
}: {
|
||||
targetableObject: ActivityTargetableObject;
|
||||
}) => {
|
||||
useActivities({
|
||||
targetableObjects: [targetableObject],
|
||||
activitiesFilters: {},
|
||||
activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,
|
||||
skip: !isDefined(targetableObject),
|
||||
});
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type TimelineActivityContextValue = {
|
||||
labelIdentifierValue: string;
|
||||
};
|
||||
|
||||
export const TimelineActivityContext =
|
||||
createContext<TimelineActivityContextValue>({
|
||||
labelIdentifierValue: '',
|
||||
});
|
||||
@ -12,7 +12,11 @@ export const useTimelineActivities = (
|
||||
nameSingular: targetableObject.targetObjectNameSingular,
|
||||
});
|
||||
|
||||
const { records: TimelineActivities } = useFindManyRecords({
|
||||
const {
|
||||
records: TimelineActivities,
|
||||
loading,
|
||||
fetchMoreRecords,
|
||||
} = useFindManyRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
|
||||
filter: {
|
||||
[targetableObjectFieldIdName]: {
|
||||
@ -22,10 +26,23 @@ export const useTimelineActivities = (
|
||||
orderBy: {
|
||||
createdAt: 'DescNullsFirst',
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
linkedObjectMetadataId: true,
|
||||
linkedRecordCachedName: true,
|
||||
linkedRecordId: true,
|
||||
name: true,
|
||||
properties: true,
|
||||
happensAt: true,
|
||||
workspaceMember: true,
|
||||
person: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
timelineActivities: TimelineActivities as TimelineActivity[],
|
||||
loading,
|
||||
fetchMoreRecords,
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
|
||||
import {
|
||||
EventRowDynamicComponentProps,
|
||||
StyledItemAction,
|
||||
StyledItemAuthorText,
|
||||
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
|
||||
|
||||
type EventRowActivityProps = EventRowDynamicComponentProps;
|
||||
|
||||
const StyledLinkedActivity = styled.span`
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
`;
|
||||
|
||||
export const EventRowActivity: React.FC<EventRowActivityProps> = ({
|
||||
event,
|
||||
authorFullName,
|
||||
}: EventRowActivityProps) => {
|
||||
const [, eventAction] = event.name.split('.');
|
||||
|
||||
const openActivityRightDrawer = useOpenActivityRightDrawer();
|
||||
|
||||
if (!event.linkedRecordId) {
|
||||
throw new Error('Could not find linked record id for event');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
|
||||
<StyledItemAction>{eventAction}</StyledItemAction>
|
||||
<StyledLinkedActivity
|
||||
onClick={() => openActivityRightDrawer(event.linkedRecordId)}
|
||||
>
|
||||
{event.linkedRecordCachedName}
|
||||
</StyledLinkedActivity>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,169 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
|
||||
import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer';
|
||||
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
|
||||
import {
|
||||
formatToHumanReadableDay,
|
||||
formatToHumanReadableMonth,
|
||||
formatToHumanReadableTime,
|
||||
} from '~/utils';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
const StyledEventCardCalendarEventContainer = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledCalendarEventContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledCalendarEventTop = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledCalendarEventTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledCalendarEventBody = styled.div`
|
||||
align-items: flex-start;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledCalendarEventDateCard = styled.div`
|
||||
display: flex;
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
`;
|
||||
|
||||
const StyledCalendarEventDateCardMonth = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.danger};
|
||||
font-size: ${({ theme }) => theme.font.size.xxs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledCalendarEventDateCardDay = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
export const EventCardCalendarEvent = ({
|
||||
calendarEventId,
|
||||
}: {
|
||||
calendarEventId: string;
|
||||
}) => {
|
||||
const { setRecords } = useSetRecordInStore();
|
||||
|
||||
const {
|
||||
record: calendarEvent,
|
||||
loading,
|
||||
error,
|
||||
} = useFindOneRecord<CalendarEvent>({
|
||||
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
|
||||
objectRecordId: calendarEventId,
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
title: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setRecords([data]);
|
||||
},
|
||||
});
|
||||
|
||||
const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer();
|
||||
|
||||
if (isDefined(error)) {
|
||||
const shouldHideMessageContent = error.graphQLErrors.some(
|
||||
(e) => e.extensions?.code === 'FORBIDDEN',
|
||||
);
|
||||
|
||||
if (shouldHideMessageContent) {
|
||||
return <div>Calendar event not shared</div>;
|
||||
}
|
||||
|
||||
const shouldHandleNotFound = error.graphQLErrors.some(
|
||||
(e) => e.extensions?.code === 'NOT_FOUND',
|
||||
);
|
||||
|
||||
if (shouldHandleNotFound) {
|
||||
return <div>Calendar event not found</div>;
|
||||
}
|
||||
|
||||
return <div>Error loading message</div>;
|
||||
}
|
||||
|
||||
if (loading || isUndefined(calendarEvent)) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const startsAtDate = calendarEvent?.startsAt;
|
||||
const endsAtDate = calendarEvent?.endsAt;
|
||||
|
||||
if (isUndefinedOrNull(startsAtDate)) {
|
||||
throw new Error("Can't render a calendarEvent without a start date");
|
||||
}
|
||||
|
||||
const startsAtMonth = formatToHumanReadableMonth(startsAtDate);
|
||||
|
||||
const startsAtDay = formatToHumanReadableDay(startsAtDate);
|
||||
|
||||
const startsAtHour = formatToHumanReadableTime(startsAtDate);
|
||||
const endsAtHour = endsAtDate ? formatToHumanReadableTime(endsAtDate) : null;
|
||||
|
||||
return (
|
||||
<StyledEventCardCalendarEventContainer
|
||||
onClick={() => openCalendarEventRightDrawer(calendarEvent.id)}
|
||||
>
|
||||
<StyledCalendarEventDateCard>
|
||||
<StyledCalendarEventDateCardMonth>
|
||||
{startsAtMonth}
|
||||
</StyledCalendarEventDateCardMonth>
|
||||
<StyledCalendarEventDateCardDay>
|
||||
{startsAtDay}
|
||||
</StyledCalendarEventDateCardDay>
|
||||
</StyledCalendarEventDateCard>
|
||||
<StyledCalendarEventContent>
|
||||
<StyledCalendarEventTop>
|
||||
<StyledCalendarEventTitle>
|
||||
{calendarEvent.title}
|
||||
</StyledCalendarEventTitle>
|
||||
</StyledCalendarEventTop>
|
||||
<StyledCalendarEventBody>
|
||||
{startsAtHour} {endsAtHour && <>→ {endsAtHour}</>}
|
||||
</StyledCalendarEventBody>
|
||||
</StyledCalendarEventContent>
|
||||
</StyledEventCardCalendarEventContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { EventCardCalendarEvent } from '@/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent';
|
||||
import {
|
||||
EventCard,
|
||||
EventCardToggleButton,
|
||||
} from '@/activities/timelineActivities/rows/components/EventCard';
|
||||
import {
|
||||
EventRowDynamicComponentProps,
|
||||
StyledItemAction,
|
||||
StyledItemAuthorText,
|
||||
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
|
||||
|
||||
type EventRowCalendarEventProps = EventRowDynamicComponentProps;
|
||||
|
||||
const StyledEventRowCalendarEventContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledRowContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const EventRowCalendarEvent: React.FC<EventRowCalendarEventProps> = ({
|
||||
event,
|
||||
authorFullName,
|
||||
labelIdentifierValue,
|
||||
}: EventRowCalendarEventProps) => {
|
||||
const [, eventAction] = event.name.split('.');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const renderRow = () => {
|
||||
switch (eventAction) {
|
||||
case 'linked': {
|
||||
return (
|
||||
<StyledItemAction>
|
||||
linked a calendar event with {labelIdentifierValue}
|
||||
</StyledItemAction>
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error('Invalid event action for calendarEvent event type.');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<StyledEventRowCalendarEventContainer>
|
||||
<StyledRowContainer>
|
||||
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
|
||||
{renderRow()}
|
||||
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
</StyledRowContainer>
|
||||
<EventCard isOpen={isOpen}>
|
||||
<EventCardCalendarEvent calendarEventId={event.linkedRecordId} />
|
||||
</EventCard>
|
||||
</StyledEventRowCalendarEventContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IconChevronDown, IconChevronUp } from 'twenty-ui';
|
||||
|
||||
import { IconButton } from '@/ui/input/button/components/IconButton';
|
||||
import { Card } from '@/ui/layout/card/components/Card';
|
||||
|
||||
type EventCardProps = {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
type EventCardToggleButtonProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
`;
|
||||
|
||||
const StyledCardContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
width: 400px;
|
||||
padding: ${({ theme }) => theme.spacing(2)} 0px
|
||||
${({ theme }) => theme.spacing(4)} 0px;
|
||||
`;
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
|
||||
display: flex;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
`;
|
||||
|
||||
export const EventCard = ({ children, isOpen }: EventCardProps) => {
|
||||
return (
|
||||
isOpen && (
|
||||
<StyledCardContainer>
|
||||
<StyledCard fullWidth>{children}</StyledCard>
|
||||
</StyledCardContainer>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const EventCardToggleButton = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: EventCardToggleButtonProps) => {
|
||||
return (
|
||||
<StyledButtonContainer>
|
||||
<IconButton
|
||||
Icon={isOpen ? IconChevronUp : IconChevronDown}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,102 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IconCirclePlus, IconEditCircle, useIcons } from 'twenty-ui';
|
||||
|
||||
import { EventRowActivity } from '@/activities/timelineActivities/rows/activity/components/EventRowActivity';
|
||||
import { EventRowCalendarEvent } from '@/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent';
|
||||
import { EventRowMainObject } from '@/activities/timelineActivities/rows/mainObject/components/EventRowMainObject';
|
||||
import { EventRowMessage } from '@/activities/timelineActivities/rows/message/components/EventRowMessage';
|
||||
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export interface EventRowDynamicComponentProps {
|
||||
labelIdentifierValue: string;
|
||||
event: TimelineActivity;
|
||||
mainObjectMetadataItem: ObjectMetadataItem;
|
||||
linkedObjectMetadataItem: ObjectMetadataItem | null;
|
||||
authorFullName: string;
|
||||
}
|
||||
|
||||
const StyledItemColumn = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const StyledItemAuthorText = styled(StyledItemColumn)``;
|
||||
|
||||
export const StyledItemLabelIdentifier = styled(StyledItemColumn)``;
|
||||
|
||||
export const StyledItemAction = styled(StyledItemColumn)`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
`;
|
||||
|
||||
const eventRowComponentMap: {
|
||||
[key: string]: React.FC<EventRowDynamicComponentProps>;
|
||||
} = {
|
||||
calendarEvent: EventRowCalendarEvent,
|
||||
message: EventRowMessage,
|
||||
task: EventRowActivity,
|
||||
note: EventRowActivity,
|
||||
};
|
||||
|
||||
export const EventRowDynamicComponent = ({
|
||||
labelIdentifierValue,
|
||||
event,
|
||||
mainObjectMetadataItem,
|
||||
linkedObjectMetadataItem,
|
||||
authorFullName,
|
||||
}: EventRowDynamicComponentProps) => {
|
||||
const [eventName] = event.name.split('.');
|
||||
const EventRowComponent = eventRowComponentMap[eventName];
|
||||
|
||||
if (isDefined(EventRowComponent)) {
|
||||
return (
|
||||
<EventRowComponent
|
||||
labelIdentifierValue={labelIdentifierValue}
|
||||
event={event}
|
||||
mainObjectMetadataItem={mainObjectMetadataItem}
|
||||
linkedObjectMetadataItem={linkedObjectMetadataItem}
|
||||
authorFullName={authorFullName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventName === mainObjectMetadataItem?.nameSingular) {
|
||||
return (
|
||||
<EventRowMainObject
|
||||
labelIdentifierValue={labelIdentifierValue}
|
||||
event={event}
|
||||
mainObjectMetadataItem={mainObjectMetadataItem}
|
||||
linkedObjectMetadataItem={linkedObjectMetadataItem}
|
||||
authorFullName={authorFullName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Cannot find event component for event name ${eventName}`);
|
||||
};
|
||||
|
||||
export const EventIconDynamicComponent = ({
|
||||
event,
|
||||
linkedObjectMetadataItem,
|
||||
}: {
|
||||
event: TimelineActivity;
|
||||
linkedObjectMetadataItem: ObjectMetadataItem | null;
|
||||
}) => {
|
||||
const { getIcon } = useIcons();
|
||||
const [, eventAction] = event.name.split('.');
|
||||
|
||||
if (eventAction === 'created') {
|
||||
return <IconCirclePlus />;
|
||||
}
|
||||
if (eventAction === 'updated') {
|
||||
return <IconEditCircle />;
|
||||
}
|
||||
|
||||
const IconComponent = getIcon(linkedObjectMetadataItem?.icon);
|
||||
|
||||
return <IconComponent />;
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { EventFieldDiffLabel } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiffLabel';
|
||||
import { EventFieldDiffValue } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValue';
|
||||
import { EventFieldDiffValueEffect } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValueEffect';
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
|
||||
type EventFieldDiffProps = {
|
||||
diffRecord: Record<string, any>;
|
||||
mainObjectMetadataItem: ObjectMetadataItem;
|
||||
fieldMetadataItem: FieldMetadataItem | undefined;
|
||||
forgedRecordId: string;
|
||||
};
|
||||
|
||||
const StyledEventFieldDiffContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: 24px;
|
||||
width: 250px;
|
||||
`;
|
||||
|
||||
export const EventFieldDiff = ({
|
||||
diffRecord,
|
||||
mainObjectMetadataItem,
|
||||
fieldMetadataItem,
|
||||
forgedRecordId,
|
||||
}: EventFieldDiffProps) => {
|
||||
if (!fieldMetadataItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledEventFieldDiffContainer>
|
||||
<EventFieldDiffLabel fieldMetadataItem={fieldMetadataItem} />→
|
||||
<EventFieldDiffValueEffect
|
||||
forgedRecordId={forgedRecordId}
|
||||
mainObjectMetadataItem={mainObjectMetadataItem}
|
||||
fieldMetadataItem={fieldMetadataItem}
|
||||
diffRecord={diffRecord}
|
||||
/>
|
||||
<EventFieldDiffValue
|
||||
forgedRecordId={forgedRecordId}
|
||||
mainObjectMetadataItem={mainObjectMetadataItem}
|
||||
fieldMetadataItem={fieldMetadataItem}
|
||||
/>
|
||||
</StyledEventFieldDiffContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Icon123, useIcons } from 'twenty-ui';
|
||||
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
|
||||
type EventFieldDiffLabelProps = {
|
||||
fieldMetadataItem: FieldMetadataItem;
|
||||
};
|
||||
|
||||
const StyledUpdatedFieldContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledUpdatedFieldIconContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
`;
|
||||
|
||||
const StyledUpdatedFieldLabel = styled.div``;
|
||||
|
||||
export const EventFieldDiffLabel = ({
|
||||
fieldMetadataItem,
|
||||
}: EventFieldDiffLabelProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const IconComponent = fieldMetadataItem?.icon
|
||||
? getIcon(fieldMetadataItem?.icon)
|
||||
: Icon123;
|
||||
|
||||
return (
|
||||
<StyledUpdatedFieldContainer>
|
||||
<StyledUpdatedFieldIconContainer>
|
||||
<IconComponent />
|
||||
</StyledUpdatedFieldIconContainer>
|
||||
<StyledUpdatedFieldLabel>
|
||||
{fieldMetadataItem.label}
|
||||
</StyledUpdatedFieldLabel>
|
||||
</StyledUpdatedFieldContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,53 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||
import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay';
|
||||
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
|
||||
|
||||
type EventFieldDiffValueProps = {
|
||||
forgedRecordId: string;
|
||||
mainObjectMetadataItem: ObjectMetadataItem;
|
||||
fieldMetadataItem: FieldMetadataItem;
|
||||
};
|
||||
|
||||
const StyledEventFieldDiffValue = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const EventFieldDiffValue = ({
|
||||
forgedRecordId,
|
||||
mainObjectMetadataItem,
|
||||
fieldMetadataItem,
|
||||
}: EventFieldDiffValueProps) => {
|
||||
return (
|
||||
<StyledEventFieldDiffValue>
|
||||
<FieldContext.Provider
|
||||
value={{
|
||||
entityId: forgedRecordId,
|
||||
isLabelIdentifier: isLabelIdentifierField({
|
||||
fieldMetadataItem,
|
||||
objectMetadataItem: mainObjectMetadataItem,
|
||||
}),
|
||||
fieldDefinition: {
|
||||
type: fieldMetadataItem.type,
|
||||
iconName: fieldMetadataItem?.icon || 'FieldIcon',
|
||||
fieldMetadataId: fieldMetadataItem.id || '',
|
||||
label: fieldMetadataItem.label,
|
||||
metadata: {
|
||||
fieldName: fieldMetadataItem.name,
|
||||
objectMetadataNameSingular: mainObjectMetadataItem.nameSingular,
|
||||
options: fieldMetadataItem.options ?? [],
|
||||
},
|
||||
defaultValue: fieldMetadataItem.defaultValue,
|
||||
},
|
||||
hotkeyScope: 'field-event-diff',
|
||||
}}
|
||||
>
|
||||
<FieldDisplay />
|
||||
</FieldContext.Provider>
|
||||
</StyledEventFieldDiffValue>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
|
||||
export const EventFieldDiffValueEffect = ({
|
||||
forgedRecordId,
|
||||
diffRecord,
|
||||
mainObjectMetadataItem,
|
||||
fieldMetadataItem,
|
||||
}: {
|
||||
forgedRecordId: string;
|
||||
diffRecord: Record<string, any> | undefined;
|
||||
mainObjectMetadataItem: ObjectMetadataItem;
|
||||
fieldMetadataItem: FieldMetadataItem;
|
||||
}) => {
|
||||
const setEntityFields = useSetRecoilState(
|
||||
recordStoreFamilyState(forgedRecordId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!diffRecord) return;
|
||||
|
||||
const forgedObjectRecord = {
|
||||
__typename: mainObjectMetadataItem.nameSingular,
|
||||
id: forgedRecordId,
|
||||
[fieldMetadataItem.name]: diffRecord,
|
||||
};
|
||||
|
||||
setEntityFields(forgedObjectRecord);
|
||||
}, [
|
||||
diffRecord,
|
||||
forgedRecordId,
|
||||
fieldMetadataItem.name,
|
||||
mainObjectMetadataItem.nameSingular,
|
||||
setEntityFields,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import {
|
||||
EventRowDynamicComponentProps,
|
||||
StyledItemAction,
|
||||
StyledItemAuthorText,
|
||||
StyledItemLabelIdentifier,
|
||||
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
|
||||
import { EventRowMainObjectUpdated } from '@/activities/timelineActivities/rows/mainObject/components/EventRowMainObjectUpdated';
|
||||
|
||||
type EventRowMainObjectProps = EventRowDynamicComponentProps;
|
||||
|
||||
const StyledMainContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const EventRowMainObject = ({
|
||||
authorFullName,
|
||||
labelIdentifierValue,
|
||||
event,
|
||||
mainObjectMetadataItem,
|
||||
}: EventRowMainObjectProps) => {
|
||||
const [, eventAction] = event.name.split('.');
|
||||
|
||||
switch (eventAction) {
|
||||
case 'created': {
|
||||
return (
|
||||
<StyledMainContainer>
|
||||
<StyledItemLabelIdentifier>
|
||||
{labelIdentifierValue}
|
||||
</StyledItemLabelIdentifier>
|
||||
<StyledItemAction>was created by</StyledItemAction>
|
||||
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
|
||||
</StyledMainContainer>
|
||||
);
|
||||
}
|
||||
case 'updated': {
|
||||
return (
|
||||
<EventRowMainObjectUpdated
|
||||
authorFullName={authorFullName}
|
||||
labelIdentifierValue={labelIdentifierValue}
|
||||
event={event}
|
||||
mainObjectMetadataItem={mainObjectMetadataItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import {
|
||||
EventCard,
|
||||
EventCardToggleButton,
|
||||
} from '@/activities/timelineActivities/rows/components/EventCard';
|
||||
import {
|
||||
StyledItemAction,
|
||||
StyledItemAuthorText,
|
||||
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
|
||||
import { EventFieldDiff } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiff';
|
||||
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
|
||||
type EventRowMainObjectUpdatedProps = {
|
||||
mainObjectMetadataItem: ObjectMetadataItem;
|
||||
authorFullName: string;
|
||||
labelIdentifierValue: string;
|
||||
event: TimelineActivity;
|
||||
};
|
||||
|
||||
const StyledRowContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledEventRowMainObjectUpdatedContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const renderUpdateDescription = (
|
||||
mainObjectMetadataItem: ObjectMetadataItem,
|
||||
diffKey: string,
|
||||
diffValue: any,
|
||||
eventId: string,
|
||||
fieldMetadataItemMap: Record<string, FieldMetadataItem>,
|
||||
) => {
|
||||
const fieldMetadataItem = fieldMetadataItemMap[diffKey];
|
||||
|
||||
if (!fieldMetadataItem) {
|
||||
throw new Error(
|
||||
`Cannot find field metadata item for field name ${diffKey} on object ${mainObjectMetadataItem.nameSingular}`,
|
||||
);
|
||||
}
|
||||
|
||||
const forgedRecordId = eventId + '--' + fieldMetadataItem.id;
|
||||
|
||||
return (
|
||||
<EventFieldDiff
|
||||
key={forgedRecordId}
|
||||
diffRecord={diffValue}
|
||||
fieldMetadataItem={fieldMetadataItem}
|
||||
mainObjectMetadataItem={mainObjectMetadataItem}
|
||||
forgedRecordId={forgedRecordId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventRowMainObjectUpdated = ({
|
||||
authorFullName,
|
||||
labelIdentifierValue,
|
||||
event,
|
||||
mainObjectMetadataItem,
|
||||
}: EventRowMainObjectUpdatedProps) => {
|
||||
const diff: Record<string, { before: any; after: any }> =
|
||||
event.properties?.diff;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const fieldMetadataItemMap: Record<string, FieldMetadataItem> =
|
||||
mainObjectMetadataItem.fields.reduce(
|
||||
(acc, field) => ({ ...acc, [field.name]: field }),
|
||||
{},
|
||||
);
|
||||
|
||||
const diffEntries = Object.entries(diff);
|
||||
if (diffEntries.length === 0) {
|
||||
throw new Error('Cannot render update description without changes');
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledEventRowMainObjectUpdatedContainer>
|
||||
<StyledRowContainer>
|
||||
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
|
||||
<StyledItemAction>
|
||||
updated
|
||||
{diffEntries.length === 1 &&
|
||||
renderUpdateDescription(
|
||||
mainObjectMetadataItem,
|
||||
diffEntries[0][0],
|
||||
diffEntries[0][1].after,
|
||||
event.id,
|
||||
fieldMetadataItemMap,
|
||||
)}
|
||||
{diffEntries.length > 1 && (
|
||||
<>
|
||||
<span>
|
||||
{diffEntries.length} fields on {labelIdentifierValue}
|
||||
</span>
|
||||
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
</>
|
||||
)}
|
||||
</StyledItemAction>
|
||||
</StyledRowContainer>
|
||||
{diffEntries.length > 1 && (
|
||||
<EventCard isOpen={isOpen}>
|
||||
{diffEntries.map(([diffKey, diffValue]) =>
|
||||
renderUpdateDescription(
|
||||
mainObjectMetadataItem,
|
||||
diffKey,
|
||||
diffValue.after,
|
||||
event.id,
|
||||
fieldMetadataItemMap,
|
||||
),
|
||||
)}
|
||||
</EventCard>
|
||||
)}
|
||||
</StyledEventRowMainObjectUpdatedContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,127 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { isUndefined } from '@sniptt/guards';
|
||||
import { OverflowingTextWithTooltip } from 'twenty-ui';
|
||||
|
||||
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
|
||||
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
|
||||
import { EventCardMessageNotShared } from '@/activities/timelineActivities/rows/message/components/EventCardMessageNotShared';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledEventCardMessageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledEmailContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledEmailTop = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledEmailTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledEmailParticipants = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledEmailBody = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const EventCardMessage = ({
|
||||
messageId,
|
||||
authorFullName,
|
||||
}: {
|
||||
messageId: string;
|
||||
authorFullName: string;
|
||||
}) => {
|
||||
const { setRecords } = useSetRecordInStore();
|
||||
|
||||
const {
|
||||
record: message,
|
||||
loading,
|
||||
error,
|
||||
} = useFindOneRecord<EmailThreadMessage>({
|
||||
objectNameSingular: CoreObjectNameSingular.Message,
|
||||
objectRecordId: messageId,
|
||||
recordGqlFields: {
|
||||
text: true,
|
||||
subject: true,
|
||||
direction: true,
|
||||
messageThreadId: true,
|
||||
messageParticipants: {
|
||||
handle: true,
|
||||
},
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setRecords([data]);
|
||||
},
|
||||
});
|
||||
|
||||
const { openEmailThread } = useEmailThread();
|
||||
|
||||
if (isDefined(error)) {
|
||||
const shouldHideMessageContent = error.graphQLErrors.some(
|
||||
(e) => e.extensions?.code === 'FORBIDDEN',
|
||||
);
|
||||
|
||||
if (shouldHideMessageContent) {
|
||||
return <EventCardMessageNotShared sharedByFullName={authorFullName} />;
|
||||
}
|
||||
|
||||
const shouldHandleNotFound = error.graphQLErrors.some(
|
||||
(e) => e.extensions?.code === 'NOT_FOUND',
|
||||
);
|
||||
|
||||
if (shouldHandleNotFound) {
|
||||
return <div>Message not found</div>;
|
||||
}
|
||||
|
||||
return <div>Error loading message</div>;
|
||||
}
|
||||
|
||||
if (loading || isUndefined(message)) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const messageParticipantHandles = message?.messageParticipants
|
||||
.map((participant) => participant.handle)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<StyledEventCardMessageContainer>
|
||||
<StyledEmailContent>
|
||||
<StyledEmailTop>
|
||||
<StyledEmailTitle>
|
||||
<div>{message.subject}</div>
|
||||
</StyledEmailTitle>
|
||||
<StyledEmailParticipants>
|
||||
<OverflowingTextWithTooltip text={messageParticipantHandles} />
|
||||
</StyledEmailParticipants>
|
||||
</StyledEmailTop>
|
||||
<StyledEmailBody
|
||||
onClick={() => openEmailThread(message.messageThreadId)}
|
||||
>
|
||||
{message.text}
|
||||
</StyledEmailBody>
|
||||
</StyledEmailContent>
|
||||
</StyledEventCardMessageContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IconLock } from 'twenty-ui';
|
||||
|
||||
const StyledEventCardMessageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledEmailContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledEmailTop = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledEmailTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledEmailBodyNotShareContainer = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
|
||||
height: 80px;
|
||||
justify-content: center;
|
||||
padding: 0 ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledEmailBodyNotSharedIconContainer = styled.div`
|
||||
display: flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledEmailBodyNotShare = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const EventCardMessageNotShared = ({
|
||||
sharedByFullName,
|
||||
}: {
|
||||
sharedByFullName: string;
|
||||
}) => {
|
||||
return (
|
||||
<StyledEventCardMessageContainer>
|
||||
<StyledEmailContent>
|
||||
<StyledEmailTop>
|
||||
<StyledEmailTitle>
|
||||
<span>Subject not shared</span>
|
||||
</StyledEmailTitle>
|
||||
</StyledEmailTop>
|
||||
<StyledEmailBodyNotShareContainer>
|
||||
<StyledEmailBodyNotShare>
|
||||
<StyledEmailBodyNotSharedIconContainer>
|
||||
<IconLock />
|
||||
</StyledEmailBodyNotSharedIconContainer>
|
||||
<span>Not shared by {sharedByFullName}</span>
|
||||
</StyledEmailBodyNotShare>
|
||||
</StyledEmailBodyNotShareContainer>
|
||||
</StyledEmailContent>
|
||||
</StyledEventCardMessageContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import {
|
||||
EventCard,
|
||||
EventCardToggleButton,
|
||||
} from '@/activities/timelineActivities/rows/components/EventCard';
|
||||
import {
|
||||
EventRowDynamicComponentProps,
|
||||
StyledItemAction,
|
||||
StyledItemAuthorText,
|
||||
StyledItemLabelIdentifier,
|
||||
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
|
||||
import { EventCardMessage } from '@/activities/timelineActivities/rows/message/components/EventCardMessage';
|
||||
|
||||
type EventRowMessageProps = EventRowDynamicComponentProps;
|
||||
|
||||
const StyledEventRowMessageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledRowContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const EventRowMessage: React.FC<EventRowMessageProps> = ({
|
||||
labelIdentifierValue,
|
||||
event,
|
||||
authorFullName,
|
||||
}: EventRowMessageProps) => {
|
||||
const [, eventAction] = event.name.split('.');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const renderRow = () => {
|
||||
switch (eventAction) {
|
||||
case 'linked': {
|
||||
return (
|
||||
<>
|
||||
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
|
||||
<StyledItemAction>linked an email with</StyledItemAction>
|
||||
<StyledItemLabelIdentifier>
|
||||
{labelIdentifierValue}
|
||||
</StyledItemLabelIdentifier>
|
||||
</>
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error('Invalid event action for message event type.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledEventRowMessageContainer>
|
||||
<StyledRowContainer>
|
||||
{renderRow()}
|
||||
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
</StyledRowContainer>
|
||||
<EventCard isOpen={isOpen}>
|
||||
<EventCardMessage
|
||||
messageId={event.linkedRecordId}
|
||||
authorFullName={authorFullName}
|
||||
/>
|
||||
</EventCard>
|
||||
</StyledEventRowMessageContainer>
|
||||
);
|
||||
};
|
||||
@ -7,13 +7,13 @@ describe('groupEventsByMonth', () => {
|
||||
const grouped = groupEventsByMonth(mockedTimelineActivities);
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
expect(grouped[0].items).toHaveLength(1);
|
||||
expect(grouped[0].items).toHaveLength(4);
|
||||
expect(grouped[1].items).toHaveLength(1);
|
||||
|
||||
expect(grouped[0].year).toBe(new Date().getFullYear());
|
||||
expect(grouped[1].year).toBe(2023);
|
||||
expect(grouped[0].year).toBe(2023);
|
||||
expect(grouped[1].year).toBe(2022);
|
||||
|
||||
expect(grouped[0].month).toBe(new Date().getMonth());
|
||||
expect(grouped[1].month).toBe(3);
|
||||
expect(grouped[0].month).toBe(3);
|
||||
expect(grouped[1].month).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,10 +2,13 @@ import { createState } from 'twenty-ui';
|
||||
|
||||
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||
|
||||
export const currentWorkspaceMemberState = createState<Omit<
|
||||
export type CurrentWorkspaceMember = Omit<
|
||||
WorkspaceMember,
|
||||
'createdAt' | 'updatedAt' | 'userId' | 'userEmail' | '__typename'
|
||||
> | null>({
|
||||
key: 'currentWorkspaceMemberState',
|
||||
defaultValue: null,
|
||||
});
|
||||
>;
|
||||
|
||||
export const currentWorkspaceMemberState =
|
||||
createState<CurrentWorkspaceMember | null>({
|
||||
key: 'currentWorkspaceMemberState',
|
||||
defaultValue: null,
|
||||
});
|
||||
|
||||
@ -3618,6 +3618,42 @@ export const getObjectMetadataItemsMock = () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
__typename: 'object',
|
||||
id: '20202020-049d-4d0c-9e7c-e74fee3f88b2',
|
||||
dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1',
|
||||
nameSingular: 'messageThread',
|
||||
namePlural: 'messageThreads',
|
||||
labelSingular: 'Message Thread',
|
||||
labelPlural: 'Message Threads',
|
||||
description: 'A webhook',
|
||||
icon: 'IconMessage',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
createdAt: '2023-11-30T11:13:15.206Z',
|
||||
updatedAt: '2023-11-30T11:13:15.206Z',
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
__typename: 'object',
|
||||
id: '20202020-049d-4d0c-9e7c-e74fee3f88b2',
|
||||
dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1',
|
||||
nameSingular: 'timelineActivity',
|
||||
namePlural: 'timelineActivities',
|
||||
labelSingular: 'Timeline Activitiy',
|
||||
labelPlural: 'Timeline Activities',
|
||||
description: 'A webhook',
|
||||
icon: 'IconIconTimelineEvent',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
createdAt: '2023-11-30T11:13:15.206Z',
|
||||
updatedAt: '2023-11-30T11:13:15.206Z',
|
||||
fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Todo fix typing here (the backend is not in sync with the frontend)
|
||||
|
||||
@ -18,6 +18,7 @@ import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks';
|
||||
import { Timeline } from '@/activities/timeline/components/Timeline';
|
||||
import { TimelineQueryEffect } from '@/activities/timeline/components/TimelineQueryEffect';
|
||||
import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities';
|
||||
import { TimelineActivitiesQueryEffect } from '@/activities/timelineActivities/components/TimelineActivitiesQueryEffect';
|
||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { TabList } from '@/ui/layout/tab/components/TabList';
|
||||
@ -74,22 +75,21 @@ export const ShowPageRightContainer = ({
|
||||
);
|
||||
const activeTabId = useRecoilValue(activeTabIdState);
|
||||
|
||||
const shouldDisplayCalendarTab =
|
||||
targetableObject.targetObjectNameSingular ===
|
||||
CoreObjectNameSingular.Company ||
|
||||
targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person;
|
||||
const targetObjectNameSingular =
|
||||
targetableObject.targetObjectNameSingular as CoreObjectNameSingular;
|
||||
|
||||
const isCompanyOrPerson = [
|
||||
CoreObjectNameSingular.Company,
|
||||
CoreObjectNameSingular.Person,
|
||||
].includes(targetObjectNameSingular);
|
||||
|
||||
const shouldDisplayCalendarTab = isCompanyOrPerson;
|
||||
const shouldDisplayLogTab = useIsFeatureEnabled('IS_EVENT_OBJECT_ENABLED');
|
||||
|
||||
const shouldDisplayEmailsTab =
|
||||
(emails &&
|
||||
targetableObject.targetObjectNameSingular ===
|
||||
CoreObjectNameSingular.Company) ||
|
||||
targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person;
|
||||
const shouldDisplayEmailsTab = emails && isCompanyOrPerson;
|
||||
|
||||
const isMobile = useIsMobile() || isRightDrawer;
|
||||
|
||||
const TASK_TABS = [
|
||||
const tabs = [
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'Summary',
|
||||
@ -102,24 +102,9 @@ export const ShowPageRightContainer = ({
|
||||
Icon: IconTimelineEvent,
|
||||
hide: !timeline || isRightDrawer,
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
Icon: IconCheckbox,
|
||||
hide: !tasks,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
title: 'Notes',
|
||||
Icon: IconNotes,
|
||||
hide: !notes,
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
title: 'Files',
|
||||
Icon: IconPaperclip,
|
||||
hide: !notes,
|
||||
},
|
||||
{ id: 'tasks', title: 'Tasks', Icon: IconCheckbox, hide: !tasks },
|
||||
{ id: 'notes', title: 'Notes', Icon: IconNotes, hide: !notes },
|
||||
{ id: 'files', title: 'Files', Icon: IconPaperclip, hide: !notes },
|
||||
{
|
||||
id: 'emails',
|
||||
title: 'Emails',
|
||||
@ -132,48 +117,51 @@ export const ShowPageRightContainer = ({
|
||||
Icon: IconCalendarEvent,
|
||||
hide: !shouldDisplayCalendarTab,
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
title: 'Logs',
|
||||
Icon: IconTimelineEvent,
|
||||
hide: !shouldDisplayLogTab,
|
||||
hasBetaPill: true,
|
||||
},
|
||||
];
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
switch (activeTabId) {
|
||||
case 'timeline':
|
||||
return shouldDisplayLogTab ? (
|
||||
<>
|
||||
<TimelineActivitiesQueryEffect
|
||||
targetableObject={targetableObject}
|
||||
/>
|
||||
<TimelineActivities targetableObject={targetableObject} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TimelineQueryEffect targetableObject={targetableObject} />
|
||||
<Timeline loading={loading} targetableObject={targetableObject} />
|
||||
</>
|
||||
);
|
||||
case 'summary':
|
||||
return summary;
|
||||
case 'tasks':
|
||||
return <ObjectTasks targetableObject={targetableObject} />;
|
||||
case 'notes':
|
||||
return <Notes targetableObject={targetableObject} />;
|
||||
case 'files':
|
||||
return <Attachments targetableObject={targetableObject} />;
|
||||
case 'emails':
|
||||
return <EmailThreads targetableObject={targetableObject} />;
|
||||
case 'calendar':
|
||||
return <Calendar targetableObject={targetableObject} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledShowPageRightContainer isMobile={isMobile}>
|
||||
<StyledTabListContainer>
|
||||
<TabList
|
||||
loading={loading}
|
||||
tabListId={TAB_LIST_COMPONENT_ID + isRightDrawer}
|
||||
tabs={TASK_TABS}
|
||||
tabs={tabs}
|
||||
/>
|
||||
</StyledTabListContainer>
|
||||
{activeTabId === 'summary' && summary}
|
||||
{activeTabId === 'timeline' && (
|
||||
<>
|
||||
<TimelineQueryEffect targetableObject={targetableObject} />
|
||||
<Timeline loading={loading} targetableObject={targetableObject} />
|
||||
</>
|
||||
)}
|
||||
{activeTabId === 'tasks' && (
|
||||
<ObjectTasks targetableObject={targetableObject} />
|
||||
)}
|
||||
{activeTabId === 'notes' && <Notes targetableObject={targetableObject} />}
|
||||
{activeTabId === 'files' && (
|
||||
<Attachments targetableObject={targetableObject} />
|
||||
)}
|
||||
{activeTabId === 'emails' && (
|
||||
<EmailThreads targetableObject={targetableObject} />
|
||||
)}
|
||||
{activeTabId === 'calendar' && (
|
||||
<Calendar targetableObject={targetableObject} />
|
||||
)}
|
||||
{activeTabId === 'logs' && (
|
||||
<TimelineActivities targetableObject={targetableObject} />
|
||||
)}
|
||||
{}
|
||||
{renderActiveTabContent()}
|
||||
</StyledShowPageRightContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user