import { generateId } from './_helpers'; import { CustomObjectFieldSection } from './custom-object-field-section'; import type { CustomObject, CustomObjectFieldInput, CustomObjectStandardFieldIdentifier, } from './custom-object'; import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import type { Field } from './field'; import type { ManifestComponentDeletion } from './manifest-builder'; import { requireStringComponentField } from './_existing-component-fields'; export interface PageLayoutDeletionIdentifier { layoutId: string; objectRqlName: string; } export type ObjectRqlName = string; export type BlueprintKey = string; export type LayoutApiName = string; export type TabKey = string; export type SectionKey = string; export type HeaderFieldKey = string; export type FieldRqlName = string; export type RelatedObjectRqlName = string; export type CanvasCompositionId = string; export type HeaderButtonKey = string; export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; export interface JsonObject { [key: string]: JsonValue; } // --------------------------------------------------------------------------- // header_edits — HeaderEditsDTO // --------------------------------------------------------------------------- /** Edit to a built-in (system) header field. */ export interface SystemHeaderFieldEdit { /** Replace the system field's RQL field reference. Optional. */ newRqlField?: FieldRqlName; /** Remove this system field from the header. Optional. */ deleted?: boolean; } /** * Custom header field added via `newFields`. * * `key` is required and must be non-empty — it is used as the stable identity * for this field in visibility conditions. */ export interface NewHeaderField { /** RQL field name to display. Required. */ rqlName: FieldRqlName; /** Stable key for this field. Required. */ key: HeaderFieldKey; /** Whether the user can remove this field. Optional. */ canBeDeleted?: boolean; /** Whether the user can swap this field for another. Optional. */ canBeChanged?: boolean; /** Whether the user can reorder this field. Optional. */ canBeMoved?: boolean; } /** * Header configuration for a page layout (`header_edits`). * * Only the keys listed here are accepted — anything else is silently dropped * on the wire. Pass `{}` for an unchanged header. * * > **Caution:** key names are camelCase on the wire. Snake_case keys are * > silently dropped and produce an empty header on deploy. */ export interface HeaderEdits { /** Replace the record title field (pass the field's api_name). Optional. */ newTitleField?: FieldRqlName; /** Remove the title field from the header. Optional. */ titleFieldDeleted?: boolean; /** Replace the record description field. Optional. */ newDescriptionField?: FieldRqlName; /** Remove the description field from the header. Optional. */ descriptionFieldDeleted?: boolean; /** Edits to built-in system header fields, keyed by system field key. Optional. */ systemFieldEdits?: Record; /** Additional custom fields to add to the header. Optional. */ newFields?: NewHeaderField[]; /** Ordered list of header field RQL names. Custom header keys are accepted and converted. Optional. */ headerFieldsOrder?: FieldRqlName[]; /** Action button keys to show in the header. Optional. */ buttons?: HeaderButtonKey[]; } interface SerializedSystemHeaderFieldEdit extends Omit { newRqlField?: FieldRqlName | null; } interface SerializedHeaderEdits extends Omit { newTitleField?: FieldRqlName | null; newDescriptionField?: FieldRqlName | null; systemFieldEdits?: Record; } // --------------------------------------------------------------------------- // tab_edits — TabEditsDTO + section/tab DTOs // --------------------------------------------------------------------------- export type SectionFieldsLayout = 'one_column' | 'two_column' | 'three_column' | 'responsive'; export type TabLayout = 'vertical' | 'horizontal'; export type SectionMovementOption = 'static' | 'tab' | 'free'; export type FieldMovementOption = 'tab' | 'free'; export type CustomObjectCustomSectionComponentKey = 'attachments' | 'comments'; /** Default value wrapper used by `SectionField.defaultValue`. */ export interface SectionFieldDefaultValue { value: JsonValue; } /** Options for one field entry inside a `fields_section`. */ export interface SectionFieldOptions { /** Hide this field from view. Optional. */ hidden?: boolean; /** Allow inline editing of this field. Optional. */ editable?: boolean; /** Render this field read-only. Optional. */ readOnly?: boolean; /** Default value metadata for the field. Optional. */ defaultValue?: SectionFieldDefaultValue; /** Rich-text field height. Optional. */ height?: number; /** Whether the layout manager can remove this field. Optional. */ canBeDeleted?: boolean; /** Controls how the field can be moved in the UI. Optional. */ movementOption?: FieldMovementOption; } /** A typed field entry inside a `fields_section`. */ export interface SectionField extends SectionFieldOptions { /** Field to display. Required. */ field: CustomObjectFieldInput; } /** @deprecated Use `SectionField` for object entries or `SectionFieldInput` for field arrays. */ export type SectionFieldRef = SectionField; /** One field entry inside a `fields_section`. */ export type SectionFieldInput = CustomObjectFieldInput | SectionField; /** Common edit controls for sections. */ export interface PageLayoutSectionOptions { /** Whether the layout manager can delete this section. Optional. */ canBeDeleted?: boolean; /** Whether the layout manager can rename this section. Optional. */ canBeRenamed?: boolean; /** Controls how the section can be moved. Optional. */ movementOption?: SectionMovementOption; /** Hide the section title. Optional. */ hideName?: boolean; } /** A standard field-grid section inside a tab. */ export interface FieldsSection extends PageLayoutSectionOptions { type: 'fields_section'; /** Field section metadata for the fields displayed in this layout section. Required. */ section: CustomObjectFieldSection; /** Stable layout section key. Optional. Defaults to `section.getSectionId()`. */ key?: SectionKey; /** Display name of the layout section. Optional. Defaults to the field section name. */ name?: string; /** Fields to display in this section. Required. */ fields: SectionFieldInput[]; /** Column layout for the fields. Optional. @default `'responsive'` (backend default). */ layout?: SectionFieldsLayout; } export interface RelatedObjectSection extends PageLayoutSectionOptions { type: 'related_object_section'; /** Stable layout section key. Required. */ key: SectionKey; /** Display name of the layout section. Required. */ name: string; /** Related object whose records point back to this custom object. Required. */ relatedObjectRqlName: RelatedObjectRqlName; /** Field on the related object that points back to this custom object. Required. */ relatedFieldRqlName: FieldRqlName; /** Field RQL names to display for related records. Required. */ fields: FieldRqlName[]; } export interface RelatedFieldSection extends PageLayoutSectionOptions { type: 'related_field_section'; /** Stable layout section key. Required. */ key: SectionKey; /** Display name of the layout section. Required. */ name: string; /** Related object reached from this custom object. Required. */ relatedObjectRqlName: RelatedObjectRqlName; /** Field on this custom object that points to the related object. Required. */ relatedFieldRqlName: FieldRqlName; /** Field RQL names to display from the related object. Required. */ fields: FieldRqlName[]; /** Column layout for the fields. Optional. @default `'responsive'` (backend default). */ layout?: SectionFieldsLayout; } export interface CustomSection extends PageLayoutSectionOptions { type: 'custom_section'; /** Stable layout section key. Required. */ key: SectionKey; /** Display name of the layout section. Required. */ name: string; /** Custom object section component key. Required. */ componentKey: CustomObjectCustomSectionComponentKey; } export interface ResizableLayoutPosition { identifier: string; width: number; height: number; minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number; } export interface ReportsSectionItem { key: string; reportId: string; position: ResizableLayoutPosition; contextualFieldAttrName?: FieldRqlName; visualizationType?: string; } export interface ReportsSection extends PageLayoutSectionOptions { type: 'reports_section'; /** Stable layout section key. Required. */ key: SectionKey; /** Display name of the layout section. Required. */ name: string; /** Reports to display in this section. Required. */ reports: ReportsSectionItem[]; } export interface CanvasSection extends PageLayoutSectionOptions { type: 'canvas_section'; /** Stable layout section key. Required. */ key: SectionKey; /** Display name of the layout section. Required. */ name: string; /** Canvas composition to render. Required. */ canvasCompositionId: CanvasCompositionId; /** Composition input name to field or state path mapping. Optional. */ inputMapping?: Record; } /** A section inside a page layout tab. */ export type PageLayoutSection = | FieldsSection | RelatedObjectSection | RelatedFieldSection | CustomSection | ReportsSection | CanvasSection; /** A tab that contains one or more field-grid sections. */ export interface TabWithSections { type: 'tab_with_sections'; /** Stable key for this tab. Required. */ key: TabKey; /** Display name of the tab. Required. */ name: string; /** Sections inside this tab. Required. */ sections: PageLayoutSection[]; } /** A tab that hosts a custom UI component. */ export interface CustomTab { type: 'custom_tab'; /** Stable key for this tab. Required. */ key: TabKey; /** Display name of the tab. Required. */ name: string; /** Key of the custom component to render. Required. */ componentKey: string; } /** A tab entry in a page layout — either a sections-based tab or a custom component tab. */ export type PageLayoutTab = TabWithSections | CustomTab; /** Column layout for a `fields_section`. */ /** Edits to a built-in section inside a system tab. */ export interface SystemSectionEdit { /** Rename this section. Optional. */ newName?: string; /** Remove this section. Optional. */ deleted?: boolean; /** Change the column layout. Optional. */ newLayout?: SectionFieldsLayout; /** Add fields to this section. Optional. */ newFields?: SectionFieldInput[]; /** Hide the section name header. Optional. */ newHideName?: boolean; } /** Edits to a built-in (system) tab. */ export interface SystemTabEdit { /** Rename this tab. Optional. */ newName?: string; /** Remove this tab. Optional. */ deleted?: boolean; /** Add new sections to this tab. Optional. */ newSections?: PageLayoutSection[]; /** Edits to built-in sections inside this tab, keyed by section key. Optional. */ systemSectionEdits?: Record; /** Reorder sections by key. Optional. */ sectionsOrder?: SectionKey[]; } /** * Tab structure configuration for a page layout (`tab_edits`). * * All fields are optional. An omitted `tabEdits` defaults to `{}`. * * > **Caution:** key names are camelCase on the wire. Snake_case keys are * > silently dropped and produce an empty layout on deploy. */ export interface TabEdits { /** New tabs to add to this layout. Each must include a `type` discriminator. Optional. */ newTabs?: PageLayoutTab[]; /** Edits to existing system tabs, keyed by tab key. Optional. */ systemTabEdits?: Record; /** Ordered list of tab `key` values (new + system). Optional. */ tabsOrder?: TabKey[]; /** Tab strip orientation. Optional. */ newTabLayout?: TabLayout; } // --------------------------------------------------------------------------- // visibility_conditions — VisibilityConditionsDTO // --------------------------------------------------------------------------- /** * A single visibility condition applied to a section or tab. * * Both fields are required by the backend — pass an empty string / empty object * if you have no formula or condition tree. */ export interface VisibilityCondition { /** RQL formula that controls visibility. Required. */ rqlFormula: string; /** Layered conditions tree (AST). Required. */ layeredConditionsTree: JsonObject; } /** * Visibility conditions for sections and tabs on a page layout. * * Pass `{}` for no conditions. */ export interface VisibilityConditions { /** Per-section visibility conditions, keyed by section key. Optional. */ sections?: Record; /** Per-tab visibility conditions, keyed by tab key. Optional. */ tabs?: Record; } // --------------------------------------------------------------------------- // Mutation helpers // --------------------------------------------------------------------------- /** Where to insert a new layout item. Defaults to `'end'`. */ export type PageLayoutMutationPosition = 'start' | 'end' | number; interface AddPageLayoutFieldOptionsBase extends SectionFieldOptions { /** Insert position inside the section field list. Optional. @default `'end'` */ position?: PageLayoutMutationPosition; /** Allow the same field to appear twice in the same section. Optional. @default false */ allowDuplicate?: boolean; } /** Options for adding a field to a typed custom-object field section. */ export interface AddCustomPageLayoutFieldOptions extends AddPageLayoutFieldOptionsBase { /** * Tab key containing the target section. Optional when `section` is unique * across the layout. */ tabKey?: string; /** Field-section metadata used to find typed custom layout sections and assign field metadata. */ section?: CustomObjectFieldSection; /** Layout section key. Optional escape hatch for ambiguous custom sections. */ sectionKey?: SectionKey; /** Deprecated. Use `section`. */ fieldSection?: never; /** Custom layout sections are selected by `section`; `sectionKey` can disambiguate. */ systemSection?: false; } /** Options for adding a field to a built-in system section. */ export interface AddSystemPageLayoutFieldOptions extends AddPageLayoutFieldOptionsBase { /** * Add to a built-in system section via `systemTabEdits`. * * When true, `tabKey` and `sectionKey` are required and the edit is created * if it does not already exist. */ systemSection: true; /** System tab key containing the target section. Required. */ tabKey: string; /** Built-in system section key. Required. */ sectionKey: string; /** Custom field-section objects are only accepted for custom layout sections. */ section?: never; /** Deprecated. Use `section` for custom sections, or `sectionKey` for system sections. */ fieldSection?: never; } /** Options for adding a field to an existing layout section. */ export type AddPageLayoutFieldOptions = AddCustomPageLayoutFieldOptions | AddSystemPageLayoutFieldOptions; /** Options for adding multiple fields to an existing layout section. */ export type AddPageLayoutFieldsOptions = AddPageLayoutFieldOptions; /** Options for removing fields from a layout. */ interface RemovePageLayoutFieldOptionsBase { /** * Remove all matching occurrences. When false, removing a field that appears * more than once throws. Optional. @default true */ removeAll?: boolean; } /** Options for removing fields from typed custom-object field sections. */ export interface RemoveCustomPageLayoutFieldOptions extends RemovePageLayoutFieldOptionsBase { /** Limit removal to one tab. Optional. */ tabKey?: string; /** Limit removal to one field section. Optional. */ section?: CustomObjectFieldSection; /** Limit removal to one layout section key. Optional. */ sectionKey?: SectionKey; /** Custom layout sections are selected by `section`; `sectionKey` can disambiguate. */ systemSection?: false; } /** Options for removing fields from built-in system section edits. */ export interface RemoveSystemPageLayoutFieldOptions extends RemovePageLayoutFieldOptionsBase { /** Remove from a built-in system section edit. Required. */ systemSection: true; /** System tab key containing the target section. Required. */ tabKey: string; /** Built-in system section key. Required. */ sectionKey: string; /** Custom field-section objects are only accepted for custom layout sections. */ section?: never; } /** Options for removing fields from a layout. */ export type RemovePageLayoutFieldOptions = | RemoveCustomPageLayoutFieldOptions | RemoveSystemPageLayoutFieldOptions; /** Options for adding a section to an existing tab. */ export interface AddPageLayoutSectionOptions { /** Tab key to add the section to. Required. */ tabKey: string; /** Insert position inside the tab. Optional. @default `'end'` */ position?: PageLayoutMutationPosition; /** Allow another section with the same key in the same tab. Optional. @default false */ allowDuplicate?: boolean; /** * Add to a built-in system tab via `systemTabEdits.newSections`. * * When false, the tab must already be present in `newTabs`. */ systemTab?: boolean; } /** Options for removing sections from a layout. */ export interface RemovePageLayoutSectionOptions { /** Limit removal to one tab. Optional. */ tabKey?: string; /** * Remove all matching occurrences. When false, removing a section that appears * more than once throws. Optional. @default true */ removeAll?: boolean; /** Custom layout sections are selected by layout section key or `CustomObjectFieldSection`. */ systemSection?: false; } /** Options for removing a built-in system section from a layout. */ export interface RemoveSystemPageLayoutSectionOptions { /** System tab key containing the target section. Required. */ tabKey: string; /** Delete a built-in system section via `systemSectionEdits`. Required. */ systemSection: true; } /** Options for adding a tab to a layout. */ export interface AddPageLayoutTabOptions { /** Insert position inside `newTabs` and `tabsOrder`. Optional. @default `'end'` */ position?: PageLayoutMutationPosition; /** Allow another tab with the same key. Optional. @default false */ allowDuplicate?: boolean; } /** Options for removing a tab from a layout. */ export interface RemovePageLayoutTabOptions { /** * Remove all matching occurrences. When false, removing a tab that appears * more than once throws. Optional. @default true */ removeAll?: boolean; /** Delete a built-in system tab via `systemTabEdits`. */ systemTab?: boolean; } /** Options for adding a custom header field. */ export interface AddHeaderFieldOptions extends Omit { /** * Stable header key. Defaults to the field api_name when adding a `Field`. * Required when adding a raw `NewHeaderField`. */ key?: string; /** Insert position inside `newFields` and `headerFieldsOrder`. Optional. @default `'end'` */ position?: PageLayoutMutationPosition; /** Allow another custom header field with the same key. Optional. @default false */ allowDuplicate?: boolean; } /** Options for removing custom header fields. */ export interface RemoveHeaderFieldOptions { /** * Remove all matching occurrences. When false, removing a header field that * appears more than once throws. Optional. @default true */ removeAll?: boolean; } // --------------------------------------------------------------------------- // Public props // --------------------------------------------------------------------------- /** * Initialization properties for {@link CustomObjectPageLayout}. */ export interface CustomObjectPageLayoutProps { /** * Stable identifier for this layout (e.g. `'default'`). * * Required. Use `'default'` for the layout that drives the **Add** form and * the default record detail view. Each custom object supports one `'default'` * layout. */ apiName: string; /** * Display name of this layout shown in the layout selector. * * Required. */ name: string; /** * Tab structure — which tabs and sections to show on the detail page. * * Optional. @default `{}` */ tabEdits?: TabEdits; /** * Header configuration — title field, description field, and action buttons. * * Optional. @default `{}` */ headerEdits?: HeaderEdits; /** * Visibility conditions for individual tabs and sections. * * Optional. @default `{}` */ visibilityConditions?: VisibilityConditions; /** * Stable identifier for this layout record. Pass a human-readable slug * to keep it stable across regenerations. * * Optional. Auto-generated when omitted. @default generateId('layout') */ layoutId?: string; /** * Blueprint key used during install. Defaults to the parent object's api_name. * * Optional. */ blueprintKey?: string; } export interface CustomObjectPageLayoutLoadFromExistingOptions extends ExistingManifestContextOptions { customObjectApiName?: string; } type ResolveFieldSectionId = (fieldRqlName: string) => string | undefined; interface SerializedSectionField extends SectionFieldOptions { fieldRqlName: string; } interface SerializedFieldsSection extends PageLayoutSectionOptions { key?: string; type: 'fields_section'; name: string; fields: SerializedSectionField[]; layout?: SectionFieldsLayout; } type SerializedPageLayoutSection = | SerializedFieldsSection | RelatedObjectSection | RelatedFieldSection | CustomSection | ReportsSection | CanvasSection; interface SerializedTabWithSections { type: 'tab_with_sections'; key: string; name: string; sections: SerializedPageLayoutSection[]; } type SerializedPageLayoutTab = SerializedTabWithSections | CustomTab; interface SerializedSystemSectionEdit extends Omit { newFields?: SerializedSectionField[]; } interface SerializedSystemTabEdit extends Omit { newSections?: SerializedPageLayoutSection[]; systemSectionEdits?: Record; } interface SerializedTabEdits extends Omit { newTabs?: SerializedPageLayoutTab[]; systemTabEdits?: Record; } function isField(value: unknown): value is Field { const candidate = value as { getApiName?: unknown; getCustomObjectApiName?: unknown; getSectionId?: unknown; _assignSection?: unknown; } | null; return ( candidate != null && typeof candidate.getApiName === 'function' && typeof candidate.getCustomObjectApiName === 'function' && typeof candidate.getSectionId === 'function' && typeof candidate._assignSection === 'function' ); } function isFieldRef(value: SectionFieldInput): value is SectionField { const candidate = value as { field?: unknown } | null; return ( candidate != null && typeof candidate === 'object' && (isField(candidate.field) || typeof candidate.field === 'string') ); } const LAYOUT_SECTION_KEY = Symbol('CustomObjectPageLayout.layoutSectionKey'); function isCustomObjectFieldSection(value: unknown): value is CustomObjectFieldSection { return value instanceof CustomObjectFieldSection; } function explicitLayoutSectionKey(section: { key?: string }): string | undefined { const record = section as unknown as Record; const key = section.key ?? record['layout_section_key'] ?? record['layoutSectionKey']; return typeof key === 'string' ? key : undefined; } function attachLayoutSectionKey( section: SerializedFieldsSection, sectionKey: string, ): SerializedFieldsSection { if (explicitLayoutSectionKey(section) == null) section.key = sectionKey; Object.defineProperty(section, LAYOUT_SECTION_KEY, { value: sectionKey, enumerable: false, configurable: true, }); return section; } function layoutSectionKey(section: { key?: string }): string | undefined { return ( explicitLayoutSectionKey(section) ?? (section as unknown as Record)[LAYOUT_SECTION_KEY] ); } function layoutSectionMatches(section: { key?: string }, sectionKey: string): boolean { return layoutSectionKey(section) === sectionKey; } function isSerializedFieldsSection(section: SerializedPageLayoutSection): section is SerializedFieldsSection { return section.type === 'fields_section'; } function assertSameObject(customObject: CustomObject, field: Field, sectionKey: string): void { if (field.getCustomObjectApiName() !== customObject.getApiName()) { throw new Error( `CustomObjectPageLayout fields_section "${sectionKey}" references field "${field.getApiName()}" ` + `from "${field.getCustomObjectApiName()}", but the layout belongs to "${customObject.getApiName()}".`, ); } } function assignSectionForLayout(field: Field, section: CustomObjectFieldSection | string): void { if (!field.isStandard()) field._assignSection(section); } function assertSectionBelongsTo(customObject: CustomObject, section: CustomObjectFieldSection): void { if (section.getModelApiName() !== customObject.getApiName()) { throw new Error( `CustomObjectPageLayout section "${section.getSectionId()}" belongs to "${section.getModelApiName()}", ` + `but the layout belongs to "${customObject.getApiName()}".`, ); } } function requireFieldSectionOption(value: unknown, label: string): CustomObjectFieldSection { if (isCustomObjectFieldSection(value)) return value; if (typeof value === 'string') { throw new TypeError( `${label} must be a CustomObjectFieldSection object; received raw section id "${value}". ` + 'Load/include the existing field section first.', ); } throw new TypeError(`${label} must be a CustomObjectFieldSection object.`); } function assertOptionAbsent(options: unknown, key: string, message: string): void { if (options == null || typeof options !== 'object') return; if (Object.prototype.hasOwnProperty.call(options, key)) throw new TypeError(message); } function toSerializedSectionField( fieldRqlName: string, options: SectionFieldOptions = {}, ): SerializedSectionField { const normalized: SerializedSectionField = { fieldRqlName }; if (options.hidden != null) normalized.hidden = options.hidden; if (options.editable != null) normalized.editable = options.editable; if (options.readOnly != null) normalized.readOnly = options.readOnly; if (options.defaultValue != null) normalized.defaultValue = options.defaultValue; if (options.height != null) normalized.height = options.height; if (options.canBeDeleted != null) normalized.canBeDeleted = options.canBeDeleted; if (options.movementOption != null) normalized.movementOption = options.movementOption; return normalized; } function copySectionOptions( target: T, input: PageLayoutSectionOptions, ): T { if (input.canBeDeleted != null) target.canBeDeleted = input.canBeDeleted; if (input.canBeRenamed != null) target.canBeRenamed = input.canBeRenamed; if (input.movementOption != null) target.movementOption = input.movementOption; if (input.hideName != null) target.hideName = input.hideName; return target; } function normalizeSectionField( customObject: CustomObject, section: CustomObjectFieldSection | string, sectionKey: string, entry: SectionFieldInput, ): SerializedSectionField { if (isField(entry) || typeof entry === 'string') { const field = customObject._resolveField(entry); assertSameObject(customObject, field, sectionKey); assignSectionForLayout(field, section); return toSerializedSectionField(field.getApiName()); } if (isFieldRef(entry)) { const field = customObject._resolveField(entry.field); assertSameObject(customObject, field, sectionKey); assignSectionForLayout(field, section); return toSerializedSectionField(field.getApiName(), entry); } throw new Error( `CustomObjectPageLayout fields_section "${sectionKey}" has an invalid field entry; ` + 'pass a Field instance or { field }.', ); } function normalizeFieldsSection(customObject: CustomObject, input: FieldsSection): SerializedFieldsSection { assertSectionBelongsTo(customObject, input.section); const sectionKey = input.key ?? input.section.getSectionId(); const name = input.name ?? input.section.getName(); const normalized = copySectionOptions( { name, type: 'fields_section', fields: input.fields.map((field) => normalizeSectionField(customObject, input.section, sectionKey, field), ), }, input, ); if (input.layout != null) normalized.layout = input.layout; return attachLayoutSectionKey(normalized, sectionKey); } function normalizeSection( customObject: CustomObject, section: PageLayoutSection, ): SerializedPageLayoutSection { if (section.type === 'fields_section') return normalizeFieldsSection(customObject, section); return { ...section }; } function normalizeTab(customObject: CustomObject, tab: PageLayoutTab): SerializedPageLayoutTab { if (tab.type === 'custom_tab') return tab; return { ...tab, sections: tab.sections.map((section) => normalizeSection(customObject, section)), }; } function normalizeSystemSectionEdit( customObject: CustomObject, tabKey: string, sectionKey: string, edit: SystemSectionEdit, ): SerializedSystemSectionEdit { const { newFields: _newFields, ...rest } = edit; const normalized: SerializedSystemSectionEdit = { ...rest }; if (edit.newFields != null) { normalized.newFields = edit.newFields.map((field) => normalizeSectionField(customObject, sectionKey, `${tabKey}.${sectionKey}`, field), ); } return normalized; } function normalizeSystemTabEdit( customObject: CustomObject, tabKey: string, edit: SystemTabEdit, ): SerializedSystemTabEdit { const { newSections: _newSections, systemSectionEdits: _systemSectionEdits, ...rest } = edit; const normalized: SerializedSystemTabEdit = { ...rest }; if (edit.newSections != null) { normalized.newSections = edit.newSections.map((section) => normalizeSection(customObject, section)); } if (edit.systemSectionEdits != null) { const systemSectionEdits: Record = {}; for (const [sectionKey, sectionEdit] of Object.entries(edit.systemSectionEdits)) { systemSectionEdits[sectionKey] = normalizeSystemSectionEdit( customObject, tabKey, sectionKey, sectionEdit, ); } normalized.systemSectionEdits = systemSectionEdits; } return normalized; } function normalizeTabEdits(customObject: CustomObject, tabEdits: TabEdits): SerializedTabEdits { const { newTabs: _newTabs, systemTabEdits: _systemTabEdits, ...rest } = tabEdits; const normalized: SerializedTabEdits = { ...rest }; if (tabEdits.newTabs != null) { normalized.newTabs = tabEdits.newTabs.map((tab) => normalizeTab(customObject, tab)); } if (tabEdits.systemTabEdits != null) { const systemTabEdits: Record = {}; for (const [tabKey, tabEdit] of Object.entries(tabEdits.systemTabEdits)) { systemTabEdits[tabKey] = normalizeSystemTabEdit(customObject, tabKey, tabEdit); } normalized.systemTabEdits = systemTabEdits; } return normalized; } function cloneJson(value: T): T { if (value == null) return value; return JSON.parse(JSON.stringify(value)) as T; } const HEADER_EDIT_KEYS = new Set([ 'newTitleField', 'titleFieldDeleted', 'newDescriptionField', 'descriptionFieldDeleted', 'systemFieldEdits', 'newFields', 'headerFieldsOrder', 'buttons', ]); const SYSTEM_HEADER_FIELD_EDIT_KEYS = new Set(['newRqlField', 'deleted']); const NEW_HEADER_FIELD_KEYS = new Set(['rqlName', 'key', 'canBeDeleted', 'canBeChanged', 'canBeMoved']); function assertKnownKeys( record: Record, allowedKeys: ReadonlySet, label: string, hint: string, ): void { for (const key of Object.keys(record)) { if (!allowedKeys.has(key)) { throw new Error(`${label} contains unsupported key "${key}". ${hint}`); } } } function normalizeHeaderEdits(headerEdits: HeaderEdits): SerializedHeaderEdits { assertKnownKeys( headerEdits as unknown as Record, HEADER_EDIT_KEYS, 'CustomObjectPageLayout headerEdits', 'Use addHeaderField(), setTitleField(), setDescriptionField(), or the HeaderEdits keys documented by the SDK.', ); for (const [key, edit] of Object.entries(headerEdits.systemFieldEdits ?? {})) { assertKnownKeys( edit as Record, SYSTEM_HEADER_FIELD_EDIT_KEYS, `CustomObjectPageLayout headerEdits.systemFieldEdits.${key}`, 'Use newRqlField and deleted for system header field edits.', ); } for (const [index, field] of (headerEdits.newFields ?? []).entries()) { assertKnownKeys( field as unknown as Record, NEW_HEADER_FIELD_KEYS, `CustomObjectPageLayout headerEdits.newFields[${index}]`, 'Use { rqlName, key } entries, or call addHeaderField(field).', ); } if (headerEdits.newTitleField === '') { throw new Error( 'CustomObjectPageLayout headerEdits.newTitleField cannot be empty. Use titleFieldDeleted.', ); } if (headerEdits.newDescriptionField === '') { throw new Error( 'CustomObjectPageLayout headerEdits.newDescriptionField cannot be empty. Use descriptionFieldDeleted.', ); } if (headerEdits.newTitleField != null && headerEdits.titleFieldDeleted === true) { throw new Error( 'CustomObjectPageLayout headerEdits cannot both set newTitleField and titleFieldDeleted.', ); } if (headerEdits.newDescriptionField != null && headerEdits.descriptionFieldDeleted === true) { throw new Error( 'CustomObjectPageLayout headerEdits cannot both set newDescriptionField and descriptionFieldDeleted.', ); } if (headerEdits.headerFieldsOrder == null) return headerEdits; return { ...headerEdits, headerFieldsOrder: normalizeHeaderFieldsOrder(headerEdits), }; } function addUniqueHeaderOrderField(order: string[], field: unknown): void { if (typeof field !== 'string' || field.length === 0) return; if (!order.includes(field)) order.push(field); } function normalizeHeaderFieldsOrder(headerEdits: SerializedHeaderEdits): string[] { const customFieldRqlByKey = new Map( (headerEdits.newFields ?? []).map((field) => [field.key, field.rqlName]), ); const systemFieldRqlByKey = new Map( Object.entries(headerEdits.systemFieldEdits ?? {}) .filter((entry): entry is [string, SerializedSystemHeaderFieldEdit & { newRqlField: string }] => { const [, edit] = entry; return typeof edit.newRqlField === 'string' && edit.newRqlField.length > 0; }) .map(([key, edit]) => [key, edit.newRqlField]), ); const order: string[] = []; for (const entry of headerEdits.headerFieldsOrder ?? []) { addUniqueHeaderOrderField( order, customFieldRqlByKey.get(entry) ?? systemFieldRqlByKey.get(entry) ?? entry, ); } addUniqueHeaderOrderField(order, headerEdits.newTitleField); addUniqueHeaderOrderField(order, headerEdits.newDescriptionField); for (const field of headerEdits.newFields ?? []) { addUniqueHeaderOrderField(order, field.rqlName); } for (const systemEdit of Object.values(headerEdits.systemFieldEdits ?? {})) { addUniqueHeaderOrderField(order, systemEdit.newRqlField); } return order; } type MutableSectionField = SerializedSectionField | Record | string; interface MutableFieldContainer { tabKey: string; sectionKey: string | undefined; section?: SerializedFieldsSection; fields: MutableSectionField[]; label: string; } interface FieldSectionLayoutSection { tabKey: string; sectionKey: string; } function isTabWithSections(tab: SerializedPageLayoutTab): tab is SerializedTabWithSections { return tab.type === 'tab_with_sections'; } function insertionIndex(length: number, position: PageLayoutMutationPosition | undefined): number { if (position == null || position === 'end') return length; if (position === 'start') return 0; if (!Number.isInteger(position) || position < 0 || position > length) { throw new Error(`Invalid insertion position ${position}; expected 'start', 'end', or 0-${length}.`); } return position; } function insertAt(items: T[], item: T, position: PageLayoutMutationPosition | undefined): void { items.splice(insertionIndex(items.length, position), 0, item); } function insertUniqueAt( items: string[], item: string | null | undefined, position: PageLayoutMutationPosition | undefined, ): void { if (item == null || item.length === 0 || items.includes(item)) return; insertAt(items, item, position); } function fieldRqlNameOf(entry: unknown): string | undefined { if (typeof entry === 'string') return entry; if (entry == null || typeof entry !== 'object') return undefined; const record = entry as Record; const fieldRqlName = record['fieldRqlName'] ?? record['field_rql_name'] ?? record['apiName'] ?? record['api_name']; return typeof fieldRqlName === 'string' ? fieldRqlName : undefined; } function addFieldSectionLayoutSection( index: Map, fieldSectionId: string, entry: FieldSectionLayoutSection, ): void { const entries = index.get(fieldSectionId) ?? []; if ( !entries.some((existing) => existing.tabKey === entry.tabKey && existing.sectionKey === entry.sectionKey) ) { entries.push(entry); } index.set(fieldSectionId, entries); } function indexFieldsSectionInput( index: Map, tabKey: string, section: FieldsSection, ): void { addFieldSectionLayoutSection(index, section.section.getSectionId(), { tabKey, sectionKey: section.key ?? section.section.getSectionId(), }); } function indexPageLayoutSectionInput( index: Map, tabKey: string, section: PageLayoutSection, ): void { if (section.type === 'fields_section') indexFieldsSectionInput(index, tabKey, section); } function buildFieldSectionLayoutIndexFromProps( tabEdits: TabEdits | undefined, ): Map { const index = new Map(); if (tabEdits == null) return index; for (const tab of tabEdits.newTabs ?? []) { if (tab.type !== 'tab_with_sections') continue; for (const section of tab.sections) indexPageLayoutSectionInput(index, tab.key, section); } for (const [tabKey, tabEdit] of Object.entries(tabEdits.systemTabEdits ?? {})) { for (const section of tabEdit.newSections ?? []) { indexPageLayoutSectionInput(index, tabKey, section); } } return index; } function indexLoadedFieldsSection( index: Map, tabKey: string, section: SerializedFieldsSection, resolveFieldSectionId: ResolveFieldSectionId | undefined, ): void { const sectionKey = layoutSectionKey(section); if (sectionKey == null || resolveFieldSectionId == null) return; for (const field of section.fields ?? []) { const fieldRqlName = fieldRqlNameOf(field); if (fieldRqlName == null) continue; const fieldSectionId = resolveFieldSectionId(fieldRqlName); if (fieldSectionId != null) { addFieldSectionLayoutSection(index, fieldSectionId, { tabKey, sectionKey }); } } } function buildFieldSectionLayoutIndexFromLoadedTabEdits( tabEdits: SerializedTabEdits, resolveFieldSectionId: ResolveFieldSectionId | undefined, ): Map { const index = new Map(); for (const tab of tabEdits.newTabs ?? []) { if (!isTabWithSections(tab)) continue; for (const section of tab.sections) { if (isSerializedFieldsSection(section)) { indexLoadedFieldsSection(index, tab.key, section, resolveFieldSectionId); } } } for (const [tabKey, tabEdit] of Object.entries(tabEdits.systemTabEdits ?? {})) { for (const section of tabEdit.newSections ?? []) { if (isSerializedFieldsSection(section)) { indexLoadedFieldsSection(index, tabKey, section, resolveFieldSectionId); } } } return index; } function annotateLoadedFieldsSection(section: SerializedFieldsSection): SerializedFieldsSection { const sectionKey = layoutSectionKey(section); return sectionKey == null ? section : attachLayoutSectionKey(section, sectionKey); } function annotateLoadedTabEdits(tabEdits: SerializedTabEdits): SerializedTabEdits { for (const tab of tabEdits.newTabs ?? []) { if (!isTabWithSections(tab)) continue; tab.sections = tab.sections.map((section) => isSerializedFieldsSection(section) ? annotateLoadedFieldsSection(section) : section, ); } for (const tabEdit of Object.values(tabEdits.systemTabEdits ?? {})) { if (tabEdit.newSections != null) { tabEdit.newSections = tabEdit.newSections.map((section) => isSerializedFieldsSection(section) ? annotateLoadedFieldsSection(section) : section, ); } } return tabEdits; } function removeIndexes(items: T[], indexes: number[]): void { for (const index of [...indexes].sort((a, b) => b - a)) { items.splice(index, 1); } } function removeFromArray( items: T[], predicate: (item: T) => boolean, label: string, removeAll = true, ): number { const indexes: number[] = []; items.forEach((item, index) => { if (predicate(item)) indexes.push(index); }); if (indexes.length > 1 && !removeAll) { throw new Error(`${label} matched ${indexes.length} entries; pass removeAll: true to remove them all.`); } removeIndexes(items, removeAll ? indexes : indexes.slice(0, 1)); return removeAll ? indexes.length : Math.min(indexes.length, 1); } function removeKeyFromOrder(order: string[] | undefined, key: string): void { if (order == null) return; removeFromArray(order, (entry) => entry === key, `order key "${key}"`); } function ensureRecordProperty(record: Record, key: string, label: string): Record { const existing = record[key]; if (existing == null) { const next: Record = {}; record[key] = next; return next; } if (typeof existing !== 'object' || Array.isArray(existing)) { throw new Error(`${label} must be an object.`); } return existing as Record; } function ensureArrayProperty(record: Record, key: string, label: string): T[] { const existing = record[key]; if (existing == null) { const next: T[] = []; record[key] = next; return next; } if (!Array.isArray(existing)) { throw new Error(`${label} must be an array.`); } return existing as T[]; } // --------------------------------------------------------------------------- // Class // --------------------------------------------------------------------------- /** * Defines a page layout and registers it with the manifest. * * A page layout controls the tab structure, section groupings, header fields, * and visibility conditions on a custom object's record detail page. You would * normally define one layout per custom object using `apiName: 'default'`. * * For simple single-tab layouts, use the {@link CustomObjectPageLayout.basic} * factory instead of constructing the full props manually. * * > **Caution:** all keys inside `tabEdits` and `headerEdits` must be camelCase. * > Snake_case keys are silently dropped on the wire and produce an empty layout. * * @example * ```ts * new CustomObjectPageLayout(memberObj, { * apiName: 'default', * name: 'Member', * layoutId: 'layout_gym_member', * tabEdits: { * newTabs: [{ * key: 'member', name: 'Member', type: 'tab_with_sections', * sections: [{ * name: 'Profile', * type: 'fields_section', * section: profileSection, * fields: [{ field: 'name', editable: true }, memberEmail], * }], * }], * systemTabEdits: {}, * tabsOrder: ['member'], * }, * visibilityConditions: {}, * }); * ``` * * @see {@link CustomObjectPageLayout.basic} — factory for simple single-tab layouts. */ export class CustomObjectPageLayout { static readonly componentType = 'CUSTOM_OBJECT_PAGE_LAYOUT' as const; static toDeletionIdentifier(identifier: PageLayoutDeletionIdentifier): ManifestComponentDeletion { return { type: CustomObjectPageLayout.componentType, layout_id: identifier.layoutId, object_rql_name: identifier.objectRqlName, }; } private readonly _layoutId: string; private readonly _apiName: string; private readonly _customObjectApiName: string; private readonly _name: string; private readonly _tabEdits: SerializedTabEdits; private readonly _headerEdits: SerializedHeaderEdits; private readonly _visibilityConditions: VisibilityConditions; private readonly _blueprintKey: string | undefined; private readonly _fieldSectionLayoutSections: Map; private readonly _customObject: CustomObject; /** * @param customObject - The custom object this layout belongs to. * @param props - Initialization properties. * @throws {Error} If `apiName` or `name` is empty. */ constructor(customObject: CustomObject, props: CustomObjectPageLayoutProps) { if (!props.apiName) throw new Error('CustomObjectPageLayout: apiName cannot be empty'); if (!props.name) throw new Error('CustomObjectPageLayout: name cannot be empty'); this._layoutId = props.layoutId ?? generateId('layout'); this._apiName = props.apiName; this._customObjectApiName = customObject.getApiName(); this._customObject = customObject; this._name = props.name; this._tabEdits = normalizeTabEdits(customObject, props.tabEdits ?? {}); this._fieldSectionLayoutSections = buildFieldSectionLayoutIndexFromProps(props.tabEdits); this._headerEdits = normalizeHeaderEdits(props.headerEdits ?? {}); this._visibilityConditions = props.visibilityConditions ?? {}; this._blueprintKey = props.blueprintKey; customObject._register(this); } /** * Loads this page layout from the active existing-manifest JSON context. * * `id` resolves against `layout_id` first, then `api_name`. When loading by * api_name such as `'default'`, pass `customObjectApiName` if multiple objects * have a layout with the same api_name. */ static loadFromExisting( layoutIdOrApiName: string, options: CustomObjectPageLayoutLoadFromExistingOptions = {}, ): CustomObjectPageLayout { return getExistingManifestContext(options).loadPageLayout(layoutIdOrApiName, options); } /** @internal Hydrates a page layout from existing manifest wire JSON. */ static _fromExistingComponent( customObject: CustomObject, component: Record, resolveFieldSectionId?: ResolveFieldSectionId, ): CustomObjectPageLayout { const layout = Object.create(CustomObjectPageLayout.prototype) as CustomObjectPageLayout; const tabEdits = annotateLoadedTabEdits(cloneJson(component['tab_edits'] ?? {})); Object.assign(layout as Record, { _layoutId: requireStringComponentField(component, 'layout_id', CustomObjectPageLayout.componentType), _apiName: requireStringComponentField(component, 'api_name', CustomObjectPageLayout.componentType), _customObjectApiName: requireStringComponentField( component, 'object_rql_name', CustomObjectPageLayout.componentType, ), _customObject: customObject, _name: requireStringComponentField(component, 'name', CustomObjectPageLayout.componentType), _tabEdits: tabEdits, _headerEdits: cloneJson(component['header_edits'] ?? {}), _visibilityConditions: cloneJson(component['visibility_conditions'] ?? {}), _blueprintKey: component['blueprint_key'], _fieldSectionLayoutSections: buildFieldSectionLayoutIndexFromLoadedTabEdits( tabEdits, resolveFieldSectionId, ), }); customObject._register(layout); return layout; } /** * Returns the stable identifier for this layout record (e.g. `'layout_gym_member'`). */ getLayoutId(): string { return this._layoutId; } /** * Returns the api_name of this layout (e.g. `'default'`). */ getApiName(): string { return this._apiName; } /** * Returns the api_name of the custom object this layout belongs to. */ getCustomObjectApiName(): string { return this._customObjectApiName; } /** * Adds one field to an existing layout section. * * For loaded layouts, this mutates the preserved `tab_edits` JSON in place * and normalizes only the inserted field entry. */ addField(field: CustomObjectFieldInput, options: AddPageLayoutFieldOptions): this { return this.addFields([field], options); } /** * Adds multiple fields to an existing layout section. * * Fields are inserted in the order provided. For custom layout sections, * pass `section` to choose the typed field section. Use `sectionKey` only * when a field section maps to multiple layout sections. */ addFields(fields: CustomObjectFieldInput[], options: AddPageLayoutFieldsOptions): this { if (fields.length === 0) return this; let fieldSectionForMutation: CustomObjectFieldSection | undefined; const container = options.systemSection === true ? (() => { assertOptionAbsent( options, 'section', 'CustomObjectPageLayout addField section is only supported for custom layout sections.', ); assertOptionAbsent( options, 'fieldSection', 'CustomObjectPageLayout addField fieldSection is no longer supported. ' + 'Use sectionKey only for systemSection: true.', ); return this.ensureSystemSectionFieldContainer(options.tabKey, options.sectionKey); })() : (() => { assertOptionAbsent( options, 'fieldSection', 'CustomObjectPageLayout addField fieldSection is no longer supported. ' + 'Pass section: CustomObjectFieldSection instead.', ); const section = options.section == null ? undefined : requireFieldSectionOption(options.section, 'CustomObjectPageLayout addField section'); if (section != null) { this.assertFieldSectionBelongsToLayout(section); fieldSectionForMutation = section; } const sectionKey = options.sectionKey ?? (section == null ? undefined : this.layoutSectionKeyForFieldSection(options.tabKey, section)) ?? section?.getSectionId(); return this.requireOneFieldContainer(options.tabKey, sectionKey, section); })(); const entries = fields.map((field) => this.serializeFieldForMutation( field, container.sectionKey ?? container.label, options, fieldSectionForMutation, ), ); if (options.allowDuplicate !== true) { const existing = new Set(container.fields.map((entry) => fieldRqlNameOf(entry)).filter(Boolean)); const seen = new Set(); for (const entry of entries) { const fieldRqlName = entry.fieldRqlName; if (existing.has(fieldRqlName) || seen.has(fieldRqlName)) { throw new Error( `CustomObjectPageLayout section "${container.label}" already contains field "${fieldRqlName}".`, ); } seen.add(fieldRqlName); } } const index = insertionIndex(container.fields.length, options.position); container.fields.splice(index, 0, ...entries); return this; } /** * Removes a field from the layout's field sections. * * This only removes the field from the page layout. It does not delete the * `CUSTOM_OBJECT_FIELD` component from the manifest. */ removeField(field: Field | string, options: RemovePageLayoutFieldOptions = {}): this { const fieldApiName = this.fieldApiName(field); if (isField(field)) this.assertFieldBelongsToLayout(field); let sectionKey: string | undefined; if (options.systemSection === true) { assertOptionAbsent( options, 'section', 'CustomObjectPageLayout removeField section is only supported for custom layout sections.', ); sectionKey = options.sectionKey; } else { sectionKey = options.sectionKey; if (options.section != null) { const section = requireFieldSectionOption( options.section, 'CustomObjectPageLayout removeField section', ); this.assertFieldSectionBelongsToLayout(section); sectionKey ??= this.layoutSectionKeyForFieldSection(options.tabKey, section); sectionKey ??= section.getSectionId(); } } const containers = this.findFieldContainers({ tabKey: options.tabKey, sectionKey, systemSectionOnly: options.systemSection === true, }); const matches: Array<{ container: MutableFieldContainer; index: number }> = []; for (const container of containers) { container.fields.forEach((entry, index) => { if (fieldRqlNameOf(entry) === fieldApiName) { matches.push({ container, index }); } }); } if (matches.length === 0) { throw new Error(`CustomObjectPageLayout does not contain field "${fieldApiName}".`); } const removeAll = options.removeAll !== false; if (matches.length > 1 && !removeAll) { throw new Error( `CustomObjectPageLayout field "${fieldApiName}" matched ${matches.length} entries; ` + 'pass removeAll: true to remove them all.', ); } const removals = removeAll ? matches : matches.slice(0, 1); const byContainer = new Map(); for (const match of removals) { const indexes = byContainer.get(match.container) ?? []; indexes.push(match.index); byContainer.set(match.container, indexes); } for (const [container, indexes] of byContainer.entries()) { removeIndexes(container.fields, indexes); } return this; } /** Removes multiple fields from the layout's field sections. */ removeFields(fields: Array, options: RemovePageLayoutFieldOptions = {}): this { for (const field of fields) { this.removeField(field, options); } return this; } /** * Adds a section to an existing tab. * * Pass `systemTab: true` to add a new section to a built-in tab via * `systemTabEdits.newSections`. */ addSection(section: PageLayoutSection, options: AddPageLayoutSectionOptions): this { const normalized = this.normalizeSectionForMutation(section); if (options.systemTab === true) { const tabEdit = this.ensureSystemTabEdit(options.tabKey); const sections = ensureArrayProperty( tabEdit as Record, 'newSections', `systemTabEdits.${options.tabKey}.newSections`, ); this.assertSectionKeyAvailable( sections, layoutSectionKey(normalized), options.tabKey, options.allowDuplicate, ); const sectionsOrder = this.ensureSystemSectionsOrder(tabEdit); insertAt(sections, normalized, options.position); insertUniqueAt(sectionsOrder, layoutSectionKey(normalized), options.position); if (section.type === 'fields_section') { this.rememberFieldSectionLayoutSection(section.section, options.tabKey, layoutSectionKey(normalized)); } return this; } const tab = this.findNewTabWithSections(options.tabKey); if (tab == null) { throw new Error( `CustomObjectPageLayout could not find new tab "${options.tabKey}". ` + 'Pass systemTab: true to add a section to a built-in system tab.', ); } this.assertSectionKeyAvailable( tab.sections, layoutSectionKey(normalized), options.tabKey, options.allowDuplicate, ); insertAt(tab.sections, normalized, options.position); if (section.type === 'fields_section') { this.rememberFieldSectionLayoutSection(section.section, tab.key, layoutSectionKey(normalized)); } return this; } /** * Removes a section from `newTabs` / `systemTabEdits.newSections`. * * Pass `systemSection: true` with `tabKey` to delete a built-in section via * `systemSectionEdits[sectionKey].deleted`. */ removeSection(section: CustomObjectFieldSection, options?: RemovePageLayoutSectionOptions): this; removeSection(sectionKey: string, options?: RemovePageLayoutSectionOptions): this; removeSection(sectionKey: string, options: RemoveSystemPageLayoutSectionOptions): this; removeSection( sectionOrKey: CustomObjectFieldSection | string, options: RemovePageLayoutSectionOptions | RemoveSystemPageLayoutSectionOptions = {}, ): this { if (options.systemSection === true) { if (typeof sectionOrKey !== 'string') { throw new TypeError( 'CustomObjectPageLayout removeSection with systemSection: true requires a system section key.', ); } if (options.tabKey == null) { throw new Error('CustomObjectPageLayout removeSection with systemSection: true requires tabKey.'); } const sectionEdit = this.ensureSystemSectionEdit(options.tabKey, sectionOrKey); sectionEdit.deleted = true; removeKeyFromOrder(this._tabEdits.systemTabEdits?.[options.tabKey]?.sectionsOrder, sectionOrKey); this.removeSectionVisibility(sectionOrKey); return this; } let sectionKey: string; if (typeof sectionOrKey === 'string') { sectionKey = sectionOrKey; } else { const section = requireFieldSectionOption(sectionOrKey, 'CustomObjectPageLayout removeSection section'); this.assertFieldSectionBelongsToLayout(section); sectionKey = this.layoutSectionKeyForFieldSection(options.tabKey, section) ?? section.getSectionId(); } const matches: Array<{ sections: SerializedPageLayoutSection[]; section: SerializedFieldsSection; index: number; tabEdit?: SerializedSystemTabEdit; tabKey: string; }> = []; for (const tab of this._tabEdits.newTabs ?? []) { if (!isTabWithSections(tab)) continue; if (options.tabKey != null && tab.key !== options.tabKey) continue; tab.sections.forEach((section, index) => { if (isSerializedFieldsSection(section) && layoutSectionMatches(section, sectionKey)) matches.push({ sections: tab.sections, section, index, tabKey: tab.key }); }); } for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) { if (options.tabKey != null && tabKey !== options.tabKey) continue; (tabEdit.newSections ?? []).forEach((section, index) => { if (isSerializedFieldsSection(section) && layoutSectionMatches(section, sectionKey)) matches.push({ sections: tabEdit.newSections!, section, index, tabEdit, tabKey }); }); } if (matches.length === 0) { throw new Error(`CustomObjectPageLayout does not contain section "${sectionKey}".`); } const removeAll = options.removeAll !== false; if (matches.length > 1 && !removeAll) { throw new Error( `CustomObjectPageLayout section "${sectionKey}" matched ${matches.length} entries; ` + 'pass removeAll: true to remove them all.', ); } const removals = removeAll ? matches : matches.slice(0, 1); const byArray = new Map(); for (const match of removals) { const indexes = byArray.get(match.sections) ?? []; indexes.push(match.index); byArray.set(match.sections, indexes); if (match.tabEdit != null) removeKeyFromOrder(match.tabEdit.sectionsOrder, sectionKey); this.removeSectionVisibility(sectionKey); } for (const [sections, indexes] of byArray.entries()) { removeIndexes(sections, indexes); } return this; } /** Adds a new tab to `tab_edits.newTabs`. */ addTab(tab: PageLayoutTab, options: AddPageLayoutTabOptions = {}): this { const normalized = this.normalizeTabForMutation(tab); const newTabs = (this._tabEdits.newTabs ??= []); if (options.allowDuplicate !== true && newTabs.some((existing) => existing.key === normalized.key)) { throw new Error(`CustomObjectPageLayout already contains tab "${normalized.key}".`); } const tabsOrder = this.ensureTabsOrder(); insertAt(newTabs, normalized, options.position); insertUniqueAt(tabsOrder, normalized.key, options.position); if (tab.type === 'tab_with_sections') { for (const section of tab.sections) indexPageLayoutSectionInput(this._fieldSectionLayoutSections, tab.key, section); } return this; } /** * Removes a tab from `newTabs`. * * Pass `systemTab: true` to delete a built-in tab via `systemTabEdits[tabKey].deleted`. */ removeTab(tabKey: string, options: RemovePageLayoutTabOptions = {}): this { if (options.systemTab === true) { const tabEdit = this.ensureSystemTabEdit(tabKey); tabEdit.deleted = true; removeKeyFromOrder(this._tabEdits.tabsOrder, tabKey); this.removeTabVisibility(tabKey); return this; } const newTabs = this._tabEdits.newTabs ?? []; const removed = removeFromArray( newTabs, (tab) => tab.key === tabKey, `CustomObjectPageLayout tab "${tabKey}"`, options.removeAll !== false, ); if (removed === 0) { throw new Error(`CustomObjectPageLayout does not contain tab "${tabKey}".`); } removeKeyFromOrder(this._tabEdits.tabsOrder, tabKey); this.removeTabVisibility(tabKey); return this; } /** Adds a custom header field to `header_edits.newFields`. */ addHeaderField(field: Field | NewHeaderField, options: AddHeaderFieldOptions = {}): this { const headerField = this.normalizeHeaderFieldForMutation(field, options); const newFields = (this._headerEdits.newFields ??= []); if (options.allowDuplicate !== true && newFields.some((existing) => existing.key === headerField.key)) { throw new Error(`CustomObjectPageLayout header already contains field key "${headerField.key}".`); } const headerFieldsOrder = this.ensureHeaderFieldsOrder(); insertAt(newFields, headerField, options.position); insertUniqueAt(headerFieldsOrder, headerField.rqlName, options.position); return this; } /** Removes custom header fields from `header_edits.newFields`. */ removeHeaderField(fieldOrKey: Field | string, options: RemoveHeaderFieldOptions = {}): this { const fieldApiName = typeof fieldOrKey === 'string' ? undefined : fieldOrKey.getApiName(); if (isField(fieldOrKey)) this.assertFieldBelongsToLayout(fieldOrKey); const key = typeof fieldOrKey === 'string' ? fieldOrKey : fieldApiName; const newFields = this._headerEdits.newFields ?? []; const removedFields: NewHeaderField[] = []; const removed = removeFromArray( newFields, (field) => { const matched = field.key === key || (fieldApiName != null && field.rqlName === fieldApiName); if (matched) removedFields.push(field); return matched; }, `CustomObjectPageLayout header field "${key}"`, options.removeAll !== false, ); if (removed === 0) { throw new Error(`CustomObjectPageLayout header does not contain field "${key}".`); } for (const removedField of removedFields) this.removeHeaderOrderFieldIfUnused(removedField); return this; } /** Sets the record title field in `header_edits`. */ setTitleField(field: Field): this; setTitleField(fieldApiName: FieldRqlName): this; setTitleField(field: Field | FieldRqlName): this { const previousTitleField = this._headerEdits.newTitleField; const nextTitleField = this.headerFieldApiName(field); this._headerEdits.newTitleField = nextTitleField; this._headerEdits.titleFieldDeleted = false; insertUniqueAt(this.ensureHeaderFieldsOrder(), nextTitleField, 'end'); this.removeHeaderOrderFieldIfUnused(previousTitleField); return this; } /** Removes the record title field from the header. */ removeTitleField(): this { const previousTitleField = this._headerEdits.newTitleField; this._headerEdits.newTitleField = null; this._headerEdits.titleFieldDeleted = true; this.removeHeaderOrderFieldIfUnused(previousTitleField); return this; } /** Sets the record description field in `header_edits`. */ setDescriptionField(field: Field): this; setDescriptionField(fieldApiName: FieldRqlName): this; setDescriptionField(field: Field | FieldRqlName): this { const previousDescriptionField = this._headerEdits.newDescriptionField; const nextDescriptionField = this.headerFieldApiName(field); this._headerEdits.newDescriptionField = nextDescriptionField; this._headerEdits.descriptionFieldDeleted = false; insertUniqueAt(this.ensureHeaderFieldsOrder(), nextDescriptionField, 'end'); this.removeHeaderOrderFieldIfUnused(previousDescriptionField); return this; } /** Removes the record description field from the header. */ removeDescriptionField(): this { const previousDescriptionField = this._headerEdits.newDescriptionField; this._headerEdits.newDescriptionField = null; this._headerEdits.descriptionFieldDeleted = true; this.removeHeaderOrderFieldIfUnused(previousDescriptionField); return this; } private ensureTabsOrder(): string[] { const order = (this._tabEdits.tabsOrder ??= []); for (const tab of this._tabEdits.newTabs ?? []) insertUniqueAt(order, tab.key, 'end'); for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) { if (tabEdit.deleted !== true) insertUniqueAt(order, tabKey, 'end'); } return order; } private ensureSystemSectionsOrder(tabEdit: SerializedSystemTabEdit): string[] { const order = (tabEdit.sectionsOrder ??= []); for (const section of tabEdit.newSections ?? []) insertUniqueAt(order, layoutSectionKey(section), 'end'); for (const [sectionKey, sectionEdit] of Object.entries(tabEdit.systemSectionEdits ?? {})) { if (sectionEdit.deleted !== true) insertUniqueAt(order, sectionKey, 'end'); } return order; } private ensureHeaderFieldsOrder(): string[] { this._headerEdits.headerFieldsOrder = normalizeHeaderFieldsOrder({ ...this._headerEdits, headerFieldsOrder: this._headerEdits.headerFieldsOrder ?? [], }); return this._headerEdits.headerFieldsOrder; } private headerOrderFieldIsUsed(rqlName: string): boolean { return ( this._headerEdits.newTitleField === rqlName || this._headerEdits.newDescriptionField === rqlName || (this._headerEdits.newFields ?? []).some((field) => field.rqlName === rqlName) || Object.values(this._headerEdits.systemFieldEdits ?? {}).some((edit) => edit.newRqlField === rqlName) ); } private removeHeaderOrderFieldIfUnused(field: NewHeaderField | string | null | undefined): void { const rqlName = typeof field === 'string' ? field : field?.rqlName; if (field != null && typeof field !== 'string' && field.key !== rqlName) { removeKeyFromOrder(this._headerEdits.headerFieldsOrder, field.key); } if (rqlName == null || this.headerOrderFieldIsUsed(rqlName)) return; removeKeyFromOrder(this._headerEdits.headerFieldsOrder, rqlName); } private serializeFieldForMutation( field: CustomObjectFieldInput, fallbackSectionKey: string, options: AddPageLayoutFieldOptions, fieldSection: CustomObjectFieldSection | undefined, ): SerializedSectionField { const resolved = this._resolveField(field); this.assertFieldBelongsToLayout(resolved); this.assignFieldSectionForMutation(resolved, fallbackSectionKey, fieldSection); return toSerializedSectionField(resolved.getApiName(), options); } private normalizeSectionFieldForMutation( section: CustomObjectFieldSection | string, sectionKey: string, entry: SectionFieldInput, ): SerializedSectionField { if (isField(entry) || typeof entry === 'string') { const field = this._resolveField(entry); this.assertFieldBelongsToLayout(field); assignSectionForLayout(field, section); return toSerializedSectionField(field.getApiName()); } if (isFieldRef(entry)) { const field = this._resolveField(entry.field); this.assertFieldBelongsToLayout(field); assignSectionForLayout(field, section); return toSerializedSectionField(field.getApiName(), entry); } throw new Error( `CustomObjectPageLayout fields_section "${sectionKey}" has an invalid field entry; ` + 'pass a Field instance or { field }.', ); } private normalizeSectionForMutation(input: PageLayoutSection): SerializedPageLayoutSection { if (input.type !== 'fields_section') return { ...input }; this.assertFieldSectionBelongsToLayout(input.section); const sectionKey = input.key ?? input.section.getSectionId(); const name = input.name ?? input.section.getName(); const normalized = copySectionOptions( { name, type: 'fields_section', fields: input.fields.map((field) => this.normalizeSectionFieldForMutation(input.section, sectionKey, field), ), }, input, ); if (input.layout != null) normalized.layout = input.layout; return attachLayoutSectionKey(normalized, sectionKey); } private normalizeTabForMutation(tab: PageLayoutTab): SerializedPageLayoutTab { if (tab.type === 'custom_tab') return { ...tab }; return { ...tab, sections: tab.sections.map((section) => this.normalizeSectionForMutation(section)), }; } private assignFieldSectionForMutation( field: Field, fallbackSectionKey: string, fieldSection: CustomObjectFieldSection | undefined, ): void { if (field.isStandard()) return; const rawFieldSection = fieldSection as CustomObjectFieldSection | string | undefined; if (rawFieldSection != null) { if (typeof rawFieldSection === 'string') { throw new TypeError( `CustomObjectPageLayout section must be a CustomObjectFieldSection object; ` + `received raw section id "${rawFieldSection}". Load/include the existing field section first.`, ); } if (rawFieldSection.getModelApiName() !== this._customObjectApiName) { throw new Error( `CustomObjectPageLayout field section "${rawFieldSection.getSectionId()}" belongs to ` + `"${rawFieldSection.getModelApiName()}", but the layout belongs to "${ this._customObjectApiName }".`, ); } field._assignSection(rawFieldSection); return; } if (field.getSectionId() == null) { throw new Error( `CustomObjectPageLayout field "${field.getApiName()}" is missing field-section metadata. ` + `Construct the field with a CustomObjectFieldSection or pass section when adding it to ` + `section "${fallbackSectionKey}".`, ); } } private assertFieldBelongsToLayout(field: Field): void { if (field.getCustomObjectApiName() !== this._customObjectApiName) { throw new Error( `CustomObjectPageLayout references field "${field.getApiName()}" from ` + `"${field.getCustomObjectApiName()}", but the layout belongs to "${this._customObjectApiName}".`, ); } } private _resolveField(field: CustomObjectFieldInput): Field { return this._customObject._resolveField(field); } private fieldApiName(field: Field | string): FieldRqlName { if (typeof field !== 'string') return field.getApiName(); if (field.endsWith('__c')) return field; return this._customObject.standardField(field as CustomObjectStandardFieldIdentifier).getApiName(); } private assertFieldSectionBelongsToLayout(section: CustomObjectFieldSection): void { if (section.getModelApiName() !== this._customObjectApiName) { throw new Error( `CustomObjectPageLayout field section "${section.getSectionId()}" belongs to ` + `"${section.getModelApiName()}", but the layout belongs to "${this._customObjectApiName}".`, ); } } private rememberFieldSectionLayoutSection( section: CustomObjectFieldSection, tabKey: string, sectionKey: string | undefined, ): void { if (sectionKey == null) return; addFieldSectionLayoutSection(this._fieldSectionLayoutSections, section.getSectionId(), { tabKey, sectionKey, }); } private layoutSectionKeyForFieldSection( tabKey: string | undefined, section: CustomObjectFieldSection, ): string | undefined { const entries = this._fieldSectionLayoutSections.get(section.getSectionId()) ?? []; const matches = tabKey == null ? entries : entries.filter((entry) => entry.tabKey === tabKey); const sectionKeys = [...new Set(matches.map((entry) => entry.sectionKey))]; if (sectionKeys.length === 0) return undefined; if (sectionKeys.length === 1) return sectionKeys[0]; const scope = tabKey == null ? '' : ` in tab "${tabKey}"`; throw new Error( `CustomObjectPageLayout field section "${section.getSectionId()}" maps to multiple layout sections${scope}: ` + `${sectionKeys.join(', ')}. Pass sectionKey to choose one.`, ); } private requireOneFieldContainer( tabKey: string | undefined, sectionKey: string | undefined, fallbackFieldSection?: CustomObjectFieldSection, ): MutableFieldContainer { const containers = this.findFieldContainers({ tabKey, sectionKey, systemSectionOnly: false, }); if (containers.length === 0) { if (sectionKey != null && fallbackFieldSection != null) { const unkeyedContainers = this.findFieldContainers({ tabKey, sectionKey: undefined, systemSectionOnly: false, }).filter((container) => container.section != null && container.sectionKey == null); if (unkeyedContainers.length === 1) { if (fallbackFieldSection.getModelApiName() !== this._customObjectApiName) { throw new Error( `CustomObjectPageLayout field section "${fallbackFieldSection.getSectionId()}" belongs to ` + `"${fallbackFieldSection.getModelApiName()}", but the layout belongs to "${ this._customObjectApiName }".`, ); } const container = unkeyedContainers[0] as MutableFieldContainer & { section: SerializedFieldsSection; }; attachLayoutSectionKey(container.section, sectionKey); container.sectionKey = sectionKey; container.label = `${container.tabKey}.${container.sectionKey}`; return container; } } const scope = sectionKey == null ? 'a field section' : ( `section "${tabKey != null ? `${tabKey}.` : ''}${sectionKey}"` ); throw new Error(`CustomObjectPageLayout could not find ${scope}.`); } if (containers.length > 1) { if (sectionKey == null) { throw new Error( `CustomObjectPageLayout matched multiple sections: ` + `${containers.map((container) => container.label).join(', ')}. Pass section to choose one.`, ); } throw new Error( `CustomObjectPageLayout section "${sectionKey}" matched multiple sections: ` + `${containers.map((container) => container.label).join(', ')}. Pass tabKey to choose one.`, ); } return containers[0] as MutableFieldContainer; } private findFieldContainers(filter: { tabKey: string | undefined; sectionKey: string | undefined; systemSectionOnly: boolean; }): MutableFieldContainer[] { const containers: MutableFieldContainer[] = []; if (!filter.systemSectionOnly) { for (const tab of this._tabEdits.newTabs ?? []) { if (!isTabWithSections(tab)) continue; if (filter.tabKey != null && tab.key !== filter.tabKey) continue; for (const [index, section] of tab.sections.entries()) { if (!isSerializedFieldsSection(section)) continue; const sectionKey = layoutSectionKey(section); if (filter.sectionKey != null && !layoutSectionMatches(section, filter.sectionKey)) continue; const label = sectionKey == null ? `${tab.key}.sections[${index}]` : `${tab.key}.${sectionKey}`; containers.push({ tabKey: tab.key, sectionKey, section, fields: ensureArrayProperty( section as unknown as Record, 'fields', `newTabs.${label}.fields`, ), label, }); } } for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) { if (filter.tabKey != null && tabKey !== filter.tabKey) continue; for (const [index, section] of (tabEdit.newSections ?? []).entries()) { if (!isSerializedFieldsSection(section)) continue; const sectionKey = layoutSectionKey(section); if (filter.sectionKey != null && !layoutSectionMatches(section, filter.sectionKey)) continue; const label = sectionKey == null ? `${tabKey}.newSections[${index}]` : `${tabKey}.${sectionKey}`; containers.push({ tabKey, sectionKey, section, fields: ensureArrayProperty( section as unknown as Record, 'fields', `systemTabEdits.${label}.fields`, ), label, }); } } } for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) { if (filter.tabKey != null && tabKey !== filter.tabKey) continue; for (const [sectionKey, sectionEdit] of Object.entries(tabEdit.systemSectionEdits ?? {})) { if (filter.sectionKey != null && sectionKey !== filter.sectionKey) continue; const fields = (sectionEdit as Record)['newFields']; if (fields == null) continue; if (!Array.isArray(fields)) { throw new Error( `systemTabEdits.${tabKey}.systemSectionEdits.${sectionKey}.newFields must be an array.`, ); } containers.push({ tabKey, sectionKey, fields: fields as MutableSectionField[], label: `${tabKey}.${sectionKey}`, }); } } return containers; } private ensureSystemSectionFieldContainer( tabKey: string | undefined, sectionKey: string | undefined, ): MutableFieldContainer { if (tabKey == null) { throw new Error('CustomObjectPageLayout addField with systemSection: true requires tabKey.'); } if (sectionKey == null) { throw new Error('CustomObjectPageLayout addField with systemSection: true requires sectionKey.'); } const sectionEdit = this.ensureSystemSectionEdit(tabKey, sectionKey); if (sectionEdit.deleted === true) { throw new Error(`CustomObjectPageLayout system section "${tabKey}.${sectionKey}" is marked deleted.`); } return { tabKey, sectionKey, fields: ensureArrayProperty( sectionEdit as Record, 'newFields', `systemTabEdits.${tabKey}.systemSectionEdits.${sectionKey}.newFields`, ), label: `${tabKey}.${sectionKey}`, }; } private ensureSystemTabEdit(tabKey: string): SerializedSystemTabEdit { const systemTabEdits = ensureRecordProperty( this._tabEdits as unknown as Record, 'systemTabEdits', 'tab_edits.systemTabEdits', ); return ensureRecordProperty( systemTabEdits, tabKey, `systemTabEdits.${tabKey}`, ) as SerializedSystemTabEdit; } private ensureSystemSectionEdit(tabKey: string, sectionKey: string): SerializedSystemSectionEdit { const tabEdit = this.ensureSystemTabEdit(tabKey); const systemSectionEdits = ensureRecordProperty( tabEdit as Record, 'systemSectionEdits', `systemTabEdits.${tabKey}.systemSectionEdits`, ); return ensureRecordProperty( systemSectionEdits, sectionKey, `systemTabEdits.${tabKey}.systemSectionEdits.${sectionKey}`, ) as SerializedSystemSectionEdit; } private findNewTabWithSections(tabKey: string): SerializedTabWithSections | undefined { let found: SerializedTabWithSections | undefined; for (const tab of this._tabEdits.newTabs ?? []) { if (tab.key !== tabKey) continue; if (!isTabWithSections(tab)) { throw new Error( `CustomObjectPageLayout tab "${tabKey}" is a custom_tab and cannot contain sections.`, ); } if (found != null) { throw new Error(`CustomObjectPageLayout contains multiple new tabs with key "${tabKey}".`); } found = tab; } return found; } private assertSectionKeyAvailable( sections: SerializedPageLayoutSection[], sectionKey: string | undefined, tabKey: string, allowDuplicate: boolean | undefined, ): void { if (allowDuplicate === true) return; if (sectionKey != null && sections.some((section) => layoutSectionMatches(section, sectionKey))) { throw new Error(`CustomObjectPageLayout tab "${tabKey}" already contains section "${sectionKey}".`); } } private normalizeHeaderFieldForMutation( field: Field | NewHeaderField, options: AddHeaderFieldOptions, ): NewHeaderField { const source = isField(field) ? undefined : field; const rqlName = isField(field) ? field.getApiName() : field.rqlName; if (isField(field)) this.assertFieldBelongsToLayout(field); if (!rqlName) throw new Error('CustomObjectPageLayout header field rqlName cannot be empty.'); const key = options.key ?? source?.key ?? (isField(field) ? field.getApiName() : undefined); if (!key) throw new Error('CustomObjectPageLayout header field key cannot be empty.'); const normalized: NewHeaderField = { rqlName, key }; const canBeDeleted = options.canBeDeleted ?? source?.canBeDeleted; const canBeChanged = options.canBeChanged ?? source?.canBeChanged; const canBeMoved = options.canBeMoved ?? source?.canBeMoved; if (canBeDeleted != null) normalized.canBeDeleted = canBeDeleted; if (canBeChanged != null) normalized.canBeChanged = canBeChanged; if (canBeMoved != null) normalized.canBeMoved = canBeMoved; return normalized; } private headerFieldApiName(field: Field | FieldRqlName): FieldRqlName { if (typeof field === 'string') return this.fieldApiName(field); this.assertFieldBelongsToLayout(field); return field.getApiName(); } private removeSectionVisibility(sectionKey: string): void { if (this._visibilityConditions.sections != null) { delete this._visibilityConditions.sections[sectionKey]; } } private removeTabVisibility(tabKey: string): void { if (this._visibilityConditions.tabs != null) { delete this._visibilityConditions.tabs[tabKey]; } } /** * Serializes this layout to the wire format consumed by the manifest install endpoint. * * @returns A plain object with `type: 'CUSTOM_OBJECT_PAGE_LAYOUT'`. */ toDict(): Record { const d: Record = { type: CustomObjectPageLayout.componentType, layout_id: this._layoutId, api_name: this._apiName, object_rql_name: this._customObjectApiName, name: this._name, // Packaged layouts are never system-created (backend invariant). system_created: false, tab_edits: this._tabEdits, header_edits: this._headerEdits, visibility_conditions: this._visibilityConditions, blueprint_key: this._blueprintKey ?? this._customObjectApiName, }; return d; } /** * Creates a simple single-tab "Details" layout with a single "General" section. * * Use this factory when you want a straightforward layout without customizing * tabs or sections. Always uses `apiName: 'default'`. * * @param customObject - The custom object to create the layout for. * @param props - Optional. Pass `section` and `fields` to populate the General section. * @returns A new `CustomObjectPageLayout` registered with the manifest. * * @example * ```ts * CustomObjectPageLayout.basic(memberObj, { * section: profileSection, * fields: [memberFirstName, memberEmail, memberStatusField], * }); * ``` */ static basic( customObject: CustomObject, props: { section?: CustomObjectFieldSection; fields?: CustomObjectFieldInput[]; } = {}, ): CustomObjectPageLayout { const coApiName = customObject.getApiName(); const coName = customObject.getName(); const fields = (props.fields ?? []).map((field) => customObject._resolveField(field)); const generalSection = props.section ?? (() => { const alreadySectionedField = fields.find((field) => field.getSectionId() != null); if (alreadySectionedField != null) { throw new Error( `CustomObjectPageLayout.basic field "${alreadySectionedField.getApiName()}" already belongs to ` + `field section "${alreadySectionedField.getSectionId()}". Pass that CustomObjectFieldSection ` + 'as section so basic() does not reassign the field.', ); } const sectionPrefix = coApiName.replace(/__c$/, '').replace(/[^a-zA-Z0-9]+/g, '_'); return new CustomObjectFieldSection(customObject, { sectionId: `sec_${sectionPrefix}_general`, name: 'General', }); })(); assertSectionBelongsTo(customObject, generalSection); for (const field of fields) { const fieldSectionId = field.getSectionId(); if (fieldSectionId != null && fieldSectionId !== generalSection.getSectionId()) { throw new Error( `CustomObjectPageLayout.basic field "${field.getApiName()}" belongs to field section ` + `"${fieldSectionId}", but basic() was given section "${generalSection.getSectionId()}".`, ); } } return new CustomObjectPageLayout(customObject, { apiName: 'default', name: `${coName} Layout`, tabEdits: { newTabs: [ { key: 'details', name: 'Details', type: 'tab_with_sections', sections: [ { name: 'General', type: 'fields_section', section: generalSection, fields, }, ], }, ], systemTabEdits: {}, tabsOrder: ['details'], }, headerEdits: {}, visibilityConditions: {}, }); } }