926cf2d0cf
## 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. -->
297 lines
9.0 KiB
TypeScript
297 lines
9.0 KiB
TypeScript
import { FieldMetadataType } from 'twenty-shared/types';
|
|
import {
|
|
type InputField,
|
|
type Node,
|
|
type NodeField,
|
|
} from 'src/utils/data.types';
|
|
import { isNonEmptyArray } from '@sniptt/guards';
|
|
|
|
const getListFromFieldMetadataType = (fieldMetadataType: FieldMetadataType) => {
|
|
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 = (
|
|
fieldMetadataType: FieldMetadataType,
|
|
): string | undefined => {
|
|
switch (fieldMetadataType) {
|
|
case FieldMetadataType.UUID:
|
|
case FieldMetadataType.TEXT:
|
|
case FieldMetadataType.ARRAY:
|
|
case FieldMetadataType.RATING:
|
|
case FieldMetadataType.SELECT:
|
|
case FieldMetadataType.MULTI_SELECT:
|
|
return 'string';
|
|
case FieldMetadataType.DATE_TIME:
|
|
return 'datetime';
|
|
case FieldMetadataType.DATE:
|
|
return 'date';
|
|
case FieldMetadataType.BOOLEAN:
|
|
return 'boolean';
|
|
case FieldMetadataType.NUMBER:
|
|
case FieldMetadataType.POSITION:
|
|
return 'integer';
|
|
case FieldMetadataType.NUMERIC:
|
|
return 'number';
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const get_subfieldsFromField = (nodeField: NodeField): NodeField[] => {
|
|
switch (nodeField.type) {
|
|
case FieldMetadataType.FULL_NAME: {
|
|
const firstName: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'firstName',
|
|
label: 'First Name',
|
|
description: 'First Name',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const lastName: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'lastName',
|
|
label: 'Last Name',
|
|
description: 'Last Name',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
return [firstName, lastName];
|
|
}
|
|
case FieldMetadataType.CURRENCY: {
|
|
const amountMicros: NodeField = {
|
|
type: FieldMetadataType.NUMBER,
|
|
name: 'amountMicros',
|
|
label: 'Amount Micros',
|
|
description: 'Amount Micros. eg: set 3210000 for 3.21$',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const currencyCode: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'currencyCode',
|
|
label: 'Currency Code',
|
|
description: 'Currency Code. eg: USD, EUR, etc...',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
return [amountMicros, currencyCode];
|
|
}
|
|
case FieldMetadataType.ADDRESS: {
|
|
const address1: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'addressStreet1',
|
|
label: 'Address',
|
|
description: 'Address',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const address2: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'addressStreet2',
|
|
label: 'Address 2',
|
|
description: 'Address 2',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const city: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'addressCity',
|
|
label: 'City',
|
|
description: 'City',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const state: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'addressState',
|
|
label: 'State',
|
|
description: 'State',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const postalCode: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'addressPostalCode',
|
|
label: 'Postal Code',
|
|
description: 'Postal Code',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const country: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'addressCountry',
|
|
label: 'Country',
|
|
description: 'Country',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
return [address1, address2, city, state, postalCode, country];
|
|
}
|
|
case FieldMetadataType.PHONES: {
|
|
const primaryPhoneNumber: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'primaryPhoneNumber',
|
|
label: 'Primary Phone Number',
|
|
description: 'Primary Phone Number. 600112233',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const primaryPhoneCountryCode: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'primaryPhoneCountryCode',
|
|
label: 'Primary Phone Country Code',
|
|
description: 'Primary Phone Country Code. eg: +33',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const additionalPhones: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'additionalPhones',
|
|
label: 'Additional Phones',
|
|
description: 'Additional Phones',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
placeholder: '{ number: "", callingCode: "" }',
|
|
list: true,
|
|
};
|
|
return [primaryPhoneNumber, primaryPhoneCountryCode, additionalPhones];
|
|
}
|
|
case FieldMetadataType.EMAILS: {
|
|
const primaryEmail: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'primaryEmail',
|
|
label: 'Primary Email',
|
|
description: 'Primary Email',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const additionalEmails: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'additionalEmails',
|
|
label: 'Additional Emails',
|
|
description: 'Additional Emails',
|
|
list: true,
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
return [primaryEmail, additionalEmails];
|
|
}
|
|
case FieldMetadataType.LINKS: {
|
|
const primaryLinkLabel: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'primaryLinkLabel',
|
|
label: 'Primary Link Label',
|
|
description: 'Primary Link Label',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const primaryLinkUrl: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'primaryLinkUrl',
|
|
label: 'Primary Link Url',
|
|
description: 'Primary Link Url',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
};
|
|
const secondaryLinks: NodeField = {
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'secondaryLinks',
|
|
label: 'Secondary Links',
|
|
description: 'Secondary Links',
|
|
isNullable: true,
|
|
defaultValue: null,
|
|
placeholder: '{ url: "", label: "" }',
|
|
list: true,
|
|
};
|
|
return [primaryLinkLabel, primaryLinkUrl, secondaryLinks];
|
|
}
|
|
default:
|
|
throw new Error(`Unknown nodeField type: ${nodeField.type}`);
|
|
}
|
|
};
|
|
|
|
const isFieldRequired = (nodeField: NodeField): boolean => {
|
|
return !nodeField.isNullable && nodeField.defaultValue === null;
|
|
};
|
|
|
|
export const computeInputFields = (
|
|
node: Node,
|
|
isRequired = false,
|
|
): InputField[] => {
|
|
const result = [];
|
|
for (const field of node.fields.edges) {
|
|
const nodeField = field.node;
|
|
switch (nodeField.type) {
|
|
case FieldMetadataType.FULL_NAME:
|
|
case FieldMetadataType.CURRENCY:
|
|
case FieldMetadataType.PHONES:
|
|
case FieldMetadataType.EMAILS:
|
|
case FieldMetadataType.LINKS:
|
|
case FieldMetadataType.ADDRESS:
|
|
for (const subNodeField of get_subfieldsFromField(nodeField)) {
|
|
const field = {
|
|
key: `${nodeField.name}__${subNodeField.name}`,
|
|
label: `${nodeField.label}: ${subNodeField.label}`,
|
|
type: getTypeFromFieldMetadataType(subNodeField.type),
|
|
helpText: `${nodeField.description}: ${subNodeField.description}`,
|
|
required: isFieldRequired(subNodeField),
|
|
list: !!subNodeField.list,
|
|
placeholder: subNodeField.placeholder,
|
|
} as InputField;
|
|
result.push(field);
|
|
}
|
|
break;
|
|
case FieldMetadataType.UUID:
|
|
case FieldMetadataType.TEXT:
|
|
case FieldMetadataType.DATE_TIME:
|
|
case FieldMetadataType.DATE:
|
|
case FieldMetadataType.BOOLEAN:
|
|
case FieldMetadataType.NUMBER:
|
|
case FieldMetadataType.NUMERIC:
|
|
case FieldMetadataType.ARRAY:
|
|
case FieldMetadataType.RATING:
|
|
case FieldMetadataType.SELECT:
|
|
case FieldMetadataType.MULTI_SELECT: {
|
|
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,
|
|
list: getListFromFieldMetadataType(nodeField.type),
|
|
placeholder: undefined,
|
|
choices: getChoicesFromNodeField(nodeField),
|
|
};
|
|
result.push(field);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return result.sort((a, _) => (a.key === 'id' ? -1 : 1));
|
|
};
|