Date field format display (#11384)

## Introduction

This PR enables functionality discussed in [Layout Date
Formatting](https://github.com/twentyhq/core-team-issues/issues/97).

### TLDR;
It enables greater control of date formatting at the object's field
level by upgrading all DATE and DATE_TIME fields' settings from:

```ts
{
    displayAsRelativeDate: boolean
}
```

to:

```ts

type FieldDateDisplayFormat = 'full_date' | 'relative_date' | 'date' | 'time' | 'year' | 'custom'

{
    displayFormat: FieldDateDisplayFormat
}
```

PR also includes an upgrade command that will update any existing DATE
and DATE_TIME fields to the new settings value

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
oliver
2025-04-18 01:00:02 -06:00
committed by GitHub
parent dd0ea2366f
commit 53042cc9dc
33 changed files with 690 additions and 250 deletions

View File

@ -2,8 +2,8 @@ import { ApolloLink, gql, Operation } from '@apollo/client';
import { logDebug } from '~/utils/logDebug';
import { logError } from '~/utils/logError';
import formatTitle from './formatTitle';
import { isDefined } from 'twenty-shared/utils';
import formatTitle from './formatTitle';
const getGroup = (collapsed: boolean) =>
collapsed

View File

@ -0,0 +1,50 @@
import { formatDateISOStringToCustomUnicodeFormat } from '@/localization/utils/formatDateISOStringToCustomUnicodeFormat';
import { formatInTimeZone } from 'date-fns-tz';
jest.mock('date-fns-tz');
describe('formatDateISOStringToCustomUnicodeFormat', () => {
const mockDate = '2023-08-15T10:30:00Z';
const mockTimeZone = 'America/New_York';
const mockTimeFormat = 'HH:mm';
beforeEach(() => {
jest.resetAllMocks();
});
it('should use provided timezone', () => {
formatInTimeZone.mockReturnValue('06:30');
const result = formatDateISOStringToCustomUnicodeFormat(
mockDate,
mockTimeZone,
mockTimeFormat,
);
expect(formatInTimeZone).toHaveBeenCalledWith(
new Date(mockDate),
mockTimeZone,
mockTimeFormat,
);
expect(result).toBe('06:30');
});
it('should gracefully handle errors', () => {
formatInTimeZone.mockImplementation(() => {
throw new Error();
});
const result = formatDateISOStringToCustomUnicodeFormat(
mockDate,
mockTimeZone,
'f',
);
expect(formatInTimeZone).toHaveBeenCalledWith(
new Date(mockDate),
mockTimeZone,
'f',
);
expect(result).toBe('Invalid format string');
});
});

View File

@ -0,0 +1,13 @@
import { formatInTimeZone } from 'date-fns-tz';
export const formatDateISOStringToCustomUnicodeFormat = (
date: string,
timeZone: string,
dateFormat: string,
) => {
try {
return formatInTimeZone(new Date(date), timeZone, dateFormat);
} catch (e) {
return 'Invalid format string';
}
};

View File

@ -6,5 +6,5 @@ export const formatDateISOStringToDate = (
timeZone: string,
dateFormat: DateFormat,
) => {
return formatInTimeZone(new Date(date), timeZone, `${dateFormat}`);
return formatInTimeZone(new Date(date), timeZone, dateFormat);
};

View File

@ -0,0 +1,10 @@
import { format } from 'date-fns';
export const validateCustomDateFormat = (formatString: string): boolean => {
try {
format(new Date(), formatString);
return true;
} catch {
return false;
}
};

View File

@ -1,10 +1,12 @@
import { FieldDateMetadataSettings } from '@/object-record/record-field/types/FieldMetadata';
import { ThemeColor } from 'twenty-ui/theme';
import {
Field,
Object as MetadataObject,
RelationDefinition,
RelationDefinitionType,
} from '~/generated-metadata/graphql';
import { ThemeColor } from 'twenty-ui/theme';
export type FieldMetadataItemOption = {
color: ThemeColor;
@ -35,8 +37,6 @@ export type FieldMetadataItem = Omit<
'id' | 'nameSingular' | 'namePlural'
>;
} | null;
settings?: {
displayAsRelativeDate?: boolean;
};
settings?: FieldDateMetadataSettings;
isLabelSyncedWithName?: boolean | null;
};

View File

@ -10,13 +10,13 @@ import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-tabl
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { t } from '@lingui/core/macro';
import isEmpty from 'lodash.isempty';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { formatDateString } from '~/utils/string/formatDateString';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
export const computeAggregateValueAndLabel = ({
data,
@ -63,7 +63,7 @@ export const computeAggregateValueAndLabel = ({
let value;
const displayAsRelativeDate = field?.settings?.displayAsRelativeDate;
const dateFieldSettings = field?.settings;
if (
COUNT_AGGREGATE_OPERATION_OPTIONS.includes(
@ -101,10 +101,10 @@ export const computeAggregateValueAndLabel = ({
value = aggregateValue as string;
value = formatDateTimeString({
value,
displayAsRelativeDate,
timeZone,
dateFormat,
timeFormat,
dateFieldSettings,
});
break;
}
@ -113,9 +113,9 @@ export const computeAggregateValueAndLabel = ({
value = aggregateValue as string;
value = formatDateString({
value,
displayAsRelativeDate,
timeZone,
dateFormat,
dateFieldSettings,
});
break;
}

View File

@ -4,13 +4,9 @@ import { DateDisplay } from '@/ui/field/display/components/DateDisplay';
export const DateFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useDateFieldDisplay();
const displayAsRelativeDate =
fieldDefinition.metadata?.settings?.displayAsRelativeDate;
const dateFieldSettings = fieldDefinition.metadata?.settings;
return (
<DateDisplay
value={fieldValue}
displayAsRelativeDate={displayAsRelativeDate}
/>
<DateDisplay value={fieldValue} dateFieldSettings={dateFieldSettings} />
);
};

View File

@ -4,13 +4,9 @@ import { DateTimeDisplay } from '@/ui/field/display/components/DateTimeDisplay';
export const DateTimeFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useDateTimeFieldDisplay();
const displayAsRelativeDate =
fieldDefinition.metadata?.settings?.displayAsRelativeDate;
const dateFieldSettings = fieldDefinition.metadata?.settings;
return (
<DateTimeDisplay
value={fieldValue}
displayAsRelativeDate={displayAsRelativeDate}
/>
<DateTimeDisplay value={fieldValue} dateFieldSettings={dateFieldSettings} />
);
};

View File

@ -28,18 +28,32 @@ export type FieldTextMetadata = BaseFieldMetadata & {
};
};
export enum FieldDateDisplayFormat {
RELATIVE = 'RELATIVE',
USER_SETTINGS = 'USER_SETTINGS',
CUSTOM = 'CUSTOM',
}
export type FieldDateMetadataSettings =
| {
displayFormat?: FieldDateDisplayFormat.CUSTOM;
customUnicodeDateFormat: string;
}
| {
displayFormat?: Exclude<
FieldDateDisplayFormat,
FieldDateDisplayFormat.CUSTOM
>;
};
export type FieldDateTimeMetadata = BaseFieldMetadata & {
placeHolder: string;
settings?: {
displayAsRelativeDate?: boolean;
};
settings?: FieldDateMetadataSettings;
};
export type FieldDateMetadata = BaseFieldMetadata & {
placeHolder: string;
settings?: {
displayAsRelativeDate?: boolean;
};
settings?: FieldDateMetadataSettings;
};
export type FieldNumberVariant = 'number' | 'percentage';

View File

@ -0,0 +1,6 @@
import { FieldDateDisplayFormat } from '../FieldMetadata';
export const isDateFieldCustomDisplayFormat = (
displayFormat: FieldDateDisplayFormat,
): displayFormat is FieldDateDisplayFormat =>
displayFormat === FieldDateDisplayFormat.CUSTOM;

View File

@ -233,7 +233,7 @@ describe('useRecordData', () => {
relationType: undefined,
targetFieldMetadataName: '',
settings: {
displayAsRelativeDate: true,
displayFormat: 'RELATIVE',
},
},
position: 10,

View File

@ -11,7 +11,7 @@ import { IconComponent } from 'twenty-ui/display';
type SettingsOptionCardContentSelectProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
description?: string | React.ReactNode;
disabled?: boolean;
children?: React.ReactNode;
};

View File

@ -1,20 +1,44 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { validateCustomDateFormat } from '@/localization/utils/validateCustomDateFormat';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata';
import { isDateFieldCustomDisplayFormat } from '@/object-record/record-field/types/guards/isDateFIeldCustomDisplayFormat';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { ADVANCED_SETTINGS_ANIMATION_DURATION } from '@/settings/constants/AdvancedSettingsAnimationDurations';
import { useDateSettingsFormInitialValues } from '@/settings/data-model/fields/forms/date/hooks/useDateSettingsFormInitialValues';
import { getDisplayFormatLabel } from '@/settings/data-model/fields/forms/date/utils/getDisplayFormatLabel';
import { getDisplayFormatSelectDescription } from '@/settings/data-model/fields/forms/date/utils/getDisplayFormatSelectDescription';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconSlash } from 'twenty-ui/display';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
const fieldDateSettings = z.discriminatedUnion('displayFormat', [
z.object({
displayFormat: z.enum([
FieldDateDisplayFormat.RELATIVE,
FieldDateDisplayFormat.USER_SETTINGS,
]),
}),
z.object({
displayFormat: z.literal(FieldDateDisplayFormat.CUSTOM),
customUnicodeDateFormat: z.string().refine(validateCustomDateFormat),
}),
]);
export const settingsDataModelFieldDateFormSchema = z.object({
settings: z
.object({
displayAsRelativeDate: z.boolean().optional(),
})
.optional(),
settings: fieldDateSettings.optional(),
});
const StyledTextInput = styled(TextInput)`
padding: ${({ theme }) => theme.spacing(4)};
padding-top: 0;
`;
export type SettingsDataModelFieldDateFormValues = z.infer<
typeof settingsDataModelFieldDateFormSchema
>;
@ -30,27 +54,78 @@ export const SettingsDataModelFieldDateForm = ({
}: SettingsDataModelFieldDateFormProps) => {
const { t } = useLingui();
const { control } = useFormContext<SettingsDataModelFieldDateFormValues>();
const { control, watch } =
useFormContext<SettingsDataModelFieldDateFormValues>();
const { initialDisplayAsRelativeDateValue } =
const { initialDisplayFormat, initialCustomUnicodeDateFormat } =
useDateSettingsFormInitialValues({
fieldMetadataItem,
});
const displayFormatFromForm = watch('settings.displayFormat');
const activeDisplayFormat = displayFormatFromForm
? displayFormatFromForm
: initialDisplayFormat;
const showCustomFormatTextInput =
isDateFieldCustomDisplayFormat(activeDisplayFormat);
const displayFormatSelectDescription =
getDisplayFormatSelectDescription(activeDisplayFormat);
return (
<Controller
name="settings.displayAsRelativeDate"
control={control}
defaultValue={initialDisplayAsRelativeDateValue}
render={({ field: { onChange, value } }) => (
<SettingsOptionCardContentToggle
Icon={IconSlash}
title={t`Display as relative date`}
checked={value ?? false}
disabled={disabled}
onChange={onChange}
<>
<Controller
name="settings.displayFormat"
control={control}
defaultValue={initialDisplayFormat}
render={({ field: { onChange, value } }) => (
<SettingsOptionCardContentSelect
Icon={IconSlash}
title={t`Display Format`}
disabled={disabled}
description={displayFormatSelectDescription}
>
<Select<FieldDateDisplayFormat>
disabled={disabled}
selectSizeVariant="small"
dropdownWidth={120}
dropdownId="selectFieldDateDisplayFormat"
value={value}
onChange={onChange}
options={Object.keys(FieldDateDisplayFormat).map((key) => {
return {
label: getDisplayFormatLabel(key as FieldDateDisplayFormat),
value: key as FieldDateDisplayFormat,
};
})}
/>
</SettingsOptionCardContentSelect>
)}
/>
<AnimatedExpandableContainer
isExpanded={showCustomFormatTextInput}
dimension={'height'}
animationDurations={ADVANCED_SETTINGS_ANIMATION_DURATION}
mode="scroll-height"
containAnimation={false}
>
<Controller
name="settings.customUnicodeDateFormat"
control={control}
defaultValue={initialCustomUnicodeDateFormat}
render={({ field: { onChange, value } }) => (
<StyledTextInput
placeholder={t`Format e.g. d-MMM-y (qqq''yy)`}
value={value}
onChange={(value) => onChange(value)}
disabled={false}
fullWidth
/>
)}
/>
)}
/>
</AnimatedExpandableContainer>
</>
);
};

View File

@ -31,7 +31,7 @@ export const SettingsDataModelFieldDateSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldDateSettingsFormCardProps) => {
const { initialDisplayAsRelativeDateValue } =
const { initialDisplayFormat, initialCustomUnicodeDateFormat } =
useDateSettingsFormInitialValues({
fieldMetadataItem,
});
@ -46,9 +46,13 @@ export const SettingsDataModelFieldDateSettingsFormCard = ({
fieldMetadataItem={{
...fieldMetadataItem,
settings: {
displayAsRelativeDate: watchFormValue(
'settings.displayAsRelativeDate',
initialDisplayAsRelativeDateValue,
displayFormat: watchFormValue(
'settings.displayFormat',
initialDisplayFormat,
),
customUnicodeDateFormat: watchFormValue(
'settings.customUnicodeDateFormat',
initialCustomUnicodeDateFormat,
),
},
}}

View File

@ -1,6 +1,7 @@
import { useFormContext } from 'react-hook-form';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata';
import { SettingsDataModelFieldDateFormValues } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
export const useDateSettingsFormInitialValues = ({
@ -8,18 +9,24 @@ export const useDateSettingsFormInitialValues = ({
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'settings'>;
}) => {
const initialDisplayAsRelativeDateValue =
fieldMetadataItem?.settings?.displayAsRelativeDate;
const initialDisplayFormat = fieldMetadataItem?.settings
?.displayFormat as FieldDateDisplayFormat;
const initialCustomUnicodeDateFormat = fieldMetadataItem?.settings
?.customUnicodeDateFormat as string;
const { resetField } = useFormContext<SettingsDataModelFieldDateFormValues>();
const resetDefaultValueField = () =>
resetField('settings.displayAsRelativeDate', {
defaultValue: initialDisplayAsRelativeDateValue,
resetField('settings', {
defaultValue: {
displayFormat: initialDisplayFormat,
customUnicodeDateFormat: initialCustomUnicodeDateFormat,
},
});
return {
initialDisplayAsRelativeDateValue,
initialDisplayFormat,
initialCustomUnicodeDateFormat,
resetDefaultValueField,
};
};

View File

@ -0,0 +1,17 @@
import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata';
import { t } from '@lingui/core/macro';
export const getDisplayFormatLabel = (
displayFormat: FieldDateDisplayFormat,
) => {
switch (displayFormat) {
case FieldDateDisplayFormat.CUSTOM:
return t`Custom`;
case FieldDateDisplayFormat.RELATIVE:
return t`Relative`;
case FieldDateDisplayFormat.USER_SETTINGS:
return t`Default`;
default:
return '';
}
};

View File

@ -0,0 +1,24 @@
import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata';
import { Trans } from '@lingui/react/macro';
export const getDisplayFormatSelectDescription = (
selectedDisplayFormat: FieldDateDisplayFormat,
) => {
if (selectedDisplayFormat === FieldDateDisplayFormat.CUSTOM) {
return (
<Trans>
Enter in{' '}
<a
href="https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'underline', color: 'inherit' }}
>
Unicode
</a>{' '}
format
</Trans>
);
}
return <Trans>Choose the format used to display date value</Trans>;
};

View File

@ -1,3 +1,4 @@
import { FieldDateMetadataSettings } from '@/object-record/record-field/types/FieldMetadata';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { formatDateString } from '~/utils/string/formatDateString';
@ -5,20 +6,17 @@ import { EllipsisDisplay } from './EllipsisDisplay';
type DateDisplayProps = {
value: string | null | undefined;
displayAsRelativeDate?: boolean;
dateFieldSettings?: FieldDateMetadataSettings;
};
export const DateDisplay = ({
value,
displayAsRelativeDate,
}: DateDisplayProps) => {
export const DateDisplay = ({ value, dateFieldSettings }: DateDisplayProps) => {
const { dateFormat, timeZone } = useContext(UserContext);
const formattedDate = formatDateString({
value,
timeZone,
dateFormat,
displayAsRelativeDate,
dateFieldSettings,
});
return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;

View File

@ -1,3 +1,4 @@
import { FieldDateMetadataSettings } from '@/object-record/record-field/types/FieldMetadata';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
@ -5,21 +6,21 @@ import { EllipsisDisplay } from './EllipsisDisplay';
type DateTimeDisplayProps = {
value: string | null | undefined;
displayAsRelativeDate?: boolean;
dateFieldSettings?: FieldDateMetadataSettings;
};
export const DateTimeDisplay = ({
value,
displayAsRelativeDate,
dateFieldSettings,
}: DateTimeDisplayProps) => {
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const formattedDate = formatDateTimeString({
value,
displayAsRelativeDate,
timeZone,
dateFormat,
timeFormat,
dateFieldSettings,
});
return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;