add field validation + add other subfields import (#12444)

- Add some subfield imports : primaryLinkLabel / primaryPhoneCallingCode
/ additionalPhones
- Add validation rules for field and subfield

Comments
- Check other validations that can be done
- Refacto on subFieldKey ("...Label")
- Add global tests on validation step -
[issue](https://github.com/twentyhq/core-team-issues/issues/1067)

closes https://github.com/twentyhq/core-team-issues/issues/903 
closes https://github.com/twentyhq/core-team-issues/issues/910
closes https://github.com/twentyhq/core-team-issues/issues/985
closes https://github.com/twentyhq/core-team-issues/issues/904
This commit is contained in:
Etienne
2025-06-05 14:12:24 +02:00
committed by GitHub
parent b481abbb0f
commit 2dd8b9af10
7 changed files with 731 additions and 89 deletions

View File

@ -53,17 +53,20 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
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 Partial<CompositeFieldLabels<FieldLinksValue>>,
} satisfies CompositeFieldLabels<FieldLinksValue>,
[FieldMetadataType.EMAILS]: {
primaryEmailLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField.primaryEmail,
additionalEmailsLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField
.additionalEmails,
} satisfies Partial<CompositeFieldLabels<FieldEmailsValue>>,
} satisfies CompositeFieldLabels<FieldEmailsValue>,
[FieldMetadataType.PHONES]: {
primaryPhoneCountryCodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
@ -71,7 +74,13 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
primaryPhoneNumberLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.primaryPhoneNumber,
} satisfies Partial<CompositeFieldLabels<FieldPhonesValue>>,
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
@ -79,7 +88,7 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
markdownLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField
.markdown,
} satisfies Partial<CompositeFieldLabels<FieldRichTextV2Value>>,
} satisfies CompositeFieldLabels<FieldRichTextV2Value>,
[FieldMetadataType.ACTOR]: {
sourceLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.source,

View File

@ -3,14 +3,10 @@ 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 { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { useIcons } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type CompositeFieldType = keyof typeof COMPOSITE_FIELD_IMPORT_LABELS;
// Helper type for field validation type resolvers
type ValidationTypeResolver = (key: string, label: string) => FieldMetadataType;
export const useBuildAvailableFieldsForImport = () => {
const { getIcon } = useIcons();
@ -39,22 +35,21 @@ export const useBuildAvailableFieldsForImport = () => {
const handleCompositeFieldWithLabels = (
fieldMetadataItem: FieldMetadataItem,
fieldType: CompositeFieldType,
validationTypeResolver?: ValidationTypeResolver,
) => {
Object.entries(COMPOSITE_FIELD_IMPORT_LABELS[fieldType]).forEach(
([key, subFieldLabel]) => {
([subFieldKey, subFieldLabel]) => {
const label = `${fieldMetadataItem.label} / ${subFieldLabel}`;
// Use the custom validation type if provided, otherwise use the field's type
const validationType = validationTypeResolver
? validationTypeResolver(key, subFieldLabel)
: fieldMetadataItem.type;
availableFieldsForImport.push(
createBaseField(fieldMetadataItem, {
label,
key: `${subFieldLabel} (${fieldMetadataItem.name})`,
fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(validationType, label),
getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
label,
subFieldKey,
),
}),
);
},
@ -84,12 +79,6 @@ export const useBuildAvailableFieldsForImport = () => {
);
};
// Special validation type resolver for currency fields
const currencyValidationResolver: ValidationTypeResolver = (key) =>
key === 'amountMicrosLabel'
? FieldMetadataType.NUMBER
: FieldMetadataType.CURRENCY;
const fieldTypeHandlers: Record<
string,
(fieldMetadataItem: FieldMetadataItem) => void
@ -134,7 +123,6 @@ export const useBuildAvailableFieldsForImport = () => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.CURRENCY,
currencyValidationResolver,
);
},
[FieldMetadataType.ACTOR]: (fieldMetadataItem) => {

View File

@ -39,6 +39,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
fieldMetadataItem.isActive &&
(!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') &&
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'updatedAt' &&
(fieldMetadataItem.type !== FieldMetadataType.RELATION ||
fieldMetadataItem.relationDefinition?.direction ===
RelationDefinitionType.MANY_TO_ONE),

View File

@ -0,0 +1,408 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow';
import { ImportedStructuredRow } from '@/spreadsheet-import/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
describe('buildRecordFromImportedStructuredRow', () => {
it('should successfully build a record from imported structured row', () => {
const importedStructuredRow: ImportedStructuredRow<string> = {
booleanField: 'true',
numberField: '30',
multiSelectField: '["tag1", "tag2", "tag3"]',
relationField: 'company-123',
selectField: 'option1',
arrayField: '["item1", "item2", "item3"]',
jsonField: '{"key": "value", "nested": {"prop": "data"}}',
richTextField: 'Some rich text content',
dateField: '2023-12-25',
dateTimeField: '2023-12-25T10:30:00Z',
ratingField: '4',
'BlockNote (richTextField)': 'Rich content in blocknote format',
'Markdown (richTextField)': 'Content in markdown format',
'First Name (fullNameField)': 'John',
'Last Name (fullNameField)': 'Doe',
'Amount (currencyField)': '75',
'Currency (currencyField)': 'USD',
'Address 1 (addressField)': '123 Main St',
'Address 2 (addressField)': 'Apt 4B',
'City (addressField)': 'New York',
'Post Code (addressField)': '10001',
'State (addressField)': 'NY',
'Country (addressField)': 'USA',
'Primary Email (emailField)': 'john.doe@example.com',
'Additional Emails (emailField)':
'["john.doe+work@example.com", "j.doe@company.com"]',
'Primary Phone Number (phoneField)': '+1-555-0123',
'Primary Phone Country Code (phoneField)': 'US',
'Primary Phone Calling Code (phoneField)': '+1',
'Additional Phones (phoneField)':
'[{"number": "+1-555-0124", "callingCode": "+1", "countryCode": "US"}]',
'Link URL (linksField)': 'https://example.com',
'Link Label (linksField)': 'Example Website',
'Secondary Links (linksField)':
'[{"url": "https://github.com/user", "label": "GitHub"}]',
};
const fields: FieldMetadataItem[] = [
{
id: '3',
name: 'booleanField',
label: 'Boolean Field',
type: FieldMetadataType.BOOLEAN,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCheck',
description: null,
},
{
id: '4',
name: 'numberField',
label: 'Number Field',
type: FieldMetadataType.NUMBER,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconNumber',
description: null,
},
{
id: '5',
name: 'multiSelectField',
label: 'Multi-Select Field',
type: FieldMetadataType.MULTI_SELECT,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconTag',
description: null,
options: [
{
id: '1',
value: 'tag1',
label: 'Tag 1',
color: 'blue',
position: 0,
},
{
id: '2',
value: 'tag2',
label: 'Tag 2',
color: 'red',
position: 1,
},
{
id: '3',
value: 'tag3',
label: 'Tag 3',
color: 'green',
position: 2,
},
],
},
{
id: '6',
name: 'relationField',
label: 'Relation Field',
type: FieldMetadataType.RELATION,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconBuilding',
description: null,
},
{
id: '7',
name: 'fullNameField',
label: 'Full Name Field',
type: FieldMetadataType.FULL_NAME,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconUser',
description: null,
},
{
id: '8',
name: 'currencyField',
label: 'Currency Field',
type: FieldMetadataType.CURRENCY,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCurrencyDollar',
description: null,
},
{
id: '9',
name: 'addressField',
label: 'Address Field',
type: FieldMetadataType.ADDRESS,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconMap',
description: null,
},
{
id: '10',
name: 'selectField',
label: 'Select Field',
type: FieldMetadataType.SELECT,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconTag',
description: null,
options: [
{
id: '1',
value: 'option1',
label: 'Option 1',
color: 'blue',
position: 0,
},
{
id: '2',
value: 'option2',
label: 'Option 2',
color: 'red',
position: 1,
},
],
},
{
id: '11',
name: 'arrayField',
label: 'Array Field',
type: FieldMetadataType.ARRAY,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconBracketsContain',
description: null,
},
{
id: '12',
name: 'jsonField',
label: 'JSON Field',
type: FieldMetadataType.RAW_JSON,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconBraces',
description: null,
},
{
id: '13',
name: 'phoneField',
label: 'Phone Field',
type: FieldMetadataType.PHONES,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconPhone',
description: null,
},
{
id: '14',
name: 'linksField',
label: 'Links Field',
type: FieldMetadataType.LINKS,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconWorld',
description: null,
},
{
id: '15',
name: 'createdBy',
label: 'Created by',
type: FieldMetadataType.ACTOR,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconUsers',
description: null,
},
{
id: '16',
name: 'richTextField',
label: 'Rich Text Field',
type: FieldMetadataType.RICH_TEXT_V2,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconTextEditor',
description: null,
},
{
id: '17',
name: 'dateField',
label: 'Date Field',
type: FieldMetadataType.DATE,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCalendarEvent',
description: null,
},
{
id: '18',
name: 'dateTimeField',
label: 'Date Time Field',
type: FieldMetadataType.DATE_TIME,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCalendarClock',
description: null,
},
{
id: '19',
name: 'ratingField',
label: 'Rating Field',
type: FieldMetadataType.RATING,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconStar',
description: null,
},
{
id: '20',
name: 'emailField',
label: 'Email Field',
type: FieldMetadataType.EMAILS,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconMail',
description: null,
},
];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fields,
});
expect(result).toEqual({
emailField: {
primaryEmail: 'john.doe@example.com',
additionalEmails: ['john.doe+work@example.com', 'j.doe@company.com'],
},
booleanField: true,
numberField: 30,
multiSelectField: ['tag1', 'tag2', 'tag3'],
relationFieldId: 'company-123',
selectField: 'option1',
arrayField: ['item1', 'item2', 'item3'],
jsonField: { key: 'value', nested: { prop: 'data' } },
fullNameField: {
firstName: 'John',
lastName: 'Doe',
},
currencyField: {
amountMicros: 75000000,
currencyCode: 'USD',
},
addressField: {
addressStreet1: '123 Main St',
addressStreet2: 'Apt 4B',
addressCity: 'New York',
addressPostcode: '10001',
addressState: 'NY',
addressCountry: 'USA',
},
phoneField: {
primaryPhoneNumber: '+1-555-0123',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: [
{
number: '+1-555-0124',
callingCode: '+1',
countryCode: 'US',
},
],
},
linksField: {
primaryLinkUrl: 'https://example.com',
primaryLinkLabel: 'Example Website',
secondaryLinks: [
{
url: 'https://github.com/user',
label: 'GitHub',
},
],
},
createdBy: {
source: 'IMPORT',
context: {},
},
richTextField: {
blocknote: 'Rich content in blocknote format',
markdown: 'Content in markdown format',
},
dateField: '2023-12-25',
dateTimeField: '2023-12-25T10:30:00Z',
ratingField: '4',
});
});
});

View File

@ -20,10 +20,67 @@ type BuildRecordFromImportedStructuredRowArgs = {
importedStructuredRow: ImportedStructuredRow<any>;
fields: FieldMetadataItem[];
};
export const buildRecordFromImportedStructuredRow = ({
fields,
importedStructuredRow,
}: BuildRecordFromImportedStructuredRowArgs) => {
const stringArrayJSONSchema = z
.preprocess((value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
}, z.array(z.string()))
.catch([]);
const linkArrayJSONSchema = z
.preprocess(
(value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
},
z.array(
z.object({
label: z.string().nullable(),
url: z.string().nullable(),
}),
),
)
.catch([]);
const phoneArrayJSONSchema = z
.preprocess(
(value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
},
z.array(
z.object({
number: z.string(),
callingCode: z.string(),
countryCode: z.string(),
}),
),
)
.catch([]);
const recordToBuild: Record<string, any> = {};
const {
@ -37,9 +94,14 @@ export const buildRecordFromImportedStructuredRow = ({
},
CURRENCY: { amountMicrosLabel, currencyCodeLabel },
FULL_NAME: { firstNameLabel, lastNameLabel },
LINKS: { primaryLinkUrlLabel },
EMAILS: { primaryEmailLabel },
PHONES: { primaryPhoneNumberLabel, primaryPhoneCountryCodeLabel },
LINKS: { primaryLinkUrlLabel, primaryLinkLabelLabel, secondaryLinksLabel },
EMAILS: { primaryEmailLabel, additionalEmailsLabel },
PHONES: {
primaryPhoneNumberLabel,
primaryPhoneCountryCodeLabel,
primaryPhoneCallingCodeLabel,
additionalPhonesLabel,
},
RICH_TEXT_V2: { blocknoteLabel, markdownLabel },
} = COMPOSITE_FIELD_IMPORT_LABELS;
@ -115,15 +177,23 @@ export const buildRecordFromImportedStructuredRow = ({
case FieldMetadataType.LINKS: {
if (
isDefined(
importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`],
importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`] ||
importedStructuredRow[
`${primaryLinkLabelLabel} (${field.name})`
] ||
importedStructuredRow[`${secondaryLinksLabel} (${field.name})`],
)
) {
recordToBuild[field.name] = {
primaryLinkLabel: '',
primaryLinkLabel: castToString(
importedStructuredRow[`${primaryLinkLabelLabel} (${field.name})`],
),
primaryLinkUrl: castToString(
importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`],
),
secondaryLinks: [],
secondaryLinks: linkArrayJSONSchema.parse(
importedStructuredRow[`${secondaryLinksLabel} (${field.name})`],
),
} satisfies FieldLinksValue;
}
break;
@ -136,7 +206,11 @@ export const buildRecordFromImportedStructuredRow = ({
] ||
importedStructuredRow[
`${primaryPhoneNumberLabel} (${field.name})`
],
] ||
importedStructuredRow[
`${primaryPhoneCallingCodeLabel} (${field.name})`
] ||
importedStructuredRow[`${additionalPhonesLabel} (${field.name})`],
)
) {
recordToBuild[field.name] = {
@ -150,7 +224,14 @@ export const buildRecordFromImportedStructuredRow = ({
`${primaryPhoneNumberLabel} (${field.name})`
],
),
additionalPhones: null,
primaryPhoneCallingCode: castToString(
importedStructuredRow[
`${primaryPhoneCallingCodeLabel} (${field.name})`
],
),
additionalPhones: phoneArrayJSONSchema.parse(
importedStructuredRow[`${additionalPhonesLabel} (${field.name})`],
),
} satisfies FieldPhonesValue;
}
break;
@ -177,13 +258,18 @@ export const buildRecordFromImportedStructuredRow = ({
if (
isDefined(
importedStructuredRow[`${primaryEmailLabel} (${field.name})`],
) ||
isDefined(
importedStructuredRow[`${additionalEmailsLabel} (${field.name})`],
)
) {
recordToBuild[field.name] = {
primaryEmail: castToString(
importedStructuredRow[`${primaryEmailLabel} (${field.name})`],
),
additionalEmails: null,
additionalEmails: stringArrayJSONSchema.parse(
importedStructuredRow[`${additionalEmailsLabel} (${field.name})`],
),
} satisfies FieldEmailsValue;
}
break;
@ -219,19 +305,6 @@ export const buildRecordFromImportedStructuredRow = ({
break;
case FieldMetadataType.ARRAY:
case FieldMetadataType.MULTI_SELECT: {
const stringArrayJSONSchema = z
.preprocess((value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
}, z.array(z.string()))
.catch([]);
recordToBuild[field.name] =
stringArrayJSONSchema.parse(importedFieldValue);
break;

View File

@ -1,62 +1,216 @@
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import { emailSchema } from '@/object-record/record-field/validation-schemas/emailSchema';
import { SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types';
import { t } from '@lingui/core/macro';
import { isDate, isString } from '@sniptt/guards';
import { absoluteUrlSchema, isDefined, isValidUuid } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const getNumberValidationDefinition = (
fieldName: string,
): SpreadsheetImportFieldValidationDefinition => ({
rule: 'function',
isValid: (value: string) => !isNaN(+value),
errorMessage: `${fieldName} ${t`must be a number`}`,
level: 'error',
});
export const getSpreadSheetFieldValidationDefinitions = (
type: FieldMetadataType,
fieldName: string,
subFieldKey?: string,
): SpreadsheetImportFieldValidationDefinition[] => {
switch (type) {
case FieldMetadataType.FULL_NAME:
return [
{
rule: 'object',
isValid: ({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) => {
return (
isDefined(firstName) &&
isDefined(lastName) &&
typeof firstName === 'string' &&
typeof lastName === 'string'
);
},
errorMessage: fieldName + ' must be a full name',
level: 'error',
},
];
case FieldMetadataType.NUMBER:
return [
{
rule: 'function',
isValid: (value: string) => !isNaN(+value),
errorMessage: fieldName + ' is not valid',
level: 'error',
},
];
return [getNumberValidationDefinition(fieldName)];
case FieldMetadataType.UUID:
case FieldMetadataType.RELATION:
return [
{
rule: 'function',
isValid: (value: string) => isValidUuid(value),
errorMessage: fieldName + ' is not valid',
errorMessage: `${fieldName} ${t`is not a valid UUID`}`,
level: 'error',
},
];
case FieldMetadataType.CURRENCY:
switch (subFieldKey) {
case 'amountMicrosLabel':
return [getNumberValidationDefinition(fieldName)];
default:
return [];
}
case FieldMetadataType.EMAILS:
switch (subFieldKey) {
case 'primaryEmailLabel':
return [
{
rule: 'function',
isValid: (email: string) => emailSchema.safeParse(email).success,
errorMessage: `${fieldName} ${t`is not a valid email`}`,
level: 'error',
},
];
case 'additionalEmailsLabel':
return [
{
rule: 'function',
isValid: (stringifiedAdditionalEmails: string) => {
if (!isDefined(stringifiedAdditionalEmails)) return true;
try {
const additionalEmails = JSON.parse(
stringifiedAdditionalEmails,
);
return additionalEmails.every(
(email: string) => emailSchema.safeParse(email).success,
);
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`must be an array of valid emails`}`,
level: 'error',
},
];
default:
return [];
}
case FieldMetadataType.LINKS:
switch (subFieldKey) {
case 'primaryLinkUrlLabel':
return [
{
rule: 'function',
isValid: (primaryLinkUrl: string) => {
if (!isDefined(primaryLinkUrl)) return true;
return absoluteUrlSchema.safeParse(primaryLinkUrl).success;
},
errorMessage: `${fieldName} ${t`is not a valid URL`}`,
level: 'error',
},
];
case 'secondaryLinksLabel':
return [
{
rule: 'function',
isValid: (stringifiedSecondaryLinks: string) => {
if (!isDefined(stringifiedSecondaryLinks)) return true;
try {
const secondaryLinks = JSON.parse(stringifiedSecondaryLinks);
return secondaryLinks.every((link: { url: string }) => {
if (!isDefined(link.url)) return true;
return absoluteUrlSchema.safeParse(link.url).success;
});
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`must be an array of object with valid url and label (format: '[{"url":"valid.url", "label":"label value")}]'`}`,
level: 'error',
},
];
default:
return [];
}
case FieldMetadataType.DATE_TIME:
return [
{
rule: 'object',
isValid: ({
primaryLinkUrl,
}: Pick<FieldLinksValue, 'primaryLinkUrl' | 'secondaryLinks'>) =>
absoluteUrlSchema.safeParse(primaryLinkUrl).success,
errorMessage: fieldName + ' is not valid',
rule: 'function',
isValid: (value: string) => {
const date = new Date(value);
return isDate(date) && !isNaN(date.getTime());
},
errorMessage: `${fieldName} ${t`is not a valid date time (format: '2021-12-01T00:00:00Z')`}`,
level: 'error',
},
];
case FieldMetadataType.DATE:
return [
{
rule: 'function',
isValid: (value: string) => {
const date = new Date(value);
return isDate(date) && !isNaN(date.getTime());
},
errorMessage: `${fieldName} ${t`is not a valid date (format: '2021-12-01')`}`,
level: 'error',
},
];
case FieldMetadataType.PHONES:
switch (subFieldKey) {
case 'primaryPhoneNumberLabel':
return [
{
rule: 'regex',
value: '^[0-9]+$',
errorMessage: `${fieldName} ${t`must contain only numbers`}`,
level: 'error',
},
];
case 'additionalPhonesLabel':
return [
{
rule: 'function',
isValid: (stringifiedAdditionalPhones: string) => {
if (!isDefined(stringifiedAdditionalPhones)) return true;
try {
const additionalPhones = JSON.parse(
stringifiedAdditionalPhones,
);
return additionalPhones.every(
(phone: {
number: string;
callingCode: string;
countryCode: string;
}) =>
isDefined(phone.number) &&
/^[0-9]+$/.test(phone.number) &&
isDefined(phone.callingCode) &&
isDefined(phone.countryCode),
);
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`must be an array of object with valid phone, calling code and country code (format: '[{"number":"123456789", "callingCode":"+33", "countryCode":"FR"}]')`}`,
level: 'error',
},
];
default:
return [];
}
case FieldMetadataType.RAW_JSON:
return [
{
rule: 'function',
isValid: (value: string) => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`is not a valid JSON`}`,
level: 'error',
},
];
case FieldMetadataType.ARRAY:
return [
{
rule: 'function',
isValid: (value: string) => {
try {
const parsedValue = JSON.parse(value);
return (
Array.isArray(parsedValue) &&
parsedValue.every((item: any) => isString(item))
);
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`is not a valid array`}`,
level: 'error',
},
];

View File

@ -9,6 +9,7 @@ import {
} from '@/spreadsheet-import/types';
import { TextInput } from '@/ui/input/components/TextInput';
import camelCase from 'lodash.camelcase';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip } from 'twenty-ui/display';
import { Checkbox, CheckboxVariant, Toggle } from 'twenty-ui/input';
@ -66,6 +67,10 @@ const StyledSelectReadonlyValueContianer = styled.div`
const SELECT_COLUMN_KEY = 'select-row';
const formatSafeId = (columnKey: string) => {
return camelCase(columnKey.replace('(', '').replace(')', ''));
};
export const generateColumns = <T extends string>(
fields: SpreadsheetImportFields<T>,
): Column<ImportedStructuredRow<T> & ImportedStructuredRowMetadata>[] => [
@ -110,13 +115,13 @@ export const generateColumns = <T extends string>(
resizable: true,
headerRenderer: () => (
<StyledHeaderContainer>
<StyledHeaderLabel id={`${column.key}`}>
<StyledHeaderLabel id={formatSafeId(column.key)}>
{column.label}
</StyledHeaderLabel>
{column.description &&
createPortal(
<AppTooltip
anchorSelect={`#${column.key}`}
anchorSelect={`#${formatSafeId(column.key)}`}
place="top"
content={column.description}
/>,
@ -168,7 +173,7 @@ export const generateColumns = <T extends string>(
case 'checkbox':
component = (
<StyledToggleContainer
id={`${columnKey}-${row.__index}`}
id={formatSafeId(`${columnKey}-${row.__index}`)}
onClick={(event) => {
event.stopPropagation();
}}
@ -187,7 +192,9 @@ export const generateColumns = <T extends string>(
break;
case 'select':
component = (
<StyledDefaultContainer id={`${columnKey}-${row.__index}`}>
<StyledDefaultContainer
id={formatSafeId(`${columnKey}-${row.__index}`)}
>
{column.fieldType.options.find(
(option) => option.value === row[columnKey as T],
)?.label || null}
@ -196,7 +203,9 @@ export const generateColumns = <T extends string>(
break;
default:
component = (
<StyledDefaultContainer id={`${columnKey}-${row.__index}`}>
<StyledDefaultContainer
id={formatSafeId(`${columnKey}-${row.__index}`)}
>
{row[columnKey]}
</StyledDefaultContainer>
);
@ -208,7 +217,7 @@ export const generateColumns = <T extends string>(
{component}
{createPortal(
<AppTooltip
anchorSelect={`#${columnKey}-${row.__index}`}
anchorSelect={`#${formatSafeId(`${columnKey}-${row.__index}`)}`}
place="top"
content={row.__errors?.[columnKey]?.message}
/>,