From 15a5fec545bb2595b911a5aa1110a3096ce67729 Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 13 Feb 2024 22:22:47 +0100 Subject: [PATCH] Zapier add description to labels (#3787) * Use object metadata graphql api to fetch input fields * Clean code * Clean code * Remove targetColumnMap * Remove duplicated testing * Fix labels --- .../twenty-zapier/src/creates/crud_record.ts | 18 +- .../src/test/utils/computeInputFields.test.ts | 249 ++++++++++++++---- .../src/test/utils/labelize.test.ts | 7 - .../triggers/find_object_names_singular.ts | 9 +- .../src/triggers/trigger_record.ts | 2 +- .../src/utils/computeInputFields.ts | 187 +++++++++---- .../src/utils/creates/creates.utils.ts | 17 -- .../twenty-zapier/src/utils/data.types.ts | 56 ++++ packages/twenty-zapier/src/utils/labelling.ts | 9 - packages/twenty-zapier/src/utils/requestDb.ts | 41 ++- .../src/utils/triggers/triggers.utils.ts | 21 +- 11 files changed, 451 insertions(+), 165 deletions(-) delete mode 100644 packages/twenty-zapier/src/test/utils/labelize.test.ts delete mode 100644 packages/twenty-zapier/src/utils/creates/creates.utils.ts delete mode 100644 packages/twenty-zapier/src/utils/labelling.ts diff --git a/packages/twenty-zapier/src/creates/crud_record.ts b/packages/twenty-zapier/src/creates/crud_record.ts index 6a18befc9..d0beb0ee4 100644 --- a/packages/twenty-zapier/src/creates/crud_record.ts +++ b/packages/twenty-zapier/src/creates/crud_record.ts @@ -3,12 +3,24 @@ import { Bundle, ZObject } from 'zapier-platform-core'; import { findObjectNamesSingularKey } from '../triggers/find_object_names_singular'; import { listRecordIdsKey } from '../triggers/list_record_ids'; import { capitalize } from '../utils/capitalize'; -import { recordInputFields } from '../utils/creates/creates.utils'; +import { computeInputFields } from '../utils/computeInputFields'; import { InputData } from '../utils/data.types'; import handleQueryParams from '../utils/handleQueryParams'; -import requestDb from '../utils/requestDb'; +import requestDb, { requestSchema } from '../utils/requestDb'; import { Operation } from '../utils/triggers/triggers.utils'; +export const recordInputFields = async ( + z: ZObject, + bundle: Bundle, + idRequired = false, +) => { + const schema = await requestSchema(z, bundle); + const node = schema.data.objects.edges.filter( + (edge) => edge.node.nameSingular === bundle.inputData.nameSingular, + )[0].node; + return computeInputFields(node, idRequired); +}; + const computeFields = async (z: ZObject, bundle: Bundle) => { const operation = bundle.inputData.crudZapierOperation; switch (operation) { @@ -84,7 +96,7 @@ export default { key: 'nameSingular', required: true, label: 'Record Name', - dynamic: `${findObjectNamesSingularKey}.nameSingular`, + dynamic: `${findObjectNamesSingularKey}.nameSingular.labelSingular`, altersDynamicFields: true, }, { diff --git a/packages/twenty-zapier/src/test/utils/computeInputFields.test.ts b/packages/twenty-zapier/src/test/utils/computeInputFields.test.ts index 7795ef0ae..3eb1433cd 100644 --- a/packages/twenty-zapier/src/test/utils/computeInputFields.test.ts +++ b/packages/twenty-zapier/src/test/utils/computeInputFields.test.ts @@ -1,78 +1,223 @@ import { computeInputFields } from '../../utils/computeInputFields'; +import { InputField } from '../../utils/data.types'; describe('computeInputFields', () => { test('should create Person input fields properly', () => { - const personInfos = { - type: 'object', - properties: { - id: { - type: 'string', - }, - email: { - type: 'string', - }, - xLink: { - type: 'object', - properties: { - url: { - type: 'string', - }, - label: { - type: 'string', + const personNode = { + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Person', + fields: { + edges: [ + { + node: { + type: 'RELATION', + name: 'favorites', + label: 'Favorites', + description: 'Favorites linked to the contact', + isNullable: true, + defaultValue: null, }, }, - }, - avatarUrl: { - type: 'string', - }, - favorites: { - type: 'array', - items: { - $ref: '#/components/schemas/Favorite', + { + node: { + type: 'CURRENCY', + name: 'annualSalary', + label: 'Annual Salary', + description: 'Annual Salary of the Person', + isNullable: true, + defaultValue: null, + }, }, - }, + { + node: { + type: 'TEXT', + name: 'jobTitle', + label: 'Job Title', + description: 'Contact’s job title', + isNullable: false, + defaultValue: { + value: '', + }, + }, + }, + { + node: { + type: 'DATE_TIME', + name: 'updatedAt', + label: 'Update date', + description: null, + isNullable: false, + defaultValue: { + type: 'now', + }, + }, + }, + { + node: { + type: 'FULL_NAME', + name: 'name', + label: 'Name', + description: 'Contact’s name', + isNullable: true, + defaultValue: { + lastName: '', + firstName: '', + }, + }, + }, + { + node: { + type: 'UUID', + name: 'id', + label: 'Id', + description: null, + icon: null, + isNullable: false, + defaultValue: { + type: 'uuid', + }, + }, + }, + { + node: { + type: 'NUMBER', + name: 'recordPosition', + label: 'RecordPosition', + description: 'Record Position', + isNullable: true, + defaultValue: null, + }, + }, + { + node: { + type: 'LINK', + name: 'xLink', + label: 'X', + description: 'Contact’s X/Twitter account', + isNullable: true, + defaultValue: null, + }, + }, + { + node: { + type: 'EMAIL', + name: 'email', + label: 'Email', + description: 'Contact’s Email', + isNullable: false, + defaultValue: { + value: '', + }, + }, + }, + { + node: { + type: 'UUID', + name: 'companyId', + label: 'Company id (foreign key)', + description: 'Contact’s company id foreign key', + isNullable: true, + defaultValue: null, + }, + }, + ], }, - example: {}, - required: ['avatarUrl'], }; - expect(computeInputFields(personInfos)).toEqual([ - { key: 'id', label: 'Id', required: false, type: 'string' }, - { key: 'email', label: 'Email', required: false, type: 'string' }, + const baseExpectedResult: InputField[] = [ + { + key: 'annualSalary__amountMicros', + label: 'Annual Salary: Amount Micros', + type: 'integer', + helpText: + 'Annual Salary of the Person: Amount Micros. eg: set 3210000 for 3.21$', + required: false, + }, + { + key: 'annualSalary__currencyCode', + label: 'Annual Salary: Currency Code', + type: 'string', + helpText: + 'Annual Salary of the Person: Currency Code. eg: USD, EUR, etc...', + required: false, + }, + { + key: 'jobTitle', + label: 'Job Title', + type: 'string', + helpText: 'Contact’s job title', + required: false, + }, + { + key: 'updatedAt', + label: 'Update date', + type: 'datetime', + helpText: null, + required: false, + }, + { + key: 'name__firstName', + label: 'Name: First Name', + type: 'string', + helpText: 'Contact’s name: First Name', + required: false, + }, + { + key: 'name__lastName', + label: 'Name: Last Name', + type: 'string', + helpText: 'Contact’s name: Last Name', + required: false, + }, + { + key: 'recordPosition', + label: 'RecordPosition', + type: 'integer', + helpText: 'Record Position', + required: false, + }, { key: 'xLink__url', - label: 'X Link: Url', - required: false, + label: 'X: Url', type: 'string', + helpText: 'Contact’s X/Twitter account: Link Url', + required: false, }, { key: 'xLink__label', - label: 'X Link: Label', - required: false, + label: 'X: Label', type: 'string', - }, - { key: 'avatarUrl', label: 'Avatar Url', required: true, type: 'string' }, - ]); - expect(computeInputFields(personInfos, true)).toEqual([ - { key: 'id', label: 'Id', required: true, type: 'string' }, - { key: 'email', label: 'Email', required: false, type: 'string' }, - { - key: 'xLink__url', - label: 'X Link: Url', + helpText: 'Contact’s X/Twitter account: Link Label', required: false, - type: 'string', }, { - key: 'xLink__label', - label: 'X Link: Label', - required: false, + key: 'email', + label: 'Email', type: 'string', + helpText: 'Contact’s Email', + required: false, }, { - key: 'avatarUrl', - label: 'Avatar Url', - required: false, + key: 'companyId', + label: 'Company id (foreign key)', type: 'string', + helpText: 'Contact’s company id foreign key', + required: false, }, - ]); + ]; + const idInputField: InputField = { + key: 'id', + label: 'Id', + type: 'string', + helpText: null, + required: false, + }; + const expectedResult = [idInputField].concat(baseExpectedResult); + expect(computeInputFields(personNode)).toEqual(expectedResult); + idInputField.required = true; + const idRequiredExpectedResult = [idInputField].concat(baseExpectedResult); + expect(computeInputFields(personNode, true)).toEqual( + idRequiredExpectedResult, + ); }); }); diff --git a/packages/twenty-zapier/src/test/utils/labelize.test.ts b/packages/twenty-zapier/src/test/utils/labelize.test.ts deleted file mode 100644 index 529d047f9..000000000 --- a/packages/twenty-zapier/src/test/utils/labelize.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { labelling } from '../../utils/labelling'; - -describe('labelling', () => { - test('should label properly', () => { - expect(labelling('createdAt')).toEqual('Created At'); - }); -}); diff --git a/packages/twenty-zapier/src/triggers/find_object_names_singular.ts b/packages/twenty-zapier/src/triggers/find_object_names_singular.ts index 35d4fcf07..a0b5dcf75 100644 --- a/packages/twenty-zapier/src/triggers/find_object_names_singular.ts +++ b/packages/twenty-zapier/src/triggers/find_object_names_singular.ts @@ -4,8 +4,13 @@ import { requestSchema } from '../utils/requestDb'; const objectListRequest = async (z: ZObject, bundle: Bundle) => { const schema = await requestSchema(z, bundle); - return Object.keys(schema.components.schemas).map((schema) => { - return { id: schema, nameSingular: schema.toLowerCase() }; + return schema.data.objects.edges.map((edge: any) => { + const object = edge.node; + return { + id: object.nameSingular, + nameSingular: object.nameSingular, + labelSingular: object.labelSingular, + }; }); }; diff --git a/packages/twenty-zapier/src/triggers/trigger_record.ts b/packages/twenty-zapier/src/triggers/trigger_record.ts index 3e589fa8e..8f408e371 100644 --- a/packages/twenty-zapier/src/triggers/trigger_record.ts +++ b/packages/twenty-zapier/src/triggers/trigger_record.ts @@ -29,7 +29,7 @@ export default { key: 'nameSingular', required: true, label: 'Record Name', - dynamic: `${findObjectNamesSingularKey}.nameSingular`, + dynamic: `${findObjectNamesSingularKey}.nameSingular.labelSingular`, altersDynamicFields: true, }, { diff --git a/packages/twenty-zapier/src/utils/computeInputFields.ts b/packages/twenty-zapier/src/utils/computeInputFields.ts index 05025301f..ca80a8fb5 100644 --- a/packages/twenty-zapier/src/utils/computeInputFields.ts +++ b/packages/twenty-zapier/src/utils/computeInputFields.ts @@ -1,63 +1,156 @@ -import { labelling } from '../utils/labelling'; +import { + FieldMetadataType, + InputField, + Node, + NodeField, +} from '../utils/data.types'; -type Infos = { - properties: { - [field: string]: { - type: string; - properties?: { [field: string]: { type: string } }; - items?: { [$ref: string]: string }; - }; - }; - example: object; - required: string[]; +const getTypeFromFieldMetadataType = ( + fieldMetadataType: string, +): string | undefined => { + switch (fieldMetadataType) { + case FieldMetadataType.UUID: + case FieldMetadataType.TEXT: + case FieldMetadataType.PHONE: + case FieldMetadataType.EMAIL: + case FieldMetadataType.LINK: + case FieldMetadataType.RATING: + return 'string'; + case FieldMetadataType.DATE_TIME: + return 'datetime'; + case FieldMetadataType.BOOLEAN: + return 'boolean'; + case FieldMetadataType.NUMBER: + return 'integer'; + case FieldMetadataType.NUMERIC: + case FieldMetadataType.PROBABILITY: + return 'number'; + default: + return undefined; + } +}; + +const get_subfieldsFromField = (nodeField: NodeField): NodeField[] => { + switch (nodeField.type) { + case FieldMetadataType.FULL_NAME: { + const firstName: NodeField = { + type: 'TEXT', + name: 'firstName', + label: 'First Name', + description: 'First Name', + isNullable: true, + defaultValue: null, + }; + const lastName: NodeField = { + type: 'TEXT', + name: 'lastName', + label: 'Last Name', + description: 'Last Name', + isNullable: true, + defaultValue: null, + }; + return [firstName, lastName]; + } + case FieldMetadataType.LINK: { + const url: NodeField = { + type: 'TEXT', + name: 'url', + label: 'Url', + description: 'Link Url', + isNullable: true, + defaultValue: null, + }; + const label: NodeField = { + type: 'TEXT', + name: 'label', + label: 'Label', + description: 'Link Label', + isNullable: true, + defaultValue: null, + }; + return [url, label]; + } + case FieldMetadataType.CURRENCY: { + const amountMicros: NodeField = { + type: 'NUMBER', + name: 'amountMicros', + label: 'Amount Micros', + description: 'Amount Micros. eg: set 3210000 for 3.21$', + isNullable: true, + defaultValue: null, + }; + const currencyCode: NodeField = { + type: 'TEXT', + name: 'currencyCode', + label: 'Currency Code', + description: 'Currency Code. eg: USD, EUR, etc...', + isNullable: true, + defaultValue: null, + }; + return [amountMicros, currencyCode]; + } + default: + throw new Error(`Unknown nodeField type: ${nodeField.type}`); + } +}; + +const isFieldRequired = (nodeField: NodeField): boolean => { + return !nodeField.isNullable && !nodeField.defaultValue; }; export const computeInputFields = ( - infos: Infos, - idRequired = false, -): object[] => { + node: Node, + isRequired = false, +): InputField[] => { const result = []; - - for (const fieldName of Object.keys(infos.properties)) { - switch (infos.properties[fieldName].type) { - case 'array': - break; - case 'object': - if (!infos.properties[fieldName].properties) { - break; - } - for (const subFieldName of Object.keys( - infos.properties[fieldName].properties || {}, - )) { + for (const field of node.fields.edges) { + const nodeField = field.node; + switch (nodeField.type) { + case FieldMetadataType.FULL_NAME: + case FieldMetadataType.LINK: + case FieldMetadataType.CURRENCY: + for (const subNodeField of get_subfieldsFromField(nodeField)) { const field = { - key: `${fieldName}__${subFieldName}`, - label: `${labelling(fieldName)}: ${labelling(subFieldName)}`, - type: infos.properties[fieldName].properties?.[subFieldName].type, - required: false, - }; - if (infos.required?.includes(fieldName)) { - field.required = true; - } + key: `${nodeField.name}__${subNodeField.name}`, + label: `${nodeField.label}: ${subNodeField.label}`, + type: getTypeFromFieldMetadataType(subNodeField.type), + helpText: `${nodeField.description}: ${subNodeField.description}`, + required: isFieldRequired(subNodeField), + } as InputField; result.push(field); } break; - default: { - const field = { - key: fieldName, - label: labelling(fieldName), - type: infos.properties[fieldName].type, - required: false, - }; - if ( - (idRequired && fieldName === 'id') || - (!idRequired && infos.required?.includes(fieldName)) - ) { - field.required = true; + case FieldMetadataType.UUID: + case FieldMetadataType.TEXT: + case FieldMetadataType.PHONE: + case FieldMetadataType.EMAIL: + case FieldMetadataType.DATE_TIME: + case FieldMetadataType.BOOLEAN: + case FieldMetadataType.NUMBER: + case FieldMetadataType.NUMERIC: + case FieldMetadataType.PROBABILITY: + case FieldMetadataType.RATING: { + const nodeFieldType = getTypeFromFieldMetadataType(nodeField.type); + if (!nodeFieldType) { + break; } + const required = + (isRequired && nodeField.name === 'id') || + (!isRequired && isFieldRequired(nodeField)); + const field = { + key: nodeField.name, + label: nodeField.label, + type: nodeFieldType, + helpText: nodeField.description, + required, + }; result.push(field); + break; } + default: + break; } } - return result; + return result.sort((a, _) => (a.key === 'id' ? -1 : 1)); }; diff --git a/packages/twenty-zapier/src/utils/creates/creates.utils.ts b/packages/twenty-zapier/src/utils/creates/creates.utils.ts deleted file mode 100644 index 4d940cbb3..000000000 --- a/packages/twenty-zapier/src/utils/creates/creates.utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Bundle, ZObject } from 'zapier-platform-core'; - -import { computeInputFields } from '../../utils/computeInputFields'; -import { requestSchema } from '../../utils/requestDb'; -import { capitalize } from '../capitalize'; - -export const recordInputFields = async ( - z: ZObject, - bundle: Bundle, - idRequired = false, -) => { - const schema = await requestSchema(z, bundle); - const infos = - schema.components.schemas[capitalize(bundle.inputData.nameSingular)]; - - return computeInputFields(infos, idRequired); -}; diff --git a/packages/twenty-zapier/src/utils/data.types.ts b/packages/twenty-zapier/src/utils/data.types.ts index 02bcb7bbb..540860c89 100644 --- a/packages/twenty-zapier/src/utils/data.types.ts +++ b/packages/twenty-zapier/src/utils/data.types.ts @@ -1,2 +1,58 @@ export type InputData = { [x: string]: any }; + export type ObjectData = { id: string } | { [x: string]: any }; + +export type NodeField = { + type: string; + name: string; + label: string; + description: string | null; + isNullable: boolean; + defaultValue: object | null; +}; + +export type Node = { + nameSingular: string; + namePlural: string; + labelSingular: string; + fields: { + edges: { + node: NodeField; + }[]; + }; +}; + +export type InputField = { + key: string; + label: string; + type: string; + helpText: string | null; + required: boolean; +}; + +export enum FieldMetadataType { + UUID = 'UUID', + TEXT = 'TEXT', + PHONE = 'PHONE', + EMAIL = 'EMAIL', + DATE_TIME = 'DATE_TIME', + BOOLEAN = 'BOOLEAN', + NUMBER = 'NUMBER', + NUMERIC = 'NUMERIC', + PROBABILITY = 'PROBABILITY', + LINK = 'LINK', + CURRENCY = 'CURRENCY', + FULL_NAME = 'FULL_NAME', + RATING = 'RATING', + SELECT = 'SELECT', + MULTI_SELECT = 'MULTI_SELECT', + RELATION = 'RELATION', +} + +export type Schema = { + data: { + objects: { + edges: { node: Node }[]; + }; + }; +}; diff --git a/packages/twenty-zapier/src/utils/labelling.ts b/packages/twenty-zapier/src/utils/labelling.ts deleted file mode 100644 index 5febd7cac..000000000 --- a/packages/twenty-zapier/src/utils/labelling.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { capitalize } from '../utils/capitalize'; - -export const labelling = (str: string): string => { - return str - .replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`) - .split(' ') - .map((word) => capitalize(word)) - .join(' '); -}; diff --git a/packages/twenty-zapier/src/utils/requestDb.ts b/packages/twenty-zapier/src/utils/requestDb.ts index 4123684fb..59cef225f 100644 --- a/packages/twenty-zapier/src/utils/requestDb.ts +++ b/packages/twenty-zapier/src/utils/requestDb.ts @@ -1,17 +1,36 @@ import { Bundle, HttpRequestOptions, ZObject } from 'zapier-platform-core'; -export const requestSchema = async (z: ZObject, bundle: Bundle) => { - const options = { - url: `${process.env.SERVER_BASE_URL}/open-api`, - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${bundle.authData.apiKey}`, - }, - } satisfies HttpRequestOptions; +import { Schema } from '../utils/data.types'; - return z.request(options).then((response) => response.json); +export const requestSchema = async ( + z: ZObject, + bundle: Bundle, +): Promise => { + const query = `query GetObjects { + objects(paging: {first: 1000}, filter: {isActive: {is:true}}) { + edges { + node { + nameSingular + namePlural + labelSingular + fields(paging: {first: 1000}, filter: {isActive: {is:true}}) { + edges { + node { + type + name + label + description + isNullable + defaultValue + } + } + } + } + } + } + }`; + const endpoint = 'metadata'; + return await requestDb(z, bundle, query, endpoint); }; const requestDb = async ( diff --git a/packages/twenty-zapier/src/utils/triggers/triggers.utils.ts b/packages/twenty-zapier/src/utils/triggers/triggers.utils.ts index 8284f7310..0fc456a7f 100644 --- a/packages/twenty-zapier/src/utils/triggers/triggers.utils.ts +++ b/packages/twenty-zapier/src/utils/triggers/triggers.utils.ts @@ -2,7 +2,10 @@ import { Bundle, ZObject } from 'zapier-platform-core'; import { ObjectData } from '../../utils/data.types'; import handleQueryParams from '../../utils/handleQueryParams'; -import requestDb, { requestDbViaRestApi } from '../../utils/requestDb'; +import requestDb, { + requestDbViaRestApi, + requestSchema, +} from '../../utils/requestDb'; export enum Operation { create = 'create', @@ -61,21 +64,7 @@ const getNamePluralFromNameSingular = async ( bundle: Bundle, nameSingular: string, ): Promise => { - const result = await requestDb( - z, - bundle, - `query GetObjects { - objects(paging: {first: 1000}) { - edges { - node { - nameSingular - namePlural - } - } - } - }`, - 'metadata', - ); + const result = await requestSchema(z, bundle); for (const object of result.data.objects.edges) { if (object.node.nameSingular === nameSingular) { return object.node.namePlural;