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:
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@ -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}
|
||||
/>,
|
||||
|
||||
Reference in New Issue
Block a user