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:
@ -1,7 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
import { css, useTheme } from '@emotion/react';
|
import { css, useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { IconCalendarEvent } from 'twenty-ui';
|
import { IconCalendarEvent } from 'twenty-ui';
|
||||||
|
|
||||||
|
import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatus';
|
||||||
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
||||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
@ -30,6 +32,8 @@ const StyledContainer = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: ${({ theme }) => theme.spacing(6)};
|
gap: ${({ theme }) => theme.spacing(6)};
|
||||||
padding: ${({ theme }) => theme.spacing(6)};
|
padding: ${({ theme }) => theme.spacing(6)};
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledEventChip = styled(Chip)`
|
const StyledEventChip = styled(Chip)`
|
||||||
@ -60,11 +64,13 @@ const StyledFields = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: ${({ theme }) => theme.spacing(3)};
|
gap: ${({ theme }) => theme.spacing(3)};
|
||||||
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledPropertyBox = styled(PropertyBox)`
|
const StyledPropertyBox = styled(PropertyBox)`
|
||||||
height: ${({ theme }) => theme.spacing(6)};
|
height: ${({ theme }) => theme.spacing(6)};
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CalendarEventDetails = ({
|
export const CalendarEventDetails = ({
|
||||||
@ -88,6 +94,31 @@ export const CalendarEventDetails = ({
|
|||||||
({ name }) => name,
|
({ 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 (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<StyledEventChip
|
<StyledEventChip
|
||||||
@ -110,27 +141,13 @@ export const CalendarEventDetails = ({
|
|||||||
</StyledCreatedAt>
|
</StyledCreatedAt>
|
||||||
</StyledHeader>
|
</StyledHeader>
|
||||||
<StyledFields>
|
<StyledFields>
|
||||||
{fieldsToDisplay.map((fieldName) => (
|
{Fields.slice(0, 2)}
|
||||||
<StyledPropertyBox key={fieldName}>
|
{calendarEventParticipants && (
|
||||||
<FieldContext.Provider
|
<CalendarEventParticipantsResponseStatus
|
||||||
value={{
|
participants={calendarEventParticipants}
|
||||||
entityId: calendarEvent.id,
|
/>
|
||||||
hotkeyScope: 'calendar-event-details',
|
)}
|
||||||
recoilScopeId: `${calendarEvent.id}-${fieldName}`,
|
{Fields.slice(2)}
|
||||||
isLabelIdentifier: false,
|
|
||||||
fieldDefinition: formatFieldMetadataItemAsFieldDefinition({
|
|
||||||
field: fieldsByName[fieldName],
|
|
||||||
objectMetadataItem,
|
|
||||||
showLabel: true,
|
|
||||||
labelWidth: 72,
|
|
||||||
}),
|
|
||||||
useUpdateRecord: () => [() => undefined, { loading: false }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RecordInlineCell readonly />
|
|
||||||
</FieldContext.Provider>
|
|
||||||
</StyledPropertyBox>
|
|
||||||
))}
|
|
||||||
</StyledFields>
|
</StyledFields>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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] || []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,6 +14,7 @@ export const RightDrawerCalendarEvent = () => {
|
|||||||
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
|
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
|
||||||
objectRecordId: viewableCalendarEventId ?? '',
|
objectRecordId: viewableCalendarEventId ?? '',
|
||||||
onCompleted: (record) => setRecords([record]),
|
onCompleted: (record) => setRecords([record]),
|
||||||
|
depth: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!calendarEvent) return null;
|
if (!calendarEvent) return null;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
|
||||||
|
|
||||||
// TODO: use backend CalendarEvent type when ready
|
// TODO: use backend CalendarEvent type when ready
|
||||||
export type CalendarEvent = {
|
export type CalendarEvent = {
|
||||||
conferenceLink?: {
|
conferenceLink?: {
|
||||||
@ -14,8 +16,5 @@ export type CalendarEvent = {
|
|||||||
startsAt: string;
|
startsAt: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
visibility: 'METADATA' | 'SHARE_EVERYTHING';
|
visibility: 'METADATA' | 'SHARE_EVERYTHING';
|
||||||
participants?: {
|
calendarEventParticipants?: CalendarEventParticipant[];
|
||||||
displayName: string;
|
|
||||||
workspaceMemberId?: string;
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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';
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,11 +1,7 @@
|
|||||||
import React from 'react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { ParticipantChip } from '@/activities/components/ParticipantChip';
|
||||||
import { EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
|
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';
|
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||||
|
|
||||||
const StyledEmailThreadMessageSender = styled.div`
|
const StyledEmailThreadMessageSender = styled.div`
|
||||||
@ -13,23 +9,6 @@ const StyledEmailThreadMessageSender = styled.div`
|
|||||||
justify-content: space-between;
|
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`
|
const StyledThreadMessageSentAt = styled.div`
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -37,10 +16,6 @@ const StyledThreadMessageSentAt = styled.div`
|
|||||||
font-size: ${({ theme }) => theme.font.size.sm};
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledRecordChip = styled(RecordChip)`
|
|
||||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
|
||||||
`;
|
|
||||||
|
|
||||||
type EmailThreadMessageSenderProps = {
|
type EmailThreadMessageSenderProps = {
|
||||||
sender: EmailThreadMessageParticipant;
|
sender: EmailThreadMessageParticipant;
|
||||||
sentAt: string;
|
sentAt: string;
|
||||||
@ -50,35 +25,9 @@ export const EmailThreadMessageSender = ({
|
|||||||
sender,
|
sender,
|
||||||
sentAt,
|
sentAt,
|
||||||
}: EmailThreadMessageSenderProps) => {
|
}: EmailThreadMessageSenderProps) => {
|
||||||
const { person, workspaceMember } = sender;
|
|
||||||
|
|
||||||
const displayName = getDisplayNameFromParticipant({
|
|
||||||
participant: sender,
|
|
||||||
shouldUseFullName: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const avatarUrl = person?.avatarUrl ?? workspaceMember?.avatarUrl ?? '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledEmailThreadMessageSender>
|
<StyledEmailThreadMessageSender>
|
||||||
<StyledEmailThreadMessageSenderUser>
|
<ParticipantChip participant={sender} variant="bold" />
|
||||||
{person ? (
|
|
||||||
<StyledRecordChip
|
|
||||||
objectNameSingular={CoreObjectNameSingular.Person}
|
|
||||||
record={person}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<StyledAvatar
|
|
||||||
avatarUrl={avatarUrl}
|
|
||||||
type="rounded"
|
|
||||||
placeholder={displayName}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<StyledSenderName>{displayName}</StyledSenderName>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StyledEmailThreadMessageSenderUser>
|
|
||||||
<StyledThreadMessageSentAt>
|
<StyledThreadMessageSentAt>
|
||||||
{beautifyPastDateRelativeToNow(sentAt)}
|
{beautifyPastDateRelativeToNow(sentAt)}
|
||||||
</StyledThreadMessageSentAt>
|
</StyledThreadMessageSentAt>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,6 +2,7 @@ import styled from '@emotion/styled';
|
|||||||
|
|
||||||
const StyledDropdownMenu = styled.div<{
|
const StyledDropdownMenu = styled.div<{
|
||||||
disableBlur?: boolean;
|
disableBlur?: boolean;
|
||||||
|
disableBorder?: boolean;
|
||||||
width?: `${string}px` | `${number}%` | 'auto' | number;
|
width?: `${string}px` | `${number}%` | 'auto' | number;
|
||||||
}>`
|
}>`
|
||||||
backdrop-filter: ${({ disableBlur }) =>
|
backdrop-filter: ${({ disableBlur }) =>
|
||||||
@ -14,7 +15,8 @@ const StyledDropdownMenu = styled.div<{
|
|||||||
? theme.background.primary
|
? theme.background.primary
|
||||||
: theme.background.transparent.secondary};
|
: 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};
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
|
||||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const StyledRightDrawerBody = styled.div`
|
|||||||
height: calc(
|
height: calc(
|
||||||
100vh - ${({ theme }) => theme.spacing(14)} - 1px
|
100vh - ${({ theme }) => theme.spacing(14)} - 1px
|
||||||
); // (-1 for border)
|
); // (-1 for border)
|
||||||
overflow: auto;
|
//overflow: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,28 @@ export const mockedCalendarEvents: CalendarEvent[] = [
|
|||||||
isFullDay: false,
|
isFullDay: false,
|
||||||
startsAt: addDays(new Date().setHours(10, 0), 1).toISOString(),
|
startsAt: addDays(new Date().setHours(10, 0), 1).toISOString(),
|
||||||
visibility: 'METADATA',
|
visibility: 'METADATA',
|
||||||
participants: [
|
calendarEventParticipants: [
|
||||||
{ displayName: 'John Doe', workspaceMemberId: 'john-doe' },
|
{
|
||||||
{ displayName: 'Jane Doe', workspaceMemberId: 'jane-doe' },
|
id: '1',
|
||||||
{ displayName: 'Tim Apple', workspaceMemberId: 'tim-apple' },
|
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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1443,7 +1443,7 @@ const mockedCalendarEventsMetadata = {
|
|||||||
__typename: 'field',
|
__typename: 'field',
|
||||||
id: '07880d2d-4f08-458f-be0b-876402d2a769',
|
id: '07880d2d-4f08-458f-be0b-876402d2a769',
|
||||||
type: 'RELATION',
|
type: 'RELATION',
|
||||||
name: 'eventParticipants',
|
name: 'calendarEventParticipants',
|
||||||
label: 'Event Participants',
|
label: 'Event Participants',
|
||||||
description: 'Event Participants',
|
description: 'Event Participants',
|
||||||
icon: 'IconUserCircle',
|
icon: 'IconUserCircle',
|
||||||
|
|||||||
@ -97,7 +97,7 @@ export const calendarEventStandardFieldIds = {
|
|||||||
conferenceLink: '20202020-35da-43ef-9ca0-e936e9dc237b',
|
conferenceLink: '20202020-35da-43ef-9ca0-e936e9dc237b',
|
||||||
recurringEventExternalId: '20202020-4b96-43d0-8156-4c7a9717635c',
|
recurringEventExternalId: '20202020-4b96-43d0-8156-4c7a9717635c',
|
||||||
calendarChannelEventAssociations: '20202020-bdf8-4572-a2cc-ecbb6bcc3a02',
|
calendarChannelEventAssociations: '20202020-bdf8-4572-a2cc-ecbb6bcc3a02',
|
||||||
eventParticipants: '20202020-e07e-4ccb-88f5-6f3d00458eec',
|
calendarEventParticipants: '20202020-e07e-4ccb-88f5-6f3d00458eec',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const commentStandardFieldIds = {
|
export const commentStandardFieldIds = {
|
||||||
|
|||||||
@ -171,7 +171,7 @@ export class CalendarEventObjectMetadata extends BaseObjectMetadata {
|
|||||||
calendarChannelEventAssociations: CalendarChannelEventAssociationObjectMetadata[];
|
calendarChannelEventAssociations: CalendarChannelEventAssociationObjectMetadata[];
|
||||||
|
|
||||||
@FieldMetadata({
|
@FieldMetadata({
|
||||||
standardId: calendarEventStandardFieldIds.eventParticipants,
|
standardId: calendarEventStandardFieldIds.calendarEventParticipants,
|
||||||
type: FieldMetadataType.RELATION,
|
type: FieldMetadataType.RELATION,
|
||||||
label: 'Event Participants',
|
label: 'Event Participants',
|
||||||
description: 'Event Participants',
|
description: 'Event Participants',
|
||||||
@ -182,5 +182,5 @@ export class CalendarEventObjectMetadata extends BaseObjectMetadata {
|
|||||||
inverseSideTarget: () => CalendarEventParticipantObjectMetadata,
|
inverseSideTarget: () => CalendarEventParticipantObjectMetadata,
|
||||||
onDelete: RelationOnDeleteAction.CASCADE,
|
onDelete: RelationOnDeleteAction.CASCADE,
|
||||||
})
|
})
|
||||||
eventParticipants: CalendarEventParticipantObjectMetadata[];
|
calendarEventParticipants: CalendarEventParticipantObjectMetadata[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ export type CalendarEvent = Omit<
|
|||||||
| 'updatedAt'
|
| 'updatedAt'
|
||||||
| 'calendarChannelEventAssociations'
|
| 'calendarChannelEventAssociations'
|
||||||
| 'calendarEventParticipants'
|
| 'calendarEventParticipants'
|
||||||
| 'eventParticipants'
|
|
||||||
| 'conferenceLink'
|
| 'conferenceLink'
|
||||||
> & {
|
> & {
|
||||||
conferenceLinkLabel: string;
|
conferenceLinkLabel: string;
|
||||||
|
|||||||
@ -113,6 +113,7 @@ export {
|
|||||||
IconPresentation,
|
IconPresentation,
|
||||||
IconProgressCheck,
|
IconProgressCheck,
|
||||||
IconPuzzle,
|
IconPuzzle,
|
||||||
|
IconQuestionMark,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconRelationManyToMany,
|
IconRelationManyToMany,
|
||||||
IconRelationOneToMany,
|
IconRelationOneToMany,
|
||||||
|
|||||||
Reference in New Issue
Block a user