@rippling/rippling-sdk 0.2.0-alpha.39 → 0.2.0-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -219,6 +219,130 @@ export interface VisibilityConditions {
219
219
  tabs?: Record<string, VisibilityCondition>;
220
220
  }
221
221
 
222
+ // ---------------------------------------------------------------------------
223
+ // Mutation helpers
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** Where to insert a new layout item. Defaults to `'end'`. */
227
+ export type PageLayoutMutationPosition = 'start' | 'end' | number;
228
+
229
+ /** Options for adding a field to an existing layout section. */
230
+ export interface AddPageLayoutFieldOptions extends SectionFieldOptions {
231
+ /**
232
+ * Tab key containing the target section. Optional when `sectionKey` is unique
233
+ * across the layout.
234
+ */
235
+ tabKey?: string;
236
+ /** Section key to add the field to. Required. */
237
+ sectionKey: string;
238
+ /**
239
+ * Field-section metadata to assign to the field component.
240
+ *
241
+ * Existing layouts often use layout section keys that differ from field section
242
+ * ids. Pass this when the field was not constructed with a `section`.
243
+ */
244
+ fieldSection?: CustomObjectFieldSection | string;
245
+ /** Insert position inside the section field list. Optional. @default `'end'` */
246
+ position?: PageLayoutMutationPosition;
247
+ /** Allow the same field to appear twice in the same section. Optional. @default false */
248
+ allowDuplicate?: boolean;
249
+ /**
250
+ * Add to a built-in system section via `systemTabEdits`.
251
+ *
252
+ * When true, `tabKey` is required and the edit is created if it does not
253
+ * already exist.
254
+ */
255
+ systemSection?: boolean;
256
+ }
257
+
258
+ /** Options for adding multiple fields to an existing layout section. */
259
+ export type AddPageLayoutFieldsOptions = AddPageLayoutFieldOptions;
260
+
261
+ /** Options for removing fields from a layout. */
262
+ export interface RemovePageLayoutFieldOptions {
263
+ /** Limit removal to one tab. Optional. */
264
+ tabKey?: string;
265
+ /** Limit removal to one section. Optional. */
266
+ sectionKey?: string;
267
+ /**
268
+ * Remove all matching occurrences. When false, removing a field that appears
269
+ * more than once throws. Optional. @default true
270
+ */
271
+ removeAll?: boolean;
272
+ /** Remove from a built-in system section edit. Optional. */
273
+ systemSection?: boolean;
274
+ }
275
+
276
+ /** Options for adding a section to an existing tab. */
277
+ export interface AddPageLayoutSectionOptions {
278
+ /** Tab key to add the section to. Required. */
279
+ tabKey: string;
280
+ /** Insert position inside the tab. Optional. @default `'end'` */
281
+ position?: PageLayoutMutationPosition;
282
+ /** Allow another section with the same key in the same tab. Optional. @default false */
283
+ allowDuplicate?: boolean;
284
+ /**
285
+ * Add to a built-in system tab via `systemTabEdits.newSections`.
286
+ *
287
+ * When false, the tab must already be present in `newTabs`.
288
+ */
289
+ systemTab?: boolean;
290
+ }
291
+
292
+ /** Options for removing sections from a layout. */
293
+ export interface RemovePageLayoutSectionOptions {
294
+ /** Limit removal to one tab. Optional. */
295
+ tabKey?: string;
296
+ /**
297
+ * Remove all matching occurrences. When false, removing a section that appears
298
+ * more than once throws. Optional. @default true
299
+ */
300
+ removeAll?: boolean;
301
+ /** Delete a built-in system section via `systemSectionEdits`. Requires `tabKey`. */
302
+ systemSection?: boolean;
303
+ }
304
+
305
+ /** Options for adding a tab to a layout. */
306
+ export interface AddPageLayoutTabOptions {
307
+ /** Insert position inside `newTabs`. Optional. @default `'end'` */
308
+ position?: PageLayoutMutationPosition;
309
+ /** Allow another tab with the same key. Optional. @default false */
310
+ allowDuplicate?: boolean;
311
+ }
312
+
313
+ /** Options for removing a tab from a layout. */
314
+ export interface RemovePageLayoutTabOptions {
315
+ /**
316
+ * Remove all matching occurrences. When false, removing a tab that appears
317
+ * more than once throws. Optional. @default true
318
+ */
319
+ removeAll?: boolean;
320
+ /** Delete a built-in system tab via `systemTabEdits`. */
321
+ systemTab?: boolean;
322
+ }
323
+
324
+ /** Options for adding a custom header field. */
325
+ export interface AddHeaderFieldOptions extends Omit<NewHeaderField, 'rqlName' | 'key'> {
326
+ /**
327
+ * Stable header key. Defaults to the field api_name when adding a `Field`.
328
+ * Required when adding a raw `NewHeaderField`.
329
+ */
330
+ key?: string;
331
+ /** Insert position inside `newFields` and `headerFieldsOrder`. Optional. @default `'end'` */
332
+ position?: PageLayoutMutationPosition;
333
+ /** Allow another custom header field with the same key. Optional. @default false */
334
+ allowDuplicate?: boolean;
335
+ }
336
+
337
+ /** Options for removing custom header fields. */
338
+ export interface RemoveHeaderFieldOptions {
339
+ /**
340
+ * Remove all matching occurrences. When false, removing a header field that
341
+ * appears more than once throws. Optional. @default true
342
+ */
343
+ removeAll?: boolean;
344
+ }
345
+
222
346
  // ---------------------------------------------------------------------------
223
347
  // Public props
224
348
  // ---------------------------------------------------------------------------
@@ -474,6 +598,94 @@ function cloneJson<T>(value: T): T {
474
598
  return JSON.parse(JSON.stringify(value)) as T;
475
599
  }
476
600
 
601
+ type MutableSectionField = SerializedSectionField | Record<string, any> | string;
602
+
603
+ interface MutableFieldContainer {
604
+ tabKey: string;
605
+ sectionKey: string;
606
+ fields: MutableSectionField[];
607
+ label: string;
608
+ }
609
+
610
+ function isTabWithSections(tab: SerializedPageLayoutTab): tab is SerializedTabWithSections {
611
+ return tab.type === 'tab_with_sections';
612
+ }
613
+
614
+ function insertionIndex(length: number, position: PageLayoutMutationPosition | undefined): number {
615
+ if (position == null || position === 'end') return length;
616
+ if (position === 'start') return 0;
617
+ if (!Number.isInteger(position) || position < 0 || position > length) {
618
+ throw new Error(`Invalid insertion position ${position}; expected 'start', 'end', or 0-${length}.`);
619
+ }
620
+ return position;
621
+ }
622
+
623
+ function insertAt<T>(items: T[], item: T, position: PageLayoutMutationPosition | undefined): void {
624
+ items.splice(insertionIndex(items.length, position), 0, item);
625
+ }
626
+
627
+ function fieldRqlNameOf(entry: unknown): string | undefined {
628
+ if (typeof entry === 'string') return entry;
629
+ if (entry == null || typeof entry !== 'object') return undefined;
630
+ const record = entry as Record<string, any>;
631
+ const fieldRqlName = record['fieldRqlName'] ?? record['field_rql_name'];
632
+ return typeof fieldRqlName === 'string' ? fieldRqlName : undefined;
633
+ }
634
+
635
+ function removeIndexes<T>(items: T[], indexes: number[]): void {
636
+ for (const index of [...indexes].sort((a, b) => b - a)) {
637
+ items.splice(index, 1);
638
+ }
639
+ }
640
+
641
+ function removeFromArray<T>(
642
+ items: T[],
643
+ predicate: (item: T) => boolean,
644
+ label: string,
645
+ removeAll = true,
646
+ ): number {
647
+ const indexes: number[] = [];
648
+ items.forEach((item, index) => {
649
+ if (predicate(item)) indexes.push(index);
650
+ });
651
+ if (indexes.length > 1 && !removeAll) {
652
+ throw new Error(`${label} matched ${indexes.length} entries; pass removeAll: true to remove them all.`);
653
+ }
654
+ removeIndexes(items, removeAll ? indexes : indexes.slice(0, 1));
655
+ return removeAll ? indexes.length : Math.min(indexes.length, 1);
656
+ }
657
+
658
+ function removeKeyFromOrder(order: string[] | undefined, key: string): void {
659
+ if (order == null) return;
660
+ removeFromArray(order, (entry) => entry === key, `order key "${key}"`);
661
+ }
662
+
663
+ function ensureRecordProperty(record: Record<string, any>, key: string, label: string): Record<string, any> {
664
+ const existing = record[key];
665
+ if (existing == null) {
666
+ const next: Record<string, any> = {};
667
+ record[key] = next;
668
+ return next;
669
+ }
670
+ if (typeof existing !== 'object' || Array.isArray(existing)) {
671
+ throw new Error(`${label} must be an object.`);
672
+ }
673
+ return existing as Record<string, any>;
674
+ }
675
+
676
+ function ensureArrayProperty<T>(record: Record<string, any>, key: string, label: string): T[] {
677
+ const existing = record[key];
678
+ if (existing == null) {
679
+ const next: T[] = [];
680
+ record[key] = next;
681
+ return next;
682
+ }
683
+ if (!Array.isArray(existing)) {
684
+ throw new Error(`${label} must be an array.`);
685
+ }
686
+ return existing as T[];
687
+ }
688
+
477
689
  // ---------------------------------------------------------------------------
478
690
  // Class
479
691
  // ---------------------------------------------------------------------------
@@ -612,6 +824,611 @@ export class CustomObjectPageLayout {
612
824
  return this._customObjectApiName;
613
825
  }
614
826
 
827
+ /**
828
+ * Adds one field to an existing layout section.
829
+ *
830
+ * For loaded layouts, this mutates the preserved `tab_edits` JSON in place
831
+ * and normalizes only the inserted field entry.
832
+ */
833
+ addField(field: Field, options: AddPageLayoutFieldOptions): this {
834
+ return this.addFields([field], options);
835
+ }
836
+
837
+ /**
838
+ * Adds multiple fields to an existing layout section.
839
+ *
840
+ * Fields are inserted in the order provided. If a field does not already have
841
+ * field-section metadata, it is assigned to `options.fieldSection` when
842
+ * provided, otherwise to `options.sectionKey`.
843
+ */
844
+ addFields(fields: Field[], options: AddPageLayoutFieldsOptions): this {
845
+ if (fields.length === 0) return this;
846
+ const container =
847
+ options.systemSection === true ?
848
+ this.ensureSystemSectionFieldContainer(options.tabKey, options.sectionKey)
849
+ : this.requireOneFieldContainer(options.tabKey, options.sectionKey);
850
+ const entries = fields.map((field) => this.serializeFieldForMutation(field, options.sectionKey, options));
851
+
852
+ if (options.allowDuplicate !== true) {
853
+ const existing = new Set(container.fields.map((entry) => fieldRqlNameOf(entry)).filter(Boolean));
854
+ const seen = new Set<string>();
855
+ for (const entry of entries) {
856
+ const fieldRqlName = entry.fieldRqlName;
857
+ if (existing.has(fieldRqlName) || seen.has(fieldRqlName)) {
858
+ throw new Error(
859
+ `CustomObjectPageLayout section "${container.label}" already contains field "${fieldRqlName}".`,
860
+ );
861
+ }
862
+ seen.add(fieldRqlName);
863
+ }
864
+ }
865
+
866
+ const index = insertionIndex(container.fields.length, options.position);
867
+ container.fields.splice(index, 0, ...entries);
868
+ return this;
869
+ }
870
+
871
+ /**
872
+ * Removes a field from the layout's field sections.
873
+ *
874
+ * This only removes the field from the page layout. It does not delete the
875
+ * `CUSTOM_OBJECT_FIELD` component from the manifest.
876
+ */
877
+ removeField(field: Field | string, options: RemovePageLayoutFieldOptions = {}): this {
878
+ const fieldApiName = typeof field === 'string' ? field : field.getApiName();
879
+ if (isField(field)) this.assertFieldBelongsToLayout(field);
880
+
881
+ const containers = this.findFieldContainers({
882
+ tabKey: options.tabKey,
883
+ sectionKey: options.sectionKey,
884
+ systemSectionOnly: options.systemSection === true,
885
+ });
886
+ const matches: Array<{ container: MutableFieldContainer; index: number }> = [];
887
+ for (const container of containers) {
888
+ container.fields.forEach((entry, index) => {
889
+ if (fieldRqlNameOf(entry) === fieldApiName) {
890
+ matches.push({ container, index });
891
+ }
892
+ });
893
+ }
894
+
895
+ if (matches.length === 0) {
896
+ throw new Error(`CustomObjectPageLayout does not contain field "${fieldApiName}".`);
897
+ }
898
+ const removeAll = options.removeAll !== false;
899
+ if (matches.length > 1 && !removeAll) {
900
+ throw new Error(
901
+ `CustomObjectPageLayout field "${fieldApiName}" matched ${matches.length} entries; ` +
902
+ 'pass removeAll: true to remove them all.',
903
+ );
904
+ }
905
+
906
+ const removals = removeAll ? matches : matches.slice(0, 1);
907
+ const byContainer = new Map<MutableFieldContainer, number[]>();
908
+ for (const match of removals) {
909
+ const indexes = byContainer.get(match.container) ?? [];
910
+ indexes.push(match.index);
911
+ byContainer.set(match.container, indexes);
912
+ }
913
+ for (const [container, indexes] of byContainer.entries()) {
914
+ removeIndexes(container.fields, indexes);
915
+ }
916
+ return this;
917
+ }
918
+
919
+ /** Removes multiple fields from the layout's field sections. */
920
+ removeFields(fields: Array<Field | string>, options: RemovePageLayoutFieldOptions = {}): this {
921
+ for (const field of fields) {
922
+ this.removeField(field, options);
923
+ }
924
+ return this;
925
+ }
926
+
927
+ /**
928
+ * Adds a section to an existing tab.
929
+ *
930
+ * Pass `systemTab: true` to add a new section to a built-in tab via
931
+ * `systemTabEdits.newSections`.
932
+ */
933
+ addSection(section: PageLayoutSection, options: AddPageLayoutSectionOptions): this {
934
+ const normalized = this.normalizeSectionForMutation(section);
935
+ if (options.systemTab === true) {
936
+ const tabEdit = this.ensureSystemTabEdit(options.tabKey);
937
+ const sections = ensureArrayProperty<SerializedFieldsSection>(
938
+ tabEdit as Record<string, any>,
939
+ 'newSections',
940
+ `systemTabEdits.${options.tabKey}.newSections`,
941
+ );
942
+ this.assertSectionKeyAvailable(sections, normalized.key, options.tabKey, options.allowDuplicate);
943
+ insertAt(sections, normalized, options.position);
944
+ if (tabEdit.sectionsOrder != null) insertAt(tabEdit.sectionsOrder, normalized.key, options.position);
945
+ return this;
946
+ }
947
+
948
+ const tab = this.findNewTabWithSections(options.tabKey);
949
+ if (tab == null) {
950
+ throw new Error(
951
+ `CustomObjectPageLayout could not find new tab "${options.tabKey}". ` +
952
+ 'Pass systemTab: true to add a section to a built-in system tab.',
953
+ );
954
+ }
955
+ this.assertSectionKeyAvailable(tab.sections, normalized.key, options.tabKey, options.allowDuplicate);
956
+ insertAt(tab.sections, normalized, options.position);
957
+ return this;
958
+ }
959
+
960
+ /**
961
+ * Removes a section from `newTabs` / `systemTabEdits.newSections`.
962
+ *
963
+ * Pass `systemSection: true` with `tabKey` to delete a built-in section via
964
+ * `systemSectionEdits[sectionKey].deleted`.
965
+ */
966
+ removeSection(sectionKey: string, options: RemovePageLayoutSectionOptions = {}): this {
967
+ if (options.systemSection === true) {
968
+ if (options.tabKey == null) {
969
+ throw new Error('CustomObjectPageLayout removeSection with systemSection: true requires tabKey.');
970
+ }
971
+ const sectionEdit = this.ensureSystemSectionEdit(options.tabKey, sectionKey);
972
+ sectionEdit.deleted = true;
973
+ this.removeSectionVisibility(sectionKey);
974
+ return this;
975
+ }
976
+
977
+ const matches: Array<{
978
+ sections: SerializedFieldsSection[];
979
+ index: number;
980
+ tabEdit?: SerializedSystemTabEdit;
981
+ tabKey: string;
982
+ }> = [];
983
+ for (const tab of this._tabEdits.newTabs ?? []) {
984
+ if (!isTabWithSections(tab)) continue;
985
+ if (options.tabKey != null && tab.key !== options.tabKey) continue;
986
+ tab.sections.forEach((section, index) => {
987
+ if (section.key === sectionKey) matches.push({ sections: tab.sections, index, tabKey: tab.key });
988
+ });
989
+ }
990
+ for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) {
991
+ if (options.tabKey != null && tabKey !== options.tabKey) continue;
992
+ (tabEdit.newSections ?? []).forEach((section, index) => {
993
+ if (section.key === sectionKey)
994
+ matches.push({ sections: tabEdit.newSections!, index, tabEdit, tabKey });
995
+ });
996
+ }
997
+
998
+ if (matches.length === 0) {
999
+ throw new Error(`CustomObjectPageLayout does not contain section "${sectionKey}".`);
1000
+ }
1001
+ const removeAll = options.removeAll !== false;
1002
+ if (matches.length > 1 && !removeAll) {
1003
+ throw new Error(
1004
+ `CustomObjectPageLayout section "${sectionKey}" matched ${matches.length} entries; ` +
1005
+ 'pass removeAll: true to remove them all.',
1006
+ );
1007
+ }
1008
+
1009
+ const removals = removeAll ? matches : matches.slice(0, 1);
1010
+ const byArray = new Map<SerializedFieldsSection[], number[]>();
1011
+ for (const match of removals) {
1012
+ const indexes = byArray.get(match.sections) ?? [];
1013
+ indexes.push(match.index);
1014
+ byArray.set(match.sections, indexes);
1015
+ if (match.tabEdit != null) removeKeyFromOrder(match.tabEdit.sectionsOrder, sectionKey);
1016
+ }
1017
+ for (const [sections, indexes] of byArray.entries()) {
1018
+ removeIndexes(sections, indexes);
1019
+ }
1020
+ this.removeSectionVisibility(sectionKey);
1021
+ return this;
1022
+ }
1023
+
1024
+ /** Adds a new tab to `tab_edits.newTabs`. */
1025
+ addTab(tab: PageLayoutTab, options: AddPageLayoutTabOptions = {}): this {
1026
+ const normalized = this.normalizeTabForMutation(tab);
1027
+ const newTabs = (this._tabEdits.newTabs ??= []);
1028
+ if (options.allowDuplicate !== true && newTabs.some((existing) => existing.key === normalized.key)) {
1029
+ throw new Error(`CustomObjectPageLayout already contains tab "${normalized.key}".`);
1030
+ }
1031
+ insertAt(newTabs, normalized, options.position);
1032
+ if (this._tabEdits.tabsOrder != null) {
1033
+ insertAt(this._tabEdits.tabsOrder, normalized.key, options.position);
1034
+ }
1035
+ return this;
1036
+ }
1037
+
1038
+ /**
1039
+ * Removes a tab from `newTabs`.
1040
+ *
1041
+ * Pass `systemTab: true` to delete a built-in tab via `systemTabEdits[tabKey].deleted`.
1042
+ */
1043
+ removeTab(tabKey: string, options: RemovePageLayoutTabOptions = {}): this {
1044
+ if (options.systemTab === true) {
1045
+ const tabEdit = this.ensureSystemTabEdit(tabKey);
1046
+ tabEdit.deleted = true;
1047
+ removeKeyFromOrder(this._tabEdits.tabsOrder, tabKey);
1048
+ this.removeTabVisibility(tabKey);
1049
+ return this;
1050
+ }
1051
+
1052
+ const newTabs = this._tabEdits.newTabs ?? [];
1053
+ const removed = removeFromArray(
1054
+ newTabs,
1055
+ (tab) => tab.key === tabKey,
1056
+ `CustomObjectPageLayout tab "${tabKey}"`,
1057
+ options.removeAll !== false,
1058
+ );
1059
+ if (removed === 0) {
1060
+ throw new Error(`CustomObjectPageLayout does not contain tab "${tabKey}".`);
1061
+ }
1062
+ removeKeyFromOrder(this._tabEdits.tabsOrder, tabKey);
1063
+ this.removeTabVisibility(tabKey);
1064
+ return this;
1065
+ }
1066
+
1067
+ /** Adds a custom header field to `header_edits.newFields`. */
1068
+ addHeaderField(field: Field | NewHeaderField, options: AddHeaderFieldOptions = {}): this {
1069
+ const headerField = this.normalizeHeaderFieldForMutation(field, options);
1070
+ const newFields = (this._headerEdits.newFields ??= []);
1071
+ if (options.allowDuplicate !== true && newFields.some((existing) => existing.key === headerField.key)) {
1072
+ throw new Error(`CustomObjectPageLayout header already contains field key "${headerField.key}".`);
1073
+ }
1074
+ insertAt(newFields, headerField, options.position);
1075
+ if (this._headerEdits.headerFieldsOrder != null) {
1076
+ insertAt(this._headerEdits.headerFieldsOrder, headerField.key, options.position);
1077
+ }
1078
+ return this;
1079
+ }
1080
+
1081
+ /** Removes custom header fields from `header_edits.newFields`. */
1082
+ removeHeaderField(fieldOrKey: Field | string, options: RemoveHeaderFieldOptions = {}): this {
1083
+ const fieldApiName = typeof fieldOrKey === 'string' ? undefined : fieldOrKey.getApiName();
1084
+ if (isField(fieldOrKey)) this.assertFieldBelongsToLayout(fieldOrKey);
1085
+ const key = typeof fieldOrKey === 'string' ? fieldOrKey : fieldApiName;
1086
+ const newFields = this._headerEdits.newFields ?? [];
1087
+ const removedKeys: string[] = [];
1088
+ const removed = removeFromArray(
1089
+ newFields,
1090
+ (field) => {
1091
+ const matched = field.key === key || (fieldApiName != null && field.rqlName === fieldApiName);
1092
+ if (matched) removedKeys.push(field.key);
1093
+ return matched;
1094
+ },
1095
+ `CustomObjectPageLayout header field "${key}"`,
1096
+ options.removeAll !== false,
1097
+ );
1098
+ if (removed === 0) {
1099
+ throw new Error(`CustomObjectPageLayout header does not contain field "${key}".`);
1100
+ }
1101
+ for (const removedKey of removedKeys) {
1102
+ removeKeyFromOrder(this._headerEdits.headerFieldsOrder, removedKey);
1103
+ }
1104
+ return this;
1105
+ }
1106
+
1107
+ /** Sets the record title field in `header_edits`. */
1108
+ setTitleField(field: Field | string | null): this {
1109
+ this._headerEdits.newTitleField = this.headerFieldApiName(field);
1110
+ if (field != null) this._headerEdits.titleFieldDeleted = false;
1111
+ return this;
1112
+ }
1113
+
1114
+ /** Removes the record title field from the header. */
1115
+ removeTitleField(): this {
1116
+ this._headerEdits.newTitleField = null;
1117
+ this._headerEdits.titleFieldDeleted = true;
1118
+ return this;
1119
+ }
1120
+
1121
+ /** Sets the record description field in `header_edits`. */
1122
+ setDescriptionField(field: Field | string | null): this {
1123
+ this._headerEdits.newDescriptionField = this.headerFieldApiName(field);
1124
+ if (field != null) this._headerEdits.descriptionFieldDeleted = false;
1125
+ return this;
1126
+ }
1127
+
1128
+ /** Removes the record description field from the header. */
1129
+ removeDescriptionField(): this {
1130
+ this._headerEdits.newDescriptionField = null;
1131
+ this._headerEdits.descriptionFieldDeleted = true;
1132
+ return this;
1133
+ }
1134
+
1135
+ private serializeFieldForMutation(
1136
+ field: Field,
1137
+ fallbackSectionKey: string,
1138
+ options: AddPageLayoutFieldOptions,
1139
+ ): SerializedSectionField {
1140
+ this.assertFieldBelongsToLayout(field);
1141
+ this.assignFieldSectionForMutation(field, fallbackSectionKey, options.fieldSection);
1142
+ return toSerializedSectionField(field.getApiName(), options);
1143
+ }
1144
+
1145
+ private normalizeSectionFieldForMutation(
1146
+ section: CustomObjectFieldSection | string,
1147
+ sectionKey: string,
1148
+ entry: SectionField,
1149
+ ): SerializedSectionField {
1150
+ if (isField(entry)) {
1151
+ this.assertFieldBelongsToLayout(entry);
1152
+ entry._assignSection(section);
1153
+ return toSerializedSectionField(entry.getApiName());
1154
+ }
1155
+
1156
+ if (isFieldRef(entry)) {
1157
+ this.assertFieldBelongsToLayout(entry.field);
1158
+ entry.field._assignSection(section);
1159
+ return toSerializedSectionField(entry.field.getApiName(), entry);
1160
+ }
1161
+
1162
+ throw new Error(
1163
+ `CustomObjectPageLayout fields_section "${sectionKey}" has an invalid field entry; ` +
1164
+ 'pass a Field instance or { field }.',
1165
+ );
1166
+ }
1167
+
1168
+ private normalizeSectionForMutation(input: PageLayoutSection): SerializedFieldsSection {
1169
+ if (input.section.getModelApiName() !== this._customObjectApiName) {
1170
+ throw new Error(
1171
+ `CustomObjectPageLayout section "${input.section.getSectionId()}" belongs to ` +
1172
+ `"${input.section.getModelApiName()}", but the layout belongs to "${this._customObjectApiName}".`,
1173
+ );
1174
+ }
1175
+
1176
+ const key = input.key ?? input.section.getSectionId();
1177
+ const name = input.name ?? input.section.getName();
1178
+ const normalized: SerializedFieldsSection = {
1179
+ key,
1180
+ name,
1181
+ type: 'fields_section',
1182
+ fields: input.fields.map((field) => this.normalizeSectionFieldForMutation(input.section, key, field)),
1183
+ };
1184
+ if (input.layout != null) normalized.layout = input.layout;
1185
+ return normalized;
1186
+ }
1187
+
1188
+ private normalizeTabForMutation(tab: PageLayoutTab): SerializedPageLayoutTab {
1189
+ if (tab.type === 'custom_tab') return { ...tab };
1190
+ return {
1191
+ ...tab,
1192
+ sections: tab.sections.map((section) => this.normalizeSectionForMutation(section)),
1193
+ };
1194
+ }
1195
+
1196
+ private assignFieldSectionForMutation(
1197
+ field: Field,
1198
+ fallbackSectionKey: string,
1199
+ fieldSection: CustomObjectFieldSection | string | undefined,
1200
+ ): void {
1201
+ if (fieldSection != null) {
1202
+ if (typeof fieldSection !== 'string' && fieldSection.getModelApiName() !== this._customObjectApiName) {
1203
+ throw new Error(
1204
+ `CustomObjectPageLayout field section "${fieldSection.getSectionId()}" belongs to ` +
1205
+ `"${fieldSection.getModelApiName()}", but the layout belongs to "${this._customObjectApiName}".`,
1206
+ );
1207
+ }
1208
+ field._assignSection(fieldSection);
1209
+ return;
1210
+ }
1211
+
1212
+ if (field.getSectionId() == null) {
1213
+ field._assignSection(fallbackSectionKey);
1214
+ }
1215
+ }
1216
+
1217
+ private assertFieldBelongsToLayout(field: Field): void {
1218
+ if (field.getCustomObjectApiName() !== this._customObjectApiName) {
1219
+ throw new Error(
1220
+ `CustomObjectPageLayout references field "${field.getApiName()}" from ` +
1221
+ `"${field.getCustomObjectApiName()}", but the layout belongs to "${this._customObjectApiName}".`,
1222
+ );
1223
+ }
1224
+ }
1225
+
1226
+ private requireOneFieldContainer(tabKey: string | undefined, sectionKey: string): MutableFieldContainer {
1227
+ const containers = this.findFieldContainers({
1228
+ tabKey,
1229
+ sectionKey,
1230
+ systemSectionOnly: false,
1231
+ });
1232
+ if (containers.length === 0) {
1233
+ const prefix = tabKey != null ? `${tabKey}.` : '';
1234
+ throw new Error(`CustomObjectPageLayout could not find section "${prefix}${sectionKey}".`);
1235
+ }
1236
+ if (containers.length > 1) {
1237
+ throw new Error(
1238
+ `CustomObjectPageLayout section "${sectionKey}" matched multiple sections: ` +
1239
+ `${containers.map((container) => container.label).join(', ')}. Pass tabKey to choose one.`,
1240
+ );
1241
+ }
1242
+ return containers[0] as MutableFieldContainer;
1243
+ }
1244
+
1245
+ private findFieldContainers(filter: {
1246
+ tabKey: string | undefined;
1247
+ sectionKey: string | undefined;
1248
+ systemSectionOnly: boolean;
1249
+ }): MutableFieldContainer[] {
1250
+ const containers: MutableFieldContainer[] = [];
1251
+
1252
+ if (!filter.systemSectionOnly) {
1253
+ for (const tab of this._tabEdits.newTabs ?? []) {
1254
+ if (!isTabWithSections(tab)) continue;
1255
+ if (filter.tabKey != null && tab.key !== filter.tabKey) continue;
1256
+ for (const section of tab.sections) {
1257
+ if (filter.sectionKey != null && section.key !== filter.sectionKey) continue;
1258
+ containers.push({
1259
+ tabKey: tab.key,
1260
+ sectionKey: section.key,
1261
+ fields: ensureArrayProperty<MutableSectionField>(
1262
+ section as unknown as Record<string, any>,
1263
+ 'fields',
1264
+ `newTabs.${tab.key}.sections.${section.key}.fields`,
1265
+ ),
1266
+ label: `${tab.key}.${section.key}`,
1267
+ });
1268
+ }
1269
+ }
1270
+
1271
+ for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) {
1272
+ if (filter.tabKey != null && tabKey !== filter.tabKey) continue;
1273
+ for (const section of tabEdit.newSections ?? []) {
1274
+ if (filter.sectionKey != null && section.key !== filter.sectionKey) continue;
1275
+ containers.push({
1276
+ tabKey,
1277
+ sectionKey: section.key,
1278
+ fields: ensureArrayProperty<MutableSectionField>(
1279
+ section as unknown as Record<string, any>,
1280
+ 'fields',
1281
+ `systemTabEdits.${tabKey}.newSections.${section.key}.fields`,
1282
+ ),
1283
+ label: `${tabKey}.${section.key}`,
1284
+ });
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) {
1290
+ if (filter.tabKey != null && tabKey !== filter.tabKey) continue;
1291
+ for (const [sectionKey, sectionEdit] of Object.entries(tabEdit.systemSectionEdits ?? {})) {
1292
+ if (filter.sectionKey != null && sectionKey !== filter.sectionKey) continue;
1293
+ const fields = (sectionEdit as Record<string, any>)['newFields'];
1294
+ if (fields == null) continue;
1295
+ if (!Array.isArray(fields)) {
1296
+ throw new Error(
1297
+ `systemTabEdits.${tabKey}.systemSectionEdits.${sectionKey}.newFields must be an array.`,
1298
+ );
1299
+ }
1300
+ containers.push({
1301
+ tabKey,
1302
+ sectionKey,
1303
+ fields: fields as MutableSectionField[],
1304
+ label: `${tabKey}.${sectionKey}`,
1305
+ });
1306
+ }
1307
+ }
1308
+
1309
+ return containers;
1310
+ }
1311
+
1312
+ private ensureSystemSectionFieldContainer(
1313
+ tabKey: string | undefined,
1314
+ sectionKey: string,
1315
+ ): MutableFieldContainer {
1316
+ if (tabKey == null) {
1317
+ throw new Error('CustomObjectPageLayout addField with systemSection: true requires tabKey.');
1318
+ }
1319
+ const sectionEdit = this.ensureSystemSectionEdit(tabKey, sectionKey);
1320
+ if (sectionEdit.deleted === true) {
1321
+ throw new Error(`CustomObjectPageLayout system section "${tabKey}.${sectionKey}" is marked deleted.`);
1322
+ }
1323
+ return {
1324
+ tabKey,
1325
+ sectionKey,
1326
+ fields: ensureArrayProperty<MutableSectionField>(
1327
+ sectionEdit as Record<string, any>,
1328
+ 'newFields',
1329
+ `systemTabEdits.${tabKey}.systemSectionEdits.${sectionKey}.newFields`,
1330
+ ),
1331
+ label: `${tabKey}.${sectionKey}`,
1332
+ };
1333
+ }
1334
+
1335
+ private ensureSystemTabEdit(tabKey: string): SerializedSystemTabEdit {
1336
+ const systemTabEdits = ensureRecordProperty(
1337
+ this._tabEdits as unknown as Record<string, any>,
1338
+ 'systemTabEdits',
1339
+ 'tab_edits.systemTabEdits',
1340
+ );
1341
+ return ensureRecordProperty(
1342
+ systemTabEdits,
1343
+ tabKey,
1344
+ `systemTabEdits.${tabKey}`,
1345
+ ) as SerializedSystemTabEdit;
1346
+ }
1347
+
1348
+ private ensureSystemSectionEdit(tabKey: string, sectionKey: string): SerializedSystemSectionEdit {
1349
+ const tabEdit = this.ensureSystemTabEdit(tabKey);
1350
+ const systemSectionEdits = ensureRecordProperty(
1351
+ tabEdit as Record<string, any>,
1352
+ 'systemSectionEdits',
1353
+ `systemTabEdits.${tabKey}.systemSectionEdits`,
1354
+ );
1355
+ return ensureRecordProperty(
1356
+ systemSectionEdits,
1357
+ sectionKey,
1358
+ `systemTabEdits.${tabKey}.systemSectionEdits.${sectionKey}`,
1359
+ ) as SerializedSystemSectionEdit;
1360
+ }
1361
+
1362
+ private findNewTabWithSections(tabKey: string): SerializedTabWithSections | undefined {
1363
+ let found: SerializedTabWithSections | undefined;
1364
+ for (const tab of this._tabEdits.newTabs ?? []) {
1365
+ if (tab.key !== tabKey) continue;
1366
+ if (!isTabWithSections(tab)) {
1367
+ throw new Error(
1368
+ `CustomObjectPageLayout tab "${tabKey}" is a custom_tab and cannot contain sections.`,
1369
+ );
1370
+ }
1371
+ if (found != null) {
1372
+ throw new Error(`CustomObjectPageLayout contains multiple new tabs with key "${tabKey}".`);
1373
+ }
1374
+ found = tab;
1375
+ }
1376
+ return found;
1377
+ }
1378
+
1379
+ private assertSectionKeyAvailable(
1380
+ sections: SerializedFieldsSection[],
1381
+ sectionKey: string,
1382
+ tabKey: string,
1383
+ allowDuplicate: boolean | undefined,
1384
+ ): void {
1385
+ if (allowDuplicate === true) return;
1386
+ if (sections.some((section) => section.key === sectionKey)) {
1387
+ throw new Error(`CustomObjectPageLayout tab "${tabKey}" already contains section "${sectionKey}".`);
1388
+ }
1389
+ }
1390
+
1391
+ private normalizeHeaderFieldForMutation(
1392
+ field: Field | NewHeaderField,
1393
+ options: AddHeaderFieldOptions,
1394
+ ): NewHeaderField {
1395
+ const source = isField(field) ? undefined : field;
1396
+ const rqlName = isField(field) ? field.getApiName() : field.rqlName;
1397
+ if (isField(field)) this.assertFieldBelongsToLayout(field);
1398
+ if (!rqlName) throw new Error('CustomObjectPageLayout header field rqlName cannot be empty.');
1399
+
1400
+ const key = options.key ?? source?.key ?? (isField(field) ? field.getApiName() : undefined);
1401
+ if (!key) throw new Error('CustomObjectPageLayout header field key cannot be empty.');
1402
+
1403
+ const normalized: NewHeaderField = { rqlName, key };
1404
+ const canBeDeleted = options.canBeDeleted ?? source?.canBeDeleted;
1405
+ const canBeChanged = options.canBeChanged ?? source?.canBeChanged;
1406
+ const canBeMoved = options.canBeMoved ?? source?.canBeMoved;
1407
+ if (canBeDeleted != null) normalized.canBeDeleted = canBeDeleted;
1408
+ if (canBeChanged != null) normalized.canBeChanged = canBeChanged;
1409
+ if (canBeMoved != null) normalized.canBeMoved = canBeMoved;
1410
+ return normalized;
1411
+ }
1412
+
1413
+ private headerFieldApiName(field: Field | string | null): string | null {
1414
+ if (field == null) return null;
1415
+ if (typeof field === 'string') return field;
1416
+ this.assertFieldBelongsToLayout(field);
1417
+ return field.getApiName();
1418
+ }
1419
+
1420
+ private removeSectionVisibility(sectionKey: string): void {
1421
+ if (this._visibilityConditions.sections != null) {
1422
+ delete this._visibilityConditions.sections[sectionKey];
1423
+ }
1424
+ }
1425
+
1426
+ private removeTabVisibility(tabKey: string): void {
1427
+ if (this._visibilityConditions.tabs != null) {
1428
+ delete this._visibilityConditions.tabs[tabKey];
1429
+ }
1430
+ }
1431
+
615
1432
  /**
616
1433
  * Serializes this layout to the wire format consumed by the manifest install endpoint.
617
1434
  *