From 41c7cd8cf7a347bd62421b7093414225c611ec81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Tue, 12 Mar 2024 10:58:34 -0300 Subject: [PATCH] feat: add calendar event attendees avatar group (#4384) * feat: add calendar event attendees avatar group Closes #4290 * fix: take CalendarEventAttendee data model into account * feat: add Color code section to Calendar Settings (#4420) Closes #4293 * Fix lint --------- Co-authored-by: Lucas Bordeau --- .../calendar/components/Calendar.tsx | 1 + .../calendar/components/CalendarEventRow.tsx | 37 +++++++++- .../calendar/contexts/CalendarContext.ts | 1 + .../calendar/types/CalendarEvent.ts | 4 + .../src/modules/users/components/Avatar.tsx | 73 +++++++------------ .../modules/users/components/AvatarGroup.tsx | 29 ++++++++ .../__stories__/AvatarGroup.stories.tsx | 68 +++++++++++++++++ .../accounts/SettingsAccountsCalendars.tsx | 64 ++++++++++++++-- .../src/testing/mock-data/calendar.ts | 5 ++ 9 files changed, 227 insertions(+), 55 deletions(-) create mode 100644 packages/twenty-front/src/modules/users/components/AvatarGroup.tsx create mode 100644 packages/twenty-front/src/modules/users/components/__stories__/AvatarGroup.stories.tsx diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx index a02ed3b73..efd85a128 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx @@ -42,6 +42,7 @@ export const Calendar = () => { value={{ calendarEventsByDayTime, currentCalendarEvent, + displayCurrentEventCursor: true, getNextCalendarEvent, updateCurrentCalendarEvent, }} diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx index 8cd98d60e..8523de072 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -1,14 +1,21 @@ +import { useContext } from 'react'; import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { format } from 'date-fns'; +import { useRecoilValue } from 'recoil'; import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor'; +import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { IconArrowRight, IconLock } from '@/ui/display/icon'; import { Card } from '@/ui/layout/card/components/Card'; import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { Avatar } from '@/users/components/Avatar'; +import { AvatarGroup } from '@/users/components/AvatarGroup'; +import { isDefined } from '~/utils/isDefined'; type CalendarEventRowProps = { calendarEvent: CalendarEvent; @@ -92,6 +99,8 @@ export const CalendarEventRow = ({ className, }: CalendarEventRowProps) => { const theme = useTheme(); + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState()); + const { displayCurrentEventCursor = false } = useContext(CalendarContext); const endsAt = getCalendarEventEndDate(calendarEvent); const hasEnded = hasCalendarEventEnded(calendarEvent); @@ -101,9 +110,13 @@ export const CalendarEventRow = ({ : format(calendarEvent.startsAt, 'HH:mm'); const endTimeLabel = calendarEvent.isFullDay ? '' : format(endsAt, 'HH:mm'); + const isCurrentWorkspaceMemberAttending = !!calendarEvent.attendees?.find( + ({ workspaceMemberId }) => workspaceMemberId === currentWorkspaceMember?.id, + ); + return ( - + {startTimeLabel} @@ -127,7 +140,27 @@ export const CalendarEventRow = ({ )} - + {!!calendarEvent.attendees?.length && ( + ( + + ))} + /> + )} + {displayCurrentEventCursor && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts index c5a6e7ee5..ff81adcf6 100644 --- a/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts +++ b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts @@ -5,6 +5,7 @@ import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; type CalendarContextValue = { calendarEventsByDayTime: Record; currentCalendarEvent: CalendarEvent; + displayCurrentEventCursor?: boolean; getNextCalendarEvent: ( calendarEvent: CalendarEvent, ) => CalendarEvent | undefined; diff --git a/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts index db48befed..c12c5f19f 100644 --- a/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts +++ b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts @@ -7,4 +7,8 @@ export type CalendarEvent = { isCanceled?: boolean; title?: string; visibility: 'METADATA' | 'SHARE_EVERYTHING'; + attendees?: { + displayName: string; + workspaceMemberId?: string; + }[]; }; diff --git a/packages/twenty-front/src/modules/users/components/Avatar.tsx b/packages/twenty-front/src/modules/users/components/Avatar.tsx index d28064bb2..8cc37a71b 100644 --- a/packages/twenty-front/src/modules/users/components/Avatar.tsx +++ b/packages/twenty-front/src/modules/users/components/Avatar.tsx @@ -12,7 +12,7 @@ export type AvatarType = 'squared' | 'rounded'; export type AvatarSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; export type AvatarProps = { - avatarUrl: string | null | undefined; + avatarUrl?: string | null; className?: string; size?: AvatarSize; placeholder: string | undefined; @@ -23,6 +23,29 @@ export type AvatarProps = { onClick?: () => void; }; +const propertiesBySize = { + xl: { + fontSize: '16px', + width: '40px', + }, + lg: { + fontSize: '13px', + width: '24px', + }, + md: { + fontSize: '12px', + width: '16px', + }, + sm: { + fontSize: '10px', + width: '14px', + }, + xs: { + fontSize: '8px', + width: '12px', + }, +}; + export const StyledAvatar = styled.div< AvatarProps & { color: string; backgroundColor: string } >` @@ -38,54 +61,12 @@ export const StyledAvatar = styled.div< display: flex; flex-shrink: 0; - font-size: ${({ size }) => { - switch (size) { - case 'xl': - return '16px'; - case 'lg': - return '13px'; - case 'md': - default: - return '12px'; - case 'sm': - return '10px'; - case 'xs': - return '8px'; - } - }}; + font-size: ${({ size = 'md' }) => propertiesBySize[size].fontSize}; font-weight: ${({ theme }) => theme.font.weight.medium}; - height: ${({ size }) => { - switch (size) { - case 'xl': - return '40px'; - case 'lg': - return '24px'; - case 'md': - default: - return '16px'; - case 'sm': - return '14px'; - case 'xs': - return '12px'; - } - }}; + height: ${({ size = 'md' }) => propertiesBySize[size].width}; justify-content: center; - width: ${({ size }) => { - switch (size) { - case 'xl': - return '40px'; - case 'lg': - return '24px'; - case 'md': - default: - return '16px'; - case 'sm': - return '14px'; - case 'xs': - return '12px'; - } - }}; + width: ${({ size = 'md' }) => propertiesBySize[size].width}; &:hover { box-shadow: ${({ theme, onClick }) => diff --git a/packages/twenty-front/src/modules/users/components/AvatarGroup.tsx b/packages/twenty-front/src/modules/users/components/AvatarGroup.tsx new file mode 100644 index 000000000..6bf437b87 --- /dev/null +++ b/packages/twenty-front/src/modules/users/components/AvatarGroup.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; +import styled from '@emotion/styled'; + +export type AvatarGroupProps = { + avatars: ReactNode[]; +}; + +const StyledContainer = styled.div` + align-items: center; + display: flex; +`; + +const StyledItemContainer = styled.div` + margin-right: -3px; +`; + +const MAX_AVATARS_NB = 4; + +export const AvatarGroup = ({ avatars }: AvatarGroupProps) => { + if (!avatars.length) return null; + + return ( + + {avatars.slice(0, MAX_AVATARS_NB).map((avatar, index) => ( + {avatar} + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/users/components/__stories__/AvatarGroup.stories.tsx b/packages/twenty-front/src/modules/users/components/__stories__/AvatarGroup.stories.tsx new file mode 100644 index 000000000..0a6676cdc --- /dev/null +++ b/packages/twenty-front/src/modules/users/components/__stories__/AvatarGroup.stories.tsx @@ -0,0 +1,68 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { + Avatar, + AvatarProps, + AvatarSize, + AvatarType, +} from '@/users/components/Avatar'; +import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; +import { avatarUrl } from '~/testing/mock-data/users'; + +import { AvatarGroup, AvatarGroupProps } from '../AvatarGroup'; + +const makeAvatar = (userName: string, props: Partial = {}) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + +); + +const getAvatars = (commonProps: Partial = {}) => [ + makeAvatar('Matthew', { avatarUrl, ...commonProps }), + makeAvatar('Sophie', commonProps), + makeAvatar('Jane', commonProps), + makeAvatar('Lily', commonProps), + makeAvatar('John', commonProps), +]; + +const meta: Meta< + AvatarGroupProps & AvatarProps & { numberOfAvatars?: number } +> = { + title: 'Modules/Users/AvatarGroup', + component: AvatarGroup, + render: ({ numberOfAvatars = 5, ...args }) => ( + + ), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ComponentDecorator], +}; + +export const Catalog: Story = { + parameters: { + catalog: { + dimensions: [ + { + name: 'number of avatars', + values: [1, 2, 3, 4, 5], + props: (numberOfAvatars: number) => ({ numberOfAvatars }), + }, + { + name: 'types', + values: ['rounded', 'squared'] as AvatarType[], + props: (type: AvatarType) => ({ type }), + }, + { + name: 'sizes', + values: ['xs', 'sm', 'md', 'lg', 'xl'] as AvatarSize[], + props: (size: AvatarSize) => ({ size }), + }, + ], + }, + }, + decorators: [CatalogDecorator], +}; diff --git a/packages/twenty-front/src/pages/settings/accounts/SettingsAccountsCalendars.tsx b/packages/twenty-front/src/pages/settings/accounts/SettingsAccountsCalendars.tsx index e65204f01..9d4a537b8 100644 --- a/packages/twenty-front/src/pages/settings/accounts/SettingsAccountsCalendars.tsx +++ b/packages/twenty-front/src/pages/settings/accounts/SettingsAccountsCalendars.tsx @@ -1,6 +1,10 @@ +import { addMinutes, endOfDay, min, startOfDay } from 'date-fns'; import { useRecoilValue } from 'recoil'; import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; +import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard'; +import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -27,6 +31,32 @@ export const SettingsAccountsCalendars = () => { }, }); + const exampleStartDate = new Date(); + const exampleEndDate = min([ + addMinutes(exampleStartDate, 30), + endOfDay(exampleStartDate), + ]); + const exampleDayTime = startOfDay(exampleStartDate).getTime(); + const exampleCalendarEvent: CalendarEvent = { + id: '', + attendees: [ + { + displayName: currentWorkspaceMember + ? [ + currentWorkspaceMember.name.firstName, + currentWorkspaceMember.name.lastName, + ].join(' ') + : '', + workspaceMemberId: currentWorkspaceMember?.id ?? '', + }, + ], + endsAt: exampleEndDate, + isFullDay: false, + startsAt: exampleStartDate, + title: 'Onboarding call', + visibility: 'SHARE_EVERYTHING', + }; + return ( @@ -48,13 +78,33 @@ export const SettingsAccountsCalendars = () => { {/* TODO: retrieve connected accounts data from back-end when the Calendar feature is ready. */} {!!mockedConnectedAccounts.length && ( -
- - -
+ <> +
+ + +
+
+ + undefined, + updateCurrentCalendarEvent: () => {}, + }} + > + + +
+ )}
diff --git a/packages/twenty-front/src/testing/mock-data/calendar.ts b/packages/twenty-front/src/testing/mock-data/calendar.ts index 7435ef582..1f1d8c760 100644 --- a/packages/twenty-front/src/testing/mock-data/calendar.ts +++ b/packages/twenty-front/src/testing/mock-data/calendar.ts @@ -9,6 +9,11 @@ export const mockedCalendarEvents: CalendarEvent[] = [ isFullDay: false, startsAt: addDays(new Date().setHours(10, 0), 1), visibility: 'METADATA', + attendees: [ + { displayName: 'John Doe', workspaceMemberId: 'john-doe' }, + { displayName: 'Jane Doe', workspaceMemberId: 'jane-doe' }, + { displayName: 'Tim Apple', workspaceMemberId: 'tim-apple' }, + ], }, { id: '19b32878-a950-4968-9e3b-ce5da514ea41',