Enforce front project structure through ESLINT (#7863)

Fixes: https://github.com/twentyhq/twenty/issues/7329
This commit is contained in:
Charles Bochet
2024-10-20 20:20:19 +02:00
committed by GitHub
parent f801f3aa9f
commit eccf0bf8ba
260 changed files with 500 additions and 290 deletions

View File

@ -0,0 +1,70 @@
import styled from '@emotion/styled';
import { ReactElement } from 'react';
import { EventsGroup } from '@/activities/timeline-activities/components/EventsGroup';
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { filterOutInvalidTimelineActivities } from '@/activities/timeline-activities/utils/filterOutInvalidTimelineActivities';
import { groupEventsByMonth } from '@/activities/timeline-activities/utils/groupEventsByMonth';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
type EventListProps = {
targetableObject: ActivityTargetableObject;
title: string;
events: TimelineActivity[];
button?: ReactElement | false;
};
const StyledTimelineContainer = styled.div`
align-items: center;
align-self: stretch;
display: flex;
flex: 1 0 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-start;
width: calc(100% - ${({ theme }) => theme.spacing(8)});
`;
export const EventList = ({ events, targetableObject }: EventListProps) => {
const mainObjectMetadataItem = useObjectMetadataItem({
objectNameSingular: targetableObject.targetObjectNameSingular,
}).objectMetadataItem;
const { objectMetadataItems } = useObjectMetadataItems();
const filteredEvents = filterOutInvalidTimelineActivities(
events,
targetableObject.targetObjectNameSingular,
objectMetadataItems,
);
const groupedEvents = groupEventsByMonth(filteredEvents);
return (
<ScrollWrapper contextProviderName="eventList">
<StyledTimelineContainer>
{groupedEvents.map((group, index) => (
<EventsGroup
mainObjectMetadataItem={mainObjectMetadataItem}
key={group.year.toString() + group.month}
group={group}
month={new Date(group.items[0].createdAt).toLocaleString(
'default',
{ month: 'long' },
)}
year={
index === 0 || group.year !== groupedEvents[index - 1].year
? group.year
: undefined
}
/>
))}
</StyledTimelineContainer>
</ScrollWrapper>
);
};

View File

@ -0,0 +1,154 @@
import styled from '@emotion/styled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext';
import { useLinkedObjectObjectMetadataItem } from '@/activities/timeline-activities/hooks/useLinkedObjectObjectMetadataItem';
import { EventIconDynamicComponent } from '@/activities/timeline-activities/rows/components/EventIconDynamicComponent';
import { EventRowDynamicComponent } from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent';
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { getTimelineActivityAuthorFullName } from '@/activities/timeline-activities/utils/getTimelineActivityAuthorFullName';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const StyledTimelineItemContainer = styled.div`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
height: 'auto';
justify-content: space-between;
overflow: hidden;
white-space: nowrap;
`;
const StyledLeftContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledIconContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
color: ${({ theme }) => theme.font.color.tertiary};
height: 16px;
width: 16px;
margin: 5px;
user-select: none;
text-decoration-line: underline;
z-index: 2;
`;
const StyledVerticalLineContainer = styled.div`
display: flex;
flex-shrink: 0;
justify-content: center;
z-index: 2;
height: 100%;
`;
const StyledVerticalLine = styled.div`
background: ${({ theme }) => theme.border.color.light};
width: 2px;
height: 100%;
`;
const StyledSummary = styled.summary`
align-items: center;
display: flex;
flex: 1;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledItemContainer = styled.div<{ isMarginBottom?: boolean }>`
align-items: flex-start;
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
margin-bottom: ${({ isMarginBottom, theme }) =>
isMarginBottom ? theme.spacing(3) : 0};
min-height: 26px;
`;
const StyledItemTitleDate = styled.div`
align-items: flex-start;
padding-top: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-end;
margin-left: auto;
`;
type EventRowProps = {
mainObjectMetadataItem: ObjectMetadataItem | null;
isLastEvent?: boolean;
event: TimelineActivity;
};
export const EventRow = ({
isLastEvent,
event,
mainObjectMetadataItem,
}: EventRowProps) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { labelIdentifierValue } = useContext(TimelineActivityContext);
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt);
const linkedObjectMetadataItem = useLinkedObjectObjectMetadataItem(
event.linkedObjectMetadataId,
);
if (isUndefinedOrNull(currentWorkspaceMember)) {
return null;
}
const authorFullName = getTimelineActivityAuthorFullName(
event,
currentWorkspaceMember,
);
if (isUndefinedOrNull(mainObjectMetadataItem)) {
throw new Error('mainObjectMetadataItem is required');
}
return (
<>
<StyledTimelineItemContainer>
<StyledLeftContainer>
<StyledIconContainer>
<EventIconDynamicComponent
event={event}
linkedObjectMetadataItem={linkedObjectMetadataItem}
/>
</StyledIconContainer>
{!isLastEvent && (
<StyledVerticalLineContainer>
<StyledVerticalLine />
</StyledVerticalLineContainer>
)}
</StyledLeftContainer>
<StyledItemContainer isMarginBottom={!isLastEvent}>
<StyledSummary>
<EventRowDynamicComponent
authorFullName={authorFullName}
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
/>
</StyledSummary>
</StyledItemContainer>
<StyledItemTitleDate id={`id-${event.id}`}>
{beautifiedCreatedAt}
</StyledItemTitleDate>
</StyledTimelineItemContainer>
</>
);
};

View File

@ -0,0 +1,83 @@
import styled from '@emotion/styled';
import { EventRow } from '@/activities/timeline-activities/components/EventRow';
import { EventGroup } from '@/activities/timeline-activities/utils/groupEventsByMonth';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type EventsGroupProps = {
group: EventGroup;
month: string;
year?: number;
mainObjectMetadataItem: ObjectMetadataItem | null;
};
const StyledActivityGroup = styled.div`
display: flex;
flex-flow: column;
gap: ${({ theme }) => theme.spacing(4)};
margin-bottom: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const StyledActivityGroupContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(3)};
margin-top: ${({ theme }) => theme.spacing(3)};
position: relative;
`;
const StyledActivityGroupBar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
position: absolute;
top: 0;
width: 24px;
`;
const StyledMonthSeperator = styled.div`
align-items: center;
align-self: stretch;
color: ${({ theme }) => theme.font.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xs};
`;
const StyledMonthSeperatorLine = styled.div`
background: ${({ theme }) => theme.border.color.light};
border-radius: 50px;
flex: 1 0 0;
height: 1px;
`;
export const EventsGroup = ({
group,
month,
year,
mainObjectMetadataItem,
}: EventsGroupProps) => {
return (
<StyledActivityGroup>
<StyledMonthSeperator>
{month} {year}
<StyledMonthSeperatorLine />
</StyledMonthSeperator>
<StyledActivityGroupContainer>
<StyledActivityGroupBar />
{group.items.map((event, index) => (
<EventRow
mainObjectMetadataItem={mainObjectMetadataItem}
key={index}
event={event}
isLastEvent={index === group.items.length - 1}
/>
))}
</StyledActivityGroupContainer>
</StyledActivityGroup>
);
};

View File

@ -0,0 +1,86 @@
import styled from '@emotion/styled';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { EventList } from '@/activities/timeline-activities/components/EventList';
import { TimelineCreateButtonGroup } from '@/activities/timeline-activities/components/TimelineCreateButtonGroup';
import { useTimelineActivities } from '@/activities/timeline-activities/hooks/useTimelineActivities';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import {
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
const StyledMainContainer = styled.div`
align-items: flex-start;
align-self: stretch;
border-top: ${({ theme }) =>
useIsMobile() ? `1px solid ${theme.border.color.medium}` : 'none'};
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
justify-content: center;
padding-top: ${({ theme }) => theme.spacing(6)};
padding-right: ${({ theme }) => theme.spacing(6)};
padding-left: ${({ theme }) => theme.spacing(6)};
gap: ${({ theme }) => theme.spacing(4)};
`;
export const TimelineActivities = ({
targetableObject,
isInRightDrawer = false,
}: {
targetableObject: ActivityTargetableObject;
isInRightDrawer?: boolean;
}) => {
const { timelineActivities, loading, fetchMoreRecords } =
useTimelineActivities(targetableObject);
const isTimelineActivitiesEmpty =
!timelineActivities || timelineActivities.length === 0;
if (loading === true) {
return <SkeletonLoader withSubSections />;
}
if (isTimelineActivitiesEmpty) {
return (
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyTimeline" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
Add your first Activity
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
There are no activities associated with this record.{' '}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<TimelineCreateButtonGroup isInRightDrawer={isInRightDrawer} />
</AnimatedPlaceholderEmptyContainer>
);
}
return (
<StyledMainContainer>
<EventList
targetableObject={targetableObject}
title="All"
events={timelineActivities ?? []}
/>
<CustomResolverFetchMoreLoader
loading={loading}
onLastRowVisible={fetchMoreRecords}
/>
</StyledMainContainer>
);
};

View File

@ -0,0 +1,42 @@
import { useSetRecoilState } from 'recoil';
import { IconCheckbox, IconNotes, IconPaperclip } from 'twenty-ui';
import { Button } from '@/ui/input/button/components/Button';
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageSubContainer';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
export const TimelineCreateButtonGroup = ({
isInRightDrawer = false,
}: {
isInRightDrawer?: boolean;
}) => {
const { activeTabIdState } = useTabList(
`${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`,
);
const setActiveTabId = useSetRecoilState(activeTabIdState);
return (
<ButtonGroup variant={'secondary'}>
<Button
Icon={IconNotes}
title="Note"
onClick={() => {
setActiveTabId('notes');
}}
/>
<Button
Icon={IconCheckbox}
title="Task"
onClick={() => {
setActiveTabId('tasks');
}}
/>
<Button
Icon={IconPaperclip}
title="File"
onClick={() => setActiveTabId('files')}
/>
</ButtonGroup>
);
};

View File

@ -0,0 +1,80 @@
import { Meta, StoryObj } from '@storybook/react';
import { HttpResponse, graphql } from 'msw';
import { ComponentDecorator } from 'twenty-ui';
import { TimelineActivities } from '@/activities/timeline-activities/components/TimelineActivities';
import { TimelineActivityContext } from '@/activities/timeline-activities/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 = {};

View File

@ -0,0 +1,8 @@
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
export const FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY: RecordGqlOperationOrderBy =
[
{
createdAt: 'DescNullsFirst',
},
];

View File

@ -0,0 +1,10 @@
import { createContext } from 'react';
type TimelineActivityContextValue = {
labelIdentifierValue: string;
};
export const TimelineActivityContext =
createContext<TimelineActivityContextValue>({
labelIdentifierValue: '',
});

View File

@ -0,0 +1,81 @@
import { renderHook } from '@testing-library/react';
import { useTimelineActivities } from '@/activities/timeline-activities/hooks/useTimelineActivities';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: jest.fn(),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useTimelineActivities', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('fetches events correctly for a given targetableObject', () => {
const mockedTimelineActivities = [
{
__typename: 'Event',
id: '166ec73f-26b1-4934-bb3b-c86c8513b99b',
workspaceMember: {
__typename: 'WorkspaceMember',
locale: 'en',
avatarUrl: '',
updatedAt: '2024-03-21T16:01:41.839Z',
name: {
__typename: 'FullName',
firstName: 'Tim',
lastName: 'Apple',
},
id: '20202020-0687-4c41-b707-ed1bfca972a7',
userEmail: 'tim@apple.dev',
colorScheme: 'Light',
createdAt: '2024-03-21T16:01:41.839Z',
userId: '20202020-9e3b-46d4-a556-88b9ddc2b034',
},
workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
createdAt: '2024-03-22T08:28:44.830Z',
name: 'updated.company',
companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4',
properties: '{"diff": {"address": {"after": "Paris", "before": ""}}}',
updatedAt: '2024-03-22T08:28:44.830Z',
},
];
const mockTargetableObject = {
id: '1',
targetObjectNameSingular: 'Opportunity',
};
const useFindManyRecordsMock = jest.requireMock(
'@/object-record/hooks/useFindManyRecords',
);
useFindManyRecordsMock.useFindManyRecords.mockReturnValue({
records: mockedTimelineActivities,
});
const { result } = renderHook(
() => {
return useTimelineActivities(mockTargetableObject);
},
{ wrapper: Wrapper },
);
const wrongMockedTimelineActivities = [
{
...mockedTimelineActivities[0],
name: 'wrong.updated.company',
},
];
expect(result.current.timelineActivities).toEqual(mockedTimelineActivities);
expect(result.current.timelineActivities).not.toEqual(
wrongMockedTimelineActivities,
);
});
});

View File

@ -0,0 +1,16 @@
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useLinkedObjectObjectMetadataItem = (id: string) => {
const objectMetadataItems: ObjectMetadataItem[] = useRecoilValue(
objectMetadataItemsState,
);
return (
objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === id,
) ?? null
);
};

View File

@ -0,0 +1,43 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecords';
import { isNonEmptyArray } from '@sniptt/guards';
export const useLinkedObjectsTitle = (linkedObjectIds: string[]) => {
const { loading } = useCombinedFindManyRecords({
skip: !isNonEmptyArray(linkedObjectIds),
operationSignatures: [
{
objectNameSingular: CoreObjectNameSingular.Task,
variables: {
filter: {
id: {
in: linkedObjectIds ?? [],
},
},
},
fields: {
id: true,
title: true,
},
},
{
objectNameSingular: CoreObjectNameSingular.Note,
variables: {
filter: {
id: {
in: linkedObjectIds ?? [],
},
},
},
fields: {
id: true,
title: true,
},
},
],
});
return {
loading,
};
};

View File

@ -0,0 +1,56 @@
import { useLinkedObjectsTitle } from '@/activities/timeline-activities/hooks/useLinkedObjectsTitle';
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
// do we need to test this?
export const useTimelineActivities = (
targetableObject: ActivityTargetableObject,
) => {
const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
});
const {
records: timelineActivities,
loading: loadingTimelineActivities,
fetchMoreRecords,
} = useFindManyRecords<TimelineActivity>({
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
filter: {
[targetableObjectFieldIdName]: {
eq: targetableObject.id,
},
},
orderBy: [
{
createdAt: 'DescNullsFirst',
},
],
recordGqlFields: generateDepthOneRecordGqlFields({ objectMetadataItem }),
fetchPolicy: 'cache-and-network',
});
const activityIds = timelineActivities
.filter((timelineActivity) => timelineActivity.name.match(/note|task/i))
.map((timelineActivity) => timelineActivity.linkedRecordId);
const { loading: loadingLinkedObjectsTitle } =
useLinkedObjectsTitle(activityIds);
const loading = loadingTimelineActivities || loadingLinkedObjectsTitle;
return {
timelineActivities,
loading,
fetchMoreRecords,
};
};

View File

@ -0,0 +1,71 @@
import styled from '@emotion/styled';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import {
EventRowDynamicComponentProps,
StyledEventRowItemAction,
StyledEventRowItemColumn,
} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isNonEmptyString } from '@sniptt/guards';
type EventRowActivityProps = EventRowDynamicComponentProps;
const StyledLinkedActivity = styled.span`
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
text-decoration: underline;
`;
export const StyledEventRowItemText = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
export const EventRowActivity = ({
event,
authorFullName,
objectNameSingular,
}: EventRowActivityProps & { objectNameSingular: CoreObjectNameSingular }) => {
const [eventLinkedObject, eventAction] = event.name.split('.');
const eventObject = eventLinkedObject.replace('linked-', '');
if (!event.linkedRecordId) {
throw new Error('Could not find linked record id for event');
}
const getActivityFromCache = useGetRecordFromCache({
objectNameSingular,
recordGqlFields: {
id: true,
title: true,
},
});
const activityInStore = getActivityFromCache(event.linkedRecordId);
const activityTitle = isNonEmptyString(activityInStore?.title)
? activityInStore?.title
: isNonEmptyString(event.linkedRecordCachedName)
? event.linkedRecordCachedName
: 'Untitled';
const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular,
});
return (
<>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
<StyledEventRowItemAction>
{`${eventAction} a related ${eventObject}`}
</StyledEventRowItemAction>
<StyledLinkedActivity
onClick={() => openActivityRightDrawer(event.linkedRecordId)}
>
{activityTitle}
</StyledLinkedActivity>
</>
);
};

View File

@ -0,0 +1,178 @@
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 { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import {
formatToHumanReadableDay,
formatToHumanReadableMonth,
formatToHumanReadableTime,
} from '~/utils/format/formatDate';
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;
overflow: hidden;
`;
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};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
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 { upsertRecords } = useUpsertRecordsInStore();
const {
record: calendarEvent,
loading,
error,
} = useFindOneRecord<CalendarEvent>({
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
objectRecordId: calendarEventId,
recordGqlFields: {
id: true,
title: true,
startsAt: true,
endsAt: true,
},
onCompleted: (data) => {
upsertRecords([data]);
},
});
const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer();
const { timeZone } = useContext(UserContext);
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 calendar event</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, timeZone);
const startsAtDay = formatToHumanReadableDay(startsAtDate, timeZone);
const startsAtHour = formatToHumanReadableTime(startsAtDate, timeZone);
const endsAtHour = endsAtDate
? formatToHumanReadableTime(endsAtDate, timeZone)
: 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,53 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { EventCardCalendarEvent } from '@/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent';
import { EventCard } from '@/activities/timeline-activities/rows/components/EventCard';
import { EventCardToggleButton } from '@/activities/timeline-activities/rows/components/EventCardToggleButton';
import {
EventRowDynamicComponentProps,
StyledEventRowItemAction,
StyledEventRowItemColumn,
} from '@/activities/timeline-activities/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 = ({
event,
authorFullName,
labelIdentifierValue,
}: EventRowCalendarEventProps) => {
const [, eventAction] = event.name.split('.');
const [isOpen, setIsOpen] = useState(false);
if (['linked'].includes(eventAction) === false) {
throw new Error('Invalid event action for calendarEvent event type.');
}
return (
<StyledEventRowCalendarEventContainer>
<StyledRowContainer>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
<StyledEventRowItemAction>
linked a calendar event with {labelIdentifierValue}
</StyledEventRowItemAction>
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
</StyledRowContainer>
<EventCard isOpen={isOpen}>
<EventCardCalendarEvent calendarEventId={event.linkedRecordId} />
</EventCard>
</StyledEventRowCalendarEventContainer>
);
};

View File

@ -0,0 +1,68 @@
import { Meta, StoryObj } from '@storybook/react';
import { HttpResponse, graphql } from 'msw';
import { ComponentDecorator } from 'twenty-ui';
import { EventCardCalendarEvent } from '@/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const meta: Meta<typeof EventCardCalendarEvent> = {
title: 'Modules/TimelineActivities/Rows/CalendarEvent/EventCardCalendarEvent',
component: EventCardCalendarEvent,
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
};
export default meta;
type Story = StoryObj<typeof EventCardCalendarEvent>;
export const Default: Story = {
args: {
calendarEventId: '1',
},
parameters: {
msw: {
handlers: [
graphql.query('FindOneCalendarEvent', () => {
return HttpResponse.json({
data: {
calendarEvent: {
id: '1',
title: 'Mock title',
startsAt: '2022-01-01T00:00:00Z',
endsAt: '2022-01-01T01:00:00Z',
},
},
});
}),
],
},
},
};
export const NotShared: Story = {
args: {
calendarEventId: '1',
},
parameters: {
msw: {
handlers: [
graphql.query('FindOneCalendarEvent', () => {
return HttpResponse.json({
errors: [
{
message: 'Forbidden',
extensions: {
code: 'FORBIDDEN',
},
},
],
});
}),
],
},
},
};

View File

@ -0,0 +1,43 @@
import styled from '@emotion/styled';
import { Card } from '@/ui/layout/card/components/Card';
type EventCardProps = {
children: React.ReactNode;
isOpen: boolean;
};
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(1)} 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>
)
);
};

View File

@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { IconChevronDown, IconChevronUp } from 'twenty-ui';
import { IconButton } from '@/ui/input/button/components/IconButton';
type EventCardToggleButtonProps = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const StyledButtonContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
`;
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,29 @@
import { IconCirclePlus, IconEditCircle, IconTrash, useIcons } from 'twenty-ui';
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
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 />;
}
if (eventAction === 'deleted') {
return <IconTrash />;
}
const IconComponent = getIcon(linkedObjectMetadataItem?.icon);
return <IconComponent />;
};

View File

@ -0,0 +1,98 @@
import styled from '@emotion/styled';
import { EventRowActivity } from '@/activities/timeline-activities/rows/activity/components/EventRowActivity';
import { EventRowCalendarEvent } from '@/activities/timeline-activities/rows/calendar/components/EventRowCalendarEvent';
import { EventRowMainObject } from '@/activities/timeline-activities/rows/main-object/components/EventRowMainObject';
import { EventRowMessage } from '@/activities/timeline-activities/rows/message/components/EventRowMessage';
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export interface EventRowDynamicComponentProps {
labelIdentifierValue: string;
event: TimelineActivity;
mainObjectMetadataItem: ObjectMetadataItem;
linkedObjectMetadataItem: ObjectMetadataItem | null;
authorFullName: string;
}
export const StyledEventRowItemColumn = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const StyledEventRowItemAction = styled(StyledEventRowItemColumn)`
color: ${({ theme }) => theme.font.color.secondary};
`;
export const EventRowDynamicComponent = ({
labelIdentifierValue,
event,
mainObjectMetadataItem,
linkedObjectMetadataItem,
authorFullName,
}: EventRowDynamicComponentProps) => {
const [eventName] = event.name.split('.');
switch (eventName) {
case 'calendarEvent':
return (
<EventRowCalendarEvent
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
/>
);
case 'message':
return (
<EventRowMessage
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
/>
);
case 'linked-task':
return (
<EventRowActivity
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
objectNameSingular={CoreObjectNameSingular.Task}
/>
);
case 'linked-note':
return (
<EventRowActivity
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
objectNameSingular={CoreObjectNameSingular.Note}
/>
);
case mainObjectMetadataItem?.nameSingular:
return (
<EventRowMainObject
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
/>
);
default:
throw new Error(
`Cannot find event component for event name ${eventName}`,
);
}
};

View File

@ -0,0 +1,76 @@
import styled from '@emotion/styled';
import { EventFieldDiffLabel } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffLabel';
import { EventFieldDiffValue } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffValue';
import { EventFieldDiffValueEffect } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffValueEffect';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
type EventFieldDiffProps = {
diffRecord: Record<string, any>;
mainObjectMetadataItem: ObjectMetadataItem;
fieldMetadataItem: FieldMetadataItem | undefined;
diffArtificialRecordStoreId: string;
};
const StyledEventFieldDiffContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
width: 380px;
`;
const StyledEmptyValue = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const EventFieldDiff = ({
diffRecord,
mainObjectMetadataItem,
fieldMetadataItem,
diffArtificialRecordStoreId,
}: EventFieldDiffProps) => {
if (!fieldMetadataItem) {
throw new Error('fieldMetadataItem is required');
}
const isValueEmpty = (value: unknown): boolean =>
value === null || value === undefined || value === '';
const isObjectEmpty = (obj: Record<string, unknown>): boolean =>
Object.values(obj).every(isValueEmpty);
const isUpdatedToEmpty =
isValueEmpty(diffRecord) ||
(typeof diffRecord === 'object' &&
diffRecord !== null &&
isObjectEmpty(diffRecord));
return (
<RecordFieldValueSelectorContextProvider>
<StyledEventFieldDiffContainer>
<EventFieldDiffLabel fieldMetadataItem={fieldMetadataItem} />
{isUpdatedToEmpty ? (
<StyledEmptyValue>Empty</StyledEmptyValue>
) : (
<>
<EventFieldDiffValueEffect
diffArtificialRecordStoreId={diffArtificialRecordStoreId}
mainObjectMetadataItem={mainObjectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
diffRecord={diffRecord}
/>
<EventFieldDiffValue
diffArtificialRecordStoreId={diffArtificialRecordStoreId}
mainObjectMetadataItem={mainObjectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
/>
</>
)}
</StyledEventFieldDiffContainer>
</RecordFieldValueSelectorContextProvider>
);
};

View File

@ -0,0 +1,39 @@
import { EventFieldDiff } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiff';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type EventFieldDiffContainerProps = {
mainObjectMetadataItem: ObjectMetadataItem;
diffKey: string;
diffValue: any;
eventId: string;
fieldMetadataItemMap: Record<string, FieldMetadataItem>;
};
export const EventFieldDiffContainer = ({
mainObjectMetadataItem,
diffKey,
diffValue,
eventId,
fieldMetadataItemMap,
}: EventFieldDiffContainerProps) => {
const fieldMetadataItem = fieldMetadataItemMap[diffKey];
if (!fieldMetadataItem) {
throw new Error(
`Cannot find field metadata item for field name ${diffKey} on object ${mainObjectMetadataItem.nameSingular}`,
);
}
const diffArtificialRecordStoreId = eventId + '--' + fieldMetadataItem.id;
return (
<EventFieldDiff
key={diffArtificialRecordStoreId}
diffRecord={diffValue}
fieldMetadataItem={fieldMetadataItem}
mainObjectMetadataItem={mainObjectMetadataItem}
diffArtificialRecordStoreId={diffArtificialRecordStoreId}
/>
);
};

View File

@ -0,0 +1,42 @@
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)};
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledUpdatedFieldIconContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 14px;
width: 14px;
`;
export const EventFieldDiffLabel = ({
fieldMetadataItem,
}: EventFieldDiffLabelProps) => {
const { getIcon } = useIcons();
const IconComponent = fieldMetadataItem?.icon
? getIcon(fieldMetadataItem?.icon)
: Icon123;
return (
<StyledUpdatedFieldContainer>
<StyledUpdatedFieldIconContainer>
<IconComponent />
</StyledUpdatedFieldIconContainer>
{fieldMetadataItem.label}
</StyledUpdatedFieldContainer>
);
};

View File

@ -0,0 +1,57 @@
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 = {
diffArtificialRecordStoreId: string;
mainObjectMetadataItem: ObjectMetadataItem;
fieldMetadataItem: FieldMetadataItem;
};
const StyledEventFieldDiffValue = styled.div`
align-items: center;
display: flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: ${({ theme }) => theme.font.color.primary};
`;
export const EventFieldDiffValue = ({
diffArtificialRecordStoreId,
mainObjectMetadataItem,
fieldMetadataItem,
}: EventFieldDiffValueProps) => {
return (
<StyledEventFieldDiffValue>
<FieldContext.Provider
value={{
recordId: diffArtificialRecordStoreId,
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,47 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useSetRecordValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isDefined } from 'twenty-ui';
export const EventFieldDiffValueEffect = ({
diffArtificialRecordStoreId,
diffRecord,
mainObjectMetadataItem,
fieldMetadataItem,
}: {
diffArtificialRecordStoreId: string;
diffRecord: Record<string, any> | undefined;
mainObjectMetadataItem: ObjectMetadataItem;
fieldMetadataItem: FieldMetadataItem;
}) => {
const setEntity = useSetRecoilState(
recordStoreFamilyState(diffArtificialRecordStoreId),
);
const setRecordValue = useSetRecordValue();
useEffect(() => {
if (!isDefined(diffRecord)) return;
const forgedObjectRecord = {
__typename: mainObjectMetadataItem.nameSingular,
id: diffArtificialRecordStoreId,
[fieldMetadataItem.name]: diffRecord,
};
setEntity(forgedObjectRecord);
setRecordValue(forgedObjectRecord.id, forgedObjectRecord);
}, [
diffRecord,
diffArtificialRecordStoreId,
fieldMetadataItem.name,
mainObjectMetadataItem.nameSingular,
setEntity,
setRecordValue,
]);
return <></>;
};

View File

@ -0,0 +1,61 @@
import {
EventRowDynamicComponentProps,
StyledEventRowItemAction,
StyledEventRowItemColumn,
} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent';
import { EventRowMainObjectUpdated } from '@/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated';
import styled from '@emotion/styled';
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>
<StyledEventRowItemColumn>
{labelIdentifierValue}
</StyledEventRowItemColumn>
<StyledEventRowItemAction>was created by</StyledEventRowItemAction>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
</StyledMainContainer>
);
}
case 'updated': {
return (
<EventRowMainObjectUpdated
authorFullName={authorFullName}
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
/>
);
}
case 'deleted': {
return (
<StyledMainContainer>
<StyledEventRowItemColumn>
{labelIdentifierValue}
</StyledEventRowItemColumn>
<StyledEventRowItemAction>was deleted by</StyledEventRowItemAction>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
</StyledMainContainer>
);
}
default:
return null;
}
};

View File

@ -0,0 +1,97 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { EventCard } from '@/activities/timeline-activities/rows/components/EventCard';
import { EventCardToggleButton } from '@/activities/timeline-activities/rows/components/EventCardToggleButton';
import {
StyledEventRowItemAction,
StyledEventRowItemColumn,
} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent';
import { EventFieldDiffContainer } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer';
import { TimelineActivity } from '@/activities/timeline-activities/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)};
`;
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>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
<StyledEventRowItemAction>
updated
{diffEntries.length === 1 && (
<EventFieldDiffContainer
mainObjectMetadataItem={mainObjectMetadataItem}
diffKey={diffEntries[0][0]}
diffValue={diffEntries[0][1].after}
eventId={event.id}
fieldMetadataItemMap={fieldMetadataItemMap}
/>
)}
{diffEntries.length > 1 && (
<>
<span>
{diffEntries.length} fields on {labelIdentifierValue}
</span>
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
</>
)}
</StyledEventRowItemAction>
</StyledRowContainer>
{diffEntries.length > 1 && (
<EventCard isOpen={isOpen}>
{diffEntries.map(([diffKey, diffValue]) => (
<EventFieldDiffContainer
key={diffKey}
mainObjectMetadataItem={mainObjectMetadataItem}
diffKey={diffKey}
diffValue={diffValue.after}
eventId={event.id}
fieldMetadataItemMap={fieldMetadataItemMap}
/>
))}
</EventCard>
)}
</StyledEventRowMainObjectUpdatedContainer>
);
};

View File

@ -0,0 +1,53 @@
import { EventRowMainObjectUpdated } from '@/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated';
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
const meta: Meta<typeof EventRowMainObjectUpdated> = {
title: 'Modules/TimelineActivities/Rows/MainObject/EventRowMainObjectUpdated',
component: EventRowMainObjectUpdated,
args: {
authorFullName: 'John Doe',
labelIdentifierValue: 'Mock',
event: {
id: '1',
name: 'mock.updated',
properties: {
diff: {
jobTitle: {
after: 'mock job title',
before: '',
},
linkedinLink: {
after: {
url: 'mock.linkedin',
label: 'mock linkedin url',
},
before: {
url: '',
label: '',
},
},
},
},
} as TimelineActivity,
mainObjectMetadataItem: generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
),
},
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof EventRowMainObjectUpdated>;
export const Default: Story = {};

View File

@ -0,0 +1,131 @@
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/timeline-activities/rows/message/components/EventCardMessageNotShared';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { isDefined } from '~/utils/isDefined';
const StyledEventCardMessageContainer = styled.div`
display: flex;
flex-direction: column;
width: 380px;
`;
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};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledEmailParticipants = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledEmailBody = styled.div`
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const EventCardMessage = ({
messageId,
authorFullName,
}: {
messageId: string;
authorFullName: string;
}) => {
const { upsertRecords } = useUpsertRecordsInStore();
const {
record: message,
loading,
error,
} = useFindOneRecord<EmailThreadMessage>({
objectNameSingular: CoreObjectNameSingular.Message,
objectRecordId: messageId,
recordGqlFields: {
id: true,
text: true,
subject: true,
direction: true,
messageThreadId: true,
messageParticipants: {
handle: true,
},
},
onCompleted: (data) => {
upsertRecords([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)
.filter((handle) => isDefined(handle) && handle !== '')
.join(', ');
return (
<StyledEventCardMessageContainer>
<StyledEmailContent>
<StyledEmailTop>
<StyledEmailTitle>{message.subject}</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,59 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { EventCard } from '@/activities/timeline-activities/rows/components/EventCard';
import { EventCardToggleButton } from '@/activities/timeline-activities/rows/components/EventCardToggleButton';
import {
EventRowDynamicComponentProps,
StyledEventRowItemAction,
StyledEventRowItemColumn,
} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent';
import { EventCardMessage } from '@/activities/timeline-activities/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 = ({
event,
authorFullName,
labelIdentifierValue,
}: EventRowMessageProps) => {
const [, eventAction] = event.name.split('.');
const [isOpen, setIsOpen] = useState(false);
if (['linked'].includes(eventAction) === false) {
throw new Error('Invalid event action for message event type.');
}
return (
<StyledEventRowMessageContainer>
<StyledRowContainer>
<StyledEventRowItemColumn>{authorFullName}</StyledEventRowItemColumn>
<StyledEventRowItemAction>
linked an email with
</StyledEventRowItemAction>
<StyledEventRowItemColumn>
{labelIdentifierValue}
</StyledEventRowItemColumn>
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
</StyledRowContainer>
<EventCard isOpen={isOpen}>
<EventCardMessage
messageId={event.linkedRecordId}
authorFullName={authorFullName}
/>
</EventCard>
</StyledEventRowMessageContainer>
);
};

View File

@ -0,0 +1,82 @@
import { Meta, StoryObj } from '@storybook/react';
import { HttpResponse, graphql } from 'msw';
import { ComponentDecorator } from 'twenty-ui';
import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext';
import { EventCardMessage } from '@/activities/timeline-activities/rows/message/components/EventCardMessage';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const meta: Meta<typeof EventCardMessage> = {
title: 'Modules/TimelineActivities/Rows/Message/EventCardMessage',
component: EventCardMessage,
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
(Story) => {
return (
<TimelineActivityContext.Provider
value={{
labelIdentifierValue: 'Mock',
}}
>
<Story />
</TimelineActivityContext.Provider>
);
},
],
};
export default meta;
type Story = StoryObj<typeof EventCardMessage>;
export const Default: Story = {
args: {
messageId: '1',
authorFullName: 'John Doe',
},
parameters: {
msw: {
handlers: [
graphql.query('FindOneMessage', () => {
return HttpResponse.json({
data: {
message: {
id: '1',
subject: 'Mock title',
text: 'Mock body',
messageParticipants: [],
},
},
});
}),
],
},
},
};
export const NotShared: Story = {
args: {
messageId: '1',
authorFullName: 'John Doe',
},
parameters: {
msw: {
handlers: [
graphql.query('FindOneMessage', () => {
return HttpResponse.json({
errors: [
{
message: 'Forbidden',
extensions: {
code: 'FORBIDDEN',
},
},
],
});
}),
],
},
},
};

View File

@ -0,0 +1,9 @@
import { atom } from 'recoil';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
export const objectShowPageTargetableObjectState =
atom<ActivityTargetableObject | null>({
key: 'objectShowPageTargetableObjectState',
default: null,
});

View File

@ -0,0 +1,16 @@
import { WorkspaceMember } from '~/generated/graphql';
export type TimelineActivity = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
workspaceMemberId: string;
workspaceMember: WorkspaceMember;
properties: any;
name: string;
linkedRecordCachedName: string;
linkedRecordId: string;
linkedObjectMetadataId: string;
__typename: 'TimelineActivity';
} & Record<string, any>;

View File

@ -0,0 +1 @@
export type TimelineActivityLinkedObject = 'note' | 'task';

View File

@ -0,0 +1,141 @@
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { filterOutInvalidTimelineActivities } from '@/activities/timeline-activities/utils/filterOutInvalidTimelineActivities';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
const noteObjectMetadataItem = {
nameSingular: CoreObjectNameSingular.Note,
namePlural: 'notes',
fields: [{ name: 'field1' }, { name: 'field2' }, { name: 'field3' }],
} as ObjectMetadataItem;
describe('filterOutInvalidTimelineActivities', () => {
it('should filter out TimelineActivities with deleted fields from the properties diff', () => {
const events = [
{
id: '1',
name: 'event1',
properties: {
diff: {
field1: { before: 'value1', after: 'value2' },
field2: { before: 'value3', after: 'value4' },
field3: { before: 'value5', after: 'value6' },
},
},
},
{
id: '2',
name: 'event2',
properties: {
diff: {
field1: { before: 'value7', after: 'value8' },
field2: { before: 'value9', after: 'value10' },
field4: { before: 'value11', after: 'value12' },
},
},
},
] as TimelineActivity[];
const mainObjectMetadataItem = {
nameSingular: 'objectNameSingular',
namePlural: 'objectNamePlural',
fields: [{ name: 'field1' }, { name: 'field2' }, { name: 'field3' }],
} as ObjectMetadataItem;
const filteredEvents = filterOutInvalidTimelineActivities(
events,
'objectNameSingular',
[mainObjectMetadataItem, noteObjectMetadataItem],
);
expect(filteredEvents).toEqual([
{
id: '1',
name: 'event1',
properties: {
diff: {
field1: { before: 'value1', after: 'value2' },
field2: { before: 'value3', after: 'value4' },
field3: { before: 'value5', after: 'value6' },
},
},
},
{
id: '2',
name: 'event2',
properties: {
diff: {
field1: { before: 'value7', after: 'value8' },
field2: { before: 'value9', after: 'value10' },
},
},
},
]);
});
it('should return an empty array if all TimelineActivities have deleted fields in the properties diff', () => {
const events = [
{
id: '1',
name: 'event1',
properties: {
diff: {
field3: { before: 'value5', after: 'value6' },
},
},
},
{
id: '2',
name: 'event2',
properties: {
diff: {
field4: { before: 'value11', after: 'value12' },
},
},
},
] as TimelineActivity[];
const mainObjectMetadataItem = {
nameSingular: 'objectNameSingular',
namePlural: 'objectNamePlural',
fields: [{ name: 'field1' }, { name: 'field2' }],
} as ObjectMetadataItem;
const filteredEvents = filterOutInvalidTimelineActivities(
events,
'objectNameSingular',
[mainObjectMetadataItem, noteObjectMetadataItem],
);
expect(filteredEvents).toEqual([]);
});
it('should return the same TimelineActivities if there are no properties diffs', () => {
const events = [
{
id: '1',
name: 'event1',
properties: {},
},
{
id: '2',
name: 'event2',
properties: {},
},
] as TimelineActivity[];
const mainObjectMetadataItem = {
nameSingular: 'objectNameSingular',
namePlural: 'objectNamePlural',
fields: [{ name: 'field1' }, { name: 'field2' }],
} as ObjectMetadataItem;
const filteredEvents = filterOutInvalidTimelineActivities(
events,
'objectNameSingular',
[mainObjectMetadataItem, noteObjectMetadataItem],
);
expect(filteredEvents).toEqual(events);
});
});

View File

@ -0,0 +1,63 @@
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { getTimelineActivityAuthorFullName } from '@/activities/timeline-activities/utils/getTimelineActivityAuthorFullName';
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
describe('getTimelineActivityAuthorFullName', () => {
it('should return "You" if the current workspace member is the author', () => {
const event = {
workspaceMember: {
id: '123',
name: {
firstName: 'John',
lastName: 'Doe',
},
},
};
const currentWorkspaceMember = {
id: '123',
};
const result = getTimelineActivityAuthorFullName(
event as TimelineActivity,
currentWorkspaceMember as CurrentWorkspaceMember,
);
expect(result).toBe('You');
});
it('should return the full name of the workspace member if they are not the current workspace member', () => {
const event = {
workspaceMember: {
id: '456',
name: {
firstName: 'Jane',
lastName: 'Smith',
},
},
};
const currentWorkspaceMember = {
id: '123',
};
const result = getTimelineActivityAuthorFullName(
event as TimelineActivity,
currentWorkspaceMember as CurrentWorkspaceMember,
);
expect(result).toBe('Jane Smith');
});
it('should return "Twenty" if the workspace member is not defined', () => {
const event = {};
const currentWorkspaceMember = {
id: '123',
};
const result = getTimelineActivityAuthorFullName(
event as TimelineActivity,
currentWorkspaceMember as CurrentWorkspaceMember,
);
expect(result).toBe('Twenty');
});
});

View File

@ -0,0 +1,19 @@
import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities';
import { groupEventsByMonth } from '../groupEventsByMonth';
describe('groupEventsByMonth', () => {
it('should group activities by month', () => {
const grouped = groupEventsByMonth(mockedTimelineActivities);
expect(grouped).toHaveLength(2);
expect(grouped[0].items).toHaveLength(4);
expect(grouped[1].items).toHaveLength(1);
expect(grouped[0].year).toBe(2023);
expect(grouped[1].year).toBe(2022);
expect(grouped[0].month).toBe(3);
expect(grouped[1].month).toBe(4);
});
});

View File

@ -0,0 +1,62 @@
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const filterOutInvalidTimelineActivities = (
timelineActivities: TimelineActivity[],
mainObjectSingularName: string,
objectMetadataItems: ObjectMetadataItem[],
): TimelineActivity[] => {
const mainObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === mainObjectSingularName,
);
const noteObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === CoreObjectNameSingular.Note,
);
if (!mainObjectMetadataItem || !noteObjectMetadataItem) {
throw new Error('Object metadata items not found');
}
const fieldMetadataItemMap = new Map(
mainObjectMetadataItem.fields.map((field) => [field.name, field]),
);
const noteFieldMetadataItemMap = new Map(
noteObjectMetadataItem.fields.map((field) => [field.name, field]),
);
return timelineActivities.filter((timelineActivity) => {
const diff = timelineActivity.properties?.diff;
const canSkipValidation = !diff;
if (canSkipValidation) {
return true;
}
const isNoteOrTask =
timelineActivity.name.startsWith('linked-note') ||
timelineActivity.name.startsWith('linked-task');
const validDiffEntries = Object.entries(diff).filter(([diffKey]) =>
isNoteOrTask
? // Note and Task objects have the same field metadata
noteFieldMetadataItemMap.has(diffKey)
: fieldMetadataItemMap.has(diffKey),
);
if (validDiffEntries.length === 0) {
return false;
}
timelineActivity.properties = {
...timelineActivity.properties,
diff: Object.fromEntries(validDiffEntries),
};
return true;
});
};

View File

@ -0,0 +1,12 @@
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { TimelineActivityLinkedObject } from '@/activities/timeline-activities/types/TimelineActivityLinkedObject';
export const filterTimelineActivityByLinkedObjectTypes =
(linkedObjectTypes: TimelineActivityLinkedObject[]) =>
(timelineActivity: TimelineActivity) => {
return linkedObjectTypes.some((linkedObjectType) => {
const linkedObjectPartInName = timelineActivity.name.split('.')[0];
return linkedObjectPartInName.includes(linkedObjectType);
});
};

View File

@ -0,0 +1,15 @@
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { isDefined } from '~/utils/isDefined';
export const getTimelineActivityAuthorFullName = (
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';
};

View File

@ -0,0 +1,33 @@
import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { isDefined } from '~/utils/isDefined';
export type EventGroup = {
month: number;
year: number;
items: TimelineActivity[];
};
export const groupEventsByMonth = (events: TimelineActivity[]) => {
const acitivityGroups: EventGroup[] = [];
for (const event of events) {
const d = new Date(event.createdAt);
const month = d.getMonth();
const year = d.getFullYear();
const matchingGroup = acitivityGroups.find(
(x) => x.year === year && x.month === month,
);
if (isDefined(matchingGroup)) {
matchingGroup.items.push(event);
} else {
acitivityGroups.push({
year,
month,
items: [event],
});
}
}
return acitivityGroups.sort((a, b) => b.year - a.year || b.month - a.month);
};