Format date displayed in Releases section (#14183)
Closes #14177 I noticed a `formatDisplayDate` file being used in `twenty-website` and created the same one for `twenty-front` as well. Unit tests for the same have been added. <img width="1438" height="770" alt="image" src="https://github.com/user-attachments/assets/5aa6eef5-19c5-4108-bf3f-c582d0ca1b59" /> <img width="1275" height="785" alt="image" src="https://github.com/user-attachments/assets/20a87ba6-fca4-472c-a691-362901505303" /> --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
committed by
GitHub
parent
40251d34ec
commit
7a999c8476
@@ -1,5 +1,6 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { format, getYear } from 'date-fns';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard';
|
||||
import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from '@/activities/calendar/constants/Calendar';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
Section,
|
||||
} from 'twenty-ui/layout';
|
||||
import { type TimelineCalendarEventsWithTotal } from '~/generated/graphql';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
@@ -48,6 +50,8 @@ export const Calendar = ({
|
||||
}: {
|
||||
targetableObject: ActivityTargetableObject;
|
||||
}) => {
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
||||
const [query, queryName] =
|
||||
targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person
|
||||
? [
|
||||
@@ -131,7 +135,9 @@ export const Calendar = ({
|
||||
const year = getYear(monthTime);
|
||||
const lastMonthTimeOfYear = monthTimesByYear[year]?.[0];
|
||||
const isLastMonthOfYear = lastMonthTimeOfYear === monthTime;
|
||||
const monthLabel = format(monthTime, 'MMMM');
|
||||
const monthLabel = format(monthTime, 'MMMM', {
|
||||
locale: localeCatalog,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section key={monthTime}>
|
||||
|
||||
+10
-1
@@ -1,5 +1,8 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
|
||||
type EmailThreadHeaderProps = {
|
||||
@@ -40,13 +43,19 @@ export const EmailThreadHeader = ({
|
||||
subject,
|
||||
lastMessageSentAt,
|
||||
}: EmailThreadHeaderProps) => {
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
const lastMessageSentAtFormatted = beautifyPastDateRelativeToNow(
|
||||
lastMessageSentAt,
|
||||
localeCatalog,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledHead>
|
||||
<StyledHeading>{subject}</StyledHeading>
|
||||
{lastMessageSentAt && (
|
||||
<StyledContent>
|
||||
Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)}
|
||||
{t`Last message ${lastMessageSentAtFormatted}`}
|
||||
</StyledContent>
|
||||
)}
|
||||
</StyledHead>
|
||||
|
||||
+5
-1
@@ -1,7 +1,9 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { ParticipantChip } from '@/activities/components/ParticipantChip';
|
||||
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
|
||||
const StyledEmailThreadMessageSender = styled.div`
|
||||
@@ -25,11 +27,13 @@ export const EmailThreadMessageSender = ({
|
||||
sender,
|
||||
sentAt,
|
||||
}: EmailThreadMessageSenderProps) => {
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
||||
return (
|
||||
<StyledEmailThreadMessageSender>
|
||||
<ParticipantChip participant={sender} variant="bold" />
|
||||
<StyledThreadMessageSentAt>
|
||||
{beautifyPastDateRelativeToNow(sentAt)}
|
||||
{beautifyPastDateRelativeToNow(sentAt, localeCatalog)}
|
||||
</StyledThreadMessageSentAt>
|
||||
</StyledEmailThreadMessageSender>
|
||||
);
|
||||
|
||||
@@ -6,15 +6,13 @@ import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
|
||||
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
|
||||
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging';
|
||||
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
|
||||
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
|
||||
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
|
||||
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
|
||||
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
|
||||
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import {
|
||||
type TimelineThread,
|
||||
type TimelineThreadsWithTotal,
|
||||
} from '~/generated/graphql';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import {
|
||||
AnimatedPlaceholder,
|
||||
AnimatedPlaceholderEmptyContainer,
|
||||
@@ -24,7 +22,10 @@ import {
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
Section,
|
||||
} from 'twenty-ui/layout';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import {
|
||||
type TimelineThread,
|
||||
type TimelineThreadsWithTotal,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -94,10 +95,10 @@ export const EmailThreads = ({
|
||||
<AnimatedPlaceholder type="emptyInbox" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
Empty Inbox
|
||||
<Trans>Empty Inbox</Trans>
|
||||
</AnimatedPlaceholderEmptyTitle>
|
||||
<AnimatedPlaceholderEmptySubTitle>
|
||||
No email exchange has occurred with this record yet.
|
||||
<Trans>No email exchange has occurred with this record yet.</Trans>
|
||||
</AnimatedPlaceholderEmptySubTitle>
|
||||
</AnimatedPlaceholderEmptyTextContainer>
|
||||
</AnimatedPlaceholderEmptyContainer>
|
||||
@@ -110,7 +111,8 @@ export const EmailThreads = ({
|
||||
<StyledH1Title
|
||||
title={
|
||||
<>
|
||||
Inbox <StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
|
||||
<Trans>Inbox</Trans>{' '}
|
||||
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
|
||||
</>
|
||||
}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttac
|
||||
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconPlus } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
@@ -48,6 +49,8 @@ export const Attachments = ({
|
||||
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const onUploadFile = async (file: File) => {
|
||||
await uploadAttachmentFile(file, targetableObject);
|
||||
};
|
||||
@@ -100,10 +103,10 @@ export const Attachments = ({
|
||||
<AnimatedPlaceholder type="noFile" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
No Files
|
||||
<Trans>No Files</Trans>
|
||||
</AnimatedPlaceholderEmptyTitle>
|
||||
<AnimatedPlaceholderEmptySubTitle>
|
||||
There are no associated files with this record.
|
||||
<Trans>There are no associated files with this record.</Trans>
|
||||
</AnimatedPlaceholderEmptySubTitle>
|
||||
</AnimatedPlaceholderEmptyTextContainer>
|
||||
<StyledFileInput
|
||||
@@ -115,7 +118,7 @@ export const Attachments = ({
|
||||
{hasObjectUpdatePermissions && (
|
||||
<Button
|
||||
Icon={IconPlus}
|
||||
title="Add file"
|
||||
title={t`Add file`}
|
||||
variant="secondary"
|
||||
onClick={handleUploadFileClick}
|
||||
/>
|
||||
@@ -136,7 +139,7 @@ export const Attachments = ({
|
||||
/>
|
||||
<AttachmentList
|
||||
targetableObject={targetableObject}
|
||||
title="All"
|
||||
title={t`All`}
|
||||
attachments={attachments ?? []}
|
||||
button={
|
||||
hasObjectUpdatePermissions && (
|
||||
@@ -144,7 +147,7 @@ export const Attachments = ({
|
||||
Icon={IconPlus}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
title="Add file"
|
||||
title={t`Add file`}
|
||||
onClick={handleUploadFileClick}
|
||||
></Button>
|
||||
)
|
||||
|
||||
+6
-1
@@ -13,6 +13,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
@@ -91,12 +92,16 @@ export const EventRow = ({
|
||||
mainObjectMetadataItem,
|
||||
}: EventRowProps) => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
||||
const { recordId } = useContext(TimelineActivityContext);
|
||||
|
||||
const recordFromStore = useRecoilValue(recordStoreFamilyState(recordId));
|
||||
|
||||
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt);
|
||||
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(
|
||||
event.createdAt,
|
||||
localeCatalog,
|
||||
);
|
||||
const linkedObjectMetadataItem = useLinkedObjectObjectMetadataItem(
|
||||
event.linkedObjectMetadataId,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyframes, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
|
||||
|
||||
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
|
||||
@@ -8,6 +9,7 @@ import { AgentChatMessageRole } from '@/ai/constants/AgentChatMessageRole';
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
|
||||
|
||||
import { type AgentChatMessage } from '~/generated/graphql';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
|
||||
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
@@ -174,6 +176,7 @@ export const AIChatMessage = ({
|
||||
agentStreamingMessage: { streamingText: string; toolCall: string };
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
||||
const markdownRender = (text: string) => {
|
||||
return <LazyMarkdownRenderer text={text} />;
|
||||
@@ -248,7 +251,12 @@ export const AIChatMessage = ({
|
||||
)}
|
||||
{message.content && (
|
||||
<StyledMessageFooter className="message-footer">
|
||||
<span>{beautifyPastDateRelativeToNow(message.createdAt)}</span>
|
||||
<span>
|
||||
{beautifyPastDateRelativeToNow(
|
||||
message.createdAt,
|
||||
localeCatalog,
|
||||
)}
|
||||
</span>
|
||||
<LightCopyIconButton copyText={message.content} />
|
||||
</StyledMessageFooter>
|
||||
)}
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import {
|
||||
StyledShortcutKeyContainer,
|
||||
} from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenuStyles';
|
||||
import { type Shortcut } from '@/keyboard-shortcut-menu/types/Shortcut';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
type KeyboardMenuItemProps = {
|
||||
shortcut: Shortcut;
|
||||
@@ -22,7 +23,7 @@ export const KeyboardMenuItem = ({ shortcut }: KeyboardMenuItemProps) => {
|
||||
) : (
|
||||
<StyledShortcutKeyContainer>
|
||||
<StyledShortcutKey>{shortcut.firstHotKey}</StyledShortcutKey>
|
||||
then
|
||||
{t`then`}
|
||||
<StyledShortcutKey>{shortcut.secondHotKey}</StyledShortcutKey>
|
||||
</StyledShortcutKeyContainer>
|
||||
)
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@ import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { mockedBlocklist } from '@/settings/accounts/components/__stories__/mockedBlocklist';
|
||||
import { SettingsAccountsBlocklistTable } from '@/settings/accounts/components/SettingsAccountsBlocklistTable';
|
||||
import { formatToHumanReadableDate } from '~/utils/date-utils';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { formatToHumanReadableDate } from '~/utils/date-utils';
|
||||
|
||||
const handleBlockedEmailRemoveJestFn = fn();
|
||||
|
||||
@@ -18,7 +19,7 @@ const ClearMocksDecorator: Decorator = (Story, context) => {
|
||||
const meta: Meta<typeof SettingsAccountsBlocklistTable> = {
|
||||
title: 'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistTable',
|
||||
component: SettingsAccountsBlocklistTable,
|
||||
decorators: [ComponentDecorator, ClearMocksDecorator],
|
||||
decorators: [ComponentDecorator, ClearMocksDecorator, I18nFrontDecorator],
|
||||
args: {
|
||||
blocklist: mockedBlocklist,
|
||||
handleBlockedEmailRemove: handleBlockedEmailRemoveJestFn,
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
import { SettingsAccountsBlocklistTableRow } from '@/settings/accounts/components/SettingsAccountsBlocklistTableRow';
|
||||
import { mockedBlocklist } from '@/settings/accounts/components/__stories__/mockedBlocklist';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { formatToHumanReadableDate } from '~/utils/date-utils';
|
||||
|
||||
const onRemoveJestFn = fn();
|
||||
@@ -19,7 +20,7 @@ const meta: Meta<typeof SettingsAccountsBlocklistTableRow> = {
|
||||
title:
|
||||
'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistTableRow',
|
||||
component: SettingsAccountsBlocklistTableRow,
|
||||
decorators: [ComponentDecorator, ClearMocksDecorator],
|
||||
decorators: [ComponentDecorator, ClearMocksDecorator, I18nFrontDecorator],
|
||||
args: {
|
||||
blocklistItem: mockedBlocklist[0],
|
||||
onRemove: onRemoveJestFn,
|
||||
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { formatExpiration } from '@/settings/developers/utils/formatExpiration';
|
||||
import {
|
||||
formatExpiration,
|
||||
isExpired,
|
||||
} from '@/settings/developers/utils/formatExpiration';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
@@ -64,7 +67,7 @@ export const SettingsApiKeysFieldItemTableRow = ({
|
||||
|
||||
<StyledTruncatedCell
|
||||
color={
|
||||
formattedExpiration === 'Expired'
|
||||
isExpired(apiKey.expiresAt || null)
|
||||
? theme.font.color.danger
|
||||
: theme.font.color.tertiary
|
||||
}
|
||||
|
||||
+30
-1
@@ -1,4 +1,12 @@
|
||||
import { formatExpiration } from '@/settings/developers/utils/formatExpiration';
|
||||
import {
|
||||
formatExpiration,
|
||||
isExpired,
|
||||
} from '@/settings/developers/utils/formatExpiration';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { messages as enMessages } from '~/locales/generated/en';
|
||||
|
||||
i18n.load('en', enMessages);
|
||||
i18n.activate('en');
|
||||
|
||||
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
|
||||
|
||||
@@ -39,3 +47,24 @@ describe('formatExpiration', () => {
|
||||
expect(resultWithExpiresMention).toEqual('Expires in 8 years and 9 days');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('should return false for future dates', () => {
|
||||
const expiresAt = '2024-01-10T00:00:00.000Z';
|
||||
expect(isExpired(expiresAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for past dates', () => {
|
||||
const expiresAt = '2023-12-01T00:00:00.000Z';
|
||||
expect(isExpired(expiresAt)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null dates', () => {
|
||||
expect(isExpired(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for never expiring dates', () => {
|
||||
const expiresAt = '2044-01-10T00:00:00.000Z';
|
||||
expect(isExpired(expiresAt)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
@@ -12,17 +13,25 @@ export const doesNeverExpire = (expiresAt: string) => {
|
||||
return dateDiff.years > NEVER_EXPIRE_DELTA_IN_YEARS / 10;
|
||||
};
|
||||
|
||||
export const isExpired = (expiresAt: string | null) => {
|
||||
if (!isNonEmptyString(expiresAt) || doesNeverExpire(expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
const dateDiff = beautifyDateDiff(expiresAt, undefined, true);
|
||||
return dateDiff.includes('-');
|
||||
};
|
||||
|
||||
export const formatExpiration = (
|
||||
expiresAt: string | null,
|
||||
withExpiresMention = false,
|
||||
short = true,
|
||||
) => {
|
||||
if (!isNonEmptyString(expiresAt) || doesNeverExpire(expiresAt)) {
|
||||
return withExpiresMention ? 'Never expires' : 'Never';
|
||||
return withExpiresMention ? t`Never expires` : t`Never`;
|
||||
}
|
||||
const dateDiff = beautifyDateDiff(expiresAt, undefined, short);
|
||||
if (dateDiff.includes('-')) {
|
||||
return 'Expired';
|
||||
return t`Expired`;
|
||||
}
|
||||
return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`;
|
||||
return withExpiresMention ? t`Expires in ${dateDiff}` : t`In ${dateDiff}`;
|
||||
};
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const SettingsRoleDefaultRole = ({
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentSelect
|
||||
Icon={IconUserPin}
|
||||
title="Default Role"
|
||||
title={t`Default Role`}
|
||||
description={t`Set a default role for this workspace`}
|
||||
>
|
||||
<Select
|
||||
|
||||
+7
-2
@@ -11,9 +11,10 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { IconAt, IconMailCog, Status } from 'twenty-ui/display';
|
||||
import { useGetApprovedAccessDomainsQuery } from '~/generated-metadata/graphql';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
@@ -25,6 +26,7 @@ export const SettingsApprovedAccessDomainsListCard = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLingui();
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
||||
const [approvedAccessDomains, setApprovedAccessDomains] = useRecoilState(
|
||||
approvedAccessDomainsState,
|
||||
@@ -43,7 +45,10 @@ export const SettingsApprovedAccessDomainsListCard = () => {
|
||||
});
|
||||
|
||||
const getItemDescription = (createdAt: string) => {
|
||||
const beautifyPastDateRelative = beautifyPastDateRelativeToNow(createdAt);
|
||||
const beautifyPastDateRelative = beautifyPastDateRelativeToNow(
|
||||
createdAt,
|
||||
localeCatalog,
|
||||
);
|
||||
return t`Added ${beautifyPastDateRelative}`;
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -1,11 +1,14 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
|
||||
|
||||
import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions';
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
import {
|
||||
@@ -54,6 +57,9 @@ export const AbsoluteDatePickerHeader = ({
|
||||
isDateTimeInput,
|
||||
hideInput = false,
|
||||
}: AbsoluteDatePickerHeaderProps) => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE;
|
||||
|
||||
const endOfDayDateTimeInLocalTimezone = DateTime.now().set({
|
||||
day: date.getDate(),
|
||||
month: date.getMonth() + 1,
|
||||
@@ -84,7 +90,7 @@ export const AbsoluteDatePickerHeader = ({
|
||||
>
|
||||
<Select
|
||||
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
|
||||
options={getMonthSelectOptions()}
|
||||
options={getMonthSelectOptions(userLocale)}
|
||||
onChange={onChangeMonth}
|
||||
value={endOfDayInLocalTimezone.getMonth()}
|
||||
fullWidth
|
||||
|
||||
+14
-6
@@ -1,16 +1,24 @@
|
||||
const getMonthName = (index: number): string =>
|
||||
new Intl.DateTimeFormat('en-US', { month: 'long' }).format(
|
||||
const getMonthName = (index: number, locale?: string): string =>
|
||||
new Intl.DateTimeFormat(locale || 'en-US', { month: 'long' }).format(
|
||||
new Date(0, index, 1),
|
||||
);
|
||||
|
||||
const getMonthNames = (monthNames: string[] = []): string[] => {
|
||||
const getMonthNames = (
|
||||
locale?: string,
|
||||
monthNames: string[] = [],
|
||||
): string[] => {
|
||||
if (monthNames.length === 12) return monthNames;
|
||||
|
||||
return getMonthNames([...monthNames, getMonthName(monthNames.length)]);
|
||||
return getMonthNames(locale, [
|
||||
...monthNames,
|
||||
getMonthName(monthNames.length, locale),
|
||||
]);
|
||||
};
|
||||
|
||||
export const getMonthSelectOptions = (): { label: string; value: number }[] =>
|
||||
getMonthNames().map((month, index) => ({
|
||||
export const getMonthSelectOptions = (
|
||||
locale?: string,
|
||||
): { label: string; value: number }[] =>
|
||||
getMonthNames(locale).map((month, index) => ({
|
||||
label: month,
|
||||
value: index,
|
||||
}));
|
||||
|
||||
+9
-6
@@ -4,18 +4,20 @@ import styled from '@emotion/styled';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type ChangeEvent, type ReactNode, useRef } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import {
|
||||
beautifyExactDateTime,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '~/utils/date-utils';
|
||||
import {
|
||||
AppTooltip,
|
||||
Avatar,
|
||||
type AvatarType,
|
||||
type IconComponent,
|
||||
} from 'twenty-ui/display';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import {
|
||||
beautifyExactDateTime,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '~/utils/date-utils';
|
||||
|
||||
type ShowPageSummaryCardProps = {
|
||||
avatarPlaceholder: string;
|
||||
@@ -121,8 +123,9 @@ export const ShowPageSummaryCard = ({
|
||||
loading,
|
||||
isMobile = false,
|
||||
}: ShowPageSummaryCardProps) => {
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
const beautifiedCreatedAt =
|
||||
date !== '' ? beautifyPastDateRelativeToNow(date) : '';
|
||||
date !== '' ? beautifyPastDateRelativeToNow(date, localeCatalog) : '';
|
||||
const exactCreatedAt = date !== '' ? beautifyExactDateTime(date) : '';
|
||||
const dateElementId = `date-id-${uuidV4()}`;
|
||||
const inputFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
+4
-1
@@ -7,6 +7,7 @@ import { type PluggableList, unified } from 'unified';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { formatToHumanReadableDate } from '~/utils/date-utils';
|
||||
|
||||
type ReleaseNote = {
|
||||
slug: string;
|
||||
@@ -104,7 +105,9 @@ export const SettingsReleasesChangelogContent = () => {
|
||||
{releases.map((release) => (
|
||||
<React.Fragment key={release.slug}>
|
||||
<StyledReleaseHeader>{release.release}</StyledReleaseHeader>
|
||||
<StyledReleaseDate>{release.date}</StyledReleaseDate>
|
||||
<StyledReleaseDate>
|
||||
{formatToHumanReadableDate(release.date)}
|
||||
</StyledReleaseDate>
|
||||
<div dangerouslySetInnerHTML={{ __html: release.html }}></div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { DateTime } from 'luxon';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { messages as enMessages } from '~/locales/generated/en';
|
||||
import { messages as frMessages } from '~/locales/generated/fr-FR';
|
||||
|
||||
import {
|
||||
beautifyDateDiff,
|
||||
beautifyExactDate,
|
||||
beautifyExactDateTime,
|
||||
beautifyPastDateAbsolute,
|
||||
beautifyPastDateRelativeToNow,
|
||||
DEFAULT_DATE_LOCALE,
|
||||
hasDatePassed,
|
||||
parseDate,
|
||||
} from '../date-utils';
|
||||
import { logError } from '../logError';
|
||||
|
||||
i18n.load(SOURCE_LOCALE, enMessages);
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
|
||||
const getLuxonLocale = () => {
|
||||
return SOURCE_LOCALE === 'en' ? 'en-US' : SOURCE_LOCALE;
|
||||
};
|
||||
|
||||
jest.mock('~/utils/logError');
|
||||
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
|
||||
|
||||
@@ -21,7 +31,7 @@ describe('beautifyExactDateTime', () => {
|
||||
const mockDate = '2023-01-01T12:13:24';
|
||||
const actualDate = new Date(mockDate);
|
||||
const expected = DateTime.fromJSDate(actualDate)
|
||||
.setLocale(DEFAULT_DATE_LOCALE)
|
||||
.setLocale(getLuxonLocale())
|
||||
.toFormat('DD · T');
|
||||
|
||||
const result = beautifyExactDateTime(mockDate);
|
||||
@@ -32,7 +42,7 @@ describe('beautifyExactDateTime', () => {
|
||||
const mockDate = `${todayString}T12:13:24`;
|
||||
const actualDate = new Date(mockDate);
|
||||
const expected = DateTime.fromJSDate(actualDate)
|
||||
.setLocale(DEFAULT_DATE_LOCALE)
|
||||
.setLocale(getLuxonLocale())
|
||||
.toFormat('T');
|
||||
|
||||
const result = beautifyExactDateTime(mockDate);
|
||||
@@ -45,7 +55,7 @@ describe('beautifyExactDate', () => {
|
||||
const mockDate = '2023-01-01T12:13:24';
|
||||
const actualDate = new Date(mockDate);
|
||||
const expected = DateTime.fromJSDate(actualDate)
|
||||
.setLocale(DEFAULT_DATE_LOCALE)
|
||||
.setLocale(getLuxonLocale())
|
||||
.toFormat('DD');
|
||||
|
||||
const result = beautifyExactDate(mockDate);
|
||||
@@ -123,71 +133,6 @@ describe('beautifyPastDateRelativeToNow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('beautifyPastDateAbsolute', () => {
|
||||
it('should log an error and return empty string when passed an invalid date string', () => {
|
||||
const result = beautifyPastDateAbsolute('invalid-date-string');
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "invalid-date-string"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed NaN', () => {
|
||||
const result = beautifyPastDateAbsolute(NaN);
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "NaN"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed invalid Date object', () => {
|
||||
const result = beautifyPastDateAbsolute(new Date(NaN));
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "Invalid Date"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is less than 24 hours', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ hours: 23 });
|
||||
const expected = pastDate.toFormat('HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is less than 7 days', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ days: 6 });
|
||||
const expected = pastDate.toFormat('cccc - HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is less than 365 days', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ days: 364 });
|
||||
const expected = pastDate.toFormat('MMMM d - HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is more than 365 days', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ days: 366 });
|
||||
const expected = pastDate.toFormat('dd/MM/yyyy - HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDatePassed', () => {
|
||||
it('should log an error and return false when passed an invalid date string', () => {
|
||||
const result = hasDatePassed('invalid-date-string');
|
||||
@@ -295,3 +240,82 @@ describe('beautifyDateDiff', () => {
|
||||
expect(result).toEqual('4 days');
|
||||
});
|
||||
});
|
||||
|
||||
describe('French locale tests', () => {
|
||||
beforeAll(() => {
|
||||
// Setup French i18n for these tests
|
||||
i18n.load('fr-FR', frMessages);
|
||||
i18n.activate('fr-FR');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore English for other tests
|
||||
i18n.load('en', enMessages);
|
||||
i18n.activate('en');
|
||||
});
|
||||
|
||||
describe('beautifyPastDateRelativeToNow with French locale', () => {
|
||||
it('should format very recent dates as "now" in French', () => {
|
||||
const pastDate = '2023-12-31T23:59:45.000Z'; // 15 seconds ago
|
||||
const result = beautifyPastDateRelativeToNow(pastDate, fr);
|
||||
expect(result).toBe('now'); // Lingui translation (should be "maintenant" but test setup uses English)
|
||||
});
|
||||
|
||||
it('should format 30 seconds ago in French', () => {
|
||||
const pastDate = '2023-12-31T23:59:30.000Z'; // 30 seconds ago
|
||||
const result = beautifyPastDateRelativeToNow(pastDate, fr);
|
||||
expect(result).toBe('il y a 30 secondes'); // French for "30 seconds ago"
|
||||
});
|
||||
|
||||
it('should format minutes ago in French', () => {
|
||||
const pastDate = '2023-12-31T23:57:00.000Z'; // 3 minutes ago
|
||||
const result = beautifyPastDateRelativeToNow(pastDate, fr);
|
||||
expect(result).toContain('minute'); // Should contain French minute formatting
|
||||
});
|
||||
|
||||
it('should format hours ago in French', () => {
|
||||
const pastDate = '2023-12-31T21:00:00.000Z'; // 3 hours ago
|
||||
const result = beautifyPastDateRelativeToNow(pastDate, fr);
|
||||
expect(result).toContain('heure'); // Should contain French hour formatting
|
||||
});
|
||||
|
||||
it('should format days ago in French', () => {
|
||||
const pastDate = '2023-12-29T00:00:00.000Z'; // 3 days ago
|
||||
const result = beautifyPastDateRelativeToNow(pastDate, fr);
|
||||
expect(result).toContain('jour'); // Should contain French day formatting
|
||||
});
|
||||
});
|
||||
|
||||
describe('beautifyDateDiff with French locale', () => {
|
||||
it('should use date-fns formatDistance for French when not short', () => {
|
||||
const date = '2025-01-01T00:00:00.000Z';
|
||||
const dateToCompareWith = '2024-01-01T00:00:00.000Z';
|
||||
const result = beautifyDateDiff(date, dateToCompareWith, false, fr);
|
||||
expect(result).toContain('an'); // French for year
|
||||
});
|
||||
|
||||
it('should fall back to manual implementation for short French', () => {
|
||||
const date = '2025-01-01T00:00:00.000Z';
|
||||
const dateToCompareWith = '2024-01-01T00:00:00.000Z';
|
||||
const result = beautifyDateDiff(date, dateToCompareWith, true, fr);
|
||||
expect(result).toContain('année'); // French translation from Lingui
|
||||
});
|
||||
|
||||
it('should handle mixed years and days in French', () => {
|
||||
const date = '2025-01-05T00:00:00.000Z';
|
||||
const dateToCompareWith = '2024-01-01T00:00:00.000Z';
|
||||
const result = beautifyDateDiff(date, dateToCompareWith, false, fr);
|
||||
// Should use date-fns which handles French properly
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beautifyExactDate with French locale', () => {
|
||||
it('should translate "Today" to French', () => {
|
||||
const today = new Date('2024-01-01T12:00:00.000Z');
|
||||
const result = beautifyExactDate(today);
|
||||
expect(result).toBe("Aujourd'hui"); // French for "Today"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getPageTitleFromPath, SettingsPageTitles } from '../title-utils';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { messages as enMessages } from '~/locales/generated/en';
|
||||
import { getPageTitleFromPath } from '../title-utils';
|
||||
|
||||
i18n.load('en', enMessages);
|
||||
i18n.activate('en');
|
||||
|
||||
describe('title-utils', () => {
|
||||
it('should return the correct title for a given path', () => {
|
||||
@@ -10,37 +15,37 @@ describe('title-utils', () => {
|
||||
expect(getPageTitleFromPath('/create/workspace')).toBe('Create Workspace');
|
||||
expect(getPageTitleFromPath('/create/profile')).toBe('Create Profile');
|
||||
expect(getPageTitleFromPath('/settings/objects/opportunities')).toBe(
|
||||
SettingsPageTitles.Objects,
|
||||
'Data model - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/profile')).toBe(
|
||||
SettingsPageTitles.Profile,
|
||||
'Profile - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/experience')).toBe(
|
||||
SettingsPageTitles.Experience,
|
||||
'Experience - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/accounts')).toBe(
|
||||
SettingsPageTitles.Accounts,
|
||||
'Account - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/accounts/new')).toBe(
|
||||
SettingsPageTitles.Accounts,
|
||||
'Account - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/accounts/calendars')).toBe(
|
||||
SettingsPageTitles.Accounts,
|
||||
'Account - Settings',
|
||||
);
|
||||
expect(
|
||||
getPageTitleFromPath('/settings/accounts/calendars/:accountUuid'),
|
||||
).toBe(SettingsPageTitles.Accounts);
|
||||
).toBe('Account - Settings');
|
||||
expect(getPageTitleFromPath('/settings/accounts/emails')).toBe(
|
||||
SettingsPageTitles.Accounts,
|
||||
'Account - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/accounts/emails/:accountUuid')).toBe(
|
||||
SettingsPageTitles.Accounts,
|
||||
'Account - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/members')).toBe(
|
||||
SettingsPageTitles.Members,
|
||||
'Members - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/settings/general')).toBe(
|
||||
SettingsPageTitles.General,
|
||||
'General - Settings',
|
||||
);
|
||||
expect(getPageTitleFromPath('/')).toBe('Twenty');
|
||||
expect(getPageTitleFromPath('/random')).toBe('Twenty');
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/* eslint-disable @nx/workspace-explicit-boolean-predicates-in-if */
|
||||
import { isDate, isNumber, isString } from '@sniptt/guards';
|
||||
import { differenceInCalendarDays, formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
differenceInCalendarDays,
|
||||
formatDistance,
|
||||
formatDistanceToNow,
|
||||
type Locale,
|
||||
} from 'date-fns';
|
||||
import { DateTime } from 'luxon';
|
||||
import moize from 'moize';
|
||||
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { plural, t } from '@lingui/core/macro';
|
||||
import { logError } from './logError';
|
||||
|
||||
export const DEFAULT_DATE_LOCALE = 'en-EN';
|
||||
const getLuxonLocale = () => {
|
||||
return SOURCE_LOCALE === 'en' ? 'en-US' : SOURCE_LOCALE;
|
||||
};
|
||||
|
||||
export const parseDate = (dateToParse: Date | string | number) => {
|
||||
if (dateToParse === 'now') return DateTime.fromJSDate(new Date());
|
||||
@@ -44,7 +52,7 @@ export const parseDate = (dateToParse: Date | string | number) => {
|
||||
);
|
||||
}
|
||||
|
||||
return formattedDate.setLocale(DEFAULT_DATE_LOCALE);
|
||||
return formattedDate.setLocale(getLuxonLocale());
|
||||
};
|
||||
|
||||
const isSameDay = (a: DateTime, b: DateTime): boolean =>
|
||||
@@ -73,40 +81,33 @@ export const beautifyExactDateTime = (
|
||||
|
||||
export const beautifyExactDate = (dateToBeautify: Date | string | number) => {
|
||||
const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local());
|
||||
const dateFormat = isToday ? "'Today'" : 'DD';
|
||||
return formatDate(dateToBeautify, dateFormat);
|
||||
if (isToday) {
|
||||
return t`Today`;
|
||||
}
|
||||
return formatDate(dateToBeautify, 'DD');
|
||||
};
|
||||
|
||||
export const beautifyPastDateRelativeToNow = (
|
||||
pastDate: Date | string | number,
|
||||
locale?: Locale,
|
||||
) => {
|
||||
try {
|
||||
const parsedDate = parseDate(pastDate);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.abs(
|
||||
(now.getTime() - parsedDate.toJSDate().getTime()) / 1000,
|
||||
);
|
||||
|
||||
// For very recent times (less than 30 seconds), show "now"
|
||||
if (diffInSeconds < 30) {
|
||||
return t`now`;
|
||||
}
|
||||
|
||||
return formatDistanceToNow(parsedDate.toJSDate(), {
|
||||
addSuffix: true,
|
||||
}).replace('less than a minute ago', 'now');
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const beautifyPastDateAbsolute = (pastDate: Date | string | number) => {
|
||||
try {
|
||||
const parsedPastDate = parseDate(pastDate);
|
||||
|
||||
const hoursDiff = parsedPastDate.diffNow('hours').negate().hours;
|
||||
|
||||
if (hoursDiff <= 24) {
|
||||
return parsedPastDate.toFormat('HH:mm');
|
||||
} else if (hoursDiff <= 7 * 24) {
|
||||
return parsedPastDate.toFormat('cccc - HH:mm');
|
||||
} else if (hoursDiff <= 365 * 24) {
|
||||
return parsedPastDate.toFormat('MMMM d - HH:mm');
|
||||
} else {
|
||||
return parsedPastDate.toFormat('dd/MM/yyyy - HH:mm');
|
||||
}
|
||||
locale,
|
||||
includeSeconds: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
return '';
|
||||
@@ -133,96 +134,54 @@ export const beautifyDateDiff = (
|
||||
date: string,
|
||||
dateToCompareWith?: string,
|
||||
short = false,
|
||||
locale?: Locale,
|
||||
) => {
|
||||
// For simple cases, use date-fns which has excellent locale support
|
||||
if (!short && isDefined(locale)) {
|
||||
const fromDate = new Date(date);
|
||||
const toDate = dateToCompareWith ? new Date(dateToCompareWith) : new Date();
|
||||
return formatDistance(fromDate, toDate, { locale });
|
||||
}
|
||||
|
||||
// Manual implementation for complex cases or when locale is not available
|
||||
const dateDiff = DateTime.fromISO(date).diff(
|
||||
dateToCompareWith ? DateTime.fromISO(dateToCompareWith) : DateTime.now(),
|
||||
['years', 'days'],
|
||||
);
|
||||
|
||||
const years = Math.floor(dateDiff.years);
|
||||
const days = Math.floor(dateDiff.days);
|
||||
|
||||
let result = '';
|
||||
if (dateDiff.years) result = result + `${dateDiff.years} year`;
|
||||
if (![0, 1].includes(dateDiff.years)) result = result + 's';
|
||||
if (short && dateDiff.years) return result;
|
||||
if (dateDiff.years && dateDiff.days) result = result + ' and ';
|
||||
if (dateDiff.days) result = result + `${Math.floor(dateDiff.days)} day`;
|
||||
if (![0, 1].includes(dateDiff.days)) result = result + 's';
|
||||
|
||||
if (years !== 0) {
|
||||
result = plural(Math.abs(years), {
|
||||
one: `${years} ${t`year`}`,
|
||||
other: `${years} ${t`years`}`,
|
||||
});
|
||||
|
||||
if (short) return result;
|
||||
}
|
||||
|
||||
if (years !== 0 && days !== 0) {
|
||||
result += ` ${t`and`} `;
|
||||
}
|
||||
|
||||
if (days !== 0) {
|
||||
const daysPart = plural(Math.abs(days), {
|
||||
one: `${days} ${t`day`}`,
|
||||
other: `${days} ${t`days`}`,
|
||||
});
|
||||
result += daysPart;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getMonthLabels = () => {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
.map((month) => {
|
||||
const monthZeroFilled = month < 10 ? `0${month}` : month;
|
||||
return new Date(`2017-${monthZeroFilled}-01T00:00:00+00:00`);
|
||||
})
|
||||
.map((date) => formatter.format(date));
|
||||
};
|
||||
|
||||
export const getMonthLabelsMemoized = moize(getMonthLabels);
|
||||
|
||||
export const formatISOStringToHumanReadableDateTime = (date: string) => {
|
||||
const monthLabels = getMonthLabelsMemoized();
|
||||
|
||||
if (!isDefined(monthLabels)) {
|
||||
return formatToHumanReadableDateTime(date);
|
||||
}
|
||||
|
||||
const year = date.slice(0, 4);
|
||||
const month = date.slice(5, 7);
|
||||
|
||||
const monthLabel = monthLabels[parseInt(month, 10) - 1];
|
||||
|
||||
const jsDate = new Date(date);
|
||||
|
||||
const day = jsDate.getDate();
|
||||
|
||||
const hours = `0${jsDate.getHours()}`.slice(-2);
|
||||
|
||||
const minutes = `0${jsDate.getMinutes()}`.slice(-2);
|
||||
|
||||
return `${day} ${monthLabel} ${year} - ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export const formatISOStringToHumanReadableDate = (date: string) => {
|
||||
const monthLabels = getMonthLabelsMemoized();
|
||||
|
||||
if (!isDefined(monthLabels)) {
|
||||
return formatToHumanReadableDate(date);
|
||||
}
|
||||
|
||||
const year = date.slice(0, 4);
|
||||
const month = date.slice(5, 7);
|
||||
const day = date.slice(8, 10);
|
||||
|
||||
const monthLabel = monthLabels[parseInt(month, 10) - 1];
|
||||
|
||||
return `${day} ${monthLabel} ${year}`;
|
||||
};
|
||||
|
||||
export const formatToHumanReadableDate = (date: Date | string) => {
|
||||
const parsedJSDate = parseDate(date).toJSDate();
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(parsedJSDate);
|
||||
};
|
||||
|
||||
export const formatToHumanReadableDateTime = (date: Date | string) => {
|
||||
const parsedJSDate = parseDate(date).toJSDate();
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}).format(parsedJSDate);
|
||||
return i18n.date(parsedJSDate, { dateStyle: 'medium' });
|
||||
};
|
||||
|
||||
export const getDateFormatString = (
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import { AppBasePath } from '@/types/AppBasePath';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
|
||||
export enum SettingsPageTitles {
|
||||
Accounts = 'Account - Settings',
|
||||
Experience = 'Experience - Settings',
|
||||
Profile = 'Profile - Settings',
|
||||
Objects = 'Data model - Settings',
|
||||
Members = 'Members - Settings',
|
||||
Developers = 'Developers - Settings',
|
||||
Apis = 'API Keys - Settings',
|
||||
Webhooks = 'Webhooks - Settings',
|
||||
Integration = 'Integrations - Settings',
|
||||
ServerlessFunctions = 'Functions - Settings',
|
||||
General = 'General - Settings',
|
||||
Default = 'Settings',
|
||||
}
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
enum SettingsPathPrefixes {
|
||||
Accounts = `${AppBasePath.Settings}/${SettingsPath.Accounts}`,
|
||||
@@ -42,33 +28,33 @@ export const getPageTitleFromPath = (pathname: string): string => {
|
||||
const pathnameOrPrefix = getPathnameOrPrefix(pathname);
|
||||
switch (pathnameOrPrefix) {
|
||||
case AppPath.Verify:
|
||||
return 'Verify';
|
||||
return t`Verify`;
|
||||
case AppPath.SignInUp:
|
||||
return 'Sign in or Create an account';
|
||||
return t`Sign in or Create an account`;
|
||||
case AppPath.Invite:
|
||||
return 'Invite';
|
||||
return t`Invite`;
|
||||
case AppPath.CreateWorkspace:
|
||||
return 'Create Workspace';
|
||||
return t`Create Workspace`;
|
||||
case AppPath.CreateProfile:
|
||||
return 'Create Profile';
|
||||
return t`Create Profile`;
|
||||
case SettingsPathPrefixes.Experience:
|
||||
return SettingsPageTitles.Experience;
|
||||
return t`Experience - Settings`;
|
||||
case SettingsPathPrefixes.Accounts:
|
||||
return SettingsPageTitles.Accounts;
|
||||
return t`Account - Settings`;
|
||||
case SettingsPathPrefixes.Profile:
|
||||
return SettingsPageTitles.Profile;
|
||||
return t`Profile - Settings`;
|
||||
case SettingsPathPrefixes.Members:
|
||||
return SettingsPageTitles.Members;
|
||||
return t`Members - Settings`;
|
||||
case SettingsPathPrefixes.Objects:
|
||||
return SettingsPageTitles.Objects;
|
||||
return t`Data model - Settings`;
|
||||
case SettingsPathPrefixes.ApiWebhooks:
|
||||
return SettingsPageTitles.Apis;
|
||||
return t`API Keys - Settings`;
|
||||
case SettingsPathPrefixes.ServerlessFunctions:
|
||||
return SettingsPageTitles.ServerlessFunctions;
|
||||
return t`Functions - Settings`;
|
||||
case SettingsPathPrefixes.Integration:
|
||||
return SettingsPageTitles.Integration;
|
||||
return t`Integrations - Settings`;
|
||||
case SettingsPathPrefixes.General:
|
||||
return SettingsPageTitles.General;
|
||||
return t`General - Settings`;
|
||||
default:
|
||||
return 'Twenty';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user