Enforce front project structure through ESLINT (#7863)
Fixes: https://github.com/twentyhq/twenty/issues/7329
This commit is contained in:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 = {};
|
||||
@ -0,0 +1,8 @@
|
||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
|
||||
export const FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY: RecordGqlOperationOrderBy =
|
||||
[
|
||||
{
|
||||
createdAt: 'DescNullsFirst',
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type TimelineActivityContextValue = {
|
||||
labelIdentifierValue: string;
|
||||
};
|
||||
|
||||
export const TimelineActivityContext =
|
||||
createContext<TimelineActivityContextValue>({
|
||||
labelIdentifierValue: '',
|
||||
});
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 />;
|
||||
};
|
||||
@ -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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 = {};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,86 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IconLock } from 'twenty-ui';
|
||||
|
||||
const StyledEventCardMessageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledEmailContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledEmailTop = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledEmailTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledEmailBodyNotShareContainer = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
|
||||
height: 80px;
|
||||
justify-content: center;
|
||||
padding: 0 ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledEmailBodyNotSharedIconContainer = styled.div`
|
||||
display: flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledEmailBodyNotShare = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const EventCardMessageNotShared = ({
|
||||
sharedByFullName,
|
||||
}: {
|
||||
sharedByFullName: string;
|
||||
}) => {
|
||||
return (
|
||||
<StyledEventCardMessageContainer>
|
||||
<StyledEmailContent>
|
||||
<StyledEmailTop>
|
||||
<StyledEmailTitle>
|
||||
<span>Subject not shared</span>
|
||||
</StyledEmailTitle>
|
||||
</StyledEmailTop>
|
||||
<StyledEmailBodyNotShareContainer>
|
||||
<StyledEmailBodyNotShare>
|
||||
<StyledEmailBodyNotSharedIconContainer>
|
||||
<IconLock />
|
||||
</StyledEmailBodyNotSharedIconContainer>
|
||||
<span>Not shared by {sharedByFullName}</span>
|
||||
</StyledEmailBodyNotShare>
|
||||
</StyledEmailBodyNotShareContainer>
|
||||
</StyledEmailContent>
|
||||
</StyledEventCardMessageContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,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>
|
||||
);
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
|
||||
export const objectShowPageTargetableObjectState =
|
||||
atom<ActivityTargetableObject | null>({
|
||||
key: 'objectShowPageTargetableObjectState',
|
||||
default: null,
|
||||
});
|
||||
@ -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>;
|
||||
@ -0,0 +1 @@
|
||||
export type TimelineActivityLinkedObject = 'note' | 'task';
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
};
|
||||
@ -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';
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user