import { FILTER_OPERATORS_BY_TYPE, type FieldType, type FormulaFieldType, type SummaryAggregation, type SummaryFilterOperator, } from './_helpers'; import type { CustomObject } from './custom-object'; import { CustomObjectFieldSection } from './custom-object-field-section'; import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import type { ManifestComponentDeletion } from './manifest-builder'; import { optionalStringComponentField, requireObjectComponentField, requireStringComponentField, } from './_existing-component-fields'; export interface CustomObjectFieldDeletionIdentifier { customObjectApiName: string; fieldApiName: string; } /** * A filter condition applied to a {@link SummaryField}. * * Filters restrict which child records are included in the aggregation. * Only scalar field types are supported as filter fields — see * `FILTER_OPERATORS_BY_TYPE` in `_helpers.ts` for the per-type operator allow-list. */ export interface SummaryFilter { /** * The child-CO field to filter on. * * Required. Must be a scalar field on the same child object as `lookup`. * Cannot be a formula field, summary field, or the `lookup` field itself. */ field: Field; /** * Comparison operator. Allowed operators depend on the field's type — * text/boolean fields support `=` and `!=`; numeric/date fields additionally * support `>`, `>=`, `<`, `<=`. * * Required. */ operator: SummaryFilterOperator; /** * Raw RQL value string, e.g. `"'Active'"`, `"50"`, `"true"`, `"DATE(2026,1,1)"`. * * Required. */ value: string; } interface SummarySpec { aggregates: Field; using: SummaryAggregation; lookup: Field; filters: SummaryFilter[]; currencyCode: string | null; } function validateApiName(apiName: string): void { if (!apiName.endsWith('__c')) { throw new Error(`Field api_name must end with __c: ${apiName}`); } } function cloneJson(value: T): T { if (value == null) return value; return JSON.parse(JSON.stringify(value)) as T; } /** Props shared by every field variant. */ interface CommonFieldProps { apiName: string; displayName: string; description: string; section: CustomObjectFieldSection; } /** * Low-level props for the base `Field`. Most users should use one of the * concrete subclasses (`TextField`, `LookupField`, …) instead of constructing * `Field` directly. */ export interface FieldProps { apiName: string; displayName: string; // Optional members use `| undefined` so subclass constructors can spread // props under `exactOptionalPropertyTypes`. fieldType?: FieldType | undefined; dataType?: Record | undefined; description: string; required?: boolean | null | undefined; unique?: boolean | null | undefined; indexed?: boolean | null | undefined; rqlFormula?: string | null | undefined; formulaAttrMetas?: Record | null | undefined; derivedAggregatedField?: Record | null | undefined; derivedFieldFormula?: string | null | undefined; section: CustomObjectFieldSection; maxLength?: number | undefined; decimalPlaces?: number | undefined; currency?: string | undefined; options?: string[] | undefined; /** Internal — set by `SummaryField`; drives the `derived_aggregated_field` wire field. */ summarySpec?: SummarySpec | undefined; } export interface FieldLoadFromExistingOptions extends ExistingManifestContextOptions { customObjectApiName?: string; } const EXISTING_FIELD_SECTION_ID = Symbol('Field.existingFieldSectionId'); function normalizeSectionInput( customObject: CustomObject, fieldApiName: string, section: CustomObjectFieldSection | string | null | undefined, ): string { if (section == null) { throw new Error( `Field "${fieldApiName}" requires a CustomObjectFieldSection. ` + 'Create a CustomObjectFieldSection for the custom object and pass it as section.', ); } if (typeof section === 'string') { throw new TypeError( `Field section must be a CustomObjectFieldSection object; received raw section id "${section}". ` + 'Load/include the existing field section first.', ); } if (!(section instanceof CustomObjectFieldSection)) { throw new TypeError('Field section must be a CustomObjectFieldSection object.'); } if (section.getModelApiName() !== customObject.getApiName()) { throw new Error( `Field "${fieldApiName}" section "${section.getSectionId()}" belongs to ` + `"${section.getModelApiName()}", but the field belongs to "${customObject.getApiName()}".`, ); } return section.getSectionId(); } class ExistingFieldReference { private _section: string | null; constructor( private readonly customObjectApiName: string, private readonly component: Record, ) { requireStringComponentField(component, 'custom_object_api_name', Field.componentType); requireStringComponentField(component, 'field_api_name', Field.componentType); requireStringComponentField(component, 'field_display_name', Field.componentType); requireObjectComponentField(component, 'data_type', Field.componentType); this._section = (component['section'] as string | null | undefined) ?? null; } getApiName(): string { return this.component['field_api_name'] as string; } getDisplayName(): string { return this.component['field_display_name'] as string; } getCustomObjectApiName(): string { return this.customObjectApiName; } getFieldType(): FieldType | null { return (this.getDataType()['field_type'] as FieldType | undefined) ?? null; } getDataType(): Readonly> { return this.component['data_type'] as Record; } isFormula(): boolean { return this.component['derived_field_formula'] != null; } isSummary(): boolean { return this.component['derived_aggregated_field'] != null; } isStandard(): boolean { return this.component['is_standard'] === true; } getSectionId(): string | null { return this._section; } _assignSection(section: CustomObjectFieldSection | string): void { this._section = typeof section === 'string' ? section : section.getSectionId(); } toDict(): Record { return cloneJson(this.component); } } // ──────────────────────────────────────────────────────────────────────── // Base class // ──────────────────────────────────────────────────────────────────────── /** * Base class for every custom-object field. * * Prefer the typed subclasses (`TextField`, `NumberField`, `LookupField`, …) over * constructing `Field` directly. Use `Field` only for wire-level field types the * SDK does not yet model with a dedicated subclass. * * All field classes share the same scope-first CDK pattern: pass the parent * {@link CustomObject} as the first argument and the field auto-registers with * the manifest. Every field api_name must end with `__c`. * * @example * ```ts * // Prefer typed subclasses: * const firstName = new TextField(memberObj, { * apiName: 'first_name__c', * displayName: 'First name', * required: true, * maxLength: 80, * section: profileSection, * }); * ``` * * @see {@link TextField}, {@link NumberField}, {@link LookupField}, {@link SummaryField} */ export class Field { static readonly componentType = 'CUSTOM_OBJECT_FIELD' as const; static toDeletionIdentifier(identifier: CustomObjectFieldDeletionIdentifier): ManifestComponentDeletion { return { type: Field.componentType, custom_object_api_name: identifier.customObjectApiName, field_api_name: identifier.fieldApiName, }; } private readonly _fieldApiName: string; private readonly _fieldDisplayName: string; private readonly _customObjectApiName: string; private readonly _dataType: Record; private readonly _description: string; private readonly _isRequired: boolean | null; private readonly _isUnique: boolean | null; private readonly _isIndexed: boolean | null; private readonly _rqlFormula: string | null; private readonly _formulaAttrMetas: Record | null; private readonly _derivedFieldFormula: string | null; private _section: string | null; private readonly _summarySpec: SummarySpec | null; private readonly _derivedAggregatedFieldStatic: Record | null; /** * @param customObject - The custom object this field belongs to. * @param props - Field configuration. See {@link FieldProps}. * @throws {Error} If `props.apiName` does not end with `__c`. */ constructor(customObject: CustomObject, props: FieldProps) { validateApiName(props.apiName); this._fieldApiName = props.apiName; this._fieldDisplayName = props.displayName; this._customObjectApiName = customObject.getApiName(); const dt: Record = { ...(props.dataType ?? {}) }; if (props.fieldType != null) dt['field_type'] = props.fieldType; if (props.maxLength != null) dt['max_length'] = props.maxLength; if (props.decimalPlaces != null) dt['num_of_decimal_places'] = props.decimalPlaces; if (props.currency != null) dt['currency'] = props.currency; if (props.options != null) dt['options_list'] = [...props.options]; this._dataType = dt; this._description = props.description; this._isRequired = props.required ?? null; this._isUnique = props.unique ?? null; this._isIndexed = props.indexed ?? null; this._rqlFormula = props.rqlFormula ?? null; this._formulaAttrMetas = props.formulaAttrMetas ?? null; this._derivedAggregatedFieldStatic = props.derivedAggregatedField ?? null; this._derivedFieldFormula = props.derivedFieldFormula ?? null; this._summarySpec = props.summarySpec ?? null; const existingSectionId = ( props as unknown as Record )[EXISTING_FIELD_SECTION_ID]; const rawSection = props.section as CustomObjectFieldSection | string | null | undefined; this._section = existingSectionId ?? normalizeSectionInput(customObject, props.apiName, rawSection); customObject._register(this); } /** * Loads this field from the active existing-manifest JSON context. * * For fields whose api_name is not globally unique, pass `customObjectApiName` * or use a qualified id like `'object__c.field__c'`. */ static loadFromExisting(apiName: string, options: FieldLoadFromExistingOptions = {}): Field { return getExistingManifestContext(options).loadField(apiName, options); } /** @internal Hydrates a custom object field from existing manifest wire JSON. */ static _fromExistingComponent(customObject: CustomObject, component: Record): Field { const props = { apiName: requireStringComponentField(component, 'field_api_name', Field.componentType), displayName: requireStringComponentField(component, 'field_display_name', Field.componentType), dataType: cloneJson(requireObjectComponentField(component, 'data_type', Field.componentType)), description: optionalStringComponentField(component, 'description'), } as FieldProps; if (component['is_required'] !== undefined) props.required = component['is_required']; if (component['is_unique'] !== undefined) props.unique = component['is_unique']; if (component['is_indexed'] !== undefined) props.indexed = component['is_indexed']; if (component['rql_formula'] !== undefined) props.rqlFormula = component['rql_formula']; if (component['formula_attr_metas'] !== undefined) props.formulaAttrMetas = component['formula_attr_metas']; if (component['derived_aggregated_field'] !== undefined) { props.derivedAggregatedField = component['derived_aggregated_field']; } if (component['derived_field_formula'] !== undefined) props.derivedFieldFormula = component['derived_field_formula']; const sectionId = component['section']; if (typeof sectionId !== 'string' || sectionId.length === 0) { throw new Error( `CUSTOM_OBJECT_FIELD ${customObject.getApiName()}.${ props.apiName } is missing required field section. ` + 'Add a CUSTOM_OBJECT_FIELD_SECTION component and set the field section to its section_id.', ); } (props as unknown as Record)[EXISTING_FIELD_SECTION_ID] = sectionId; const field = new Field(customObject, props); return field; } /** @internal Creates a non-registered reference for standard or external fields. */ static _referenceFromExistingComponent(component: Record): Field { const customObjectApiName = requireStringComponentField( component, 'custom_object_api_name', Field.componentType, ); requireStringComponentField(component, 'field_api_name', Field.componentType); return new ExistingFieldReference(customObjectApiName, cloneJson(component)) as unknown as Field; } /** * @internal Creates a non-registered reference for a known wire field name. * @deprecated Agent-facing code should use CustomObject.standardFields or CustomObject.standardField(...). */ static _syntheticReference(customObjectApiName: string, fieldApiName: string): Field { return new ExistingFieldReference(customObjectApiName, { type: 'CUSTOM_OBJECT_FIELD', custom_object_api_name: customObjectApiName, field_api_name: fieldApiName, field_display_name: fieldApiName, data_type: {}, description: null, is_required: null, is_unique: null, is_indexed: null, rql_formula: null, formula_attr_metas: null, is_standard: true, derived_aggregated_field: null, derived_field_formula: null, section: null, }) as unknown as Field; } // ── Getters ──────────────────────────────────────────────────────────── /** Returns the api_name of this field (e.g. `'first_name__c'`). */ getApiName(): string { return this._fieldApiName; } /** Returns the display name of this field. */ getDisplayName(): string { return this._fieldDisplayName; } /** Returns the api_name of the custom object this field belongs to. */ getCustomObjectApiName(): string { return this._customObjectApiName; } /** Returns the resolved `field_type` (e.g. `'TEXT'`), or `null` if unset. */ getFieldType(): FieldType | null { return (this._dataType['field_type'] as FieldType | undefined) ?? null; } /** Returns a read-only view of the raw `data_type` dict. */ getDataType(): Readonly> { return this._dataType; } /** Returns `true` if this is a formula (computed) field. */ isFormula(): boolean { return this._derivedFieldFormula != null; } /** Returns `true` if this is a summary (rollup) field. */ isSummary(): boolean { return this._summarySpec != null; } /** Returns `true` if this is a platform-managed standard field reference. */ isStandard(): boolean { return false; } /** Returns the field section id assigned to this field, if any. */ getSectionId(): string | null { return this._section; } /** @internal Assigns this field to a field section during page-layout normalization. */ _assignSection(section: CustomObjectFieldSection | string): void { this._section = typeof section === 'string' ? section : section.getSectionId(); } // ── Serialization ────────────────────────────────────────────────────── /** * Serializes this field to the wire format consumed by the manifest install endpoint. * * @returns A plain object with `type: 'CUSTOM_OBJECT_FIELD'`. */ toDict(): Record { return { type: Field.componentType, custom_object_api_name: this._customObjectApiName, field_api_name: this._fieldApiName, field_display_name: this._fieldDisplayName, data_type: { options_list: [], supported_types: [], ...this._dataType }, description: this._description, is_required: this._isRequired, is_unique: this._isUnique, is_indexed: this._isIndexed, rql_formula: this._rqlFormula ?? null, formula_attr_metas: this._formulaAttrMetas, // User-authored package fields are never standard (backend invariant). is_standard: false, derived_aggregated_field: this._resolveAggregatedField(), derived_field_formula: this._derivedFieldFormula ?? null, section: this._section, }; } /** Build the `derived_aggregated_field` dict from `_summarySpec` with cross-CO checks. */ private _resolveAggregatedField(): Record | null { if (this._summarySpec == null) return this._derivedAggregatedFieldStatic; const spec = this._summarySpec; const aggParent = spec.aggregates.getCustomObjectApiName(); const lookupParent = spec.lookup.getCustomObjectApiName(); if (lookupParent !== aggParent) { throw new Error( `SummaryField "${this._fieldApiName}": lookup lives on "${lookupParent}" ` + `but aggregates lives on "${aggParent}" — both must live on the same ` + `child CustomObject.`, ); } // Per-filter residency / back-ref / LONG_TEXT / formula checks: backend only. return { aggregation_function: spec.using, child_model_name: aggParent, currency_code: spec.currencyCode, field_to_aggregate: spec.aggregates.getApiName(), filters: spec.filters.map((f) => ({ field_name: f.field.getApiName(), filter_value: f.value, operator: f.operator, })), lookup_field_api_name: spec.lookup.getApiName(), }; } } // ──────────────────────────────────────────────────────────────────────── // Concrete field subclasses — one per wire-level variant. // Each sets its own `fieldType` / `dataType` and forwards the rest to // `Field`'s constructor. Order matches the UI's "Data type" dropdown. // ──────────────────────────────────────────────────────────────────────── // ── Text-like ────────────────────────────────────────────────────────── /** Initialization properties for {@link TextField}. */ export interface TextFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; /** Whether values must be unique across records. Optional. */ unique?: boolean; /** Maximum character length. Optional. */ maxLength?: number; } /** Single-line text field. */ export class TextField extends Field { constructor(customObject: CustomObject, props: TextFieldProps) { super(customObject, { ...props, fieldType: 'TEXT' }); } } /** Initialization properties for {@link LongTextField}. */ export interface LongTextFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; /** Maximum character length. Optional. */ maxLength?: number; } /** Multi-line text field. */ export class LongTextField extends Field { constructor(customObject: CustomObject, props: LongTextFieldProps) { super(customObject, { ...props, fieldType: 'LONG_TEXT' }); } } /** Initialization properties for {@link EmailField}. */ export interface EmailFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; /** Whether values must be unique across records. Optional. */ unique?: boolean; } /** Email address field. Format is validated by the Rippling UI. */ export class EmailField extends Field { constructor(customObject: CustomObject, props: EmailFieldProps) { super(customObject, { ...props, fieldType: 'EMAIL' }); } } /** Initialization properties for {@link UrlField}. */ export interface UrlFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; } /** URL field. Format is validated by the Rippling UI. */ export class UrlField extends Field { constructor(customObject: CustomObject, props: UrlFieldProps) { super(customObject, { ...props, fieldType: 'URL' }); } } /** Initialization properties for {@link PhoneField}. */ export interface PhoneFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; } /** Phone number field. */ export class PhoneField extends Field { constructor(customObject: CustomObject, props: PhoneFieldProps) { super(customObject, { ...props, fieldType: 'PHONE_NUMBER' }); } } // ── Numeric ──────────────────────────────────────────────────────────── /** Initialization properties for {@link NumberField}. */ export interface NumberFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; /** Whether values must be unique across records. Optional. */ unique?: boolean; /** Number of decimal places to display and store. Optional. */ decimalPlaces?: number; } /** Numeric field. */ export class NumberField extends Field { constructor(customObject: CustomObject, props: NumberFieldProps) { super(customObject, { ...props, fieldType: 'NUMBER' }); } } /** Initialization properties for {@link CurrencyField}. */ export interface CurrencyFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; /** ISO 4217 currency code (e.g. `'USD'`). Optional. Forwarded to the wire only when set. */ currency?: string; /** Number of decimal places. Optional. */ decimalPlaces?: number; } /** Currency amount field. */ export class CurrencyField extends Field { constructor(customObject: CustomObject, props: CurrencyFieldProps) { super(customObject, { ...props, fieldType: 'CURRENCY' }); } } /** Initialization properties for {@link PercentageField}. */ export interface PercentageFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; } /** Percentage field. */ export class PercentageField extends Field { constructor(customObject: CustomObject, props: PercentageFieldProps) { super(customObject, { ...props, fieldType: 'PERCENTAGE', decimalPlaces: 2 }); } } // ── Date/time ────────────────────────────────────────────────────────── /** Initialization properties for {@link DateField}. */ export interface DateFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; } /** Date field (no time component). */ export class DateField extends Field { constructor(customObject: CustomObject, props: DateFieldProps) { super(customObject, { ...props, fieldType: 'DATE' }); } } /** Initialization properties for {@link DatetimeField}. */ export interface DatetimeFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; } /** Date and time field. */ export class DatetimeField extends Field { constructor(customObject: CustomObject, props: DatetimeFieldProps) { super(customObject, { ...props, fieldType: 'DATETIME' }); } } /** Initialization properties for {@link TimeField}. */ export interface TimeFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; } /** Time-of-day field. */ export class TimeField extends Field { constructor(customObject: CustomObject, props: TimeFieldProps) { super(customObject, { ...props, fieldType: 'TIME' }); } } // ── Choice ───────────────────────────────────────────────────────────── /** Initialization properties for {@link SelectField}. */ export interface SelectFieldProps extends CommonFieldProps { /** * The list of selectable options (e.g. `['Active', 'Frozen', 'Cancelled']`). * * Required. */ options: string[]; /** Whether a value is required on save. Optional. */ required?: boolean; } /** * Multi-select dropdown field. Renders as a dropdown that allows multiple * options to be selected at once. * * Use {@link RadioField} instead when only one option should be selectable. */ export class SelectField extends Field { constructor(customObject: CustomObject, props: SelectFieldProps) { super(customObject, { ...props, fieldType: 'SELECT' }); } } /** Initialization properties for {@link RadioField}. */ export interface RadioFieldProps extends CommonFieldProps { /** * The list of selectable options. * * Required. */ options: string[]; /** Whether a value is required on save. Optional. */ required?: boolean; } /** * Single-select radio button group field. Exactly one option can be selected. * * Use {@link SelectField} instead when multiple options should be selectable. */ export class RadioField extends Field { constructor(customObject: CustomObject, props: RadioFieldProps) { super(customObject, { ...props, fieldType: 'RADIO' }); } } /** Initialization properties for {@link BooleanField}. */ export interface BooleanFieldProps extends CommonFieldProps { /** Whether a value is required on save. Optional. */ required?: boolean; } /** Boolean (checkbox) field. */ export class BooleanField extends Field { constructor(customObject: CustomObject, props: BooleanFieldProps) { super(customObject, { ...props, fieldType: 'BOOLEAN' }); } } // ── File ─────────────────────────────────────────────────────────────── /** Initialization properties for {@link FileField}. */ export interface FileFieldProps extends CommonFieldProps { /** * Restricts which file types can be uploaded. Mirrors the UI's "File type" dropdown. * * Required. * - `'images'` — jpg, jpeg, png, gif, bmp, svg * - `'documents'` — doc, docx, pdf, csv, txt, rtf, ppt, pptx * - `'any'` — any file type */ allowedType: 'images' | 'documents' | 'any'; /** Whether a file is required on save. Optional. */ required?: boolean; } /** File attachment field. */ export class FileField extends Field { constructor(customObject: CustomObject, props: FileFieldProps) { const { allowedType, ...rest } = props; const supported = allowedType === 'any' ? ['*'] : [allowedType]; super(customObject, { ...rest, fieldType: 'FILE', dataType: { supported_types: supported }, }); } } // ── Relational ───────────────────────────────────────────────────────── /** Initialization properties for {@link LookupField}. */ export interface LookupFieldProps extends CommonFieldProps { /** * The object this field points to. Pass a live {@link CustomObject} instance, * or a raw api_name string for native Rippling models (e.g. `'Employee'`). * * Required. */ target: CustomObject | string; /** * Label for the related-records panel on the target object's detail page * (e.g. `'Class enrollments'`). * * Optional. */ relatedDataLabel?: string; /** Whether a value is required on save. Optional. */ required?: boolean; } /** * Many-to-one reference field. Links this record to one record on the target object. * * Use {@link ParentChildField} instead when the relationship should create a * parent-child hierarchy with rollup support. * * @remarks * **For "the employee responsible for this record" semantics, consider the built-in * `owner_role` standard field instead of a new `LookupField` to `'Employee'`.** Every * `CustomObject` ships with `owner_role` (an Employee reference) plus `created_by` * and `last_modified_by`. `owner_role` drives Rippling's record-level permissions, * so a parallel custom employee field can fragment ownership and complicate the * permission story. Consider a new `LookupField` to `'Employee'` when you need a * *second*, non-owner employee reference (e.g. an "approver", a "manager on record"). */ export class LookupField extends Field { constructor(customObject: CustomObject, props: LookupFieldProps) { const targetApiName = typeof props.target === 'string' ? props.target : props.target.getApiName(); super(customObject, { apiName: props.apiName, displayName: props.displayName, dataType: { field_type: 'OG_REFERENCE_FIELD', og_model_rql_name: targetApiName, edge_type: 'LOOKUP', ...(props.relatedDataLabel != null ? { related_data_label: props.relatedDataLabel } : {}), }, required: props.required, description: props.description, section: props.section, }); } } /** Initialization properties for {@link ParentChildField}. */ export interface ParentChildFieldProps extends CommonFieldProps { /** * The parent object. Must be a live {@link CustomObject} instance. * * Required. */ target: CustomObject; /** * Label for the child-records panel on the parent's detail page * (e.g. `'Enrollments'`). * * Optional. */ relatedDataLabel?: string; /** * Whether this is the primary or secondary parent-child edge. * One of `'PRIMARY'` | `'SECONDARY'`. * * Optional. */ parentChildType?: 'PRIMARY' | 'SECONDARY'; /** * Whether the child record inherits the owner from the parent. * * Optional. */ enableParentOwnerInheritance?: boolean; } /** * Parent-child reference field. Creates a hierarchical relationship between * this object (child) and the target (parent), enabling {@link SummaryField} * rollups from the child to the parent. * * Always emitted as `required: true` on the wire. */ export class ParentChildField extends Field { constructor(customObject: CustomObject, props: ParentChildFieldProps) { super(customObject, { apiName: props.apiName, displayName: props.displayName, dataType: { field_type: 'OG_REFERENCE_FIELD', og_model_rql_name: props.target.getApiName(), edge_type: 'PARENT_CHILD', ...(props.relatedDataLabel != null ? { related_data_label: props.relatedDataLabel } : {}), ...(props.parentChildType != null ? { parent_child_type: props.parentChildType } : {}), ...(props.enableParentOwnerInheritance != null ? { enable_parent_owner_inheritance: props.enableParentOwnerInheritance } : {}), }, required: true, description: props.description, section: props.section, }); } } // ── Computed ─────────────────────────────────────────────────────────── /** Initialization properties for {@link FormulaField}. */ export interface FormulaFieldProps extends CommonFieldProps { /** * The RQL expression that computes this field's value * (e.g. `"CONCATENATE(TRIM(first_name__c), ' ', TRIM(last_name__c))"`). * * Required. */ formula: string; /** * Output data type. One of `'TEXT'` | `'NUMBER'` | `'BOOLEAN'` | `'CURRENCY'` * | `'PERCENTAGE'` | `'DATE'` | `'URL'`. * * Required. */ fieldType: FormulaFieldType; /** * Maximum character length of the computed value. * * Required when `fieldType === 'TEXT'`. The constructor throws if omitted for text formulas. */ maxLength?: number; } /** * Read-only computed field whose value is derived from an RQL formula. * * `fieldType` is required and restricted to the 7 output types the Rippling * formula editor supports. `maxLength` is required when `fieldType === 'TEXT'`. * * @example * ```ts * const memberLabel = new FormulaField(memberObj, { * apiName: 'member_label__c', * displayName: 'Member label', * fieldType: 'TEXT', * maxLength: 200, * formula: "CONCATENATE(TRIM(first_name__c), ' ', TRIM(last_name__c))", * section: profileSection, * }); * ``` */ export class FormulaField extends Field { constructor(customObject: CustomObject, props: FormulaFieldProps) { if (props.fieldType === 'TEXT' && props.maxLength == null) { throw new Error(`FormulaField "${props.apiName}": maxLength is required when fieldType is 'TEXT'.`); } const { formula, ...rest } = props; super(customObject, { ...rest, derivedFieldFormula: formula, required: null, unique: null, indexed: null, }); } } /** Initialization properties for {@link SummaryField}. */ export interface SummaryFieldProps extends CommonFieldProps { /** * The child-CO field whose values are aggregated. * * Required. Must have a known `fieldType` (use a typed subclass, not bare `Field`). * Must live on the same child object as `lookup`. */ aggregates: Field; /** * Aggregation function. One of `'COUNT'` | `'SUM'` | `'MIN'` | `'MAX'`. * * Required. `COUNT` always produces a `NUMBER` output; other functions * inherit the output type from `aggregates`. */ using: SummaryAggregation; /** * The {@link ParentChildField} on the child CO that points back at this parent object. * * Required. Must be a `ParentChildField` (the constructor throws otherwise). */ lookup: Field; /** * Optional filter conditions applied before aggregating. Each filter restricts * which child records are included. * * Optional. @default `[]` */ filters?: SummaryFilter[]; /** * ISO 4217 currency code (e.g. `'USD'`). Only valid when the aggregated field * resolves to `'CURRENCY'`. * * Optional. */ currencyCode?: string; } /** * Read-only rollup field that aggregates a child object's field values. * * Defined on the **parent** object. Requires a {@link ParentChildField} on the * child object pointing back at this parent as the `lookup` edge. * * @example * ```ts * // Count enrollments on a class session: * const enrollmentCount = new SummaryField(sessionObj, { * apiName: 'enrollment_count__c', * displayName: 'Enrolled (count)', * aggregates: enrollmentMemberRef, * using: 'COUNT', * lookup: enrollmentSessionRef, // ParentChildField on enrollmentObj → sessionObj * section: sessionStatsSection, * }); * ``` * * @see {@link ParentChildField} — must be used as `lookup`. */ export class SummaryField extends Field { constructor(customObject: CustomObject, props: SummaryFieldProps) { // lookup must be a parentChild field. if (props.lookup.getDataType()['edge_type'] !== 'PARENT_CHILD') { throw new Error( `SummaryField "${props.apiName}": 'lookup' must be a parentChild ` + `field on the child CO (got edge_type=` + `${props.lookup.getDataType()['edge_type'] ?? 'undefined'}).`, ); } // Infer output fieldType: NUMBER for COUNT, else the aggregated field's type. let outputFieldType: FieldType; if (props.using === 'COUNT') { outputFieldType = 'NUMBER'; } else { const agg = props.aggregates.getFieldType(); if (agg == null) { throw new Error( `SummaryField "${props.apiName}": cannot infer output type — ` + `aggregates field "${props.aggregates.getApiName()}" has no ` + `known field_type. Construct it with a typed subclass (e.g. ` + `CurrencyField / NumberField).`, ); } outputFieldType = agg; } // Summary-filter field restrictions. Mirror the admin condition-builder // picker so SDK-authored manifests are fully editable in the UI, and // catch the backend's own hard rejects ahead of /validate. for (const f of props.filters ?? []) { const fname = f.field.getApiName(); const ft = f.field.getFieldType(); // (a) Can't filter on the parent-child lookup field itself // (backend: parent_child_util.py rejects this explicitly). if (f.field === props.lookup) { throw new Error( `SummaryField "${props.apiName}": filter field "${fname}" is the ` + `parent-child lookup field — that's reserved as the aggregation ` + `axis and can't be a filter. Pick a scalar field on the child CO.`, ); } // (b) Can't filter on a derived (formula or summary) field // (backend rejects both). if (f.field.isFormula() || f.field.isSummary()) { throw new Error( `SummaryField "${props.apiName}": filter field "${fname}" is ` + `itself a derived field (formula/summary). The backend does not ` + `allow derived fields as summary-filter fields. Filter on a ` + `plain scalar field on the child CO instead.`, ); } if (ft == null) continue; // (c) FILTER_OPERATORS_BY_TYPE is the single source of truth for // "which field types can be used as summary-filter fields". // Absent key ⇒ UI picker hides this type ⇒ SDK rejects it. const allowed = FILTER_OPERATORS_BY_TYPE[ft]; if (allowed == null) { throw new Error( `SummaryField "${props.apiName}": filter field "${fname}" has type ` + `${ft}, which the admin UI excludes from summary-filter pickers ` + `(ambiguous semantics for aggregations). Model a plain BOOLEAN ` + `field on the child CO (updated via a Rule) and filter on that.`, ); } // (d) Per-type operator allow-list. if (!allowed.has(f.operator)) { throw new Error( `SummaryField "${props.apiName}": operator "${f.operator}" is ` + `not valid for ${ft} field "${fname}". ` + `Allowed: ${[...allowed].join(', ')}.`, ); } } super(customObject, { apiName: props.apiName, displayName: props.displayName, fieldType: outputFieldType, currency: outputFieldType === 'CURRENCY' ? props.currencyCode : undefined, required: null, unique: null, indexed: null, description: props.description, section: props.section, summarySpec: { aggregates: props.aggregates, using: props.using, lookup: props.lookup, filters: props.filters ?? [], currencyCode: props.currencyCode ?? null, }, }); } }