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
This commit is contained in:
martmull
2024-02-13 22:22:47 +01:00
committed by GitHub
parent e011ecbd6f
commit 15a5fec545
11 changed files with 451 additions and 165 deletions

View File

@ -3,12 +3,24 @@ import { Bundle, ZObject } from 'zapier-platform-core';
import { findObjectNamesSingularKey } from '../triggers/find_object_names_singular'; import { findObjectNamesSingularKey } from '../triggers/find_object_names_singular';
import { listRecordIdsKey } from '../triggers/list_record_ids'; import { listRecordIdsKey } from '../triggers/list_record_ids';
import { capitalize } from '../utils/capitalize'; import { capitalize } from '../utils/capitalize';
import { recordInputFields } from '../utils/creates/creates.utils'; import { computeInputFields } from '../utils/computeInputFields';
import { InputData } from '../utils/data.types'; import { InputData } from '../utils/data.types';
import handleQueryParams from '../utils/handleQueryParams'; import handleQueryParams from '../utils/handleQueryParams';
import requestDb from '../utils/requestDb'; import requestDb, { requestSchema } from '../utils/requestDb';
import { Operation } from '../utils/triggers/triggers.utils'; 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 computeFields = async (z: ZObject, bundle: Bundle) => {
const operation = bundle.inputData.crudZapierOperation; const operation = bundle.inputData.crudZapierOperation;
switch (operation) { switch (operation) {
@ -84,7 +96,7 @@ export default {
key: 'nameSingular', key: 'nameSingular',
required: true, required: true,
label: 'Record Name', label: 'Record Name',
dynamic: `${findObjectNamesSingularKey}.nameSingular`, dynamic: `${findObjectNamesSingularKey}.nameSingular.labelSingular`,
altersDynamicFields: true, altersDynamicFields: true,
}, },
{ {

View File

@ -1,78 +1,223 @@
import { computeInputFields } from '../../utils/computeInputFields'; import { computeInputFields } from '../../utils/computeInputFields';
import { InputField } from '../../utils/data.types';
describe('computeInputFields', () => { describe('computeInputFields', () => {
test('should create Person input fields properly', () => { test('should create Person input fields properly', () => {
const personInfos = { const personNode = {
type: 'object', nameSingular: 'person',
properties: { namePlural: 'people',
id: { labelSingular: 'Person',
type: 'string', fields: {
}, edges: [
email: { {
type: 'string', node: {
}, type: 'RELATION',
xLink: { name: 'favorites',
type: 'object', label: 'Favorites',
properties: { description: 'Favorites linked to the contact',
url: { isNullable: true,
type: 'string', defaultValue: null,
},
label: {
type: 'string',
}, },
}, },
}, {
avatarUrl: { node: {
type: 'string', type: 'CURRENCY',
}, name: 'annualSalary',
favorites: { label: 'Annual Salary',
type: 'array', description: 'Annual Salary of the Person',
items: { isNullable: true,
$ref: '#/components/schemas/Favorite', defaultValue: null,
},
}, },
}, {
node: {
type: 'TEXT',
name: 'jobTitle',
label: 'Job Title',
description: 'Contacts 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: 'Contacts 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: 'Contacts X/Twitter account',
isNullable: true,
defaultValue: null,
},
},
{
node: {
type: 'EMAIL',
name: 'email',
label: 'Email',
description: 'Contacts Email',
isNullable: false,
defaultValue: {
value: '',
},
},
},
{
node: {
type: 'UUID',
name: 'companyId',
label: 'Company id (foreign key)',
description: 'Contacts company id foreign key',
isNullable: true,
defaultValue: null,
},
},
],
}, },
example: {},
required: ['avatarUrl'],
}; };
expect(computeInputFields(personInfos)).toEqual([ const baseExpectedResult: InputField[] = [
{ key: 'id', label: 'Id', required: false, type: 'string' }, {
{ key: 'email', label: 'Email', required: false, type: 'string' }, 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: 'Contacts 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: 'Contacts name: First Name',
required: false,
},
{
key: 'name__lastName',
label: 'Name: Last Name',
type: 'string',
helpText: 'Contacts name: Last Name',
required: false,
},
{
key: 'recordPosition',
label: 'RecordPosition',
type: 'integer',
helpText: 'Record Position',
required: false,
},
{ {
key: 'xLink__url', key: 'xLink__url',
label: 'X Link: Url', label: 'X: Url',
required: false,
type: 'string', type: 'string',
helpText: 'Contacts X/Twitter account: Link Url',
required: false,
}, },
{ {
key: 'xLink__label', key: 'xLink__label',
label: 'X Link: Label', label: 'X: Label',
required: false,
type: 'string', type: 'string',
}, helpText: 'Contacts X/Twitter account: Link Label',
{ 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',
required: false, required: false,
type: 'string',
}, },
{ {
key: 'xLink__label', key: 'email',
label: 'X Link: Label', label: 'Email',
required: false,
type: 'string', type: 'string',
helpText: 'Contacts Email',
required: false,
}, },
{ {
key: 'avatarUrl', key: 'companyId',
label: 'Avatar Url', label: 'Company id (foreign key)',
required: false,
type: 'string', type: 'string',
helpText: 'Contacts 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,
);
}); });
}); });

View File

@ -1,7 +0,0 @@
import { labelling } from '../../utils/labelling';
describe('labelling', () => {
test('should label properly', () => {
expect(labelling('createdAt')).toEqual('Created At');
});
});

View File

@ -4,8 +4,13 @@ import { requestSchema } from '../utils/requestDb';
const objectListRequest = async (z: ZObject, bundle: Bundle) => { const objectListRequest = async (z: ZObject, bundle: Bundle) => {
const schema = await requestSchema(z, bundle); const schema = await requestSchema(z, bundle);
return Object.keys(schema.components.schemas).map((schema) => { return schema.data.objects.edges.map((edge: any) => {
return { id: schema, nameSingular: schema.toLowerCase() }; const object = edge.node;
return {
id: object.nameSingular,
nameSingular: object.nameSingular,
labelSingular: object.labelSingular,
};
}); });
}; };

View File

@ -29,7 +29,7 @@ export default {
key: 'nameSingular', key: 'nameSingular',
required: true, required: true,
label: 'Record Name', label: 'Record Name',
dynamic: `${findObjectNamesSingularKey}.nameSingular`, dynamic: `${findObjectNamesSingularKey}.nameSingular.labelSingular`,
altersDynamicFields: true, altersDynamicFields: true,
}, },
{ {

View File

@ -1,63 +1,156 @@
import { labelling } from '../utils/labelling'; import {
FieldMetadataType,
InputField,
Node,
NodeField,
} from '../utils/data.types';
type Infos = { const getTypeFromFieldMetadataType = (
properties: { fieldMetadataType: string,
[field: string]: { ): string | undefined => {
type: string; switch (fieldMetadataType) {
properties?: { [field: string]: { type: string } }; case FieldMetadataType.UUID:
items?: { [$ref: string]: string }; case FieldMetadataType.TEXT:
}; case FieldMetadataType.PHONE:
}; case FieldMetadataType.EMAIL:
example: object; case FieldMetadataType.LINK:
required: string[]; 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 = ( export const computeInputFields = (
infos: Infos, node: Node,
idRequired = false, isRequired = false,
): object[] => { ): InputField[] => {
const result = []; const result = [];
for (const field of node.fields.edges) {
for (const fieldName of Object.keys(infos.properties)) { const nodeField = field.node;
switch (infos.properties[fieldName].type) { switch (nodeField.type) {
case 'array': case FieldMetadataType.FULL_NAME:
break; case FieldMetadataType.LINK:
case 'object': case FieldMetadataType.CURRENCY:
if (!infos.properties[fieldName].properties) { for (const subNodeField of get_subfieldsFromField(nodeField)) {
break;
}
for (const subFieldName of Object.keys(
infos.properties[fieldName].properties || {},
)) {
const field = { const field = {
key: `${fieldName}__${subFieldName}`, key: `${nodeField.name}__${subNodeField.name}`,
label: `${labelling(fieldName)}: ${labelling(subFieldName)}`, label: `${nodeField.label}: ${subNodeField.label}`,
type: infos.properties[fieldName].properties?.[subFieldName].type, type: getTypeFromFieldMetadataType(subNodeField.type),
required: false, helpText: `${nodeField.description}: ${subNodeField.description}`,
}; required: isFieldRequired(subNodeField),
if (infos.required?.includes(fieldName)) { } as InputField;
field.required = true;
}
result.push(field); result.push(field);
} }
break; break;
default: { case FieldMetadataType.UUID:
const field = { case FieldMetadataType.TEXT:
key: fieldName, case FieldMetadataType.PHONE:
label: labelling(fieldName), case FieldMetadataType.EMAIL:
type: infos.properties[fieldName].type, case FieldMetadataType.DATE_TIME:
required: false, case FieldMetadataType.BOOLEAN:
}; case FieldMetadataType.NUMBER:
if ( case FieldMetadataType.NUMERIC:
(idRequired && fieldName === 'id') || case FieldMetadataType.PROBABILITY:
(!idRequired && infos.required?.includes(fieldName)) case FieldMetadataType.RATING: {
) { const nodeFieldType = getTypeFromFieldMetadataType(nodeField.type);
field.required = true; 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); result.push(field);
break;
} }
default:
break;
} }
} }
return result; return result.sort((a, _) => (a.key === 'id' ? -1 : 1));
}; };

View File

@ -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);
};

View File

@ -1,2 +1,58 @@
export type InputData = { [x: string]: any }; export type InputData = { [x: string]: any };
export type ObjectData = { id: string } | { [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 }[];
};
};
};

View File

@ -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(' ');
};

View File

@ -1,17 +1,36 @@
import { Bundle, HttpRequestOptions, ZObject } from 'zapier-platform-core'; import { Bundle, HttpRequestOptions, ZObject } from 'zapier-platform-core';
export const requestSchema = async (z: ZObject, bundle: Bundle) => { import { Schema } from '../utils/data.types';
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;
return z.request(options).then((response) => response.json); export const requestSchema = async (
z: ZObject,
bundle: Bundle,
): Promise<Schema> => {
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 ( const requestDb = async (

View File

@ -2,7 +2,10 @@ import { Bundle, ZObject } from 'zapier-platform-core';
import { ObjectData } from '../../utils/data.types'; import { ObjectData } from '../../utils/data.types';
import handleQueryParams from '../../utils/handleQueryParams'; import handleQueryParams from '../../utils/handleQueryParams';
import requestDb, { requestDbViaRestApi } from '../../utils/requestDb'; import requestDb, {
requestDbViaRestApi,
requestSchema,
} from '../../utils/requestDb';
export enum Operation { export enum Operation {
create = 'create', create = 'create',
@ -61,21 +64,7 @@ const getNamePluralFromNameSingular = async (
bundle: Bundle, bundle: Bundle,
nameSingular: string, nameSingular: string,
): Promise<string> => { ): Promise<string> => {
const result = await requestDb( const result = await requestSchema(z, bundle);
z,
bundle,
`query GetObjects {
objects(paging: {first: 1000}) {
edges {
node {
nameSingular
namePlural
}
}
}
}`,
'metadata',
);
for (const object of result.data.objects.edges) { for (const object of result.data.objects.edges) {
if (object.node.nameSingular === nameSingular) { if (object.node.nameSingular === nameSingular) {
return object.node.namePlural; return object.node.namePlural;