fixing Numbers formatting (#14403)
fix #13880 --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -3899,6 +3899,7 @@ export type WorkspaceMember = {
|
||||
id: Scalars['UUID'];
|
||||
locale?: Maybe<Scalars['String']>;
|
||||
name: FullName;
|
||||
numberFormat?: Maybe<WorkspaceMemberNumberFormatEnum>;
|
||||
roles?: Maybe<Array<Role>>;
|
||||
timeFormat?: Maybe<WorkspaceMemberTimeFormatEnum>;
|
||||
timeZone?: Maybe<Scalars['String']>;
|
||||
@@ -3914,6 +3915,15 @@ export enum WorkspaceMemberDateFormatEnum {
|
||||
YEAR_FIRST = 'YEAR_FIRST'
|
||||
}
|
||||
|
||||
/** Number format for displaying numbers */
|
||||
export enum WorkspaceMemberNumberFormatEnum {
|
||||
APOSTROPHE_AND_DOT = 'APOSTROPHE_AND_DOT',
|
||||
COMMAS_AND_DOT = 'COMMAS_AND_DOT',
|
||||
DOTS_AND_COMMA = 'DOTS_AND_COMMA',
|
||||
SPACES_AND_COMMA = 'SPACES_AND_COMMA',
|
||||
SYSTEM = 'SYSTEM'
|
||||
}
|
||||
|
||||
/** Time time as Military, Standard or system as default */
|
||||
export enum WorkspaceMemberTimeFormatEnum {
|
||||
HOUR_12 = 'HOUR_12',
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ import { ProgressBar } from 'twenty-ui/feedback';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
|
||||
import { SubscriptionStatus } from '~/generated/graphql';
|
||||
import { formatAmount } from '~/utils/format/formatAmount';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
// import { useListAvailableMeteredBillingPricesQuery } from '~/generated-metadata/graphql';
|
||||
// import { MeteredPriceSelector } from '@/billing/components/internal/MeteredPriceSelector';
|
||||
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
|
||||
@@ -54,7 +54,7 @@ export const SettingsBillingCreditsSection = ({
|
||||
<SubscriptionInfoContainer>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Credits Used`}
|
||||
value={`${formatNumber(usedCredits)}/${formatAmount(grantedCredits)}`}
|
||||
value={`${formatNumber(usedCredits)}/${formatToShortNumber(grantedCredits)}`}
|
||||
/>
|
||||
<ProgressBar
|
||||
value={progressBarValue}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
||||
SubscriptionInterval,
|
||||
} from '~/generated/graphql';
|
||||
import { findOrThrow } from '~/utils/array/findOrThrow';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
const compareByAmountAsc = (a: BillingPriceOutput, b: BillingPriceOutput) =>
|
||||
a.amount - b.amount;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum NumberFormat {
|
||||
SYSTEM = 'SYSTEM',
|
||||
COMMAS_AND_DOT = 'COMMAS_AND_DOT',
|
||||
SPACES_AND_COMMA = 'SPACES_AND_COMMA',
|
||||
DOTS_AND_COMMA = 'DOTS_AND_COMMA',
|
||||
APOSTROPHE_AND_DOT = 'APOSTROPHE_AND_DOT',
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { detectNumberFormat } from '../detectNumberFormat';
|
||||
|
||||
// Mock navigator.language
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'en-US',
|
||||
});
|
||||
|
||||
describe('detectNumberFormat', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to default
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'en-US',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect COMMAS_AND_DOT format for en-US locale', () => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'en-US',
|
||||
});
|
||||
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
|
||||
});
|
||||
|
||||
it('should detect SPACES_AND_COMMA format for fr-FR locale', () => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'fr-FR',
|
||||
});
|
||||
expect(detectNumberFormat()).toBe('SPACES_AND_COMMA');
|
||||
});
|
||||
|
||||
it('should detect DOTS_AND_COMMA format for de-DE locale', () => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'de-DE',
|
||||
});
|
||||
expect(detectNumberFormat()).toBe('DOTS_AND_COMMA');
|
||||
});
|
||||
|
||||
it('should detect APOSTROPHE_AND_DOT format for de-CH locale', () => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'de-CH',
|
||||
});
|
||||
expect(detectNumberFormat()).toBe('APOSTROPHE_AND_DOT');
|
||||
});
|
||||
|
||||
it('should fallback to COMMAS_AND_DOT for unknown patterns', () => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'ja-JP', // Uses different separators not in our patterns
|
||||
});
|
||||
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
|
||||
});
|
||||
|
||||
it('should handle invalid locale gracefully', () => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: 'invalid-locale',
|
||||
});
|
||||
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
|
||||
});
|
||||
|
||||
it('should handle missing navigator.language', () => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
|
||||
const SPACE_CHARS = new Set([' ', '\u00A0', '\u202F']); // space, non-breaking space, narrow no-break space
|
||||
const APOSTROPHE_CHARS = new Set(["'", '\u2019']); // apostrophe, right single quotation mark
|
||||
|
||||
const FORMAT_PATTERNS = new Map<string, keyof typeof NumberFormat>([
|
||||
[',|.', NumberFormat.COMMAS_AND_DOT],
|
||||
['.|,', NumberFormat.DOTS_AND_COMMA],
|
||||
['space|,', NumberFormat.SPACES_AND_COMMA],
|
||||
['apostrophe|.', NumberFormat.APOSTROPHE_AND_DOT],
|
||||
]);
|
||||
|
||||
export const detectNumberFormat = (): keyof typeof NumberFormat => {
|
||||
const testNumber = 1234567.89;
|
||||
let language = navigator?.language || 'en-US';
|
||||
|
||||
let formatter: Intl.NumberFormat;
|
||||
try {
|
||||
formatter = new Intl.NumberFormat(language);
|
||||
} catch {
|
||||
formatter = new Intl.NumberFormat('en-US');
|
||||
}
|
||||
|
||||
const parts = formatter.formatToParts(testNumber);
|
||||
const thousandSeparator =
|
||||
parts.find((part) => part.type === 'group')?.value || '';
|
||||
const decimalSeparator =
|
||||
parts.find((part) => part.type === 'decimal')?.value || '';
|
||||
|
||||
let thousandCategory: string;
|
||||
if (SPACE_CHARS.has(thousandSeparator)) {
|
||||
thousandCategory = 'space';
|
||||
} else if (APOSTROPHE_CHARS.has(thousandSeparator)) {
|
||||
thousandCategory = 'apostrophe';
|
||||
} else {
|
||||
thousandCategory = thousandSeparator;
|
||||
}
|
||||
|
||||
const pattern = `${thousandCategory}|${decimalSeparator}`;
|
||||
return FORMAT_PATTERNS.get(pattern) || NumberFormat.COMMAS_AND_DOT;
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { detectNumberFormat } from '@/localization/utils/detectNumberFormat';
|
||||
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
|
||||
|
||||
export const getNumberFormatFromWorkspaceNumberFormat = (
|
||||
numberFormat: WorkspaceMemberNumberFormatEnum,
|
||||
): NumberFormat => {
|
||||
switch (numberFormat) {
|
||||
case WorkspaceMemberNumberFormatEnum.SYSTEM:
|
||||
return NumberFormat[detectNumberFormat()];
|
||||
case WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT:
|
||||
return NumberFormat.COMMAS_AND_DOT;
|
||||
case WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA:
|
||||
return NumberFormat.SPACES_AND_COMMA;
|
||||
case WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA:
|
||||
return NumberFormat.DOTS_AND_COMMA;
|
||||
case WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT:
|
||||
return NumberFormat.APOSTROPHE_AND_DOT;
|
||||
default:
|
||||
return NumberFormat.COMMAS_AND_DOT;
|
||||
}
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
|
||||
|
||||
export const getWorkspaceNumberFormatFromNumberFormat = (
|
||||
numberFormat: NumberFormat,
|
||||
): WorkspaceMemberNumberFormatEnum => {
|
||||
switch (numberFormat) {
|
||||
case NumberFormat.SYSTEM:
|
||||
return WorkspaceMemberNumberFormatEnum.SYSTEM;
|
||||
case NumberFormat.COMMAS_AND_DOT:
|
||||
return WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT;
|
||||
case NumberFormat.SPACES_AND_COMMA:
|
||||
return WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA;
|
||||
case NumberFormat.DOTS_AND_COMMA:
|
||||
return WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA;
|
||||
case NumberFormat.APOSTROPHE_AND_DOT:
|
||||
return WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT;
|
||||
default:
|
||||
return WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT;
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
export const useBatchCreateManyRecords = <
|
||||
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
|
||||
|
||||
+3
-3
@@ -13,8 +13,8 @@ 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 { formatToShortNumber } from '~/utils/format/formatToShortNumber';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
import { formatDateString } from '~/utils/string/formatDateString';
|
||||
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
|
||||
|
||||
@@ -83,7 +83,7 @@ export const computeAggregateValueAndLabel = ({
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.CURRENCY: {
|
||||
value = Number(aggregateValue);
|
||||
value = formatAmount(value / 1_000_000);
|
||||
value = formatToShortNumber(value / 1_000_000);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
+28
-12
@@ -1,22 +1,38 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { getNumberFormatFromWorkspaceNumberFormat } from '@/localization/utils/getNumberFormatFromWorkspaceNumberFormat';
|
||||
import { useNumberFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useNumberFieldDisplay';
|
||||
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { formatAmount } from '~/utils/format/formatAmount';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
export const NumberFieldDisplay = () => {
|
||||
const { fieldValue, fieldDefinition } = useNumberFieldDisplay();
|
||||
const decimals = fieldDefinition.metadata.settings?.decimals;
|
||||
const [currentWorkspaceMember] = useRecoilState(currentWorkspaceMemberState);
|
||||
const type = fieldDefinition.metadata.settings?.type;
|
||||
const decimals = fieldDefinition.metadata.settings?.decimals;
|
||||
|
||||
if (!isDefined(fieldValue))
|
||||
return <NumberDisplay value={null} decimals={decimals} />;
|
||||
const value =
|
||||
type === 'percentage'
|
||||
? `${formatNumber(Number(fieldValue) * 100, decimals)}%`
|
||||
: type === 'shortNumber'
|
||||
? formatAmount(Number(fieldValue))
|
||||
: formatNumber(Number(fieldValue), decimals);
|
||||
if (!isDefined(fieldValue)) {
|
||||
return <NumberDisplay value={null} />;
|
||||
}
|
||||
|
||||
return <NumberDisplay value={value} decimals={decimals} />;
|
||||
const numericValue = Number(fieldValue);
|
||||
let formattedValue: string;
|
||||
|
||||
if (type === 'percentage') {
|
||||
formattedValue = `${formatNumber(numericValue * 100, getNumberFormatFromWorkspaceNumberFormat(currentWorkspaceMember?.numberFormat!), decimals)}%`;
|
||||
} else if (type === 'shortNumber') {
|
||||
formattedValue = formatToShortNumber(numericValue);
|
||||
} else {
|
||||
formattedValue = formatNumber(
|
||||
numericValue,
|
||||
getNumberFormatFromWorkspaceNumberFormat(
|
||||
currentWorkspaceMember?.numberFormat!,
|
||||
),
|
||||
decimals,
|
||||
);
|
||||
}
|
||||
|
||||
return <NumberDisplay value={formattedValue} />;
|
||||
};
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { formatAmount } from '~/utils/format/formatAmount';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
|
||||
|
||||
export type GraphValueFormatOptions = {
|
||||
displayType?: 'percentage' | 'number' | 'shortNumber' | 'currency' | 'custom';
|
||||
@@ -31,7 +31,7 @@ export const formatGraphValue = (
|
||||
return `${formatNumber(value * 100, decimals)}%`;
|
||||
|
||||
case 'shortNumber':
|
||||
return `${prefix}${formatAmount(value)}${suffix}`;
|
||||
return `${prefix}${formatToShortNumber(value)}${suffix}`;
|
||||
|
||||
case 'currency': {
|
||||
const currencyPrefix = prefix || '$';
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { NUMBER_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/f
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { IconDecimal, IconEye } from 'twenty-ui/display';
|
||||
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/number';
|
||||
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
|
||||
|
||||
export const settingsDataModelFieldNumberFormSchema = z.object({
|
||||
settings: numberFieldDefaultValueSchema,
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
||||
import { detectTimeZone } from '@/localization/utils/detectTimeZone';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
type DateTimeSettingsDateFormatSelectProps = {
|
||||
value: DateFormat;
|
||||
@@ -17,6 +17,8 @@ export const DateTimeSettingsDateFormatSelect = ({
|
||||
timeZone,
|
||||
value,
|
||||
}: DateTimeSettingsDateFormatSelectProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const systemTimeZone = detectTimeZone();
|
||||
|
||||
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
|
||||
|
||||
+2
-2
@@ -56,11 +56,11 @@ export const DateTimeSettingsTimeFormatSelect = ({
|
||||
value: TimeFormat.SYSTEM,
|
||||
},
|
||||
{
|
||||
label: t`24h (${hour24Label})`,
|
||||
label: t`24h - ${hour24Label}`,
|
||||
value: TimeFormat.HOUR_24,
|
||||
},
|
||||
{
|
||||
label: t`12h (${hour12Label})`,
|
||||
label: t`12h - ${hour12Label}`,
|
||||
value: TimeFormat.HOUR_12,
|
||||
},
|
||||
]}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { detectNumberFormat } from '@/localization/utils/detectNumberFormat';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
type NumberFormatSelectProps = {
|
||||
value: NumberFormat;
|
||||
onChange: (nextValue: NumberFormat) => void;
|
||||
};
|
||||
|
||||
export const NumberFormatSelect = ({
|
||||
onChange,
|
||||
value,
|
||||
}: NumberFormatSelectProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const systemNumberFormat = NumberFormat[detectNumberFormat()];
|
||||
|
||||
const systemNumberFormatLabel = formatNumber(1234.56, systemNumberFormat, 2);
|
||||
const commasAndDotExample = formatNumber(
|
||||
1234.56,
|
||||
NumberFormat.COMMAS_AND_DOT,
|
||||
2,
|
||||
);
|
||||
const spacesAndCommaExample = formatNumber(
|
||||
1234.56,
|
||||
NumberFormat.SPACES_AND_COMMA,
|
||||
2,
|
||||
);
|
||||
const dotsAndCommaExample = formatNumber(
|
||||
1234.56,
|
||||
NumberFormat.DOTS_AND_COMMA,
|
||||
2,
|
||||
);
|
||||
const apostropheAndDotExample = formatNumber(
|
||||
1234.56,
|
||||
NumberFormat.APOSTROPHE_AND_DOT,
|
||||
2,
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
dropdownId="number-format-select"
|
||||
dropdownWidth={218}
|
||||
label={t`Number format`}
|
||||
dropdownWidthAuto
|
||||
fullWidth
|
||||
value={value}
|
||||
options={[
|
||||
{
|
||||
label: t`System Settings - ${systemNumberFormatLabel}`,
|
||||
value: NumberFormat.SYSTEM,
|
||||
},
|
||||
{
|
||||
label: t`Commas and dot - ${commasAndDotExample}`,
|
||||
value: NumberFormat.COMMAS_AND_DOT,
|
||||
},
|
||||
{
|
||||
label: t`Spaces and comma - ${spacesAndCommaExample}`,
|
||||
value: NumberFormat.SPACES_AND_COMMA,
|
||||
},
|
||||
{
|
||||
label: t`Dots and comma - ${dotsAndCommaExample}`,
|
||||
value: NumberFormat.DOTS_AND_COMMA,
|
||||
},
|
||||
{
|
||||
label: t`Apostrophe and dot - ${apostropheAndDotExample}`,
|
||||
value: NumberFormat.APOSTROPHE_AND_DOT,
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Loader } from 'twenty-ui/feedback';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
const StyledContent = styled(Modal.Content)`
|
||||
align-items: center;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { MainButton } from 'twenty-ui/input';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
|
||||
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { formatAmount } from '~/utils/format/formatAmount';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
type CurrencyDisplayProps = {
|
||||
@@ -52,7 +52,7 @@ export const CurrencyDisplay = ({
|
||||
)}
|
||||
{amountToDisplay !== null
|
||||
? !isDefined(format) || format === 'short'
|
||||
? formatAmount(amountToDisplay)
|
||||
? formatToShortNumber(amountToDisplay)
|
||||
: formatNumber(amountToDisplay)
|
||||
: null}
|
||||
</EllipsisDisplay>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
|
||||
+1
@@ -15,5 +15,6 @@ export const WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
|
||||
dateFormat
|
||||
timeFormat
|
||||
calendarStartDay
|
||||
numberFormat
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type WorkspaceMemberDateFormatEnum,
|
||||
type WorkspaceMemberNumberFormatEnum,
|
||||
type WorkspaceMemberTimeFormatEnum,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
@@ -23,6 +24,7 @@ export type WorkspaceMember = {
|
||||
timeZone?: string | null;
|
||||
dateFormat?: WorkspaceMemberDateFormatEnum | null;
|
||||
timeFormat?: WorkspaceMemberTimeFormatEnum | null;
|
||||
numberFormat?: WorkspaceMemberNumberFormatEnum | null;
|
||||
calendarStartDay?: number | null;
|
||||
};
|
||||
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { getNumberFormatFromWorkspaceNumberFormat } from '@/localization/utils/getNumberFormatFromWorkspaceNumberFormat';
|
||||
import { getWorkspaceNumberFormatFromNumberFormat } from '@/localization/utils/getWorkspaceNumberFormatFromNumberFormat';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { NumberFormatSelect } from '@/settings/experience/components/NumberFormatSelect';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export const NumberFormatSettings = () => {
|
||||
const { updateOneRecord } = useUpdateOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
|
||||
});
|
||||
|
||||
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
|
||||
const handleNumberFormatChange = async (value: NumberFormat) => {
|
||||
if (!currentWorkspaceMember?.id) {
|
||||
logError('User is not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceNumberFormat =
|
||||
getWorkspaceNumberFormatFromNumberFormat(value);
|
||||
|
||||
try {
|
||||
await updateOneRecord({
|
||||
idToUpdate: currentWorkspaceMember.id,
|
||||
updateOneRecordInput: {
|
||||
numberFormat: workspaceNumberFormat,
|
||||
},
|
||||
});
|
||||
|
||||
setCurrentWorkspaceMember({
|
||||
...currentWorkspaceMember,
|
||||
numberFormat: workspaceNumberFormat,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const numberFormat = currentWorkspaceMember?.numberFormat
|
||||
? currentWorkspaceMember.numberFormat ===
|
||||
WorkspaceMemberNumberFormatEnum.SYSTEM
|
||||
? NumberFormat.SYSTEM
|
||||
: getNumberFormatFromWorkspaceNumberFormat(
|
||||
currentWorkspaceMember.numberFormat,
|
||||
)
|
||||
: NumberFormat.SYSTEM;
|
||||
|
||||
return (
|
||||
<NumberFormatSelect
|
||||
value={numberFormat}
|
||||
onChange={handleNumberFormatChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+8
-2
@@ -2,7 +2,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
@@ -10,10 +9,10 @@ import { ColorSchemePicker } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { DateTimeSettings } from '~/pages/settings/profile/appearance/components/DateTimeSettings';
|
||||
import { LocalePicker } from '~/pages/settings/profile/appearance/components/LocalePicker';
|
||||
import { NumberFormatSettings } from '~/pages/settings/profile/appearance/components/NumberFormatSettings';
|
||||
|
||||
export const SettingsExperience = () => {
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
@@ -54,6 +53,13 @@ export const SettingsExperience = () => {
|
||||
/>
|
||||
<DateTimeSettings />
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Other formats`}
|
||||
description={t`Choose additional formatting preferences`}
|
||||
/>
|
||||
<NumberFormatSettings />
|
||||
</Section>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { formatAmount } from '../formatAmount';
|
||||
|
||||
describe('amountFormat', () => {
|
||||
it('formats numbers less than 1000 correctly', () => {
|
||||
expect(formatAmount(500)).toBe('500');
|
||||
expect(formatAmount(123.456)).toBe('123.5');
|
||||
});
|
||||
|
||||
it('formats numbers between 1000 and 999999 correctly', () => {
|
||||
expect(formatAmount(1500)).toBe('1.5k');
|
||||
expect(formatAmount(789456)).toBe('789.5k');
|
||||
});
|
||||
|
||||
it('formats numbers between 1000000 and 999999999 correctly', () => {
|
||||
expect(formatAmount(2000000)).toBe('2m');
|
||||
expect(formatAmount(654987654)).toBe('655m');
|
||||
});
|
||||
|
||||
it('formats numbers greater than or equal to 1000000000 correctly', () => {
|
||||
expect(formatAmount(1200000000)).toBe('1.2b');
|
||||
expect(formatAmount(987654321987)).toBe('987.7b');
|
||||
});
|
||||
|
||||
it('handles numbers with decimal places correctly', () => {
|
||||
expect(formatAmount(123.456)).toBe('123.5');
|
||||
expect(formatAmount(789.0123)).toBe('789');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { formatNumber } from '../formatNumber';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
describe('without format (backward compatibility)', () => {
|
||||
it('should format 123 correctly', () => {
|
||||
expect(formatNumber(123)).toEqual('123');
|
||||
});
|
||||
it('should format decimal numbers correctly', () => {
|
||||
expect(formatNumber(123.92, 2)).toEqual('123.92');
|
||||
});
|
||||
it('should format large numbers correctly', () => {
|
||||
expect(formatNumber(1234567)).toEqual('1,234,567');
|
||||
});
|
||||
it('should format large numbers with a decimal point correctly', () => {
|
||||
expect(formatNumber(7654321.89, 2)).toEqual('7,654,321.89');
|
||||
});
|
||||
it('should format apply decimals correctly', () => {
|
||||
expect(formatNumber(123.456, 2)).toEqual('123.46');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with localized formats', () => {
|
||||
it('should format with COMMAS_AND_DOT', () => {
|
||||
expect(formatNumber(1234.56, NumberFormat.COMMAS_AND_DOT, 2)).toEqual(
|
||||
'1,234.56',
|
||||
);
|
||||
});
|
||||
it('should format with SPACES_AND_COMMA', () => {
|
||||
expect(formatNumber(1234.56, NumberFormat.SPACES_AND_COMMA, 2)).toEqual(
|
||||
'1\u202F234,56', // Uses narrow no-break space (U+202F)
|
||||
);
|
||||
});
|
||||
it('should format with DOTS_AND_COMMA', () => {
|
||||
expect(formatNumber(1234.56, NumberFormat.DOTS_AND_COMMA, 2)).toEqual(
|
||||
'1.234,56',
|
||||
);
|
||||
});
|
||||
it('should format with APOSTROPHE_AND_DOT', () => {
|
||||
expect(formatNumber(1234.56, NumberFormat.APOSTROPHE_AND_DOT, 2)).toEqual(
|
||||
'1\u2019234.56', // Uses right single quotation mark (U+2019)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { formatToShortNumber } from '../formatToShortNumber';
|
||||
|
||||
describe('formatToShortNumber', () => {
|
||||
it('formats numbers less than 1000 correctly', () => {
|
||||
expect(formatToShortNumber(500)).toBe('500');
|
||||
expect(formatToShortNumber(123.456)).toBe('123.5');
|
||||
});
|
||||
|
||||
it('formats numbers between 1000 and 999999 correctly', () => {
|
||||
expect(formatToShortNumber(1500)).toBe('1.5k');
|
||||
expect(formatToShortNumber(789456)).toBe('789.5k');
|
||||
});
|
||||
|
||||
it('formats numbers between 1000000 and 999999999 correctly', () => {
|
||||
expect(formatToShortNumber(2000000)).toBe('2m');
|
||||
expect(formatToShortNumber(654987654)).toBe('655m');
|
||||
});
|
||||
|
||||
it('formats numbers greater than or equal to 1000000000 correctly', () => {
|
||||
expect(formatToShortNumber(1200000000)).toBe('1.2b');
|
||||
expect(formatToShortNumber(987654321987)).toBe('987.7b');
|
||||
});
|
||||
|
||||
it('handles numbers with decimal places correctly', () => {
|
||||
expect(formatToShortNumber(123.456)).toBe('123.5');
|
||||
expect(formatToShortNumber(789.0123)).toBe('789');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { formatNumber } from '../number';
|
||||
|
||||
// This tests the en-US locale by default
|
||||
describe('formatNumber', () => {
|
||||
it(`Should format 123 correctly`, () => {
|
||||
expect(formatNumber(123)).toEqual('123');
|
||||
});
|
||||
it(`Should format decimal numbers correctly`, () => {
|
||||
expect(formatNumber(123.92, 2)).toEqual('123.92');
|
||||
});
|
||||
it(`Should format large numbers correctly`, () => {
|
||||
expect(formatNumber(1234567)).toEqual('1,234,567');
|
||||
});
|
||||
it(`Should format large numbers with a decimal point correctly`, () => {
|
||||
expect(formatNumber(7654321.89, 2)).toEqual('7,654,321.89');
|
||||
});
|
||||
it('should format apply decimals correctly', () => {
|
||||
expect(formatNumber(123.456, 2)).toEqual('123.46');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { detectNumberFormat } from '@/localization/utils/detectNumberFormat';
|
||||
|
||||
const DEFAULT_DECIMALS = 0;
|
||||
|
||||
export const DEFAULT_DECIMAL_VALUE = DEFAULT_DECIMALS;
|
||||
const DEFAULT_LOCALE = 'en-US';
|
||||
|
||||
const FORMAT_LOCALE_MAP = {
|
||||
[NumberFormat.COMMAS_AND_DOT]: 'en-US',
|
||||
[NumberFormat.SPACES_AND_COMMA]: 'fr-FR',
|
||||
[NumberFormat.DOTS_AND_COMMA]: 'de-DE',
|
||||
[NumberFormat.APOSTROPHE_AND_DOT]: 'de-CH',
|
||||
} as const;
|
||||
|
||||
const getLocaleForFormat = (format?: NumberFormat): string => {
|
||||
if (!format) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
if (format === NumberFormat.SYSTEM) {
|
||||
const detectedFormat = NumberFormat[detectNumberFormat()];
|
||||
return (
|
||||
FORMAT_LOCALE_MAP[detectedFormat as keyof typeof FORMAT_LOCALE_MAP] ??
|
||||
DEFAULT_LOCALE
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
FORMAT_LOCALE_MAP[format as keyof typeof FORMAT_LOCALE_MAP] ??
|
||||
DEFAULT_LOCALE
|
||||
);
|
||||
};
|
||||
|
||||
export const formatNumber = (
|
||||
value: number,
|
||||
decimalsOrFormat?: number | NumberFormat,
|
||||
decimals?: number,
|
||||
): string => {
|
||||
// Parse parameters
|
||||
const isLegacyCall = typeof decimalsOrFormat === 'number';
|
||||
const actualDecimals = isLegacyCall
|
||||
? decimalsOrFormat
|
||||
: (decimals ?? DEFAULT_DECIMALS);
|
||||
const format = isLegacyCall ? undefined : decimalsOrFormat;
|
||||
|
||||
// Create formatting options
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: actualDecimals,
|
||||
maximumFractionDigits: actualDecimals,
|
||||
};
|
||||
|
||||
// Determine locale
|
||||
const locale = getLocaleForFormat(format);
|
||||
|
||||
return new Intl.NumberFormat(locale, options).format(value);
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export const formatAmount = (amount: number) => {
|
||||
export const formatToShortNumber = (amount: number) => {
|
||||
if (amount < 1000) {
|
||||
return amount.toFixed(1).replace(/\.?0+$/, '');
|
||||
} else if (amount < 1000000) {
|
||||
@@ -1,8 +0,0 @@
|
||||
export const DEFAULT_DECIMAL_VALUE = 0;
|
||||
|
||||
export const formatNumber = (value: number, decimals?: number): string => {
|
||||
return value.toLocaleString('en-US', {
|
||||
minimumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
|
||||
maximumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
|
||||
});
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
import {
|
||||
WorkspaceMemberDateFormatEnum,
|
||||
WorkspaceMemberNumberFormatEnum,
|
||||
WorkspaceMemberTimeFormatEnum,
|
||||
} from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
@@ -58,4 +59,7 @@ export class WorkspaceMember {
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
userWorkspaceId?: string;
|
||||
|
||||
@Field(() => WorkspaceMemberNumberFormatEnum, { nullable: true })
|
||||
numberFormat?: WorkspaceMemberNumberFormatEnum;
|
||||
}
|
||||
|
||||
+3
@@ -10,6 +10,7 @@ import { type WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspac
|
||||
import { type RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { fromRoleEntitiesToRoleDtos } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util';
|
||||
import {
|
||||
type WorkspaceMemberNumberFormatEnum,
|
||||
type WorkspaceMemberDateFormatEnum,
|
||||
type WorkspaceMemberTimeFormatEnum,
|
||||
type WorkspaceMemberWorkspaceEntity,
|
||||
@@ -61,6 +62,7 @@ export class WorkspaceMemberTranspiler {
|
||||
timeZone,
|
||||
dateFormat,
|
||||
calendarStartDay,
|
||||
numberFormat,
|
||||
} = workspaceMemberEntity;
|
||||
|
||||
const avatarUrl = this.generateSignedAvatarUrl({
|
||||
@@ -86,6 +88,7 @@ export class WorkspaceMemberTranspiler {
|
||||
timeZone,
|
||||
roles,
|
||||
calendarStartDay,
|
||||
numberFormat: numberFormat as WorkspaceMemberNumberFormatEnum,
|
||||
} satisfies WorkspaceMember;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -526,6 +526,7 @@ export const WORKSPACE_MEMBER_STANDARD_FIELD_IDS = {
|
||||
timeFormat: '20202020-8acb-4cf8-a851-a6ed443c8d81',
|
||||
searchVector: '20202020-46d0-4e7f-bc26-74c0edaeb619',
|
||||
calendarStartDay: '20202020-92d0-1d7f-a126-25ededa6b142',
|
||||
numberFormat: '20202020-7f40-4e7f-b126-11c0eda6b141',
|
||||
} as const;
|
||||
|
||||
export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = {
|
||||
|
||||
+56
@@ -51,6 +51,19 @@ export enum WorkspaceMemberTimeFormatEnum {
|
||||
HOUR_24 = 'HOUR_24',
|
||||
}
|
||||
|
||||
export enum WorkspaceMemberNumberFormatEnum {
|
||||
SYSTEM = 'SYSTEM',
|
||||
COMMAS_AND_DOT = 'COMMAS_AND_DOT',
|
||||
SPACES_AND_COMMA = 'SPACES_AND_COMMA',
|
||||
DOTS_AND_COMMA = 'DOTS_AND_COMMA',
|
||||
APOSTROPHE_AND_DOT = 'APOSTROPHE_AND_DOT',
|
||||
}
|
||||
|
||||
registerEnumType(WorkspaceMemberNumberFormatEnum, {
|
||||
name: 'WorkspaceMemberNumberFormatEnum',
|
||||
description: 'Number format for displaying numbers',
|
||||
});
|
||||
|
||||
registerEnumType(WorkspaceMemberTimeFormatEnum, {
|
||||
name: 'WorkspaceMemberTimeFormatEnum',
|
||||
description: 'Time time as Military, Standard or system as default',
|
||||
@@ -376,4 +389,47 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
@WorkspaceIsSystem()
|
||||
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
|
||||
searchVector: string;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.numberFormat,
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: msg`Number format`,
|
||||
description: msg`User's preferred number format`,
|
||||
icon: 'IconNumbers',
|
||||
options: [
|
||||
{
|
||||
value: WorkspaceMemberNumberFormatEnum.SYSTEM,
|
||||
label: 'System',
|
||||
position: 0,
|
||||
color: 'turquoise',
|
||||
},
|
||||
{
|
||||
value: WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT,
|
||||
label: 'Commas and dot (1,234.56)',
|
||||
position: 1,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
value: WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA,
|
||||
label: 'Spaces and comma (1 234,56)',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA,
|
||||
label: 'Dots and comma (1.234,56)',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
value: WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT,
|
||||
label: "Apostrophe and dot (1'234.56)",
|
||||
position: 4,
|
||||
color: 'purple',
|
||||
},
|
||||
],
|
||||
defaultValue: `'${WorkspaceMemberNumberFormatEnum.SYSTEM}'`,
|
||||
})
|
||||
@WorkspaceIsSystem()
|
||||
numberFormat: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user