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:
Charles Bochet
2026-06-12 22:54:49 +02:00
committed by GitHub
parent 9bb98fa5b5
commit 926cf2d0cf
9 changed files with 256 additions and 48 deletions
@@ -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
}
}
}