fix(zapier): support Select and Multi Select fields (#21509)
## Context Closes #15970 Select and Multi Select fields did not show up in the Zapier integration. ## Root cause `computeInputFields` only emitted input fields for an explicit allow-list of field types and silently dropped everything else, so `SELECT` and `MULTI_SELECT` were never surfaced. On top of that, `SELECT`, `MULTI_SELECT` and `RATING` are **GraphQL enums** in Twenty's API. Their values must be sent as unquoted enum literals in a mutation (`stage: SCREENING`), but `handleQueryParams` quoted every string value (`stage: "SCREENING"`), which produces an invalid mutation. So even surfacing the fields would not have been enough to create/update records. ## Changes - Surface `SELECT` (string) and `MULTI_SELECT` (string list) as input fields in `computeInputFields`. - Fetch field `options` in the metadata query and expose them as Zapier `choices` (`value -> label`) so users pick from the real options instead of typing raw enum values. - Emit enum field values **unquoted** in create/update mutations. This is derived from the object schema in `crud_record` and threaded into `handleQueryParams`. This also fixes `RATING`, which was silently broken for the same reason. ## Tests - `computeInputFields`: new test asserting `SELECT`/`MULTI_SELECT` produce the right fields with `choices` and `list`. - `handleQueryParams`: new test asserting enum values are emitted unquoted (scalar and array). ``` Test Suites: 2 passed Tests: 5 passed ``` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21509?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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<InputData>) => {
|
||||
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}
|
||||
}`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, FieldMetadataType> =>
|
||||
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<string, FieldMetadataType>,
|
||||
): 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, FieldMetadataType>,
|
||||
): 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, FieldMetadataType>,
|
||||
): 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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export const requestSchema = async (
|
||||
description
|
||||
isNullable
|
||||
defaultValue
|
||||
options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user