import { generateId } from './_helpers'; import { CustomFieldSection } from './custom-field-section'; import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import type { ManifestComponentDeletion, ManifestScope } from './manifest-builder'; import { requireObjectComponentField, requireStringComponentField } from './_existing-component-fields'; export const CUSTOM_FIELD_TYPES = [ 'TEXT', 'DATE', 'NUMBER', 'CURRENCY', 'PERCENTAGE', 'SELECT', 'FILE', 'ID', 'RADIO', 'TEXTAREA', 'RANGE', 'REFERENCE_ID', 'BOOLEAN', 'ADDRESS', 'OG_REFERENCE_FIELD', 'EMAIL', 'URL', ] as const; export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number]; export type CustomFieldApiName = `${string}__cfv2`; export type CustomFieldUniqueIDCharacterType = 'DIGITS_ONLY' | 'TEXT_ONLY' | 'DIGITS_TEXT_BOTH'; type CustomFieldSimpleType = Exclude< CustomFieldType, 'NUMBER' | 'CURRENCY' | 'SELECT' | 'RADIO' | 'ID' | 'OG_REFERENCE_FIELD' >; export type CustomFieldDataType = | { fieldType: CustomFieldSimpleType } | { fieldType: 'NUMBER'; decimalPlaces?: number | null } | { fieldType: 'CURRENCY'; currency?: string | null } | { fieldType: 'SELECT' | 'RADIO'; options: readonly string[] } | { fieldType: 'ID'; numberOfCharacters?: number | null; characterType?: CustomFieldUniqueIDCharacterType | null; } | { fieldType: 'OG_REFERENCE_FIELD'; referencedModelRqlName: string }; export type CustomFieldPermissionClassification = | 'BASIC_PERSONAL_INFO' | 'PERSONAL_INFO' | 'SENSITIVE_PERSONAL_INFO' | 'BASIC_EMPLOYMENT_INFO' | 'EMPLOYMENT_INFO' | 'SENSITIVE_EMPLOYMENT_INFO'; export type CustomFieldReadAccess = 'ALL_ACCESS' | 'ADMIN_ACCESS' | 'ADMIN_AND_EMPLOYEE_ACCESS'; export type CustomFieldWriteAccess = | 'EMPLOYEE_ACCESS' | 'ADMIN_ACCESS' | 'ADMIN_AND_EMPLOYEE_ACCESS' | 'NO_ACCESS'; export type CustomFieldCollectionTiming = 'NONE' | 'HIRING' | 'ONBOARDING' | 'TERMINATING'; export type CustomFieldDefaultValue = | { value: string; rqlDefinitionFormula?: never } | { value?: never; rqlDefinitionFormula: string }; /** Properties shared by formula and user-input custom fields. */ export interface CustomFieldBaseProps { fieldId?: string; name: string; section: CustomFieldSection; modelRqlName: ModelRqlName; dataType: CustomFieldDataType; apiName: CustomFieldApiName; description?: string | null; displayOrder?: number | null; } type NonSensitiveCustomFieldPermissionClassification = Exclude< CustomFieldPermissionClassification, 'SENSITIVE_PERSONAL_INFO' | 'SENSITIVE_EMPLOYMENT_INFO' >; /** Permission properties required for every Employee custom field. */ export type EmployeeCustomFieldPermissionProps = { applicableGroupId: string } & ( | { fieldReadAccess: 'ALL_ACCESS'; fieldPermissionClassification: NonSensitiveCustomFieldPermissionClassification; } | { fieldReadAccess: 'ADMIN_ACCESS' | 'ADMIN_AND_EMPLOYEE_ACCESS'; fieldPermissionClassification: CustomFieldPermissionClassification; } ); /** Optional permission properties used by non-Employee custom fields. */ export interface NonEmployeeCustomFieldPermissionProps { fieldPermissionClassification?: CustomFieldPermissionClassification | null; fieldReadAccess?: CustomFieldReadAccess | null; applicableGroupId?: string | null; } export type CustomFieldPermissionProps = ModelRqlName extends 'Employee' ? EmployeeCustomFieldPermissionProps : NonEmployeeCustomFieldPermissionProps; interface CustomFieldUserInputOptions { rqlDefinitionFormula?: null; required?: boolean | null; tip?: string | null; defaultValue?: CustomFieldDefaultValue | null; } type EmployeeCustomFieldUserInputAccess = | { collectDuring: 'ONBOARDING'; fieldReadAccess: 'ALL_ACCESS' | 'ADMIN_AND_EMPLOYEE_ACCESS'; fieldWriteAccess: 'EMPLOYEE_ACCESS' | 'ADMIN_AND_EMPLOYEE_ACCESS' | 'NO_ACCESS'; } | { collectDuring?: 'NONE' | 'HIRING' | 'TERMINATING' | null; fieldWriteAccess: CustomFieldWriteAccess; }; interface NonEmployeeCustomFieldUserInputAccess { collectDuring?: CustomFieldCollectionTiming | null; fieldWriteAccess?: CustomFieldWriteAccess | null; } type CustomFieldUserInputAccess = ModelRqlName extends 'Employee' ? EmployeeCustomFieldUserInputAccess : NonEmployeeCustomFieldUserInputAccess; interface CustomFieldFormulaOptions { rqlDefinitionFormula: string; fieldWriteAccess: 'NO_ACCESS'; collectDuring?: null; required?: null; tip?: null; defaultValue?: null; } /** A calculated custom field whose value cannot be edited directly. */ export type FormulaCustomFieldProps = CustomFieldBaseProps & CustomFieldPermissionProps & CustomFieldFormulaOptions; /** A custom field whose value is supplied by a user or a default value. */ export type UserInputCustomFieldProps = CustomFieldBaseProps & CustomFieldPermissionProps & CustomFieldUserInputOptions & CustomFieldUserInputAccess; /** A formula or user-input custom field on the given model. */ export type CustomFieldProps = | FormulaCustomFieldProps | UserInputCustomFieldProps; /** A calculated Employee custom field. */ export type EmployeeFormulaCustomFieldProps = FormulaCustomFieldProps<'Employee'>; /** An Employee custom field whose value is supplied by a user or a default value. */ export type EmployeeUserInputCustomFieldProps = UserInputCustomFieldProps<'Employee'>; /** Any supported Employee custom field. */ export type EmployeeCustomFieldProps = EmployeeFormulaCustomFieldProps | EmployeeUserInputCustomFieldProps; function cloneJson(value: T): T { if (value == null) return value; return JSON.parse(JSON.stringify(value)) as T; } function customFieldDataTypeFromWire(dataType: Record): CustomFieldDataType { const fieldType = requireStringComponentField( dataType, 'field_type', CustomField.componentType, ) as CustomFieldType; switch (fieldType) { case 'NUMBER': return { fieldType, decimalPlaces: (dataType['num_of_decimal_places'] as number | null | undefined) ?? null, }; case 'CURRENCY': return { fieldType, currency: (dataType['currency'] as string | null | undefined) ?? null }; case 'SELECT': case 'RADIO': { const options = dataType['options_list']; if (!Array.isArray(options) || !options.every((option) => typeof option === 'string')) { throw new TypeError(`CUSTOM_FIELD_V2 data_type.options_list must be a string array.`); } return { fieldType, options }; } case 'ID': return { fieldType, numberOfCharacters: (dataType['num_of_chars'] as number | null | undefined) ?? null, characterType: (dataType['type_of_chars'] as CustomFieldUniqueIDCharacterType | null | undefined) ?? null, }; case 'OG_REFERENCE_FIELD': return { fieldType, referencedModelRqlName: requireStringComponentField( dataType, 'og_model_rql_name', CustomField.componentType, ), }; default: if (!(CUSTOM_FIELD_TYPES as readonly string[]).includes(fieldType)) { throw new TypeError(`Unsupported CUSTOM_FIELD_V2 field_type: ${fieldType}`); } return { fieldType }; } } /** A Custom Field V2 field on a native or custom object. */ export class CustomField { static readonly componentType = 'CUSTOM_FIELD_V2' as const; static toDeletionIdentifier(customFieldId: string): ManifestComponentDeletion { return { type: CustomField.componentType, cfv2_id: customFieldId }; } private readonly _fieldId: string; private readonly props: CustomFieldProps; constructor(scope: ManifestScope, props: CustomFieldProps) { const runtimeProps = props as CustomFieldBaseProps & { fieldPermissionClassification?: CustomFieldPermissionClassification | null; fieldReadAccess?: CustomFieldReadAccess | null; fieldWriteAccess?: CustomFieldWriteAccess | null; applicableGroupId?: string | null; collectDuring?: CustomFieldCollectionTiming | null; rqlDefinitionFormula?: string | null; }; if (!props.apiName.endsWith('__cfv2')) { throw new Error(`Custom Field V2 api_name must end with __cfv2: ${props.apiName}`); } if (props.section.getModelRqlName() !== props.modelRqlName) { throw new Error( `Custom field "${props.apiName}" section belongs to "${props.section.getModelRqlName()}", ` + `but the field belongs to "${props.modelRqlName}".`, ); } if (runtimeProps.rqlDefinitionFormula != null && runtimeProps.fieldWriteAccess !== 'NO_ACCESS') { throw new Error(`Formula custom field "${props.apiName}" must have fieldWriteAccess "NO_ACCESS".`); } if (props.modelRqlName === 'Employee') { if ( runtimeProps.fieldPermissionClassification == null || runtimeProps.fieldReadAccess == null || runtimeProps.fieldWriteAccess == null || runtimeProps.applicableGroupId == null ) { throw new Error( `Employee custom field "${props.apiName}" requires fieldPermissionClassification, ` + `fieldReadAccess, fieldWriteAccess, and applicableGroupId.`, ); } if ( runtimeProps.fieldReadAccess === 'ALL_ACCESS' && (runtimeProps.fieldPermissionClassification === 'SENSITIVE_PERSONAL_INFO' || runtimeProps.fieldPermissionClassification === 'SENSITIVE_EMPLOYMENT_INFO') ) { throw new Error( `Employee custom field "${props.apiName}" cannot use a sensitive permission classification ` + `with fieldReadAccess "ALL_ACCESS".`, ); } if ( runtimeProps.collectDuring === 'ONBOARDING' && (runtimeProps.fieldReadAccess === 'ADMIN_ACCESS' || runtimeProps.fieldWriteAccess === 'ADMIN_ACCESS') ) { throw new Error( `Employee onboarding custom field "${props.apiName}" must grant the employee read and write access.`, ); } } this._fieldId = props.fieldId ?? generateId('cfv2'); this.props = { ...props, dataType: cloneJson(props.dataType), } as CustomFieldProps; scope._register(this); } static loadFromExisting( fieldIdOrApiName: string, options: ExistingManifestContextOptions = {}, ): CustomField { return getExistingManifestContext(options).loadCustomField(fieldIdOrApiName); } /** @internal Hydrates a custom field from existing manifest wire JSON. */ static _fromExistingComponent( scope: ManifestScope, component: Record, section: CustomFieldSection, ): CustomField { const dataType = requireObjectComponentField(component, 'data_type', CustomField.componentType); const defaultValue = component['default_value'] as Record | null | undefined; const formula = component['rql_definition_formula']; const applicableGroupId = component['applicable_group_id'] == null ? null : requireStringComponentField(component, 'applicable_group_id', CustomField.componentType); const common = { fieldId: requireStringComponentField(component, 'obj_id', CustomField.componentType), name: requireStringComponentField(component, 'name', CustomField.componentType), section, modelRqlName: requireStringComponentField(component, 'model_rql_name', CustomField.componentType), apiName: requireStringComponentField( component, 'api_name', CustomField.componentType, ) as CustomFieldApiName, dataType: customFieldDataTypeFromWire(dataType), fieldPermissionClassification: (component['field_permission_classification'] as | CustomFieldPermissionClassification | null | undefined) ?? null, fieldReadAccess: (component['field_read_access'] as CustomFieldReadAccess | null | undefined) ?? null, fieldWriteAccess: (component['field_write_access'] as CustomFieldWriteAccess | null | undefined) ?? null, applicableGroupId, description: (component['description'] as string | null | undefined) ?? null, displayOrder: (component['display_order'] as number | null | undefined) ?? null, }; if (typeof formula === 'string') { if (common.fieldWriteAccess != null && common.fieldWriteAccess !== 'NO_ACCESS') { throw new TypeError(`Formula CUSTOM_FIELD_V2 field_write_access must be NO_ACCESS.`); } return new CustomField(scope, { ...common, fieldWriteAccess: 'NO_ACCESS', rqlDefinitionFormula: formula, collectDuring: null, required: null, tip: null, defaultValue: null, }); } const defaultValueFormula = defaultValue?.['rql_definition_formula']; const staticDefaultValue = defaultValue?.['value']; return new CustomField(scope, { ...common, rqlDefinitionFormula: null, collectDuring: (component['collect_during'] as CustomFieldCollectionTiming | null | undefined) ?? null, required: (component['required'] as boolean | null | undefined) ?? null, tip: (component['tip'] as string | null | undefined) ?? null, defaultValue: typeof defaultValueFormula === 'string' ? { rqlDefinitionFormula: defaultValueFormula } : typeof staticDefaultValue === 'string' ? { value: staticDefaultValue } : null, }); } getFieldId(): string { return this._fieldId; } getApiName(): CustomFieldApiName { return this.props.apiName; } getModelRqlName(): string { return this.props.modelRqlName; } toDict(): Record { const dataType = this.props.dataType; const defaultValue = this.props.defaultValue; return { type: CustomField.componentType, obj_id: this._fieldId, name: this.props.name, section: this.props.section.getSectionId(), model_rql_name: this.props.modelRqlName, data_type: { type: CustomField.componentType, field_type: dataType.fieldType, options_list: 'options' in dataType ? [...dataType.options] : null, currency: 'currency' in dataType ? dataType.currency ?? null : null, num_of_decimal_places: 'decimalPlaces' in dataType ? dataType.decimalPlaces ?? null : null, num_of_chars: 'numberOfCharacters' in dataType ? dataType.numberOfCharacters ?? null : null, type_of_chars: 'characterType' in dataType ? dataType.characterType ?? null : null, og_model_rql_name: 'referencedModelRqlName' in dataType ? dataType.referencedModelRqlName : null, }, api_name: this.props.apiName, field_permission_classification: this.props.fieldPermissionClassification ?? null, field_read_access: this.props.fieldReadAccess ?? null, field_write_access: this.props.fieldWriteAccess ?? null, collect_during: this.props.collectDuring ?? null, applicable_group_id: this.props.applicableGroupId ?? null, description: this.props.description ?? null, required: this.props.required ?? null, tip: this.props.tip ?? null, rql_definition_formula: this.props.rqlDefinitionFormula ?? null, default_value: defaultValue == null ? null : ( { type: CustomField.componentType, value: 'value' in defaultValue ? defaultValue.value : null, rql_definition_formula: 'rqlDefinitionFormula' in defaultValue ? defaultValue.rqlDefinitionFormula : null, } ), display_order: this.props.displayOrder ?? null, }; } }