download record sample - Import (#12489)

<img width="400" alt="Screenshot 2025-06-10 at 18 14 17"
src="https://github.com/user-attachments/assets/05591b46-c36d-45c6-a236-3469c29d7420"
/>


closes https://github.com/twentyhq/core-team-issues/issues/915

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2025-06-16 16:01:27 +02:00
committed by GitHub
parent 79b8c4660c
commit c16ba6a7d7
32 changed files with 753 additions and 481 deletions

View File

@ -90,9 +90,9 @@ export const AdvancedFilterSubFieldSelectMenu = ({
return null;
}
const subFieldNames =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[objectFilterDropdownSubMenuFieldType]
.filterableSubFields;
const subFieldNames = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[
objectFilterDropdownSubMenuFieldType
].subFields.map((subField) => subField.subFieldName);
const subFieldsAreFilterable =
isDefined(fieldMetadataItemUsedInDropdown) &&

View File

@ -3,10 +3,11 @@ import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldTy
export const getCompositeSubFieldLabel = (
compositeFieldType: CompositeFieldType,
subFieldName: (typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS)[CompositeFieldType]['subFields'][number],
subFieldName: (typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS)[CompositeFieldType]['subFields'][number]['subFieldName'],
): string => {
return (
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType]
.labelBySubField as any
)[subFieldName];
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields.find(
(subField) => subField.subFieldName === subFieldName,
)?.subFieldLabel || ''
);
};

View File

@ -5,16 +5,15 @@ export const isExpectedSubFieldName = <
CompositeFieldTypeSettings extends
typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS,
PossibleSubFieldsForGivenFieldType extends
CompositeFieldTypeSettings[GivenFieldType]['subFields'][number],
CompositeFieldTypeSettings[GivenFieldType]['subFields'][number]['subFieldName'],
>(
fieldMetadataType: GivenFieldType,
subFieldName: PossibleSubFieldsForGivenFieldType,
subFieldNameToCheck: string | null | undefined,
): subFieldNameToCheck is PossibleSubFieldsForGivenFieldType => {
return (
(
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataType]
.subFields as string[]
).includes(subFieldName) && subFieldName === subFieldNameToCheck
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataType].subFields
.map((subField) => subField.subFieldName)
.includes(subFieldName) && subFieldName === subFieldNameToCheck
);
};

View File

@ -12,6 +12,7 @@ import {
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
import { t } from '@lingui/core/macro';
import { saveAs } from 'file-saver';
import { isDefined } from 'twenty-shared/utils';
@ -59,9 +60,9 @@ export const generateCsv: GenerateExport = ({
const keys = columnsToExportWithIdColumn.flatMap((col) => {
const column = {
field: `${col.metadata.fieldName}${col.type === 'RELATION' ? 'Id' : ''}`,
title: [col.label, col.type === 'RELATION' ? 'Id' : null]
.filter(isDefined)
.join(' '),
title: escapeCSVValue(
`${col.label}${col.type === 'RELATION' ? ' Id' : ''}`,
),
};
const columnType = col.type;

View File

@ -1,97 +0,0 @@
import {
FieldActorValue,
FieldAddressValue,
FieldCurrencyValue,
FieldEmailsValue,
FieldFullNameValue,
FieldLinksValue,
FieldPhonesValue,
FieldRichTextV2Value,
} from '@/object-record/record-field/types/FieldMetadata';
import { CompositeFieldLabels } from '@/object-record/spreadsheet-import/types/CompositeFieldLabels';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const COMPOSITE_FIELD_IMPORT_LABELS = {
[FieldMetadataType.FULL_NAME]: {
firstNameLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.FULL_NAME.labelBySubField.firstName,
lastNameLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.FULL_NAME.labelBySubField.lastName,
} satisfies CompositeFieldLabels<FieldFullNameValue>,
[FieldMetadataType.CURRENCY]: {
currencyCodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.CURRENCY.labelBySubField
.currencyCode,
amountMicrosLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.CURRENCY.labelBySubField
.amountMicros,
} satisfies CompositeFieldLabels<FieldCurrencyValue>,
[FieldMetadataType.ADDRESS]: {
addressStreet1Label:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField
.addressStreet1,
addressStreet2Label:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField
.addressStreet2,
addressCityLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField.addressCity,
addressPostcodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField
.addressPostcode,
addressStateLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField
.addressState,
addressCountryLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField
.addressCountry,
} satisfies Omit<
CompositeFieldLabels<FieldAddressValue>,
'addressLatLabel' | 'addressLngLabel'
>,
[FieldMetadataType.LINKS]: {
primaryLinkUrlLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField
.primaryLinkUrl,
primaryLinkLabelLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField
.primaryLinkLabel,
secondaryLinksLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField
.secondaryLinks,
} satisfies CompositeFieldLabels<FieldLinksValue>,
[FieldMetadataType.EMAILS]: {
primaryEmailLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField.primaryEmail,
additionalEmailsLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField
.additionalEmails,
} satisfies CompositeFieldLabels<FieldEmailsValue>,
[FieldMetadataType.PHONES]: {
primaryPhoneCountryCodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.primaryPhoneCountryCode,
primaryPhoneNumberLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.primaryPhoneNumber,
primaryPhoneCallingCodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.primaryPhoneCallingCode,
additionalPhonesLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.additionalPhones,
} satisfies CompositeFieldLabels<FieldPhonesValue>,
[FieldMetadataType.RICH_TEXT_V2]: {
blocknoteLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField
.blocknote,
markdownLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField
.markdown,
} satisfies CompositeFieldLabels<FieldRichTextV2Value>,
[FieldMetadataType.ACTOR]: {
sourceLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.source,
nameLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.name,
} satisfies Partial<CompositeFieldLabels<FieldActorValue>>,
};

View File

@ -1,8 +1,8 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport';
import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { useIcons } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -36,8 +36,9 @@ export const useBuildAvailableFieldsForImport = () => {
fieldMetadataItem: FieldMetadataItem,
fieldType: CompositeFieldType,
) => {
Object.entries(COMPOSITE_FIELD_IMPORT_LABELS[fieldType]).forEach(
([subFieldKey, subFieldLabel]) => {
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldType].subFields.forEach(
({ subFieldName, subFieldLabel, isImportable }) => {
if (!isImportable) return;
const label = `${fieldMetadataItem.label} / ${subFieldLabel}`;
availableFieldsForImport.push(
@ -48,7 +49,7 @@ export const useBuildAvailableFieldsForImport = () => {
getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
label,
subFieldKey,
subFieldName,
),
}),
);

View File

@ -2,11 +2,12 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { useBuildAvailableFieldsForImport } from '@/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport';
import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow';
import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts';
import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog';
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useOpenObjectRecordsSpreadsheetImportDialog = (
objectNameSingular: string,
@ -30,22 +31,20 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
'fields' | 'isOpen' | 'onClose'
>,
) => {
const availableFieldMetadataItems = objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.isActive &&
(!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') &&
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'updatedAt' &&
(fieldMetadataItem.type !== FieldMetadataType.RELATION ||
fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
//All fields that can be imported (included matchable and auto-filled)
const availableFieldMetadataItemsToImport =
spreadsheetImportFilterAvailableFieldMetadataItems(
objectMetadataItem.fields,
);
const availableFields = buildAvailableFieldsForImport(
availableFieldMetadataItems,
const availableFieldMetadataItemsForMatching =
availableFieldMetadataItemsToImport.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.ACTOR,
);
const availableFieldsForMatching = buildAvailableFieldsForImport(
availableFieldMetadataItemsForMatching,
);
openSpreadsheetImportDialog({
@ -55,7 +54,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
const fieldMapping: Record<string, any> =
buildRecordFromImportedStructuredRow({
importedStructuredRow: record,
fields: availableFieldMetadataItems,
fields: availableFieldMetadataItemsToImport,
});
return fieldMapping;
@ -70,8 +69,8 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
});
}
},
fields: availableFields,
availableFieldMetadataItems,
fields: availableFieldsForMatching,
availableFieldMetadataItems: availableFieldMetadataItemsToImport,
});
};

View File

@ -7,7 +7,7 @@ import {
FieldPhonesValue,
FieldRichTextV2Value,
} from '@/object-record/record-field/types/FieldMetadata';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
import { ImportedStructuredRow } from '@/spreadsheet-import/types';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
@ -85,25 +85,35 @@ export const buildRecordFromImportedStructuredRow = ({
const {
ADDRESS: {
addressCityLabel,
addressCountryLabel,
addressPostcodeLabel,
addressStateLabel,
addressStreet1Label,
addressStreet2Label,
addressCity: addressCityLabel,
addressCountry: addressCountryLabel,
addressPostcode: addressPostcodeLabel,
addressState: addressStateLabel,
addressStreet1: addressStreet1Label,
addressStreet2: addressStreet2Label,
},
CURRENCY: {
amountMicros: amountMicrosLabel,
currencyCode: currencyCodeLabel,
},
FULL_NAME: { firstName: firstNameLabel, lastName: lastNameLabel },
LINKS: {
primaryLinkUrl: primaryLinkUrlLabel,
primaryLinkLabel: primaryLinkLabelLabel,
secondaryLinks: secondaryLinksLabel,
},
EMAILS: {
primaryEmail: primaryEmailLabel,
additionalEmails: additionalEmailsLabel,
},
CURRENCY: { amountMicrosLabel, currencyCodeLabel },
FULL_NAME: { firstNameLabel, lastNameLabel },
LINKS: { primaryLinkUrlLabel, primaryLinkLabelLabel, secondaryLinksLabel },
EMAILS: { primaryEmailLabel, additionalEmailsLabel },
PHONES: {
primaryPhoneNumberLabel,
primaryPhoneCountryCodeLabel,
primaryPhoneCallingCodeLabel,
additionalPhonesLabel,
primaryPhoneNumber: primaryPhoneNumberLabel,
primaryPhoneCountryCode: primaryPhoneCountryCodeLabel,
primaryPhoneCallingCode: primaryPhoneCallingCodeLabel,
additionalPhones: additionalPhonesLabel,
},
RICH_TEXT_V2: { blocknoteLabel, markdownLabel },
} = COMPOSITE_FIELD_IMPORT_LABELS;
RICH_TEXT_V2: { blocknote: blocknoteLabel, markdown: markdownLabel },
} = COMPOSITE_FIELD_SUB_FIELD_LABELS;
for (const field of fields) {
const importedFieldValue = importedStructuredRow[field.name];
@ -274,10 +284,18 @@ export const buildRecordFromImportedStructuredRow = ({
}
break;
}
case FieldMetadataType.UUID:
if (
isDefined(importedFieldValue) &&
isNonEmptyString(importedFieldValue)
) {
recordToBuild[field.name] = importedFieldValue;
}
break;
case FieldMetadataType.RELATION:
if (
isDefined(importedFieldValue) &&
(isNonEmptyString(importedFieldValue) || importedFieldValue !== false)
isNonEmptyString(importedFieldValue)
) {
recordToBuild[field.name + 'Id'] = importedFieldValue;
}

View File

@ -35,14 +35,14 @@ export const getSpreadSheetFieldValidationDefinitions = (
];
case FieldMetadataType.CURRENCY:
switch (subFieldKey) {
case 'amountMicrosLabel':
case 'amountMicros':
return [getNumberValidationDefinition(fieldName)];
default:
return [];
}
case FieldMetadataType.EMAILS:
switch (subFieldKey) {
case 'primaryEmailLabel':
case 'primaryEmail':
return [
{
rule: 'function',
@ -51,7 +51,7 @@ export const getSpreadSheetFieldValidationDefinitions = (
level: 'error',
},
];
case 'additionalEmailsLabel':
case 'additionalEmails':
return [
{
rule: 'function',
@ -77,7 +77,7 @@ export const getSpreadSheetFieldValidationDefinitions = (
}
case FieldMetadataType.LINKS:
switch (subFieldKey) {
case 'primaryLinkUrlLabel':
case 'primaryLinkUrl':
return [
{
rule: 'function',
@ -89,7 +89,7 @@ export const getSpreadSheetFieldValidationDefinitions = (
level: 'error',
},
];
case 'secondaryLinksLabel':
case 'secondaryLinks':
return [
{
rule: 'function',
@ -139,7 +139,7 @@ export const getSpreadSheetFieldValidationDefinitions = (
];
case FieldMetadataType.PHONES:
switch (subFieldKey) {
case 'primaryPhoneNumberLabel':
case 'primaryPhoneNumber':
return [
{
rule: 'regex',
@ -148,7 +148,7 @@ export const getSpreadSheetFieldValidationDefinitions = (
level: 'error',
},
];
case 'additionalPhonesLabel':
case 'additionalPhones':
return [
{
rule: 'function',

View File

@ -1,15 +1,19 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
export const getSubFieldOptionKey = (
fieldMetadataItem: FieldMetadataItem,
subFieldName: string,
) => {
const subFieldNameLabelKey = `${subFieldName}Label`;
if (!isCompositeFieldType(fieldMetadataItem.type)) {
throw new Error(
`getSubFieldOptionKey can only be called for composite field types. Received: ${fieldMetadataItem.type}`,
);
}
const subFieldLabel = (
(COMPOSITE_FIELD_IMPORT_LABELS as any)[fieldMetadataItem.type] as any
)[subFieldNameLabelKey];
const subFieldLabel =
COMPOSITE_FIELD_SUB_FIELD_LABELS[fieldMetadataItem.type][subFieldName];
const subFieldKey = `${subFieldLabel} (${fieldMetadataItem.name})`;

View File

@ -0,0 +1,23 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
export const spreadsheetImportFilterAvailableFieldMetadataItems = (
fields: FieldMetadataItem[],
) => {
return fields
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.isActive &&
(!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') &&
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'updatedAt' &&
(![FieldMetadataType.RELATION, FieldMetadataType.RICH_TEXT].includes(
fieldMetadataItem.type,
) ||
fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
};

View File

@ -3,5 +3,7 @@ import { COMPOSITE_FIELD_TYPES } from '@/settings/data-model/types/CompositeFiel
export const ALL_SUB_FIELDS = COMPOSITE_FIELD_TYPES.flatMap(
(compositeFieldType) =>
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields,
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields.map(
(subField) => subField.subFieldName,
),
);

View File

@ -25,11 +25,16 @@ import {
} from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type CompositeSubFieldConfig<T> = {
subFieldName: keyof T;
subFieldLabel: string;
isImportable: boolean;
isFilterable: boolean;
};
export type SettingsCompositeFieldTypeConfig<T> = SettingsFieldTypeConfig<T> & {
subFields: (keyof T)[];
filterableSubFields: (keyof T)[];
labelBySubField: Record<keyof T, string>;
exampleValue: T;
subFields: CompositeSubFieldConfig<T>[];
exampleValues: [T, T, T];
};
type SettingsCompositeFieldTypeConfigArray = Record<
@ -41,215 +46,415 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
[FieldMetadataType.CURRENCY]: {
label: 'Currency',
Icon: IllustrationIconCurrency,
subFields: ['amountMicros', 'currencyCode'],
filterableSubFields: ['amountMicros', 'currencyCode'],
labelBySubField: {
amountMicros:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY]
.amountMicros,
currencyCode:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY]
.currencyCode,
},
exampleValue: {
amountMicros: 2000000000,
currencyCode: CurrencyCode.USD,
},
subFields: [
{
subFieldName: 'amountMicros',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY]
.amountMicros,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'currencyCode',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY]
.currencyCode,
isImportable: true,
isFilterable: true,
},
],
exampleValues: [
{
amountMicros: 2000000000,
currencyCode: CurrencyCode.USD,
},
{
amountMicros: 3000000000,
currencyCode: CurrencyCode.GBP,
},
{
amountMicros: 100000000,
currencyCode: CurrencyCode.AED,
},
],
category: 'Basic',
} as const satisfies SettingsCompositeFieldTypeConfig<FieldCurrencyValue>,
[FieldMetadataType.EMAILS]: {
label: 'Emails',
Icon: IllustrationIconMail,
subFields: ['primaryEmail', 'additionalEmails'],
filterableSubFields: ['primaryEmail', 'additionalEmails'],
labelBySubField: {
primaryEmail: 'Primary Email',
additionalEmails: 'Additional Emails',
},
exampleValue: {
primaryEmail: 'john@twenty.com',
additionalEmails: [
'tim@twenty.com',
'timapple@twenty.com',
'johnappletim@twenty.com',
],
},
subFields: [
{
subFieldName: 'primaryEmail',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.EMAILS]
.primaryEmail,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'additionalEmails',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.EMAILS]
.additionalEmails,
isImportable: true,
isFilterable: true,
},
],
exampleValues: [
{
primaryEmail: 'tim@twenty.com',
additionalEmails: [
'tim@twenty.com',
'timapple@twenty.com',
'johnappletim@twenty.com',
],
},
{
primaryEmail: 'jane@twenty.com',
additionalEmails: ['jane@twenty.com', 'jane.doe@twenty.com'],
},
{
primaryEmail: 'john@twenty.com',
additionalEmails: ['john.doe@twenty.com'],
},
],
category: 'Basic',
} as const satisfies SettingsCompositeFieldTypeConfig<FieldEmailsValue>,
[FieldMetadataType.LINKS]: {
label: 'Links',
Icon: IllustrationIconLink,
exampleValue: {
primaryLinkUrl: 'twenty.com',
primaryLinkLabel: '',
secondaryLinks: [{ url: 'twenty.com', label: 'Twenty' }],
},
category: 'Basic',
subFields: ['primaryLinkUrl', 'primaryLinkLabel', 'secondaryLinks'],
filterableSubFields: [
'primaryLinkUrl',
'primaryLinkLabel',
'secondaryLinks',
subFields: [
{
subFieldName: 'primaryLinkUrl',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.primaryLinkUrl,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'primaryLinkLabel',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.primaryLinkLabel,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'secondaryLinks',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.secondaryLinks,
isImportable: true,
isFilterable: true,
},
],
labelBySubField: {
primaryLinkUrl:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.primaryLinkUrl,
primaryLinkLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.primaryLinkLabel,
secondaryLinks:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.secondaryLinks,
},
exampleValues: [
{
primaryLinkUrl: 'twenty.com',
primaryLinkLabel: '',
secondaryLinks: [{ url: 'twenty.com', label: 'Twenty' }],
},
{
primaryLinkUrl: 'github.com/twentyhq/twenty',
primaryLinkLabel: 'Twenty Repo',
secondaryLinks: [{ url: 'twenty.com', label: '' }],
},
{
primaryLinkUrl: 'react.dev',
primaryLinkLabel: '',
secondaryLinks: [],
},
],
category: 'Basic',
} as const satisfies SettingsCompositeFieldTypeConfig<FieldLinksValue>,
[FieldMetadataType.PHONES]: {
label: 'Phones',
Icon: IllustrationIconPhone,
exampleValue: {
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '789012345',
additionalPhones: [
{ number: '617272323', callingCode: '+33', countryCode: 'FR' },
],
},
subFields: [
'primaryPhoneNumber',
'primaryPhoneCountryCode',
'primaryPhoneCallingCode',
'additionalPhones',
{
subFieldName: 'primaryPhoneCallingCode',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.primaryPhoneCallingCode,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'primaryPhoneCountryCode',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.primaryPhoneCountryCode,
isImportable: true,
isFilterable: false,
},
{
subFieldName: 'primaryPhoneNumber',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.primaryPhoneNumber,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'additionalPhones',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.additionalPhones,
isImportable: true,
isFilterable: true,
},
],
filterableSubFields: [
'primaryPhoneNumber',
'primaryPhoneCallingCode',
'additionalPhones',
exampleValues: [
{
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '789012345',
additionalPhones: [
{ number: '617272323', callingCode: '+33', countryCode: 'FR' },
],
},
{
primaryPhoneCallingCode: '+1',
primaryPhoneCountryCode: 'US',
primaryPhoneNumber: '612345789',
additionalPhones: [
{ number: '123456789', callingCode: '+1', countryCode: 'US' },
{ number: '617272323', callingCode: '+1', countryCode: 'US' },
],
},
{
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '123456789',
additionalPhones: [],
},
],
labelBySubField: {
primaryPhoneNumber:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.primaryPhoneNumber,
primaryPhoneCountryCode:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.primaryPhoneCountryCode,
primaryPhoneCallingCode:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.primaryPhoneCallingCode,
additionalPhones:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
.additionalPhones,
},
category: 'Basic',
} as const satisfies SettingsCompositeFieldTypeConfig<FieldPhonesValue>,
[FieldMetadataType.FULL_NAME]: {
label: 'Full Name',
Icon: IllustrationIconUser,
exampleValue: { firstName: 'John', lastName: 'Doe' },
subFields: [
{
subFieldName: 'firstName',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME]
.firstName,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'lastName',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME]
.lastName,
isImportable: true,
isFilterable: true,
},
],
exampleValues: [
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Doe' },
{ firstName: 'John', lastName: 'Smith' },
],
category: 'Basic',
subFields: ['firstName', 'lastName'],
filterableSubFields: ['firstName', 'lastName'],
labelBySubField: {
firstName:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME].firstName,
lastName:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME].lastName,
},
} as const satisfies SettingsCompositeFieldTypeConfig<FieldFullNameValue>,
[FieldMetadataType.ADDRESS]: {
label: 'Address',
Icon: IllustrationIconMap,
subFields: [
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressCountry',
'addressPostcode',
'addressLat',
'addressLng',
{
subFieldName: 'addressStreet1',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressStreet1,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'addressStreet2',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressStreet2,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'addressCity',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressCity,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'addressState',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressState,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'addressCountry',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressCountry,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'addressPostcode',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressPostcode,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'addressLat',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressLat,
isImportable: false,
isFilterable: false,
},
{
subFieldName: 'addressLng',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressLng,
isImportable: false,
isFilterable: false,
},
],
filterableSubFields: [
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressCountry',
'addressPostcode',
exampleValues: [
{
addressStreet1: '456 Oak Street',
addressStreet2: '',
addressCity: 'Springfield',
addressState: 'California',
addressCountry: 'United States',
addressPostcode: '90210',
addressLat: 34.0522,
addressLng: -118.2437,
},
{
addressStreet1: '123 Main Street',
addressStreet2: '',
addressCity: 'New York',
addressState: 'New York',
addressCountry: 'United States',
addressPostcode: '10001',
addressLat: 40.7128,
addressLng: -74.006,
},
{
addressStreet1: '8 rue Saint-Anne',
addressStreet2: '',
addressCity: 'Paris',
addressState: 'Ile-de-France',
addressCountry: 'France',
addressPostcode: '75001',
addressLat: 40.7128,
addressLng: -74.006,
},
],
labelBySubField: {
addressStreet1:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressStreet1,
addressStreet2:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressStreet2,
addressCity:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS].addressCity,
addressState:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressState,
addressCountry:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressCountry,
addressPostcode:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
.addressPostcode,
addressLat:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS].addressLat,
addressLng:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS].addressLng,
},
exampleValue: {
addressStreet1: '456 Oak Street',
addressStreet2: '',
addressCity: 'Springfield',
addressState: 'California',
addressCountry: 'United States',
addressPostcode: '90210',
addressLat: 34.0522,
addressLng: -118.2437,
},
category: 'Basic',
} as const satisfies SettingsCompositeFieldTypeConfig<FieldAddressValue>,
[FieldMetadataType.ACTOR]: {
label: 'Actor',
Icon: IllustrationIconSetting,
category: 'Basic',
subFields: ['source', 'name'],
filterableSubFields: ['source', 'name'],
labelBySubField: {
source: COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].source,
name: COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].name,
workspaceMemberId:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR]
.workspaceMemberId,
context:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].context,
},
exampleValue: {
source: 'IMPORT',
name: 'name',
workspaceMemberId: 'id',
context: { provider: ConnectedAccountProvider.GOOGLE },
},
subFields: [
{
subFieldName: 'source',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].source,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'name',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].name,
isImportable: true,
isFilterable: true,
},
{
subFieldName: 'workspaceMemberId',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR]
.workspaceMemberId,
isImportable: true,
isFilterable: false,
},
{
subFieldName: 'context',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].context,
isImportable: true,
isFilterable: false,
},
],
exampleValues: [
{
source: 'IMPORT',
name: 'name',
workspaceMemberId: 'id',
context: { provider: ConnectedAccountProvider.GOOGLE },
},
{
source: 'MANUAL',
name: 'name',
workspaceMemberId: 'id',
context: { provider: ConnectedAccountProvider.MICROSOFT },
},
{
source: 'WEBHOOK',
name: 'name',
workspaceMemberId: 'id',
context: {},
},
],
} as const satisfies SettingsCompositeFieldTypeConfig<FieldActorValue>,
[FieldMetadataType.RICH_TEXT_V2]: {
label: 'Rich Text',
Icon: IllustrationIconText,
subFields: ['blocknote', 'markdown'],
filterableSubFields: [],
labelBySubField: {
blocknote:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2]
.blocknote,
markdown:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2]
.markdown,
},
exampleValue: {
blocknote: '[{"type":"heading","content":"Hello"}]',
markdown: '# Hello',
},
category: 'Basic',
subFields: [
{
subFieldName: 'blocknote',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2]
.blocknote,
isImportable: false,
isFilterable: false,
},
{
subFieldName: 'markdown',
subFieldLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2]
.markdown,
isImportable: false,
isFilterable: false,
},
],
exampleValues: [
{
blocknote: '[{"type":"heading","content":"Hello"}]',
markdown: '# Hello',
},
{
blocknote: '[{"type":"heading","content":"Hello World"}]',
markdown: '# Hello World',
},
{
blocknote: '[{"type":"heading","content":"Hello Again"}]',
markdown: '# Hello Again',
},
],
} as const satisfies SettingsCompositeFieldTypeConfig<FieldRichTextV2Value>,
} as const satisfies SettingsCompositeFieldTypeConfigArray;

View File

@ -15,7 +15,6 @@ import {
import { DEFAULT_DATE_VALUE } from '@/settings/data-model/constants/DefaultDateValue';
import { SettingsFieldTypeCategoryType } from '@/settings/data-model/types/SettingsFieldTypeCategoryType';
import { SettingsNonCompositeFieldType } from '@/settings/data-model/types/SettingsNonCompositeFieldType';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
IconComponent,
IllustrationIconArray,
@ -31,13 +30,14 @@ import {
IllustrationIconToggle,
IllustrationIconUid,
} from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
DEFAULT_DATE_VALUE.setFullYear(DEFAULT_DATE_VALUE.getFullYear() + 2);
export type SettingsFieldTypeConfig<T> = {
label: string;
Icon: IconComponent;
exampleValue?: T;
exampleValues?: [T, T, T];
category: SettingsFieldTypeCategoryType;
};
@ -52,44 +52,59 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
[FieldMetadataType.UUID]: {
label: 'Unique ID',
Icon: IllustrationIconUid,
exampleValue: '00000000-0000-0000-0000-000000000000',
exampleValues: [
'00000000-0000-0000-0000-000000000000',
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000002',
],
category: 'Advanced',
} as const satisfies SettingsFieldTypeConfig<FieldUUidValue>,
[FieldMetadataType.TEXT]: {
label: 'Text',
Icon: IllustrationIconText,
exampleValue:
exampleValues: [
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum magna enim, dapibus non enim in, lacinia faucibus nunc. Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.',
'Vestibulum magna enim, dapibus non enim in, lacinia faucibus nunc. Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.',
'Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.',
],
category: 'Basic',
} as const satisfies SettingsFieldTypeConfig<FieldTextValue>,
[FieldMetadataType.NUMERIC]: {
label: 'Numeric',
Icon: IllustrationIconNumbers,
exampleValue: 2000,
exampleValues: [2000, 3000, 4000],
category: 'Basic',
} as const satisfies SettingsFieldTypeConfig<FieldNumberValue>,
[FieldMetadataType.NUMBER]: {
label: 'Number',
Icon: IllustrationIconNumbers,
exampleValue: 2000,
exampleValues: [2000, 3000, 4000],
category: 'Basic',
} as const satisfies SettingsFieldTypeConfig<FieldNumberValue>,
[FieldMetadataType.BOOLEAN]: {
label: 'True/False',
Icon: IllustrationIconToggle,
exampleValue: true,
exampleValues: [true, false, true],
category: 'Basic',
} as const satisfies SettingsFieldTypeConfig<FieldBooleanValue>,
[FieldMetadataType.DATE_TIME]: {
label: 'Date and Time',
Icon: IllustrationIconCalendarTime,
exampleValue: DEFAULT_DATE_VALUE.toISOString(),
exampleValues: [
DEFAULT_DATE_VALUE.toISOString(),
'2025-06-10T12:01:00.000Z',
'2018-07-14T12:02:00.000Z',
],
category: 'Basic',
} as const satisfies SettingsFieldTypeConfig<FieldDateTimeValue>,
[FieldMetadataType.DATE]: {
label: 'Date',
Icon: IllustrationIconCalendarEvent,
exampleValue: DEFAULT_DATE_VALUE.toISOString(),
exampleValues: [
DEFAULT_DATE_VALUE.toISOString(),
'2025-06-10T00:00:00.000Z',
'2018-07-14T00:00:00.000Z',
],
category: 'Basic',
} as const satisfies SettingsFieldTypeConfig<FieldDateValue>,
[FieldMetadataType.SELECT]: {
@ -110,19 +125,19 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
[FieldMetadataType.RATING]: {
label: 'Rating',
Icon: IllustrationIconStar,
exampleValue: 'RATING_3',
exampleValues: ['RATING_3', 'RATING_4', 'RATING_5'],
category: 'Basic',
} as const satisfies SettingsFieldTypeConfig<FieldRatingValue>,
[FieldMetadataType.RAW_JSON]: {
label: 'JSON',
Icon: IllustrationIconJson,
exampleValue: { key: 'value' },
exampleValues: [{ key: 'value1' }, { key: 'value2', key2: 'value2' }, {}],
category: 'Advanced',
} as const satisfies SettingsFieldTypeConfig<FieldJsonValue>,
[FieldMetadataType.ARRAY]: {
label: 'Array',
Icon: IllustrationIconArray,
category: 'Advanced',
exampleValue: ['value1', 'value2'],
exampleValues: [['value1', 'value2'], ['value3'], []],
} as const satisfies SettingsFieldTypeConfig<FieldArrayValue>,
};

View File

@ -51,7 +51,7 @@ describe('getFieldPreviewValue', () => {
// Then
expect(result).toBe(2000);
expect(result).toBe(
getSettingsFieldTypeConfig(FieldMetadataType.NUMBER).exampleValue,
getSettingsFieldTypeConfig(FieldMetadataType.NUMBER).exampleValues?.[0],
);
});

View File

@ -18,7 +18,7 @@ export const getAddressFieldPreviewValue = ({
FieldMetadataType.ADDRESS,
);
const placeholderDefaultValue = addressFieldTypeConfig.exampleValue;
const placeholderDefaultValue = addressFieldTypeConfig.exampleValues?.[0];
const addressCountry =
fieldMetadataItem.defaultValue?.addressCountry &&

View File

@ -20,7 +20,7 @@ export const getCurrencyFieldPreviewValue = ({
FieldMetadataType.CURRENCY,
);
const placeholderDefaultValue = currencyFieldTypeConfig.exampleValue;
const placeholderDefaultValue = currencyFieldTypeConfig.exampleValues?.[0];
return currencyFieldDefaultValueSchema
.transform((value) => ({

View File

@ -31,10 +31,10 @@ export const getFieldPreviewValue = ({
if (
isDefined(fieldTypeConfig) &&
'exampleValue' in fieldTypeConfig &&
isDefined(fieldTypeConfig.exampleValue)
'exampleValues' in fieldTypeConfig &&
isDefined(fieldTypeConfig.exampleValues?.[0])
) {
return fieldTypeConfig.exampleValue;
return fieldTypeConfig.exampleValues?.[0];
}
return null;

View File

@ -38,7 +38,7 @@ export const getPhonesFieldPreviewValue = ({
FieldMetadataType.PHONES,
);
const placeholderDefaultValue = phonesFieldTypeConfig.exampleValue;
const placeholderDefaultValue = phonesFieldTypeConfig.exampleValues?.[0];
const primaryPhoneCountryCode =
fieldMetadataItem.defaultValue?.primaryPhoneCountryCode &&
fieldMetadataItem.defaultValue.primaryPhoneCountryCode !== ''

View File

@ -5,9 +5,10 @@ import { COMPOSITE_FIELD_TYPES } from '@/settings/data-model/types/CompositeFiel
export const isValidSubFieldName = (
subFieldName: string,
): subFieldName is CompositeFieldSubFieldName => {
const allSubFields = COMPOSITE_FIELD_TYPES.flatMap(
(compositeFieldType) =>
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields,
const allSubFields = COMPOSITE_FIELD_TYPES.flatMap((compositeFieldType) =>
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields.map(
(subField) => subField.subFieldName,
),
);
return allSubFields.includes(subFieldName as any);

View File

@ -58,8 +58,8 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
const fieldMetadataItemSettings =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataItem.type];
const subFieldNamesThatExistInOptions = fieldMetadataItemSettings.subFields
.filter((subFieldName) => {
const subFieldsThatExistInOptions = fieldMetadataItemSettings.subFields
.filter(({ subFieldName }) => {
const optionKey = getSubFieldOptionKey(fieldMetadataItem, subFieldName);
const correspondingOption = options.find(
@ -68,7 +68,7 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
return isDefined(correspondingOption);
})
.filter((subFieldName) =>
.filter(({ subFieldName }) =>
getCompositeSubFieldLabel(
fieldMetadataItem.type as CompositeFieldType,
subFieldName,
@ -96,7 +96,7 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{subFieldNamesThatExistInOptions.map((subFieldName) => (
{subFieldsThatExistInOptions.map(({ subFieldName }) => (
<MenuItem
key={subFieldName}
onClick={() => handleSubFieldSelect(subFieldName)}

View File

@ -0,0 +1 @@
export const SpreadsheetMaxRecordImportCapacity = 2000;

View File

@ -1,6 +1,7 @@
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { SpreadSheetImportModalWrapper } from '@/spreadsheet-import/components/SpreadSheetImportModalWrapper';
import { SPREADSHEET_IMPORT_MODAL_ID } from '@/spreadsheet-import/constants/SpreadsheetImportModalId';
import { SpreadsheetMaxRecordImportCapacity } from '@/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity';
import { SpreadsheetImportStepperContainer } from '@/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer';
import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types';
@ -19,7 +20,7 @@ export const defaultSpreadsheetImportProps: Partial<
dateFormat: 'yyyy-mm-dd', // ISO 8601,
parseRaw: true,
selectHeader: false,
maxRecords: 2000,
maxRecords: SpreadsheetMaxRecordImportCapacity,
} as const;
export const SpreadsheetImport = <T extends string>(

View File

@ -3,7 +3,9 @@ import { useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { read, WorkBook } from 'xlsx-ugnis';
import { SpreadsheetMaxRecordImportCapacity } from '@/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { useDownloadFakeRecords } from '@/spreadsheet-import/steps/components/UploadStep/hooks/useDownloadFakeRecords';
import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -83,6 +85,23 @@ const StyledText = styled.span`
padding: 16px;
`;
const StyledFooterText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.regular};
text-align: center;
position: absolute;
bottom: ${({ theme }) => theme.spacing(4)};
left: 50%;
transform: translateX(-50%);
width: 100%;
`;
const StyledTextAction = styled.span`
cursor: pointer;
text-decoration: underline;
`;
type DropZoneProps = {
onContinue: (data: WorkBook, file: File) => void;
isLoading: boolean;
@ -95,6 +114,8 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
const { enqueueSnackBar } = useSnackBar();
const { downloadSample } = useDownloadFakeRecords();
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
noClick: true,
noKeyboard: true,
@ -157,6 +178,12 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
<Trans>Upload .xlsx, .xls or .csv file</Trans>
</StyledText>
<MainButton onClick={open} title={t`Select file`} />
<StyledFooterText>
{t`Max import capacity: ${SpreadsheetMaxRecordImportCapacity} records. Otherwise, consider splitting your file or using the API.`}{' '}
<StyledTextAction onClick={downloadSample}>
{t`Download sample file.`}
</StyledTextAction>
</StyledFooterText>
</>
)}
</StyledContainer>

View File

@ -1,26 +0,0 @@
import { useMemo } from 'react';
import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable';
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
import { generateColumns } from './columns';
interface ExampleTableProps<T extends string> {
fields: SpreadsheetImportFields<T>;
}
export const ExampleTable = <T extends string>({
fields,
}: ExampleTableProps<T>) => {
const data = useMemo(() => generateExampleRow(fields), [fields]);
const columns = useMemo(() => generateColumns(fields), [fields]);
return (
<SpreadsheetImportTable
rows={data}
columns={columns}
className={'rdg-example'}
/>
);
};

View File

@ -0,0 +1,138 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs';
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
import { saveAs } from 'file-saver';
import { FieldMetadataType } from 'twenty-shared/types';
export const useDownloadFakeRecords = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const availableFieldMetadataItems =
spreadsheetImportFilterAvailableFieldMetadataItems(
objectMetadataItem.fields,
);
const buildTableWithFakeRecords = () => {
const headerRow: string[] = [];
const bodyRows: string[][] = [[], [], []];
availableFieldMetadataItems.forEach((field) => {
switch (field.type) {
case FieldMetadataType.RATING:
case FieldMetadataType.ARRAY:
case FieldMetadataType.RAW_JSON:
case FieldMetadataType.UUID:
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE:
case FieldMetadataType.BOOLEAN:
case FieldMetadataType.NUMBER:
case FieldMetadataType.TEXT: {
headerRow.push(field.label);
const exampleValues =
SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues;
bodyRows.forEach((_, index) => {
bodyRows[index].push(exampleValues?.[index] || '');
});
break;
}
case FieldMetadataType.ACTOR:
case FieldMetadataType.EMAILS:
case FieldMetadataType.CURRENCY:
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.LINKS:
case FieldMetadataType.PHONES:
case FieldMetadataType.ADDRESS: {
const compositeFieldSettings =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type];
const subFields = compositeFieldSettings.subFields.filter(
(subField) => subField.isImportable,
);
const exampleValues =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues;
headerRow.push(
...subFields.map(
({ subFieldLabel }) => `${field.label} / ${subFieldLabel}`,
),
);
bodyRows.forEach((_, index) => {
subFields.forEach(({ subFieldName }) => {
bodyRows[index].push(
exampleValues?.[index]?.[
subFieldName as keyof (typeof exampleValues)[typeof index]
] || '',
);
});
});
break;
}
case FieldMetadataType.RELATION: {
headerRow.push(`${field.label} (ID)`);
const exampleValues =
SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[FieldMetadataType.UUID]
.exampleValues;
bodyRows.forEach((_, index) => {
bodyRows[index].push(exampleValues?.[index] || '');
});
break;
}
case FieldMetadataType.MULTI_SELECT:
headerRow.push(field.label);
bodyRows.forEach((_, index) => {
bodyRows[index].push(
JSON.stringify(
field?.options
?.map((option) => option?.value)
.slice(0, index) || [],
),
);
});
break;
case FieldMetadataType.SELECT:
headerRow.push(field.label);
bodyRows.forEach((_, index) => {
bodyRows[index].push(field?.options?.[index]?.value || '');
});
break;
}
});
return { headerRow, bodyRows };
};
const formatToCsvContent = (rows: string[][]) => {
const escapedRows = rows.map((row) => {
return row.map((value) => escapeCSVValue(value));
});
const csvContent = [...escapedRows.map((row) => row.join(','))].join('\n');
return [csvContent];
};
const downloadSample = () => {
const { headerRow, bodyRows } = buildTableWithFakeRecords();
const csvContent = formatToCsvContent([headerRow, ...bodyRows]);
const blob = new Blob(csvContent, { type: 'text/csv' });
saveAs(blob, `${objectMetadataItem.labelPlural.toLowerCase()}-sample.csv`);
};
return { downloadSample };
};

View File

@ -4,7 +4,9 @@ import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/Componen
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { stepBarInternalState } from '@/ui/navigation/step-bar/states/stepBarInternalState';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SpreadsheetImportStepperContainer } from '../SpreadsheetImportStepperContainer';
const meta: Meta<typeof SpreadsheetImportStepperContainer> = {
@ -14,6 +16,8 @@ const meta: Meta<typeof SpreadsheetImportStepperContainer> = {
ComponentWithRecoilScopeDecorator,
SnackBarDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
ContextStoreDecorator,
],
parameters: {
initialRecoilState: {

View File

@ -8,7 +8,9 @@ import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/Spre
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { RecoilRoot } from 'recoil';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const meta: Meta<typeof UploadStep> = {
@ -18,6 +20,8 @@ const meta: Meta<typeof UploadStep> = {
layout: 'fullscreen',
},
decorators: [
ObjectMetadataItemsDecorator,
ContextStoreDecorator,
(Story) => (
<RecoilRoot
initializeState={({ set }) => {

View File

@ -0,0 +1,24 @@
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
describe('escapeCSVValue', () => {
it('should escape values with commas, quotes, newlines and carriage returns', () => {
expect(escapeCSVValue('test,test')).toBe('"test,test"');
});
it('should escape array or JSON values', () => {
expect(escapeCSVValue(['test', 'test'])).toBe('"[""test"",""test""]"');
expect(escapeCSVValue({ test: 'test' })).toBe('"{""test"":""test""}"');
});
it('should escape null values', () => {
expect(escapeCSVValue(null)).toBe('');
});
it('should escape simple string value', () => {
expect(escapeCSVValue('test')).toBe('test');
});
it('should escape simple number value', () => {
expect(escapeCSVValue(1)).toBe('1');
});
});

View File

@ -1,65 +0,0 @@
import { SpreadsheetImportField } from '@/spreadsheet-import/types';
import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
import { FieldMetadataType } from 'twenty-shared/types';
describe('generateExampleRow', () => {
const defaultField: SpreadsheetImportField<'defaultField'> = {
key: 'defaultField',
Icon: null,
label: 'label',
fieldType: {
type: 'input',
},
fieldMetadataType: FieldMetadataType.TEXT,
};
it('should generate an example row from input field type', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [defaultField];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Text' }]);
});
it('should generate an example row from checkbox field type', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [
{
...defaultField,
fieldType: { type: 'checkbox' },
fieldMetadataType: FieldMetadataType.BOOLEAN,
},
];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Boolean' }]);
});
it('should generate an example row from select field type', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [
{
...defaultField,
fieldType: { type: 'select', options: [] },
fieldMetadataType: FieldMetadataType.SELECT,
},
];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Options' }]);
});
it('should generate an example row with provided example values for fields', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [
{
...defaultField,
example: 'Example',
fieldMetadataType: FieldMetadataType.TEXT,
},
];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Example' }]);
});
});

View File

@ -0,0 +1,18 @@
import { isString } from '@sniptt/guards';
export const escapeCSVValue = (value: any) => {
if (value == null) return '';
const stringValue = isString(value) ? value : JSON.stringify(value);
if (
stringValue.includes(',') ||
stringValue.includes('"') ||
stringValue.includes('\n') ||
stringValue.includes('\r')
) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
};

View File

@ -1,26 +0,0 @@
import {
SpreadsheetImportField,
SpreadsheetImportFields,
} from '@/spreadsheet-import/types';
const titleMap: Record<
SpreadsheetImportField<string>['fieldType']['type'],
string
> = {
checkbox: 'Boolean',
select: 'Options',
multiSelect: 'Options',
input: 'Text',
};
export const generateExampleRow = <T extends string>(
fields: SpreadsheetImportFields<T>,
) => [
fields.reduce(
(acc, field) => {
acc[field.key as T] = field.example || titleMap[field.fieldType.type];
return acc;
},
{} as Record<T, string>,
),
];