diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index a6bf9f218..dd65834c4 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -34,6 +34,7 @@ import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; import { PageTitle } from '@/ui/utilities/page-title/PageTitle'; import { UserProvider } from '@/users/components/UserProvider'; import { UserProviderEffect } from '@/users/components/UserProviderEffect'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect'; import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect'; import { PageChangeEffect } from '~/effect-components/PageChangeEffect'; @@ -74,18 +75,17 @@ import { SettingsIntegrationEditDatabaseConnection } from '~/pages/settings/inte import { SettingsIntegrationNewDatabaseConnection } from '~/pages/settings/integrations/SettingsIntegrationNewDatabaseConnection'; import { SettingsIntegrations } from '~/pages/settings/integrations/SettingsIntegrations'; import { SettingsIntegrationShowDatabaseConnection } from '~/pages/settings/integrations/SettingsIntegrationShowDatabaseConnection'; +import { SettingsAppearance } from '~/pages/settings/profile/appearance/components/SettingsAppearance'; import { Releases } from '~/pages/settings/Releases'; -import { SettingsAppearance } from '~/pages/settings/SettingsAppearance'; +import { SettingsServerlessFunctionDetailWrapper } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper'; +import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions'; +import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew'; import { SettingsBilling } from '~/pages/settings/SettingsBilling'; import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace'; import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers'; import { Tasks } from '~/pages/tasks/Tasks'; import { getPageTitleFromPath } from '~/utils/title-utils'; -import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions'; -import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; -import { SettingsServerlessFunctionDetailWrapper } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper'; const ProvidersThatNeedRouterContext = () => { const { pathname } = useLocation(); diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 97d333280..9a44597c4 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1015,11 +1015,29 @@ export type WorkspaceMember = { __typename?: 'WorkspaceMember'; avatarUrl?: Maybe; colorScheme: Scalars['String']; + dateFormat?: Maybe; id: Scalars['UUID']; - locale: Scalars['String']; + locale?: Maybe; name: FullName; + timeFormat?: Maybe; + timeZone?: Maybe; }; +/** Date format as Month first, Day first, Year first or system as default */ +export enum WorkspaceMemberDateFormatEnum { + DayFirst = 'DAY_FIRST', + MonthFirst = 'MONTH_FIRST', + System = 'SYSTEM', + YearFirst = 'YEAR_FIRST' +} + +/** Time time as Military, Standard or system as default */ +export enum WorkspaceMemberTimeFormatEnum { + Hour_12 = 'HOUR_12', + Hour_24 = 'HOUR_24', + System = 'SYSTEM' +} + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']; @@ -1242,7 +1260,7 @@ export type ImpersonateMutationVariables = Exact<{ }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1274,7 +1292,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1335,7 +1353,7 @@ export type GetAisqlQueryQueryVariables = Exact<{ export type GetAisqlQueryQuery = { __typename?: 'Query', getAISQLQuery: { __typename?: 'AISQLQueryResult', sqlQuery: string, sqlQueryResult?: string | null, queryFailedErrorMessage?: string | null } }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1352,7 +1370,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; export type AddUserToWorkspaceMutationVariables = Exact<{ inviteHash: Scalars['String']; @@ -1506,6 +1524,9 @@ export const UserQueryFragmentFragmentDoc = gql` colorScheme avatarUrl locale + timeZone + dateFormat + timeFormat } defaultWorkspace { id diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx index 02d11fa87..9d0211cf1 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx @@ -1,4 +1,3 @@ -import { useContext, useMemo, useState } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { @@ -8,13 +7,14 @@ import { startOfMonth, } from 'date-fns'; import { AnimatePresence, motion } from 'framer-motion'; +import { useContext, useMemo, useState } from 'react'; import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; import { hasCalendarEventStarted } from '@/activities/calendar/utils/hasCalendarEventStarted'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { TimelineCalendarEvent } from '~/generated/graphql'; type CalendarCurrentEventCursorProps = { calendarEvent: TimelineCalendarEvent; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx index c4ff26571..ca656f3b5 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx @@ -5,7 +5,7 @@ import { differenceInSeconds, endOfDay, format } from 'date-fns'; import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow'; import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { TimelineCalendarEvent } from '~/generated/graphql'; type CalendarDayCardContentProps = { calendarEvents: TimelineCalendarEvent[]; 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 bdaf54830..a246ff432 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -3,7 +3,13 @@ import styled from '@emotion/styled'; import { format } from 'date-fns'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; -import { Avatar, AvatarGroup, IconArrowRight, IconLock } from 'twenty-ui'; +import { + Avatar, + AvatarGroup, + IconArrowRight, + IconLock, + isDefined, +} from 'twenty-ui'; import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor'; import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; @@ -14,9 +20,10 @@ import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEv import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { Card } from '@/ui/layout/card/components/Card'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; -import { CalendarChannelVisibility } from '~/generated/graphql'; -import { isDefined } from '~/utils/isDefined'; +import { + CalendarChannelVisibility, + TimelineCalendarEvent, +} from '~/generated-metadata/graphql'; type CalendarEventRowProps = { calendarEvent: TimelineCalendarEvent; 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 423b48f82..e012d90f2 100644 --- a/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts +++ b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { TimelineCalendarEvent } from '~/generated/graphql'; type CalendarContextValue = { calendarEventsByDayTime: Record; diff --git a/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx b/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx index d25294901..c335d8a8a 100644 --- a/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx @@ -1,41 +1,87 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents'; -import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; -import { CalendarChannelVisibility } from '~/generated/graphql'; +import { + CalendarChannelVisibility, + TimelineCalendarEvent, +} from '~/generated/graphql'; -const calendarEvents: CalendarEvent[] = [ +const calendarEvents: TimelineCalendarEvent[] = [ { id: '1234', - externalCreatedAt: '2024-02-17T20:45:43.854Z', isFullDay: false, startsAt: '2024-02-17T21:45:27.822Z', visibility: CalendarChannelVisibility.Metadata, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, { id: '5678', - externalCreatedAt: '2024-02-18T19:43:37.854Z', isFullDay: false, startsAt: '2024-02-18T21:43:27.754Z', visibility: CalendarChannelVisibility.ShareEverything, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, { id: '91011', - externalCreatedAt: '2024-02-19T20:45:20.854Z', isFullDay: true, startsAt: '2024-02-19T22:05:27.653Z', visibility: CalendarChannelVisibility.Metadata, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, { id: '121314', - externalCreatedAt: '2024-02-20T20:45:12.854Z', isFullDay: true, startsAt: '2024-02-20T23:15:23.150Z', visibility: CalendarChannelVisibility.ShareEverything, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, ]; diff --git a/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts index 15baaf0b2..751122499 100644 --- a/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts +++ b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts @@ -1,21 +1,14 @@ -import { useMemo, useState } from 'react'; import { getYear, isThisMonth, startOfDay, startOfMonth } from 'date-fns'; +import { useMemo, useState } from 'react'; -import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { findUpcomingCalendarEvent } from '@/activities/calendar/utils/findUpcomingCalendarEvent'; import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; +import { TimelineCalendarEvent } from '~/generated/graphql'; import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; import { isDefined } from '~/utils/isDefined'; import { sortDesc } from '~/utils/sort'; -type CalendarEventGeneric = Omit< - CalendarEvent, - 'participants' | 'externalCreatedAt' | '__typename' ->; - -export const useCalendarEvents = ( - calendarEvents: T[], -) => { +export const useCalendarEvents = (calendarEvents: TimelineCalendarEvent[]) => { const calendarEventsByDayTime = groupArrayItemsBy( calendarEvents, (calendarEvent) => @@ -36,14 +29,14 @@ export const useCalendarEvents = ( const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear); - const getPreviousCalendarEvent = (calendarEvent: T) => { + const getPreviousCalendarEvent = (calendarEvent: TimelineCalendarEvent) => { const calendarEventIndex = calendarEvents.indexOf(calendarEvent); return calendarEventIndex < calendarEvents.length - 1 ? calendarEvents[calendarEventIndex + 1] : undefined; }; - const getNextCalendarEvent = (calendarEvent: T) => { + const getNextCalendarEvent = (calendarEvent: TimelineCalendarEvent) => { const calendarEventIndex = calendarEvents.indexOf(calendarEvent); return calendarEventIndex > 0 ? calendarEvents[calendarEventIndex - 1] diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx index 8d572594f..c345d81f4 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx @@ -6,6 +6,8 @@ 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, @@ -107,6 +109,8 @@ export const EventCardCalendarEvent = ({ const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer(); + const { timeZone } = useContext(UserContext); + if (isDefined(error)) { const shouldHideMessageContent = error.graphQLErrors.some( (e) => e.extensions?.code === 'FORBIDDEN', @@ -138,12 +142,14 @@ export const EventCardCalendarEvent = ({ throw new Error("Can't render a calendarEvent without a start date"); } - const startsAtMonth = formatToHumanReadableMonth(startsAtDate); + const startsAtMonth = formatToHumanReadableMonth(startsAtDate, timeZone); - const startsAtDay = formatToHumanReadableDay(startsAtDate); + const startsAtDay = formatToHumanReadableDay(startsAtDate, timeZone); - const startsAtHour = formatToHumanReadableTime(startsAtDate); - const endsAtHour = endsAtDate ? formatToHumanReadableTime(endsAtDate) : null; + const startsAtHour = formatToHumanReadableTime(startsAtDate, timeZone); + const endsAtHour = endsAtDate + ? formatToHumanReadableTime(endsAtDate, timeZone) + : null; return ( { const goToRecoilSnapshot = useGotoRecoilSnapshot(); + const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); + const handleChallenge = useCallback( async (email: string, password: string, captchaToken?: string) => { const challengeResult = await challenge({ @@ -96,17 +104,42 @@ export const useAuth = () => { setTokenPair(verifyResult.data?.verify.tokens); const user = verifyResult.data?.verify.user; + let workspaceMember = null; + setCurrentUser(user); + if (isDefined(user.workspaceMember)) { workspaceMember = { ...user.workspaceMember, colorScheme: user.workspaceMember?.colorScheme as ColorScheme, }; + setCurrentWorkspaceMember(workspaceMember); + + // TODO: factorize with UserProviderEffect + setDateTimeFormat({ + timeZone: + workspaceMember.timeZone && workspaceMember.timeZone !== 'system' + ? workspaceMember.timeZone + : detectTimeZone(), + dateFormat: isDefined(user.workspaceMember.dateFormat) + ? getDateFormatFromWorkspaceDateFormat( + user.workspaceMember.dateFormat, + ) + : detectDateFormat(), + timeFormat: isDefined(user.workspaceMember.timeFormat) + ? getTimeFormatFromWorkspaceTimeFormat( + user.workspaceMember.timeFormat, + ) + : detectTimeFormat(), + }); } + const workspace = user.defaultWorkspace ?? null; + setCurrentWorkspace(workspace); + if (isDefined(verifyResult.data?.verify.user.workspaces)) { const validWorkspaces = verifyResult.data?.verify.user.workspaces .filter( @@ -117,6 +150,7 @@ export const useAuth = () => { setWorkspaces(validWorkspaces); } + return { user, workspaceMember, @@ -131,6 +165,7 @@ export const useAuth = () => { setCurrentWorkspaceMember, setCurrentWorkspace, setWorkspaces, + setDateTimeFormat, ], ); diff --git a/packages/twenty-front/src/modules/localization/constants/DateFormat.ts b/packages/twenty-front/src/modules/localization/constants/DateFormat.ts new file mode 100644 index 000000000..503e28ce3 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/constants/DateFormat.ts @@ -0,0 +1,6 @@ +export enum DateFormat { + SYSTEM = 'SYSTEM', + MONTH_FIRST = 'MMM d, yyyy', // US + DAY_FIRST = 'd MMM, yyyy', // UK + YEAR_FIRST = 'yyyy MMM d', +} diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/IanaTimeZones.ts b/packages/twenty-front/src/modules/localization/constants/IanaTimeZones.ts similarity index 100% rename from packages/twenty-front/src/modules/settings/accounts/constants/IanaTimeZones.ts rename to packages/twenty-front/src/modules/localization/constants/IanaTimeZones.ts diff --git a/packages/twenty-front/src/modules/localization/constants/TimeFormat.ts b/packages/twenty-front/src/modules/localization/constants/TimeFormat.ts new file mode 100644 index 000000000..a169872b3 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/constants/TimeFormat.ts @@ -0,0 +1,5 @@ +export enum TimeFormat { + SYSTEM = 'SYSTEM', + HOUR_24 = 'HH:mm', + HOUR_12 = 'h:mm aa', +} diff --git a/packages/twenty-front/src/modules/localization/states/dateTimeFormatState.ts b/packages/twenty-front/src/modules/localization/states/dateTimeFormatState.ts new file mode 100644 index 000000000..2392151e3 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/states/dateTimeFormatState.ts @@ -0,0 +1,17 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { createState } from 'twenty-ui'; + +export const dateTimeFormatState = createState<{ + timeZone: string; + dateFormat: DateFormat; + timeFormat: TimeFormat; +}>({ + key: 'dateTimeFormatState', + defaultValue: { + timeZone: detectTimeZone(), + dateFormat: DateFormat.MONTH_FIRST, + timeFormat: TimeFormat['HOUR_24'], + }, +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/detectDateFormat.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/detectDateFormat.test.ts new file mode 100644 index 000000000..2b641f302 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/detectDateFormat.test.ts @@ -0,0 +1,69 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; + +describe('detectDateFormat', () => { + it('should return DateFormat.MONTH_FIRST if the detected format starts with month', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'month', value: '01' }, + { type: 'day', value: '01' }, + { type: 'year', value: '2022' }, + ], + supportedLocalesOf: () => [], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.MONTH_FIRST); + }); + + it('should return DateFormat.DAY_FIRST if the detected format starts with day', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'day', value: '01' }, + { type: 'month', value: '01' }, + { type: 'year', value: '2022' }, + ], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.DAY_FIRST); + }); + + it('should return DateFormat.YEAR_FIRST if the detected format starts with year', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'year', value: '2022' }, + { type: 'month', value: '01' }, + { type: 'day', value: '01' }, + ], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.YEAR_FIRST); + }); + + it('should return DateFormat.MONTH_FIRST by default if the detected format does not match any specific order', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'hour', value: '12' }, + { type: 'minute', value: '00' }, + { type: 'second', value: '00' }, + ], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.MONTH_FIRST); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/detectTimeFormat.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/detectTimeFormat.test.ts new file mode 100644 index 000000000..643349578 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/detectTimeFormat.test.ts @@ -0,0 +1,30 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; + +describe('detectTimeFormat', () => { + it('should return TimeFormat.HOUR_12 if the hour format is 12-hour', () => { + // Mock the resolvedOptions method to return hour12 as true + const mockResolvedOptions = jest.fn(() => ({ hour12: true })); + Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({ + resolvedOptions: mockResolvedOptions, + })) as any; + + const result = detectTimeFormat(); + + expect(result).toBe(TimeFormat.HOUR_12); + expect(mockResolvedOptions).toHaveBeenCalled(); + }); + + it('should return TimeFormat.HOUR_24 if the hour format is 24-hour', () => { + // Mock the resolvedOptions method to return hour12 as false + const mockResolvedOptions = jest.fn(() => ({ hour12: false })); + Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({ + resolvedOptions: mockResolvedOptions, + })) as any; + + const result = detectTimeFormat(); + + expect(result).toBe(TimeFormat.HOUR_24); + expect(mockResolvedOptions).toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/formatTimeZoneLabel.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/formatTimeZoneLabel.test.ts new file mode 100644 index 000000000..bec60cc4e --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/formatTimeZoneLabel.test.ts @@ -0,0 +1,21 @@ +import { formatTimeZoneLabel } from '@/localization/utils/formatTimeZoneLabel'; + +describe('formatTimeZoneLabel', () => { + it('should format the time zone label correctly when location is included in the label', () => { + const ianaTimeZone = 'Europe/Paris'; + const expectedLabel = '(GMT+02:00) Central European Summer Time - Paris'; + + const formattedLabel = formatTimeZoneLabel(ianaTimeZone); + + expect(formattedLabel).toEqual(expectedLabel); + }); + + it('should format the time zone label correctly when location is not included in the label', () => { + const ianaTimeZone = 'America/New_York'; + const expectedLabel = '(GMT-04:00) Eastern Daylight Time - New York'; + + const formattedLabel = formatTimeZoneLabel(ianaTimeZone); + + expect(formattedLabel).toEqual(expectedLabel); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/detectDateFormat.ts b/packages/twenty-front/src/modules/localization/utils/detectDateFormat.ts new file mode 100644 index 000000000..b503ef826 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/detectDateFormat.ts @@ -0,0 +1,17 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; + +export const detectDateFormat = (): DateFormat => { + const date = new Date(); + const formatter = new Intl.DateTimeFormat(navigator.language); + const parts = formatter.formatToParts(date); + + const partOrder = parts + .filter((part) => ['year', 'month', 'day'].includes(part.type)) + .map((part) => part.type); + + if (partOrder[0] === 'month') return DateFormat.MONTH_FIRST; + if (partOrder[0] === 'day') return DateFormat.DAY_FIRST; + if (partOrder[0] === 'year') return DateFormat.YEAR_FIRST; + + return DateFormat.MONTH_FIRST; +}; diff --git a/packages/twenty-front/src/modules/localization/utils/detectTimeFormat.ts b/packages/twenty-front/src/modules/localization/utils/detectTimeFormat.ts new file mode 100644 index 000000000..0580333af --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/detectTimeFormat.ts @@ -0,0 +1,10 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { isDefined } from '~/utils/isDefined'; + +export const detectTimeFormat = () => { + const isHour12 = Intl.DateTimeFormat(navigator.language, { + hour: 'numeric', + }).resolvedOptions().hour12; + if (isDefined(isHour12) && isHour12) return TimeFormat.HOUR_12; + return TimeFormat.HOUR_24; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/utils/detectTimeZone.ts b/packages/twenty-front/src/modules/localization/utils/detectTimeZone.ts similarity index 100% rename from packages/twenty-front/src/modules/settings/accounts/utils/detectTimeZone.ts rename to packages/twenty-front/src/modules/localization/utils/detectTimeZone.ts diff --git a/packages/twenty-front/src/modules/settings/accounts/utils/findAvailableTimeZoneOption.ts b/packages/twenty-front/src/modules/localization/utils/findAvailableTimeZoneOption.ts similarity index 84% rename from packages/twenty-front/src/modules/settings/accounts/utils/findAvailableTimeZoneOption.ts rename to packages/twenty-front/src/modules/localization/utils/findAvailableTimeZoneOption.ts index ab3b6cb5e..a1ce9a26b 100644 --- a/packages/twenty-front/src/modules/settings/accounts/utils/findAvailableTimeZoneOption.ts +++ b/packages/twenty-front/src/modules/localization/utils/findAvailableTimeZoneOption.ts @@ -1,5 +1,5 @@ +import { formatTimeZoneLabel } from '@/localization/utils/formatTimeZoneLabel'; import { AVAILABLE_TIME_ZONE_OPTIONS_BY_LABEL } from '@/settings/accounts/constants/AvailableTimezoneOptionsByLabel'; -import { formatTimeZoneLabel } from '@/settings/accounts/utils/formatTimeZoneLabel'; /** * Finds the matching available IANA time zone select option from a given IANA time zone. diff --git a/packages/twenty-front/src/modules/localization/utils/formatDataTime.ts b/packages/twenty-front/src/modules/localization/utils/formatDataTime.ts new file mode 100644 index 000000000..fd4aa504c --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatDataTime.ts @@ -0,0 +1,15 @@ +import { formatInTimeZone } from 'date-fns-tz'; +import { parseDate } from '~/utils/date-utils'; + +export const formatDatetime = ( + date: Date | string, + timeZone: string, + dateFormat: string, + timeFormat: string, +) => { + return formatInTimeZone( + parseDate(date).toJSDate(), + timeZone, + `${dateFormat} ${timeFormat}`, + ); +}; diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts new file mode 100644 index 000000000..9cbbf415e --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts @@ -0,0 +1,10 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { formatInTimeZone } from 'date-fns-tz'; + +export const formatDateISOStringToDate = ( + date: string, + timeZone: string, + dateFormat: DateFormat, +) => { + return formatInTimeZone(new Date(date), timeZone, `${dateFormat}`); +}; diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts new file mode 100644 index 000000000..ad8389251 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts @@ -0,0 +1,16 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { formatInTimeZone } from 'date-fns-tz'; + +export const formatDateISOStringToDateTime = ( + date: string, + timeZone: string, + dateFormat: DateFormat, + timeFormat: TimeFormat, +) => { + return formatInTimeZone( + new Date(date), + timeZone, + `${dateFormat} ${timeFormat}`, + ); +}; diff --git a/packages/twenty-front/src/modules/localization/utils/formatTimeZoneLabel.ts b/packages/twenty-front/src/modules/localization/utils/formatTimeZoneLabel.ts new file mode 100644 index 000000000..f9640e2c2 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatTimeZoneLabel.ts @@ -0,0 +1,29 @@ +import { formatInTimeZone } from 'date-fns-tz'; +import defaultLocale from 'date-fns/locale/en-US'; + +/** + * Formats a IANA time zone to a select option label. + * @param ianaTimeZone IANA time zone + * @returns Formatted label + * @example 'Europe/Paris' => '(GMT+01:00) Central European Time - Paris' + */ +export const formatTimeZoneLabel = (ianaTimeZone: string) => { + const timeZoneWithGmtOffset = formatInTimeZone( + Date.now(), + ianaTimeZone, + `(OOOO) zzzz`, + { locale: defaultLocale }, + ); + const ianaTimeZoneParts = ianaTimeZone.split('/'); + const location = + ianaTimeZoneParts.length > 1 + ? ianaTimeZoneParts.slice(-1)[0].replaceAll('_', ' ') + : undefined; + + const timeZoneLabel = + !location || timeZoneWithGmtOffset.includes(location) + ? timeZoneWithGmtOffset + : [timeZoneWithGmtOffset, location].join(' - '); + + return timeZoneLabel; +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getDateFormatFromWorkspaceDateFormat.ts b/packages/twenty-front/src/modules/localization/utils/getDateFormatFromWorkspaceDateFormat.ts new file mode 100644 index 000000000..f32bdbb93 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getDateFormatFromWorkspaceDateFormat.ts @@ -0,0 +1,20 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; +import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql'; + +export const getDateFormatFromWorkspaceDateFormat = ( + workspaceDateFormat: WorkspaceMemberDateFormatEnum, +) => { + switch (workspaceDateFormat) { + case WorkspaceMemberDateFormatEnum.System: + return detectDateFormat(); + case WorkspaceMemberDateFormatEnum.MonthFirst: + return DateFormat.MONTH_FIRST; + case WorkspaceMemberDateFormatEnum.DayFirst: + return DateFormat.DAY_FIRST; + case WorkspaceMemberDateFormatEnum.YearFirst: + return DateFormat.YEAR_FIRST; + default: + return DateFormat.MONTH_FIRST; + } +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getTimeFormatFromWorkspaceTimeFormat.ts b/packages/twenty-front/src/modules/localization/utils/getTimeFormatFromWorkspaceTimeFormat.ts new file mode 100644 index 000000000..f6aebb437 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getTimeFormatFromWorkspaceTimeFormat.ts @@ -0,0 +1,18 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; +import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql'; + +export const getTimeFormatFromWorkspaceTimeFormat = ( + workspaceTimeFormat: WorkspaceMemberTimeFormatEnum, +) => { + switch (workspaceTimeFormat) { + case WorkspaceMemberTimeFormatEnum.System: + return detectTimeFormat(); + case WorkspaceMemberTimeFormatEnum.Hour_24: + return TimeFormat.HOUR_24; + case WorkspaceMemberTimeFormatEnum.Hour_12: + return TimeFormat.HOUR_12; + default: + return TimeFormat.HOUR_24; + } +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getWorkspaceDateFormatFromDateFormat.ts b/packages/twenty-front/src/modules/localization/utils/getWorkspaceDateFormatFromDateFormat.ts new file mode 100644 index 000000000..251a57582 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getWorkspaceDateFormatFromDateFormat.ts @@ -0,0 +1,19 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql'; + +export const getWorkspaceDateFormatFromDateFormat = ( + dateFormat: DateFormat, +) => { + switch (dateFormat) { + case DateFormat.SYSTEM: + return WorkspaceMemberDateFormatEnum.System; + case DateFormat.MONTH_FIRST: + return WorkspaceMemberDateFormatEnum.MonthFirst; + case DateFormat.DAY_FIRST: + return WorkspaceMemberDateFormatEnum.DayFirst; + case DateFormat.YEAR_FIRST: + return WorkspaceMemberDateFormatEnum.YearFirst; + default: + return WorkspaceMemberDateFormatEnum.MonthFirst; + } +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getWorkspaceTimeFormatFromTimeFormat.ts b/packages/twenty-front/src/modules/localization/utils/getWorkspaceTimeFormatFromTimeFormat.ts new file mode 100644 index 000000000..58a563f96 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getWorkspaceTimeFormatFromTimeFormat.ts @@ -0,0 +1,17 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql'; + +export const getWorkspaceTimeFormatFromTimeFormat = ( + timeFormat: TimeFormat, +) => { + switch (timeFormat) { + case TimeFormat.SYSTEM: + return WorkspaceMemberTimeFormatEnum.System; + case TimeFormat.HOUR_24: + return WorkspaceMemberTimeFormatEnum.Hour_24; + case TimeFormat.HOUR_12: + return WorkspaceMemberTimeFormatEnum.Hour_12; + default: + return WorkspaceMemberTimeFormatEnum.Hour_24; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx index b3ab3a732..35e9c6562 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx @@ -1,7 +1,10 @@ import { Meta, StoryObj } from '@storybook/react'; import { ComponentDecorator } from 'twenty-ui'; +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; import { DateFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateFieldDisplay'; +import { UserContext } from '@/users/contexts/UserContext'; import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; @@ -12,6 +15,19 @@ const meta: Meta = { MemoryRouterDecorator, getFieldDecorator('person', 'createdAt'), ComponentDecorator, + (Story) => { + return ( + + + + ); + }, ], component: DateFieldDisplay, args: {}, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx index f974e587a..079b84520 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx @@ -1,7 +1,10 @@ import { Meta, StoryObj } from '@storybook/react'; import { ComponentDecorator } from 'twenty-ui'; +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; import { DateTimeFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay'; +import { UserContext } from '@/users/contexts/UserContext'; import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; @@ -12,6 +15,19 @@ const meta: Meta = { MemoryRouterDecorator, getFieldDecorator('person', 'createdAt'), ComponentDecorator, + (Story) => { + return ( + + + + ); + }, ], component: DateTimeFieldDisplay, args: {}, diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx index b1e7c5134..4b1a873a1 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx @@ -10,7 +10,7 @@ import { H2Title } from 'twenty-ui'; import { CalendarChannelVisibility, TimelineCalendarEvent, -} from '~/generated-metadata/graphql'; +} from '~/generated/graphql'; const StyledGeneralContainer = styled.div` display: flex; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx index 278af533e..7555556c9 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx @@ -1,6 +1,6 @@ import { formatInTimeZone } from 'date-fns-tz'; -import { DateFormat } from '@/settings/accounts/constants/DateFormat'; +import { DateFormat } from '@/localization/constants/DateFormat'; import { Select } from '@/ui/input/components/Select'; type SettingsAccountsCalendarDateFormatSelectProps = { @@ -21,12 +21,12 @@ export const SettingsAccountsCalendarDateFormatSelect = ({ value={value} options={[ { - label: formatInTimeZone(Date.now(), timeZone, DateFormat.US), - value: DateFormat.US, + label: formatInTimeZone(Date.now(), timeZone, DateFormat.MONTH_FIRST), + value: DateFormat.MONTH_FIRST, }, { - label: formatInTimeZone(Date.now(), timeZone, DateFormat.UK), - value: DateFormat.UK, + label: formatInTimeZone(Date.now(), timeZone, DateFormat.DAY_FIRST), + value: DateFormat.DAY_FIRST, }, ]} onChange={onChange} diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx index 4f01ed496..05231f0cf 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx @@ -1,12 +1,13 @@ -import { useState } from 'react'; import styled from '@emotion/styled'; +import { useState } from 'react'; import { SettingsAccountsCalendarDateFormatSelect } from '@/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect'; import { SettingsAccountsCalendarTimeFormatSelect } from '@/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect'; import { SettingsAccountsCalendarTimeZoneSelect } from '@/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect'; -import { DateFormat } from '@/settings/accounts/constants/DateFormat'; -import { TimeFormat } from '@/settings/accounts/constants/TimeFormat'; -import { detectTimeZone } from '@/settings/accounts/utils/detectTimeZone'; + +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; const StyledContainer = styled.div` display: flex; @@ -19,10 +20,10 @@ export const SettingsAccountsCalendarDisplaySettings = () => { const [timeZone, setTimeZone] = useState(detectTimeZone()); // TODO: use the user's saved date format. - const [dateFormat, setDateFormat] = useState(DateFormat.US); + const [dateFormat, setDateFormat] = useState(DateFormat.MONTH_FIRST); // TODO: use the user's saved time format. - const [timeFormat, setTimeFormat] = useState(TimeFormat['24h']); + const [timeFormat, setTimeFormat] = useState(TimeFormat['HOUR_24']); return ( diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx index cab95257d..bb59eb95f 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx @@ -1,6 +1,6 @@ import { formatInTimeZone } from 'date-fns-tz'; -import { TimeFormat } from '@/settings/accounts/constants/TimeFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; import { Select } from '@/ui/input/components/Select'; type SettingsAccountsCalendarTimeFormatSelectProps = { @@ -24,17 +24,17 @@ export const SettingsAccountsCalendarTimeFormatSelect = ({ label: `24h (${formatInTimeZone( Date.now(), timeZone, - TimeFormat['24h'], + TimeFormat.HOUR_24, )})`, - value: TimeFormat['24h'], + value: TimeFormat.HOUR_24, }, { label: `12h (${formatInTimeZone( Date.now(), timeZone, - TimeFormat['12h'], + TimeFormat.HOUR_12, )})`, - value: TimeFormat['12h'], + value: TimeFormat.HOUR_12, }, ]} onChange={onChange} diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx index 81fba15c6..db3155a4e 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx @@ -1,6 +1,7 @@ +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption'; import { AVAILABLE_TIMEZONE_OPTIONS } from '@/settings/accounts/constants/AvailableTimezoneOptions'; -import { detectTimeZone } from '@/settings/accounts/utils/detectTimeZone'; -import { findAvailableTimeZoneOption } from '@/settings/accounts/utils/findAvailableTimeZoneOption'; + import { Select } from '@/ui/input/components/Select'; type SettingsAccountsCalendarTimeZoneSelectProps = { diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts b/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts index 391352a93..0b78523b4 100644 --- a/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts +++ b/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @nx/workspace-max-consts-per-file */ -import { IANA_TIME_ZONES } from '@/settings/accounts/constants/IanaTimeZones'; +import { IANA_TIME_ZONES } from '@/localization/constants/IanaTimeZones'; import { formatTimeZoneLabel } from '@/settings/accounts/utils/formatTimeZoneLabel'; import { SelectOption } from '@/ui/input/components/Select'; diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/DateFormat.ts b/packages/twenty-front/src/modules/settings/accounts/constants/DateFormat.ts deleted file mode 100644 index 985814c67..000000000 --- a/packages/twenty-front/src/modules/settings/accounts/constants/DateFormat.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum DateFormat { - US = 'MMM d, yyyy', - UK = 'd MMM yyyy', -} diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/TimeFormat.ts b/packages/twenty-front/src/modules/settings/accounts/constants/TimeFormat.ts deleted file mode 100644 index e168d1fda..000000000 --- a/packages/twenty-front/src/modules/settings/accounts/constants/TimeFormat.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum TimeFormat { - '24h' = 'HH:mm', - '12h' = 'h:mm aa', -} diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx index 54053bc59..982e7a9af 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx @@ -1,13 +1,18 @@ -import { formatISOStringToHumanReadableDate } from '~/utils/date-utils'; - +import { formatDateISOStringToDate } from '@/localization/utils/formatDateISOStringToDate'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useContext } from 'react'; import { EllipsisDisplay } from './EllipsisDisplay'; type DateDisplayProps = { value: string | null | undefined; }; -export const DateDisplay = ({ value }: DateDisplayProps) => ( - - {value ? formatISOStringToHumanReadableDate(value) : ''} - -); +export const DateDisplay = ({ value }: DateDisplayProps) => { + const { dateFormat, timeZone } = useContext(UserContext); + + const formattedDate = value + ? formatDateISOStringToDate(value, timeZone, dateFormat) + : ''; + + return {formattedDate}; +}; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx index 7ce008c6a..f90e4a0c5 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx @@ -1,13 +1,18 @@ -import { formatISOStringToHumanReadableDateTime } from '~/utils/date-utils'; - +import { formatDateISOStringToDateTime } from '@/localization/utils/formatDateISOStringToDateTime'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useContext } from 'react'; import { EllipsisDisplay } from './EllipsisDisplay'; type DateTimeDisplayProps = { value: string | null | undefined; }; -export const DateTimeDisplay = ({ value }: DateTimeDisplayProps) => ( - - {value ? formatISOStringToHumanReadableDateTime(value) : ''} - -); +export const DateTimeDisplay = ({ value }: DateTimeDisplayProps) => { + const { dateFormat, timeFormat, timeZone } = useContext(UserContext); + + const formattedDate = value + ? formatDateISOStringToDateTime(value, timeZone, dateFormat, timeFormat) + : ''; + + return {formattedDate}; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx index 239f80509..3fac47943 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useIMask } from 'react-imask'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { DateTime } from 'luxon'; +import { useCallback, useEffect, useState } from 'react'; +import { useIMask } from 'react-imask'; import { DATE_BLOCKS } from '@/ui/input/components/internal/date/constants/DateBlocks'; import { DATE_MASK } from '@/ui/input/components/internal/date/constants/DateMask'; @@ -41,6 +41,7 @@ type DateTimeInputProps = { onChange?: (date: Date | null) => void; date: Date | null; isDateTimeInput?: boolean; + userTimezone?: string; onError?: (error: Error) => void; }; @@ -48,6 +49,7 @@ export const DateTimeInput = ({ date, onChange, isDateTimeInput, + userTimezone, }: DateTimeInputProps) => { const parsingFormat = isDateTimeInput ? 'MM/dd/yyyy HH:mm' : 'MM/dd/yyyy'; @@ -55,7 +57,7 @@ export const DateTimeInput = ({ const parseDateToString = useCallback( (date: any) => { - const dateParsed = DateTime.fromJSDate(date); + const dateParsed = DateTime.fromJSDate(date, { zone: userTimezone }); const dateWithoutTime = DateTime.fromJSDate(date) .toLocal() @@ -70,19 +72,19 @@ export const DateTimeInput = ({ }); const formattedDate = isDateTimeInput - ? dateParsed.toFormat(parsingFormat) + ? dateParsed.setZone(userTimezone).toFormat(parsingFormat) : dateWithoutTime.toFormat(parsingFormat); return formattedDate; }, - [parsingFormat, isDateTimeInput], + [parsingFormat, isDateTimeInput, userTimezone], ); const parseStringToDate = (str: string) => { setHasError(false); const parsedDate = isDateTimeInput - ? DateTime.fromFormat(str, parsingFormat) + ? DateTime.fromFormat(str, parsingFormat, { zone: userTimezone }) : DateTime.fromFormat(str, parsingFormat, { zone: 'utc' }); const isValid = parsedDate.isValid; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx index 95a1164cf..59f1cd481 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx @@ -17,6 +17,8 @@ import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/compone import { StyledHoverableMenuItemBase } from '@/ui/navigation/menu-item/internals/components/StyledMenuItemBase'; import { isDefined } from '~/utils/isDefined'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useContext } from 'react'; import 'react-datepicker/dist/react-datepicker.css'; export const months = [ @@ -322,6 +324,8 @@ export const InternalDatePicker = ({ }: InternalDatePickerProps) => { const internalDate = date ?? new Date(); + const { timeZone } = useContext(UserContext); + const { closeDropdown } = useDropdown(MONTH_AND_YEAR_DROPDOWN_ID); const { closeDropdown: closeDropdownMonthSelect } = useDropdown( MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, @@ -377,10 +381,56 @@ export const InternalDatePicker = ({ onChange?.(newDate); }; + const handleAddMonth = () => { + const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) + .plus({ months: 1 }) + .toJSDate(); + + onChange?.(dateParsed); + }; + + const handleSubtractMonth = () => { + const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) + .minus({ months: 1 }) + .toJSDate(); + + onChange?.(dateParsed); + }; + const handleChangeYear = (year: number) => { - const newDate = new Date(internalDate); - newDate.setFullYear(year); - onChange?.(newDate); + const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) + .set({ year: year }) + .toJSDate(); + + onChange?.(dateParsed); + }; + + const handleDateChange = (date: Date) => { + const dateParsed = DateTime.fromJSDate(internalDate, { + zone: isDateTimeInput ? timeZone : 'local', + }) + .set({ + day: date.getDate(), + month: date.getMonth() + 1, + year: date.getFullYear(), + }) + .toJSDate(); + + onChange?.(dateParsed); + }; + + const handleDateSelect = (date: Date) => { + const dateParsed = DateTime.fromJSDate(internalDate, { + zone: isDateTimeInput ? timeZone : 'local', + }) + .set({ + day: date.getDate(), + month: date.getMonth() + 1, + year: date.getFullYear(), + }) + .toJSDate(); + + handleMouseSelect?.(dateParsed); }; const dateWithoutTime = DateTime.fromJSDate(internalDate) @@ -395,7 +445,29 @@ export const InternalDatePicker = ({ millisecond: 0, }) .toJSDate(); - const dateToUse = isDateTimeInput ? date : dateWithoutTime; + + const dateParsed = DateTime.fromJSDate(internalDate, { + zone: isDateTimeInput ? timeZone : 'local', + }); + + // We have to force a end of day on the computer local timezone with the given date + // Because JS Date API cannot hold a timezone other than the local one + // And if we don't do that workaround we will have problems when changing the date + // Because the shown date will have 1 day more or less than the real date + // Leading to bugs where we select 1st of January and it shows 31st of December for example + const endOfDayDateTimeInLocalTimezone = DateTime.now().set({ + day: dateParsed.get('day'), + month: dateParsed.get('month'), + year: dateParsed.get('year'), + hour: 23, + minute: 59, + second: 59, + millisecond: 999, + }); + + const endOfDayInLocalTimezone = endOfDayDateTimeInLocalTimezone.toJSDate(); + + const dateToUse = isDateTimeInput ? endOfDayInLocalTimezone : dateWithoutTime; return ( @@ -405,27 +477,16 @@ export const InternalDatePicker = ({ selected={dateToUse} openToDate={isDefined(dateToUse) ? dateToUse : undefined} disabledKeyboardNavigation - onChange={(newDate) => { - newDate?.setHours(internalDate.getUTCHours()); - newDate?.setUTCMinutes(internalDate.getUTCMinutes()); - onChange?.(newDate); - }} + onChange={handleDateChange} customInput={ } - onMonthChange={(newDate) => { - onChange?.(newDate); - }} - onYearChange={(newDate) => { - onChange?.(newDate); - }} renderCustomHeader={({ - decreaseMonth, - increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled, }) => ( @@ -434,6 +495,7 @@ export const InternalDatePicker = ({ date={internalDate} isDateTimeInput={isDateTimeInput} onChange={onChange} + userTimezone={timeZone} /> decreaseMonth()} + onClick={handleSubtractMonth} size="medium" disabled={prevMonthButtonDisabled} /> increaseMonth()} + onClick={handleAddMonth} size="medium" disabled={nextMonthButtonDisabled} /> )} - onSelect={(date: Date) => { - const dateParsedWithoutTime = DateTime.fromObject( - { - day: date.getDate(), - month: date.getMonth() + 1, - year: date.getFullYear(), - hour: 0, - minute: 0, - second: 0, - }, - { zone: 'utc' }, - ).toJSDate(); - - const dateForUpdate = isDateTimeInput - ? date - : dateParsedWithoutTime; - - handleMouseSelect?.(dateForUpdate); - }} + onSelect={handleDateSelect} /> {clearable && ( diff --git a/packages/twenty-front/src/modules/users/components/UserProvider.tsx b/packages/twenty-front/src/modules/users/components/UserProvider.tsx index e35cbabfa..4528a1ea1 100644 --- a/packages/twenty-front/src/modules/users/components/UserProvider.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProvider.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { useRecoilValue } from 'recoil'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState'; +import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; import { AppPath } from '@/types/AppPath'; +import { UserContext } from '@/users/contexts/UserContext'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader'; @@ -10,10 +12,20 @@ export const UserProvider = ({ children }: React.PropsWithChildren) => { const isCurrentUserLoaded = useRecoilValue(isCurrentUserLoadedState); const isMatchingLocation = useIsMatchingLocation(); + const dateTimeFormat = useRecoilValue(dateTimeFormatState); + return !isCurrentUserLoaded && !isMatchingLocation(AppPath.CreateWorkspace) ? ( ) : ( - <>{children} + + {children} + ); }; diff --git a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx index 2951f4530..9ab6656f4 100644 --- a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx @@ -6,6 +6,12 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState'; import { workspacesState } from '@/auth/states/workspaces'; +import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat'; +import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; import { useGetCurrentUserQuery } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -20,6 +26,8 @@ export const UserProviderEffect = () => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setWorkspaces = useSetRecoilState(workspacesState); + const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); + const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); @@ -47,6 +55,20 @@ export const UserProviderEffect = () => { ...workspaceMember, colorScheme: (workspaceMember.colorScheme as ColorScheme) ?? 'Light', }); + + // TODO: factorize + setDateTimeFormat({ + timeZone: + workspaceMember.timeZone && workspaceMember.timeZone !== 'system' + ? workspaceMember.timeZone + : detectTimeZone(), + dateFormat: isDefined(workspaceMember.dateFormat) + ? getDateFormatFromWorkspaceDateFormat(workspaceMember.dateFormat) + : detectDateFormat(), + timeFormat: isDefined(workspaceMember.timeFormat) + ? getTimeFormatFromWorkspaceTimeFormat(workspaceMember.timeFormat) + : detectTimeFormat(), + }); } if (isDefined(userWorkspaces)) { @@ -65,6 +87,7 @@ export const UserProviderEffect = () => { setWorkspaces, queryData?.currentUser, setIsCurrentUserLoaded, + setDateTimeFormat, ]); return <>; diff --git a/packages/twenty-front/src/modules/users/contexts/UserContext.ts b/packages/twenty-front/src/modules/users/contexts/UserContext.ts new file mode 100644 index 000000000..43776e2a5 --- /dev/null +++ b/packages/twenty-front/src/modules/users/contexts/UserContext.ts @@ -0,0 +1,13 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { createContext } from 'react'; + +export type UserContextType = { + dateFormat: DateFormat; + timeFormat: TimeFormat; + timeZone: string; +}; + +export const UserContext = createContext( + {} as UserContextType, +); diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 67dfa226e..baaad11ca 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -18,6 +18,9 @@ export const USER_QUERY_FRAGMENT = gql` colorScheme avatarUrl locale + timeZone + dateFormat + timeFormat } defaultWorkspace { id diff --git a/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts b/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts index 83f029206..61977b340 100644 --- a/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts +++ b/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts @@ -1,3 +1,8 @@ +import { + WorkspaceMemberDateFormatEnum, + WorkspaceMemberTimeFormatEnum, +} from '~/generated/graphql'; + export type ColorScheme = 'Dark' | 'Light' | 'System'; export type WorkspaceMember = { @@ -9,10 +14,13 @@ export type WorkspaceMember = { lastName: string; }; avatarUrl?: string | null; - locale: string; + locale?: string | null; colorScheme?: ColorScheme; createdAt: string; updatedAt: string; userEmail: string; userId: string; + timeZone?: string | null; + dateFormat?: WorkspaceMemberDateFormatEnum | null; + timeFormat?: WorkspaceMemberTimeFormatEnum | null; }; diff --git a/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx b/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx index 5bb3a33ea..5ad191a10 100644 --- a/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx +++ b/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx @@ -6,7 +6,7 @@ import { } from '~/testing/decorators/PageDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; -import { SettingsAppearance } from '../SettingsAppearance'; +import { SettingsAppearance } from '../profile/appearance/components/SettingsAppearance'; const meta: Meta = { title: 'Pages/Settings/SettingsAppearance', diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettings.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettings.tsx new file mode 100644 index 000000000..6a2e58e52 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettings.tsx @@ -0,0 +1,145 @@ +import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; + +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { getWorkspaceDateFormatFromDateFormat } from '@/localization/utils/getWorkspaceDateFormatFromDateFormat'; +import { getWorkspaceTimeFormatFromTimeFormat } from '@/localization/utils/getWorkspaceTimeFormatFromTimeFormat'; +import { + WorkspaceMemberDateFormatEnum, + WorkspaceMemberTimeFormatEnum, +} from '~/generated/graphql'; +import { DateTimeSettingsDateFormatSelect } from '~/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect'; +import { DateTimeSettingsTimeFormatSelect } from '~/pages/settings/profile/appearance/components/DateTimeSettingsTimeFormatSelect'; +import { DateTimeSettingsTimeZoneSelect } from '~/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect'; +import { isDefined } from '~/utils/isDefined'; +import { isEmptyObject } from '~/utils/isEmptyObject'; +import { logError } from '~/utils/logError'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +export const DateTimeSettings = () => { + const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( + currentWorkspaceMemberState, + ); + const [dateTimeFormat, setDateTimeFormat] = + useRecoilState(dateTimeFormatState); + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + }); + + const updateWorkspaceMember = async (changedFields: any) => { + if (!currentWorkspaceMember?.id) { + throw new Error('User is not logged in'); + } + + try { + await updateOneRecord({ + idToUpdate: currentWorkspaceMember.id, + updateOneRecordInput: changedFields, + }); + } catch (error) { + logError(error); + } + }; + + if (!isDefined(currentWorkspaceMember)) return; + + const handleSettingsChange = ( + settingName: 'timeZone' | 'dateFormat' | 'timeFormat', + value: string, + ) => { + const workspaceMember: any = {}; + const dateTime: any = {}; + + switch (settingName) { + case 'timeZone': { + workspaceMember[settingName] = value; + dateTime[settingName] = value === 'system' ? detectTimeZone() : value; + break; + } + case 'dateFormat': { + workspaceMember[settingName] = getWorkspaceDateFormatFromDateFormat( + value as DateFormat, + ); + dateTime[settingName] = + (value as DateFormat) === DateFormat.SYSTEM + ? detectDateFormat() + : (value as DateFormat); + break; + } + case 'timeFormat': { + workspaceMember[settingName] = getWorkspaceTimeFormatFromTimeFormat( + value as TimeFormat, + ); + dateTime[settingName] = + (value as TimeFormat) === TimeFormat.SYSTEM + ? detectTimeFormat() + : (value as TimeFormat); + break; + } + } + + if (!isEmptyObject(dateTime)) { + setDateTimeFormat({ + ...dateTimeFormat, + ...dateTime, + }); + } + + if (!isEmptyObject(workspaceMember)) { + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + ...workspaceMember, + }); + updateWorkspaceMember(workspaceMember); + } + }; + + const timeZone = + currentWorkspaceMember.timeZone === 'system' + ? 'system' + : dateTimeFormat.timeZone; + + const dateFormat = + currentWorkspaceMember.dateFormat === WorkspaceMemberDateFormatEnum.System + ? DateFormat.SYSTEM + : dateTimeFormat.dateFormat; + + const timeFormat = + currentWorkspaceMember.timeFormat === WorkspaceMemberTimeFormatEnum.System + ? TimeFormat.SYSTEM + : dateTimeFormat.timeFormat; + + return ( + + handleSettingsChange('timeZone', value)} + /> + handleSettingsChange('dateFormat', value)} + timeZone={timeZone} + /> + handleSettingsChange('timeFormat', value)} + timeZone={timeZone} + /> + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect.tsx new file mode 100644 index 000000000..c17f7a8e7 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect.tsx @@ -0,0 +1,59 @@ +import { formatInTimeZone } from 'date-fns-tz'; + +import { DateFormat } from '@/localization/constants/DateFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { Select } from '@/ui/input/components/Select'; + +type DateTimeSettingsDateFormatSelectProps = { + value: DateFormat; + onChange: (nextValue: DateFormat) => void; + timeZone: string; +}; + +export const DateTimeSettingsDateFormatSelect = ({ + onChange, + timeZone, + value, +}: DateTimeSettingsDateFormatSelectProps) => { + const setTimeZone = timeZone === 'system' ? detectTimeZone() : timeZone; + return ( + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect.tsx new file mode 100644 index 000000000..a48dea5db --- /dev/null +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect.tsx @@ -0,0 +1,34 @@ +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption'; +import { AVAILABLE_TIMEZONE_OPTIONS } from '@/settings/accounts/constants/AvailableTimezoneOptions'; +import { Select } from '@/ui/input/components/Select'; + +type DateTimeSettingsTimeZoneSelectProps = { + value?: string; + onChange: (nextValue: string) => void; +}; + +export const DateTimeSettingsTimeZoneSelect = ({ + value = detectTimeZone(), + onChange, +}: DateTimeSettingsTimeZoneSelectProps) => { + return ( +