import { Category } from './category'; import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import { Field } from './field'; import type { ManifestComponent, ManifestComponentDeletion, ManifestScope } from './manifest-builder'; import { optionalStringComponentField, requireStringComponentField } from './_existing-component-fields'; // ──────────────────────────────────────────────────────────────────────── // Platform-managed standard fields — mirrors // `app/hub_platform/custom_objects/constants.py::STANDARD_FIELDS`. // Kept here (not in `field.ts`) so the `Field` class stays focused on // user-authored `__c` fields with full validation. // ──────────────────────────────────────────────────────────────────────── /** Specs for the 9 standard fields every CO ships with. Order matches the backend constant. */ const STANDARD_FIELD_SPECS = [ { key: 'id', apiName: 'id', displayName: 'ID', description: 'The Rippling ID of a record', dataType: { field_type: 'TEXT' }, required: true, unique: true, }, { key: 'createdAt', apiName: 'created_at', displayName: 'Created Date', description: 'The date and time a record was created', dataType: { field_type: 'DATETIME' }, required: false, unique: false, }, { key: 'updatedAt', apiName: 'updated_at', displayName: 'Last Modified Date', description: 'The date and time a record was last updated', dataType: { field_type: 'DATETIME' }, required: false, unique: false, }, { key: 'systemUpdatedAt', apiName: 'system_updated_at', displayName: 'System Modified At', description: 'The date and time a record was last updated by the system', dataType: { field_type: 'DATETIME' }, required: false, unique: false, }, { key: 'externalId', apiName: 'external_id', displayName: 'External ID', description: 'The external identifier for a record used in third party integrations', dataType: { field_type: 'TEXT' }, required: false, unique: true, }, { key: 'name', apiName: 'name', displayName: 'Name', description: 'A string used to describe a record. This is used for display purposes as well as search', dataType: { field_type: 'TEXT' }, required: true, unique: false, }, { key: 'ownerRole', apiName: 'owner_role', displayName: 'Owner', description: 'The Employee responsible for the record. This is used by Permissions to determine who has ability to view, edit, and delete data', dataType: { field_type: 'NATIVE_EDGE', og_model_rql_name: 'Employee', db_model_name: 'RoleWithCompany', reference_column_storage_type: 'STRING', }, required: false, unique: false, }, { key: 'lastModifiedBy', apiName: 'last_modified_by', displayName: 'Last Modified by', description: 'The Employee who made the latest change.', dataType: { field_type: 'NATIVE_EDGE', og_model_rql_name: 'Employee', db_model_name: 'RoleWithCompany', reference_column_storage_type: 'STRING', }, required: false, unique: false, }, { key: 'createdBy', apiName: 'created_by', displayName: 'Created by', description: 'The Employee who created the record', dataType: { field_type: 'NATIVE_EDGE', og_model_rql_name: 'Employee', db_model_name: 'RoleWithCompany', reference_column_storage_type: 'STRING', }, required: false, unique: false, }, ] as const satisfies ReadonlyArray<{ key: string; apiName: string; displayName: string; description: string; dataType: Record; required: boolean; unique: boolean; }>; type StandardFieldSpec = (typeof STANDARD_FIELD_SPECS)[number]; export type CustomObjectStandardFieldKey = StandardFieldSpec['key']; export type CustomObjectStandardFieldApiName = StandardFieldSpec['apiName']; export type CustomObjectStandardFieldIdentifier = | CustomObjectStandardFieldKey | CustomObjectStandardFieldApiName; export type CustomObjectStandardFields = Readonly>; export type CustomObjectFieldInput = Field | CustomObjectStandardFieldIdentifier; const STANDARD_FIELD_SPECS_BY_IDENTIFIER = new Map(); for (const spec of STANDARD_FIELD_SPECS) { STANDARD_FIELD_SPECS_BY_IDENTIFIER.set(spec.key, spec); STANDARD_FIELD_SPECS_BY_IDENTIFIER.set(spec.apiName, spec); } function standardFieldSpec(identifier: string): StandardFieldSpec { const spec = STANDARD_FIELD_SPECS_BY_IDENTIFIER.get(identifier); if (spec != null) return spec; throw new Error( `Unknown standard field "${identifier}". Use one of: ` + STANDARD_FIELD_SPECS.map((candidate) => candidate.key).join(', ') + '.', ); } function standardFieldComponent(customObjectApiName: string, spec: StandardFieldSpec): Record { return new _StandardField(customObjectApiName, spec).toDict(); } function createStandardFieldsAccessor(customObject: CustomObject): CustomObjectStandardFields { const fields = {} as Record; for (const spec of STANDARD_FIELD_SPECS) { Object.defineProperty(fields, spec.key, { enumerable: true, get: () => customObject.standardField(spec.key), }); } return Object.freeze(fields) as CustomObjectStandardFields; } /** * Minimal `ManifestComponent` that emits a single standard field stub. * Intentionally bypasses `Field`'s `__c` validator since standard api_names * (`id`, `created_at`, …) don't carry the custom-field suffix. */ class _StandardField implements ManifestComponent { static readonly componentType = 'CUSTOM_OBJECT_FIELD' as const; constructor( private readonly customObjectApiName: string, private readonly spec: (typeof STANDARD_FIELD_SPECS)[number], ) {} toDict(): Record { return { type: _StandardField.componentType, custom_object_api_name: this.customObjectApiName, field_api_name: this.spec.apiName, field_display_name: this.spec.displayName, data_type: { options_list: [], supported_types: [], ...this.spec.dataType }, description: this.spec.description, is_required: this.spec.required, is_unique: this.spec.unique, is_indexed: null, rql_formula: null, formula_attr_metas: null, is_standard: true, derived_aggregated_field: null, derived_field_formula: null, section: null, }; } } /** * Icon displayed next to the custom object name in the Rippling UI. */ export interface IconConfig { /** * A Unicode emoji character, e.g. `'🏋️'`. * * Optional. Omit all four sources (or pass `null`) to show no icon. */ emoji?: string | null; /** * Name of a built-in Rippling icon asset. * * Optional. */ ripplingIcon?: string | null; /** * S3 bucket for a custom image asset. * * Optional. Must be set together with `s3Key`. */ s3Bucket?: string; /** * S3 key (path) within `s3Bucket`. * * Optional. Must be set together with `s3Bucket`. */ s3Key?: string; } /** * Initialization properties for {@link CustomObject}. */ export interface CustomObjectProps { /** * Unique identifier for this custom object. * * Required. Must end with `__c` (e.g. `'gym_member__c'`). The constructor throws * immediately if this suffix is missing. */ apiName: string; /** * Singular display name shown in the Rippling UI (e.g. `'Member'`). * * Required. */ name: string; /** * The category this object belongs to. * * Required. Pass a {@link Category} instance defined or loaded in the same manifest. */ category: Category; /** * Human-readable description of what this entity represents. * * Required. */ description: string; /** * Icon shown next to the object name in the Rippling UI. * * Optional. @default null */ icon?: IconConfig | null; /** * Plural form of the display name, used in list headings (e.g. `'Members'`). * * Optional. @default null */ pluralLabel?: string | null; /** * Overrides for the auto-number name field — controls the format string and starting number. * Leave `null` to use the default name field behavior. * * Example: * ```ts * nameFieldDetails: { * is_autonumber_field: true, * starting_number: 1, * autonumber_format: 'EXP-{0000}', * } * ``` * * @remarks * The backend auto-appends `{0000}` to `autonumber_format` if the format string * doesn't already contain a sequence token — so `'EXP-'` becomes `'EXP-{0000}'` * on the wire. Include the token explicitly when you want a different width * (e.g. `'EXP-{00000}'`). * * Optional. @default null */ nameFieldDetails?: Record | null; /** * API name of the field section that newly created fields are grouped into by default. * * Optional. @default null */ defaultFieldSectionName?: string | null; } /** * Defines a custom object and registers it with the manifest. * * A custom object is a new entity type with its own fields, validations, rules, list views, * and page layouts. You would normally define all custom objects near the top of your manifest * file, then define their children (fields, sections, rules, etc.) below. Each child takes * the `CustomObject` instance as its first argument and registers automatically. * * @example * ```ts * const manifest = new ManifestBuilder({ * key: 'gym_membership_management', * name: 'Gym Membership Management', * }); * * const gymCategory = new Category(manifest, { apiName: 'gym__c', name: 'Gym', description: 'Gym domain' }); * * const memberObj = new CustomObject(manifest, { * apiName: 'gym_member__c', * name: 'Member', * pluralLabel: 'Members', * category: gymCategory, * description: 'Gym member profile, contact, and membership status', * icon: { emoji: '🏋️' }, * }); * * // The object is now a scope — children register through it: * const memberProfileSection = new CustomObjectFieldSection(memberObj, { * name: 'Profile', * sectionId: 'sec_gm_profile', * }); * * const firstName = new TextField(memberObj, { * apiName: 'first_name__c', * displayName: 'First name', * required: true, * section: memberProfileSection, * }); * ``` * * @remarks * **Required: every `CustomObject` must have at least one `CustomObjectPageLayout` in the * manifest.** Omitting it passes component-level validation but fails at the package level * with `DEPENDENCY_ERROR: Page layout config for CO (api_name=...) not found in package * configs. CO requires at least one page layout in the package.` * Use {@link CustomObjectPageLayout.basic} for a minimal single-tab layout. * * **Consider defining at least one {@link ListViewDef}.** Without a list view, * the records page has nothing to display by default — users land on an empty grid. * Consider shipping a "default" view with the columns most users want to see * (typically `name`, the status / category field, owner, and `created_at`), sorted * however the records are most commonly browsed. * * **Consider system fields before authoring new ones.** Every CO ships with * `owner_role`, `created_by`, and `last_modified_by` (all `Employee` references), * plus `name`, `created_at`, `updated_at`, `external_id`, and `id`. For any * "the employee responsible for this record" semantics, consider using * `owner_role` rather than adding a new {@link LookupField} targeting `'Employee'`. * `owner_role` drives Rippling's record-level permissions, so a parallel field * can fragment ownership and complicate the permission story. */ export class CustomObject implements ManifestScope { static readonly componentType = 'CUSTOM_OBJECT' as const; static toDeletionIdentifier(apiName: string): ManifestComponentDeletion { return { type: CustomObject.componentType, api_name: apiName, }; } private readonly _scope: ManifestScope; private readonly _apiName: string; private readonly _name: string; private readonly _category: Category; private _description: string; private _icon: IconConfig | null | undefined; private _pluralLabel: string | null | undefined; private _nameFieldDetails: Record | null | undefined; private _defaultFieldSectionName: string | null | undefined; private readonly _standardFieldRefs = new Map(); private _standardFieldResolver: ((apiName: CustomObjectStandardFieldApiName) => Field) | undefined; readonly standardFields: CustomObjectStandardFields; /** * @param scope - The manifest or parent scope to register this object with. * @param props - Initialization properties. * @throws {Error} If `props.apiName` does not end with `__c`. */ constructor(scope: ManifestScope, props: CustomObjectProps) { if (!props.apiName.endsWith('__c')) { throw new Error(`Custom object api_name must end with __c: ${props.apiName}`); } if (!(props.category instanceof Category)) { throw new TypeError( 'CustomObject category must be a Category object; raw category api_name strings are not supported. ' + 'Define or load the category first.', ); } this._scope = scope; this._apiName = props.apiName; this._name = props.name; this._category = props.category; this._description = props.description; this._icon = props.icon; this._pluralLabel = props.pluralLabel; this._nameFieldDetails = props.nameFieldDetails; this._defaultFieldSectionName = props.defaultFieldSectionName; this.standardFields = createStandardFieldsAccessor(this); scope._register(this); // Backend closed-world validation requires these 9 configs for every new CO. for (const spec of STANDARD_FIELD_SPECS) { scope._register(new _StandardField(this._apiName, spec)); } } /** * Loads this custom object from the active existing-manifest JSON context. */ static loadFromExisting(apiName: string, options: ExistingManifestContextOptions = {}): CustomObject { return getExistingManifestContext(options).loadCustomObject(apiName); } /** @internal Hydrates a custom object from existing manifest wire JSON. */ static _fromExistingComponent( scope: ManifestScope, component: Record, resolveCategory: (apiName: string) => Category, ): CustomObject { const categoryApiName = requireStringComponentField( component, 'category_api_name', CustomObject.componentType, ); const props: CustomObjectProps = { apiName: requireStringComponentField(component, 'api_name', CustomObject.componentType), name: requireStringComponentField(component, 'name', CustomObject.componentType), category: resolveCategory(categoryApiName), description: optionalStringComponentField(component, 'description'), }; if (component['plural_label'] !== undefined) props.pluralLabel = component['plural_label']; if (component['name_field_details'] !== undefined) { props.nameFieldDetails = component['name_field_details']; } if (component['default_field_section_name'] !== undefined) { props.defaultFieldSectionName = component['default_field_section_name']; } const icon = CustomObject.iconFromExistingComponent(component['icon']); if (icon != null) props.icon = icon; return new CustomObject(scope, props); } /** Returns a platform-managed standard field on this custom object. */ standardField(identifier: CustomObjectStandardFieldIdentifier): Field { const spec = standardFieldSpec(identifier); const cached = this._standardFieldRefs.get(spec.apiName); if (cached != null) return cached; const field = this._standardFieldResolver?.(spec.apiName) ?? Field._referenceFromExistingComponent(standardFieldComponent(this._apiName, spec)); this._standardFieldRefs.set(spec.apiName, field); return field; } /** Alias for {@link standardField}. */ field(identifier: CustomObjectStandardFieldIdentifier): Field { return this.standardField(identifier); } /** @internal Resolves object-scoped field inputs accepted by layout/list-view APIs. */ _resolveField(field: CustomObjectFieldInput): Field { return typeof field === 'string' ? this.standardField(field) : field; } /** @internal Lets loaded objects return existing standard field refs when present. */ _setStandardFieldResolver(resolve: (apiName: CustomObjectStandardFieldApiName) => Field): void { this._standardFieldResolver = resolve; this._standardFieldRefs.clear(); } private static iconFromExistingComponent(value: unknown): IconConfig | undefined { if (value == null || typeof value !== 'object') return undefined; const wire = value as Record; const icon: IconConfig = {}; if (wire['emoji'] !== undefined) icon.emoji = wire['emoji']; if (wire['rippling_icon'] !== undefined) icon.ripplingIcon = wire['rippling_icon']; if (wire['s3_bucket'] !== undefined) icon.s3Bucket = wire['s3_bucket']; if (wire['s3_key'] !== undefined) icon.s3Key = wire['s3_key']; return Object.keys(icon).length > 0 ? icon : undefined; } /** @internal Scope hook — forwards child registration up to the root builder. */ _register(component: ManifestComponent): void { this._scope._register(component); } /** * Returns the api_name of this custom object (e.g. `'gym_member__c'`). * * Use the return value wherever a field or layout needs to reference this object by name. */ getApiName(): string { return this._apiName; } /** * Returns the singular display name of this custom object (e.g. `'Member'`). */ getName(): string { return this._name; } /** * Serializes this custom object to the wire format consumed by the manifest install endpoint. * * @returns A plain object with `type: 'CUSTOM_OBJECT'` and all configured fields. * Optional fields are omitted when `null`. An `icon` with no populated keys is omitted entirely. */ toDict(): Record { const component: Record = { type: CustomObject.componentType, api_name: this._apiName, name: this._name, description: this._description, category_api_name: this._category.getApiName(), }; if (this._icon != null) { const iconDict: Record = {}; if (this._icon.emoji != null) iconDict['emoji'] = this._icon.emoji; if (this._icon.ripplingIcon != null) iconDict['rippling_icon'] = this._icon.ripplingIcon; if (this._icon.s3Bucket != null) iconDict['s3_bucket'] = this._icon.s3Bucket; if (this._icon.s3Key != null) iconDict['s3_key'] = this._icon.s3Key; // Mirror backend `if self.icon:` — empty dict is falsy and gets omitted. if (Object.keys(iconDict).length > 0) { component['icon'] = iconDict; } } if (this._pluralLabel != null) component['plural_label'] = this._pluralLabel; if (this._nameFieldDetails != null) component['name_field_details'] = this._nameFieldDetails; if (this._defaultFieldSectionName != null) { component['default_field_section_name'] = this._defaultFieldSectionName; } return component; } }