mxrb 0.1.2 → 0.1.4

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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -17,11 +17,25 @@ module Mxrb
17
17
  # rendering these elements, not merely serializing their nested settings.
18
18
  VISUAL_WIDGET_TYPES = %w[
19
19
  Forms$ActionButton Forms$CheckBox Forms$DataGrid Forms$DataView Forms$DatePicker
20
- Forms$DivContainer Forms$DropDown Forms$DynamicText Forms$ImageViewer Forms$Label
21
- Forms$LayoutGrid Forms$ListView Forms$RadioButtonGroup Forms$ReferenceSelector
20
+ Forms$DivContainer Forms$DropDown Forms$DynamicText Forms$ImageUploader Forms$ImageViewer
21
+ Forms$Label
22
+ Forms$Header Forms$LayoutGrid Forms$ListView Forms$MenuBar Forms$NavigationTree
23
+ Forms$RadioButtonGroup Forms$ReferenceSelector Forms$ScrollContainer
24
+ Forms$ReferenceSetSelector Forms$Table Forms$TemplateGrid Forms$VerticalSplitPane
25
+ Forms$SidebarToggleButton Forms$SimpleMenuBar
26
+ Forms$MobileBackButton Forms$MobileCancelButton Forms$MobileSaveButton
22
27
  Forms$SnippetCallWidget Forms$StaticImageViewer Forms$TabControl Forms$TextArea
23
28
  Forms$TextBox Forms$InputReferenceSetSelector CustomWidgets$CustomWidget
24
29
  ].freeze
30
+ ACTION_BUTTON_TYPES = %w[
31
+ Forms$CancelChangesClientAction Forms$ClosePageClientAction Forms$FormAction
32
+ Forms$MicroflowAction Forms$NoAction Forms$OpenLinkClientAction
33
+ Forms$SaveChangesClientAction
34
+ ].freeze
35
+ BOUND_WIDGET_TYPES = %w[
36
+ Forms$CheckBox Forms$DatePicker Forms$DropDown Forms$InputReferenceSetSelector
37
+ Forms$RadioButtonGroup Forms$ReferenceSelector Forms$TextArea Forms$TextBox
38
+ ].freeze
25
39
 
26
40
  def initialize(source, web_root, profiles: [:dojo])
27
41
  @source = source
@@ -31,8 +45,8 @@ module Mxrb
31
45
  end
32
46
 
33
47
  def build
34
- pages = @source.units_of('Forms$Page')
35
- layouts = @source.units_of('Forms$Layout')
48
+ pages = legacy_pages
49
+ layouts = legacy_layouts
36
50
  languages.each do |language|
37
51
  layouts.each { write_layout(_1, language) }
38
52
  pages.each { write_page(_1, language) }
@@ -48,7 +62,7 @@ module Mxrb
48
62
  # Performs the same widget audit as #build without writing deployment files.
49
63
  def audit
50
64
  @unsupported.clear
51
- (@source.units_of('Forms$Layout') + @source.units_of('Forms$Page')).each do |unit|
65
+ (legacy_layouts + legacy_pages).each do |unit|
52
66
  name = "#{unit.module_name}.#{unit.document['Name']}"
53
67
  audit_unsupported_widgets(unit.document, name)
54
68
  descendants(unit.document).select { _1['$Type'] == 'Forms$DataGrid' }.each_with_index do |grid, index|
@@ -62,10 +76,22 @@ module Mxrb
62
76
 
63
77
  private
64
78
 
79
+ def legacy_pages
80
+ units = @source.units_of('Forms$Page')
81
+ @source.is_a?(SourceModel) ? units.select { @source.web_page?(_1) } : units
82
+ end
83
+
84
+ def legacy_layouts
85
+ units = @source.units_of('Forms$Layout')
86
+ @source.is_a?(SourceModel) ? units.select { @source.web_layout?(_1) } : units
87
+ end
88
+
65
89
  def write_page(unit, language)
66
90
  name = "#{unit.module_name}.#{unit.document['Name']}"
67
91
  audit_unsupported_widgets(unit.document, name)
68
92
  @widget_sequence = 0
93
+ @widget_ids_by_name = {}
94
+ @templates = []
69
95
  rendered = page_widgets(unit.document).map { render_widget(_1, name, language) }.join
70
96
  path = File.join(@web_root, 'pages', language, unit.module_name, "#{unit.document['Name']}.page.xml")
71
97
  FileUtils.mkdir_p(File.dirname(path))
@@ -76,36 +102,87 @@ module Mxrb
76
102
  name = "#{unit.module_name}.#{unit.document['Name']}"
77
103
  audit_unsupported_widgets(unit.document, name)
78
104
  @widget_sequence = 0
105
+ @widget_ids_by_name = {}
106
+ @templates = []
79
107
  rendered = layout_widgets(unit.document).map { render_widget(_1, name, language) }.join
80
108
  path = File.join(@web_root, 'pages', language, unit.module_name, "#{unit.document['Name']}.layout.xml")
81
109
  FileUtils.mkdir_p(File.dirname(path))
82
110
  File.binwrite(path, layout_xml(unit, rendered))
83
111
  end
84
112
 
85
- def audit_unsupported_widgets(root, page_name)
86
- visit = lambda do |value|
113
+ def audit_unsupported_widgets(root, page_name, initial_data_view_context: false)
114
+ visit = lambda do |value, data_view_context = initial_data_view_context|
87
115
  case value
88
116
  when Hash
89
117
  type = value['$Type'].to_s
90
- if type == 'Forms$DataGrid'
91
- return
92
- elsif VISUAL_WIDGET_TYPES.include?(type) && !supported_widget_type?(type)
93
- @unsupported[page_name] << type
94
- elsif type == 'Forms$DynamicText' && !array(value.dig('Content', 'Parameters')).empty?
95
- @unsupported[page_name] << 'Forms$DynamicText(parameters)'
96
- elsif type == 'Forms$DivContainer'
97
- action_type = value.dig('OnClickAction', '$Type').to_s
98
- @unsupported[page_name] << 'Forms$DivContainer(onClick)' \
99
- unless action_type.empty? || action_type == 'Forms$NoAction'
118
+ data_view_context ||= %w[Forms$DataView Forms$ListView].include?(type)
119
+ next if type == 'Forms$DataGrid'
120
+
121
+ if type == 'Forms$TemplateGrid'
122
+ audit_template_grid(value, page_name)
123
+ next
100
124
  end
101
125
 
102
- value.each_value { visit.call(_1) }
103
- when Array then value.each { visit.call(_1) }
126
+ audit_widget(value, type, page_name, data_view_context)
127
+
128
+ value.each_value { visit.call(_1, data_view_context) }
129
+ when Array then value.each { visit.call(_1, data_view_context) }
104
130
  end
105
131
  end
106
132
  visit.call(root)
107
133
  end
108
134
 
135
+ def audit_widget(widget, type, page_name, data_view_context)
136
+ if VISUAL_WIDGET_TYPES.include?(type) && !supported_widget_type?(type)
137
+ @unsupported[page_name] << type
138
+ elsif type == 'Forms$DynamicText' && !supported_dynamic_text?(widget, data_view_context)
139
+ @unsupported[page_name] << 'Forms$DynamicText(parameters)'
140
+ elsif type == 'Forms$ActionButton' && !supported_action_button?(widget)
141
+ action_type = widget.dig('Action', '$Type').to_s
142
+ @unsupported[page_name] << (action_type.empty? ? 'Forms$ActionButton(action)' : action_type)
143
+ elsif type == 'Forms$DataView' && !supported_data_view?(widget)
144
+ source_type = widget.dig('DataSource', '$Type').to_s
145
+ @unsupported[page_name] << (source_type.empty? ? 'Forms$DataView(source)' : source_type)
146
+ elsif type == 'Forms$ListView' && !supported_list_view?(widget)
147
+ source_type = widget.dig('DataSource', '$Type').to_s
148
+ @unsupported[page_name] << (source_type.empty? ? 'Forms$ListView(source)' : source_type)
149
+ elsif type == 'Forms$StaticImageViewer' && !supported_static_image?(widget)
150
+ @unsupported[page_name] << type
151
+ elsif type == 'Forms$ImageViewer' && !supported_image_viewer?(widget, data_view_context)
152
+ @unsupported[page_name] << type
153
+ elsif type == 'Forms$ImageUploader' && !data_view_context
154
+ @unsupported[page_name] << type
155
+ elsif type == 'Forms$ReferenceSetSelector' &&
156
+ !supported_reference_set_selector?(widget, data_view_context, page_name)
157
+ @unsupported[page_name] << type
158
+ elsif %w[Forms$MenuBar Forms$NavigationTree Forms$SimpleMenuBar].include?(type) &&
159
+ menu_identifier(widget['MenuSource']).to_s.empty?
160
+ @unsupported[page_name] << type
161
+ elsif type == 'Forms$SnippetCallWidget'
162
+ audit_snippet_call(widget, page_name, data_view_context)
163
+ elsif type == 'CustomWidgets$CustomWidget' && !supported_custom_widget?(widget)
164
+ identifier = widget.dig('Type', 'WidgetId').to_s
165
+ @unsupported[page_name] << (identifier.empty? ? type : identifier)
166
+ elsif BOUND_WIDGET_TYPES.include?(type) && !supported_bound_widget?(widget, data_view_context)
167
+ @unsupported[page_name] << type
168
+ elsif type == 'Forms$DivContainer'
169
+ action_type = widget.dig('OnClickAction', '$Type').to_s
170
+ @unsupported[page_name] << 'Forms$DivContainer(onClick)' \
171
+ unless action_type.empty? || action_type == 'Forms$NoAction'
172
+ end
173
+ end
174
+
175
+ def audit_template_grid(widget, page_name)
176
+ compiler = LegacyDataGridCompiler.new(
177
+ @source, page_name, widget, language: 'en_US', sequence: 1
178
+ )
179
+ compiler.html
180
+ @unsupported[page_name].concat(compiler.unsupported)
181
+ audit_unsupported_widgets(
182
+ widget['Contents'] || {}, page_name, initial_data_view_context: true
183
+ )
184
+ end
185
+
109
186
  def page_xml(unit, language, content)
110
187
  page = unit.document
111
188
  title = translated(page['Title'], language)
@@ -119,14 +196,14 @@ module Mxrb
119
196
  "title='#{escape(title)}'#{class_attribute} xmlns:m='http://schemas.mendix.com/forms/1.0'>" \
120
197
  "<m:layouts>#{layout.empty? ? '' : "<m:layout path='#{escape(layout)}.layout.xml'></m:layout>"}</m:layouts>" \
121
198
  "<m:arguments><m:argument parameterName='#{escape(parameter)}'>#{content}</m:argument></m:arguments>" \
122
- '</m:page>'
199
+ "#{templates_xml}</m:page>"
123
200
  end
124
201
 
125
202
  def layout_xml(unit, content)
126
203
  "\uFEFF<?xml version='1.0' encoding='utf-8'?>" \
127
204
  "<m:layout id='#{unit.id}' xmlns='http://www.w3.org/1999/xhtml' " \
128
205
  "xmlns:m='http://schemas.mendix.com/forms/1.0'>" \
129
- "<m:arguments><m:argument>#{content}</m:argument></m:arguments></m:layout>"
206
+ "<m:arguments><m:argument>#{content}</m:argument></m:arguments>#{templates_xml}</m:layout>"
130
207
  end
131
208
 
132
209
  def render_widget(widget, page_name, language)
@@ -140,8 +217,58 @@ module Mxrb
140
217
  "<div data-mx-placeholder='#{escape(identifier)}' class='mx-placeholder'></div>"
141
218
  when 'Forms$DivContainer'
142
219
  render_container(widget, page_name, language)
220
+ when 'Forms$Table'
221
+ render_table(widget, page_name, language)
222
+ when 'Forms$TemplateGrid'
223
+ render_template_grid(widget, page_name, language)
224
+ when 'Forms$LayoutGrid'
225
+ render_layout_grid(widget, page_name, language)
226
+ when 'Forms$LayoutGridRow'
227
+ render_grid_row(widget, page_name, language)
228
+ when 'Forms$LayoutGridColumn'
229
+ render_grid_column(widget, page_name, language)
230
+ when 'Forms$ScrollContainer'
231
+ render_scroll_container(widget, page_name, language)
232
+ when 'Forms$Header'
233
+ render_header(widget, page_name, language)
234
+ when 'Forms$SidebarToggleButton'
235
+ render_sidebar_toggle(widget, language)
236
+ when 'Forms$NavigationTree', 'Forms$MenuBar', 'Forms$SimpleMenuBar'
237
+ render_navigation(widget)
238
+ when 'Forms$ActionButton'
239
+ render_action_button(widget, language)
240
+ when 'Forms$MobileSaveButton'
241
+ render_mobile_button(widget, language, 'mxui.widget.SaveButton')
242
+ when 'Forms$MobileCancelButton'
243
+ render_mobile_button(widget, language, 'mxui.widget.CancelButton')
244
+ when 'Forms$MobileBackButton'
245
+ render_mobile_button(widget, language, 'mxui.widget.BackButton')
143
246
  when 'Forms$DynamicText'
144
247
  render_dynamic_text(widget, language)
248
+ when 'Forms$DataView'
249
+ render_data_view(widget, page_name, language)
250
+ when 'Forms$ListView'
251
+ render_list_view(widget, page_name, language)
252
+ when 'Forms$TextBox', 'Forms$TextArea', 'Forms$CheckBox', 'Forms$DatePicker',
253
+ 'Forms$DropDown', 'Forms$RadioButtonGroup', 'Forms$ReferenceSelector',
254
+ 'Forms$InputReferenceSetSelector'
255
+ render_bound_input(widget, language)
256
+ when 'Forms$Label'
257
+ render_label(widget, language)
258
+ when 'Forms$StaticImageViewer'
259
+ render_static_image(widget)
260
+ when 'Forms$ImageViewer'
261
+ render_image_viewer(widget)
262
+ when 'Forms$ImageUploader'
263
+ render_image_uploader(widget)
264
+ when 'Forms$ReferenceSetSelector'
265
+ render_reference_set_selector(widget, page_name, language)
266
+ when 'Forms$TabControl'
267
+ render_tab_control(widget, page_name, language)
268
+ when 'Forms$SnippetCallWidget'
269
+ render_snippet_call(widget, page_name, language)
270
+ when 'CustomWidgets$CustomWidget'
271
+ render_custom_widget(widget, language)
145
272
  when 'Forms$DataGrid'
146
273
  render_data_grid(widget, page_name, language)
147
274
  else
@@ -155,15 +282,960 @@ module Mxrb
155
282
  "<#{tag}#{html_attributes(widget, base: nil)}>#{children}</#{tag}>"
156
283
  end
157
284
 
285
+ def render_table(widget, page_name, language)
286
+ columns = array(widget['ColumnWidths'])
287
+ colgroup = columns.map do |column|
288
+ "<col style='width:#{column['Value'].to_i}%'></col>"
289
+ end.join
290
+ rows = array(widget['Rows']).each_with_index.map do |row, index|
291
+ cells = array(widget['Cells']).select { _1['TopRowIndex'].to_i == index }
292
+ .sort_by { _1['LeftColumnIndex'].to_i }
293
+ content = cells.map { render_table_cell(_1, page_name, language) }.join
294
+ "<tr#{html_attributes(row, base: nil)}>#{content}</tr>"
295
+ end.join
296
+ "<table#{html_attributes(widget, base: 'mx-table')}><colgroup>#{colgroup}</colgroup>" \
297
+ "<tbody>#{rows}</tbody></table>"
298
+ end
299
+
300
+ def render_table_cell(cell, page_name, language)
301
+ tag = cell['IsHeader'] == true ? 'th' : 'td'
302
+ spans = {}
303
+ spans['colspan'] = cell['Width'].to_i if cell['Width'].to_i > 1
304
+ spans['rowspan'] = cell['Height'].to_i if cell['Height'].to_i > 1
305
+ span_attributes = spans.map { |key, value| " #{key}='#{value}'" }.join
306
+ content = widget_children(cell).map { render_widget(_1, page_name, language) }.join
307
+ "<#{tag}#{html_attributes(cell, base: nil)}#{span_attributes}>#{content}</#{tag}>"
308
+ end
309
+
310
+ def render_template_grid(widget, page_name, language)
311
+ sequence = next_widget_sequence
312
+ compiler = LegacyDataGridCompiler.new(
313
+ @source, page_name, widget, language:, sequence:
314
+ )
315
+ html = compiler.html
316
+ @unsupported[page_name].concat(compiler.unsupported)
317
+ entity = widget.dig('DataSource', 'EntityPath').to_s
318
+ @data_view_stack ||= []
319
+ @data_view_stack << { entity:, label_width: 0, editable: false }
320
+ content = widget_children(widget['Contents'] || {}).map do |child|
321
+ render_widget(child, page_name, language)
322
+ end.join
323
+ @data_view_stack.pop
324
+ @templates << "<m:template widget-id='#{escape(compiler.id)}' name='content'>" \
325
+ "#{content}</m:template>"
326
+ html
327
+ end
328
+
329
+ def render_layout_grid(widget, page_name, language)
330
+ render_structural_widget(
331
+ widget, array(widget['Rows']), page_name, language,
332
+ base: 'mx-layoutgrid mx-layoutgrid-fluid'
333
+ )
334
+ end
335
+
336
+ def render_grid_row(widget, page_name, language)
337
+ render_structural_widget(widget, array(widget['Columns']), page_name, language, base: 'row')
338
+ end
339
+
340
+ def render_grid_column(widget, page_name, language)
341
+ classes = ['col', grid_weight_class('md', widget['Weight']),
342
+ grid_weight_class('sm', widget['TabletWeight']),
343
+ grid_weight_class('xs', widget['PhoneWeight'])].compact.join(' ')
344
+ render_structural_widget(widget, widget_children(widget), page_name, language, base: classes)
345
+ end
346
+
347
+ def render_structural_widget(widget, children, page_name, language, base:)
348
+ content = children.map { render_widget(_1, page_name, language) }.join
349
+ "<div#{html_attributes(widget, base:)}>#{content}</div>"
350
+ end
351
+
352
+ def render_scroll_container(widget, page_name, language)
353
+ fixed = widget['ScrollBehavior'] == 'PerRegion'
354
+ sides = [widget['Left'], widget['Right']].compact
355
+ return render_vertical_scroll_container(widget, page_name, language, fixed:) if sides.empty?
356
+
357
+ props = scroll_container_properties(widget, %w[Left CenterRegion Right], fixed:)
358
+ center = render_scroll_region(
359
+ widget['CenterRegion'], 'center', page_name, language, nested: true
360
+ ) do
361
+ render_vertical_scroll_container(widget, page_name, language, fixed:)
362
+ end
363
+ content = render_scroll_region(widget['Left'], 'left', page_name, language) + center +
364
+ render_scroll_region(widget['Right'], 'right', page_name, language)
365
+ render_client_container(
366
+ widget, 'mxui.widget.HorizontalScrollContainer', props, content,
367
+ base: scroll_container_classes('horizontal', fixed), widget_classes: false, tabindex: false
368
+ )
369
+ end
370
+
371
+ def render_vertical_scroll_container(widget, page_name, language, fixed:)
372
+ props = scroll_container_properties(widget, %w[Top CenterRegion Bottom], fixed:)
373
+ content = render_scroll_region(widget['Top'], 'top', page_name, language) +
374
+ render_scroll_region(widget['CenterRegion'], 'middle', page_name, language) +
375
+ render_scroll_region(widget['Bottom'], 'bottom', page_name, language)
376
+ render_client_container(
377
+ widget, 'mxui.widget.VerticalScrollContainer', props, content,
378
+ base: scroll_container_classes('vertical', fixed), widget_classes: false, tabindex: false
379
+ )
380
+ end
381
+
382
+ def scroll_container_properties(widget, region_names, fixed:)
383
+ config = region_names.filter_map do |name|
384
+ region = widget[name]
385
+ next unless region
386
+
387
+ position = if name == 'CenterRegion'
388
+ region_names.include?('Left') ? 'center' : 'middle'
389
+ else
390
+ name.downcase
391
+ end
392
+ scroll_region_config(region, position)
393
+ end
394
+ { 'fixed' => fixed, 'config' => config }
395
+ end
396
+
397
+ def scroll_region_config(region, position)
398
+ config = { 'position' => position }
399
+ toggle = region['ToggleMode'].to_s
400
+ return config if toggle.empty? || toggle == 'None'
401
+
402
+ config['toggleMode'] = legacy_toggle_mode(toggle)
403
+ config['initiallyOpen'] = toggle.include?('InitiallyOpen') ||
404
+ (!toggle.include?('InitiallyClosed') && toggle.start_with?('ShrinkContent'))
405
+ config
406
+ end
407
+
408
+ def legacy_toggle_mode(value)
409
+ return 'shrinkContent' if value.start_with?('ShrinkContent')
410
+ return 'pushContentAside' if value.start_with?('PushContent')
411
+ return 'slideOverContent' if value.start_with?('SlideOverContent')
412
+
413
+ value.sub(/Initially(?:Open|Closed)\z/, '').then { _1[0].to_s.downcase + _1[1..].to_s }
414
+ end
415
+
416
+ def render_scroll_region(region, position, page_name, language, nested: false)
417
+ return '' unless region
418
+
419
+ content = if block_given?
420
+ yield
421
+ else
422
+ array(region['Widgets']).map do |child|
423
+ render_widget(child, page_name, language)
424
+ end.join
425
+ end
426
+ base = "mx-layoutcontainer-#{position} mx-scrollcontainer-#{position}"
427
+ region_classes = css_classes(region, base:)
428
+ wrapper = 'mx-layoutcontainer-wrapper mx-scrollcontainer-wrapper'
429
+ wrapper += ' mx-layoutcontainer-nested mx-scrollcontainer-nested' if nested
430
+ style = scroll_region_style(region, position)
431
+ style_attribute = style.empty? ? '' : " style='#{escape(style)}'"
432
+ "<div class='#{escape(region_classes)}'#{style_attribute}>" \
433
+ "<div class='#{wrapper}'>#{content}</div></div>"
434
+ end
435
+
436
+ def scroll_region_style(region, position)
437
+ styles = [region['Style'].to_s, region.dig('Appearance', 'Style').to_s].reject(&:empty?)
438
+ if region['SizeMode'] == 'Pixels' && region['Size'].to_i.positive?
439
+ dimension = %w[left right].include?(position) ? 'width' : 'height'
440
+ styles << "#{dimension}:#{region['Size'].to_i}px"
441
+ end
442
+ styles.join(';')
443
+ end
444
+
445
+ def scroll_container_classes(orientation, fixed)
446
+ classes = "mx-layoutcontainer mx-scrollcontainer mx-layoutcontainer-#{orientation} " \
447
+ "mx-scrollcontainer-#{orientation}"
448
+ classes += ' mx-layoutcontainer-fixed mx-scrollcontainer-fixed' if fixed
449
+ classes
450
+ end
451
+
452
+ def render_header(widget, page_name, language)
453
+ left = array(widget['LeftWidgets']).map { render_widget(_1, page_name, language) }.join
454
+ right = array(widget['RightWidgets']).map { render_widget(_1, page_name, language) }.join
455
+ title = render_client_widget(
456
+ {}, 'mxui.widget.Title', {}, base: nil, widget_classes: false, tabindex: false,
457
+ properties: false
458
+ )
459
+ "<div#{html_attributes(widget, base: 'mx-header')}>" \
460
+ "<div class='mx-header-center'>#{title}</div>" \
461
+ "<div class='mx-header-left'>#{left}</div>" \
462
+ "<div class='mx-header-right'>#{right}</div></div>"
463
+ end
464
+
465
+ def render_sidebar_toggle(widget, language)
466
+ props = button_properties(widget, language)
467
+ props['iconClass'] = 'glyphicon-menu-hamburger' if widget['Icon']&.dig('$Type') == 'Forms$GlyphIcon'
468
+ render_type = props['renderType']
469
+ base = render_type == 'button' ? button_style(widget) : nil
470
+ render_client_widget(widget, 'mxui.widget.SidebarToggleButton', props, base:)
471
+ end
472
+
473
+ def render_navigation(widget)
474
+ type = case widget['$Type']
475
+ when 'Forms$NavigationTree' then 'mxui.widget.NavigationTree'
476
+ when 'Forms$MenuBar' then 'mxui.widget.Navbar'
477
+ else 'mxui.widget.MenuBar'
478
+ end
479
+ props = { 'menuID' => menu_identifier(widget['MenuSource']) }
480
+ props['orientation'] = widget['Orientation'].to_s.downcase if widget['$Type'] == 'Forms$SimpleMenuBar'
481
+ render_client_widget(widget, type, props, base: nil, tabindex: false)
482
+ end
483
+
484
+ def menu_identifier(source)
485
+ case source&.fetch('$Type', nil)
486
+ when 'Forms$NavigationSource'
487
+ documents = @source.units_of('Navigation$NavigationDocument').map(&:document)
488
+ profile = documents.flat_map { array(_1['Profiles']) }
489
+ .find { _1['Name'] == source['NavigationProfile'] }
490
+ device_key = "#{source['DeviceType']}Profile"
491
+ profile ||= documents.filter_map { _1[device_key] }.first
492
+ IO::BsonCodec.extract_id(profile&.dig('Menu', '$ID'))
493
+ when 'Forms$MenuDocumentSource'
494
+ qualified = source['Menu'].to_s
495
+ menu = @source.units_of('Menus$MenuDocument').find do |unit|
496
+ "#{unit.module_name}.#{unit.document['Name']}" == qualified
497
+ end
498
+ IO::BsonCodec.extract_id(menu&.document&.dig('ItemCollection', '$ID')) || menu&.id
499
+ end
500
+ end
501
+
502
+ def render_action_button(widget, language)
503
+ action = widget['Action'] || {}
504
+ props = button_properties(widget, language)
505
+ case action['$Type']
506
+ when 'Forms$SaveChangesClientAction'
507
+ props.merge!('closeForm' => action.fetch('ClosePage', true), 'needsObject' => true)
508
+ render_client_widget(widget, 'mxui.widget.SaveButton', props)
509
+ when 'Forms$CancelChangesClientAction'
510
+ props.merge!('closeForm' => action.fetch('ClosePage', true), 'needsObject' => true)
511
+ render_client_widget(widget, 'mxui.widget.CancelButton', props)
512
+ when 'Forms$ClosePageClientAction'
513
+ render_client_widget(widget, 'mxui.widget.BackButton', props)
514
+ when 'Forms$OpenLinkClientAction'
515
+ render_link_button(widget, action, props)
516
+ else
517
+ props['action'] = legacy_client_action(action)
518
+ render_client_widget(widget, 'mxui.widget.ActionButton', props)
519
+ end
520
+ end
521
+
522
+ def render_mobile_button(widget, language, type)
523
+ props = button_properties(widget, language)
524
+ unless type == 'mxui.widget.BackButton'
525
+ props.merge!('closeForm' => widget.fetch('ClosePage', true), 'needsObject' => true)
526
+ end
527
+ render_client_widget(widget, type, props)
528
+ end
529
+
530
+ def render_data_view(widget, page_name, language)
531
+ id = next_widget_id
532
+ template_index = @templates.length
533
+ @templates << nil
534
+ @data_view_stack ||= []
535
+ @data_view_stack << {
536
+ entity: data_view_entity(widget['DataSource'] || {}),
537
+ label_width: positive_integer(widget['LabelWidth'], 3),
538
+ editable: widget['Editable'] != false
539
+ }
540
+ content = data_view_children(widget, 'Widgets', 'Widget')
541
+ .map { render_widget(_1, page_name, language) }.join
542
+ footer = data_view_children(widget, 'FooterWidgets', 'FooterWidget')
543
+ .map { render_widget(_1, page_name, language) }.join
544
+ @data_view_stack.pop
545
+ @templates[template_index] = data_view_templates(id, content, footer)
546
+ render_client_widget(
547
+ widget, 'mxui.widget.DataView', data_view_properties(widget), id:, base: 'form-horizontal'
548
+ )
549
+ end
550
+
551
+ def render_list_view(widget, page_name, language)
552
+ id = next_widget_id
553
+ @widget_ids_by_name ||= {}
554
+ @widget_ids_by_name[widget['Name'].to_s] = id unless widget['Name'].to_s.empty?
555
+ entity = list_view_entity(widget)
556
+ template_index = @templates.length
557
+ @templates << nil
558
+ @data_view_stack ||= []
559
+ @data_view_stack << { entity:, label_width: 0, editable: widget['Editable'] != false }
560
+ content = widget_children(widget).map do |child|
561
+ render_widget(child, page_name, language)
562
+ end.join
563
+ @data_view_stack.pop
564
+ @templates[template_index] = "<m:template widget-id='#{escape(id)}' name='content'>" \
565
+ "#{content}</m:template>"
566
+ props = {
567
+ 'datasource' => list_view_source(widget, page_name, entity),
568
+ 'page' => positive_integer(widget['PageSize'], 20),
569
+ 'hasSearch' => !list_view_search_paths(widget['DataSource'] || {}).empty?,
570
+ 'action' => legacy_client_action(widget['ClickAction'] || { '$Type' => 'Forms$NoAction' }),
571
+ 'selectable' => list_view_listened_to?(widget['Name'], page_name) ? 'single' : '',
572
+ 'templateMap' => { entity => 'content' },
573
+ 'readOnly' => widget['Editable'] == false
574
+ }
575
+ render_client_widget(widget, 'mxui.widget.ListView', props, id:, base: nil)
576
+ end
577
+
578
+ def list_view_source(widget, page_name, entity)
579
+ source = widget['DataSource'] || {}
580
+ type = source['$Type'] == 'Forms$NewListViewDatabaseSource' ? 'database' : 'xpath'
581
+ props = {
582
+ 'friendlyId' => "#{page_name}.#{widget['Name']}", 'type' => type, 'path' => entity
583
+ }
584
+ props['offlineConstraints'] = [] if type == 'database'
585
+ constraint = source['XPathConstraint'].to_s
586
+ props['xpathConstraints'] = constraint unless constraint.empty?
587
+ sort = array(source.dig('SortBar', 'SortItems')).map do |item|
588
+ [relative_list_attribute(item['AttributePath'], entity),
589
+ item['SortOrder'].to_s == 'Descending' ? 'desc' : 'asc']
590
+ end
591
+ props['sort'] = sort unless sort.empty?
592
+ search = list_view_search_paths(source).map do |path|
593
+ relative_list_attribute(path['AttributePath'] || path['Attribute'] || path, entity)
594
+ end.reject(&:empty?)
595
+ props['search'] = search unless search.empty?
596
+ props
597
+ end
598
+
599
+ def relative_list_attribute(path, entity)
600
+ path.to_s.delete_prefix("#{entity}.")
601
+ end
602
+
603
+ def list_view_entity(widget)
604
+ source = widget['DataSource'] || {}
605
+ entity = source['EntityPath'].to_s
606
+ entity = source.dig('EntityRef', 'Entity').to_s if entity.empty?
607
+ entity
608
+ end
609
+
610
+ def list_view_search_paths(source)
611
+ paths = array(source.dig('Search', 'SearchPaths'))
612
+ paths.empty? ? array(source.dig('Search', 'SearchRefs')) : paths
613
+ end
614
+
615
+ def list_view_listened_to?(name, page_name)
616
+ legacy_related_documents(page_name).any? do |document|
617
+ descendants(document).any? do |value|
618
+ value['$Type'] == 'Forms$ListenTargetSource' && value['ListenTarget'] == name
619
+ end
620
+ end
621
+ end
622
+
623
+ def legacy_related_documents(page_name, seen = {})
624
+ unit = (@source.units_of('Forms$Page') + @source.units_of('Forms$Layout')).find do |candidate|
625
+ "#{candidate.module_name}.#{candidate.document['Name']}" == page_name
626
+ end
627
+ return [] unless unit
628
+
629
+ documents = [unit.document]
630
+ descendants(unit.document).select { _1['$Type'] == 'Forms$SnippetCallWidget' }.each do |call|
631
+ qualified = call.dig('FormCall', 'Form').to_s
632
+ next if seen[qualified]
633
+
634
+ seen[qualified] = true
635
+ snippet = snippet_unit(qualified)
636
+ documents.concat(legacy_related_snippet_documents(snippet, seen)) if snippet
637
+ end
638
+ documents
639
+ end
640
+
641
+ def legacy_related_snippet_documents(snippet, seen)
642
+ documents = [snippet.document]
643
+ descendants(snippet.document).select { _1['$Type'] == 'Forms$SnippetCallWidget' }.each do |call|
644
+ qualified = call.dig('FormCall', 'Form').to_s
645
+ next if seen[qualified]
646
+
647
+ seen[qualified] = true
648
+ child = snippet_unit(qualified)
649
+ documents.concat(legacy_related_snippet_documents(child, seen)) if child
650
+ end
651
+ documents
652
+ end
653
+
654
+ def data_view_templates(id, content, footer)
655
+ templates = +"<m:template widget-id='#{escape(id)}' name='content'>#{content}</m:template>"
656
+ templates << "<m:template widget-id='#{escape(id)}' name='footer'>#{footer}</m:template>" unless footer.empty?
657
+ templates
658
+ end
659
+
660
+ def data_view_properties(widget)
661
+ source = widget['DataSource'] || {}
662
+ entity = data_view_entity(source)
663
+ props = {
664
+ 'entity' => entity,
665
+ 'schema' => IO::BsonCodec.extract_id(widget['$ID']).to_s,
666
+ 'datasource' => data_view_source(source, entity)
667
+ }
668
+ props['readOnly'] = true if widget['Editable'] == false
669
+ props['hideFooter'] = true if widget['ShowFooter'] == false
670
+ props
671
+ end
672
+
673
+ def data_view_source(source, entity)
674
+ if source['$Type'] == 'Forms$ListenTargetSource'
675
+ target = source['ListenTarget'].to_s
676
+ return { 'type' => 'listen', 'contextsource' => @widget_ids_by_name&.fetch(target, target) }
677
+ end
678
+
679
+ if source['$Type'] == 'Forms$MicroflowSource'
680
+ return {
681
+ 'type' => 'microflow',
682
+ 'microflow' => source.dig('MicroflowSettings', 'Microflow').to_s,
683
+ 'argMap' => {}
684
+ }
685
+ end
686
+
687
+ path = data_view_path(source)
688
+ { 'type' => 'direct', 'path' => path.empty? ? entity : path }
689
+ end
690
+
691
+ def data_view_entity(source)
692
+ if source['$Type'] == 'Forms$ListenTargetSource'
693
+ target = source['ListenTarget'].to_s
694
+ list = @source.documents.flat_map { descendants(_1) }.find do |value|
695
+ value['$Type'] == 'Forms$ListView' && value['Name'] == target
696
+ end
697
+ return list_view_entity(list || {})
698
+ end
699
+
700
+ path = source['EntityPath'].to_s
701
+ return path.split('/').last unless path.empty?
702
+
703
+ reference = source['EntityRef'] || {}
704
+ steps = array(reference['Steps'])
705
+ destination = steps.last&.fetch('DestinationEntity', '').to_s
706
+ return destination unless destination.empty?
707
+
708
+ direct = reference['Entity'].to_s
709
+ return direct unless direct.empty?
710
+
711
+ microflow_return_entity(source)
712
+ end
713
+
714
+ def data_view_path(source)
715
+ legacy = source['EntityPath'].to_s
716
+ return legacy unless legacy.empty?
717
+
718
+ reference = source['EntityRef'] || {}
719
+ steps = array(reference['Steps'])
720
+ return reference['Entity'].to_s if steps.empty?
721
+
722
+ steps.flat_map { [_1['Association'], _1['DestinationEntity']] }
723
+ .map(&:to_s).reject(&:empty?).join('/')
724
+ end
725
+
726
+ def microflow_return_entity(source)
727
+ name = source.dig('MicroflowSettings', 'Microflow').to_s
728
+ unit = @source.units_of('Microflows$Microflow').find do |candidate|
729
+ "#{candidate.module_name}.#{candidate.document['Name']}" == name
730
+ end
731
+ unit&.document&.dig('MicroflowReturnType', 'Entity').to_s
732
+ end
733
+
734
+ def data_view_children(widget, plural, singular)
735
+ children = array(widget[plural])
736
+ return children unless children.empty?
737
+
738
+ widget_children(widget[singular])
739
+ end
740
+
741
+ def templates_xml
742
+ templates = Array(@templates).compact.join
743
+ templates.empty? ? '' : "<m:templates>#{templates}</m:templates>"
744
+ end
745
+
746
+ def render_bound_input(widget, language)
747
+ context = Array(@data_view_stack).last
748
+ return '' unless context
749
+
750
+ outer_id = next_widget_id
751
+ type, props = bound_input_contract(widget, language, context)
752
+ input = render_client_widget(
753
+ widget, type, props, base: "col-sm-#{12 - context[:label_width]}", widget_classes: false
754
+ )
755
+ label = translated_input_text(widget, 'LabelTemplate', 'LabelText', language)
756
+ attributes = html_attributes(widget, base: 'form-group', mendix_id: outer_id)
757
+ "<div#{attributes}>" \
758
+ "<label class='control-label col-sm-#{context[:label_width]}'>#{escape(label)}</label>" \
759
+ "#{input}</div>"
760
+ end
761
+
762
+ def bound_input_contract(widget, language, context)
763
+ attribute = attribute_reference(widget)
764
+ props = {
765
+ 'insideFormGroup' => true,
766
+ 'attributePath' => bound_attribute_path(widget, context),
767
+ 'readOnlyStyle' => read_only_style(widget)
768
+ }
769
+ props['readOnly'] = true if widget['Editable'] == 'Never' || !context[:editable]
770
+ props.merge!(required_properties(widget, language))
771
+ case widget['$Type']
772
+ when 'Forms$TextBox' then text_box_contract(widget, attribute, language, props)
773
+ when 'Forms$TextArea' then text_area_contract(widget, language, props)
774
+ when 'Forms$CheckBox' then ['mxui.widget.BoolSelect', props]
775
+ when 'Forms$DatePicker' then date_picker_contract(widget, language, props)
776
+ when 'Forms$DropDown' then ['mxui.widget.EnumSelect', props]
777
+ when 'Forms$RadioButtonGroup' then radio_button_contract(widget, attribute, language, props)
778
+ when 'Forms$ReferenceSelector' then reference_selector_contract(widget, props)
779
+ when 'Forms$InputReferenceSetSelector' then reference_set_selector_contract(widget, props)
780
+ end
781
+ end
782
+
783
+ def bound_attribute_path(widget, context)
784
+ return reference_attribute_path(widget, context) if %w[
785
+ Forms$ReferenceSelector Forms$InputReferenceSetSelector
786
+ ].include?(widget['$Type'])
787
+
788
+ "#{context[:entity]}/#{attribute_reference(widget).split('.').last}"
789
+ end
790
+
791
+ def text_box_contract(widget, attribute, language, props)
792
+ props['placeholder'] = translated_input_text(widget, 'PlaceholderTemplate', 'Placeholder', language)
793
+ type = attribute_definition(attribute)&.dig('NewType') ||
794
+ attribute_definition(attribute)&.dig('Type') || {}
795
+ input_type = if widget['IsPasswordBox'] == true
796
+ 'mxui.widget.PasswordInput'
797
+ elsif numeric_attribute_type?(type)
798
+ 'mxui.widget.NumberInput'
799
+ else
800
+ 'mxui.widget.TextInput'
801
+ end
802
+ max_length = positive_integer(widget['MaxLengthCode'], type['Length'].to_i)
803
+ props['maxLength'] = max_length if input_type != 'mxui.widget.NumberInput' && max_length.positive?
804
+ props['mask'] = widget['InputMask'].to_s unless widget['InputMask'].to_s.empty?
805
+ [input_type, props]
806
+ end
807
+
808
+ def text_area_contract(widget, language, props)
809
+ props['placeholder'] = translated_input_text(widget, 'PlaceholderTemplate', 'Placeholder', language)
810
+ props['rows'] = positive_integer(widget['NumberOfLines'], 5)
811
+ max_length = positive_integer(widget['MaxLengthCode'], 0)
812
+ props['maxLength'] = max_length if max_length.positive?
813
+ ['mxui.widget.TextArea', props]
814
+ end
815
+
816
+ def date_picker_contract(widget, language, props)
817
+ props['placeholder'] = translated_input_text(widget, 'PlaceholderTemplate', 'Placeholder', language)
818
+ format = widget.dig('FormattingInfo', 'DateFormat').to_s.downcase
819
+ props['selector'] = %w[date time].include?(format) ? format : 'datetime'
820
+ custom = widget.dig('FormattingInfo', 'CustomDateFormat').to_s
821
+ props['format'] = custom unless custom.empty?
822
+ ['mxui.widget.DateInput', props]
823
+ end
824
+
825
+ def radio_button_contract(widget, attribute, language, props)
826
+ props['horizontal'] = widget.fetch('Orientation', 'Horizontal') != 'Vertical'
827
+ props['options'] = enumeration_options(attribute, language)
828
+ ['mxui.widget.RadioButtonGroup', props]
829
+ end
830
+
831
+ def reference_selector_contract(widget, props)
832
+ props['datasource'] = selector_source(widget)
833
+ settings = widget['GotoFormSettings'] || {}
834
+ props['gotoPageSettings'] = page_settings(settings) unless settings['Form'].to_s.empty?
835
+ ['mxui.widget.ReferenceSelector', props]
836
+ end
837
+
838
+ def reference_set_selector_contract(widget, props)
839
+ props['datasource'] = selector_source(widget)
840
+ settings = widget['PopupFormSettings'] || {}
841
+ props['selectPageSettings'] = page_settings(settings) unless settings['Form'].to_s.empty?
842
+ ['mxui.widget.InputReferenceSetSelector', props]
843
+ end
844
+
845
+ def selector_source(widget)
846
+ source = widget['SelectorSource'] || {}
847
+ target, caption = selector_target(widget)
848
+ if source['$Type'] == 'Forms$SelectorMicroflowSource'
849
+ return {
850
+ 'type' => 'microflow',
851
+ 'microflow' => source.dig('DataSourceMicroflowSettings', 'Microflow').to_s,
852
+ 'path' => target
853
+ }
854
+ end
855
+
856
+ result = {
857
+ 'type' => 'xpath', 'path' => target, 'constrainedBy' => [],
858
+ 'sort' => [[caption, 'asc']], 'contextAction' => 'keep'
859
+ }
860
+ constraint = source['XPathConstraint'].to_s
861
+ result['constraint'] = constraint unless constraint.empty?
862
+ result
863
+ end
864
+
865
+ def selector_target(widget)
866
+ attribute = attribute_reference(widget).split('/').last.to_s
867
+ parts = attribute.split('.')
868
+ [parts.first(2).join('.'), parts.last.to_s]
869
+ end
870
+
871
+ def reference_attribute_path(widget, context)
872
+ legacy = widget['AttributePath'].to_s
873
+ unless legacy.empty?
874
+ segments = legacy.split('/')
875
+ segments[-1] = segments.last.to_s.split('.').last
876
+ return ([context[:entity]] + segments).join('/')
877
+ end
878
+
879
+ reference = widget.dig('AttributeRef', 'EntityRef') || {}
880
+ steps = array(reference['Steps']).flat_map do |step|
881
+ [step['Association'], step['DestinationEntity']]
882
+ end
883
+ ([context[:entity]] + steps + [attribute_reference(widget).split('.').last])
884
+ .map(&:to_s).reject(&:empty?).join('/')
885
+ end
886
+
887
+ def required_properties(widget, language)
888
+ return {} unless widget['Required'] == true
889
+
890
+ {
891
+ 'required' => 'true',
892
+ 'requiredMsg' => translated_input_text(widget, 'RequiredMessageTemplate', 'RequiredMessage', language)
893
+ }
894
+ end
895
+
896
+ def read_only_style(widget)
897
+ style = widget['ReadOnlyStyle'].to_s.downcase
898
+ %w[control text].include?(style) ? style : 'text'
899
+ end
900
+
901
+ def translated_input_text(widget, template_key, text_key, language)
902
+ template = widget.dig(template_key, 'Template')
903
+ translated(template || widget[text_key], language)
904
+ end
905
+
906
+ def render_label(widget, language)
907
+ tag = widget['RenderMode'].to_s == 'Paragraph' ? 'p' : 'label'
908
+ attributes = html_attributes(widget, base: nil, mendix_id: next_widget_id)
909
+ "<#{tag}#{attributes}>#{escape(translated(widget['Caption'], language))}</#{tag}>"
910
+ end
911
+
912
+ def render_static_image(widget)
913
+ props = { 'url' => image_uri(widget['Image']) }
914
+ action = widget['ClickAction'] || {}
915
+ props['action'] = legacy_client_action(action) unless action['$Type'] == 'Forms$NoAction'
916
+ base = widget['Responsive'] == true ? 'img-responsive' : nil
917
+ render_client_widget(
918
+ widget, 'mxui.widget.StaticImage', props, base:, style: static_image_style(widget)
919
+ )
920
+ end
921
+
922
+ def render_image_viewer(widget)
923
+ source = widget['DataSource'] || {}
924
+ props = {
925
+ 'datasource' => { 'type' => 'direct', 'path' => source['EntityPath'].to_s },
926
+ 'defaultUrl' => widget['DefaultImage'].to_s.empty? ? '' : image_uri(widget['DefaultImage']),
927
+ 'thumb' => widget['ShowAsThumbnail'] == true,
928
+ 'width' => image_dimension(widget['Width'], widget['WidthUnit']),
929
+ 'height' => image_dimension(widget['Height'], widget['HeightUnit'])
930
+ }
931
+ base = widget['Responsive'] == true ? 'img-responsive' : nil
932
+ render_client_widget(widget, 'mxui.widget.Image', props, base:)
933
+ end
934
+
935
+ def render_image_uploader(widget)
936
+ width, height = widget['ThumbnailSize'].to_s.split(';').map(&:to_i)
937
+ props = {
938
+ 'thumbnailWidth' => width.positive? ? width : 100,
939
+ 'thumbnailHeight' => height.positive? ? height : 75,
940
+ 'maxFileSize' => widget['MaxFileSize'].to_i,
941
+ 'restrictions' => widget['AllowedExtensions'].to_s,
942
+ 'uploadable' => widget['Editable'] != 'Never'
943
+ }
944
+ render_client_widget(widget, 'mxui.widget.ImageUploader', props, base: nil)
945
+ end
946
+
947
+ def render_reference_set_selector(widget, page_name, language)
948
+ compiler = LegacyDataGridCompiler.new(
949
+ @source, page_name, widget, language:, sequence: next_widget_sequence
950
+ )
951
+ compiler.html.tap { @unsupported[page_name].concat(compiler.unsupported) }
952
+ end
953
+
954
+ def image_dimension(value, unit)
955
+ number = value.to_i
956
+ return '' unless number.positive?
957
+
958
+ case unit
959
+ when 'Pixels' then "#{number}px"
960
+ when 'Percentage' then "#{number}%"
961
+ else ''
962
+ end
963
+ end
964
+
965
+ def static_image_style(widget)
966
+ dimensions = [%w[Width WidthUnit width], %w[Height HeightUnit height]].filter_map do |value, unit, name|
967
+ amount = widget[value].to_i
968
+ suffix = { 'Pixels' => 'px', 'Percentage' => '%' }[widget[unit].to_s]
969
+ "#{name}:#{amount}#{suffix}" if amount.positive? && suffix
970
+ end
971
+ [widget['Style'].to_s, *dimensions].reject(&:empty?).join(';')
972
+ end
973
+
974
+ def render_tab_control(widget, page_name, language)
975
+ id = next_widget_id
976
+ tabs = array(widget['TabPages'])
977
+ selected = default_tab_index(widget, tabs)
978
+ pages = tabs.each_with_index.map do |tab, index|
979
+ tab_id = next_widget_id
980
+ props = {
981
+ 'title' => translated(tab['Caption'], language), 'delayLoading' => index != selected,
982
+ 'tabName' => tab['Name'].to_s
983
+ }
984
+ [tab, tab_id, render_client_widget(
985
+ tab, 'mxui.widget.TabContent', props, id: tab_id, base: nil,
986
+ widget_classes: false, tabindex: false
987
+ )]
988
+ end
989
+ @templates << "<m:template widget-id='#{escape(id)}' name='pages'>" \
990
+ "#{pages.map(&:last).join}</m:template>"
991
+ pages.each do |tab, tab_id, _html|
992
+ content_index = @templates.length
993
+ @templates << nil
994
+ content = array(tab['Widgets']).map { render_widget(_1, page_name, language) }.join
995
+ @templates[content_index] = "<m:template widget-id='#{escape(tab_id)}' " \
996
+ "name='content'>#{content}</m:template>"
997
+ end
998
+ render_client_widget(widget, 'mxui.widget.TabContainer', {}, id:, base: nil)
999
+ end
1000
+
1001
+ def default_tab_index(widget, tabs)
1002
+ pointer = IO::BsonCodec.extract_id(widget['DefaultPagePointer'])
1003
+ tabs.index { IO::BsonCodec.extract_id(_1['$ID']) == pointer } || 0
1004
+ end
1005
+
1006
+ def render_snippet_call(widget, page_name, language)
1007
+ qualified = widget.dig('FormCall', 'Form').to_s
1008
+ snippet = snippet_unit(qualified)
1009
+ @snippet_stack ||= []
1010
+ return '' unless snippet && !@snippet_stack.include?(qualified)
1011
+
1012
+ @snippet_stack << qualified
1013
+ widget_children(snippet.document).map { render_widget(_1, page_name, language) }.join
1014
+ ensure
1015
+ @snippet_stack.pop if @snippet_stack&.last == qualified
1016
+ end
1017
+
1018
+ def audit_snippet_call(widget, page_name, data_view_context)
1019
+ qualified = widget.dig('FormCall', 'Form').to_s
1020
+ snippet = snippet_unit(qualified)
1021
+ @snippet_audit_stack ||= []
1022
+ if !snippet || @snippet_audit_stack.include?(qualified)
1023
+ @unsupported[page_name] << 'Forms$SnippetCallWidget'
1024
+ return
1025
+ end
1026
+
1027
+ @snippet_audit_stack << qualified
1028
+ audit_unsupported_widgets(
1029
+ snippet.document, page_name, initial_data_view_context: data_view_context
1030
+ )
1031
+ ensure
1032
+ @snippet_audit_stack.pop if @snippet_audit_stack&.last == qualified
1033
+ end
1034
+
1035
+ def snippet_unit(qualified)
1036
+ @source.units_of('Forms$Snippet').find do |unit|
1037
+ "#{unit.module_name}.#{unit.document['Name']}" == qualified
1038
+ end
1039
+ end
1040
+
1041
+ def render_custom_widget(widget, language)
1042
+ compiler = LegacyCustomWidgetCompiler.new(@source, widget, language:)
1043
+ return '' unless compiler.supported?
1044
+
1045
+ render_client_widget(widget, compiler.widget_id, compiler.properties_hash, base: nil)
1046
+ end
1047
+
1048
+ def button_properties(widget, language)
1049
+ caption = translated(widget.dig('CaptionTemplate', 'Template'), language)
1050
+ render_type = widget['RenderType'].to_s.downcase
1051
+ props = {
1052
+ 'caption' => { 'text' => caption },
1053
+ 'renderType' => render_type.empty? ? 'button' : render_type
1054
+ }
1055
+ icon = image_uri(widget.dig('Icon', 'Image'))
1056
+ props['iconUrl'] = icon if icon
1057
+ props
1058
+ end
1059
+
1060
+ def render_link_button(widget, action, props)
1061
+ address = action['Address'] || {}
1062
+ props['action'] = {
1063
+ 'Web' => 'open', 'Email' => 'email', 'Call' => 'call', 'Text' => 'text'
1064
+ }.fetch(action['LinkType'].to_s, action['LinkType'].to_s.downcase)
1065
+ props['address'] = address['Value'].to_s
1066
+ render_client_widget(widget, 'mxui.widget.LinkButton', props)
1067
+ end
1068
+
1069
+ def render_client_widget(widget, type, props, **options)
1070
+ attributes = client_widget_attributes(widget, type, props, **options)
1071
+ serialized = attributes.map { |key, value| "#{key}='#{escape(value)}'" }.join(' ')
1072
+ "<div #{serialized}></div>"
1073
+ end
1074
+
1075
+ def render_client_container(widget, type, props, content, **options)
1076
+ attributes = client_widget_attributes(widget, type, props, **options)
1077
+ serialized = attributes.map { |key, value| "#{key}='#{escape(value)}'" }.join(' ')
1078
+ "<div #{serialized}>#{content}</div>"
1079
+ end
1080
+
1081
+ def client_widget_attributes(widget, type, props, **options)
1082
+ id = options.key?(:id) ? options[:id] : next_widget_id
1083
+ @widget_ids_by_name ||= {}
1084
+ @widget_ids_by_name[widget['Name'].to_s] = id unless widget['Name'].to_s.empty?
1085
+ base = options.key?(:base) ? options[:base] : button_style(widget)
1086
+ widget_classes = options.fetch(:widget_classes, true)
1087
+ style = options.fetch(:style, widget['Style'].to_s)
1088
+ style = widget.dig('Appearance', 'Style').to_s if style.empty?
1089
+ tabindex = options.fetch(:tabindex, true)
1090
+ properties = options.fetch(:properties, true)
1091
+ {
1092
+ 'data-mendix-id' => id,
1093
+ 'data-mendix-type' => type,
1094
+ 'data-mendix-props' => properties ? JSON.generate(props)[1..-2] : '',
1095
+ 'class' => widget_classes ? css_classes(widget, base:) : base,
1096
+ 'style' => style,
1097
+ 'tabindex' => tabindex ? widget['TabIndex'].to_i : ''
1098
+ }.reject { |_key, value| value.to_s.empty? }
1099
+ end
1100
+
1101
+ def legacy_client_action(action)
1102
+ case action['$Type']
1103
+ when 'Forms$MicroflowAction'
1104
+ microflow_action(action)
1105
+ when 'Forms$FormAction'
1106
+ settings = action['FormSettings'] || {}
1107
+ {
1108
+ 'type' => 'openPage',
1109
+ 'hasParameter' => !array(settings['ParameterMappings']).empty?,
1110
+ 'params' => page_settings(settings)
1111
+ }
1112
+ when 'Forms$NoAction'
1113
+ { 'type' => 'doNothing', 'hasParameter' => false, 'params' => {} }
1114
+ end
1115
+ end
1116
+
1117
+ def microflow_action(action)
1118
+ settings = action['MicroflowSettings'] || action
1119
+ name = settings['Microflow'].to_s
1120
+ has_parameter = microflow_has_parameter?(name)
1121
+ params = { 'name' => name }
1122
+ params['validate'] = 'view' unless settings['FormValidations'] == 'None'
1123
+ params['applyTo'] = 'selection' if has_parameter
1124
+ { 'type' => 'callMicroflow', 'hasParameter' => has_parameter, 'params' => params }
1125
+ end
1126
+
1127
+ def microflow_has_parameter?(qualified_name)
1128
+ unit = @source.units_of('Microflows$Microflow').find do |candidate|
1129
+ "#{candidate.module_name}.#{candidate.document['Name']}" == qualified_name
1130
+ end
1131
+ descendants(unit&.document || {}).any? { _1['$Type'] == 'Microflows$MicroflowParameter' }
1132
+ end
1133
+
1134
+ def page_settings(settings)
1135
+ form = settings['Form'].to_s
1136
+ location = settings['Location'].to_s
1137
+ result = {
1138
+ 'path' => "#{form.tr('.', '/')}.page.xml",
1139
+ 'location' => %w[Popup ModalPopup].include?(location) ? 'modal' : 'content'
1140
+ }
1141
+ target = @source.units_of('Forms$Page').find do |unit|
1142
+ "#{unit.module_name}.#{unit.document['Name']}" == form
1143
+ end
1144
+ result['resizable'] = true if target&.document&.fetch('PopupResizable', false) == true
1145
+ result
1146
+ end
1147
+
1148
+ def image_uri(reference)
1149
+ module_name, collection_name, image_name = reference.to_s.split('.', 3)
1150
+ unit = @source.units_of('Images$ImageCollection').find do |candidate|
1151
+ candidate.module_name == module_name && candidate.document['Name'] == collection_name
1152
+ end
1153
+ image = array(unit&.document&.fetch('Images', nil)).find { _1['Name'] == image_name }
1154
+ unless image
1155
+ return system_image_uri(collection_name, image_name) if module_name == 'System'
1156
+
1157
+ return
1158
+ end
1159
+
1160
+ "img/#{module_name}$#{image_name}.#{image_format(image)}"
1161
+ end
1162
+
1163
+ def system_image_uri(collection_name, image_name)
1164
+ return if collection_name != 'Images' || image_name.to_s.empty?
1165
+
1166
+ extension = %w[Error Running Completed Module].include?(image_name) ? 'gif' : 'png'
1167
+ "img/System$#{image_name}.#{extension}"
1168
+ end
1169
+
1170
+ def button_style(widget)
1171
+ style = widget['ButtonStyle'].to_s.downcase
1172
+ "btn-#{style.empty? ? 'default' : style}"
1173
+ end
1174
+
158
1175
  def render_dynamic_text(widget, language)
159
1176
  content = widget['Content'] || {}
160
1177
  text = translated(content['Template'], language)
161
1178
  text = translated(content['Fallback'], language) if text.empty?
1179
+ parameters = array(content['Parameters'])
1180
+ unless parameters.empty?
1181
+ return render_client_widget(
1182
+ widget, 'mxui.widget.DynamicText',
1183
+ { 'content' => dynamic_text_template(text, parameters) }, base: 'mx-text'
1184
+ )
1185
+ end
1186
+
162
1187
  tag = widget['RenderMode'].to_s == 'Paragraph' ? 'p' : 'span'
163
1188
  attributes = html_attributes(widget, base: 'mx-text', mendix_id: next_widget_id)
164
1189
  "<#{tag}#{attributes}>#{escape(text)}</#{tag}>"
165
1190
  end
166
1191
 
1192
+ def dynamic_text_template(text, parameters)
1193
+ values = parameters.each_with_index.to_h do |parameter, index|
1194
+ [index.to_s, dynamic_text_attribute_path(parameter)]
1195
+ end
1196
+ formats = parameters.each_with_index.to_h do |parameter, index|
1197
+ [index.to_s, dynamic_text_format(parameter)]
1198
+ end
1199
+ elements = text.split(/(\{\d+\})/).filter_map do |part|
1200
+ next if part.empty?
1201
+
1202
+ match = part.match(/\A\{(\d+)\}\z/)
1203
+ match ? match[1].to_i - 1 : part
1204
+ end
1205
+ { 'elements' => elements, 'parameters' => values, 'formats' => formats }
1206
+ end
1207
+
1208
+ def dynamic_text_attribute_path(parameter)
1209
+ reference = parameter['AttributeRef'] || {}
1210
+ attribute = attribute_reference(parameter)
1211
+ steps = array(reference.dig('EntityRef', 'Steps')).flat_map do |step|
1212
+ [step['Association'], step['DestinationEntity']]
1213
+ end
1214
+ return (steps + [attribute.split('.').last]).reject(&:empty?).join('/') unless steps.empty?
1215
+
1216
+ entity = Array(@data_view_stack).last&.fetch(:entity, '').to_s
1217
+ relative_list_attribute(attribute, entity)
1218
+ end
1219
+
1220
+ def dynamic_text_format(parameter)
1221
+ formatting = parameter['FormattingInfo'] || {}
1222
+ type = attribute_definition(attribute_reference(parameter))&.dig('NewType', '$Type') ||
1223
+ attribute_definition(attribute_reference(parameter))&.dig('Type', '$Type')
1224
+ date = formatting['DateFormat'].to_s.downcase
1225
+ custom = formatting['CustomDateFormat'].to_s
1226
+ if type == 'DomainModels$DateTimeAttributeType'
1227
+ return { 'type' => 'custom', 'pattern' => custom } unless custom.empty?
1228
+ return { 'type' => date } if %w[date time datetime].include?(date)
1229
+ end
1230
+
1231
+ return {} unless numeric_attribute_type?('$Type' => type)
1232
+
1233
+ {
1234
+ 'groupDigits' => formatting['GroupDigits'] == true,
1235
+ 'decimalPrecision' => positive_integer(formatting['DecimalPrecision'], 2)
1236
+ }
1237
+ end
1238
+
167
1239
  def render_data_grid(widget, page_name, language)
168
1240
  compiler = LegacyDataGridCompiler.new(
169
1241
  @source, page_name, widget, language:, sequence: next_widget_sequence
@@ -212,7 +1284,160 @@ module Mxrb
212
1284
  end
213
1285
 
214
1286
  def supported_widget_type?(type)
215
- %w[Forms$DataGrid Forms$DivContainer Forms$DynamicText].include?(type)
1287
+ %w[
1288
+ Forms$ActionButton Forms$CheckBox Forms$DataGrid Forms$DataView Forms$DatePicker
1289
+ Forms$DivContainer Forms$DropDown Forms$DynamicText Forms$InputReferenceSetSelector
1290
+ Forms$Header Forms$ImageUploader Forms$ImageViewer Forms$Label Forms$LayoutGrid Forms$ListView
1291
+ Forms$MenuBar Forms$NavigationTree
1292
+ Forms$MobileBackButton Forms$MobileCancelButton Forms$MobileSaveButton
1293
+ Forms$RadioButtonGroup Forms$ReferenceSelector Forms$ReferenceSetSelector
1294
+ Forms$SnippetCallWidget
1295
+ Forms$ScrollContainer Forms$SidebarToggleButton Forms$SimpleMenuBar
1296
+ Forms$StaticImageViewer Forms$Table Forms$TabControl Forms$TemplateGrid
1297
+ Forms$TextArea Forms$TextBox
1298
+ CustomWidgets$CustomWidget
1299
+ ].include?(type)
1300
+ end
1301
+
1302
+ def supported_action_button?(widget)
1303
+ action = widget['Action'] || {}
1304
+ return false unless ACTION_BUTTON_TYPES.include?(action['$Type'])
1305
+ return false if action['$Type'] == 'Forms$OpenLinkClientAction' && action.dig('Address', 'IsDynamic') == true
1306
+
1307
+ true
1308
+ end
1309
+
1310
+ def supported_data_view?(widget)
1311
+ source = widget['DataSource'] || {}
1312
+ return !data_view_entity(source).empty? if source['$Type'] == 'Forms$ListenTargetSource'
1313
+ return !data_view_entity(source).empty? if source['$Type'] == 'Forms$MicroflowSource'
1314
+ return false unless source['$Type'] == 'Forms$DataViewSource'
1315
+
1316
+ !data_view_entity(source).empty?
1317
+ end
1318
+
1319
+ def supported_list_view?(widget)
1320
+ %w[Forms$ListViewXPathSource Forms$NewListViewDatabaseSource].include?(
1321
+ widget.dig('DataSource', '$Type')
1322
+ ) &&
1323
+ !list_view_entity(widget).empty?
1324
+ end
1325
+
1326
+ def supported_dynamic_text?(widget, data_view_context)
1327
+ parameters = array(widget.dig('Content', 'Parameters'))
1328
+ return true if parameters.empty?
1329
+ return false unless data_view_context
1330
+
1331
+ parameters.all? do |parameter|
1332
+ !attribute_reference(parameter).empty? && parameter['Expression'].to_s.empty?
1333
+ end
1334
+ end
1335
+
1336
+ def supported_static_image?(widget)
1337
+ return false unless image_uri(widget['Image'])
1338
+
1339
+ %w[Forms$MicroflowAction Forms$NoAction].include?(widget.dig('ClickAction', '$Type'))
1340
+ end
1341
+
1342
+ def supported_image_viewer?(widget, data_view_context)
1343
+ return false unless data_view_context
1344
+ return false unless widget.dig('DataSource', '$Type') == 'Forms$ImageViewerSource'
1345
+ return false if widget.dig('DataSource', 'EntityPath').to_s.empty?
1346
+ return false unless widget['DefaultImage'].to_s.empty? || image_uri(widget['DefaultImage'])
1347
+
1348
+ widget.dig('OnClickBehavior', '$Type').to_s == 'Forms$OnClickNothing'
1349
+ end
1350
+
1351
+ def supported_reference_set_selector?(widget, data_view_context, page_name)
1352
+ path = widget.dig('DataSource', 'EntityPath').to_s
1353
+ return false unless data_view_context && widget.dig('DataSource', '$Type') == 'Forms$ReferenceSetSource'
1354
+ return false unless path.split('/').size == 2
1355
+
1356
+ compiler = LegacyDataGridCompiler.new(
1357
+ @source, page_name, widget, language: 'en_US', sequence: 1
1358
+ )
1359
+ compiler.html
1360
+ compiler.unsupported.empty?
1361
+ end
1362
+
1363
+ def supported_custom_widget?(widget)
1364
+ LegacyCustomWidgetCompiler.new(@source, widget, language: 'en_US').supported?
1365
+ end
1366
+
1367
+ def supported_bound_widget?(widget, data_view_context)
1368
+ return false unless data_view_context && !attribute_reference(widget).empty?
1369
+ return true unless %w[
1370
+ Forms$ReferenceSelector Forms$InputReferenceSetSelector
1371
+ ].include?(widget['$Type'])
1372
+
1373
+ source = widget['SelectorSource'] || {}
1374
+ return false unless %w[
1375
+ Forms$NewSelectorDatabaseSource Forms$SelectorDatabaseSource Forms$SelectorMicroflowSource
1376
+ Forms$SelectorXPathSource
1377
+ ].include?(source['$Type'])
1378
+ return false if selector_target(widget).first.empty?
1379
+
1380
+ source['$Type'] != 'Forms$SelectorMicroflowSource' ||
1381
+ !source.dig('DataSourceMicroflowSettings', 'Microflow').to_s.empty?
1382
+ end
1383
+
1384
+ def attribute_reference(widget)
1385
+ (widget['AttributePath'] || widget.dig('AttributeRef', 'Attribute')).to_s
1386
+ end
1387
+
1388
+ def attribute_definition(reference)
1389
+ module_name, entity_name, attribute_name = reference.to_s.split('.', 3)
1390
+ return unless module_name && entity_name && attribute_name
1391
+
1392
+ domain = domain_document(module_name)
1393
+ entity = array(domain&.fetch('Entities', nil)).find do |candidate|
1394
+ (candidate['Name'] || candidate['UnqualifiedName']) == entity_name
1395
+ end
1396
+ array(entity&.fetch('Attributes', nil)).find { _1['Name'] == attribute_name }
1397
+ end
1398
+
1399
+ def domain_document(module_name)
1400
+ application = @source.units_of('DomainModels$DomainModel').find do |unit|
1401
+ unit.module_name == module_name
1402
+ end
1403
+ return application.document if application
1404
+ return unless module_name == 'System' && @source.respond_to?(:version)
1405
+
1406
+ @system_domain_document ||= SystemModelSeed.for(@source.version).domain_document
1407
+ rescue CompilationError
1408
+ nil
1409
+ end
1410
+
1411
+ def numeric_attribute_type?(type)
1412
+ %w[
1413
+ DomainModels$AutoNumberAttributeType DomainModels$DecimalAttributeType
1414
+ DomainModels$IntegerAttributeType DomainModels$LongAttributeType
1415
+ ].include?(type['$Type'])
1416
+ end
1417
+
1418
+ def enumeration_options(attribute, language)
1419
+ type = attribute_definition(attribute)&.dig('NewType') ||
1420
+ attribute_definition(attribute)&.dig('Type') || {}
1421
+ qualified_name = type['Enumeration'].to_s
1422
+ module_name, name = qualified_name.split('.', 2)
1423
+ enumeration = @source.units_of('Enumerations$Enumeration').find do |unit|
1424
+ unit.module_name == module_name && unit.document['Name'] == name
1425
+ end
1426
+ array(enumeration&.document&.fetch('Values', nil)).map do |value|
1427
+ { 'key' => value['Name'].to_s, 'caption' => translated(value['Caption'], language) }
1428
+ end
1429
+ end
1430
+
1431
+ def positive_integer(value, fallback)
1432
+ number = Integer(value || 0)
1433
+ number.positive? ? number : fallback
1434
+ rescue ArgumentError, TypeError
1435
+ fallback
1436
+ end
1437
+
1438
+ def grid_weight_class(size, weight)
1439
+ value = Integer(weight || -1)
1440
+ "col-#{size}-#{value}" if (1..12).cover?(value)
216
1441
  end
217
1442
 
218
1443
  def layout_argument_id(parameter)
@@ -274,7 +1499,7 @@ module Mxrb
274
1499
  end
275
1500
 
276
1501
  def frozen_unsupported
277
- @unsupported.transform_values { _1.reject(&:empty?).uniq.sort }.freeze
1502
+ @unsupported.transform_values { _1.reject(&:empty?).uniq.sort }.reject { |_key, value| value.empty? }.freeze
278
1503
  end
279
1504
 
280
1505
  def escape(value)