4810 display participants in the right drawer of the calendar event (#4896)

Closes #4810

- Introduces a new component `ExpandableList` which uses intersection
observers to display the maximum number of elements possible
This commit is contained in:
bosiraphael
2024-04-12 10:33:46 +02:00
committed by GitHub
parent 432d041203
commit c0b3a8715f
18 changed files with 468 additions and 89 deletions

View File

@ -1,7 +1,9 @@
import React from 'react';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCalendarEvent } from 'twenty-ui';
import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatus';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -30,6 +32,8 @@ const StyledContainer = styled.div`
flex-direction: column;
gap: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(6)};
width: 100%;
box-sizing: border-box;
`;
const StyledEventChip = styled(Chip)`
@ -60,11 +64,13 @@ const StyledFields = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledPropertyBox = styled(PropertyBox)`
height: ${({ theme }) => theme.spacing(6)};
padding: 0;
width: 100%;
`;
export const CalendarEventDetails = ({
@ -88,6 +94,31 @@ export const CalendarEventDetails = ({
({ name }) => name,
);
const { calendarEventParticipants } = calendarEvent;
const Fields = fieldsToDisplay.map((fieldName) => (
<StyledPropertyBox key={fieldName}>
<FieldContext.Provider
value={{
entityId: calendarEvent.id,
hotkeyScope: 'calendar-event-details',
recoilScopeId: `${calendarEvent.id}-${fieldName}`,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsFieldDefinition({
field: fieldsByName[fieldName],
objectMetadataItem,
showLabel: true,
labelWidth: 72,
}),
useUpdateRecord: () => [() => undefined, { loading: false }],
maxWidth: 300,
}}
>
<RecordInlineCell readonly />
</FieldContext.Provider>
</StyledPropertyBox>
));
return (
<StyledContainer>
<StyledEventChip
@ -110,27 +141,13 @@ export const CalendarEventDetails = ({
</StyledCreatedAt>
</StyledHeader>
<StyledFields>
{fieldsToDisplay.map((fieldName) => (
<StyledPropertyBox key={fieldName}>
<FieldContext.Provider
value={{
entityId: calendarEvent.id,
hotkeyScope: 'calendar-event-details',
recoilScopeId: `${calendarEvent.id}-${fieldName}`,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsFieldDefinition({
field: fieldsByName[fieldName],
objectMetadataItem,
showLabel: true,
labelWidth: 72,
}),
useUpdateRecord: () => [() => undefined, { loading: false }],
}}
>
<RecordInlineCell readonly />
</FieldContext.Provider>
</StyledPropertyBox>
))}
{Fields.slice(0, 2)}
{calendarEventParticipants && (
<CalendarEventParticipantsResponseStatus
participants={calendarEventParticipants}
/>
)}
{Fields.slice(2)}
</StyledFields>
</StyledContainer>
);

View File

@ -0,0 +1,42 @@
import groupBy from 'lodash.groupby';
import { CalendarEventParticipantsResponseStatusField } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatusField';
import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
export const CalendarEventParticipantsResponseStatus = ({
participants,
}: {
participants: CalendarEventParticipant[];
}) => {
const groupedParticipants = groupBy(participants, (participant) => {
switch (participant.responseStatus) {
case 'ACCEPTED':
return 'Yes';
case 'DECLINED':
return 'No';
case 'NEEDS_ACTION':
case 'TENTATIVE':
return 'Maybe';
default:
return '';
}
});
const responseStatusOrder: ('Yes' | 'Maybe' | 'No')[] = [
'Yes',
'Maybe',
'No',
];
return (
<>
{responseStatusOrder.map((responseStatus) => (
<CalendarEventParticipantsResponseStatusField
key={responseStatus}
responseStatus={responseStatus}
participants={groupedParticipants[responseStatus] || []}
/>
))}
</>
);
};

View File

@ -0,0 +1,108 @@
import { useRef } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck, IconQuestionMark, IconX } from 'twenty-ui';
import { v4 } from 'uuid';
import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
import { ParticipantChip } from '@/activities/components/ParticipantChip';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { ExpandableList } from '@/ui/display/expandable-list/ExpandableList';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
const StyledInlineCellBaseContainer = styled.div`
align-items: center;
box-sizing: border-box;
width: 100%;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
position: relative;
user-select: none;
`;
const StyledPropertyBox = styled(PropertyBox)`
height: ${({ theme }) => theme.spacing(6)};
padding: 0;
width: 100%;
`;
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
width: 16px;
svg {
align-items: center;
display: flex;
height: 16px;
justify-content: center;
width: 16px;
}
`;
const StyledLabelAndIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledLabelContainer = styled.div<{ width?: number }>`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
width: ${({ width }) => width}px;
`;
export const CalendarEventParticipantsResponseStatusField = ({
responseStatus,
participants,
}: {
responseStatus: 'Yes' | 'Maybe' | 'No';
participants: CalendarEventParticipant[];
}) => {
const theme = useTheme();
const Icon = {
Yes: <IconCheck stroke={theme.icon.stroke.sm} />,
Maybe: <IconQuestionMark stroke={theme.icon.stroke.sm} />,
No: <IconX stroke={theme.icon.stroke.sm} />,
}[responseStatus];
// We want to display external participants first
const orderedParticipants = [
...participants.filter((participant) => participant.person),
...participants.filter(
(participant) => !participant.person && !participant.workspaceMember,
),
...participants.filter((participant) => participant.workspaceMember),
];
const participantsContainerRef = useRef<HTMLDivElement>(null);
const StyledChips = orderedParticipants.map((participant) => (
<ParticipantChip participant={participant} />
));
return (
<StyledPropertyBox>
<StyledInlineCellBaseContainer>
<StyledLabelAndIconContainer>
<StyledIconContainer>{Icon}</StyledIconContainer>
<StyledLabelContainer width={72}>
<EllipsisDisplay>{responseStatus}</EllipsisDisplay>
</StyledLabelContainer>
</StyledLabelAndIconContainer>
<ExpandableList
listItems={StyledChips}
id={v4()}
rootRef={participantsContainerRef}
/>
</StyledInlineCellBaseContainer>
</StyledPropertyBox>
);
};

View File

@ -14,6 +14,7 @@ export const RightDrawerCalendarEvent = () => {
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
objectRecordId: viewableCalendarEventId ?? '',
onCompleted: (record) => setRecords([record]),
depth: 2,
});
if (!calendarEvent) return null;

View File

@ -1,3 +1,5 @@
import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
// TODO: use backend CalendarEvent type when ready
export type CalendarEvent = {
conferenceLink?: {
@ -14,8 +16,5 @@ export type CalendarEvent = {
startsAt: string;
title?: string;
visibility: 'METADATA' | 'SHARE_EVERYTHING';
participants?: {
displayName: string;
workspaceMemberId?: string;
}[];
calendarEventParticipants?: CalendarEventParticipant[];
};

View File

@ -0,0 +1,12 @@
import { Person } from '@/people/types/Person';
import { WorkspaceMember } from '~/generated-metadata/graphql';
export type CalendarEventParticipant = {
id: string;
handle: string;
isOrganizer: boolean;
displayName: string;
person?: Person;
workspaceMember?: WorkspaceMember;
responseStatus: 'ACCEPTED' | 'DECLINED' | 'NEEDS_ACTION' | 'TENTATIVE';
};

View File

@ -0,0 +1,80 @@
import styled from '@emotion/styled';
import { getDisplayNameFromParticipant } from '@/activities/emails/utils/getDisplayNameFromParticipant';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip';
import { Avatar } from '@/users/components/Avatar';
const StyledAvatar = styled(Avatar)`
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledSenderName = styled.span<{ variant?: 'default' | 'bold' }>`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme, variant }) =>
variant === 'bold' ? theme.font.weight.medium : theme.font.weight.regular};
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledContainer = styled.div`
align-items: flex-start;
display: flex;
`;
const StyledRecordChip = styled(RecordChip)<{ variant: 'default' | 'bold' }>`
font-weight: ${({ theme, variant }) =>
variant === 'bold' ? theme.font.weight.medium : theme.font.weight.regular};
`;
const StyledChip = styled.div`
align-items: center;
display: flex;
padding: ${({ theme }) => theme.spacing(1)};
height: 20px;
box-sizing: border-box;
`;
type ParticipantChipVariant = 'default' | 'bold';
export const ParticipantChip = ({
participant,
variant = 'default',
className,
}: {
participant: any;
variant?: ParticipantChipVariant;
className?: string;
}) => {
const { person, workspaceMember } = participant;
const displayName = getDisplayNameFromParticipant({
participant,
shouldUseFullName: true,
});
const avatarUrl = person?.avatarUrl ?? workspaceMember?.avatarUrl ?? '';
return (
<StyledContainer className={className}>
{person ? (
<StyledRecordChip
objectNameSingular={CoreObjectNameSingular.Person}
record={person}
variant={variant}
/>
) : (
<StyledChip>
<StyledAvatar
avatarUrl={avatarUrl}
type="rounded"
placeholder={displayName}
size="sm"
/>
<StyledSenderName variant={variant}>{displayName}</StyledSenderName>
</StyledChip>
)}
</StyledContainer>
);
};

View File

@ -1,11 +1,7 @@
import React from 'react';
import styled from '@emotion/styled';
import { ParticipantChip } from '@/activities/components/ParticipantChip';
import { EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { getDisplayNameFromParticipant } from '@/activities/emails/utils/getDisplayNameFromParticipant';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip';
import { Avatar } from '@/users/components/Avatar';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledEmailThreadMessageSender = styled.div`
@ -13,23 +9,6 @@ const StyledEmailThreadMessageSender = styled.div`
justify-content: space-between;
`;
const StyledEmailThreadMessageSenderUser = styled.div`
align-items: flex-start;
display: flex;
`;
const StyledAvatar = styled(Avatar)`
margin: ${({ theme }) => theme.spacing(0, 1)};
`;
const StyledSenderName = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledThreadMessageSentAt = styled.div`
align-items: flex-end;
display: flex;
@ -37,10 +16,6 @@ const StyledThreadMessageSentAt = styled.div`
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledRecordChip = styled(RecordChip)`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
type EmailThreadMessageSenderProps = {
sender: EmailThreadMessageParticipant;
sentAt: string;
@ -50,35 +25,9 @@ export const EmailThreadMessageSender = ({
sender,
sentAt,
}: EmailThreadMessageSenderProps) => {
const { person, workspaceMember } = sender;
const displayName = getDisplayNameFromParticipant({
participant: sender,
shouldUseFullName: true,
});
const avatarUrl = person?.avatarUrl ?? workspaceMember?.avatarUrl ?? '';
return (
<StyledEmailThreadMessageSender>
<StyledEmailThreadMessageSenderUser>
{person ? (
<StyledRecordChip
objectNameSingular={CoreObjectNameSingular.Person}
record={person}
/>
) : (
<>
<StyledAvatar
avatarUrl={avatarUrl}
type="rounded"
placeholder={displayName}
size="sm"
/>
<StyledSenderName>{displayName}</StyledSenderName>
</>
)}
</StyledEmailThreadMessageSenderUser>
<ParticipantChip participant={sender} variant="bold" />
<StyledThreadMessageSentAt>
{beautifyPastDateRelativeToNow(sentAt)}
</StyledThreadMessageSentAt>

View File

@ -0,0 +1,104 @@
import React, { ReactElement, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styled from '@emotion/styled';
import { Chip, ChipVariant } from '@/ui/display/chip/components/Chip';
import { IntersectionObserverWrapper } from '@/ui/display/expandable-list/IntersectionObserverWrapper';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
box-sizing: border-box;
white-space: nowrap;
overflow-x: hidden;
`;
const StyledExpendableCell = styled.div`
align-content: center;
align-items: center;
backdrop-filter: ${({ theme }) => theme.blur.strong};
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.light};
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const ExpandableList = ({
listItems,
rootRef,
id,
}: {
listItems: ReactElement[];
rootRef: React.RefObject<HTMLElement>;
id: string;
}) => {
const [listItemsInView, setListItemsInView] = useState(new Set<number>());
const firstListItem = listItems[0];
const dropdownId = `expandable-list-dropdown-${id}`;
const containerRef = useRef<HTMLDivElement>(null);
const divRef = useRef<HTMLDivElement>(null);
return (
<StyledContainer ref={containerRef}>
{firstListItem}
{listItems.slice(1).map((listItem, index) => (
<React.Fragment key={index}>
<IntersectionObserverWrapper
set={setListItemsInView}
id={index}
rootRef={rootRef}
>
{listItem}
</IntersectionObserverWrapper>
{index === listItemsInView.size - 1 &&
listItems.length - listItemsInView.size - 1 !== 0 && (
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: dropdownId,
}}
clickableComponent={
<Chip
label={`+${listItems.length - listItemsInView.size - 1}`}
variant={ChipVariant.Highlighted}
/>
}
dropdownComponents={
<>
{divRef.current &&
createPortal(
<StyledExpendableCell>
{listItems}
</StyledExpendableCell>,
divRef.current as HTMLElement,
)}
</>
}
/>
)}
</React.Fragment>
))}
<div
ref={divRef}
style={{
position: 'absolute',
top: '100%',
zIndex: 1,
boxSizing: 'border-box',
}}
></div>
</StyledContainer>
);
};

View File

@ -0,0 +1,47 @@
import React from 'react';
import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
const StyledDiv = styled.div<{ inView?: boolean }>`
opacity: ${({ inView }) => (inView === undefined || inView ? 1 : 0)};
`;
export const IntersectionObserverWrapper = ({
set,
id,
rootRef,
children,
}: {
set: React.Dispatch<React.SetStateAction<Set<number>>>;
id: number;
rootRef?: React.RefObject<HTMLElement>;
children: React.ReactNode;
}) => {
const { ref, inView } = useInView({
threshold: 1,
onChange: (inView) => {
if (inView) {
set((prev: Set<number>) => {
const newSet = new Set(prev);
newSet.add(id);
return newSet;
});
}
if (!inView) {
set((prev: Set<number>) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
},
root: rootRef?.current,
rootMargin: '0px 0px -50px 0px',
});
return (
<StyledDiv ref={ref} inView={inView}>
{children}
</StyledDiv>
);
};

View File

@ -2,6 +2,7 @@ import styled from '@emotion/styled';
const StyledDropdownMenu = styled.div<{
disableBlur?: boolean;
disableBorder?: boolean;
width?: `${string}px` | `${number}%` | 'auto' | number;
}>`
backdrop-filter: ${({ disableBlur }) =>
@ -14,7 +15,8 @@ const StyledDropdownMenu = styled.div<{
? theme.background.primary
: theme.background.transparent.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border: ${({ disableBorder, theme }) =>
disableBorder ? 'none' : `1px solid ${theme.border.color.medium}`};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};

View File

@ -23,7 +23,7 @@ const StyledRightDrawerBody = styled.div`
height: calc(
100vh - ${({ theme }) => theme.spacing(14)} - 1px
); // (-1 for border)
overflow: auto;
//overflow: auto;
position: relative;
`;

View File

@ -10,10 +10,28 @@ export const mockedCalendarEvents: CalendarEvent[] = [
isFullDay: false,
startsAt: addDays(new Date().setHours(10, 0), 1).toISOString(),
visibility: 'METADATA',
participants: [
{ displayName: 'John Doe', workspaceMemberId: 'john-doe' },
{ displayName: 'Jane Doe', workspaceMemberId: 'jane-doe' },
{ displayName: 'Tim Apple', workspaceMemberId: 'tim-apple' },
calendarEventParticipants: [
{
id: '1',
handle: 'jdoe',
isOrganizer: false,
responseStatus: 'ACCEPTED',
displayName: 'John Doe',
},
{
id: '2',
handle: 'jadoe',
isOrganizer: false,
responseStatus: 'ACCEPTED',
displayName: 'Jane Doe',
},
{
id: '3',
handle: 'tapple',
isOrganizer: false,
responseStatus: 'ACCEPTED',
displayName: 'Tim Apple',
},
],
},
{

View File

@ -1443,7 +1443,7 @@ const mockedCalendarEventsMetadata = {
__typename: 'field',
id: '07880d2d-4f08-458f-be0b-876402d2a769',
type: 'RELATION',
name: 'eventParticipants',
name: 'calendarEventParticipants',
label: 'Event Participants',
description: 'Event Participants',
icon: 'IconUserCircle',

View File

@ -97,7 +97,7 @@ export const calendarEventStandardFieldIds = {
conferenceLink: '20202020-35da-43ef-9ca0-e936e9dc237b',
recurringEventExternalId: '20202020-4b96-43d0-8156-4c7a9717635c',
calendarChannelEventAssociations: '20202020-bdf8-4572-a2cc-ecbb6bcc3a02',
eventParticipants: '20202020-e07e-4ccb-88f5-6f3d00458eec',
calendarEventParticipants: '20202020-e07e-4ccb-88f5-6f3d00458eec',
};
export const commentStandardFieldIds = {

View File

@ -171,7 +171,7 @@ export class CalendarEventObjectMetadata extends BaseObjectMetadata {
calendarChannelEventAssociations: CalendarChannelEventAssociationObjectMetadata[];
@FieldMetadata({
standardId: calendarEventStandardFieldIds.eventParticipants,
standardId: calendarEventStandardFieldIds.calendarEventParticipants,
type: FieldMetadataType.RELATION,
label: 'Event Participants',
description: 'Event Participants',
@ -182,5 +182,5 @@ export class CalendarEventObjectMetadata extends BaseObjectMetadata {
inverseSideTarget: () => CalendarEventParticipantObjectMetadata,
onDelete: RelationOnDeleteAction.CASCADE,
})
eventParticipants: CalendarEventParticipantObjectMetadata[];
calendarEventParticipants: CalendarEventParticipantObjectMetadata[];
}

View File

@ -8,7 +8,6 @@ export type CalendarEvent = Omit<
| 'updatedAt'
| 'calendarChannelEventAssociations'
| 'calendarEventParticipants'
| 'eventParticipants'
| 'conferenceLink'
> & {
conferenceLinkLabel: string;

View File

@ -113,6 +113,7 @@ export {
IconPresentation,
IconProgressCheck,
IconPuzzle,
IconQuestionMark,
IconRefresh,
IconRelationManyToMany,
IconRelationOneToMany,