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:
Weiko
2024-06-11 18:53:28 +02:00
committed by GitHub
parent 64b8e4ec4d
commit be96c68416
60 changed files with 2134 additions and 443 deletions

View File

@ -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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 />;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 <></>;
};

View File

@ -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;
}
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};