@servicenow/sdk-build-plugins 4.4.1 → 4.5.0

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 (287) hide show
  1. package/dist/acl-plugin.js +54 -4
  2. package/dist/acl-plugin.js.map +1 -1
  3. package/dist/applicability-plugin.js +2 -0
  4. package/dist/applicability-plugin.js.map +1 -1
  5. package/dist/application-menu-plugin.js +2 -0
  6. package/dist/application-menu-plugin.js.map +1 -1
  7. package/dist/arrow-function-plugin.d.ts +6 -1
  8. package/dist/arrow-function-plugin.js +105 -12
  9. package/dist/arrow-function-plugin.js.map +1 -1
  10. package/dist/atf/test-plugin.js +2 -0
  11. package/dist/atf/test-plugin.js.map +1 -1
  12. package/dist/basic-syntax-plugin.js +20 -0
  13. package/dist/basic-syntax-plugin.js.map +1 -1
  14. package/dist/call-expression-plugin.js +1 -0
  15. package/dist/call-expression-plugin.js.map +1 -1
  16. package/dist/claims-plugin.js +1 -0
  17. package/dist/claims-plugin.js.map +1 -1
  18. package/dist/client-script-plugin.js +1 -0
  19. package/dist/client-script-plugin.js.map +1 -1
  20. package/dist/column-plugin.js +1 -0
  21. package/dist/column-plugin.js.map +1 -1
  22. package/dist/cross-scope-privilege-plugin.js +1 -0
  23. package/dist/cross-scope-privilege-plugin.js.map +1 -1
  24. package/dist/dashboard/dashboard-plugin.js +2 -0
  25. package/dist/dashboard/dashboard-plugin.js.map +1 -1
  26. package/dist/data-plugin.js +1 -0
  27. package/dist/data-plugin.js.map +1 -1
  28. package/dist/email-notification-plugin.js +9 -13
  29. package/dist/email-notification-plugin.js.map +1 -1
  30. package/dist/flow/constants/flow-plugin-constants.d.ts +1 -1
  31. package/dist/flow/constants/flow-plugin-constants.js +1 -1
  32. package/dist/flow/constants/flow-plugin-constants.js.map +1 -1
  33. package/dist/flow/flow-logic/flow-logic-plugin-helpers.d.ts +82 -2
  34. package/dist/flow/flow-logic/flow-logic-plugin-helpers.js +48 -40
  35. package/dist/flow/flow-logic/flow-logic-plugin-helpers.js.map +1 -1
  36. package/dist/flow/flow-logic/flow-logic-plugin.js +1 -0
  37. package/dist/flow/flow-logic/flow-logic-plugin.js.map +1 -1
  38. package/dist/flow/plugins/approval-rules-plugin.js +1 -0
  39. package/dist/flow/plugins/approval-rules-plugin.js.map +1 -1
  40. package/dist/flow/plugins/flow-action-definition-plugin.js +4 -2
  41. package/dist/flow/plugins/flow-action-definition-plugin.js.map +1 -1
  42. package/dist/flow/plugins/flow-data-pill-plugin.js +1 -0
  43. package/dist/flow/plugins/flow-data-pill-plugin.js.map +1 -1
  44. package/dist/flow/plugins/flow-definition-plugin.js +8 -3
  45. package/dist/flow/plugins/flow-definition-plugin.js.map +1 -1
  46. package/dist/flow/plugins/flow-diagnostics-plugin.js +1 -0
  47. package/dist/flow/plugins/flow-diagnostics-plugin.js.map +1 -1
  48. package/dist/flow/plugins/flow-instance-plugin.js +68 -12
  49. package/dist/flow/plugins/flow-instance-plugin.js.map +1 -1
  50. package/dist/flow/plugins/flow-trigger-instance-plugin.js +1 -0
  51. package/dist/flow/plugins/flow-trigger-instance-plugin.js.map +1 -1
  52. package/dist/flow/plugins/inline-script-plugin.js +1 -0
  53. package/dist/flow/plugins/inline-script-plugin.js.map +1 -1
  54. package/dist/flow/plugins/step-definition-plugin.js +3 -2
  55. package/dist/flow/plugins/step-definition-plugin.js.map +1 -1
  56. package/dist/flow/plugins/step-instance-plugin.js +1 -0
  57. package/dist/flow/plugins/step-instance-plugin.js.map +1 -1
  58. package/dist/flow/plugins/trigger-plugin.js +2 -0
  59. package/dist/flow/plugins/trigger-plugin.js.map +1 -1
  60. package/dist/flow/plugins/wfa-datapill-plugin.js +1 -0
  61. package/dist/flow/plugins/wfa-datapill-plugin.js.map +1 -1
  62. package/dist/flow/post-install.d.ts +2 -0
  63. package/dist/flow/post-install.js +58 -0
  64. package/dist/flow/post-install.js.map +1 -0
  65. package/dist/flow/utils/complex-objects.js +4 -2
  66. package/dist/flow/utils/complex-objects.js.map +1 -1
  67. package/dist/flow/utils/flow-constants.d.ts +24 -0
  68. package/dist/flow/utils/flow-constants.js +29 -2
  69. package/dist/flow/utils/flow-constants.js.map +1 -1
  70. package/dist/flow/utils/flow-to-xml.d.ts +3 -2
  71. package/dist/flow/utils/flow-to-xml.js +3 -4
  72. package/dist/flow/utils/flow-to-xml.js.map +1 -1
  73. package/dist/flow/utils/label-cache-processor.d.ts +5 -0
  74. package/dist/flow/utils/label-cache-processor.js +14 -2
  75. package/dist/flow/utils/label-cache-processor.js.map +1 -1
  76. package/dist/flow/utils/service-catalog.js +5 -1
  77. package/dist/flow/utils/service-catalog.js.map +1 -1
  78. package/dist/form-plugin.d.ts +2 -0
  79. package/dist/form-plugin.js +1134 -0
  80. package/dist/form-plugin.js.map +1 -0
  81. package/dist/html-import-plugin.js +1 -0
  82. package/dist/html-import-plugin.js.map +1 -1
  83. package/dist/import-sets-plugin.js +2 -0
  84. package/dist/import-sets-plugin.js.map +1 -1
  85. package/dist/index.d.ts +9 -0
  86. package/dist/index.js +13 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/instance-scan-plugin.d.ts +2 -0
  89. package/dist/instance-scan-plugin.js +298 -0
  90. package/dist/instance-scan-plugin.js.map +1 -0
  91. package/dist/json-plugin.js +1 -0
  92. package/dist/json-plugin.js.map +1 -1
  93. package/dist/list-plugin.js +1 -0
  94. package/dist/list-plugin.js.map +1 -1
  95. package/dist/now-attach-plugin.js +1 -0
  96. package/dist/now-attach-plugin.js.map +1 -1
  97. package/dist/now-config-plugin.js +659 -51
  98. package/dist/now-config-plugin.js.map +1 -1
  99. package/dist/now-id-plugin.js +1 -0
  100. package/dist/now-id-plugin.js.map +1 -1
  101. package/dist/now-include-plugin.js +1 -0
  102. package/dist/now-include-plugin.js.map +1 -1
  103. package/dist/now-ref-plugin.js +1 -0
  104. package/dist/now-ref-plugin.js.map +1 -1
  105. package/dist/now-unresolved-plugin.js +1 -0
  106. package/dist/now-unresolved-plugin.js.map +1 -1
  107. package/dist/package-json-plugin.js +1 -0
  108. package/dist/package-json-plugin.js.map +1 -1
  109. package/dist/property-plugin.js +3 -1
  110. package/dist/property-plugin.js.map +1 -1
  111. package/dist/record-plugin.d.ts +30 -0
  112. package/dist/record-plugin.js +37 -1
  113. package/dist/record-plugin.js.map +1 -1
  114. package/dist/repack/lint/Rules.d.ts +11 -2
  115. package/dist/repack/lint/Rules.js +160 -16
  116. package/dist/repack/lint/Rules.js.map +1 -1
  117. package/dist/repack/lint/index.d.ts +10 -5
  118. package/dist/repack/lint/index.js +76 -50
  119. package/dist/repack/lint/index.js.map +1 -1
  120. package/dist/rest-api-plugin.js +14 -0
  121. package/dist/rest-api-plugin.js.map +1 -1
  122. package/dist/role-plugin.js +1 -0
  123. package/dist/role-plugin.js.map +1 -1
  124. package/dist/schedule-script/index.d.ts +1 -0
  125. package/dist/schedule-script/index.js +18 -0
  126. package/dist/schedule-script/index.js.map +1 -0
  127. package/dist/schedule-script/scheduled-script-plugin.d.ts +2 -0
  128. package/dist/schedule-script/scheduled-script-plugin.js +551 -0
  129. package/dist/schedule-script/scheduled-script-plugin.js.map +1 -0
  130. package/dist/schedule-script/timeZoneConverter.d.ts +61 -0
  131. package/dist/schedule-script/timeZoneConverter.js +170 -0
  132. package/dist/schedule-script/timeZoneConverter.js.map +1 -0
  133. package/dist/script-action-plugin.js +2 -0
  134. package/dist/script-action-plugin.js.map +1 -1
  135. package/dist/script-include-plugin.js +2 -0
  136. package/dist/script-include-plugin.js.map +1 -1
  137. package/dist/server-module-plugin/index.js +13 -2
  138. package/dist/server-module-plugin/index.js.map +1 -1
  139. package/dist/service-catalog/catalog-clientscript-plugin.js +2 -0
  140. package/dist/service-catalog/catalog-clientscript-plugin.js.map +1 -1
  141. package/dist/service-catalog/catalog-item-plugin.js +2 -0
  142. package/dist/service-catalog/catalog-item-plugin.js.map +1 -1
  143. package/dist/service-catalog/catalog-ui-policy-plugin.js +2 -0
  144. package/dist/service-catalog/catalog-ui-policy-plugin.js.map +1 -1
  145. package/dist/service-catalog/sc-record-producer-plugin.js +2 -0
  146. package/dist/service-catalog/sc-record-producer-plugin.js.map +1 -1
  147. package/dist/service-catalog/service-catalog-diagnostics.d.ts +6 -0
  148. package/dist/service-catalog/service-catalog-diagnostics.js +20 -0
  149. package/dist/service-catalog/service-catalog-diagnostics.js.map +1 -1
  150. package/dist/service-catalog/shape-to-record.js +7 -2
  151. package/dist/service-catalog/shape-to-record.js.map +1 -1
  152. package/dist/service-catalog/variable-set-plugin.js +2 -0
  153. package/dist/service-catalog/variable-set-plugin.js.map +1 -1
  154. package/dist/service-portal/angular-provider-plugin.js +2 -0
  155. package/dist/service-portal/angular-provider-plugin.js.map +1 -1
  156. package/dist/service-portal/dependency-plugin.js +5 -31
  157. package/dist/service-portal/dependency-plugin.js.map +1 -1
  158. package/dist/service-portal/menu-plugin.d.ts +2 -0
  159. package/dist/service-portal/menu-plugin.js +353 -0
  160. package/dist/service-portal/menu-plugin.js.map +1 -0
  161. package/dist/service-portal/page-plugin.d.ts +2 -0
  162. package/dist/service-portal/page-plugin.js +702 -0
  163. package/dist/service-portal/page-plugin.js.map +1 -0
  164. package/dist/service-portal/portal-plugin.d.ts +2 -0
  165. package/dist/service-portal/portal-plugin.js +296 -0
  166. package/dist/service-portal/portal-plugin.js.map +1 -0
  167. package/dist/service-portal/theme-plugin.d.ts +2 -0
  168. package/dist/service-portal/theme-plugin.js +112 -0
  169. package/dist/service-portal/theme-plugin.js.map +1 -0
  170. package/dist/service-portal/utils.d.ts +8 -0
  171. package/dist/service-portal/utils.js +50 -0
  172. package/dist/service-portal/utils.js.map +1 -0
  173. package/dist/service-portal/widget-plugin.js +45 -8
  174. package/dist/service-portal/widget-plugin.js.map +1 -1
  175. package/dist/sla-plugin.js +2 -0
  176. package/dist/sla-plugin.js.map +1 -1
  177. package/dist/static-content-plugin.js +1 -0
  178. package/dist/static-content-plugin.js.map +1 -1
  179. package/dist/table-plugin.js +1 -0
  180. package/dist/table-plugin.js.map +1 -1
  181. package/dist/ui-action-plugin.js +2 -0
  182. package/dist/ui-action-plugin.js.map +1 -1
  183. package/dist/ui-page-plugin.js +33 -8
  184. package/dist/ui-page-plugin.js.map +1 -1
  185. package/dist/ui-policy-plugin.js +1 -0
  186. package/dist/ui-policy-plugin.js.map +1 -1
  187. package/dist/user-preference-plugin.js +2 -0
  188. package/dist/user-preference-plugin.js.map +1 -1
  189. package/dist/utils.d.ts +20 -2
  190. package/dist/utils.js +34 -3
  191. package/dist/utils.js.map +1 -1
  192. package/dist/ux-list-menu-config-plugin.js +2 -0
  193. package/dist/ux-list-menu-config-plugin.js.map +1 -1
  194. package/dist/view-plugin.js +1 -0
  195. package/dist/view-plugin.js.map +1 -1
  196. package/dist/workspace-plugin.js +2 -0
  197. package/dist/workspace-plugin.js.map +1 -1
  198. package/package.json +10 -11
  199. package/src/_types/eslint-community-eslint-utils.d.ts +15 -0
  200. package/src/acl-plugin.ts +97 -8
  201. package/src/applicability-plugin.ts +2 -0
  202. package/src/application-menu-plugin.ts +2 -0
  203. package/src/arrow-function-plugin.ts +128 -13
  204. package/src/atf/test-plugin.ts +2 -0
  205. package/src/basic-syntax-plugin.ts +21 -0
  206. package/src/call-expression-plugin.ts +1 -0
  207. package/src/claims-plugin.ts +1 -0
  208. package/src/client-script-plugin.ts +2 -1
  209. package/src/column-plugin.ts +1 -0
  210. package/src/cross-scope-privilege-plugin.ts +2 -1
  211. package/src/dashboard/dashboard-plugin.ts +2 -0
  212. package/src/data-plugin.ts +1 -0
  213. package/src/email-notification-plugin.ts +3 -23
  214. package/src/flow/constants/flow-plugin-constants.ts +1 -1
  215. package/src/flow/flow-logic/flow-logic-plugin-helpers.ts +47 -45
  216. package/src/flow/flow-logic/flow-logic-plugin.ts +1 -0
  217. package/src/flow/plugins/approval-rules-plugin.ts +1 -0
  218. package/src/flow/plugins/flow-action-definition-plugin.ts +4 -2
  219. package/src/flow/plugins/flow-data-pill-plugin.ts +1 -0
  220. package/src/flow/plugins/flow-definition-plugin.ts +10 -4
  221. package/src/flow/plugins/flow-diagnostics-plugin.ts +1 -0
  222. package/src/flow/plugins/flow-instance-plugin.ts +103 -14
  223. package/src/flow/plugins/flow-trigger-instance-plugin.ts +1 -0
  224. package/src/flow/plugins/inline-script-plugin.ts +1 -0
  225. package/src/flow/plugins/step-definition-plugin.ts +3 -2
  226. package/src/flow/plugins/step-instance-plugin.ts +1 -0
  227. package/src/flow/plugins/trigger-plugin.ts +2 -0
  228. package/src/flow/plugins/wfa-datapill-plugin.ts +1 -0
  229. package/src/flow/post-install.ts +92 -0
  230. package/src/flow/utils/complex-objects.ts +10 -2
  231. package/src/flow/utils/flow-constants.ts +30 -1
  232. package/src/flow/utils/flow-to-xml.ts +4 -4
  233. package/src/flow/utils/label-cache-processor.ts +14 -2
  234. package/src/flow/utils/service-catalog.ts +5 -2
  235. package/src/form-plugin.ts +1411 -0
  236. package/src/html-import-plugin.ts +1 -0
  237. package/src/import-sets-plugin.ts +2 -0
  238. package/src/index.ts +9 -0
  239. package/src/instance-scan-plugin.ts +318 -0
  240. package/src/json-plugin.ts +1 -0
  241. package/src/list-plugin.ts +2 -1
  242. package/src/now-attach-plugin.ts +1 -0
  243. package/src/now-config-plugin.ts +833 -53
  244. package/src/now-id-plugin.ts +1 -0
  245. package/src/now-include-plugin.ts +1 -0
  246. package/src/now-ref-plugin.ts +1 -0
  247. package/src/now-unresolved-plugin.ts +1 -0
  248. package/src/package-json-plugin.ts +1 -0
  249. package/src/property-plugin.ts +3 -1
  250. package/src/record-plugin.ts +42 -2
  251. package/src/repack/lint/Rules.ts +171 -22
  252. package/src/repack/lint/index.ts +80 -56
  253. package/src/rest-api-plugin.ts +21 -1
  254. package/src/role-plugin.ts +2 -1
  255. package/src/schedule-script/index.ts +1 -0
  256. package/src/schedule-script/scheduled-script-plugin.ts +679 -0
  257. package/src/schedule-script/timeZoneConverter.ts +188 -0
  258. package/src/script-action-plugin.ts +2 -0
  259. package/src/script-include-plugin.ts +2 -0
  260. package/src/server-module-plugin/index.ts +14 -2
  261. package/src/service-catalog/catalog-clientscript-plugin.ts +2 -0
  262. package/src/service-catalog/catalog-item-plugin.ts +2 -0
  263. package/src/service-catalog/catalog-ui-policy-plugin.ts +2 -0
  264. package/src/service-catalog/sc-record-producer-plugin.ts +2 -0
  265. package/src/service-catalog/service-catalog-diagnostics.ts +30 -0
  266. package/src/service-catalog/shape-to-record.ts +8 -2
  267. package/src/service-catalog/variable-set-plugin.ts +2 -0
  268. package/src/service-portal/angular-provider-plugin.ts +2 -0
  269. package/src/service-portal/dependency-plugin.ts +6 -53
  270. package/src/service-portal/menu-plugin.ts +435 -0
  271. package/src/service-portal/page-plugin.ts +830 -0
  272. package/src/service-portal/portal-plugin.ts +319 -0
  273. package/src/service-portal/theme-plugin.ts +135 -0
  274. package/src/service-portal/utils.ts +69 -0
  275. package/src/service-portal/widget-plugin.ts +79 -9
  276. package/src/sla-plugin.ts +2 -0
  277. package/src/static-content-plugin.ts +1 -0
  278. package/src/table-plugin.ts +2 -1
  279. package/src/ui-action-plugin.ts +2 -0
  280. package/src/ui-page-plugin.ts +34 -8
  281. package/src/ui-policy-plugin.ts +2 -1
  282. package/src/user-preference-plugin.ts +2 -0
  283. package/src/utils.ts +42 -2
  284. package/src/ux-list-menu-config-plugin.ts +2 -0
  285. package/src/view-plugin.ts +1 -0
  286. package/src/workspace-plugin.ts +2 -0
  287. package/src/_types/eslint-plugin-es-x.d.ts +0 -17
@@ -0,0 +1,1411 @@
1
+ import {
2
+ CallExpressionShape,
3
+ Plugin,
4
+ IdentifierShape,
5
+ ElementAccessExpressionShape,
6
+ type Factory,
7
+ type Diagnostics,
8
+ type ObjectShape,
9
+ type Record,
10
+ type RecordId,
11
+ type Shape,
12
+ Database,
13
+ isGUID,
14
+ } from '@servicenow/sdk-build-core'
15
+ import { create } from 'xmlbuilder2'
16
+ import { createSdkDocEntry, showGuidFieldDiagnostic } from './utils'
17
+ import { NowIdShape } from './now-id-plugin'
18
+
19
+ const DEFAULT_VIEW = 'Default view'
20
+ const DEFAULT_ANNOTATION_TYPE = '753f88a80f930000b12e6903cfe01206'
21
+ const FORM_XML_TABLES = ['sys_ui_form', 'sys_ui_form_section']
22
+
23
+ // Derive sys_id → key name maps from the source-of-truth constants
24
+ function buildReverseMap(ns: { [key: string]: string }): Map<string, string> {
25
+ const map = new Map<string, string>()
26
+ for (const [key, value] of Object.entries(ns)) {
27
+ map.set(value, key)
28
+ }
29
+ return map
30
+ }
31
+
32
+ //TODO:: Import AnnotationType and Formatter from sdk-api once Form plugin is fixed.
33
+ const AnnotationType = {}
34
+ const Formatter = {}
35
+ const ANNOTATION_TYPE_MAP = buildReverseMap(AnnotationType)
36
+ const FORMATTER_MAP = buildReverseMap(Formatter)
37
+
38
+ // Maps Formatter sys_id → element name (the `formatter` column on sys_ui_formatter)
39
+ const FORMATTER_ELEMENT_MAP = new Map<string, string>([
40
+ ['444ea5c6bf310100e628555b3f0739d6', 'activity.xml'],
41
+ ['cfa76e850a0a0b1f01446f67c8538d00', 'attached_knowledge'],
42
+ ])
43
+
44
+ // Split type constants for form layout
45
+ const SPLIT_TYPE = {
46
+ BEGIN: '.begin_split',
47
+ MIDDLE: '.split',
48
+ END: '.end_split',
49
+ } as const
50
+
51
+ const SPLIT_TYPES = [SPLIT_TYPE.BEGIN, SPLIT_TYPE.MIDDLE, SPLIT_TYPE.END] as const
52
+
53
+ function sortByPosition(records: Record[]): Record[] {
54
+ return records.sort(
55
+ (a, b) => (a.get('position')?.toNumber()?.getValue() ?? 0) - (b.get('position')?.toNumber()?.getValue() ?? 0)
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Converts a UI element record to a FormElement for shape generation.
61
+ * Returns null for split marker elements (they are handled by groupElementsIntoLayoutBlocks).
62
+ */
63
+ function convertElementToField(element: Record, descendants?: Database): FormElement | null {
64
+ const elementValue = element.get('element').asString().getValue()
65
+ const type = getOptionalString(element, 'type')
66
+ const formatter = getOptionalString(element, 'sys_ui_formatter')
67
+
68
+ // Split markers are handled by groupElementsIntoLayoutBlocks, not emitted as elements
69
+ if (SPLIT_TYPES.includes(type as (typeof SPLIT_TYPES)[number])) {
70
+ return null
71
+ }
72
+
73
+ if (type === 'annotation' && descendants) {
74
+ // element field of sys_ui_element contains the sys_id of the sys_ui_annotation record
75
+ const annotation = descendants.query('sys_ui_annotation').find((a) => a.getId().getValue() === elementValue)
76
+ if (annotation) {
77
+ const isPlainText = annotation.get('is_plain_text').toBoolean()?.getValue() ?? true
78
+ const annotationTypeSysId = getOptionalString(annotation, 'type')
79
+ const annotationTypeKey = ANNOTATION_TYPE_MAP.get(annotationTypeSysId)
80
+ return {
81
+ type: 'annotation',
82
+ annotationId: NowIdShape.from(annotation),
83
+ text: getOptionalString(annotation, 'text'),
84
+ isPlainText: isPlainText,
85
+ annotationType: annotationTypeKey ?? annotationTypeSysId,
86
+ }
87
+ }
88
+ return {
89
+ type: 'annotation',
90
+ annotationId: new NowIdShape({ source: element, id: elementValue }),
91
+ text: '',
92
+ annotationType: 'Info_Box_Blue',
93
+ }
94
+ }
95
+
96
+ if (type === 'formatter') {
97
+ const formatterKey = FORMATTER_MAP.get(formatter)
98
+ const derivableName = FORMATTER_ELEMENT_MAP.get(formatter)
99
+ return {
100
+ type: 'formatter',
101
+ // Only emit formatterName when it differs from the derivable value
102
+ ...(elementValue !== derivableName ? { formatterName: elementValue } : {}),
103
+ formatterRef: formatterKey ?? formatter,
104
+ }
105
+ }
106
+
107
+ if (type === 'list') {
108
+ // Parse the encoded element string to extract listType and list components
109
+ const parsed = parseListElement(elementValue)
110
+ if (parsed.listType === '12M' || parsed.listType === 'M2M') {
111
+ return {
112
+ type: 'list',
113
+ listType: parsed.listType,
114
+ listRef: `${parsed.listTable}.${parsed.listColumn}`,
115
+ }
116
+ }
117
+ if (parsed.listType === 'custom') {
118
+ return {
119
+ type: 'list',
120
+ listType: 'custom',
121
+ listRef: parsed.relationship,
122
+ }
123
+ }
124
+ return null
125
+ }
126
+
127
+ // Default: table field element
128
+ return {
129
+ field: elementValue,
130
+ type: 'table_field',
131
+ }
132
+ }
133
+
134
+ type ParsedListElement =
135
+ | { listType: '12M' | 'M2M'; listTable: string; listColumn: string }
136
+ | { listType: 'custom'; relationship: string }
137
+
138
+ /**
139
+ * Parses an encoded list element string into its components.
140
+ * Formats:
141
+ * - '12M.<parent>.<child>.<field>' → { listType: '12M', listTable: '<child>', listColumn: '<field>' }
142
+ * - 'M2M.<parent>.<join>.<field>' → { listType: 'M2M', listTable: '<join>', listColumn: '<field>' }
143
+ * - 'REL.<parent>.REL:<sys_id>' → { listType: 'custom', relationship: '<sys_id>' }
144
+ */
145
+ function parseListElement(elementValue: string): ParsedListElement {
146
+ if (elementValue.startsWith('12M.')) {
147
+ const parts = elementValue.substring(4).split('.')
148
+ // parts: [parent_table, child_table, ref_column]
149
+ return { listType: '12M', listTable: parts[1] ?? '', listColumn: parts[2] ?? '' }
150
+ }
151
+ if (elementValue.startsWith('M2M.')) {
152
+ const parts = elementValue.substring(4).split('.')
153
+ // parts: [parent_table, join_table, ref_column]
154
+ return { listType: 'M2M', listTable: parts[1] ?? '', listColumn: parts[2] ?? '' }
155
+ }
156
+ if (elementValue.startsWith('REL.')) {
157
+ // Format: REL.<table>.REL:<sys_id>
158
+ const relMatch = elementValue.match(/^REL\.[^.]+\.REL:(.+)$/)
159
+ return { listType: 'custom', relationship: relMatch?.[1] ?? elementValue }
160
+ }
161
+ // Fallback
162
+ return { listType: 'custom', relationship: elementValue }
163
+ }
164
+
165
+ /**
166
+ * Groups a flat array of sorted sys_ui_element records into LayoutBlock[] for the content array.
167
+ * Recognizes .begin_split / .split / .end_split sequences and wraps them into two-column blocks.
168
+ * Consecutive non-split elements are grouped into one-column blocks.
169
+ */
170
+ function groupElementsIntoLayoutBlocks(elements: Record[], descendants?: Database): LayoutBlock[] {
171
+ const blocks: LayoutBlock[] = []
172
+ let currentOneColumnElements: FormElement[] = []
173
+
174
+ // Flush accumulated one-column elements into a block. The length check handles
175
+ // edge cases where flushOneColumn is called with no pending elements, e.g. when
176
+ // a .begin_split is the first element or two split blocks are adjacent.
177
+ const flushOneColumn = () => {
178
+ if (currentOneColumnElements.length > 0) {
179
+ blocks.push({ layout: 'one-column', elements: currentOneColumnElements })
180
+ currentOneColumnElements = []
181
+ }
182
+ }
183
+
184
+ let i = 0
185
+ while (i < elements.length) {
186
+ const elem = elements[i]!
187
+ const type = getOptionalString(elem, 'type')
188
+
189
+ if (type === SPLIT_TYPE.BEGIN) {
190
+ // Flush any pending one-column elements
191
+ flushOneColumn()
192
+
193
+ // Collect left and right elements until .end_split
194
+ const leftElements: FormElement[] = []
195
+ const rightElements: FormElement[] = []
196
+ let side: 'left' | 'right' = 'left'
197
+ i++ // skip .begin_split
198
+
199
+ while (i < elements.length) {
200
+ const innerElem = elements[i]!
201
+ const innerType = getOptionalString(innerElem, 'type')
202
+ if (innerType === SPLIT_TYPE.END) {
203
+ i++ // skip .end_split
204
+ break
205
+ }
206
+ if (innerType === SPLIT_TYPE.MIDDLE) {
207
+ side = 'right'
208
+ i++
209
+ continue
210
+ }
211
+ const converted = convertElementToField(innerElem, descendants)
212
+ if (converted) {
213
+ if (side === 'left') {
214
+ leftElements.push(converted)
215
+ } else {
216
+ rightElements.push(converted)
217
+ }
218
+ }
219
+ i++
220
+ }
221
+
222
+ blocks.push({ layout: 'two-column', leftElements, rightElements })
223
+ } else if (type === SPLIT_TYPE.MIDDLE) {
224
+ // Handle lone .split without .begin_split / .end_split.
225
+ // ServiceNow can produce this when fields are dragged across columns.
226
+ // Treat accumulated one-column elements as leftElements,
227
+ // and everything after .split until the next split marker or end as rightElements.
228
+ const leftElements: FormElement[] = [...currentOneColumnElements]
229
+ currentOneColumnElements = []
230
+ const rightElements: FormElement[] = []
231
+ i++ // skip .split
232
+
233
+ while (i < elements.length) {
234
+ const innerElem = elements[i]!
235
+ const innerType = getOptionalString(innerElem, 'type')
236
+ if (innerType === SPLIT_TYPE.END || innerType === SPLIT_TYPE.MIDDLE || innerType === SPLIT_TYPE.BEGIN) {
237
+ break
238
+ }
239
+ const converted = convertElementToField(innerElem, descendants)
240
+ if (converted) {
241
+ rightElements.push(converted)
242
+ }
243
+ i++
244
+ }
245
+
246
+ blocks.push({ layout: 'two-column', leftElements, rightElements })
247
+ } else {
248
+ const converted = convertElementToField(elem, descendants)
249
+ if (converted) {
250
+ currentOneColumnElements.push(converted)
251
+ }
252
+ i++
253
+ }
254
+ }
255
+
256
+ // Flush remaining one-column elements
257
+ flushOneColumn()
258
+
259
+ return blocks
260
+ }
261
+
262
+ /**
263
+ * Gets an optional string value from a record field with a default fallback
264
+ */
265
+ function getOptionalString(record: Record, field: string, defaultValue = ''): string {
266
+ return record.get(field).ifString()?.getValue() ?? defaultValue
267
+ }
268
+
269
+ /**
270
+ * Resolves a view value to its sys_id and view name for XML output.
271
+ * The name is needed as an attribute on the <view> XML element so the parser
272
+ * can create a RecordId with proper coalesce keys when re-reading the XML.
273
+ */
274
+ function resolveView(view: ReturnType<Record['get']>, database: Database): { sysId: string; name: string } {
275
+ if (view.isRecordId() || view.isRecord()) {
276
+ const viewId = view.isRecordId() ? view : view.getId()
277
+ const sysId = viewId.getValue()
278
+ // Try to resolve the view record to get its name
279
+ const viewRecord = database.resolve(viewId)
280
+ if (viewRecord) {
281
+ const name = viewRecord.get('name').ifString()?.getValue() ?? ''
282
+ return { sysId, name }
283
+ }
284
+ // Fall back to primary key (view name) if available
285
+ if (view.isRecordId() && view.asRecordId().hasPrimaryKey()) {
286
+ const pk = view.asRecordId().getPrimaryKey()
287
+ if (pk && !isGUID(pk)) {
288
+ return { sysId, name: pk }
289
+ }
290
+ }
291
+ return { sysId, name: '' }
292
+ }
293
+
294
+ const viewValue = view.getValue()
295
+ if (viewValue === 'NULL' || viewValue === DEFAULT_VIEW) {
296
+ return { sysId: DEFAULT_VIEW, name: DEFAULT_VIEW }
297
+ }
298
+
299
+ // View is a string - try to resolve to record ID if it exists in database
300
+ const viewNameStr = view.ifString()?.getValue() ?? ''
301
+ if (!viewNameStr) {
302
+ return { sysId: '', name: '' }
303
+ }
304
+
305
+ const viewRecord = database.query('sys_ui_view').find((v) => v.get('name').ifString()?.getValue() === viewNameStr)
306
+ if (viewRecord) {
307
+ return { sysId: viewRecord.getId().getValue(), name: viewNameStr }
308
+ }
309
+ return { sysId: viewNameStr, name: isGUID(viewNameStr) ? '' : viewNameStr }
310
+ }
311
+
312
+ /**
313
+ * Resolves a view value to its string representation (sys_id or name)
314
+ * Handles RecordId, Record, string values, and database lookups
315
+ */
316
+ function resolveViewName(view: ReturnType<Record['get']>, database: Database): string {
317
+ return resolveView(view, database).sysId
318
+ }
319
+
320
+ type FormElement =
321
+ | { field: string; type: 'table_field' }
322
+ | {
323
+ type: 'annotation'
324
+ annotationId: NowIdShape
325
+ text: string
326
+ isPlainText?: boolean
327
+ annotationType?: string
328
+ }
329
+ | { type: 'formatter'; formatterName?: string; formatterRef: string }
330
+ | { type: 'list'; listType: '12M' | 'M2M'; listRef: string }
331
+ | { type: 'list'; listType: 'custom'; listRef: string }
332
+
333
+ type LayoutBlock =
334
+ | { layout: 'one-column'; elements: FormElement[] }
335
+ | { layout: 'two-column'; leftElements: FormElement[]; rightElements: FormElement[] }
336
+
337
+ export const FormPlugin = Plugin.create({
338
+ name: 'FormPlugin',
339
+ docs: [
340
+ createSdkDocEntry('Form', [
341
+ 'sys_ui_form',
342
+ 'sys_ui_form_section',
343
+ 'sys_ui_section',
344
+ 'sys_ui_element',
345
+ 'sys_ui_annotation',
346
+ ]),
347
+ ],
348
+ records: {
349
+ sys_ui_section: {
350
+ composite: true,
351
+ coalesce: ['name', 'caption', 'view', 'sys_domain'],
352
+ relationships: {
353
+ sys_ui_element: {
354
+ via: 'sys_ui_section',
355
+ descendant: true,
356
+ relationships: {
357
+ sys_ui_annotation: {
358
+ // sys_ui_element.element field holds the sys_id of sys_ui_annotation
359
+ via: 'element',
360
+ inverse: true,
361
+ descendant: true,
362
+ },
363
+ },
364
+ },
365
+ sys_ui_view: {
366
+ via: 'view',
367
+ inverse: true,
368
+ },
369
+ },
370
+ toFile(section, { config, descendants, database }) {
371
+ // For DELETE records, output a simple delete XML
372
+ if (section.getAction() === 'DELETE') {
373
+ const xml = create().ele('record_update', { table: 'sys_ui_section' })
374
+ const child = xml.ele('sys_ui_section', { action: 'DELETE' })
375
+ child.ele('sys_id').txt(section.getId().getValue())
376
+ child.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId)
377
+ child.ele('sys_update_name').txt(`sys_ui_section_${section.getId().getValue()}`)
378
+ return {
379
+ success: true,
380
+ value: {
381
+ source: section,
382
+ name: `sys_ui_section_${section.getId().getValue()}.xml`,
383
+ category: section.getInstallCategory(),
384
+ content: xml.end({ prettyPrint: true }),
385
+ },
386
+ }
387
+ }
388
+
389
+ const sectionName = section.get('name').asString().getValue()
390
+ const caption = getOptionalString(section, 'caption')
391
+ const sysDomain = getOptionalString(section, 'sys_domain', 'global')
392
+ const { sysId: viewName, name: viewDisplayName } = resolveView(section.get('view'), database)
393
+
394
+ const xml = create().ele('record_update')
395
+ const root = xml.ele('sys_ui_section', {
396
+ caption,
397
+ section_id: section.getId().getValue(),
398
+ sys_domain: sysDomain,
399
+ table: sectionName,
400
+ view: viewName,
401
+ })
402
+
403
+ // Add all sys_ui_annotation records for this section (excluding deleted ones)
404
+ const annotations = descendants
405
+ .query('sys_ui_annotation')
406
+ .filter((annotation) => annotation.getAction() !== 'DELETE')
407
+
408
+ for (const annotation of annotations) {
409
+ const annotationChild = root.ele('sys_ui_annotation', { action: annotation.getAction() })
410
+ const isPlainTextVal = annotation.get('is_plain_text').ifBoolean()?.getValue() ?? true
411
+ annotationChild.ele('is_plain_text').txt(String(isPlainTextVal))
412
+ annotationChild.ele('name').txt(getOptionalString(annotation, 'name'))
413
+ annotationChild.ele('sys_id').txt(annotation.getId().getValue())
414
+ annotationChild.ele('text').txt(getOptionalString(annotation, 'text'))
415
+ const annotationType = annotation.get('type')
416
+ if (annotationType?.isRecordId() || annotationType?.isRecord()) {
417
+ const typeId = annotationType.isRecordId() ? annotationType : annotationType.getId()
418
+ const typeDisplayValue = getOptionalString(annotation, 'type_display_value')
419
+ annotationChild.ele('type', { display_value: typeDisplayValue }).txt(typeId.getValue())
420
+ } else {
421
+ annotationChild.ele('type').txt(getOptionalString(annotation, 'type'))
422
+ }
423
+ }
424
+
425
+ // Add all sys_ui_element records for this section (excluding deleted ones)
426
+ const elements = sortByPosition(
427
+ descendants.query('sys_ui_element').filter((element) => element.getAction() !== 'DELETE')
428
+ )
429
+
430
+ for (const element of elements) {
431
+ const child = root.ele('sys_ui_element', { action: element.getAction() })
432
+ child.ele('element').txt(element.get('element').asString().getValue())
433
+ child.ele('position').txt(element.get('position').toNumber().getValue().toString())
434
+ child.ele('sys_id').txt(element.getId().getValue())
435
+ child.ele('sys_ui_formatter').txt(getOptionalString(element, 'sys_ui_formatter'))
436
+ child
437
+ .ele('sys_ui_section', {
438
+ caption,
439
+ display_value: caption,
440
+ name: sectionName,
441
+ sys_domain: sysDomain,
442
+ view: viewName,
443
+ })
444
+ .txt(section.getId().getValue())
445
+ child.ele('sys_user')
446
+ child.ele('type').txt(getOptionalString(element, 'type'))
447
+ }
448
+
449
+ // Add the sys_ui_section record itself
450
+ const sectionChild = root.ele('sys_ui_section', { action: section.getAction() })
451
+ sectionChild.ele('caption').txt(caption)
452
+ sectionChild.ele('header').txt(String(section.get('header').ifBoolean()?.getValue() ?? false))
453
+ sectionChild.ele('name').txt(sectionName)
454
+ sectionChild.ele('roles').txt(getOptionalString(section, 'roles'))
455
+ sectionChild.ele('sys_domain').txt(sysDomain)
456
+ sectionChild.ele('sys_id').txt(section.getId().getValue())
457
+ sectionChild.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId)
458
+ sectionChild.ele('sys_user')
459
+ sectionChild.ele('title').txt(String(section.get('title').ifBoolean()?.getValue() ?? false))
460
+ sectionChild.ele('view_name')
461
+ sectionChild.ele('view', viewDisplayName ? { name: viewDisplayName } : {}).txt(viewName)
462
+
463
+ return {
464
+ success: true,
465
+ value: {
466
+ source: section,
467
+ name: `sys_ui_section_${section.getId().getValue()}.xml`,
468
+ category: section.getInstallCategory(),
469
+ content: xml.end({ prettyPrint: true }),
470
+ },
471
+ }
472
+ },
473
+ async diff(existing, incoming, _, { factory }) {
474
+ // If either database is empty, return the incoming as-is or empty database
475
+ if (incoming.query().length === 0 || existing.query().length === 0) {
476
+ return {
477
+ success: true,
478
+ value: incoming.query().length === 0 ? new Database() : new Database(incoming.query()),
479
+ }
480
+ }
481
+
482
+ const changeDatabase = new Database()
483
+ let hasChanges = false
484
+
485
+ const existingSection = existing.query('sys_ui_section')[0]
486
+ const incomingSection = incoming.query('sys_ui_section')[0]
487
+ const existingElements = existing.query('sys_ui_element')
488
+ const incomingElements = incoming.query('sys_ui_element')
489
+ const existingAnnotations = existing.query('sys_ui_annotation')
490
+ const incomingAnnotations = incoming.query('sys_ui_annotation')
491
+
492
+ // 1. Compare and merge the main form record
493
+ if (incomingSection && existingSection) {
494
+ if (!existingSection.strictEquals(incomingSection)) {
495
+ changeDatabase.insert(existingSection.merge(incomingSection))
496
+ hasChanges = true
497
+ }
498
+ } else if (incomingSection) {
499
+ changeDatabase.insert(incomingSection)
500
+ hasChanges = true
501
+ }
502
+
503
+ // 2. Compare and merge sys_ui_annotation records
504
+ const markAnnotationsForRemoval: Record[] = []
505
+ for (const annotation of existingAnnotations) {
506
+ const match = incoming.resolve(annotation.getId())
507
+ if (!match) {
508
+ hasChanges = true
509
+ markAnnotationsForRemoval.push(annotation)
510
+ } else {
511
+ hasChanges = hasChanges || !annotation.strictEquals(match)
512
+ changeDatabase.insert(annotation.merge(match))
513
+ }
514
+ }
515
+
516
+ // Add new annotations
517
+ incomingAnnotations.forEach((annotation) => {
518
+ const match = changeDatabase.resolve(annotation.getId())
519
+ if (!match) {
520
+ changeDatabase.insert(annotation)
521
+ hasChanges = true
522
+ }
523
+ })
524
+
525
+ // Delete removed annotations
526
+ for (const annotation of markAnnotationsForRemoval) {
527
+ const deleteRecord = await factory.createRecord({
528
+ source: annotation.getSource(),
529
+ table: 'sys_ui_annotation',
530
+ explicitId: annotation.getId(),
531
+ properties: annotation.properties(),
532
+ action: 'DELETE',
533
+ })
534
+ changeDatabase.insert(deleteRecord)
535
+ }
536
+
537
+ // 3. Compare and merge sys_ui_element records
538
+ const markElementsForRemoval: Record[] = []
539
+ for (const element of existingElements) {
540
+ const match = incoming.resolve(element.getId())
541
+ if (!match) {
542
+ hasChanges = true
543
+ markElementsForRemoval.push(element)
544
+ } else {
545
+ hasChanges = hasChanges || !element.strictEquals(match)
546
+ changeDatabase.insert(element.merge(match))
547
+ }
548
+ }
549
+
550
+ // Add new elements
551
+ incomingElements.forEach((element) => {
552
+ const match = changeDatabase.resolve(element.getId())
553
+ if (!match) {
554
+ changeDatabase.insert(element)
555
+ hasChanges = true
556
+ }
557
+ })
558
+
559
+ // Delete removed elements
560
+ for (const element of markElementsForRemoval) {
561
+ const deleteRecord = await factory.createRecord({
562
+ source: element.getSource(),
563
+ table: 'sys_ui_element',
564
+ explicitId: element.getId(),
565
+ properties: element.properties(),
566
+ action: 'DELETE',
567
+ })
568
+ changeDatabase.insert(deleteRecord)
569
+ }
570
+
571
+ return {
572
+ success: true,
573
+ value: hasChanges ? changeDatabase : new Database(),
574
+ }
575
+ },
576
+ },
577
+ sys_ui_element: {
578
+ coalesce: ['sys_ui_section', 'element', 'position'],
579
+ },
580
+ sys_ui_form_section: {
581
+ coalesce: ['sys_ui_form', 'sys_ui_section'],
582
+ },
583
+ sys_ui_form: {
584
+ coalesce: ['name', 'view', 'sys_domain'],
585
+ relationships: {
586
+ sys_ui_form_section: {
587
+ via: 'sys_ui_form', // Reference column name on this table
588
+ descendant: true,
589
+ relationships: {
590
+ sys_ui_section: {
591
+ via: 'sys_ui_section',
592
+ inverse: true, // Indicates the parent refers to this table
593
+ descendant: true,
594
+ relationships: {
595
+ sys_ui_element: {
596
+ via: 'sys_ui_section',
597
+ descendant: true,
598
+ relationships: {
599
+ sys_ui_annotation: {
600
+ via: 'element',
601
+ inverse: true,
602
+ descendant: true,
603
+ },
604
+ },
605
+ },
606
+ sys_ui_view: {
607
+ via: 'view',
608
+ inverse: true,
609
+ },
610
+ },
611
+ },
612
+ },
613
+ },
614
+ sys_ui_view: {
615
+ via: 'view',
616
+ inverse: true,
617
+ },
618
+ },
619
+ toShape(record, { descendants, database }) {
620
+ const sections = sortByPosition(descendants.query('sys_ui_form_section'))
621
+ .map((formSection) => {
622
+ // Get section ID from form_section record
623
+ const sectionId = formSection.get('sys_ui_section')
624
+
625
+ // Find the section record
626
+ const section = descendants.query('sys_ui_section').find((s) => s.getId().equals(sectionId))
627
+
628
+ if (!section) {
629
+ return null
630
+ }
631
+
632
+ // Get section caption
633
+ const caption = section.get('caption').ifString()?.getValue() ?? ''
634
+ const header = section.get('header')?.ifBoolean()?.getValue() ?? false
635
+ const title = section.get('title')?.ifBoolean()?.getValue() ?? false
636
+
637
+ // Get elements for this section, sorted by position
638
+ const sortedElements = sortByPosition(
639
+ descendants
640
+ .query('sys_ui_element')
641
+ .filter((elem) => elem.get('sys_ui_section').equals(sectionId))
642
+ )
643
+
644
+ // Group flat elements into layout blocks (one-column / two-column)
645
+ const content = groupElementsIntoLayoutBlocks(sortedElements, descendants)
646
+
647
+ return {
648
+ caption,
649
+ content,
650
+ ...(header && { header }),
651
+ ...(title && { title }),
652
+ }
653
+ })
654
+ .filter(Boolean) // Remove any nulls
655
+ return {
656
+ success: true,
657
+ value: new CallExpressionShape({
658
+ source: record,
659
+ callee: 'Form',
660
+ args: [
661
+ record.transform(({ $ }) => ({
662
+ table: $.from('name'),
663
+ view: $.map((v) => {
664
+ if (v.equals(DEFAULT_VIEW)) {
665
+ return new IdentifierShape({ source: record, name: 'default_view' })
666
+ }
667
+ // Reuse resolveView to resolve the view value to its name
668
+ const resolved = resolveView(v, database)
669
+ if (resolved.name && resolved.name !== DEFAULT_VIEW) {
670
+ return resolved.name
671
+ }
672
+ return v.ifString() ?? v.toRecordId()
673
+ }),
674
+ user: $.from('sys_user').def(''),
675
+ roles: $.from('roles')
676
+ .map((v) => {
677
+ return v.isString() && !v.isEmpty()
678
+ ? v
679
+ .asString()
680
+ .getValue()
681
+ .split(',')
682
+ .map((role) => role.trim())
683
+ : []
684
+ })
685
+ .def([]),
686
+ sections: $.val(sections),
687
+ })),
688
+ ],
689
+ }),
690
+ }
691
+ },
692
+ toFile(form, { config, descendants, database }) {
693
+ if (!form.has('name')) {
694
+ return { success: false }
695
+ }
696
+
697
+ // For DELETE records, output a simple delete XML
698
+ if (form.getAction() === 'DELETE') {
699
+ const xml = create().ele('record_update', { table: 'sys_ui_form' })
700
+ const child = xml.ele('sys_ui_form', { action: 'DELETE' })
701
+ child.ele('sys_id').txt(form.getId().getValue())
702
+ child.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId)
703
+ child.ele('sys_update_name').txt(`sys_ui_form_sections_${form.getId().getValue()}`)
704
+ return {
705
+ success: true,
706
+ value: {
707
+ source: form,
708
+ name: `sys_ui_form_sections_${form.getId().getValue()}.xml`,
709
+ category: form.getInstallCategory(),
710
+ content: xml.end({ prettyPrint: true }),
711
+ },
712
+ }
713
+ }
714
+
715
+ const formName = form.get('name').asString().getValue()
716
+ const formSysDomain = getOptionalString(form, 'sys_domain', 'global')
717
+ const { sysId: formViewName, name: formViewDisplayName } = resolveView(form.get('view'), database)
718
+
719
+ const xml = create().ele('record_update')
720
+ const root = xml.ele('sys_ui_form_sections', {
721
+ form_id: form.getId().getValue(),
722
+ sys_domain: formSysDomain,
723
+ table: formName,
724
+ })
725
+
726
+ // Add all sys_ui_form_section records (excluding deleted ones)
727
+ const formSections = sortByPosition(
728
+ descendants
729
+ .query('sys_ui_form_section')
730
+ .filter((formSection) => formSection.getAction() !== 'DELETE')
731
+ )
732
+
733
+ for (const formSection of formSections) {
734
+ const child = root.ele('sys_ui_form_section', { action: 'INSERT_OR_UPDATE' })
735
+ child.ele('position').txt(formSection.get('position').toNumber().getValue().toString())
736
+ child.ele('sys_id').txt(formSection.getId().getValue())
737
+ child
738
+ .ele('sys_ui_form', {
739
+ display_value: formName,
740
+ name: formName,
741
+ sys_domain: formSysDomain,
742
+ view: formViewName,
743
+ })
744
+ .txt(form.getId().getValue())
745
+
746
+ // Add section reference
747
+ const sectionId = formSection.get('sys_ui_section')
748
+ const section = descendants.query('sys_ui_section').find((s) => s.getId().equals(sectionId))
749
+
750
+ if (section) {
751
+ const sectionCaption = getOptionalString(section, 'caption')
752
+ const sectionViewName = resolveViewName(section.get('view'), database)
753
+ child
754
+ .ele('sys_ui_section', {
755
+ caption: sectionCaption,
756
+ display_value: sectionCaption,
757
+ name: formName,
758
+ sys_domain: getOptionalString(section, 'sys_domain', 'global'),
759
+ view: sectionViewName,
760
+ })
761
+ .txt(section.getId().getValue())
762
+ }
763
+ }
764
+
765
+ // Add the sys_ui_form record itself
766
+ const formChild = root.ele('sys_ui_form', { action: 'INSERT_OR_UPDATE' })
767
+ formChild.ele('name').txt(formName)
768
+ formChild.ele('roles').txt(getOptionalString(form, 'roles'))
769
+ formChild.ele('sys_id').txt(form.getId().getValue())
770
+ formChild.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId)
771
+ formChild.ele('sys_user').txt(getOptionalString(form, 'sys_user'))
772
+ formChild.ele('view', formViewDisplayName ? { name: formViewDisplayName } : {}).txt(formViewName)
773
+ formChild.ele('view_name')
774
+
775
+ return {
776
+ success: true,
777
+ value: {
778
+ source: form,
779
+ name: `sys_ui_form_sections_${form.getId().getValue()}.xml`,
780
+ category: form.getInstallCategory(),
781
+ content: xml.end({ prettyPrint: true }),
782
+ },
783
+ }
784
+ },
785
+ async diff(existingDB, incomingDB, _, { factory }) {
786
+ /**
787
+ * Exclude sys_ui_section and sys_ui_element records from this diff to handle incremental updates correctly.
788
+ *
789
+ * Why: If a user modifies only a UI section during incremental transformation, we don't want that change
790
+ * to trigger a diff in sys_ui_form that would incorrectly remove form_sections. Instead, sys_ui_section
791
+ * and sys_ui_element records are compared separately using their own dedicated diff function.
792
+ */
793
+ const incoming = incomingDB.query().filter((r) => FORM_XML_TABLES.includes(r.getTable()))
794
+ const existing = existingDB.query().filter((r) => FORM_XML_TABLES.includes(r.getTable()))
795
+
796
+ // If either database is empty, return the incoming as-is or empty database
797
+ if (incoming.length === 0 || existing.length === 0) {
798
+ return {
799
+ success: true,
800
+ value: incoming.length === 0 ? new Database() : new Database(incoming),
801
+ }
802
+ }
803
+
804
+ const changeDatabase = new Database()
805
+ let hasChanges = false
806
+
807
+ // Get the main records
808
+ const existingForm = existing.filter((r) => r.getTable() === 'sys_ui_form')[0]
809
+ const incomingForm = incoming.filter((r) => r.getTable() === 'sys_ui_form')[0]
810
+ const existingFormSections = existing.filter((r) => r.getTable() === 'sys_ui_form_section')
811
+ const incomingFormSections = incoming.filter((r) => r.getTable() === 'sys_ui_form_section')
812
+
813
+ // 1. Compare and merge the main form record
814
+ if (incomingForm && existingForm) {
815
+ if (!existingForm.strictEquals(incomingForm)) {
816
+ changeDatabase.insert(existingForm.merge(incomingForm))
817
+ hasChanges = true
818
+ }
819
+ } else if (incomingForm) {
820
+ changeDatabase.insert(incomingForm)
821
+ hasChanges = true
822
+ }
823
+
824
+ // 2. Compare and merge sys_ui_form_section records (join table)
825
+ const markFormSectionsForRemoval: Record[] = []
826
+ for (const formSection of existingFormSections) {
827
+ const match = incomingDB.resolve(formSection.getId())
828
+ if (!match) {
829
+ hasChanges = true
830
+ markFormSectionsForRemoval.push(formSection)
831
+ } else {
832
+ hasChanges = hasChanges || !formSection.strictEquals(match)
833
+ changeDatabase.insert(formSection.merge(match))
834
+ }
835
+ }
836
+
837
+ incomingFormSections.forEach((formSection) => {
838
+ const match = changeDatabase.resolve(formSection.getId())
839
+ if (!match) {
840
+ changeDatabase.insert(formSection)
841
+ hasChanges = true
842
+ }
843
+ })
844
+
845
+ // Delete removed form sections
846
+ for (const formSection of markFormSectionsForRemoval) {
847
+ const deleteRecord = await factory.createRecord({
848
+ source: formSection.getSource(),
849
+ table: 'sys_ui_form_section',
850
+ explicitId: formSection.getId(),
851
+ properties: formSection.properties(),
852
+ action: 'DELETE',
853
+ })
854
+ changeDatabase.insert(deleteRecord)
855
+ }
856
+
857
+ return {
858
+ success: true,
859
+ value: hasChanges ? changeDatabase : new Database(),
860
+ }
861
+ },
862
+ },
863
+ },
864
+ shapes: [
865
+ {
866
+ shape: CallExpressionShape,
867
+ fileTypes: ['fluent'],
868
+ async toRecord(callExpression, { factory, diagnostics }) {
869
+ if (callExpression.getCallee() !== 'Form') {
870
+ return { success: false }
871
+ }
872
+
873
+ const arg = callExpression.getArgument(0).asObject()
874
+ const tableArg = arg.get('table')
875
+ const tableName = tableArg.ifString()
876
+
877
+ // View handling: supports Record, RecordId, and string (aligned with ListPlugin)
878
+ const viewArg = arg.get('view')
879
+
880
+ let viewReference: Record | RecordId | string
881
+
882
+ if (viewArg.isRecord()) {
883
+ viewReference = viewArg.asRecord()
884
+ } else if (viewArg.isRecordId()) {
885
+ viewReference = viewArg.asRecordId()
886
+ } else if (viewArg.isString()) {
887
+ const stringValue = viewArg.asString().getValue()
888
+ // Diagnostic 1: 'Default view' string literal footgun
889
+ if (stringValue === DEFAULT_VIEW) {
890
+ diagnostics.error(
891
+ viewArg,
892
+ `Do not use the hard-coded string '${DEFAULT_VIEW}' as the view. ` +
893
+ `Use the exported 'default_view' identifier instead: view: default_view`
894
+ )
895
+ return { success: false }
896
+ }
897
+ viewReference = await factory.createReference({
898
+ source: callExpression,
899
+ table: 'sys_ui_view',
900
+ keys: { name: stringValue },
901
+ })
902
+ } else {
903
+ // Default for any other case
904
+ viewReference = DEFAULT_VIEW
905
+ }
906
+
907
+ // Process roles if they exist as an array
908
+ let rolesValue = ''
909
+ const rolesArray = arg.get('roles').ifArray()?.getElements() ?? []
910
+ if (rolesArray.length > 0) {
911
+ rolesValue = rolesArray
912
+ .map((role) => {
913
+ if (role.isString()) {
914
+ return role.asString().getValue()
915
+ } else if (role.isObject()) {
916
+ return role.get('name')?.ifString()?.getValue() ?? ''
917
+ }
918
+ return ''
919
+ })
920
+ .filter((name) => name !== '')
921
+ .join(',')
922
+ }
923
+
924
+ // Create the main form record
925
+ const form = await factory.createRecord({
926
+ source: callExpression,
927
+ table: 'sys_ui_form',
928
+ properties: arg.transform(({ $ }) => ({
929
+ name: $.val(tableName),
930
+ view: $.val(viewReference),
931
+ sys_user: $.from('user'),
932
+ sys_domain: $.val('global'),
933
+ roles: $.val(rolesValue).def(''),
934
+ })),
935
+ })
936
+ // Process all sections
937
+ const sections = arg.get('sections').ifArray()?.getElements() || []
938
+ if (sections.length === 0) {
939
+ diagnostics.error(arg.get('sections'), `Form does not have any sections. Add at least one section.`)
940
+ }
941
+ const sectionRecords: Record[] = []
942
+ const formSectionRecords: Record[] = []
943
+ const elementRecords: Record[] = []
944
+ const seenCaptions = new Map<string, number>()
945
+ const globalSeenFields = new Set<string>()
946
+
947
+ for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
948
+ const sectionObj = sections[sectionIndex]?.asObject()
949
+ if (!sectionObj) {
950
+ continue
951
+ }
952
+ const caption = sectionObj.get('caption').ifString()?.getValue() ?? ''
953
+
954
+ // Diagnostic 2: Empty section caption
955
+ if (!caption.trim()) {
956
+ diagnostics.warn(sectionObj.get('caption'), `Section caption cannot be empty.`)
957
+ }
958
+
959
+ // Diagnostic 3: Duplicate section captions
960
+ // Note: When two sections share the same caption, fluent merges them into
961
+ // a single section on the instance due to coalesce keys. This may be unexpected if captions collide by accident.
962
+ if (caption.trim()) {
963
+ const prevIndex = seenCaptions.get(caption)
964
+ if (prevIndex !== undefined) {
965
+ diagnostics.error(
966
+ sectionObj.get('caption'),
967
+ `Duplicate section caption '${caption}'. Each section must have a unique caption.`
968
+ )
969
+ } else {
970
+ seenCaptions.set(caption, sectionIndex)
971
+ }
972
+ }
973
+ // Create the section record
974
+ const section = await factory.createRecord({
975
+ source: callExpression,
976
+ table: 'sys_ui_section',
977
+ properties: sectionObj.transform(({ $ }) => ({
978
+ name: $.val(tableName),
979
+ caption: $.val(caption),
980
+ title: $.def(false),
981
+ header: $.def(false),
982
+ view: $.val(viewReference),
983
+ sys_domain: $.val('global'),
984
+ })),
985
+ })
986
+
987
+ sectionRecords.push(section)
988
+
989
+ // Create the form-section linking record
990
+ const formSection = await factory.createRecord({
991
+ source: callExpression,
992
+ table: 'sys_ui_form_section',
993
+ properties: arg.transform(({ $ }) => ({
994
+ sys_ui_form: $.val(form),
995
+ sys_ui_section: $.val(section.getId()),
996
+ position: $.val(sectionIndex),
997
+ })),
998
+ })
999
+ formSectionRecords.push(formSection)
1000
+
1001
+ // Process content layout blocks
1002
+ await processContentBlocks(
1003
+ arg,
1004
+ sectionObj,
1005
+ callExpression,
1006
+ factory,
1007
+ section,
1008
+ elementRecords,
1009
+ diagnostics,
1010
+ caption,
1011
+ globalSeenFields
1012
+ )
1013
+ }
1014
+
1015
+ return {
1016
+ success: true,
1017
+ value: form.with(...sectionRecords, ...formSectionRecords, ...elementRecords),
1018
+ }
1019
+ },
1020
+ },
1021
+ ],
1022
+ })
1023
+ /**
1024
+ * Creates a sys_ui_element record
1025
+ */
1026
+ async function createUiElement(
1027
+ arg: ObjectShape,
1028
+ callExpression: CallExpressionShape,
1029
+ factory: Factory,
1030
+ section: Record,
1031
+ element: string,
1032
+ position: number,
1033
+ type: string = '',
1034
+ formatter: string = ''
1035
+ ) {
1036
+ return await factory.createRecord({
1037
+ source: callExpression,
1038
+ table: 'sys_ui_element',
1039
+ properties: arg.transform(({ $ }) => ({
1040
+ sys_ui_section: $.val(section.getId()),
1041
+ element: $.val(element),
1042
+ position: $.val(position),
1043
+ type: $.val(type),
1044
+ sys_ui_formatter: $.val(formatter),
1045
+ })),
1046
+ })
1047
+ }
1048
+
1049
+ /**
1050
+ * Processes the content layout blocks in a form section and creates UI element records.
1051
+ * Reads the `content` array of layout blocks (one-column / two-column) and flattens them
1052
+ * into sys_ui_element records with proper positioning and split markers.
1053
+ */
1054
+ async function processContentBlocks(
1055
+ arg: ObjectShape,
1056
+ sectionObj: ObjectShape,
1057
+ callExpression: CallExpressionShape,
1058
+ factory: Factory,
1059
+ section: Record,
1060
+ elementRecords: Record[],
1061
+ diagnostics: Diagnostics,
1062
+ caption: string,
1063
+ globalSeenFields: Set<string>
1064
+ ) {
1065
+ const contentBlocks = sectionObj.get('content').ifArray()?.getElements() || []
1066
+ let position = 0
1067
+ const seenFields = new Set<string>()
1068
+
1069
+ // Diagnostic 6: Empty content blocks
1070
+ if (contentBlocks.length === 0) {
1071
+ diagnostics.warn(
1072
+ sectionObj.get('content'),
1073
+ `Section '${caption}' has no content blocks. The section will be empty on the form.`
1074
+ )
1075
+ }
1076
+
1077
+ /** Processes an array of element shapes, reporting errors for non-object entries. */
1078
+ async function processElements(
1079
+ elements: ReturnType<NonNullable<ReturnType<Shape['ifArray']>>['getElements']>,
1080
+ context: string
1081
+ ) {
1082
+ for (const elem of elements) {
1083
+ if (!elem || !elem.isObject()) {
1084
+ diagnostics.error(
1085
+ elem ?? sectionObj,
1086
+ `Invalid element in ${context} of section '${caption}'. Each element must be an object.`
1087
+ )
1088
+ continue
1089
+ }
1090
+ position = await processFormElement(
1091
+ arg,
1092
+ callExpression,
1093
+ factory,
1094
+ section,
1095
+ elementRecords,
1096
+ elem.asObject(),
1097
+ position,
1098
+ diagnostics,
1099
+ seenFields,
1100
+ caption,
1101
+ globalSeenFields
1102
+ )
1103
+ }
1104
+ }
1105
+
1106
+ for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
1107
+ const block = contentBlocks[blockIdx]
1108
+ if (!block || !block.isObject()) {
1109
+ diagnostics.error(
1110
+ block ?? sectionObj,
1111
+ `Content block at index ${blockIdx} in section '${caption}' must be an object.`
1112
+ )
1113
+ continue
1114
+ }
1115
+ const blockObj = block.asObject()
1116
+ const layout = blockObj.get('layout').ifString()?.getValue()
1117
+
1118
+ if (!layout || (layout !== 'one-column' && layout !== 'two-column')) {
1119
+ diagnostics.error(
1120
+ blockObj.get('layout'),
1121
+ `Layout block requires 'layout' property set to 'one-column' or 'two-column'.`
1122
+ )
1123
+ continue
1124
+ }
1125
+
1126
+ if (layout === 'one-column') {
1127
+ await processElements(blockObj.get('elements').ifArray()?.getElements() || [], 'one-column block')
1128
+ } else {
1129
+ // Diagnostic 5: Empty two-column sides
1130
+ const leftElements = blockObj.get('leftElements').ifArray()?.getElements() || []
1131
+ const rightElements = blockObj.get('rightElements').ifArray()?.getElements() || []
1132
+ if (leftElements.length === 0) {
1133
+ diagnostics.warn(
1134
+ blockObj.get('leftElements'),
1135
+ `Two-column layout block at index ${blockIdx} in section '${caption}' has empty 'leftElements'. Consider using a one-column layout instead.`
1136
+ )
1137
+ }
1138
+ if (rightElements.length === 0) {
1139
+ diagnostics.warn(
1140
+ blockObj.get('rightElements'),
1141
+ `Two-column layout block at index ${blockIdx} in section '${caption}' has empty 'rightElements'. Consider using a one-column layout instead.`
1142
+ )
1143
+ }
1144
+
1145
+ // two-column: emit .begin_split, left elements, .split, right elements, .end_split
1146
+ elementRecords.push(
1147
+ await createUiElement(
1148
+ arg,
1149
+ callExpression,
1150
+ factory,
1151
+ section,
1152
+ SPLIT_TYPE.BEGIN,
1153
+ position++,
1154
+ SPLIT_TYPE.BEGIN
1155
+ )
1156
+ )
1157
+ await processElements(leftElements, 'two-column leftElements')
1158
+ elementRecords.push(
1159
+ await createUiElement(
1160
+ arg,
1161
+ callExpression,
1162
+ factory,
1163
+ section,
1164
+ SPLIT_TYPE.MIDDLE,
1165
+ position++,
1166
+ SPLIT_TYPE.MIDDLE
1167
+ )
1168
+ )
1169
+ await processElements(rightElements, 'two-column rightElements')
1170
+ elementRecords.push(
1171
+ await createUiElement(arg, callExpression, factory, section, SPLIT_TYPE.END, position++, SPLIT_TYPE.END)
1172
+ )
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * Processes a single form element object and creates the appropriate record(s).
1179
+ * Handles table_field, annotation, formatter, and list element types.
1180
+ * Returns the next position index.
1181
+ */
1182
+ async function processFormElement(
1183
+ arg: ObjectShape,
1184
+ callExpression: CallExpressionShape,
1185
+ factory: Factory,
1186
+ section: Record,
1187
+ elementRecords: Record[],
1188
+ field: ObjectShape,
1189
+ position: number,
1190
+ diagnostics: Diagnostics,
1191
+ seenFields: Set<string>,
1192
+ caption: string,
1193
+ globalSeenFields: Set<string>
1194
+ ): Promise<number> {
1195
+ const typeField = field.get('type').ifString()?.getValue() ?? ''
1196
+
1197
+ const validTypes = ['table_field', 'annotation', 'formatter', 'list']
1198
+ if (!validTypes.includes(typeField)) {
1199
+ diagnostics.error(
1200
+ field.get('type'),
1201
+ `Invalid type '${typeField}'. Valid types are: ${validTypes.map((t) => `'${t}'`).join(', ')}`
1202
+ )
1203
+ return position
1204
+ }
1205
+
1206
+ // ── table_field ──
1207
+ if (typeField === 'table_field') {
1208
+ const fieldName = field.get('field').ifString()?.getValue() ?? ''
1209
+ if (!fieldName) {
1210
+ diagnostics.error(field.get('field'), `Table field element requires a 'field' property.`)
1211
+ return position
1212
+ }
1213
+ // Duplicate field check within the same section
1214
+ if (seenFields.has(fieldName)) {
1215
+ diagnostics.error(
1216
+ field,
1217
+ `Duplicate field '${fieldName}' in section '${caption}'. Each field should only appear once per section.`
1218
+ )
1219
+ }
1220
+ seenFields.add(fieldName)
1221
+
1222
+ // Diagnostic 4: Duplicate field across sections
1223
+ if (globalSeenFields.has(fieldName)) {
1224
+ diagnostics.warn(
1225
+ field,
1226
+ `Field '${fieldName}' already appears in another section. Duplicate fields across sections may cause unexpected behavior on the form.`
1227
+ )
1228
+ }
1229
+ globalSeenFields.add(fieldName)
1230
+
1231
+ elementRecords.push(await createUiElement(arg, callExpression, factory, section, fieldName, position))
1232
+ return position + 1
1233
+ }
1234
+
1235
+ // ── annotation ──
1236
+ if (typeField === 'annotation') {
1237
+ const annotationIdField = field.get('annotationId')
1238
+
1239
+ if (
1240
+ !(annotationIdField instanceof ElementAccessExpressionShape && annotationIdField.getCallee() === 'Now.ID')
1241
+ ) {
1242
+ diagnostics.error(annotationIdField, `'annotationId' must be a Now.ID['...'] reference.`)
1243
+ return position
1244
+ }
1245
+ const explicitId: Shape = annotationIdField
1246
+
1247
+ const textValue = field.get('text').ifString()?.getValue() ?? ''
1248
+ const isPlainText = field.get('isPlainText').ifBoolean()?.getValue() ?? true
1249
+
1250
+ // Handle annotationType - can be AnnotationType record or string GUID
1251
+ const annotationTypeArg = field.get('annotationType')
1252
+ let annotationTypeValue = DEFAULT_ANNOTATION_TYPE
1253
+
1254
+ if (annotationTypeArg) {
1255
+ if (annotationTypeArg.isRecord()) {
1256
+ annotationTypeValue = annotationTypeArg.asRecord().getId().getValue()
1257
+ } else if (annotationTypeArg.isString()) {
1258
+ const annotationTypeStr = annotationTypeArg.asString().getValue()
1259
+ if (annotationTypeStr in AnnotationType) {
1260
+ annotationTypeValue = AnnotationType[annotationTypeStr as keyof typeof AnnotationType]
1261
+ } else if (isGUID(annotationTypeStr)) {
1262
+ annotationTypeValue = annotationTypeStr
1263
+ } else {
1264
+ showGuidFieldDiagnostic(annotationTypeArg, 'annotationType', 'sys_ui_annotation_type', diagnostics)
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ // Create sys_ui_annotation record
1270
+ const annotationRecord = await factory.createRecord({
1271
+ source: callExpression,
1272
+ table: 'sys_ui_annotation',
1273
+ explicitId: explicitId,
1274
+ properties: arg.transform(({ $ }) => ({
1275
+ text: $.val(textValue),
1276
+ is_plain_text: $.val(isPlainText).def(true),
1277
+ type: $.val(annotationTypeValue),
1278
+ })),
1279
+ })
1280
+ elementRecords.push(annotationRecord)
1281
+
1282
+ // Create sys_ui_element with element = annotation sys_id
1283
+ elementRecords.push(
1284
+ await createUiElement(
1285
+ arg,
1286
+ callExpression,
1287
+ factory,
1288
+ section,
1289
+ annotationRecord.getId().getValue(),
1290
+ position,
1291
+ 'annotation'
1292
+ )
1293
+ )
1294
+ return position + 1
1295
+ }
1296
+
1297
+ // ── formatter ──
1298
+ if (typeField === 'formatter') {
1299
+ const formatterRefArg = field.get('formatterRef')
1300
+ let formatterRefValue = ''
1301
+ let recordFormatterField = ''
1302
+
1303
+ if (formatterRefArg) {
1304
+ if (formatterRefArg.isRecord()) {
1305
+ const record = formatterRefArg.asRecord()
1306
+ formatterRefValue = record.getId().getValue()
1307
+ // Try to read the `formatter` column from the Record data
1308
+ recordFormatterField = record.get('formatter')?.ifString()?.getValue() ?? ''
1309
+ } else if (formatterRefArg.isString()) {
1310
+ const formatterStr = formatterRefArg.asString().getValue()
1311
+ if (formatterStr in Formatter) {
1312
+ formatterRefValue = Formatter[formatterStr as keyof typeof Formatter]
1313
+ } else if (isGUID(formatterStr)) {
1314
+ formatterRefValue = formatterStr
1315
+ } else {
1316
+ showGuidFieldDiagnostic(formatterRefArg, 'formatterRef', 'sys_ui_formatter', diagnostics)
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ // formatterName is optional — derive in order: explicit > Record `formatter` field > FORMATTER_ELEMENT_MAP > empty
1322
+ let formatterName = field.get('formatterName').ifString()?.getValue() ?? ''
1323
+ if (!formatterName && recordFormatterField) {
1324
+ formatterName = recordFormatterField
1325
+ }
1326
+ if (!formatterName && formatterRefValue) {
1327
+ formatterName = FORMATTER_ELEMENT_MAP.get(formatterRefValue) ?? ''
1328
+ }
1329
+
1330
+ elementRecords.push(
1331
+ await createUiElement(
1332
+ arg,
1333
+ callExpression,
1334
+ factory,
1335
+ section,
1336
+ formatterName,
1337
+ position,
1338
+ 'formatter',
1339
+ formatterRefValue
1340
+ )
1341
+ )
1342
+ return position + 1
1343
+ }
1344
+
1345
+ // ── list ──
1346
+ if (typeField === 'list') {
1347
+ const listType = field.get('listType').ifString()?.getValue() ?? ''
1348
+ if (!listType || !['12M', 'M2M', 'custom'].includes(listType)) {
1349
+ diagnostics.error(
1350
+ field.get('listType'),
1351
+ `List element requires 'listType' set to '12M', 'M2M', or 'custom'.`
1352
+ )
1353
+ return position
1354
+ }
1355
+
1356
+ let elementValue = ''
1357
+ const tableName = arg.get('table').asString().getValue()
1358
+ const listRefArg = field.get('listRef')
1359
+
1360
+ if (listType === 'custom') {
1361
+ // Custom lists use Record<'sys_relationship'> reference or string GUID via 'listRef' key
1362
+ if (listRefArg.isRecord()) {
1363
+ const relSysId = listRefArg.asRecord().getId().getValue()
1364
+ elementValue = `REL.${tableName}.REL:${relSysId}`
1365
+ } else if (listRefArg.isString()) {
1366
+ const relStr = listRefArg.asString().getValue()
1367
+ if (isGUID(relStr)) {
1368
+ elementValue = `REL.${tableName}.REL:${relStr}`
1369
+ } else {
1370
+ showGuidFieldDiagnostic(listRefArg, 'listRef', 'sys_relationship', diagnostics)
1371
+ return position
1372
+ }
1373
+ } else {
1374
+ diagnostics.error(
1375
+ listRefArg,
1376
+ `Custom list requires 'listRef' with a Record<'sys_relationship'> reference or a sys_relationship sys_id string (GUID).`
1377
+ )
1378
+ return position
1379
+ }
1380
+ } else {
1381
+ // 12M and M2M use listRef as 'table.column' dot-notation string
1382
+ if (!listRefArg.isString()) {
1383
+ diagnostics.error(
1384
+ field,
1385
+ `List element requires 'listRef' as a dot-notation string '<table_name>.<column_name>' for '${listType}' type.`
1386
+ )
1387
+ return position
1388
+ }
1389
+
1390
+ const listRefStr = listRefArg.asString().getValue()
1391
+ const parts = listRefStr.split('.')
1392
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
1393
+ diagnostics.error(
1394
+ listRefArg,
1395
+ `Invalid 'listRef' format '${listRefStr}'. Expected '<table_name>.<column_name>' (e.g., 'task_sla.task').`
1396
+ )
1397
+ return position
1398
+ }
1399
+
1400
+ const [listTable, listColumn] = parts
1401
+ elementValue = `${listType}.${tableName}.${listTable}.${listColumn}`
1402
+ }
1403
+
1404
+ elementRecords.push(
1405
+ await createUiElement(arg, callExpression, factory, section, elementValue, position, 'list')
1406
+ )
1407
+ return position + 1
1408
+ }
1409
+
1410
+ return position
1411
+ }