diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataEnumKind.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataEnumKind.ts new file mode 100644 index 0000000000..9c16be9f7b --- /dev/null +++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataEnumKind.ts @@ -0,0 +1,11 @@ +import { FieldMetadataType } from '@/types'; + +export const isFieldMetadataEnumKind = ( + fieldMetadataType: FieldMetadataType, +): boolean => { + return ( + fieldMetadataType === FieldMetadataType.SELECT || + fieldMetadataType === FieldMetadataType.MULTI_SELECT || + fieldMetadataType === FieldMetadataType.RATING + ); +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index d6e8717830..ce7dbf402f 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -53,6 +53,7 @@ export { export { isFieldMetadataArrayKind } from './fieldMetadata/isFieldMetadataArrayKind'; export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind'; export { isFieldMetadataEligibleForFieldsWidget } from './fieldMetadata/isFieldMetadataEligibleForFieldsWidget'; +export { isFieldMetadataEnumKind } from './fieldMetadata/isFieldMetadataEnumKind'; export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumericKind'; export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind'; export { isFieldMetadataSupportedInGroupBy } from './fieldMetadata/isFieldMetadataSupportedInGroupBy'; diff --git a/packages/twenty-zapier/src/creates/crud_record.ts b/packages/twenty-zapier/src/creates/crud_record.ts index 74c3c978a6..de84d2df8a 100644 --- a/packages/twenty-zapier/src/creates/crud_record.ts +++ b/packages/twenty-zapier/src/creates/crud_record.ts @@ -2,12 +2,20 @@ import type { Bundle, ZObject } from 'zapier-platform-core'; import { findObjectNamesSingularKey } from 'src/triggers/find_object_names_singular'; import { listRecordIdsKey } from 'src/triggers/list_record_ids'; import { computeInputFields } from 'src/utils/computeInputFields'; -import { type InputData } from 'src/utils/data.types'; +import { type InputData, type Node, type Schema } from 'src/utils/data.types'; import handleQueryParams from 'src/utils/handleQueryParams'; import requestDb, { requestSchema } from 'src/utils/requestDb'; import { DatabaseEventAction } from 'src/utils/triggers/triggers.utils'; import { isNonEmptyString } from '@sniptt/guards'; +const getObjectNode = ( + schema: Schema, + nameSingular: string, +): Node | undefined => + schema.data.objects.edges.find( + (edge) => edge.node.nameSingular === nameSingular, + )?.node; + const capitalize = (stringToCapitalize: string) => { if (!isNonEmptyString(stringToCapitalize)) return ''; @@ -51,13 +59,14 @@ const computeFields = async (z: ZObject, bundle: Bundle) => { const computeQueryParameters = ( operation: DatabaseEventAction, data: InputData, + node?: Node, ): string => { switch (operation) { case DatabaseEventAction.CREATED: - return `data:{${handleQueryParams(data)}}`; + return `data:{${handleQueryParams(data, node)}}`; case DatabaseEventAction.UPDATED: return ` - data:{${handleQueryParams(data)}}, + data:{${handleQueryParams(data, node)}}, id: "${data.id}" `; case DatabaseEventAction.DELETED: @@ -94,12 +103,16 @@ const perform = async (z: ZObject, bundle: Bundle) => { const operation = data.crudZapierOperation; const queryOperation = getOperationFromDatabaseEventAction(z, operation); const nameSingular = data.nameSingular; + const node = + operation === DatabaseEventAction.DELETED + ? undefined + : getObjectNode(await requestSchema(z, bundle), nameSingular); delete data.nameSingular; delete data.crudZapierOperation; const query = ` mutation ${queryOperation}${capitalize(nameSingular)} { ${queryOperation}${capitalize(nameSingular)}( - ${computeQueryParameters(operation, data)} + ${computeQueryParameters(operation, data, node)} ) {id} }`; diff --git a/packages/twenty-zapier/src/utils/__tests__/computeInputFields.test.ts b/packages/twenty-zapier/src/utils/__tests__/computeInputFields.test.ts index 3fd044d8e1..3d6984453e 100644 --- a/packages/twenty-zapier/src/utils/__tests__/computeInputFields.test.ts +++ b/packages/twenty-zapier/src/utils/__tests__/computeInputFields.test.ts @@ -252,4 +252,67 @@ describe('computeInputFields', () => { idRequiredExpectedResult, ); }); + + test('should create Select and Multi Select input fields with choices', () => { + const node = { + nameSingular: 'opportunity', + namePlural: 'opportunities', + labelSingular: 'Opportunity', + fields: { + edges: [ + { + node: { + type: FieldMetadataType.SELECT, + name: 'stage', + label: 'Stage', + description: 'Opportunity stage', + isNullable: true, + defaultValue: null, + options: [ + { value: 'NEW', label: 'New' }, + { value: 'SCREENING', label: 'Screening' }, + ], + }, + }, + { + node: { + type: FieldMetadataType.MULTI_SELECT, + name: 'tags', + label: 'Tags', + description: 'Opportunity tags', + isNullable: true, + defaultValue: null, + options: [ + { value: 'URGENT', label: 'Urgent' }, + { value: 'VIP', label: 'VIP' }, + ], + }, + }, + ], + }, + }; + const expectedResult: InputField[] = [ + { + key: 'stage', + label: 'Stage', + type: 'string', + helpText: 'Opportunity stage', + required: false, + list: false, + placeholder: undefined, + choices: { NEW: 'New', SCREENING: 'Screening' }, + }, + { + key: 'tags', + label: 'Tags', + type: 'string', + helpText: 'Opportunity tags', + required: false, + list: true, + placeholder: undefined, + choices: { URGENT: 'Urgent', VIP: 'VIP' }, + }, + ]; + expect(computeInputFields(node)).toEqual(expectedResult); + }); }); diff --git a/packages/twenty-zapier/src/utils/__tests__/handleQueryParams.test.ts b/packages/twenty-zapier/src/utils/__tests__/handleQueryParams.test.ts index 17476b57d7..530f146cc9 100644 --- a/packages/twenty-zapier/src/utils/__tests__/handleQueryParams.test.ts +++ b/packages/twenty-zapier/src/utils/__tests__/handleQueryParams.test.ts @@ -1,3 +1,5 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { type Node } from 'src/utils/data.types'; import handleQueryParams from 'src/utils/handleQueryParams'; describe('utils.handleQueryParams', () => { @@ -52,4 +54,65 @@ describe('utils.handleQueryParams', () => { 'employees: 25'; expect(result).toEqual(expectedResult); }); + test('should not quote Select, Multi Select and Rating enum values', () => { + const inputData = { + name: 'Opportunity Name', + stage: 'SCREENING', + tags: ['URGENT', 'VIP'], + rating: 'RATING_3', + }; + const node: Node = { + nameSingular: 'opportunity', + namePlural: 'opportunities', + labelSingular: 'Opportunity', + fields: { + edges: [ + { + node: { + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + description: null, + isNullable: true, + defaultValue: null, + }, + }, + { + node: { + type: FieldMetadataType.SELECT, + name: 'stage', + label: 'Stage', + description: null, + isNullable: true, + defaultValue: null, + }, + }, + { + node: { + type: FieldMetadataType.MULTI_SELECT, + name: 'tags', + label: 'Tags', + description: null, + isNullable: true, + defaultValue: null, + }, + }, + { + node: { + type: FieldMetadataType.RATING, + name: 'rating', + label: 'Rating', + description: null, + isNullable: true, + defaultValue: null, + }, + }, + ], + }, + }; + const result = handleQueryParams(inputData, node); + const expectedResult = + 'name: "Opportunity Name", stage: SCREENING, tags: [URGENT, VIP], rating: RATING_3'; + expect(result).toEqual(expectedResult); + }); }); diff --git a/packages/twenty-zapier/src/utils/computeInputFields.ts b/packages/twenty-zapier/src/utils/computeInputFields.ts index bb409cd28b..f1f825279f 100644 --- a/packages/twenty-zapier/src/utils/computeInputFields.ts +++ b/packages/twenty-zapier/src/utils/computeInputFields.ts @@ -4,9 +4,24 @@ import { type Node, type NodeField, } from 'src/utils/data.types'; +import { isNonEmptyArray } from '@sniptt/guards'; const getListFromFieldMetadataType = (fieldMetadataType: FieldMetadataType) => { - return fieldMetadataType === FieldMetadataType.ARRAY; + return ( + fieldMetadataType === FieldMetadataType.ARRAY || + fieldMetadataType === FieldMetadataType.MULTI_SELECT + ); +}; + +const getChoicesFromNodeField = ( + nodeField: NodeField, +): { [value: string]: string } | undefined => { + if (!isNonEmptyArray(nodeField.options)) { + return undefined; + } + return Object.fromEntries( + nodeField.options.map((option) => [option.value, option.label]), + ); }; const getTypeFromFieldMetadataType = ( @@ -17,6 +32,8 @@ const getTypeFromFieldMetadataType = ( case FieldMetadataType.TEXT: case FieldMetadataType.ARRAY: case FieldMetadataType.RATING: + case FieldMetadataType.SELECT: + case FieldMetadataType.MULTI_SELECT: return 'string'; case FieldMetadataType.DATE_TIME: return 'datetime'; @@ -247,7 +264,9 @@ export const computeInputFields = ( case FieldMetadataType.NUMBER: case FieldMetadataType.NUMERIC: case FieldMetadataType.ARRAY: - case FieldMetadataType.RATING: { + case FieldMetadataType.RATING: + case FieldMetadataType.SELECT: + case FieldMetadataType.MULTI_SELECT: { const nodeFieldType = getTypeFromFieldMetadataType(nodeField.type); if (!nodeFieldType) { break; @@ -263,6 +282,7 @@ export const computeInputFields = ( required, list: getListFromFieldMetadataType(nodeField.type), placeholder: undefined, + choices: getChoicesFromNodeField(nodeField), }; result.push(field); break; diff --git a/packages/twenty-zapier/src/utils/data.types.ts b/packages/twenty-zapier/src/utils/data.types.ts index c70ccafb02..85a8f6dfdf 100644 --- a/packages/twenty-zapier/src/utils/data.types.ts +++ b/packages/twenty-zapier/src/utils/data.types.ts @@ -2,6 +2,11 @@ import { type FieldMetadataType } from 'twenty-shared/types'; export type InputData = { [x: string]: any }; +export type NodeFieldOption = { + value: string; + label: string; +}; + export type NodeField = { type: FieldMetadataType; name: string; @@ -11,6 +16,7 @@ export type NodeField = { defaultValue: boolean | object | null; list?: boolean; placeholder?: string; + options?: NodeFieldOption[] | null; }; export type Node = { @@ -32,6 +38,7 @@ export type InputField = { required: boolean; list?: boolean; placeholder?: string; + choices?: { [value: string]: string }; }; export type Schema = { diff --git a/packages/twenty-zapier/src/utils/handleQueryParams.ts b/packages/twenty-zapier/src/utils/handleQueryParams.ts index 6a10214c8e..116c974c96 100644 --- a/packages/twenty-zapier/src/utils/handleQueryParams.ts +++ b/packages/twenty-zapier/src/utils/handleQueryParams.ts @@ -1,50 +1,79 @@ -import { type InputData } from 'src/utils/data.types'; +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isFieldMetadataEnumKind } from 'twenty-shared/utils'; +import { type InputData, type Node } from 'src/utils/data.types'; -const OBJECT_SUBFIELD_NAMES = ['secondaryLinks', 'additionalPhones']; +const RAW_JSON_SUBFIELD_NAMES = ['secondaryLinks', 'additionalPhones']; -const formatArrayInputData = ( - key: string, - arrayInputData: InputData, -): string => { - if (OBJECT_SUBFIELD_NAMES.includes(key)) { - return `${arrayInputData[key].join('","')}`; - } - return `"${arrayInputData[key].join('","')}"`; -}; +const isPlainObject = (value: unknown): value is InputData => + typeof value === 'object' && value !== null && !Array.isArray(value); -const handleQueryParams = (inputData: InputData): string => { - const formattedInputData: InputData = {}; - Object.keys(inputData).forEach((key) => { +const getFieldTypeByName = (node?: Node): Map => + new Map( + (node?.fields.edges ?? []).map((edge) => [edge.node.name, edge.node.type]), + ); + +const nestCompositeFields = (inputData: InputData): InputData => { + const nestedInputData: InputData = {}; + for (const [key, value] of Object.entries(inputData)) { if (key.includes('__')) { - const [objectKey, nestedObjectKey] = key.split('__'); - if (formattedInputData[objectKey]) { - formattedInputData[objectKey][nestedObjectKey] = inputData[key]; - } else { - formattedInputData[objectKey] = { [nestedObjectKey]: inputData[key] }; - } + const [fieldName, subFieldName] = key.split('__'); + nestedInputData[fieldName] = { + ...nestedInputData[fieldName], + [subFieldName]: value, + }; } else { - formattedInputData[key] = inputData[key]; + nestedInputData[key] = value; } - }); - let result = ''; - Object.keys(formattedInputData).forEach((key) => { - let quote = ''; - if (Array.isArray(formattedInputData[key])) { - result = result.concat( - `${key}: [${formatArrayInputData(key, formattedInputData)}], `, - ); - } else if (typeof formattedInputData[key] === 'object') { - result = result.concat( - `${key}: {${handleQueryParams(formattedInputData[key])}}, `, - ); - } else { - if (typeof formattedInputData[key] === 'string') quote = '"'; - result = result.concat( - `${key}: ${quote}${formattedInputData[key]}${quote}, `, - ); - } - }); - if (result.length) result = result.slice(0, -2); // Remove the last ', ' - return result; + } + return nestedInputData; }; + +const isUnquotedField = ( + fieldName: string, + fieldTypeByName: Map, +): boolean => { + const fieldType = fieldTypeByName.get(fieldName); + return ( + (fieldType !== undefined && isFieldMetadataEnumKind(fieldType)) || + RAW_JSON_SUBFIELD_NAMES.includes(fieldName) + ); +}; + +const serializeValue = ( + fieldName: string, + value: unknown, + fieldTypeByName: Map, +): string => { + if (Array.isArray(value)) { + const items = value.map((item) => + serializeValue(fieldName, item, fieldTypeByName), + ); + return `[${items.join(', ')}]`; + } + if (isPlainObject(value)) { + return `{${serializeFields(value, fieldTypeByName)}}`; + } + if ( + typeof value === 'string' && + !isUnquotedField(fieldName, fieldTypeByName) + ) { + return `"${value}"`; + } + return `${value}`; +}; + +const serializeFields = ( + inputData: InputData, + fieldTypeByName: Map, +): string => + Object.entries(inputData) + .map( + ([key, value]) => + `${key}: ${serializeValue(key, value, fieldTypeByName)}`, + ) + .join(', '); + +const handleQueryParams = (inputData: InputData, node?: Node): string => + serializeFields(nestCompositeFields(inputData), getFieldTypeByName(node)); + export default handleQueryParams; diff --git a/packages/twenty-zapier/src/utils/requestDb.ts b/packages/twenty-zapier/src/utils/requestDb.ts index 1f6dbe8172..7b9928d985 100644 --- a/packages/twenty-zapier/src/utils/requestDb.ts +++ b/packages/twenty-zapier/src/utils/requestDb.ts @@ -22,6 +22,7 @@ export const requestSchema = async ( description isNullable defaultValue + options } } }