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