@rippling/rippling-sdk 0.2.0-alpha.44 → 0.2.0-alpha.46

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.
Files changed (61) hide show
  1. package/examples/manifest/dental-practice-management/dental-practice-management.ts +0 -7
  2. package/examples/manifest/dental-practice-management/manifest.json +0 -7
  3. package/examples/manifest/existing-manifest-mutations/existing-manifest-mutations.ts +6 -7
  4. package/examples/manifest/gym-membership-management/gym-membership-management.ts +0 -7
  5. package/examples/manifest/gym-membership-management/manifest.json +0 -7
  6. package/lib/manifest/app.d.mts +2 -2
  7. package/lib/manifest/app.d.mts.map +1 -1
  8. package/lib/manifest/app.d.ts +2 -2
  9. package/lib/manifest/app.d.ts.map +1 -1
  10. package/lib/manifest/app.js +15 -4
  11. package/lib/manifest/app.js.map +1 -1
  12. package/lib/manifest/app.mjs +15 -4
  13. package/lib/manifest/app.mjs.map +1 -1
  14. package/lib/manifest/custom-object-field-section.d.mts +2 -2
  15. package/lib/manifest/custom-object-field-section.d.ts +2 -2
  16. package/lib/manifest/custom-object-field-section.js +2 -2
  17. package/lib/manifest/custom-object-field-section.mjs +2 -2
  18. package/lib/manifest/custom-object-page-layout.d.mts +86 -40
  19. package/lib/manifest/custom-object-page-layout.d.mts.map +1 -1
  20. package/lib/manifest/custom-object-page-layout.d.ts +86 -40
  21. package/lib/manifest/custom-object-page-layout.d.ts.map +1 -1
  22. package/lib/manifest/custom-object-page-layout.js +369 -79
  23. package/lib/manifest/custom-object-page-layout.js.map +1 -1
  24. package/lib/manifest/custom-object-page-layout.mjs +369 -79
  25. package/lib/manifest/custom-object-page-layout.mjs.map +1 -1
  26. package/lib/manifest/custom-object.d.mts +6 -0
  27. package/lib/manifest/custom-object.d.mts.map +1 -1
  28. package/lib/manifest/custom-object.d.ts +6 -0
  29. package/lib/manifest/custom-object.d.ts.map +1 -1
  30. package/lib/manifest/custom-object.js +6 -0
  31. package/lib/manifest/custom-object.js.map +1 -1
  32. package/lib/manifest/custom-object.mjs +6 -0
  33. package/lib/manifest/custom-object.mjs.map +1 -1
  34. package/lib/manifest/existing-manifest-context.d.mts +0 -1
  35. package/lib/manifest/existing-manifest-context.d.mts.map +1 -1
  36. package/lib/manifest/existing-manifest-context.d.ts +0 -1
  37. package/lib/manifest/existing-manifest-context.d.ts.map +1 -1
  38. package/lib/manifest/existing-manifest-context.js +5 -10
  39. package/lib/manifest/existing-manifest-context.js.map +1 -1
  40. package/lib/manifest/existing-manifest-context.mjs +5 -10
  41. package/lib/manifest/existing-manifest-context.mjs.map +1 -1
  42. package/lib/manifest/field.d.mts +6 -3
  43. package/lib/manifest/field.d.mts.map +1 -1
  44. package/lib/manifest/field.d.ts +6 -3
  45. package/lib/manifest/field.d.ts.map +1 -1
  46. package/lib/manifest/field.js +26 -6
  47. package/lib/manifest/field.js.map +1 -1
  48. package/lib/manifest/field.mjs +26 -6
  49. package/lib/manifest/field.mjs.map +1 -1
  50. package/package.json +1 -1
  51. package/src/lib/manifest/app.ts +19 -5
  52. package/src/lib/manifest/custom-object-field-section.ts +2 -2
  53. package/src/lib/manifest/custom-object-page-layout.ts +599 -109
  54. package/src/lib/manifest/custom-object.ts +6 -0
  55. package/src/lib/manifest/existing-manifest-context.ts +5 -12
  56. package/src/lib/manifest/field.ts +45 -9
  57. package/src/version.ts +1 -1
  58. package/version.d.mts +1 -1
  59. package/version.d.ts +1 -1
  60. package/version.js +1 -1
  61. package/version.mjs +1 -1
@@ -27,7 +27,7 @@ export interface SystemHeaderFieldEdit {
27
27
  * Custom header field added via `newFields`.
28
28
  *
29
29
  * `key` is required and must be non-empty — it is used as the stable identity
30
- * for this field in `headerFieldsOrder` and visibility conditions.
30
+ * for this field in visibility conditions.
31
31
  */
32
32
  export interface NewHeaderField {
33
33
  /** RQL field name to display. Required. */
@@ -64,7 +64,7 @@ export interface HeaderEdits {
64
64
  systemFieldEdits?: Record<string, SystemHeaderFieldEdit>;
65
65
  /** Additional custom fields to add to the header. Optional. */
66
66
  newFields?: NewHeaderField[];
67
- /** Ordered list of header field `key` values (custom + system). Optional. */
67
+ /** Ordered list of header field RQL names. Custom header keys are accepted and converted. Optional. */
68
68
  headerFieldsOrder?: string[];
69
69
  /** Action button keys to show in the header. Optional. */
70
70
  buttons?: string[];
@@ -98,8 +98,6 @@ export interface FieldsSection {
98
98
  type: 'fields_section';
99
99
  /** Field section metadata for the fields displayed in this layout section. Required. */
100
100
  section: CustomObjectFieldSection;
101
- /** Stable layout key for this section. Optional. Defaults to the field section id. */
102
- key?: string;
103
101
  /** Display name of the layout section. Optional. Defaults to the field section name. */
104
102
  name?: string;
105
103
  /** Fields to display in this section. Required. */
@@ -227,53 +225,93 @@ export interface VisibilityConditions {
227
225
  /** Where to insert a new layout item. Defaults to `'end'`. */
228
226
  export type PageLayoutMutationPosition = 'start' | 'end' | number;
229
227
 
230
- /** Options for adding a field to an existing layout section. */
231
- export interface AddPageLayoutFieldOptions extends SectionFieldOptions {
232
- /**
233
- * Tab key containing the target section. Optional when `sectionKey` is unique
234
- * across the layout.
235
- */
236
- tabKey?: string;
237
- /** Section key to add the field to. Required. */
238
- sectionKey: string;
239
- /**
240
- * Field-section metadata to assign to the field component.
241
- *
242
- * Existing layouts often use layout section keys that differ from field section
243
- * ids. Pass this when the field was not constructed with a `section`.
244
- */
245
- fieldSection?: CustomObjectFieldSection;
228
+ interface AddPageLayoutFieldOptionsBase extends SectionFieldOptions {
246
229
  /** Insert position inside the section field list. Optional. @default `'end'` */
247
230
  position?: PageLayoutMutationPosition;
248
231
  /** Allow the same field to appear twice in the same section. Optional. @default false */
249
232
  allowDuplicate?: boolean;
233
+ }
234
+
235
+ /** Options for adding a field to a typed custom-object field section. */
236
+ export interface AddCustomPageLayoutFieldOptions extends AddPageLayoutFieldOptionsBase {
237
+ /**
238
+ * Tab key containing the target section. Optional when `section` is unique
239
+ * across the layout.
240
+ */
241
+ tabKey?: string;
242
+ /** Field section to add the field to. Required for custom layout sections. */
243
+ section: CustomObjectFieldSection;
244
+ /** Raw section keys are only accepted for built-in system sections. */
245
+ sectionKey?: never;
246
+ /** Deprecated. Use `section`. */
247
+ fieldSection?: never;
248
+ /** Custom layout sections are selected by `section`; `sectionKey` is only for system sections. */
249
+ systemSection?: false;
250
+ }
251
+
252
+ /** Options for adding a field to a built-in system section. */
253
+ export interface AddSystemPageLayoutFieldOptions extends AddPageLayoutFieldOptionsBase {
250
254
  /**
251
255
  * Add to a built-in system section via `systemTabEdits`.
252
256
  *
253
- * When true, `tabKey` is required and the edit is created if it does not
254
- * already exist.
257
+ * When true, `tabKey` and `sectionKey` are required and the edit is created
258
+ * if it does not already exist.
255
259
  */
256
- systemSection?: boolean;
260
+ systemSection: true;
261
+ /** System tab key containing the target section. Required. */
262
+ tabKey: string;
263
+ /** Built-in system section key. Required. */
264
+ sectionKey: string;
265
+ /** Custom field-section objects are only accepted for custom layout sections. */
266
+ section?: never;
267
+ /** Deprecated. Use `section` for custom sections, or `sectionKey` for system sections. */
268
+ fieldSection?: never;
257
269
  }
258
270
 
271
+ /** Options for adding a field to an existing layout section. */
272
+ export type AddPageLayoutFieldOptions = AddCustomPageLayoutFieldOptions | AddSystemPageLayoutFieldOptions;
273
+
259
274
  /** Options for adding multiple fields to an existing layout section. */
260
275
  export type AddPageLayoutFieldsOptions = AddPageLayoutFieldOptions;
261
276
 
262
277
  /** Options for removing fields from a layout. */
263
- export interface RemovePageLayoutFieldOptions {
264
- /** Limit removal to one tab. Optional. */
265
- tabKey?: string;
266
- /** Limit removal to one section. Optional. */
267
- sectionKey?: string;
278
+ interface RemovePageLayoutFieldOptionsBase {
268
279
  /**
269
280
  * Remove all matching occurrences. When false, removing a field that appears
270
281
  * more than once throws. Optional. @default true
271
282
  */
272
283
  removeAll?: boolean;
273
- /** Remove from a built-in system section edit. Optional. */
274
- systemSection?: boolean;
275
284
  }
276
285
 
286
+ /** Options for removing fields from typed custom-object field sections. */
287
+ export interface RemoveCustomPageLayoutFieldOptions extends RemovePageLayoutFieldOptionsBase {
288
+ /** Limit removal to one tab. Optional. */
289
+ tabKey?: string;
290
+ /** Limit removal to one field section. Optional. */
291
+ section?: CustomObjectFieldSection;
292
+ /** Raw section keys are only accepted for built-in system sections. */
293
+ sectionKey?: never;
294
+ /** Custom layout sections are selected by `section`; `sectionKey` is only for system sections. */
295
+ systemSection?: false;
296
+ }
297
+
298
+ /** Options for removing fields from built-in system section edits. */
299
+ export interface RemoveSystemPageLayoutFieldOptions extends RemovePageLayoutFieldOptionsBase {
300
+ /** Remove from a built-in system section edit. Required. */
301
+ systemSection: true;
302
+ /** System tab key containing the target section. Required. */
303
+ tabKey: string;
304
+ /** Built-in system section key. Required. */
305
+ sectionKey: string;
306
+ /** Custom field-section objects are only accepted for custom layout sections. */
307
+ section?: never;
308
+ }
309
+
310
+ /** Options for removing fields from a layout. */
311
+ export type RemovePageLayoutFieldOptions =
312
+ | RemoveCustomPageLayoutFieldOptions
313
+ | RemoveSystemPageLayoutFieldOptions;
314
+
277
315
  /** Options for adding a section to an existing tab. */
278
316
  export interface AddPageLayoutSectionOptions {
279
317
  /** Tab key to add the section to. Required. */
@@ -299,13 +337,21 @@ export interface RemovePageLayoutSectionOptions {
299
337
  * more than once throws. Optional. @default true
300
338
  */
301
339
  removeAll?: boolean;
302
- /** Delete a built-in system section via `systemSectionEdits`. Requires `tabKey`. */
303
- systemSection?: boolean;
340
+ /** Custom layout sections are selected by `CustomObjectFieldSection`; raw keys are only for system sections. */
341
+ systemSection?: false;
342
+ }
343
+
344
+ /** Options for removing a built-in system section from a layout. */
345
+ export interface RemoveSystemPageLayoutSectionOptions {
346
+ /** System tab key containing the target section. Required. */
347
+ tabKey: string;
348
+ /** Delete a built-in system section via `systemSectionEdits`. Required. */
349
+ systemSection: true;
304
350
  }
305
351
 
306
352
  /** Options for adding a tab to a layout. */
307
353
  export interface AddPageLayoutTabOptions {
308
- /** Insert position inside `newTabs`. Optional. @default `'end'` */
354
+ /** Insert position inside `newTabs` and `tabsOrder`. Optional. @default `'end'` */
309
355
  position?: PageLayoutMutationPosition;
310
356
  /** Allow another tab with the same key. Optional. @default false */
311
357
  allowDuplicate?: boolean;
@@ -409,7 +455,6 @@ interface SerializedSectionField extends SectionFieldOptions {
409
455
 
410
456
  interface SerializedFieldsSection {
411
457
  type: 'fields_section';
412
- key: string;
413
458
  name: string;
414
459
  fields: SerializedSectionField[];
415
460
  layout?: SectionFieldsLayout;
@@ -459,6 +504,44 @@ function isFieldRef(value: SectionField): value is SectionFieldRef {
459
504
  return candidate != null && typeof candidate === 'object' && isField(candidate.field);
460
505
  }
461
506
 
507
+ const FIELD_SECTION_ID = Symbol('CustomObjectPageLayout.fieldSectionId');
508
+
509
+ function isCustomObjectFieldSection(value: unknown): value is CustomObjectFieldSection {
510
+ return value instanceof CustomObjectFieldSection;
511
+ }
512
+
513
+ function attachFieldSectionId(section: SerializedFieldsSection, sectionId: string): SerializedFieldsSection {
514
+ Object.defineProperty(section, FIELD_SECTION_ID, {
515
+ value: sectionId,
516
+ enumerable: false,
517
+ configurable: true,
518
+ });
519
+ return section;
520
+ }
521
+
522
+ function layoutSectionKey(section: SerializedFieldsSection): string | undefined {
523
+ return (section as unknown as Record<typeof FIELD_SECTION_ID, string | undefined>)[FIELD_SECTION_ID];
524
+ }
525
+
526
+ function layoutSectionMatches(section: SerializedFieldsSection, sectionKey: string): boolean {
527
+ return layoutSectionKey(section) === sectionKey;
528
+ }
529
+
530
+ function inputFieldsSectionHasKey(input: PageLayoutSection): boolean {
531
+ const record = input as unknown as Record<string, unknown>;
532
+ return Object.prototype.hasOwnProperty.call(record, 'key') && record['key'] != null;
533
+ }
534
+
535
+ function assertNoSerializedSectionKey(section: SerializedFieldsSection): void {
536
+ const record = section as unknown as Record<string, unknown>;
537
+ if (Object.prototype.hasOwnProperty.call(record, 'key') && record['key'] != null) {
538
+ throw new Error(
539
+ 'CustomObjectPageLayout loaded fields_section key is not supported. ' +
540
+ 'Remove key and target the section with CustomObjectFieldSection metadata.',
541
+ );
542
+ }
543
+ }
544
+
462
545
  function assertSameObject(customObject: CustomObject, field: Field, sectionKey: string): void {
463
546
  if (field.getCustomObjectApiName() !== customObject.getApiName()) {
464
547
  throw new Error(
@@ -477,6 +560,22 @@ function assertSectionBelongsTo(customObject: CustomObject, section: CustomObjec
477
560
  }
478
561
  }
479
562
 
563
+ function requireFieldSectionOption(value: unknown, label: string): CustomObjectFieldSection {
564
+ if (isCustomObjectFieldSection(value)) return value;
565
+ if (typeof value === 'string') {
566
+ throw new TypeError(
567
+ `${label} must be a CustomObjectFieldSection object; received raw section id "${value}". ` +
568
+ 'Load/include the existing field section first.',
569
+ );
570
+ }
571
+ throw new TypeError(`${label} must be a CustomObjectFieldSection object.`);
572
+ }
573
+
574
+ function assertOptionAbsent(options: unknown, key: string, message: string): void {
575
+ if (options == null || typeof options !== 'object') return;
576
+ if (Object.prototype.hasOwnProperty.call(options, key)) throw new TypeError(message);
577
+ }
578
+
480
579
  function toSerializedSectionField(
481
580
  fieldRqlName: string,
482
581
  options: SectionFieldOptions = {},
@@ -517,16 +616,18 @@ function normalizeFieldsSection(
517
616
  input: PageLayoutSection,
518
617
  ): SerializedFieldsSection {
519
618
  assertSectionBelongsTo(customObject, input.section);
520
- const key = input.key ?? input.section.getSectionId();
619
+ const sectionId = input.section.getSectionId();
620
+ if (inputFieldsSectionHasKey(input)) {
621
+ throw new Error('CustomObjectPageLayout fields_section key is not supported. Omit key.');
622
+ }
521
623
  const name = input.name ?? input.section.getName();
522
624
  const normalized: SerializedFieldsSection = {
523
- key,
524
625
  name,
525
626
  type: 'fields_section',
526
- fields: input.fields.map((field) => normalizeSectionField(customObject, input.section, key, field)),
627
+ fields: input.fields.map((field) => normalizeSectionField(customObject, input.section, sectionId, field)),
527
628
  };
528
629
  if (input.layout != null) normalized.layout = input.layout;
529
- return normalized;
630
+ return attachFieldSectionId(normalized, sectionId);
530
631
  }
531
632
 
532
633
  function normalizeTab(customObject: CustomObject, tab: PageLayoutTab): SerializedPageLayoutTab {
@@ -599,11 +700,111 @@ function cloneJson<T>(value: T): T {
599
700
  return JSON.parse(JSON.stringify(value)) as T;
600
701
  }
601
702
 
703
+ const HEADER_EDIT_KEYS = new Set([
704
+ 'newTitleField',
705
+ 'titleFieldDeleted',
706
+ 'newDescriptionField',
707
+ 'descriptionFieldDeleted',
708
+ 'systemFieldEdits',
709
+ 'newFields',
710
+ 'headerFieldsOrder',
711
+ 'buttons',
712
+ ]);
713
+
714
+ const SYSTEM_HEADER_FIELD_EDIT_KEYS = new Set(['newRqlField', 'deleted']);
715
+
716
+ const NEW_HEADER_FIELD_KEYS = new Set(['rqlName', 'key', 'canBeDeleted', 'canBeChanged', 'canBeMoved']);
717
+
718
+ function assertKnownKeys(
719
+ record: Record<string, unknown>,
720
+ allowedKeys: ReadonlySet<string>,
721
+ label: string,
722
+ hint: string,
723
+ ): void {
724
+ for (const key of Object.keys(record)) {
725
+ if (!allowedKeys.has(key)) {
726
+ throw new Error(`${label} contains unsupported key "${key}". ${hint}`);
727
+ }
728
+ }
729
+ }
730
+
731
+ function normalizeHeaderEdits(headerEdits: HeaderEdits): HeaderEdits {
732
+ assertKnownKeys(
733
+ headerEdits as unknown as Record<string, unknown>,
734
+ HEADER_EDIT_KEYS,
735
+ 'CustomObjectPageLayout headerEdits',
736
+ 'Use addHeaderField(), setTitleField(), setDescriptionField(), or the HeaderEdits keys documented by the SDK.',
737
+ );
738
+
739
+ for (const [key, edit] of Object.entries(headerEdits.systemFieldEdits ?? {})) {
740
+ assertKnownKeys(
741
+ edit as Record<string, unknown>,
742
+ SYSTEM_HEADER_FIELD_EDIT_KEYS,
743
+ `CustomObjectPageLayout headerEdits.systemFieldEdits.${key}`,
744
+ 'Use newRqlField and deleted for system header field edits.',
745
+ );
746
+ }
747
+
748
+ for (const [index, field] of (headerEdits.newFields ?? []).entries()) {
749
+ assertKnownKeys(
750
+ field as unknown as Record<string, unknown>,
751
+ NEW_HEADER_FIELD_KEYS,
752
+ `CustomObjectPageLayout headerEdits.newFields[${index}]`,
753
+ 'Use { rqlName, key } entries, or call addHeaderField(field).',
754
+ );
755
+ }
756
+
757
+ if (headerEdits.headerFieldsOrder == null) return headerEdits;
758
+ return {
759
+ ...headerEdits,
760
+ headerFieldsOrder: normalizeHeaderFieldsOrder(headerEdits),
761
+ };
762
+ }
763
+
764
+ function addUniqueHeaderOrderField(order: string[], field: unknown): void {
765
+ if (typeof field !== 'string' || field.length === 0) return;
766
+ if (!order.includes(field)) order.push(field);
767
+ }
768
+
769
+ function normalizeHeaderFieldsOrder(headerEdits: HeaderEdits): string[] {
770
+ const customFieldRqlByKey = new Map(
771
+ (headerEdits.newFields ?? []).map((field) => [field.key, field.rqlName]),
772
+ );
773
+ const systemFieldRqlByKey = new Map(
774
+ Object.entries(headerEdits.systemFieldEdits ?? {})
775
+ .filter((entry): entry is [string, SystemHeaderFieldEdit & { newRqlField: string }] => {
776
+ const [, edit] = entry;
777
+ return typeof edit.newRqlField === 'string' && edit.newRqlField.length > 0;
778
+ })
779
+ .map(([key, edit]) => [key, edit.newRqlField]),
780
+ );
781
+ const order: string[] = [];
782
+
783
+ for (const entry of headerEdits.headerFieldsOrder ?? []) {
784
+ addUniqueHeaderOrderField(
785
+ order,
786
+ customFieldRqlByKey.get(entry) ?? systemFieldRqlByKey.get(entry) ?? entry,
787
+ );
788
+ }
789
+
790
+ addUniqueHeaderOrderField(order, headerEdits.newTitleField);
791
+ addUniqueHeaderOrderField(order, headerEdits.newDescriptionField);
792
+ for (const field of headerEdits.newFields ?? []) {
793
+ addUniqueHeaderOrderField(order, field.rqlName);
794
+ }
795
+ for (const systemEdit of Object.values(headerEdits.systemFieldEdits ?? {})) {
796
+ addUniqueHeaderOrderField(order, systemEdit.newRqlField);
797
+ }
798
+
799
+ return order;
800
+ }
801
+
602
802
  type MutableSectionField = SerializedSectionField | Record<string, any> | string;
603
803
 
604
804
  interface MutableFieldContainer {
605
805
  tabKey: string;
606
- sectionKey: string;
806
+ sectionKey: string | undefined;
807
+ section?: SerializedFieldsSection;
607
808
  fields: MutableSectionField[];
608
809
  label: string;
609
810
  }
@@ -625,6 +826,15 @@ function insertAt<T>(items: T[], item: T, position: PageLayoutMutationPosition |
625
826
  items.splice(insertionIndex(items.length, position), 0, item);
626
827
  }
627
828
 
829
+ function insertUniqueAt(
830
+ items: string[],
831
+ item: string | null | undefined,
832
+ position: PageLayoutMutationPosition | undefined,
833
+ ): void {
834
+ if (item == null || item.length === 0 || items.includes(item)) return;
835
+ insertAt(items, item, position);
836
+ }
837
+
628
838
  function fieldRqlNameOf(entry: unknown): string | undefined {
629
839
  if (typeof entry === 'string') return entry;
630
840
  if (entry == null || typeof entry !== 'object') return undefined;
@@ -633,6 +843,61 @@ function fieldRqlNameOf(entry: unknown): string | undefined {
633
843
  return typeof fieldRqlName === 'string' ? fieldRqlName : undefined;
634
844
  }
635
845
 
846
+ function explicitFieldSectionId(entry: unknown): string | undefined {
847
+ if (entry == null || typeof entry !== 'object') return undefined;
848
+ const record = entry as Record<string, any>;
849
+ const fieldSectionId = record['fieldSection'] ?? record['field_section'];
850
+ return typeof fieldSectionId === 'string' ? fieldSectionId : undefined;
851
+ }
852
+
853
+ function inferredSectionIdFromFields(
854
+ section: SerializedFieldsSection,
855
+ resolveFieldSectionId?: (fieldApiName: string) => string | undefined,
856
+ ): string | undefined {
857
+ const sectionIds = new Set<string>();
858
+ for (const field of section.fields ?? []) {
859
+ const explicitSectionId = explicitFieldSectionId(field);
860
+ if (explicitSectionId != null) {
861
+ sectionIds.add(explicitSectionId);
862
+ continue;
863
+ }
864
+
865
+ const fieldApiName = fieldRqlNameOf(field);
866
+ if (fieldApiName == null || resolveFieldSectionId == null) continue;
867
+ const resolvedSectionId = resolveFieldSectionId(fieldApiName);
868
+ if (resolvedSectionId != null) sectionIds.add(resolvedSectionId);
869
+ }
870
+ return sectionIds.size === 1 ? [...sectionIds][0] : undefined;
871
+ }
872
+
873
+ function annotateLoadedFieldsSection(
874
+ section: SerializedFieldsSection,
875
+ resolveFieldSectionId?: (fieldApiName: string) => string | undefined,
876
+ ): SerializedFieldsSection {
877
+ assertNoSerializedSectionKey(section);
878
+ const sectionId = layoutSectionKey(section) ?? inferredSectionIdFromFields(section, resolveFieldSectionId);
879
+ return sectionId == null ? section : attachFieldSectionId(section, sectionId);
880
+ }
881
+
882
+ function annotateLoadedTabEdits(
883
+ tabEdits: SerializedTabEdits,
884
+ resolveFieldSectionId?: (fieldApiName: string) => string | undefined,
885
+ ): SerializedTabEdits {
886
+ for (const tab of tabEdits.newTabs ?? []) {
887
+ if (!isTabWithSections(tab)) continue;
888
+ tab.sections = tab.sections.map((section) => annotateLoadedFieldsSection(section, resolveFieldSectionId));
889
+ }
890
+
891
+ for (const tabEdit of Object.values(tabEdits.systemTabEdits ?? {})) {
892
+ if (tabEdit.newSections != null) {
893
+ tabEdit.newSections = tabEdit.newSections.map((section) =>
894
+ annotateLoadedFieldsSection(section, resolveFieldSectionId),
895
+ );
896
+ }
897
+ }
898
+ return tabEdits;
899
+ }
900
+
636
901
  function removeIndexes<T>(items: T[], indexes: number[]): void {
637
902
  for (const index of [...indexes].sort((a, b) => b - a)) {
638
903
  items.splice(index, 1);
@@ -715,7 +980,6 @@ function ensureArrayProperty<T>(record: Record<string, any>, key: string, label:
715
980
  * newTabs: [{
716
981
  * key: 'member', name: 'Member', type: 'tab_with_sections',
717
982
  * sections: [{
718
- * key: 'profile',
719
983
  * name: 'Profile',
720
984
  * type: 'fields_section',
721
985
  * section: profileSection,
@@ -764,7 +1028,7 @@ export class CustomObjectPageLayout {
764
1028
  this._customObjectApiName = customObject.getApiName();
765
1029
  this._name = props.name;
766
1030
  this._tabEdits = normalizeTabEdits(customObject, props.tabEdits ?? {});
767
- this._headerEdits = props.headerEdits ?? {};
1031
+ this._headerEdits = normalizeHeaderEdits(props.headerEdits ?? {});
768
1032
  this._visibilityConditions = props.visibilityConditions ?? {};
769
1033
  this._blueprintKey = props.blueprintKey;
770
1034
  customObject._register(this);
@@ -788,6 +1052,7 @@ export class CustomObjectPageLayout {
788
1052
  static _fromExistingComponent(
789
1053
  customObject: CustomObject,
790
1054
  component: Record<string, any>,
1055
+ resolveFieldSectionId?: (fieldApiName: string) => string | undefined,
791
1056
  ): CustomObjectPageLayout {
792
1057
  const layout = Object.create(CustomObjectPageLayout.prototype) as CustomObjectPageLayout;
793
1058
  Object.assign(layout as Record<string, any>, {
@@ -799,7 +1064,7 @@ export class CustomObjectPageLayout {
799
1064
  CustomObjectPageLayout.componentType,
800
1065
  ),
801
1066
  _name: requireStringComponentField(component, 'name', CustomObjectPageLayout.componentType),
802
- _tabEdits: cloneJson(component['tab_edits'] ?? {}),
1067
+ _tabEdits: annotateLoadedTabEdits(cloneJson(component['tab_edits'] ?? {}), resolveFieldSectionId),
803
1068
  _headerEdits: cloneJson(component['header_edits'] ?? {}),
804
1069
  _visibilityConditions: cloneJson(component['visibility_conditions'] ?? {}),
805
1070
  _blueprintKey: component['blueprint_key'],
@@ -842,17 +1107,59 @@ export class CustomObjectPageLayout {
842
1107
  /**
843
1108
  * Adds multiple fields to an existing layout section.
844
1109
  *
845
- * Fields are inserted in the order provided. If a field does not already have
846
- * field-section metadata, pass `options.fieldSection` so the corresponding
847
- * `CUSTOM_OBJECT_FIELD_SECTION` component is registered in the manifest.
1110
+ * Fields are inserted in the order provided. For custom layout sections,
1111
+ * pass `options.section` so TypeScript and runtime validation agree on the
1112
+ * target `CUSTOM_OBJECT_FIELD_SECTION`.
848
1113
  */
849
1114
  addFields(fields: Field[], options: AddPageLayoutFieldsOptions): this {
850
1115
  if (fields.length === 0) return this;
1116
+
1117
+ let fieldSectionForMutation: CustomObjectFieldSection | undefined;
851
1118
  const container =
852
1119
  options.systemSection === true ?
853
- this.ensureSystemSectionFieldContainer(options.tabKey, options.sectionKey)
854
- : this.requireOneFieldContainer(options.tabKey, options.sectionKey);
855
- const entries = fields.map((field) => this.serializeFieldForMutation(field, options.sectionKey, options));
1120
+ (() => {
1121
+ assertOptionAbsent(
1122
+ options,
1123
+ 'section',
1124
+ 'CustomObjectPageLayout addField section is only supported for custom layout sections.',
1125
+ );
1126
+ assertOptionAbsent(
1127
+ options,
1128
+ 'fieldSection',
1129
+ 'CustomObjectPageLayout addField fieldSection is no longer supported. ' +
1130
+ 'Use sectionKey only for systemSection: true.',
1131
+ );
1132
+ return this.ensureSystemSectionFieldContainer(options.tabKey, options.sectionKey);
1133
+ })()
1134
+ : (() => {
1135
+ assertOptionAbsent(
1136
+ options,
1137
+ 'sectionKey',
1138
+ 'CustomObjectPageLayout addField sectionKey is only supported with systemSection: true. ' +
1139
+ 'Pass section: CustomObjectFieldSection for custom layout sections.',
1140
+ );
1141
+ assertOptionAbsent(
1142
+ options,
1143
+ 'fieldSection',
1144
+ 'CustomObjectPageLayout addField fieldSection is no longer supported. ' +
1145
+ 'Pass section: CustomObjectFieldSection instead.',
1146
+ );
1147
+ const section = requireFieldSectionOption(
1148
+ options.section,
1149
+ 'CustomObjectPageLayout addField section',
1150
+ );
1151
+ this.assertFieldSectionBelongsToLayout(section);
1152
+ fieldSectionForMutation = section;
1153
+ return this.requireOneFieldContainer(options.tabKey, section.getSectionId(), section);
1154
+ })();
1155
+ const entries = fields.map((field) =>
1156
+ this.serializeFieldForMutation(
1157
+ field,
1158
+ container.sectionKey ?? container.label,
1159
+ options,
1160
+ fieldSectionForMutation,
1161
+ ),
1162
+ );
856
1163
 
857
1164
  if (options.allowDuplicate !== true) {
858
1165
  const existing = new Set(container.fields.map((entry) => fieldRqlNameOf(entry)).filter(Boolean));
@@ -883,9 +1190,34 @@ export class CustomObjectPageLayout {
883
1190
  const fieldApiName = typeof field === 'string' ? field : field.getApiName();
884
1191
  if (isField(field)) this.assertFieldBelongsToLayout(field);
885
1192
 
1193
+ let sectionKey: string | undefined;
1194
+ if (options.systemSection === true) {
1195
+ assertOptionAbsent(
1196
+ options,
1197
+ 'section',
1198
+ 'CustomObjectPageLayout removeField section is only supported for custom layout sections.',
1199
+ );
1200
+ sectionKey = options.sectionKey;
1201
+ } else {
1202
+ assertOptionAbsent(
1203
+ options,
1204
+ 'sectionKey',
1205
+ 'CustomObjectPageLayout removeField sectionKey is only supported with systemSection: true. ' +
1206
+ 'Pass section: CustomObjectFieldSection for custom layout sections.',
1207
+ );
1208
+ if (options.section != null) {
1209
+ const section = requireFieldSectionOption(
1210
+ options.section,
1211
+ 'CustomObjectPageLayout removeField section',
1212
+ );
1213
+ this.assertFieldSectionBelongsToLayout(section);
1214
+ sectionKey = section.getSectionId();
1215
+ }
1216
+ }
1217
+
886
1218
  const containers = this.findFieldContainers({
887
1219
  tabKey: options.tabKey,
888
- sectionKey: options.sectionKey,
1220
+ sectionKey,
889
1221
  systemSectionOnly: options.systemSection === true,
890
1222
  });
891
1223
  const matches: Array<{ container: MutableFieldContainer; index: number }> = [];
@@ -944,9 +1276,15 @@ export class CustomObjectPageLayout {
944
1276
  'newSections',
945
1277
  `systemTabEdits.${options.tabKey}.newSections`,
946
1278
  );
947
- this.assertSectionKeyAvailable(sections, normalized.key, options.tabKey, options.allowDuplicate);
1279
+ this.assertSectionKeyAvailable(
1280
+ sections,
1281
+ layoutSectionKey(normalized),
1282
+ options.tabKey,
1283
+ options.allowDuplicate,
1284
+ );
1285
+ const sectionsOrder = this.ensureSystemSectionsOrder(tabEdit);
948
1286
  insertAt(sections, normalized, options.position);
949
- if (tabEdit.sectionsOrder != null) insertAt(tabEdit.sectionsOrder, normalized.key, options.position);
1287
+ insertUniqueAt(sectionsOrder, layoutSectionKey(normalized), options.position);
950
1288
  return this;
951
1289
  }
952
1290
 
@@ -957,7 +1295,12 @@ export class CustomObjectPageLayout {
957
1295
  'Pass systemTab: true to add a section to a built-in system tab.',
958
1296
  );
959
1297
  }
960
- this.assertSectionKeyAvailable(tab.sections, normalized.key, options.tabKey, options.allowDuplicate);
1298
+ this.assertSectionKeyAvailable(
1299
+ tab.sections,
1300
+ layoutSectionKey(normalized),
1301
+ options.tabKey,
1302
+ options.allowDuplicate,
1303
+ );
961
1304
  insertAt(tab.sections, normalized, options.position);
962
1305
  return this;
963
1306
  }
@@ -968,19 +1311,34 @@ export class CustomObjectPageLayout {
968
1311
  * Pass `systemSection: true` with `tabKey` to delete a built-in section via
969
1312
  * `systemSectionEdits[sectionKey].deleted`.
970
1313
  */
971
- removeSection(sectionKey: string, options: RemovePageLayoutSectionOptions = {}): this {
1314
+ removeSection(section: CustomObjectFieldSection, options?: RemovePageLayoutSectionOptions): this;
1315
+ removeSection(sectionKey: string, options: RemoveSystemPageLayoutSectionOptions): this;
1316
+ removeSection(
1317
+ sectionOrKey: CustomObjectFieldSection | string,
1318
+ options: RemovePageLayoutSectionOptions | RemoveSystemPageLayoutSectionOptions = {},
1319
+ ): this {
972
1320
  if (options.systemSection === true) {
1321
+ if (typeof sectionOrKey !== 'string') {
1322
+ throw new TypeError(
1323
+ 'CustomObjectPageLayout removeSection with systemSection: true requires a system section key.',
1324
+ );
1325
+ }
973
1326
  if (options.tabKey == null) {
974
1327
  throw new Error('CustomObjectPageLayout removeSection with systemSection: true requires tabKey.');
975
1328
  }
976
- const sectionEdit = this.ensureSystemSectionEdit(options.tabKey, sectionKey);
1329
+ const sectionEdit = this.ensureSystemSectionEdit(options.tabKey, sectionOrKey);
977
1330
  sectionEdit.deleted = true;
978
- this.removeSectionVisibility(sectionKey);
1331
+ removeKeyFromOrder(this._tabEdits.systemTabEdits?.[options.tabKey]?.sectionsOrder, sectionOrKey);
1332
+ this.removeSectionVisibility(sectionOrKey);
979
1333
  return this;
980
1334
  }
981
1335
 
1336
+ const section = requireFieldSectionOption(sectionOrKey, 'CustomObjectPageLayout removeSection section');
1337
+ this.assertFieldSectionBelongsToLayout(section);
1338
+ const sectionKey = section.getSectionId();
982
1339
  const matches: Array<{
983
1340
  sections: SerializedFieldsSection[];
1341
+ section: SerializedFieldsSection;
984
1342
  index: number;
985
1343
  tabEdit?: SerializedSystemTabEdit;
986
1344
  tabKey: string;
@@ -989,14 +1347,15 @@ export class CustomObjectPageLayout {
989
1347
  if (!isTabWithSections(tab)) continue;
990
1348
  if (options.tabKey != null && tab.key !== options.tabKey) continue;
991
1349
  tab.sections.forEach((section, index) => {
992
- if (section.key === sectionKey) matches.push({ sections: tab.sections, index, tabKey: tab.key });
1350
+ if (layoutSectionMatches(section, sectionKey))
1351
+ matches.push({ sections: tab.sections, section, index, tabKey: tab.key });
993
1352
  });
994
1353
  }
995
1354
  for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) {
996
1355
  if (options.tabKey != null && tabKey !== options.tabKey) continue;
997
1356
  (tabEdit.newSections ?? []).forEach((section, index) => {
998
- if (section.key === sectionKey)
999
- matches.push({ sections: tabEdit.newSections!, index, tabEdit, tabKey });
1357
+ if (layoutSectionMatches(section, sectionKey))
1358
+ matches.push({ sections: tabEdit.newSections!, section, index, tabEdit, tabKey });
1000
1359
  });
1001
1360
  }
1002
1361
 
@@ -1018,11 +1377,11 @@ export class CustomObjectPageLayout {
1018
1377
  indexes.push(match.index);
1019
1378
  byArray.set(match.sections, indexes);
1020
1379
  if (match.tabEdit != null) removeKeyFromOrder(match.tabEdit.sectionsOrder, sectionKey);
1380
+ this.removeSectionVisibility(sectionKey);
1021
1381
  }
1022
1382
  for (const [sections, indexes] of byArray.entries()) {
1023
1383
  removeIndexes(sections, indexes);
1024
1384
  }
1025
- this.removeSectionVisibility(sectionKey);
1026
1385
  return this;
1027
1386
  }
1028
1387
 
@@ -1033,10 +1392,9 @@ export class CustomObjectPageLayout {
1033
1392
  if (options.allowDuplicate !== true && newTabs.some((existing) => existing.key === normalized.key)) {
1034
1393
  throw new Error(`CustomObjectPageLayout already contains tab "${normalized.key}".`);
1035
1394
  }
1395
+ const tabsOrder = this.ensureTabsOrder();
1036
1396
  insertAt(newTabs, normalized, options.position);
1037
- if (this._tabEdits.tabsOrder != null) {
1038
- insertAt(this._tabEdits.tabsOrder, normalized.key, options.position);
1039
- }
1397
+ insertUniqueAt(tabsOrder, normalized.key, options.position);
1040
1398
  return this;
1041
1399
  }
1042
1400
 
@@ -1076,10 +1434,9 @@ export class CustomObjectPageLayout {
1076
1434
  if (options.allowDuplicate !== true && newFields.some((existing) => existing.key === headerField.key)) {
1077
1435
  throw new Error(`CustomObjectPageLayout header already contains field key "${headerField.key}".`);
1078
1436
  }
1437
+ const headerFieldsOrder = this.ensureHeaderFieldsOrder();
1079
1438
  insertAt(newFields, headerField, options.position);
1080
- if (this._headerEdits.headerFieldsOrder != null) {
1081
- insertAt(this._headerEdits.headerFieldsOrder, headerField.key, options.position);
1082
- }
1439
+ insertUniqueAt(headerFieldsOrder, headerField.rqlName, options.position);
1083
1440
  return this;
1084
1441
  }
1085
1442
 
@@ -1089,12 +1446,12 @@ export class CustomObjectPageLayout {
1089
1446
  if (isField(fieldOrKey)) this.assertFieldBelongsToLayout(fieldOrKey);
1090
1447
  const key = typeof fieldOrKey === 'string' ? fieldOrKey : fieldApiName;
1091
1448
  const newFields = this._headerEdits.newFields ?? [];
1092
- const removedKeys: string[] = [];
1449
+ const removedFields: NewHeaderField[] = [];
1093
1450
  const removed = removeFromArray(
1094
1451
  newFields,
1095
1452
  (field) => {
1096
1453
  const matched = field.key === key || (fieldApiName != null && field.rqlName === fieldApiName);
1097
- if (matched) removedKeys.push(field.key);
1454
+ if (matched) removedFields.push(field);
1098
1455
  return matched;
1099
1456
  },
1100
1457
  `CustomObjectPageLayout header field "${key}"`,
@@ -1103,47 +1460,102 @@ export class CustomObjectPageLayout {
1103
1460
  if (removed === 0) {
1104
1461
  throw new Error(`CustomObjectPageLayout header does not contain field "${key}".`);
1105
1462
  }
1106
- for (const removedKey of removedKeys) {
1107
- removeKeyFromOrder(this._headerEdits.headerFieldsOrder, removedKey);
1108
- }
1463
+ for (const removedField of removedFields) this.removeHeaderOrderFieldIfUnused(removedField);
1109
1464
  return this;
1110
1465
  }
1111
1466
 
1112
1467
  /** Sets the record title field in `header_edits`. */
1113
1468
  setTitleField(field: Field | string | null): this {
1114
- this._headerEdits.newTitleField = this.headerFieldApiName(field);
1469
+ const previousTitleField = this._headerEdits.newTitleField;
1470
+ const nextTitleField = this.headerFieldApiName(field);
1471
+ this._headerEdits.newTitleField = nextTitleField;
1115
1472
  if (field != null) this._headerEdits.titleFieldDeleted = false;
1473
+ insertUniqueAt(this.ensureHeaderFieldsOrder(), nextTitleField, 'end');
1474
+ this.removeHeaderOrderFieldIfUnused(previousTitleField);
1116
1475
  return this;
1117
1476
  }
1118
1477
 
1119
1478
  /** Removes the record title field from the header. */
1120
1479
  removeTitleField(): this {
1480
+ const previousTitleField = this._headerEdits.newTitleField;
1121
1481
  this._headerEdits.newTitleField = null;
1122
1482
  this._headerEdits.titleFieldDeleted = true;
1483
+ this.removeHeaderOrderFieldIfUnused(previousTitleField);
1123
1484
  return this;
1124
1485
  }
1125
1486
 
1126
1487
  /** Sets the record description field in `header_edits`. */
1127
1488
  setDescriptionField(field: Field | string | null): this {
1128
- this._headerEdits.newDescriptionField = this.headerFieldApiName(field);
1489
+ const previousDescriptionField = this._headerEdits.newDescriptionField;
1490
+ const nextDescriptionField = this.headerFieldApiName(field);
1491
+ this._headerEdits.newDescriptionField = nextDescriptionField;
1129
1492
  if (field != null) this._headerEdits.descriptionFieldDeleted = false;
1493
+ insertUniqueAt(this.ensureHeaderFieldsOrder(), nextDescriptionField, 'end');
1494
+ this.removeHeaderOrderFieldIfUnused(previousDescriptionField);
1130
1495
  return this;
1131
1496
  }
1132
1497
 
1133
1498
  /** Removes the record description field from the header. */
1134
1499
  removeDescriptionField(): this {
1500
+ const previousDescriptionField = this._headerEdits.newDescriptionField;
1135
1501
  this._headerEdits.newDescriptionField = null;
1136
1502
  this._headerEdits.descriptionFieldDeleted = true;
1503
+ this.removeHeaderOrderFieldIfUnused(previousDescriptionField);
1137
1504
  return this;
1138
1505
  }
1139
1506
 
1507
+ private ensureTabsOrder(): string[] {
1508
+ const order = (this._tabEdits.tabsOrder ??= []);
1509
+ for (const tab of this._tabEdits.newTabs ?? []) insertUniqueAt(order, tab.key, 'end');
1510
+ for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) {
1511
+ if (tabEdit.deleted !== true) insertUniqueAt(order, tabKey, 'end');
1512
+ }
1513
+ return order;
1514
+ }
1515
+
1516
+ private ensureSystemSectionsOrder(tabEdit: SerializedSystemTabEdit): string[] {
1517
+ const order = (tabEdit.sectionsOrder ??= []);
1518
+ for (const section of tabEdit.newSections ?? []) insertUniqueAt(order, layoutSectionKey(section), 'end');
1519
+ for (const [sectionKey, sectionEdit] of Object.entries(tabEdit.systemSectionEdits ?? {})) {
1520
+ if (sectionEdit.deleted !== true) insertUniqueAt(order, sectionKey, 'end');
1521
+ }
1522
+ return order;
1523
+ }
1524
+
1525
+ private ensureHeaderFieldsOrder(): string[] {
1526
+ this._headerEdits.headerFieldsOrder = normalizeHeaderFieldsOrder({
1527
+ ...this._headerEdits,
1528
+ headerFieldsOrder: this._headerEdits.headerFieldsOrder ?? [],
1529
+ });
1530
+ return this._headerEdits.headerFieldsOrder;
1531
+ }
1532
+
1533
+ private headerOrderFieldIsUsed(rqlName: string): boolean {
1534
+ return (
1535
+ this._headerEdits.newTitleField === rqlName ||
1536
+ this._headerEdits.newDescriptionField === rqlName ||
1537
+ (this._headerEdits.newFields ?? []).some((field) => field.rqlName === rqlName) ||
1538
+ Object.values(this._headerEdits.systemFieldEdits ?? {}).some((edit) => edit.newRqlField === rqlName)
1539
+ );
1540
+ }
1541
+
1542
+ private removeHeaderOrderFieldIfUnused(field: NewHeaderField | string | null | undefined): void {
1543
+ const rqlName = typeof field === 'string' ? field : field?.rqlName;
1544
+ if (field != null && typeof field !== 'string' && field.key !== rqlName) {
1545
+ removeKeyFromOrder(this._headerEdits.headerFieldsOrder, field.key);
1546
+ }
1547
+ if (rqlName == null || this.headerOrderFieldIsUsed(rqlName)) return;
1548
+ removeKeyFromOrder(this._headerEdits.headerFieldsOrder, rqlName);
1549
+ }
1550
+
1140
1551
  private serializeFieldForMutation(
1141
1552
  field: Field,
1142
1553
  fallbackSectionKey: string,
1143
1554
  options: AddPageLayoutFieldOptions,
1555
+ fieldSection: CustomObjectFieldSection | undefined,
1144
1556
  ): SerializedSectionField {
1145
1557
  this.assertFieldBelongsToLayout(field);
1146
- this.assignFieldSectionForMutation(field, fallbackSectionKey, options.fieldSection);
1558
+ this.assignFieldSectionForMutation(field, fallbackSectionKey, fieldSection);
1147
1559
  return toSerializedSectionField(field.getApiName(), options);
1148
1560
  }
1149
1561
 
@@ -1171,23 +1583,22 @@ export class CustomObjectPageLayout {
1171
1583
  }
1172
1584
 
1173
1585
  private normalizeSectionForMutation(input: PageLayoutSection): SerializedFieldsSection {
1174
- if (input.section.getModelApiName() !== this._customObjectApiName) {
1175
- throw new Error(
1176
- `CustomObjectPageLayout section "${input.section.getSectionId()}" belongs to ` +
1177
- `"${input.section.getModelApiName()}", but the layout belongs to "${this._customObjectApiName}".`,
1178
- );
1179
- }
1586
+ this.assertFieldSectionBelongsToLayout(input.section);
1180
1587
 
1181
- const key = input.key ?? input.section.getSectionId();
1588
+ const sectionId = input.section.getSectionId();
1589
+ if (inputFieldsSectionHasKey(input)) {
1590
+ throw new Error('CustomObjectPageLayout fields_section key is not supported. Omit key.');
1591
+ }
1182
1592
  const name = input.name ?? input.section.getName();
1183
1593
  const normalized: SerializedFieldsSection = {
1184
- key,
1185
1594
  name,
1186
1595
  type: 'fields_section',
1187
- fields: input.fields.map((field) => this.normalizeSectionFieldForMutation(input.section, key, field)),
1596
+ fields: input.fields.map((field) =>
1597
+ this.normalizeSectionFieldForMutation(input.section, sectionId, field),
1598
+ ),
1188
1599
  };
1189
1600
  if (input.layout != null) normalized.layout = input.layout;
1190
- return normalized;
1601
+ return attachFieldSectionId(normalized, sectionId);
1191
1602
  }
1192
1603
 
1193
1604
  private normalizeTabForMutation(tab: PageLayoutTab): SerializedPageLayoutTab {
@@ -1207,7 +1618,7 @@ export class CustomObjectPageLayout {
1207
1618
  if (rawFieldSection != null) {
1208
1619
  if (typeof rawFieldSection === 'string') {
1209
1620
  throw new TypeError(
1210
- `CustomObjectPageLayout fieldSection must be a CustomObjectFieldSection object; ` +
1621
+ `CustomObjectPageLayout section must be a CustomObjectFieldSection object; ` +
1211
1622
  `received raw section id "${rawFieldSection}". Load/include the existing field section first.`,
1212
1623
  );
1213
1624
  }
@@ -1226,7 +1637,7 @@ export class CustomObjectPageLayout {
1226
1637
  if (field.getSectionId() == null) {
1227
1638
  throw new Error(
1228
1639
  `CustomObjectPageLayout field "${field.getApiName()}" is missing field-section metadata. ` +
1229
- `Construct the field with a CustomObjectFieldSection or pass fieldSection when adding it to ` +
1640
+ `Construct the field with a CustomObjectFieldSection or pass section when adding it to ` +
1230
1641
  `section "${fallbackSectionKey}".`,
1231
1642
  );
1232
1643
  }
@@ -1241,17 +1652,63 @@ export class CustomObjectPageLayout {
1241
1652
  }
1242
1653
  }
1243
1654
 
1244
- private requireOneFieldContainer(tabKey: string | undefined, sectionKey: string): MutableFieldContainer {
1655
+ private assertFieldSectionBelongsToLayout(section: CustomObjectFieldSection): void {
1656
+ if (section.getModelApiName() !== this._customObjectApiName) {
1657
+ throw new Error(
1658
+ `CustomObjectPageLayout field section "${section.getSectionId()}" belongs to ` +
1659
+ `"${section.getModelApiName()}", but the layout belongs to "${this._customObjectApiName}".`,
1660
+ );
1661
+ }
1662
+ }
1663
+
1664
+ private requireOneFieldContainer(
1665
+ tabKey: string | undefined,
1666
+ sectionKey: string | undefined,
1667
+ fallbackFieldSection?: CustomObjectFieldSection,
1668
+ ): MutableFieldContainer {
1245
1669
  const containers = this.findFieldContainers({
1246
1670
  tabKey,
1247
1671
  sectionKey,
1248
1672
  systemSectionOnly: false,
1249
1673
  });
1250
1674
  if (containers.length === 0) {
1251
- const prefix = tabKey != null ? `${tabKey}.` : '';
1252
- throw new Error(`CustomObjectPageLayout could not find section "${prefix}${sectionKey}".`);
1675
+ if (sectionKey != null && fallbackFieldSection != null) {
1676
+ const unkeyedContainers = this.findFieldContainers({
1677
+ tabKey,
1678
+ sectionKey: undefined,
1679
+ systemSectionOnly: false,
1680
+ }).filter((container) => container.section != null && container.sectionKey == null);
1681
+ if (unkeyedContainers.length === 1) {
1682
+ if (fallbackFieldSection.getModelApiName() !== this._customObjectApiName) {
1683
+ throw new Error(
1684
+ `CustomObjectPageLayout field section "${fallbackFieldSection.getSectionId()}" belongs to ` +
1685
+ `"${fallbackFieldSection.getModelApiName()}", but the layout belongs to "${
1686
+ this._customObjectApiName
1687
+ }".`,
1688
+ );
1689
+ }
1690
+ const container = unkeyedContainers[0] as MutableFieldContainer & {
1691
+ section: SerializedFieldsSection;
1692
+ };
1693
+ attachFieldSectionId(container.section, fallbackFieldSection.getSectionId());
1694
+ container.sectionKey = fallbackFieldSection.getSectionId();
1695
+ container.label = `${container.tabKey}.${container.sectionKey}`;
1696
+ return container;
1697
+ }
1698
+ }
1699
+ const scope =
1700
+ sectionKey == null ? 'a field section' : (
1701
+ `section "${tabKey != null ? `${tabKey}.` : ''}${sectionKey}"`
1702
+ );
1703
+ throw new Error(`CustomObjectPageLayout could not find ${scope}.`);
1253
1704
  }
1254
1705
  if (containers.length > 1) {
1706
+ if (sectionKey == null) {
1707
+ throw new Error(
1708
+ `CustomObjectPageLayout matched multiple sections: ` +
1709
+ `${containers.map((container) => container.label).join(', ')}. Pass section to choose one.`,
1710
+ );
1711
+ }
1255
1712
  throw new Error(
1256
1713
  `CustomObjectPageLayout section "${sectionKey}" matched multiple sections: ` +
1257
1714
  `${containers.map((container) => container.label).join(', ')}. Pass tabKey to choose one.`,
@@ -1271,34 +1728,40 @@ export class CustomObjectPageLayout {
1271
1728
  for (const tab of this._tabEdits.newTabs ?? []) {
1272
1729
  if (!isTabWithSections(tab)) continue;
1273
1730
  if (filter.tabKey != null && tab.key !== filter.tabKey) continue;
1274
- for (const section of tab.sections) {
1275
- if (filter.sectionKey != null && section.key !== filter.sectionKey) continue;
1731
+ for (const [index, section] of tab.sections.entries()) {
1732
+ const sectionKey = layoutSectionKey(section);
1733
+ if (filter.sectionKey != null && !layoutSectionMatches(section, filter.sectionKey)) continue;
1734
+ const label = sectionKey == null ? `${tab.key}.sections[${index}]` : `${tab.key}.${sectionKey}`;
1276
1735
  containers.push({
1277
1736
  tabKey: tab.key,
1278
- sectionKey: section.key,
1737
+ sectionKey,
1738
+ section,
1279
1739
  fields: ensureArrayProperty<MutableSectionField>(
1280
1740
  section as unknown as Record<string, any>,
1281
1741
  'fields',
1282
- `newTabs.${tab.key}.sections.${section.key}.fields`,
1742
+ `newTabs.${label}.fields`,
1283
1743
  ),
1284
- label: `${tab.key}.${section.key}`,
1744
+ label,
1285
1745
  });
1286
1746
  }
1287
1747
  }
1288
1748
 
1289
1749
  for (const [tabKey, tabEdit] of Object.entries(this._tabEdits.systemTabEdits ?? {})) {
1290
1750
  if (filter.tabKey != null && tabKey !== filter.tabKey) continue;
1291
- for (const section of tabEdit.newSections ?? []) {
1292
- if (filter.sectionKey != null && section.key !== filter.sectionKey) continue;
1751
+ for (const [index, section] of (tabEdit.newSections ?? []).entries()) {
1752
+ const sectionKey = layoutSectionKey(section);
1753
+ if (filter.sectionKey != null && !layoutSectionMatches(section, filter.sectionKey)) continue;
1754
+ const label = sectionKey == null ? `${tabKey}.newSections[${index}]` : `${tabKey}.${sectionKey}`;
1293
1755
  containers.push({
1294
1756
  tabKey,
1295
- sectionKey: section.key,
1757
+ sectionKey,
1758
+ section,
1296
1759
  fields: ensureArrayProperty<MutableSectionField>(
1297
1760
  section as unknown as Record<string, any>,
1298
1761
  'fields',
1299
- `systemTabEdits.${tabKey}.newSections.${section.key}.fields`,
1762
+ `systemTabEdits.${label}.fields`,
1300
1763
  ),
1301
- label: `${tabKey}.${section.key}`,
1764
+ label,
1302
1765
  });
1303
1766
  }
1304
1767
  }
@@ -1329,11 +1792,14 @@ export class CustomObjectPageLayout {
1329
1792
 
1330
1793
  private ensureSystemSectionFieldContainer(
1331
1794
  tabKey: string | undefined,
1332
- sectionKey: string,
1795
+ sectionKey: string | undefined,
1333
1796
  ): MutableFieldContainer {
1334
1797
  if (tabKey == null) {
1335
1798
  throw new Error('CustomObjectPageLayout addField with systemSection: true requires tabKey.');
1336
1799
  }
1800
+ if (sectionKey == null) {
1801
+ throw new Error('CustomObjectPageLayout addField with systemSection: true requires sectionKey.');
1802
+ }
1337
1803
  const sectionEdit = this.ensureSystemSectionEdit(tabKey, sectionKey);
1338
1804
  if (sectionEdit.deleted === true) {
1339
1805
  throw new Error(`CustomObjectPageLayout system section "${tabKey}.${sectionKey}" is marked deleted.`);
@@ -1396,12 +1862,12 @@ export class CustomObjectPageLayout {
1396
1862
 
1397
1863
  private assertSectionKeyAvailable(
1398
1864
  sections: SerializedFieldsSection[],
1399
- sectionKey: string,
1865
+ sectionKey: string | undefined,
1400
1866
  tabKey: string,
1401
1867
  allowDuplicate: boolean | undefined,
1402
1868
  ): void {
1403
1869
  if (allowDuplicate === true) return;
1404
- if (sections.some((section) => section.key === sectionKey)) {
1870
+ if (sectionKey != null && sections.some((section) => layoutSectionMatches(section, sectionKey))) {
1405
1871
  throw new Error(`CustomObjectPageLayout tab "${tabKey}" already contains section "${sectionKey}".`);
1406
1872
  }
1407
1873
  }
@@ -1476,12 +1942,13 @@ export class CustomObjectPageLayout {
1476
1942
  * tabs or sections. Always uses `apiName: 'default'`.
1477
1943
  *
1478
1944
  * @param customObject - The custom object to create the layout for.
1479
- * @param props - Optional. Pass `fields` to populate the General section.
1945
+ * @param props - Optional. Pass `section` and `fields` to populate the General section.
1480
1946
  * @returns A new `CustomObjectPageLayout` registered with the manifest.
1481
1947
  *
1482
1948
  * @example
1483
1949
  * ```ts
1484
1950
  * CustomObjectPageLayout.basic(memberObj, {
1951
+ * section: profileSection,
1485
1952
  * fields: [memberFirstName, memberEmail, memberStatusField],
1486
1953
  * });
1487
1954
  * ```
@@ -1489,16 +1956,40 @@ export class CustomObjectPageLayout {
1489
1956
  static basic(
1490
1957
  customObject: CustomObject,
1491
1958
  props: {
1959
+ section?: CustomObjectFieldSection;
1492
1960
  fields?: Field[];
1493
1961
  } = {},
1494
1962
  ): CustomObjectPageLayout {
1495
1963
  const coApiName = customObject.getApiName();
1496
1964
  const coName = customObject.getName();
1497
- const sectionPrefix = coApiName.replace(/__c$/, '').replace(/[^a-zA-Z0-9]+/g, '_');
1498
- const generalSection = new CustomObjectFieldSection(customObject, {
1499
- sectionId: `sec_${sectionPrefix}_general`,
1500
- name: 'General',
1501
- });
1965
+ const fields = props.fields ?? [];
1966
+ const generalSection =
1967
+ props.section ??
1968
+ (() => {
1969
+ const alreadySectionedField = fields.find((field) => field.getSectionId() != null);
1970
+ if (alreadySectionedField != null) {
1971
+ throw new Error(
1972
+ `CustomObjectPageLayout.basic field "${alreadySectionedField.getApiName()}" already belongs to ` +
1973
+ `field section "${alreadySectionedField.getSectionId()}". Pass that CustomObjectFieldSection ` +
1974
+ 'as section so basic() does not reassign the field.',
1975
+ );
1976
+ }
1977
+ const sectionPrefix = coApiName.replace(/__c$/, '').replace(/[^a-zA-Z0-9]+/g, '_');
1978
+ return new CustomObjectFieldSection(customObject, {
1979
+ sectionId: `sec_${sectionPrefix}_general`,
1980
+ name: 'General',
1981
+ });
1982
+ })();
1983
+ assertSectionBelongsTo(customObject, generalSection);
1984
+ for (const field of fields) {
1985
+ const fieldSectionId = field.getSectionId();
1986
+ if (fieldSectionId != null && fieldSectionId !== generalSection.getSectionId()) {
1987
+ throw new Error(
1988
+ `CustomObjectPageLayout.basic field "${field.getApiName()}" belongs to field section ` +
1989
+ `"${fieldSectionId}", but basic() was given section "${generalSection.getSectionId()}".`,
1990
+ );
1991
+ }
1992
+ }
1502
1993
  return new CustomObjectPageLayout(customObject, {
1503
1994
  apiName: 'default',
1504
1995
  name: `${coName} Layout`,
@@ -1510,11 +2001,10 @@ export class CustomObjectPageLayout {
1510
2001
  type: 'tab_with_sections',
1511
2002
  sections: [
1512
2003
  {
1513
- key: 'general',
1514
2004
  name: 'General',
1515
2005
  type: 'fields_section',
1516
2006
  section: generalSection,
1517
- fields: props.fields ?? [],
2007
+ fields,
1518
2008
  },
1519
2009
  ],
1520
2010
  },