mxrb 0.1.3 → 0.1.5

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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +455 -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/oql-sql.md +41 -0
  17. data/docs/pt-BR/project-structure.md +197 -2
  18. data/docs/pt-BR/runtime-ruby.md +108 -0
  19. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  20. data/docs/pt-BR/validation-matrix.md +42 -5
  21. data/lib/mxrb/cli/help.rb +426 -0
  22. data/lib/mxrb/cli/release.rb +137 -0
  23. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  24. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  25. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  26. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  27. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  28. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  29. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  30. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  31. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  32. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  33. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  34. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  35. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  36. data/lib/mxrb/domain_diagram.rb +409 -0
  37. data/lib/mxrb/dsl/builder.rb +43 -9
  38. data/lib/mxrb/environment.rb +226 -0
  39. data/lib/mxrb/exporter.rb +89 -15
  40. data/lib/mxrb/http/server.rb +107 -0
  41. data/lib/mxrb/initializer.rb +80 -9
  42. data/lib/mxrb/integrity/validator.rb +14 -0
  43. data/lib/mxrb/io/bson_codec.rb +42 -1
  44. data/lib/mxrb/io/mpr_file.rb +146 -3
  45. data/lib/mxrb/model/attribute.rb +3 -0
  46. data/lib/mxrb/model/entity.rb +28 -2
  47. data/lib/mxrb/model/microflow.rb +3 -1
  48. data/lib/mxrb/model/page.rb +257 -17
  49. data/lib/mxrb/model/project.rb +3 -0
  50. data/lib/mxrb/modeler/catalog.rb +178 -0
  51. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  52. data/lib/mxrb/oql/server.rb +4 -8
  53. data/lib/mxrb/oql.rb +23 -1
  54. data/lib/mxrb/project_lifecycle.rb +5 -2
  55. data/lib/mxrb/ruby_app/exporter.rb +2508 -0
  56. data/lib/mxrb/ruby_app/preset.rb +506 -0
  57. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  58. data/lib/mxrb/ruby_app.rb +1369 -0
  59. data/lib/mxrb/runtime/access_control.rb +408 -0
  60. data/lib/mxrb/runtime/native.rb +1015 -93
  61. data/lib/mxrb/runtime/scheduler.rb +396 -0
  62. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  63. data/lib/mxrb/runtime/shared_store.rb +335 -0
  64. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  65. data/lib/mxrb/scaffold/generator.rb +11 -3
  66. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  67. data/lib/mxrb/uml/class_diagram.rb +118 -0
  68. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  69. data/lib/mxrb/uml/server.rb +146 -0
  70. data/lib/mxrb/uml/support.rb +35 -0
  71. data/lib/mxrb/version.rb +1 -1
  72. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  73. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  74. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  75. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  76. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  77. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  78. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  79. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  80. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  81. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  83. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  84. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  85. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  86. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  87. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  88. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  89. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  90. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  93. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  94. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  95. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  96. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  98. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  99. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  100. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  101. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  102. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  103. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  104. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  105. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  106. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  107. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  108. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  109. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  110. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  111. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  112. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  113. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  114. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  115. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  116. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  117. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  118. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  119. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  120. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  121. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  122. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  123. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  124. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  125. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  126. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  127. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  128. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  129. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  130. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  131. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  132. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  133. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  134. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  135. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  136. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  137. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  138. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  139. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  140. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  141. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  142. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  143. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  144. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  145. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  146. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  147. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  148. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  149. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  150. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  151. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  152. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  153. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  154. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  155. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  156. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  157. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  158. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  159. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  160. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  161. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  162. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  163. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  164. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  165. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  166. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  167. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  168. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  169. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  170. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  171. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  172. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  173. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  174. data/lib/mxrb/web_ui/domain.html +15 -0
  175. data/lib/mxrb/web_ui/modeler.html +16 -0
  176. data/lib/mxrb/web_ui/uml.html +31 -0
  177. data/lib/mxrb/web_ui.rb +89 -0
  178. data/lib/mxrb/widget_certification.rb +208 -0
  179. data/lib/mxrb/writer.rb +200 -24
  180. data/lib/mxrb.rb +26 -1
  181. metadata +156 -5
@@ -0,0 +1,2508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'find'
6
+ require 'json'
7
+
8
+ module Mxrb
9
+ module RubyApp
10
+ # Projects a Mendix model into conventional executable Ruby source while
11
+ # retaining the complete Mendix-mode tree as the reversible sidecar.
12
+ # rubocop:disable Metrics
13
+ class Exporter
14
+ REST_STATUS_CODES = {
15
+ 'ok' => 200, 'created' => 201, 'accepted' => 202,
16
+ 'nocontent' => 204, 'movedpermanently' => 301, 'found' => 302,
17
+ 'badrequest' => 400, 'unauthorized' => 401, 'forbidden' => 403,
18
+ 'notfound' => 404, 'conflict' => 409, 'internalservererror' => 500
19
+ }.freeze
20
+ RUBY_KEYWORDS = %w[
21
+ alias and begin break case class def defined do else elsif end ensure false
22
+ for if in module next nil not or redo rescue retry return self super then
23
+ true undef unless until when while yield
24
+ ].freeze
25
+ RECORD_RESERVED = %w[attributes id initialize mendix_id mendix_name to_h type].freeze
26
+
27
+ def initialize(mpr_path, output_dir, mendix_sidecar:)
28
+ @mpr_path = File.expand_path(mpr_path)
29
+ @output_dir = File.expand_path(output_dir)
30
+ @mendix_sidecar = File.expand_path(mendix_sidecar)
31
+ end
32
+
33
+ def export!
34
+ FileUtils.mkdir_p(@output_dir)
35
+ runtime_mpr = copy_runtime_mpr
36
+ embedded_sources = read_embedded_sources
37
+ Mxrb.open(@mpr_path) do |project|
38
+ @project = project
39
+ @coverage = []
40
+ @nanoflow_entries = []
41
+ @page_entries = []
42
+ modules = project.modules.map { export_module(_1) }
43
+ @module_manifests = modules
44
+ write_support_files
45
+ copy_frontend_theme
46
+ restore_embedded_sources(embedded_sources)
47
+ write_manifest(project, modules, runtime_mpr)
48
+ end
49
+ @output_dir
50
+ ensure
51
+ @project = nil
52
+ @module_manifests = nil
53
+ end
54
+
55
+ private
56
+
57
+ def read_embedded_sources
58
+ mpr = IO::MprFile.open(@mpr_path, readonly: true)
59
+ mpr.ruby_app_sources
60
+ ensure
61
+ mpr&.close
62
+ end
63
+
64
+ def restore_embedded_sources(files)
65
+ files.each do |file|
66
+ contents = file.fetch(:contents)
67
+ checksum = Digest::SHA256.hexdigest(contents)
68
+ raise SerializationError, "embedded Ruby source checksum mismatch: #{file.fetch(:path)}" \
69
+ unless checksum == file.fetch(:sha256)
70
+
71
+ path = RubyApp.safe_source_path(@output_dir, file.fetch(:path))
72
+ FileUtils.mkdir_p(File.dirname(path))
73
+ File.binwrite(path, contents)
74
+ File.chmod(RubyApp.safe_source_mode(file[:mode], file.fetch(:path)), path)
75
+ end
76
+ end
77
+
78
+ def copy_runtime_mpr
79
+ directory = File.join(@output_dir, '.mxrb', 'runtime')
80
+ FileUtils.mkdir_p(directory)
81
+ destination = File.join(directory, File.basename(@mpr_path))
82
+ FileUtils.cp(@mpr_path, destination)
83
+ source_contents = File.join(File.dirname(@mpr_path), 'mprcontents')
84
+ FileUtils.cp_r(source_contents, directory, remove_destination: true) if File.directory?(source_contents)
85
+ destination
86
+ end
87
+
88
+ def export_module(mod)
89
+ namespace = ruby_constant(mod.name)
90
+ root = underscore(mod.name)
91
+ entities = mod.entities.map { export_entity(_1, mod, namespace, root) }
92
+ microflows = mod.microflows.map { export_service(_1, mod, namespace, root, :microflow) }
93
+ nanoflows = mod.nanoflows.map { export_nanoflow(_1, mod, root) }
94
+ pages = mod.pages.map { export_page(_1, mod, namespace, root) }
95
+ endpoints = export_endpoints(mod)
96
+ {
97
+ 'name' => mod.name, 'ruby_namespace' => namespace,
98
+ 'module_roles' => runtime_value(mod.module_roles),
99
+ 'models' => entities.reject { _1['dto'] },
100
+ 'dtos' => entities.select { _1['dto'] },
101
+ 'services' => microflows, 'nanoflows' => nanoflows, 'pages' => pages,
102
+ 'endpoints' => endpoints,
103
+ 'enumerations' => mod.enumerations.map { enumeration_manifest(mod, _1) },
104
+ 'associations' => mod.associations.map { association_manifest(mod, _1) },
105
+ 'scheduled_events' => mod.scheduled_events.map { runtime_value(_1) }
106
+ }
107
+ end
108
+
109
+ def export_entity(entity, mod, namespace, root)
110
+ dto = !entity.persistable && !entity.oql_view?
111
+ class_name = ruby_constant(entity.name, suffix: dto ? 'Dto' : nil)
112
+ base_name = underscore(entity.name)
113
+ base_name = "#{base_name}_dto" if dto && !base_name.end_with?('_dto')
114
+ category = dto ? 'dtos' : 'models'
115
+ relative = File.join('app', category, root, "#{base_name}.rb")
116
+ qualified = "#{mod.name}.#{entity.name}"
117
+ attributes = entity.attributes.map { attribute_manifest(_1) }
118
+ write(
119
+ relative,
120
+ entity_source(
121
+ namespace, class_name, qualified, entity.id, attributes,
122
+ dto:, persistable: entity.persistable == true
123
+ )
124
+ )
125
+ add_coverage(entity.id, qualified, dto ? 'dto' : 'model', relative, 'executable_bidirectional')
126
+ {
127
+ 'name' => qualified, 'id' => entity.id, 'ruby_class' => "#{namespace}::#{class_name}",
128
+ 'path' => relative, 'dto' => dto, 'persistable' => entity.persistable == true,
129
+ 'attributes' => attributes,
130
+ 'system_members' => runtime_value(entity.system_members || {}),
131
+ 'access_rules' => runtime_value(entity.access_rules || []),
132
+ 'lifecycle' => runtime_value(entity.respond_to?(:lifecycle) ? entity.lifecycle : [])
133
+ }
134
+ end
135
+
136
+ def association_manifest(mod, association)
137
+ entities = @project.modules.flat_map do |project_module|
138
+ project_module.entities.map do |entity|
139
+ [entity.id.to_s, "#{project_module.name}.#{entity.name}"]
140
+ end
141
+ end.to_h
142
+ {
143
+ 'name' => "#{mod.name}.#{association.name}", 'id' => association.id,
144
+ 'type' => association.association_type.to_s,
145
+ 'from_entity' => entities[association.from_entity_id.to_s] || association.from_entity_id.to_s,
146
+ 'to_entity' => entities[association.to_entity_id.to_s] || association.to_entity_id.to_s
147
+ }
148
+ end
149
+
150
+ def export_service(flow, mod, namespace, root, kind)
151
+ class_name = ruby_constant(flow.name)
152
+ relative = File.join('app', 'services', root, "#{underscore(flow.name)}.rb")
153
+ qualified = "#{mod.name}.#{flow.name}"
154
+ write(relative, service_source(namespace, class_name, qualified, flow.id))
155
+ add_coverage(flow.id, qualified, kind.to_s, relative, 'runtime_source_preserved')
156
+ {
157
+ 'name' => qualified, 'id' => flow.id,
158
+ 'ruby_class' => "#{namespace}::#{class_name}", 'path' => relative,
159
+ 'kind' => kind.to_s, 'parameters' => flow.parameters.map { flow_parameter_manifest(_1) },
160
+ 'allowed_module_roles' => flow.allowed_module_roles.map(&:to_s)
161
+ }
162
+ end
163
+
164
+ def flow_parameter_manifest(parameter)
165
+ variable_type = parameter['VariableType'] || parameter['Type'] || {}
166
+ {
167
+ 'name' => parameter['Name'].to_s,
168
+ 'type' => variable_type['$Type'].to_s,
169
+ 'entity' => variable_type['Entity'],
170
+ 'required' => parameter['IsRequired'] != false
171
+ }.compact
172
+ end
173
+
174
+ def export_endpoints(mod)
175
+ mod.infrastructure_documents.filter_map do |document|
176
+ next unless document[:type] == 'Rest$PublishedRestService'
177
+
178
+ rest_service_manifest(mod, document)
179
+ end
180
+ end
181
+
182
+ def rest_service_manifest(mod, document)
183
+ source = document.fetch(:doc)
184
+ operations = IO::BsonCodec.parse_array(source['Resources'])[:items].flat_map do |resource|
185
+ IO::BsonCodec.parse_array(resource['Operations'])[:items].map do |operation|
186
+ rest_operation_manifest(mod, source, resource, operation)
187
+ end
188
+ end
189
+ add_coverage(
190
+ document.fetch(:id), "#{mod.name}.#{document.fetch(:name)}", 'published_rest_service',
191
+ relative(@mendix_sidecar), 'executable_backend_route'
192
+ )
193
+ {
194
+ 'name' => "#{mod.name}.#{document.fetch(:name)}", 'path' => source['Path'].to_s,
195
+ 'version' => source['Version'].to_s, 'enable_cors' => source['EnableCors'] == true,
196
+ 'requires_authentication' => source['RequiresAuthentication'] == true,
197
+ 'operations' => operations
198
+ }
199
+ end
200
+
201
+ def rest_operation_manifest(mod, service, resource, operation)
202
+ microflow = operation['Microflow'].to_s
203
+ microflow = "#{mod.name}.#{microflow}" unless microflow.empty? || microflow.include?('.')
204
+ service_path = service['Path'].to_s.sub(%r{\A/+}, '').sub(%r{/+\z}, '')
205
+ operation_path = operation['Path'].to_s.sub(%r{\A/+}, '')
206
+ {
207
+ 'name' => resource['Name'].to_s, 'method' => operation['HttpMethod'].to_s.upcase,
208
+ 'path' => "/#{[service_path, operation_path].reject(&:empty?).join('/')}",
209
+ 'microflow' => microflow, 'success_status' => rest_success_status(operation['SuccessStatusCode'])
210
+ }
211
+ end
212
+
213
+ def rest_success_status(value)
214
+ return 200 if value.nil? || value.to_s.empty?
215
+
216
+ raw = value.is_a?(Hash) ? (value['Value'] || value['Name'] || value['$Type']) : value
217
+ integer = raw.to_s[/\d{3}/]
218
+ return integer.to_i if integer
219
+
220
+ REST_STATUS_CODES.fetch(raw.to_s.gsub(/[^A-Za-z]/, '').downcase, 200)
221
+ end
222
+
223
+ def export_nanoflow(flow, mod, root)
224
+ qualified = "#{mod.name}.#{flow.name}"
225
+ relative = File.join('frontend', 'src', 'nanoflows', root, "#{underscore(flow.name)}.ts")
226
+ write(relative, nanoflow_typescript(flow, qualified))
227
+ entry = {
228
+ 'name' => qualified, 'id' => flow.id, 'path' => relative,
229
+ 'kind' => 'nanoflow', 'runtime' => 'frontend'
230
+ }
231
+ @nanoflow_entries << entry.merge('import_name' => "Nanoflow#{@nanoflow_entries.size}")
232
+ add_coverage(flow.id, qualified, 'nanoflow', relative, 'frontend_executable')
233
+ entry
234
+ end
235
+
236
+ def nanoflow_typescript(flow, qualified)
237
+ plan = nanoflow_plan(flow, qualified)
238
+ parameters = nanoflow_parameter_type(flow.parameters)
239
+ result_type = nanoflow_result_type(flow.respond_to?(:return_type) ? flow.return_type : nil)
240
+ start = plan.fetch('objects').find { _1['type'] == 'StartEvent' }
241
+ cases = plan.fetch('objects').filter_map do |object|
242
+ nanoflow_typescript_case(object, plan.fetch('flows'), result_type)
243
+ end
244
+ <<~TS
245
+ import { defineNanoflow } from '../../runtime/nanoflow';
246
+ import type { EntityTypeMap, NanoflowParameters, RuntimeValue } from '../../types';
247
+
248
+ type Parameters = NanoflowParameters & #{parameters};
249
+
250
+ export default defineNanoflow<Parameters, #{result_type}>({
251
+ name: #{JSON.generate(qualified)},
252
+ id: #{JSON.generate(flow.id.to_s)},
253
+ parameters: #{JSON.generate(plan.fetch('parameters'))}
254
+ }, runtime => {
255
+ let current = #{JSON.generate(start && start['id'])};
256
+ for (let step = 0; step < 10_000; step += 1) {
257
+ switch (current) {
258
+ #{cases.join("\n")}
259
+ default:
260
+ throw runtime.missing(current);
261
+ }
262
+ }
263
+ throw runtime.exceeded();
264
+ });
265
+ TS
266
+ end
267
+
268
+ def nanoflow_parameter_type(parameters)
269
+ fields = parameters.filter_map do |parameter|
270
+ next unless parameter.is_a?(Hash)
271
+
272
+ " #{JSON.generate(parameter['Name'].to_s)}: #{typescript_flow_type(parameter['VariableType'])};"
273
+ end
274
+ "{\n#{fields.join("\n")}\n}"
275
+ end
276
+
277
+ def nanoflow_result_type(type)
278
+ typescript_flow_type('$Type' => type.to_s).delete_suffix(' | null')
279
+ end
280
+
281
+ def typescript_flow_type(type)
282
+ type = {} unless type.is_a?(Hash)
283
+ kind = (type['$Type'] || type['Type']).to_s
284
+ entity = type['Entity'].to_s
285
+ return "EntityTypeMap[#{JSON.generate(entity)}] | null" if kind.end_with?('ObjectType') && !entity.empty?
286
+ return "Array<EntityTypeMap[#{JSON.generate(entity)}]>" if kind.end_with?('ListType') && !entity.empty?
287
+ return 'boolean' if kind.match?(/Boolean/)
288
+ return 'number' if kind.match?(/Integer|Long|Decimal|Float/)
289
+ return 'string' if kind.match?(/String|DateTime|Enumeration/)
290
+ return 'undefined' if kind.empty? || kind.match?(/Void|MicroflowReturnType/)
291
+
292
+ 'RuntimeValue | undefined'
293
+ end
294
+
295
+ def nanoflow_typescript_case(object, flows, result_type)
296
+ return if object['type'] == 'MicroflowParameter'
297
+
298
+ outgoing = flows.select { _1['origin'] == object['id'] }
299
+ body = case object['type']
300
+ when 'EndEvent' then nanoflow_end_source(object, result_type)
301
+ when 'ExclusiveSplit' then nanoflow_split_source(object, outgoing)
302
+ when 'ActionActivity' then nanoflow_action_source(object['action']) + nanoflow_next_source(outgoing)
303
+ else nanoflow_next_source(outgoing)
304
+ end
305
+ <<~TS.chomp
306
+ case #{JSON.generate(object['id'])}: {
307
+ #{indent(body, 10)}
308
+ }
309
+ TS
310
+ end
311
+
312
+ def nanoflow_end_source(object, result_type)
313
+ expression = JSON.generate(object['return'].to_s)
314
+ value = case result_type
315
+ when 'boolean' then "runtime.boolean(#{expression})"
316
+ when 'number' then "runtime.number(#{expression})"
317
+ when 'string' then "runtime.string(#{expression})"
318
+ when 'undefined' then 'undefined'
319
+ else "runtime.value(#{expression}) as #{result_type}"
320
+ end
321
+ "return runtime.complete(#{value});"
322
+ end
323
+
324
+ def nanoflow_split_source(object, flows)
325
+ cases = flows.reject { _1['case'].to_s.empty? }.map do |edge|
326
+ "case #{JSON.generate(edge['case'].to_s)}: current = #{JSON.generate(edge['destination'])}; break;"
327
+ end
328
+ fallback = flows.find { _1['case'].to_s.empty? }
329
+ default = if fallback
330
+ "current = #{JSON.generate(fallback['destination'])}; break;"
331
+ else
332
+ "throw runtime.stopped(#{JSON.generate(object['type'])});"
333
+ end
334
+ <<~TS.chomp
335
+ switch (String(runtime.condition(#{JSON.generate(object['condition'].to_s)}))) {
336
+ #{indent(cases.join("\n"), 2)}
337
+ default: #{default}
338
+ }
339
+ break;
340
+ TS
341
+ end
342
+
343
+ def nanoflow_action_source(action)
344
+ action ||= {}
345
+ case action['type']
346
+ when 'LogMessage'
347
+ "runtime.log(#{JSON.generate(action['message'].to_s)});\n"
348
+ when 'CreateVariable', 'ChangeVariable'
349
+ "runtime.set(#{JSON.generate(action['variable'].to_s)}, " \
350
+ "runtime.value(#{JSON.generate(action['value'].to_s)}));\n"
351
+ when 'Change'
352
+ changes = action.fetch('changes', []).to_h do |change|
353
+ [change['member'].to_s, change['value'].to_s]
354
+ end
355
+ "runtime.change(#{JSON.generate(action['variable'].to_s)}, #{JSON.generate(changes)});\n"
356
+ else
357
+ "throw runtime.unsupported(#{JSON.generate(action['type'].to_s)});\n"
358
+ end
359
+ end
360
+
361
+ def nanoflow_next_source(flows)
362
+ edge = flows.first
363
+ return "throw runtime.stopped('node');" unless edge
364
+
365
+ "current = #{JSON.generate(edge['destination'])};\nbreak;"
366
+ end
367
+
368
+ def indent(source, spaces)
369
+ prefix = ' ' * spaces
370
+ source.to_s.lines.map { "#{prefix}#{_1}" }.join.chomp
371
+ end
372
+
373
+ def nanoflow_plan(flow, qualified)
374
+ {
375
+ 'name' => qualified, 'id' => flow.id,
376
+ 'parameters' => flow.parameters.filter_map { _1['Name'] if _1.is_a?(Hash) },
377
+ 'objects' => flow.objects.filter_map { nanoflow_object(_1) },
378
+ 'flows' => flow.flows.reject { _1['IsErrorHandler'] == true }.map do |edge|
379
+ {
380
+ 'origin' => native_identifier(edge['OriginPointer']),
381
+ 'destination' => native_identifier(edge['DestinationPointer']),
382
+ 'case' => nanoflow_case(edge)
383
+ }
384
+ end
385
+ }
386
+ end
387
+
388
+ def nanoflow_object(object)
389
+ return unless object.is_a?(Hash)
390
+
391
+ type = object['$Type'].to_s.delete_prefix('Microflows$')
392
+ result = { 'id' => native_identifier(object), 'type' => type }
393
+ result['return'] = object['ReturnValue'].to_s if type == 'EndEvent'
394
+ result['condition'] = object.dig('SplitCondition', 'Expression').to_s if type == 'ExclusiveSplit'
395
+ result['action'] = nanoflow_action(object['Action']) if type == 'ActionActivity'
396
+ result
397
+ end
398
+
399
+ def nanoflow_action(action)
400
+ return {} unless action.is_a?(Hash)
401
+
402
+ type = action['$Type'].to_s.delete_prefix('Microflows$').delete_suffix('Action')
403
+ result = { 'type' => type }
404
+ case type
405
+ when 'CreateVariable'
406
+ result.merge!('variable' => action['VariableName'].to_s, 'value' => action['InitialValue'].to_s)
407
+ when 'ChangeVariable'
408
+ result.merge!('variable' => action['ChangeVariableName'].to_s, 'value' => action['Value'].to_s)
409
+ when 'Change'
410
+ changes = native_items(action['Items']).map do |item|
411
+ member = (item['Attribute'].to_s.empty? ? item['Association'] : item['Attribute']).to_s
412
+ { 'member' => member.split(%r{[./]}).last, 'value' => item['Value'].to_s }
413
+ end
414
+ result.merge!('variable' => action['ChangeVariableName'].to_s, 'changes' => changes)
415
+ when 'LogMessage'
416
+ result['message'] = action.dig('MessageTemplate', 'Text').to_s
417
+ end
418
+ result
419
+ end
420
+
421
+ def nanoflow_case(edge)
422
+ value = native_items(edge['CaseValues']).first || edge['NewCaseValue'] || {}
423
+ value['$Type'] == 'Microflows$NoCase' ? '' : value['Value'].to_s
424
+ end
425
+
426
+ def native_items(value)
427
+ IO::BsonCodec.parse_array(value)[:items]
428
+ rescue StandardError
429
+ Array(value).drop(value.is_a?(Array) && value.first.is_a?(Integer) ? 1 : 0)
430
+ end
431
+
432
+ def native_identifier(value)
433
+ IO::BsonCodec.extract_id(value.is_a?(Hash) ? value['$ID'] : value).to_s
434
+ end
435
+
436
+ def export_page(page, mod, namespace, root)
437
+ class_name = ruby_constant(page.name, suffix: 'Page')
438
+ relative = File.join('app', 'pages', root, "#{underscore(page.name)}_page.rb")
439
+ qualified = "#{mod.name}.#{page.name}"
440
+ widgets = page.widgets.map { widget_manifest(_1) }
441
+ write(
442
+ relative,
443
+ page_source(namespace, class_name, qualified, page.id, page.title, widgets,
444
+ appearance_class: page.appearance_class,
445
+ appearance_style: page.appearance_style,
446
+ data_source: page.data_source)
447
+ )
448
+ add_coverage(page.id, qualified, 'page', relative, 'native_projection_source_preserved')
449
+ manifest = {
450
+ 'name' => qualified, 'id' => page.id, 'title' => page.title,
451
+ 'ruby_class' => "#{namespace}::#{class_name}", 'path' => relative,
452
+ 'appearance_class' => page.appearance_class,
453
+ 'appearance_style' => page.appearance_style,
454
+ 'data_source' => page.data_source,
455
+ 'allowed_module_roles' => page.allowed_module_roles.map(&:to_s),
456
+ 'widgets' => widgets
457
+ }
458
+ export_frontend_page(manifest, root, page.name)
459
+ manifest
460
+ end
461
+
462
+ def export_frontend_page(manifest, root, page_name)
463
+ relative = File.join('frontend', 'src', 'pages', root, "#{underscore(page_name)}.tsx")
464
+ component = "#{typescript_identifier(manifest.fetch('name'))}Page"
465
+ definition = manifest.slice('name', 'title', 'appearance_class', 'appearance_style', 'widgets')
466
+ declarations = []
467
+ compiled_widgets = definition.fetch('widgets').each_with_index.map do |widget, index|
468
+ frontend_widget_jsx(widget, [index], declarations, 6)
469
+ end
470
+ widget_tree = compiled_widgets.map(&:first).join("\n")
471
+ definition_source = frontend_page_definition_source(definition, compiled_widgets.map(&:last))
472
+ write(relative, <<~TS)
473
+ import type { PageComponentProps, PageDefinition, WidgetDefinition } from '../../types';
474
+
475
+ #{declarations.join("\n\n")}
476
+
477
+ export const definition = #{definition_source} satisfies PageDefinition;
478
+
479
+ export default function #{component}({ busy, Widget: PageWidget }: PageComponentProps) {
480
+ return <main className="mxrb-page region-content mx-scrollcontainer-wrapper"
481
+ aria-busy={busy} data-page={definition.name}>
482
+ #{widget_tree}
483
+ </main>;
484
+ }
485
+ TS
486
+ @page_entries << {
487
+ 'name' => manifest.fetch('name'), 'path' => relative,
488
+ 'import_name' => component
489
+ }
490
+ end
491
+
492
+ def frontend_widget_jsx(widget, path, declarations, indent)
493
+ identifier = "widget#{path.join('_')}"
494
+ children = Array(widget['children'])
495
+ compiled = widget.reject { |key, _value| key == 'children' }
496
+ nested_widgets = children.each_with_index.map do |child, index|
497
+ frontend_widget_jsx(child, path + [index], declarations, indent + 2)
498
+ end
499
+ source = JSON.pretty_generate(compiled)
500
+ unless nested_widgets.empty?
501
+ source = source.sub(/\n}\z/, ",\n \"children\": [#{nested_widgets.map(&:last).join(', ')}]\n}")
502
+ end
503
+ declarations << "const #{identifier} = #{source} satisfies WidgetDefinition;"
504
+ padding = ' ' * indent
505
+ return ["#{padding}<PageWidget widget={#{identifier}} index={#{path.last}} />", identifier] if children.empty?
506
+
507
+ nested = nested_widgets.map(&:first).join("\n")
508
+ jsx = <<~TS.chomp
509
+ #{padding}<PageWidget widget={#{identifier}} index={#{path.last}}>
510
+ #{nested}
511
+ #{padding}</PageWidget>
512
+ TS
513
+ [jsx, identifier]
514
+ end
515
+
516
+ def frontend_page_definition_source(definition, widget_identifiers)
517
+ source = JSON.pretty_generate(definition.reject { |key, _value| key == 'widgets' })
518
+ source.sub(/\n}\z/, ",\n \"widgets\": [#{widget_identifiers.join(', ')}]\n}")
519
+ end
520
+
521
+ def attribute_manifest(attribute)
522
+ value = {
523
+ 'name' => attribute.name, 'ruby_name' => ruby_method_name(attribute.name),
524
+ 'type' => attribute.type.to_s, 'required' => attribute.required == true,
525
+ 'default' => attribute.default_value, 'id' => attribute.id
526
+ }
527
+ enumeration = native_identifier(attribute.respond_to?(:enumeration) ? attribute.enumeration : nil)
528
+ value['enumeration'] = enumeration unless enumeration.empty?
529
+ value
530
+ end
531
+
532
+ def enumeration_manifest(mod, enumeration)
533
+ name = enumeration['Name'].to_s
534
+ {
535
+ 'name' => "#{mod.name}.#{name}",
536
+ 'id' => native_identifier(enumeration['$ID']),
537
+ 'values' => native_items(enumeration['Values']).map do |value|
538
+ value_name = value['Name'].to_s
539
+ { 'name' => value_name, 'caption' => translated_caption(value['Caption'], value_name) }
540
+ end
541
+ }
542
+ end
543
+
544
+ def translated_caption(caption, fallback)
545
+ translation = native_items(caption.is_a?(Hash) ? caption['Items'] : nil).first
546
+ text = translation.is_a?(Hash) ? translation['Text'].to_s : ''
547
+ text.empty? ? fallback : text
548
+ end
549
+
550
+ def widget_manifest(widget)
551
+ options = runtime_value(widget.fetch(:options, {}))
552
+ value = { 'type' => runtime_widget_type(widget), 'name' => widget.fetch(:name, '').to_s }
553
+ value['options'] = options unless options.empty?
554
+ caption = options['caption']
555
+ value['caption'] = caption unless caption.to_s.empty?
556
+ events = runtime_value(widget.fetch(:events, []))
557
+ value['events'] = events unless events.empty?
558
+ children = Array(widget[:children]).map { widget_manifest(_1) }
559
+ value['children'] = children unless children.empty?
560
+ value
561
+ end
562
+
563
+ def runtime_widget_type(widget)
564
+ type = widget.fetch(:type).to_s
565
+ return type unless type == 'text_box'
566
+
567
+ path = widget.dig(:options, :attribute).to_s.tr('/', '.')
568
+ module_name, entity_name, attribute_name = path.split('.', 3)
569
+ return type unless attribute_name
570
+
571
+ entity = @project.modules.find { _1.name == module_name }
572
+ &.entities&.find { _1.name == entity_name }
573
+ attribute = entity&.attributes&.find { _1.name == attribute_name }
574
+ %i[integer long decimal autonumber].include?(attribute&.type) ? 'number_input' : type
575
+ end
576
+
577
+ def runtime_value(value)
578
+ case value
579
+ when Hash
580
+ value.each_with_object({}) do |(key, child), result|
581
+ next if key.to_s == 'deep_structure'
582
+
583
+ result[key.to_s] = runtime_value(child)
584
+ end
585
+ when Array then value.map { runtime_value(_1) }
586
+ when Symbol then value.to_s
587
+ when String, Numeric, TrueClass, FalseClass, NilClass then value
588
+ else value.to_s
589
+ end
590
+ end
591
+
592
+ def entity_source(namespace, class_name, qualified, id, attributes, dto:, persistable:)
593
+ declarations = attributes.map do |attribute|
594
+ " attribute :#{attribute.fetch('ruby_name')}, type: :#{attribute.fetch('type')}, " \
595
+ "mendix_name: #{attribute.fetch('name').inspect}, required: #{attribute.fetch('required')}, " \
596
+ "default: #{attribute.fetch('default').inspect}"
597
+ end
598
+ <<~RUBY
599
+ # frozen_string_literal: true
600
+
601
+ module #{namespace}
602
+ class #{class_name} < Mxrb::RubyApp::#{dto ? 'DTO' : 'Record'}
603
+ mendix_name #{qualified.inspect}, id: #{id.inspect}
604
+ persistence #{persistable}
605
+ #{declarations.join("\n")}
606
+ end
607
+ end
608
+ RUBY
609
+ end
610
+
611
+ def service_source(namespace, class_name, qualified, id)
612
+ <<~RUBY
613
+ # frozen_string_literal: true
614
+
615
+ module #{namespace}
616
+ class #{class_name} < Mxrb::RubyApp::Service
617
+ mendix_name #{qualified.inspect}, id: #{id.inspect}
618
+
619
+ def call(**arguments)
620
+ native_call(arguments)
621
+ end
622
+ end
623
+ end
624
+ RUBY
625
+ end
626
+
627
+ def page_source(namespace, class_name, qualified, id, title, widgets,
628
+ appearance_class:, appearance_style:, data_source:)
629
+ <<~RUBY
630
+ # frozen_string_literal: true
631
+
632
+ module #{namespace}
633
+ class #{class_name} < Mxrb::RubyApp::Page
634
+ mendix_name #{qualified.inspect}, id: #{id.inspect}
635
+ configure title: #{title.inspect}, widgets: #{widgets.inspect},
636
+ appearance_class: #{appearance_class.inspect},
637
+ appearance_style: #{appearance_style.inspect},
638
+ data_source: #{data_source.inspect}
639
+ end
640
+ end
641
+ RUBY
642
+ end
643
+
644
+ def write_support_files
645
+ write('Gemfile', gemfile)
646
+ write('.gitignore', ruby_gitignore)
647
+ write('.env.example', ruby_env_example)
648
+ %w[development qa staging production].each do |name|
649
+ write(File.join('config', 'environments', "#{name}.env.example"), environment_example(name))
650
+ end
651
+ write('project.rb', project_source)
652
+ write(File.join('config', 'application.rb'), application_source)
653
+ write(File.join('config', 'adapters.rb'), adapters_source)
654
+ write(File.join('bin', 'server'), server_source)
655
+ File.chmod(0o755, File.join(@output_dir, 'bin', 'server'))
656
+ write(File.join('frontend', 'package.json'), frontend_package)
657
+ write(File.join('frontend', 'vite.config.ts'), vite_config)
658
+ write(File.join('frontend', 'tsconfig.json'), frontend_tsconfig)
659
+ write(File.join('frontend', 'index.html'), frontend_index)
660
+ write(File.join('frontend', 'src', 'vite-env.d.ts'), "/// <reference types=\"vite/client\" />\n")
661
+ write(File.join('frontend', 'src', 'main.tsx'), frontend_main)
662
+ write(File.join('frontend', 'src', 'types.ts'), frontend_types)
663
+ write(File.join('frontend', 'src', 'api', 'client.ts'), frontend_api_client)
664
+ write(File.join('frontend', 'src', 'runtime', 'nanoflow.ts'), frontend_nanoflow_runtime)
665
+ write(File.join('frontend', 'src', 'runtime', 'marketplace.tsx'), frontend_marketplace_runtime)
666
+ write(File.join('frontend', 'src', 'nanoflows.ts'), frontend_nanoflows)
667
+ write(File.join('frontend', 'src', 'pages.ts'), frontend_pages)
668
+ write(File.join('frontend', 'src', 'app', 'App.tsx'), frontend_app)
669
+ write(File.join('frontend', 'src', 'App.tsx'), "export { default } from './app/App';\n")
670
+ write(File.join('frontend', 'src', 'app.css'), frontend_css)
671
+ write('README.md', readme)
672
+ end
673
+
674
+ def copy_frontend_theme
675
+ root = File.join(@output_dir, 'frontend', 'src', 'mendix')
676
+ FileUtils.mkdir_p(root)
677
+ %w[theme themesource].each do |directory|
678
+ source = File.join(@mendix_sidecar, directory)
679
+ next unless File.directory?(source)
680
+
681
+ copy_frontend_web_assets(source, File.join(root, directory))
682
+ end
683
+ fallback = File.join(root, 'theme', 'web', 'main.scss')
684
+ write(relative(fallback), '') unless File.file?(fallback)
685
+ end
686
+
687
+ def copy_frontend_web_assets(source, destination)
688
+ FileUtils.rm_rf(destination)
689
+ Find.find(source) do |path|
690
+ relative_path = path.delete_prefix("#{source}/")
691
+ Find.prune if File.directory?(path) && relative_path.split(File::SEPARATOR).include?('native')
692
+ next if path == source
693
+ next if File.file?(path) && %w[.js .jsx].include?(File.extname(path).downcase)
694
+
695
+ target = File.join(destination, relative_path)
696
+ if File.directory?(path)
697
+ FileUtils.mkdir_p(target)
698
+ else
699
+ FileUtils.mkdir_p(File.dirname(target))
700
+ FileUtils.cp(path, target)
701
+ end
702
+ end
703
+ end
704
+
705
+ def write_manifest(project, modules, runtime_mpr)
706
+ native_coverage(project)
707
+ payload = {
708
+ 'format_version' => 1, 'mode' => 'ruby',
709
+ 'project' => { 'name' => project.name, 'mendix_version' => project.mendix_version },
710
+ 'navigation' => runtime_value(project.navigation.to_h),
711
+ 'source' => {
712
+ 'name' => File.basename(@mpr_path),
713
+ 'sha256' => Digest::SHA256.file(@mpr_path).hexdigest
714
+ },
715
+ 'modules' => modules, 'coverage' => @coverage,
716
+ 'frontend' => {
717
+ 'framework' => 'react', 'language' => 'typescript', 'bundler' => 'vite',
718
+ 'source' => 'frontend/src', 'types' => 'frontend/src/types.ts',
719
+ 'typecheck' => 'npm run typecheck', 'build' => 'frontend/dist'
720
+ },
721
+ 'round_trip' => {
722
+ 'compiler' => 'project.rb',
723
+ 'mendix_project' => relative(File.join(@mendix_sidecar, 'project.rb')),
724
+ 'runtime_mpr' => relative(runtime_mpr),
725
+ 'editable_mendix_source' => relative(@mendix_sidecar)
726
+ }
727
+ }
728
+ preset = Preset.detect(@output_dir)
729
+ payload['ruby_stack'] = Preset.manifest(preset) if preset
730
+ write(MANIFEST_PATH, JSON.pretty_generate(payload) << "\n")
731
+ end
732
+
733
+ def native_coverage(project)
734
+ known_ids = @coverage.to_h { [_1.fetch('id').to_s, true] }
735
+ project.all_units.each do |unit|
736
+ next if known_ids[unit.fetch('UnitID').to_s]
737
+
738
+ document = project.parse_bson(unit)
739
+ name = document['Name'] || document['name'] || document['$Type']
740
+ add_coverage(
741
+ unit['UnitID'], name.to_s, document['$Type'].to_s,
742
+ relative(@mendix_sidecar), 'preserved_native'
743
+ )
744
+ end
745
+ end
746
+
747
+ def add_coverage(id, name, kind, path, status)
748
+ @coverage << {
749
+ 'id' => id.to_s, 'name' => name.to_s, 'kind' => kind.to_s,
750
+ 'ruby_path' => path, 'status' => status
751
+ }
752
+ end
753
+
754
+ def ruby_constant(value, suffix: nil)
755
+ parts = value.to_s.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
756
+ .split(/[^A-Za-z0-9]+/).reject(&:empty?)
757
+ constant = parts.map { _1[0].upcase + _1[1..].to_s.downcase }.join
758
+ constant = "Artifact#{constant}" if constant.empty? || constant.match?(/\A\d/)
759
+ constant = "#{constant}#{suffix}" if suffix && !constant.end_with?(suffix)
760
+ constant
761
+ end
762
+
763
+ def ruby_method_name(value)
764
+ name = underscore(value)
765
+ name = "field_#{name}" if name.match?(/\A\d/) || RUBY_KEYWORDS.include?(name) || RECORD_RESERVED.include?(name)
766
+ name.empty? ? 'field' : name
767
+ end
768
+
769
+ def underscore(value)
770
+ value.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
771
+ .gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
772
+ .gsub(/[^A-Za-z0-9]+/, '_').gsub(/\A_+|_+\z/, '').downcase
773
+ end
774
+
775
+ def relative(path)
776
+ Pathname.new(path).relative_path_from(Pathname.new(@output_dir)).to_s
777
+ end
778
+
779
+ def write(relative_path, contents)
780
+ path = File.join(@output_dir, relative_path)
781
+ FileUtils.mkdir_p(File.dirname(path))
782
+ File.write(path, contents)
783
+ end
784
+
785
+ def gemfile
786
+ <<~RUBY
787
+ # frozen_string_literal: true
788
+
789
+ source 'https://rubygems.org'
790
+ gem 'mxrb'
791
+ RUBY
792
+ end
793
+
794
+ def ruby_gitignore
795
+ <<~TEXT
796
+ .env
797
+ .env.*
798
+ !.env.example
799
+ config/environments/*.env
800
+ !config/environments/*.env.example
801
+ .mxrb/runtime/*.sqlite3
802
+ frontend/node_modules/
803
+ frontend/dist/
804
+ TEXT
805
+ end
806
+
807
+ def ruby_env_example
808
+ <<~TEXT
809
+ # Base values; environment profiles override this file.
810
+ MXRB_ENV=development
811
+ TEXT
812
+ end
813
+
814
+ def environment_example(name)
815
+ <<~TEXT
816
+ # Copy to #{name}.env. Process ENV has highest precedence.
817
+ MXRB_DATABASE_PATH=.mxrb/runtime/#{name}.sqlite3
818
+ MXRB_SHARED_STORE_PATH=.mxrb/runtime/#{name}-shared.sqlite3
819
+ MXRB_SESSION_TTL=3600
820
+ MXRB_SCHEDULER_LEASE_TTL=300
821
+ MXRB_ALLOW_DESTRUCTIVE_MIGRATIONS=false
822
+ MXRB_AUTH_TOKENS=
823
+ MXRB_USERS_JSON=
824
+ TEXT
825
+ end
826
+
827
+ def project_source
828
+ <<~RUBY
829
+ # frozen_string_literal: true
830
+
831
+ require 'mxrb'
832
+
833
+ mendix_version = #{@project.mendix_version.inspect}
834
+ Mxrb::RubyApp.compile(__dir__, mendix_version:)
835
+ RUBY
836
+ end
837
+
838
+ def application_source
839
+ <<~RUBY
840
+ # frozen_string_literal: true
841
+
842
+ require 'mxrb'
843
+
844
+ MXRB_APPLICATION_ROOT = File.expand_path('..', __dir__)
845
+ RUBY
846
+ end
847
+
848
+ def adapters_source
849
+ <<~RUBY
850
+ # frozen_string_literal: true
851
+
852
+ # Register only the integrations this application uses. Credentials
853
+ # belong in ignored environment files or a deployment secret manager.
854
+ #
855
+ # Mxrb::RubyApp::Registry.register_adapter(:app_service) do |name, document, variables|
856
+ # MyAppServiceClient.call(name, document:, variables:)
857
+ # end
858
+ #
859
+ # Legacy Custom Actions use Ruby implementations only. Register every
860
+ # permitted action explicitly by its qualified Mendix name.
861
+ #
862
+ # Mxrb::RubyApp::Registry.register_java_custom_action('MyModule.MyAction') do |arguments|
863
+ # MyRubyAction.call(arguments)
864
+ # end
865
+ #
866
+ # Supported kinds: :app_service, :web_service, :import_xml,
867
+ # :import_mapping, :export_mapping, and :document.
868
+ RUBY
869
+ end
870
+
871
+ def server_source
872
+ <<~RUBY
873
+ #!/usr/bin/env ruby
874
+ # frozen_string_literal: true
875
+
876
+ require_relative '../config/application'
877
+
878
+ host = ENV.fetch('HOST', '127.0.0.1')
879
+ port = Integer(ENV.fetch('PORT', '9292'))
880
+ frontend_port = Integer(ENV.fetch('FRONTEND_PORT', '5173'))
881
+ supervisor = Mxrb::RubyApp::Supervisor.new(
882
+ MXRB_APPLICATION_ROOT, host:, api_port: port, frontend_port:
883
+ )
884
+ puts "[mxrb] Ruby API: http://\#{host}:\#{port}"
885
+ puts "[mxrb] React + Vite: http://\#{host}:\#{frontend_port}"
886
+ trap('INT') { Thread.new { supervisor.shutdown } }
887
+ supervisor.start
888
+ RUBY
889
+ end
890
+
891
+ def frontend_package
892
+ JSON.pretty_generate(
893
+ 'name' => underscore(@project.name), 'private' => true, 'version' => '0.0.0',
894
+ 'type' => 'module',
895
+ 'scripts' => {
896
+ 'dev' => 'vite', 'typecheck' => 'tsc --noEmit',
897
+ 'build' => 'npm run typecheck && vite build', 'preview' => 'vite preview'
898
+ },
899
+ 'dependencies' => { 'react' => '^19.2.8', 'react-dom' => '^19.2.8' },
900
+ 'devDependencies' => {
901
+ '@types/node' => '^26.2.0', '@types/react' => '^19.2.18',
902
+ '@types/react-dom' => '^19.2.4', '@vitejs/plugin-react' => '^6.0.5',
903
+ 'sass-embedded' => '^1.90.0', 'typescript' => '^7.0.2', 'vite' => '^8.2.1'
904
+ }
905
+ ) << "\n"
906
+ end
907
+
908
+ def frontend_tsconfig
909
+ JSON.pretty_generate(
910
+ 'compilerOptions' => {
911
+ 'target' => 'ES2022', 'useDefineForClassFields' => true,
912
+ 'lib' => %w[ES2022 DOM DOM.Iterable], 'allowJs' => false,
913
+ 'skipLibCheck' => true, 'esModuleInterop' => true,
914
+ 'allowSyntheticDefaultImports' => true, 'strict' => true,
915
+ 'noImplicitAny' => true, 'useUnknownInCatchVariables' => true,
916
+ 'forceConsistentCasingInFileNames' => true, 'module' => 'ESNext',
917
+ 'moduleResolution' => 'Bundler', 'resolveJsonModule' => true,
918
+ 'isolatedModules' => true, 'noEmit' => true, 'jsx' => 'react-jsx'
919
+ },
920
+ 'include' => ['src', 'vite.config.ts']
921
+ ) << "\n"
922
+ end
923
+
924
+ def vite_config
925
+ <<~JS
926
+ import { defineConfig } from 'vite';
927
+ import react from '@vitejs/plugin-react';
928
+
929
+ const apiPort = process.env.MXRB_API_PORT || '9292';
930
+
931
+ export default defineConfig({
932
+ plugins: [react()],
933
+ css: {
934
+ preprocessorOptions: {
935
+ scss: {
936
+ // Mendix Atlas still depends on the legacy global Sass module model.
937
+ silenceDeprecations: ['import', 'global-builtin']
938
+ }
939
+ }
940
+ },
941
+ server: { proxy: { '/api': `http://127.0.0.1:${apiPort}` } }
942
+ });
943
+ JS
944
+ end
945
+
946
+ def frontend_index
947
+ <<~HTML
948
+ <!doctype html>
949
+ <html lang="en">
950
+ <head>
951
+ <meta charset="utf-8">
952
+ <meta name="viewport" content="width=device-width,initial-scale=1">
953
+ <title>#{escape_html(@project.name)} · MXRB Ruby</title>
954
+ </head>
955
+ <body>
956
+ <div id="root"></div>
957
+ <script type="module" src="/src/main.tsx"></script>
958
+ </body>
959
+ </html>
960
+ HTML
961
+ end
962
+
963
+ def frontend_main
964
+ <<~JS
965
+ import React from 'react';
966
+ import { createRoot } from 'react-dom/client';
967
+ import App from './app/App';
968
+ import './app.css';
969
+ import './mendix/theme/web/main.scss';
970
+
971
+ const root = document.getElementById('root');
972
+ if (!root) throw new Error('MXRB frontend root element is missing');
973
+
974
+ createRoot(root).render(
975
+ <React.StrictMode><App /></React.StrictMode>
976
+ );
977
+ JS
978
+ end
979
+
980
+ def frontend_nanoflows
981
+ imports = @nanoflow_entries.map do |entry|
982
+ relative = entry.fetch('path').delete_prefix('frontend/src/')
983
+ "import #{entry.fetch('import_name')} from './#{relative.delete_suffix('.ts')}';"
984
+ end
985
+ mappings = @nanoflow_entries.map do |entry|
986
+ " #{entry.fetch('name').inspect}: #{entry.fetch('import_name')}"
987
+ end
988
+ <<~TS
989
+ import type { RegisteredNanoflow } from './types';
990
+
991
+ #{imports.join("\n")}
992
+
993
+ const nanoflows: Record<string, RegisteredNanoflow> = {
994
+ #{mappings.join(",\n")}
995
+ };
996
+
997
+ export default nanoflows;
998
+ TS
999
+ end
1000
+
1001
+ def frontend_pages
1002
+ imports = @page_entries.map do |entry|
1003
+ relative = entry.fetch('path').delete_prefix('frontend/src/')
1004
+ "import #{entry.fetch('import_name')} from './#{relative.delete_suffix('.tsx')}';"
1005
+ end
1006
+ mappings = @page_entries.map do |entry|
1007
+ " #{JSON.generate(entry.fetch('name'))}: #{entry.fetch('import_name')}"
1008
+ end
1009
+ <<~TS
1010
+ import type { ComponentType } from 'react';
1011
+ import type { PageComponentProps } from './types';
1012
+
1013
+ #{imports.join("\n")}
1014
+
1015
+ const pages: Record<string, ComponentType<PageComponentProps>> = {
1016
+ #{mappings.join(",\n")}
1017
+ };
1018
+
1019
+ export default pages;
1020
+ TS
1021
+ end
1022
+
1023
+ def frontend_api_client
1024
+ <<~'TS'
1025
+ import type { ApiFailure, RuntimeValue } from '../types';
1026
+
1027
+ const errorMessage = (payload: unknown, status: number): string => {
1028
+ if (payload && typeof payload === 'object' && 'error' in payload) {
1029
+ const error = payload.error;
1030
+ if (error && typeof error === 'object' && 'message' in error) return String(error.message);
1031
+ }
1032
+ return `HTTP ${status}`;
1033
+ };
1034
+
1035
+ export const api = async <T = RuntimeValue | undefined>(
1036
+ path: string, options: RequestInit = {}, token: string | null = null
1037
+ ): Promise<T> => {
1038
+ const headers = new Headers(options.headers);
1039
+ headers.set('Content-Type', 'application/json');
1040
+ if (token) headers.set('Authorization', `Bearer ${token}`);
1041
+ const response = await fetch(path, { ...options, headers });
1042
+ const payload: unknown = await response.json();
1043
+ if (!response.ok) {
1044
+ const error: ApiFailure = new Error(errorMessage(payload, response.status));
1045
+ error.status = response.status;
1046
+ throw error;
1047
+ }
1048
+ return payload as T;
1049
+ };
1050
+ TS
1051
+ end
1052
+
1053
+ def frontend_nanoflow_runtime
1054
+ <<~'TS'
1055
+ import type {
1056
+ EntityRecord, NanoflowExecution, NanoflowMetadata, NanoflowParameters,
1057
+ RegisteredNanoflow, RuntimeValue
1058
+ } from '../types';
1059
+
1060
+ type ChangeExpressions = Record<string, string>;
1061
+ type Comparable = string | number;
1062
+
1063
+ const isRecord = (value: RuntimeValue | undefined): value is EntityRecord => {
1064
+ return Boolean(value && typeof value === 'object'
1065
+ && 'id' in value && 'type' in value && 'attributes' in value);
1066
+ };
1067
+
1068
+ const attributes = (value: RuntimeValue | undefined): Record<string, RuntimeValue | undefined> => {
1069
+ return isRecord(value) ? value.attributes : {};
1070
+ };
1071
+
1072
+ const memberName = (value: string): string => value.split(/[./]/).at(-1) || value;
1073
+
1074
+ export class NanoflowRuntime<P extends NanoflowParameters = NanoflowParameters> {
1075
+ readonly variables: NanoflowParameters;
1076
+ readonly #changes = new Map<string, EntityRecord>();
1077
+
1078
+ constructor(parameters: P, readonly metadata: NanoflowMetadata) {
1079
+ this.variables = structuredClone(parameters);
1080
+ }
1081
+
1082
+ value(source: string | undefined, context: EntityRecord | null = null): RuntimeValue | undefined {
1083
+ const text = (source || '').trim();
1084
+ if (/\s(?:and|or)\s|(?:=|!=|>=|<=|>|<)/.test(text)) {
1085
+ return this.condition(text, context);
1086
+ }
1087
+ const wrapped = text.match(/^toString\((.*)\)$/);
1088
+ if (wrapped) return this.string(wrapped[1], context);
1089
+ if (text === '$currentObject') return context;
1090
+ const variable = text.match(/^\$([A-Za-z_]\w*)$/);
1091
+ if (variable) return this.variables[variable[1]] ?? context;
1092
+ const member = text.match(/^\$([A-Za-z_]\w*)\/([A-Za-z_][\w.]*)$/);
1093
+ if (member) {
1094
+ return attributes(this.variables[member[1]] ?? context)[memberName(member[2])];
1095
+ }
1096
+ if (text === 'empty') return null;
1097
+ if (text === 'true') return true;
1098
+ if (text === 'false') return false;
1099
+ if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
1100
+ if (/^'.*'$/.test(text)) return text.slice(1, -1).replaceAll("''", "'");
1101
+ return text;
1102
+ }
1103
+
1104
+ condition(source: string | undefined, context: EntityRecord | null = null): boolean {
1105
+ const text = (source || '').trim().replace(/^\((.*)\)$/, '$1');
1106
+ const orParts = text.split(/\s+or\s+/);
1107
+ if (orParts.length > 1) return orParts.some(part => this.condition(part, context));
1108
+ const andParts = text.split(/\s+and\s+/);
1109
+ if (andParts.length > 1) return andParts.every(part => this.condition(part, context));
1110
+ const comparison = text.match(/^(.*?)\s*(=|!=|>=|<=|>|<)\s*(.*?)$/);
1111
+ if (!comparison) return Boolean(this.value(text, context));
1112
+ const left = this.value(comparison[1], context);
1113
+ const right = this.value(comparison[3], context);
1114
+ switch (comparison[2]) {
1115
+ case '=': return left === right;
1116
+ case '!=': return left !== right;
1117
+ case '>': return this.comparable(left) > this.comparable(right);
1118
+ case '<': return this.comparable(left) < this.comparable(right);
1119
+ case '>=': return this.comparable(left) >= this.comparable(right);
1120
+ case '<=': return this.comparable(left) <= this.comparable(right);
1121
+ default: return false;
1122
+ }
1123
+ }
1124
+
1125
+ boolean(source: string | undefined, context: EntityRecord | null = null): boolean {
1126
+ return this.condition(source, context);
1127
+ }
1128
+
1129
+ number(source: string | undefined, context: EntityRecord | null = null): number {
1130
+ return Number(this.value(source, context));
1131
+ }
1132
+
1133
+ string(source: string | undefined, context: EntityRecord | null = null): string {
1134
+ return String(this.value(source, context) ?? '');
1135
+ }
1136
+
1137
+ set(name: string, value: RuntimeValue | undefined): void {
1138
+ this.variables[name] = value;
1139
+ }
1140
+
1141
+ log(message: string): void {
1142
+ console.info(`[nanoflow] ${message || this.metadata.name}`);
1143
+ }
1144
+
1145
+ change(variable: string, expressions: ChangeExpressions): void {
1146
+ const record = this.variables[variable];
1147
+ if (!isRecord(record)) throw new Error(`Nanoflow object $${variable} is missing`);
1148
+ Object.entries(expressions).forEach(([member, expression]) => {
1149
+ record.attributes[member] = this.value(expression, record);
1150
+ });
1151
+ this.#changes.set(`${record.type}:${record.id}`, record);
1152
+ }
1153
+
1154
+ complete<R>(result: R): NanoflowExecution<R> {
1155
+ return { result, variables: this.variables, changes: [...this.#changes.values()] };
1156
+ }
1157
+
1158
+ missing(current: string | null): Error {
1159
+ return new Error(`Nanoflow ${this.metadata.name} points to missing object ${current || '(none)'}`);
1160
+ }
1161
+
1162
+ stopped(type: string): Error {
1163
+ return new Error(`Nanoflow ${this.metadata.name} stops at ${type}`);
1164
+ }
1165
+
1166
+ unsupported(type: string): Error {
1167
+ return new Error(`Unsupported frontend nanoflow action: ${type || '(empty)'}`);
1168
+ }
1169
+
1170
+ exceeded(): Error {
1171
+ return new Error(`Nanoflow ${this.metadata.name} exceeded 10000 steps`);
1172
+ }
1173
+
1174
+ private comparable(value: RuntimeValue | undefined): Comparable {
1175
+ return typeof value === 'number' ? value : String(value ?? '');
1176
+ }
1177
+ }
1178
+
1179
+ export const defineNanoflow = <P extends NanoflowParameters, R extends RuntimeValue | undefined>(
1180
+ metadata: NanoflowMetadata,
1181
+ compiled: (runtime: NanoflowRuntime<P>) => NanoflowExecution<R> | Promise<NanoflowExecution<R>>
1182
+ ): RegisteredNanoflow => ({
1183
+ ...metadata,
1184
+ execute: async parameters => compiled(new NanoflowRuntime(parameters as P, metadata))
1185
+ });
1186
+ TS
1187
+ end
1188
+
1189
+ def frontend_marketplace_runtime
1190
+ <<~'TS'
1191
+ import { useState } from 'react';
1192
+ import type { ReactNode } from 'react';
1193
+ import type { EntityRecord, RuntimeValue, WidgetDefinition } from '../types';
1194
+
1195
+ export interface MarketplaceWidgetProps {
1196
+ widget: WidgetDefinition;
1197
+ context: EntityRecord | null;
1198
+ children?: ReactNode;
1199
+ onChange(attribute: string | undefined, value: RuntimeValue): unknown;
1200
+ }
1201
+
1202
+ type Properties = Record<string, unknown>;
1203
+
1204
+ const asProperties = (value: unknown): Properties =>
1205
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Properties : {};
1206
+
1207
+ const firstText = (properties: Properties, keys: string[], fallback: string): string => {
1208
+ for (const key of keys) {
1209
+ const value = properties[key];
1210
+ if (typeof value === 'string' && value.trim()) return value;
1211
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
1212
+ }
1213
+ return fallback;
1214
+ };
1215
+
1216
+ const findAttribute = (value: unknown): string | undefined => {
1217
+ if (!value || typeof value !== 'object') return undefined;
1218
+ if (Array.isArray(value)) {
1219
+ for (const child of value) {
1220
+ const found = findAttribute(child);
1221
+ if (found) return found;
1222
+ }
1223
+ return undefined;
1224
+ }
1225
+ for (const [key, child] of Object.entries(value as Properties)) {
1226
+ if (/attribute/i.test(key) && typeof child === 'string' && child.includes('.')) return child;
1227
+ const found = findAttribute(child);
1228
+ if (found) return found;
1229
+ }
1230
+ return undefined;
1231
+ };
1232
+
1233
+ const memberName = (value: string | undefined): string =>
1234
+ (value || '').split(/[./]/).pop() || '';
1235
+
1236
+ const numericValue = (value: RuntimeValue | undefined, fallback = 0): number => {
1237
+ const number = Number(value);
1238
+ return Number.isFinite(number) ? number : fallback;
1239
+ };
1240
+
1241
+ const chart = (name: string, children?: ReactNode) => <>
1242
+ <figure className="mxrb-marketplace-chart">
1243
+ <svg viewBox="0 0 240 100" role="img" aria-label={name}>
1244
+ <title>{name}</title>
1245
+ <polyline points="8,82 48,55 88,68 128,25 168,42 228,12" fill="none"
1246
+ stroke="currentColor" strokeWidth="4" />
1247
+ <line x1="8" y1="90" x2="232" y2="90" stroke="currentColor" />
1248
+ </svg>
1249
+ <figcaption>{name}</figcaption>
1250
+ </figure>
1251
+ {children}
1252
+ </>;
1253
+
1254
+ export function MarketplaceWidget({ widget, context, children, onChange }: MarketplaceWidgetProps) {
1255
+ const options = widget.options || {};
1256
+ const properties = asProperties(options.properties);
1257
+ const id = String(options.widget_id || options.native_type || widget.name).toLowerCase();
1258
+ const name = String(options.widget_name || widget.name);
1259
+ const attribute = findAttribute(properties);
1260
+ const current = context?.attributes?.[memberName(attribute)];
1261
+ const [localValue, setLocalValue] = useState<RuntimeValue>(current ?? 0);
1262
+ const update = (value: RuntimeValue) => {
1263
+ setLocalValue(value);
1264
+ return onChange(attribute, value);
1265
+ };
1266
+ const label = firstText(
1267
+ properties,
1268
+ ['label', 'value', 'caption', 'title', 'legend', 'textMessage', 'alternativeText'],
1269
+ name
1270
+ );
1271
+
1272
+ if (/(area|bar|bubble|column|custom|heatmap|line|pie|time)chart/.test(id)
1273
+ || id.includes('timeseries') || id.includes('heatmap')) return chart(name, children);
1274
+ if (id.includes('progresscircle') || id.includes('progressbar')) {
1275
+ const value = numericValue(current ?? localValue, 50);
1276
+ return <label>{label}<progress value={value} max={100}>{value}%</progress></label>;
1277
+ }
1278
+ if (id.includes('rangeslider')) {
1279
+ const start = Array.isArray(localValue) ? numericValue(localValue[0]) : numericValue(localValue);
1280
+ return <label>{label}<input aria-label={`${label} minimum`} type="range" value={start}
1281
+ onChange={event => update(Number(event.target.value))} /></label>;
1282
+ }
1283
+ if (id.includes('slider')) {
1284
+ return <label>{label}<input aria-label={label} type="range"
1285
+ value={numericValue(current ?? localValue)}
1286
+ onChange={event => update(Number(event.target.value))} /></label>;
1287
+ }
1288
+ if (id.includes('starrating') || id.endsWith('.rating')) {
1289
+ const rating = numericValue(current ?? localValue);
1290
+ return <fieldset className="mxrb-marketplace-rating"><legend>{label}</legend>
1291
+ {[1, 2, 3, 4, 5].map(value => <button type="button" key={value}
1292
+ aria-label={`${value} stars`} aria-pressed={value <= rating}
1293
+ onClick={() => update(value)}>{value <= rating ? '★' : '☆'}</button>)}
1294
+ </fieldset>;
1295
+ }
1296
+ if (id.includes('switch')) {
1297
+ return <label><input type="checkbox" checked={Boolean(current ?? localValue)}
1298
+ onChange={event => update(event.target.checked)} />{label}</label>;
1299
+ }
1300
+ if (id.includes('badgebutton')) return <button type="button">{label}</button>;
1301
+ if (id.includes('badge')) return <output className="mxrb-marketplace-badge">{label}</output>;
1302
+ if (id.includes('accordion')) return <details><summary>{label}</summary>{children}</details>;
1303
+ if (id.includes('fieldset')) return <fieldset><legend>{label}</legend>{children}</fieldset>;
1304
+ if (id.includes('accessibilityhelper')) return <div aria-live="polite">{children}</div>;
1305
+ if (id.includes('htmlelement')) return <article>{children || label}</article>;
1306
+ if (id.endsWith('.image')) {
1307
+ const source = firstText(properties, ['imageUrl', 'url'], '');
1308
+ return source ? <img src={source} alt={label} /> : <span>{label}</span>;
1309
+ }
1310
+ if (id.includes('languageselector')) return <label>{label}<select defaultValue="pt-BR">
1311
+ <option value="pt-BR">Português</option><option value="en-US">English</option>
1312
+ </select></label>;
1313
+ if (id.includes('popupmenu')) return <details><summary>{label}</summary>{children || 'Menu'}</details>;
1314
+ if (id.includes('timeline')) return <ol className="mxrb-marketplace-timeline"><li>{label}</li>{children}</ol>;
1315
+ if (id.includes('tooltip')) return <span title={label}>{children || label}</span>;
1316
+ if (id.includes('treenode') || id.includes('treeview')) return <ul><li>{label}{children}</li></ul>;
1317
+ if (id.includes('videoplayer')) {
1318
+ const source = firstText(properties, ['videoUrl', 'videoURL', 'url'], '');
1319
+ return <video controls src={source || undefined}>{label}</video>;
1320
+ }
1321
+ if (id.includes('barcodescanner')) return <button type="button">{label}</button>;
1322
+
1323
+ return <section className="mxrb-marketplace-generic" aria-label={name}>
1324
+ <strong>{name}</strong>{children}
1325
+ </section>;
1326
+ }
1327
+ TS
1328
+ end
1329
+
1330
+ def frontend_types # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
1331
+ modules = @module_manifests || []
1332
+ entities = modules.flat_map { |mod| Array(mod['models']) + Array(mod['dtos']) }
1333
+ pages = modules.flat_map { |mod| Array(mod['pages']) }
1334
+ enumerations = modules.flat_map { |mod| Array(mod['enumerations']) }
1335
+ enumeration_types = enumerations.map do |enumeration|
1336
+ name = typescript_identifier(enumeration.fetch('name'))
1337
+ values = enumeration.fetch('values', []).map { JSON.generate(_1.fetch('name')) }
1338
+ "export type #{name} = #{values.empty? ? 'never' : values.join(' | ')};"
1339
+ end
1340
+ entity_types = entities.flat_map do |entity|
1341
+ name = typescript_identifier(entity.fetch('name'))
1342
+ attributes = entity.fetch('attributes', []).map do |attribute|
1343
+ optional = attribute['required'] ? '' : '?'
1344
+ type = typescript_attribute_type(attribute, enumerations)
1345
+ " #{JSON.generate(attribute.fetch('name'))}#{optional}: #{type};"
1346
+ end
1347
+ [
1348
+ "export interface #{name}Attributes {\n#{attributes.join("\n")}\n}",
1349
+ "export type #{name}Record = EntityRecord<#{name}Attributes, #{JSON.generate(entity.fetch('name'))}>;"
1350
+ ]
1351
+ end
1352
+ entity_map = entities.map do |entity|
1353
+ " #{JSON.generate(entity.fetch('name'))}: #{typescript_identifier(entity.fetch('name'))}Record;"
1354
+ end
1355
+ page_types = pages.map do |page|
1356
+ name = typescript_identifier(page.fetch('name'))
1357
+ widgets = page.fetch('widgets', []).flat_map { frontend_widget_names(_1) }.uniq
1358
+ widget_names = widgets.empty? ? 'never' : widgets.map { JSON.generate(_1) }.join(' | ')
1359
+ "export type #{name}WidgetName = #{widget_names};"
1360
+ end
1361
+ page_map = pages.map do |page|
1362
+ name = typescript_identifier(page.fetch('name'))
1363
+ qualified = JSON.generate(page.fetch('name'))
1364
+ " #{qualified}: PageDefinition<#{qualified}, #{name}WidgetName>;"
1365
+ end
1366
+ <<~TS
1367
+ // Generated from the Mendix domain, page, widget, effect, and API contracts.
1368
+ import type { ComponentType, ReactNode } from 'react';
1369
+
1370
+ export type RuntimeScalar = string | number | boolean | null;
1371
+ export type RuntimeValue = RuntimeScalar | EntityRecord | RuntimeValue[] | { [key: string]: RuntimeValue };
1372
+ export type RuntimeVariables = Record<string, RuntimeValue | undefined>;
1373
+
1374
+ export interface EntityRecord<
1375
+ Attributes extends object = Record<string, RuntimeValue | undefined>,
1376
+ Name extends string = string
1377
+ > {
1378
+ id: string;
1379
+ type: Name;
1380
+ attributes: Attributes;
1381
+ transient?: boolean;
1382
+ }
1383
+
1384
+ export interface WidgetEvent {
1385
+ event: string;
1386
+ kind: 'microflow' | 'nanoflow' | 'page' | string;
1387
+ handler: string;
1388
+ arguments?: Record<string, string>;
1389
+ }
1390
+
1391
+ export interface WidgetColumn {
1392
+ name?: string;
1393
+ attribute?: string;
1394
+ caption?: string;
1395
+ }
1396
+
1397
+ export interface WidgetTab {
1398
+ name: string;
1399
+ caption?: string;
1400
+ widgets?: WidgetDefinition[];
1401
+ }
1402
+
1403
+ export interface WidgetOptions {
1404
+ [key: string]: unknown;
1405
+ association?: string;
1406
+ attribute?: string;
1407
+ caption?: string;
1408
+ class?: string;
1409
+ columns?: WidgetColumn[];
1410
+ display_attribute?: string;
1411
+ dynamic_class?: string;
1412
+ entity?: string;
1413
+ items?: RuntimeValue[] | Record<string, RuntimeValue>;
1414
+ lines?: number;
1415
+ native_type?: string;
1416
+ options?: RuntimeValue[] | Record<string, RuntimeValue>;
1417
+ pageSize?: number;
1418
+ page_size?: number;
1419
+ parameters?: string[];
1420
+ read_only?: boolean;
1421
+ sort?: Array<{ attribute: string; direction?: string }>;
1422
+ style?: string;
1423
+ tabs?: WidgetTab[];
1424
+ target_entity?: string;
1425
+ toolbar?: { buttons?: Array<{ type: string }> };
1426
+ values?: RuntimeValue[] | Record<string, RuntimeValue>;
1427
+ visible?: string | boolean;
1428
+ platform?: string;
1429
+ properties?: Record<string, unknown>;
1430
+ widget_id?: string;
1431
+ widget_name?: string;
1432
+ }
1433
+
1434
+ export interface WidgetDefinition<Name extends string = string> {
1435
+ type: string;
1436
+ name: Name;
1437
+ caption?: string;
1438
+ options?: WidgetOptions;
1439
+ events?: WidgetEvent[];
1440
+ children?: WidgetDefinition[];
1441
+ }
1442
+
1443
+ export interface PageDefinition<Name extends string = string, WidgetName extends string = string> {
1444
+ name: Name;
1445
+ title: string;
1446
+ appearance_class?: string;
1447
+ appearance_style?: string;
1448
+ data_source?: { kind: 'microflow' | 'nanoflow' | string; name: string } | null;
1449
+ widgets: WidgetDefinition<WidgetName>[];
1450
+ }
1451
+
1452
+ export interface PageComponentProps {
1453
+ busy: boolean;
1454
+ Widget: ComponentType<PageWidgetProps>;
1455
+ }
1456
+
1457
+ export interface PageWidgetProps {
1458
+ widget: WidgetDefinition;
1459
+ index: number;
1460
+ children?: ReactNode;
1461
+ }
1462
+
1463
+ export interface AttributeDefinition {
1464
+ name: string;
1465
+ type: string;
1466
+ enumeration?: string;
1467
+ }
1468
+
1469
+ export interface EntityDefinition {
1470
+ name: string;
1471
+ attributes?: AttributeDefinition[];
1472
+ }
1473
+
1474
+ export interface EnumerationDefinition {
1475
+ id: string;
1476
+ name: string;
1477
+ values: Array<{ name: string; caption: string }>;
1478
+ }
1479
+
1480
+ export interface AssociationDefinition {
1481
+ name: string;
1482
+ from_entity: string;
1483
+ to_entity: string;
1484
+ type: string;
1485
+ }
1486
+
1487
+ export interface NavigationItem {
1488
+ page?: string;
1489
+ caption?: Record<string, string>;
1490
+ items?: NavigationItem[];
1491
+ }
1492
+
1493
+ export interface NavigationProfile {
1494
+ kind: string;
1495
+ home_page?: string;
1496
+ items?: NavigationItem[];
1497
+ }
1498
+
1499
+ export interface RuntimeModule {
1500
+ name: string;
1501
+ models?: EntityDefinition[];
1502
+ dtos?: EntityDefinition[];
1503
+ pages: PageDefinition[];
1504
+ enumerations?: EnumerationDefinition[];
1505
+ associations?: AssociationDefinition[];
1506
+ }
1507
+
1508
+ export interface ApplicationSchema {
1509
+ project: { name: string; mendix_version: string };
1510
+ navigation?: { profiles?: NavigationProfile[] };
1511
+ modules: RuntimeModule[];
1512
+ }
1513
+
1514
+ export interface OpenPageEffect {
1515
+ type: 'open_page';
1516
+ page: string;
1517
+ arguments?: Record<string, RuntimeValue>;
1518
+ }
1519
+
1520
+ export interface RuntimeEffect {
1521
+ type: string;
1522
+ [key: string]: RuntimeValue | undefined;
1523
+ }
1524
+
1525
+ export interface InvocationResult {
1526
+ result?: RuntimeValue;
1527
+ context?: EntityRecord | null;
1528
+ effects?: Array<OpenPageEffect | RuntimeEffect>;
1529
+ }
1530
+
1531
+ export interface EntityCollectionResponse<T extends EntityRecord = EntityRecord> {
1532
+ records: T[];
1533
+ }
1534
+
1535
+ export interface Session {
1536
+ id?: string;
1537
+ username?: string;
1538
+ [key: string]: RuntimeValue | undefined;
1539
+ }
1540
+
1541
+ export interface LoginResponse {
1542
+ token: string;
1543
+ }
1544
+
1545
+ export interface ApiFailure extends Error {
1546
+ status?: number;
1547
+ }
1548
+
1549
+ export type ApiRequest = <T = RuntimeValue | undefined>(
1550
+ path: string, options?: RequestInit
1551
+ ) => Promise<T>;
1552
+
1553
+ export type NanoflowParameters = RuntimeVariables;
1554
+
1555
+ export interface NanoflowMetadata {
1556
+ name: string;
1557
+ id: string;
1558
+ parameters: string[];
1559
+ }
1560
+
1561
+ export interface NanoflowExecution<R = RuntimeValue | undefined> {
1562
+ result: R;
1563
+ variables: NanoflowParameters;
1564
+ changes: EntityRecord[];
1565
+ }
1566
+
1567
+ export interface RegisteredNanoflow extends NanoflowMetadata {
1568
+ execute(parameters: NanoflowParameters): Promise<NanoflowExecution>;
1569
+ }
1570
+
1571
+ #{enumeration_types.join("\n")}
1572
+
1573
+ #{entity_types.join("\n\n")}
1574
+
1575
+ export interface EntityTypeMap {
1576
+ #{entity_map.join("\n")}
1577
+ }
1578
+ export type EntityName = keyof EntityTypeMap;
1579
+
1580
+ #{page_types.join("\n")}
1581
+
1582
+ export interface PageTypeMap {
1583
+ #{page_map.join("\n")}
1584
+ }
1585
+ export type PageName = keyof PageTypeMap;
1586
+ TS
1587
+ end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
1588
+
1589
+ def frontend_widget_names(widget)
1590
+ [widget['name'].to_s, *Array(widget['children']).flat_map { frontend_widget_names(_1) }]
1591
+ .reject(&:empty?)
1592
+ end
1593
+
1594
+ def typescript_attribute_type(attribute, enumerations)
1595
+ if attribute['type'] == 'enum'
1596
+ enumeration = enumerations.find do |candidate|
1597
+ [candidate['id'], candidate['name']].include?(attribute['enumeration'])
1598
+ end
1599
+ return typescript_identifier(enumeration['name']) if enumeration
1600
+ end
1601
+
1602
+ {
1603
+ 'boolean' => 'boolean', 'integer' => 'number', 'long' => 'number',
1604
+ 'autonumber' => 'number', 'decimal' => 'number', 'datetime' => 'string',
1605
+ 'binary' => 'string'
1606
+ }.fetch(attribute['type'].to_s, 'string')
1607
+ end
1608
+
1609
+ def typescript_identifier(value)
1610
+ parts = value.to_s.split(/[^A-Za-z0-9]+/).reject(&:empty?)
1611
+ identifier = parts.map { _1[0].to_s.upcase + _1[1..].to_s }.join
1612
+ return 'MxrbType' if identifier.empty?
1613
+
1614
+ identifier.match?(/\A[A-Za-z_]/) ? identifier : "Mx#{identifier}"
1615
+ end
1616
+
1617
+ def frontend_app
1618
+ <<~'JS'
1619
+ import { useCallback, useEffect, useRef, useState } from 'react';
1620
+ import type { CSSProperties, FormEvent, ReactNode } from 'react';
1621
+ import { api } from '../api/client';
1622
+ import { MarketplaceWidget } from '../runtime/marketplace';
1623
+ import nanoflows from '../nanoflows';
1624
+ import pages from '../pages';
1625
+ import type {
1626
+ ApiFailure, ApiRequest, ApplicationSchema, EntityCollectionResponse, EntityRecord,
1627
+ InvocationResult, LoginResponse, NavigationItem, OpenPageEffect, PageDefinition,
1628
+ PageWidgetProps, RuntimeValue, RuntimeVariables, Session, WidgetDefinition,
1629
+ WidgetEvent, WidgetOptions
1630
+ } from '../types';
1631
+
1632
+ const TOKEN_KEY = 'mxrb.session.token';
1633
+
1634
+ type ErrorHandler = (failure: unknown) => void;
1635
+ type SaveRecord = (
1636
+ record: EntityRecord | null, changes: Record<string, RuntimeValue | undefined>
1637
+ ) => Promise<EntityRecord | null>;
1638
+ type InvokeHandler = (
1639
+ name: string, parameters?: RuntimeVariables, contextOverride?: EntityRecord | null
1640
+ ) => Promise<unknown>;
1641
+ type NavigateHandler = (name: string, context?: EntityRecord | null) => Promise<unknown>;
1642
+ type SelectRecord = (record: EntityRecord | null) => void;
1643
+
1644
+ interface WidgetRuntimeProps {
1645
+ widget: WidgetDefinition;
1646
+ children?: ReactNode;
1647
+ moduleName: string;
1648
+ invoke: InvokeHandler;
1649
+ invokeNanoflow: InvokeHandler;
1650
+ navigate: NavigateHandler;
1651
+ context?: EntityRecord | null;
1652
+ pageContext: EntityRecord | null;
1653
+ revision: number;
1654
+ schema: ApplicationSchema;
1655
+ request: ApiRequest;
1656
+ saveRecord: SaveRecord;
1657
+ onError: ErrorHandler;
1658
+ onMutation: () => void;
1659
+ onSelectRecord: SelectRecord;
1660
+ }
1661
+
1662
+ interface BoundFieldProps {
1663
+ widget: WidgetDefinition;
1664
+ record: EntityRecord | null;
1665
+ schema: ApplicationSchema;
1666
+ request: ApiRequest;
1667
+ saveRecord: SaveRecord;
1668
+ revision: number;
1669
+ onChanged?: (record: EntityRecord) => unknown;
1670
+ onError: ErrorHandler;
1671
+ }
1672
+
1673
+ interface DataGridProps {
1674
+ widget: WidgetDefinition;
1675
+ request: ApiRequest;
1676
+ pageContext: EntityRecord | null;
1677
+ revision: number;
1678
+ onError: ErrorHandler;
1679
+ onMutation: () => void;
1680
+ onRowAction: (record: EntityRecord) => unknown;
1681
+ onSelectRecord: SelectRecord;
1682
+ }
1683
+
1684
+ type GalleryProps = Omit<WidgetRuntimeProps, 'context'>;
1685
+
1686
+ interface LoginProps {
1687
+ onLogin: (username: string, password: string) => Promise<void>;
1688
+ error: ApiFailure | null;
1689
+ busy: boolean;
1690
+ }
1691
+
1692
+ const classes = (...values: Array<string | false | null | undefined>): string =>
1693
+ values.filter(Boolean).join(' ');
1694
+ const isEntityRecord = (value: RuntimeValue | undefined): value is EntityRecord =>
1695
+ Boolean(value && typeof value === 'object' && !Array.isArray(value)
1696
+ && 'id' in value && 'type' in value && 'attributes' in value);
1697
+ const attributes = (object: RuntimeValue | undefined): Record<string, RuntimeValue | undefined> =>
1698
+ isEntityRecord(object) ? object.attributes : {};
1699
+ const memberName = (value: string | undefined): string =>
1700
+ (value || '').split(/[./]/).pop() || '';
1701
+ const entityCollectionPath = (
1702
+ entity: string, association: string | undefined, context: EntityRecord | null
1703
+ ): string => {
1704
+ const path = `/api/entities/${encodeURIComponent(entity)}`;
1705
+ if (!association || !context?.type || !context?.id) return path;
1706
+ const query = new URLSearchParams({
1707
+ association, context_type: context.type, context_id: context.id
1708
+ });
1709
+ return `${path}?${query}`;
1710
+ };
1711
+ const expressionValue = (
1712
+ source: string | undefined, context: EntityRecord | null,
1713
+ variables: RuntimeVariables = {}
1714
+ ): RuntimeValue | undefined => {
1715
+ const text = (source || '').trim();
1716
+ const wrapped = text.match(/^toString\((.*)\)$/);
1717
+ if (wrapped) return String(expressionValue(wrapped[1], context, variables) ?? '');
1718
+ if (text === '$currentObject') return context;
1719
+ const variable = text.match(/^\$([A-Za-z_]\w*)$/);
1720
+ if (variable) return variables[variable[1]] ?? context;
1721
+ const member = text.match(/^\$([A-Za-z_]\w*)\/([A-Za-z_][\w.]*)$/);
1722
+ if (member) return attributes(variables[member[1]] ?? context)[memberName(member[2])];
1723
+ if (text === 'empty') return null;
1724
+ if (text === 'true') return true;
1725
+ if (text === 'false') return false;
1726
+ if (/^'.*'$/.test(text)) return text.slice(1, -1).replaceAll("''", "'");
1727
+ return text;
1728
+ };
1729
+ const conditionValue = (
1730
+ source: string | undefined, context: EntityRecord | null,
1731
+ variables: RuntimeVariables = {}
1732
+ ): boolean => {
1733
+ const text = (source || '').trim().replace(/^\((.*)\)$/, '$1');
1734
+ const orParts = text.split(/\s+or\s+/);
1735
+ if (orParts.length > 1) return orParts.some(part => conditionValue(part, context, variables));
1736
+ const andParts = text.split(/\s+and\s+/);
1737
+ if (andParts.length > 1) return andParts.every(part => conditionValue(part, context, variables));
1738
+ const comparison = text.match(/^(.*?)\s*(=|!=|>=|<=|>|<)\s*(.*?)$/);
1739
+ if (!comparison) return Boolean(expressionValue(text, context, variables));
1740
+ const left = expressionValue(comparison[1], context, variables);
1741
+ const right = expressionValue(comparison[3], context, variables);
1742
+ if (comparison[2] === '=') return left === right;
1743
+ if (comparison[2] === '!=') return left !== right;
1744
+ const comparable = (value: RuntimeValue | undefined): string | number =>
1745
+ typeof value === 'number' ? value : String(value ?? '');
1746
+ if (comparison[2] === '>') return comparable(left) > comparable(right);
1747
+ if (comparison[2] === '<') return comparable(left) < comparable(right);
1748
+ if (comparison[2] === '>=') return comparable(left) >= comparable(right);
1749
+ return comparable(left) <= comparable(right);
1750
+ };
1751
+ const isVisible = (source: string | boolean | undefined, context: EntityRecord | null): boolean => {
1752
+ if (typeof source === 'boolean') return source;
1753
+ return !source || conditionValue(source, context);
1754
+ };
1755
+ const dynamicClass = (source: string | undefined, context: EntityRecord | null): string => {
1756
+ let text = source || '';
1757
+ text = text.replace(/\(?if\s+(.+?)\s+then\s+'([^']*)'\s+else\s+'([^']*)'\)?/g,
1758
+ (_match: string, condition: string, yes: string, no: string) =>
1759
+ conditionValue(condition, context) ? yes : no);
1760
+ text = text.replace(/toString\(\$[A-Za-z_]\w*\/([A-Za-z_][\w.]*)\)/g,
1761
+ (_match: string, member: string) => String(attributes(context)[memberName(member)] ?? ''));
1762
+ text = text.replace(/\$[A-Za-z_]\w*\/([A-Za-z_][\w.]*)/g,
1763
+ (_match: string, member: string) => String(attributes(context)[memberName(member)] ?? ''));
1764
+ return text.replace(/[+()']/g, ' ').replace(/\s+/g, ' ').trim();
1765
+ };
1766
+ const caption = (
1767
+ widget: WidgetDefinition, options: WidgetOptions, context: EntityRecord | null
1768
+ ): string => {
1769
+ let value = options.caption || widget.caption || widget.name;
1770
+ (options.parameters || []).forEach((parameter, index) => {
1771
+ value = value.replaceAll(`{${index + 1}}`, String(expressionValue(parameter, context) ?? ''));
1772
+ });
1773
+ return value;
1774
+ };
1775
+ const inlineStyle = (value: string | undefined): CSSProperties =>
1776
+ Object.fromEntries((value || '').split(';').filter(Boolean).map((rule: string) => {
1777
+ const [property, ...parts] = rule.split(':');
1778
+ const name = property.trim().replace(/-([a-z])/g,
1779
+ (_match: string, letter: string) => letter.toUpperCase());
1780
+ return [name, parts.join(':').trim()];
1781
+ }));
1782
+
1783
+ const eventArguments = (event: WidgetEvent | undefined, context: EntityRecord | null): RuntimeVariables => Object.fromEntries(
1784
+ Object.entries(event?.arguments || {}).map(([name, expression]) => [name, expressionValue(expression, context)])
1785
+ );
1786
+
1787
+ const recordValue = (record: EntityRecord | null, attribute: string | undefined): RuntimeValue | undefined =>
1788
+ attributes(record || undefined)[memberName(attribute)];
1789
+ const displayValue = (value: RuntimeValue | undefined): string | number | boolean => {
1790
+ if (value == null) return '';
1791
+ if (Array.isArray(value)) return value.map(displayValue).join(', ');
1792
+ if (isEntityRecord(value)) return Object.values(value.attributes).find(item =>
1793
+ ['string', 'number', 'boolean'].includes(typeof item)) as string | number | boolean || value.id;
1794
+ if (typeof value === 'object') return JSON.stringify(value);
1795
+ return String(value);
1796
+ };
1797
+
1798
+ const choiceValue = (value: RuntimeValue, key: string): RuntimeValue | undefined => {
1799
+ if (isEntityRecord(value)) return key === 'id' ? value.id : value.attributes[key];
1800
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value[key];
1801
+ return undefined;
1802
+ };
1803
+
1804
+ const draftValue = (value: RuntimeValue | undefined): string | number | boolean => {
1805
+ if (isEntityRecord(value)) return value.id;
1806
+ return ['string', 'number', 'boolean'].includes(typeof value)
1807
+ ? value as string | number | boolean : '';
1808
+ };
1809
+
1810
+ const apiFailure = (failure: unknown): ApiFailure => {
1811
+ if (failure instanceof Error) return failure as ApiFailure;
1812
+ return new Error(String(failure));
1813
+ };
1814
+
1815
+ const sortRecords = (
1816
+ records: EntityRecord[], sortings: Array<{ attribute: string; direction?: string }> = []
1817
+ ) => {
1818
+ const result = records.slice();
1819
+ sortings.slice().reverse().forEach(sorting => {
1820
+ const member = memberName(sorting.attribute);
1821
+ const direction = sorting.direction === 'Descending' ? -1 : 1;
1822
+ result.sort((left, right) => direction * String(
1823
+ left.attributes?.[member] ?? ''
1824
+ ).localeCompare(String(right.attributes?.[member] ?? ''), undefined, { numeric: true }));
1825
+ });
1826
+ return result;
1827
+ };
1828
+
1829
+ function BoundField({
1830
+ widget, record, schema, request, saveRecord, revision, onChanged, onError
1831
+ }: BoundFieldProps) {
1832
+ const options = widget.options || {};
1833
+ const member = memberName(options.attribute || widget.name);
1834
+ const kind = widget.type;
1835
+ const value = recordValue(record, member);
1836
+ const [draft, setDraft] = useState<string | number | boolean>(
1837
+ kind === 'check_box' ? Boolean(value) : draftValue(value)
1838
+ );
1839
+ const [references, setReferences] = useState<EntityRecord[]>([]);
1840
+ const associations = (schema?.modules || []).flatMap(module => module.associations || []);
1841
+ const association = associations.find(item =>
1842
+ item.name === options.attribute || memberName(item.name) === member
1843
+ );
1844
+ const referenceEntity = options.entity || options.target_entity || association?.to_entity;
1845
+ const entityDefinition = (schema?.modules || []).flatMap(module =>
1846
+ [...(module.models || []), ...(module.dtos || [])]
1847
+ ).find(entity => entity.name === record?.type);
1848
+ const attributeDefinition = (entityDefinition?.attributes || []).find(attribute =>
1849
+ attribute.name === member
1850
+ );
1851
+ const enumeration = (schema?.modules || []).flatMap(module =>
1852
+ module.enumerations || []
1853
+ ).find(item => item.id === attributeDefinition?.enumeration
1854
+ || item.name === attributeDefinition?.enumeration);
1855
+
1856
+ useEffect(() => {
1857
+ setDraft(kind === 'check_box' ? Boolean(value) : draftValue(value));
1858
+ }, [kind, record?.id, value]);
1859
+
1860
+ useEffect(() => {
1861
+ if (kind !== 'reference_selector' || !referenceEntity) return;
1862
+ request<EntityCollectionResponse>(`/api/entities/${encodeURIComponent(referenceEntity)}`)
1863
+ .then(payload => setReferences(payload.records || [])).catch(onError);
1864
+ }, [kind, referenceEntity, revision, request, onError]);
1865
+
1866
+ const persist = (next: string | number | boolean) => {
1867
+ setDraft(next);
1868
+ if (!record?.type || !record?.id || !member) return Promise.resolve(record);
1869
+ let normalized: RuntimeValue = next;
1870
+ if (kind === 'number_input') normalized = next === '' ? null : Number(next);
1871
+ if (kind === 'reference_selector') {
1872
+ normalized = references.find(item => item.id === next) || null;
1873
+ }
1874
+ return saveRecord(record, { [member]: normalized }).then(updated => {
1875
+ if (!updated) return null;
1876
+ if (onChanged) return onChanged(updated);
1877
+ return updated;
1878
+ });
1879
+ };
1880
+ const disabled = !record?.id || !member || options.read_only === true;
1881
+
1882
+ if (kind === 'text_area') {
1883
+ return <textarea rows={options.lines || 4} value={String(draft)} disabled={disabled}
1884
+ onChange={event => setDraft(event.target.value)} onBlur={() => persist(draft)} />;
1885
+ }
1886
+ if (kind === 'check_box') {
1887
+ return <input type="checkbox" checked={Boolean(draft)} disabled={disabled}
1888
+ onChange={event => persist(event.target.checked)} />;
1889
+ }
1890
+ if (kind === 'drop_down' || kind === 'reference_selector') {
1891
+ const enumValues = (enumeration?.values || []).map(item => ({
1892
+ id: item.name, label: item.caption || item.name
1893
+ }));
1894
+ const configuredValue = options.values || options.items || options.options || enumValues;
1895
+ const configured: RuntimeValue[] = Array.isArray(configuredValue)
1896
+ ? configuredValue : configuredValue ? Object.values(configuredValue) : [];
1897
+ const choices: RuntimeValue[] = kind === 'reference_selector' ? references : configured;
1898
+ return <select value={String(draft)} disabled={disabled} onChange={event => persist(event.target.value)}>
1899
+ <option value="">—</option>
1900
+ {draft && !choices.some(item => (choiceValue(item, 'id') || choiceValue(item, 'value') || item) === draft) ?
1901
+ <option value={String(draft)}>{displayValue(value)}</option> : null}
1902
+ {choices.map((item, index) => <option
1903
+ key={String(choiceValue(item, 'id') || choiceValue(item, 'value') || index)}
1904
+ value={String(choiceValue(item, 'id') || choiceValue(item, 'value') || item)}>
1905
+ {displayValue(choiceValue(item, 'label') || choiceValue(item, 'caption')
1906
+ || (isEntityRecord(item) ? recordValue(item, options.display_attribute) : undefined)
1907
+ || choiceValue(item, 'id') || choiceValue(item, 'value') || item)}
1908
+ </option>)}
1909
+ </select>;
1910
+ }
1911
+ const inputType = kind === 'date_picker' ? 'date' : kind === 'number_input' ? 'number' : 'text';
1912
+ const inputValue = inputType === 'date' ? String(draft).slice(0, 10)
1913
+ : typeof draft === 'boolean' ? String(draft) : draft;
1914
+ return <input type={inputType} value={inputValue} disabled={disabled}
1915
+ onChange={event => setDraft(event.target.value)} onBlur={() => persist(draft)} />;
1916
+ }
1917
+
1918
+ function DataGrid({ widget, request, pageContext, revision, onError, onMutation,
1919
+ onRowAction, onSelectRecord }: DataGridProps) {
1920
+ const options = widget.options || {};
1921
+ const [records, setRecords] = useState<EntityRecord[]>([]);
1922
+ const [pageNumber, setPageNumber] = useState(0);
1923
+ const [reload, setReload] = useState(0);
1924
+ const [selected, setSelected] = useState<EntityRecord | null>(null);
1925
+ const [loading, setLoading] = useState(false);
1926
+ const pageSize = Math.max(1, Number(options.page_size || options.pageSize || 20));
1927
+
1928
+ useEffect(() => {
1929
+ if (!options.entity) return;
1930
+ setLoading(true);
1931
+ request<EntityCollectionResponse>(
1932
+ entityCollectionPath(options.entity, options.association, pageContext)
1933
+ ).then(payload => {
1934
+ let values = payload.records || [];
1935
+ values = sortRecords(values, options.sort || []);
1936
+ setRecords(values);
1937
+ setPageNumber(current => Math.min(current, Math.max(0, Math.ceil(values.length / pageSize) - 1)));
1938
+ }).catch(onError).finally(() => setLoading(false));
1939
+ }, [options.entity, options.association, pageContext?.type, pageContext?.id,
1940
+ pageSize, reload, revision, request, onError]);
1941
+
1942
+ const mutate = <T,>(operation: Promise<T>): Promise<T> => operation.then(result => {
1943
+ setReload(value => value + 1);
1944
+ onMutation();
1945
+ return result;
1946
+ }).catch(failure => {
1947
+ onError(failure);
1948
+ throw failure;
1949
+ });
1950
+ const createRecord = () => mutate(request<EntityRecord>(`/api/entities/${encodeURIComponent(options.entity || '')}`, {
1951
+ method: 'POST', body: '{}'
1952
+ })).then(record => {
1953
+ setSelected(record);
1954
+ if (record) onSelectRecord(record);
1955
+ });
1956
+ const deleteRecord = () => selected && mutate(request(
1957
+ `/api/entities/${encodeURIComponent(options.entity || '')}/${encodeURIComponent(selected.id)}`,
1958
+ { method: 'DELETE' }
1959
+ )).then(() => { setSelected(null); onSelectRecord(null); });
1960
+ const toolbar = options.toolbar?.buttons || [{ type: 'new' }, { type: 'delete' }];
1961
+ const pageCount = Math.max(1, Math.ceil(records.length / pageSize));
1962
+ const visible = records.slice(pageNumber * pageSize, (pageNumber + 1) * pageSize);
1963
+
1964
+ return <div className={classes('mxrb-data-grid-runtime', loading && 'is-loading')}
1965
+ data-entity={options.entity || ''}>
1966
+ <div className="mxrb-grid-toolbar">
1967
+ {toolbar.some(button => button.type === 'new') ? <button type="button" onClick={createRecord}>New</button> : null}
1968
+ {toolbar.some(button => button.type === 'delete') ? <button type="button" disabled={!selected} onClick={deleteRecord}>Delete</button> : null}
1969
+ <button type="button" onClick={() => setReload(value => value + 1)}>Reload</button>
1970
+ </div>
1971
+ <table><thead><tr>{(options.columns || []).map(column =>
1972
+ <th key={column.name || column.attribute}>{column.caption || column.name}</th>)}</tr></thead>
1973
+ <tbody>{visible.map(record => <tr key={record.id}
1974
+ className={selected?.id === record.id ? 'is-selected' : ''}
1975
+ onClick={() => { setSelected(record); onSelectRecord(record); onRowAction(record); }}>
1976
+ {(options.columns || []).map(column => <td key={column.name || column.attribute}>
1977
+ {displayValue(recordValue(record, column.attribute || column.name))}
1978
+ </td>)}
1979
+ </tr>)}</tbody></table>
1980
+ <div className="mxrb-grid-pagination">
1981
+ <button type="button" disabled={pageNumber === 0}
1982
+ onClick={() => setPageNumber(value => value - 1)}>Previous</button>
1983
+ <span>Page {pageNumber + 1} of {pageCount} · {records.length} rows</span>
1984
+ <button type="button" disabled={pageNumber + 1 >= pageCount}
1985
+ onClick={() => setPageNumber(value => value + 1)}>Next</button>
1986
+ </div>
1987
+ </div>;
1988
+ }
1989
+
1990
+ function Gallery({ widget, moduleName, invoke, invokeNanoflow, navigate, pageContext, revision,
1991
+ schema, request, saveRecord, onError, onMutation, onSelectRecord }: GalleryProps) {
1992
+ const options = widget.options || {};
1993
+ const [records, setRecords] = useState<EntityRecord[]>([]);
1994
+ useEffect(() => {
1995
+ if (!options.entity) return;
1996
+ request<EntityCollectionResponse>(
1997
+ entityCollectionPath(options.entity, options.association, pageContext)
1998
+ ).then(payload => {
1999
+ setRecords(sortRecords(payload.records || [], options.sort || []));
2000
+ }).catch(onError);
2001
+ }, [options.entity, options.association, pageContext?.type, pageContext?.id,
2002
+ revision, request, onError]);
2003
+ return <div className={classes('mxrb-widget', 'mxrb-gallery', options.class)}
2004
+ data-widget-name={widget.name} data-widget-type={widget.type}>
2005
+ <div className="mxrb-gallery-items gallery-items">
2006
+ {records.map(record => <div className="mxrb-gallery-item gallery-item" key={record.id}>
2007
+ {(widget.children || []).map((child, index) => <Widget key={`${child.name}-${index}`}
2008
+ widget={child} moduleName={moduleName} invoke={invoke} invokeNanoflow={invokeNanoflow}
2009
+ navigate={navigate} context={record} schema={schema} request={request}
2010
+ saveRecord={saveRecord} onError={onError} onMutation={onMutation}
2011
+ onSelectRecord={onSelectRecord}
2012
+ pageContext={pageContext} revision={revision} />)}
2013
+ </div>)}
2014
+ </div>
2015
+ </div>;
2016
+ }
2017
+
2018
+ function Widget({ widget, children: compiledChildren, moduleName, invoke, invokeNanoflow, navigate,
2019
+ context, pageContext, revision, schema, request, saveRecord,
2020
+ onError, onMutation, onSelectRecord }: WidgetRuntimeProps) {
2021
+ const options = widget.options || {};
2022
+ if (!isVisible(options.visible, context || pageContext)) return null;
2023
+ const className = classes('mxrb-widget', `mxrb-${widget.type}`, `mx-name-${widget.name}`,
2024
+ options.class,
2025
+ dynamicClass(options.dynamic_class, context || pageContext));
2026
+ const runtimeProps = {
2027
+ 'data-widget-name': widget.name,
2028
+ 'data-widget-type': widget.type
2029
+ };
2030
+ const children = compiledChildren ?? (widget.children || []).map((child, index) =>
2031
+ <Widget key={`${child.name}-${index}`} widget={child} moduleName={moduleName} invoke={invoke}
2032
+ invokeNanoflow={invokeNanoflow} navigate={navigate}
2033
+ context={context} pageContext={pageContext} revision={revision} schema={schema}
2034
+ request={request} saveRecord={saveRecord} onError={onError} onMutation={onMutation}
2035
+ onSelectRecord={onSelectRecord} />);
2036
+ const click = (widget.events || []).find(event => event.event === 'on_click');
2037
+ const change = (widget.events || []).find(event => event.event === 'on_change');
2038
+ const runEvent = (
2039
+ event: WidgetEvent | undefined, eventContext: EntityRecord | null = context || pageContext
2040
+ ): Promise<unknown> => {
2041
+ if (!event) return Promise.resolve();
2042
+ const handler = event.handler.includes('.') ? event.handler : `${moduleName}.${event.handler}`;
2043
+ const parameters = eventArguments(event, eventContext);
2044
+ if (event.kind === 'nanoflow') return invokeNanoflow(handler, parameters, eventContext);
2045
+ if (event.kind === 'page') {
2046
+ const candidate = Object.values(parameters)[0];
2047
+ const targetContext = isEntityRecord(candidate) ? candidate : pageContext || context || null;
2048
+ return navigate(handler, targetContext);
2049
+ }
2050
+ return invoke(handler, parameters, eventContext);
2051
+ };
2052
+ const onClick = click ? () => runEvent(click) : undefined;
2053
+ const onChanged = (updated: EntityRecord) => runEvent(change, updated);
2054
+
2055
+ switch (widget.type) {
2056
+ case 'container':
2057
+ return <div {...runtimeProps} className={className} style={inlineStyle(options.style)} onClick={onClick}
2058
+ role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined}>
2059
+ {children}
2060
+ </div>;
2061
+ case 'text':
2062
+ return <span {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}</span>;
2063
+ case 'button':
2064
+ return <button {...runtimeProps} type="button" className={className} onClick={onClick}>
2065
+ {caption(widget, options, context || pageContext)}
2066
+ </button>;
2067
+ case 'text_area':
2068
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
2069
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
2070
+ request={request} saveRecord={saveRecord} revision={revision}
2071
+ onChanged={onChanged} onError={onError} />
2072
+ </label>;
2073
+ case 'text_box':
2074
+ case 'number_input':
2075
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
2076
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
2077
+ request={request} saveRecord={saveRecord} revision={revision}
2078
+ onChanged={onChanged} onError={onError} />
2079
+ </label>;
2080
+ case 'check_box':
2081
+ return <label {...runtimeProps} className={className}>
2082
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
2083
+ request={request} saveRecord={saveRecord} revision={revision}
2084
+ onChanged={onChanged} onError={onError} />
2085
+ {caption(widget, options, context || pageContext)}
2086
+ </label>;
2087
+ case 'date_picker':
2088
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
2089
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
2090
+ request={request} saveRecord={saveRecord} revision={revision}
2091
+ onChanged={onChanged} onError={onError} />
2092
+ </label>;
2093
+ case 'drop_down':
2094
+ case 'reference_selector':
2095
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
2096
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
2097
+ request={request} saveRecord={saveRecord} revision={revision}
2098
+ onChanged={onChanged} onError={onError} />
2099
+ </label>;
2100
+ case 'tab_control':
2101
+ return <div {...runtimeProps} className={className}>{(options.tabs || []).map(tab =>
2102
+ <section key={tab.name}><h3>{tab.caption || tab.name}</h3>
2103
+ {(tab.widgets || []).map((child, index) =>
2104
+ <Widget key={`${child.name}-${index}`} widget={child} moduleName={moduleName} invoke={invoke}
2105
+ invokeNanoflow={invokeNanoflow} navigate={navigate}
2106
+ context={context} pageContext={pageContext} revision={revision} schema={schema}
2107
+ request={request} saveRecord={saveRecord} onError={onError} onMutation={onMutation}
2108
+ onSelectRecord={onSelectRecord} />)}
2109
+ </section>)}</div>;
2110
+ case 'data_grid':
2111
+ return <div {...runtimeProps} className={className}><DataGrid widget={widget} request={request}
2112
+ pageContext={pageContext} revision={revision} onError={onError}
2113
+ onMutation={onMutation} onSelectRecord={onSelectRecord}
2114
+ onRowAction={record => runEvent(change || click, record)} />
2115
+ </div>;
2116
+ case 'gallery':
2117
+ return <Gallery widget={widget} moduleName={moduleName} invoke={invoke}
2118
+ invokeNanoflow={invokeNanoflow} navigate={navigate}
2119
+ pageContext={pageContext} revision={revision} schema={schema} request={request}
2120
+ saveRecord={saveRecord} onError={onError} onMutation={onMutation}
2121
+ onSelectRecord={onSelectRecord} />;
2122
+ case 'pluggable_widget':
2123
+ return <div {...runtimeProps} className={classes(className, 'mxrb-marketplace-widget')}
2124
+ data-widget-id={options.widget_id || ''}>
2125
+ <MarketplaceWidget widget={widget} context={context || pageContext}
2126
+ onChange={(attribute, value) => {
2127
+ const active = context || pageContext;
2128
+ const member = memberName(attribute);
2129
+ if (!active || !member) return Promise.resolve(active);
2130
+ return saveRecord(active, { [member]: value }).then(updated => {
2131
+ if (updated) return onChanged(updated);
2132
+ return updated;
2133
+ });
2134
+ }}>{children}</MarketplaceWidget>
2135
+ </div>;
2136
+ case 'native_widget':
2137
+ return <div {...runtimeProps} className={classes(className, 'mxrb-native-widget')}
2138
+ data-native-type={options.native_type || ''} role="alert">
2139
+ Could not render widget {widget.name}: unsupported native type {options.native_type || 'unknown'}
2140
+ </div>;
2141
+ default:
2142
+ return <div {...runtimeProps} className={className} role="alert">
2143
+ Could not render widget {widget.name}: unsupported type {widget.type}
2144
+ </div>;
2145
+ }
2146
+ }
2147
+
2148
+ function navigationItems(items: NavigationItem[], openPage: (name: string) => unknown) {
2149
+ return (items || []).map((item, index) => <li key={`${item.page || item.caption?.en_US}-${index}`}>
2150
+ {item.page ? <button type="button" onClick={() => openPage(item.page!)}>
2151
+ {item.caption?.en_US || item.page}
2152
+ </button> : <span>{item.caption?.en_US || ''}</span>}
2153
+ {item.items?.length ? <ul>{navigationItems(item.items, openPage)}</ul> : null}
2154
+ </li>);
2155
+ }
2156
+
2157
+ function Login({ onLogin, error, busy }: LoginProps) {
2158
+ const [username, setUsername] = useState('');
2159
+ const [password, setPassword] = useState('');
2160
+ const submit = (event: FormEvent<HTMLFormElement>) => {
2161
+ event.preventDefault();
2162
+ onLogin(username, password).finally(() => setPassword(''));
2163
+ };
2164
+ return <main className="mxrb-login">
2165
+ <form onSubmit={submit}>
2166
+ <h1>Sign in</h1>
2167
+ <label>Username<input autoComplete="username" value={username}
2168
+ onChange={event => setUsername(event.target.value)} /></label>
2169
+ <label>Password<input type="password" autoComplete="current-password" value={password}
2170
+ onChange={event => setPassword(event.target.value)} /></label>
2171
+ <button type="submit" disabled={busy || !username || !password}>Sign in</button>
2172
+ {error ? <p role="alert">{error.message}</p> : null}
2173
+ </form>
2174
+ </main>;
2175
+ }
2176
+
2177
+ export default function App() {
2178
+ const [schema, setSchema] = useState<ApplicationSchema | null>(null);
2179
+ const [page, setPage] = useState<PageDefinition | null>(null);
2180
+ const [pageContext, setPageContext] = useState<EntityRecord | null>(null);
2181
+ const [error, setError] = useState<ApiFailure | null>(null);
2182
+ const [busy, setBusy] = useState(false);
2183
+ const initialLoadStarted = useRef(false);
2184
+ const invocationInFlight = useRef(false);
2185
+ const [revision, setRevision] = useState(0);
2186
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY));
2187
+ const [session, setSession] = useState<Session | null>(null);
2188
+ const [authRequired, setAuthRequired] = useState(false);
2189
+
2190
+ const handleError = useCallback((failure: unknown) => {
2191
+ const normalized = apiFailure(failure);
2192
+ if (normalized.status === 401) setAuthRequired(true);
2193
+ setError(normalized);
2194
+ }, []);
2195
+ const request: ApiRequest = useCallback(
2196
+ (path: string, options: RequestInit = {}) => api(path, options, token), [token]
2197
+ );
2198
+
2199
+ const openPage = async (
2200
+ name: string, context: EntityRecord | null = null, activeToken = token
2201
+ ): Promise<void> => {
2202
+ try {
2203
+ const value = await api<PageDefinition>(
2204
+ `/api/pages/${encodeURIComponent(name)}`, {}, activeToken
2205
+ );
2206
+ let resolvedContext = context;
2207
+ if (!resolvedContext && value.data_source?.name) {
2208
+ if (value.data_source.kind === 'nanoflow') {
2209
+ const source = nanoflows[value.data_source.name as keyof typeof nanoflows];
2210
+ if (!source) throw new Error(`Page data source nanoflow not found: ${value.data_source.name}`);
2211
+ const execution = await source.execute({});
2212
+ resolvedContext = isEntityRecord(execution.result)
2213
+ ? { ...execution.result, transient: true } : null;
2214
+ } else {
2215
+ const payload = await api<InvocationResult>(
2216
+ `/api/microflows/${encodeURIComponent(value.data_source.name)}`,
2217
+ { method: 'POST', body: '{}' }, activeToken
2218
+ );
2219
+ const candidate = payload.context || payload.result;
2220
+ resolvedContext = isEntityRecord(candidate)
2221
+ ? { ...candidate, transient: true } : null;
2222
+ }
2223
+ }
2224
+ setPage(value);
2225
+ setPageContext(resolvedContext);
2226
+ setRevision(current => current + 1);
2227
+ setError(null);
2228
+ } catch (failure) {
2229
+ handleError(failure);
2230
+ }
2231
+ };
2232
+
2233
+ const loadApplication = async (activeToken = token) => {
2234
+ try {
2235
+ if (activeToken) {
2236
+ const currentSession = await api<Session>('/api/session', {}, activeToken);
2237
+ setSession(currentSession);
2238
+ }
2239
+ const value = await api<ApplicationSchema>('/api/schema', {}, activeToken);
2240
+ setSchema(value);
2241
+ setAuthRequired(false);
2242
+ setError(null);
2243
+ const profile = value.navigation?.profiles?.find(item => item.kind === 'Responsive')
2244
+ || value.navigation?.profiles?.[0];
2245
+ const fallback = value.modules.flatMap(module => module.pages)[0]?.name;
2246
+ await openPage(profile?.home_page || fallback, null, activeToken);
2247
+ } catch (failure) {
2248
+ const normalized = apiFailure(failure);
2249
+ if (normalized.status === 401) {
2250
+ localStorage.removeItem(TOKEN_KEY);
2251
+ setToken(null);
2252
+ setSession(null);
2253
+ setAuthRequired(true);
2254
+ }
2255
+ setError(normalized);
2256
+ }
2257
+ };
2258
+
2259
+ useEffect(() => {
2260
+ if (initialLoadStarted.current) return;
2261
+ initialLoadStarted.current = true;
2262
+ loadApplication(token);
2263
+ }, []);
2264
+
2265
+ const login = async (username: string, password: string): Promise<void> => {
2266
+ setBusy(true);
2267
+ try {
2268
+ const authenticated = await api<LoginResponse>('/api/login', {
2269
+ method: 'POST', body: JSON.stringify({ username, password })
2270
+ });
2271
+ localStorage.setItem(TOKEN_KEY, authenticated.token);
2272
+ setToken(authenticated.token);
2273
+ await loadApplication(authenticated.token);
2274
+ } catch (failure) {
2275
+ setError(apiFailure(failure));
2276
+ } finally {
2277
+ setBusy(false);
2278
+ }
2279
+ };
2280
+
2281
+ const logout = async () => {
2282
+ try {
2283
+ await api('/api/logout', { method: 'POST' }, token);
2284
+ } catch (failure) {
2285
+ const normalized = apiFailure(failure);
2286
+ if (normalized.status !== 401) setError(normalized);
2287
+ } finally {
2288
+ localStorage.removeItem(TOKEN_KEY);
2289
+ setToken(null);
2290
+ setSession(null);
2291
+ setSchema(null);
2292
+ setPage(null);
2293
+ setAuthRequired(true);
2294
+ }
2295
+ };
2296
+
2297
+ const refreshPageContext = () => {
2298
+ if (!pageContext?.type || !pageContext?.id) return Promise.resolve();
2299
+ return request<EntityRecord>(
2300
+ `/api/entities/${encodeURIComponent(pageContext.type)}/${encodeURIComponent(pageContext.id)}`
2301
+ ).then(setPageContext).catch(handleError);
2302
+ };
2303
+
2304
+ const saveRecord: SaveRecord = useCallback((record, changes) => {
2305
+ if (!record?.type || !record?.id) return Promise.resolve(record);
2306
+ if (record.transient) {
2307
+ const updated: EntityRecord = {
2308
+ ...record, attributes: { ...record.attributes, ...changes }
2309
+ };
2310
+ setPageContext(current => current?.id === updated.id ? updated : current);
2311
+ setRevision(value => value + 1);
2312
+ setError(null);
2313
+ return Promise.resolve(updated);
2314
+ }
2315
+ return request<EntityRecord>(`/api/entities/${encodeURIComponent(record.type)}/${encodeURIComponent(record.id)}`, {
2316
+ method: 'PATCH', body: JSON.stringify(changes)
2317
+ }).then(updated => {
2318
+ setPageContext(current => current?.id === updated.id ? updated : current);
2319
+ setRevision(value => value + 1);
2320
+ setError(null);
2321
+ return updated;
2322
+ }).catch(failure => {
2323
+ const normalized = apiFailure(failure);
2324
+ if (normalized.status === 404) {
2325
+ const updated: EntityRecord = {
2326
+ ...record, attributes: { ...record.attributes, ...changes }
2327
+ };
2328
+ setPageContext(current => current?.id === updated.id ? updated : current);
2329
+ setRevision(value => value + 1);
2330
+ setError(null);
2331
+ return updated;
2332
+ }
2333
+ handleError(failure);
2334
+ return null;
2335
+ });
2336
+ }, [request, handleError]);
2337
+
2338
+ const markMutation = useCallback(() => setRevision(value => value + 1), []);
2339
+ const selectRecord: SelectRecord = useCallback(record => setPageContext(record), []);
2340
+
2341
+ const invoke: InvokeHandler = (name, parameters = {}, contextOverride = null) => {
2342
+ if (invocationInFlight.current) return Promise.resolve(null);
2343
+ invocationInFlight.current = true;
2344
+ setBusy(true);
2345
+ const activeContext = pageContext || contextOverride;
2346
+ return request<InvocationResult>(`/api/microflows/${encodeURIComponent(name)}`, {
2347
+ method: 'POST', body: JSON.stringify({
2348
+ ...parameters, ...(activeContext ? { __mxrb_context: activeContext } : {})
2349
+ })
2350
+ })
2351
+ .then(payload => {
2352
+ setRevision(value => value + 1);
2353
+ if (payload.context) setPageContext(payload.context);
2354
+ const navigation = (payload.effects || []).find(
2355
+ (effect): effect is OpenPageEffect => effect.type === 'open_page'
2356
+ );
2357
+ if (navigation?.page) {
2358
+ const context = Object.values(navigation.arguments || {})[0]
2359
+ || payload.context || payload.result || null;
2360
+ return openPage(navigation.page, context as EntityRecord | null).then(() => payload);
2361
+ }
2362
+ return payload.context ? Promise.resolve(payload) : refreshPageContext().then(() => payload);
2363
+ }).catch(handleError).finally(() => {
2364
+ invocationInFlight.current = false;
2365
+ setBusy(false);
2366
+ });
2367
+ };
2368
+
2369
+ const invokeNanoflow: InvokeHandler = async (
2370
+ name, parameters = {}, contextOverride = null
2371
+ ) => {
2372
+ setBusy(true);
2373
+ try {
2374
+ const definition = nanoflows[name as keyof typeof nanoflows];
2375
+ const resolvedParameters: RuntimeVariables = { ...parameters };
2376
+ const activeContext = contextOverride || pageContext;
2377
+ if (definition?.parameters?.length === 1 && !(definition.parameters[0] in resolvedParameters)
2378
+ && activeContext) {
2379
+ resolvedParameters[definition.parameters[0]] = activeContext;
2380
+ }
2381
+ if (!definition) throw new Error(`Nanoflow frontend not found: ${name}`);
2382
+ const execution = await definition.execute(resolvedParameters);
2383
+ for (const changed of execution.changes) {
2384
+ await saveRecord(changed, changed.attributes);
2385
+ }
2386
+ setError(null);
2387
+ return execution.result;
2388
+ } catch (failure) {
2389
+ setError(apiFailure(failure));
2390
+ return null;
2391
+ } finally {
2392
+ setBusy(false);
2393
+ }
2394
+ };
2395
+
2396
+ if (authRequired) return <Login onLogin={login} error={error} busy={busy} />;
2397
+ if (!schema || !page) return <main className="mxrb-loading">Loading application…</main>;
2398
+ const profile = schema.navigation?.profiles?.find(item => item.kind === 'Responsive')
2399
+ || schema.navigation?.profiles?.[0];
2400
+ const moduleName = page.name.split('.')[0];
2401
+ const PageComponent = pages[page.name as keyof typeof pages];
2402
+ const PageWidget = ({ widget, children }: PageWidgetProps) =>
2403
+ <Widget widget={widget} moduleName={moduleName} invoke={invoke}
2404
+ invokeNanoflow={invokeNanoflow} navigate={openPage}
2405
+ context={pageContext} pageContext={pageContext} revision={revision} schema={schema}
2406
+ request={request} saveRecord={saveRecord} onError={handleError}
2407
+ onMutation={markMutation} onSelectRecord={selectRecord}>{children}</Widget>;
2408
+
2409
+ return <div className={classes('mxrb-app-shell', 'mx-page', page.appearance_class)} style={inlineStyle(page.appearance_style)}>
2410
+ {profile?.items?.length || session ? <nav className="mxrb-navigation region-sidebar">
2411
+ {profile?.items?.length ? <ul>{navigationItems(profile.items, openPage)}</ul> : null}
2412
+ {session ? <button type="button" onClick={logout}>Sign out</button> : null}
2413
+ </nav> : null}
2414
+ {PageComponent ? <PageComponent busy={busy} Widget={PageWidget} /> :
2415
+ <main className="mxrb-page region-content" role="alert">
2416
+ Generated React page not found: {page.name}
2417
+ </main>}
2418
+ {error ? <aside className="mxrb-runtime-error" role="alert">
2419
+ <button type="button" onClick={() => setError(null)}>×</button>{error.message}
2420
+ </aside> : null}
2421
+ </div>;
2422
+ }
2423
+ JS
2424
+ end
2425
+
2426
+ def frontend_css
2427
+ <<~CSS
2428
+ :root { font: 16px/1.5 system-ui, sans-serif; }
2429
+ * { box-sizing: border-box; }
2430
+ html, body, #root { min-height: 100%; margin: 0; }
2431
+ button, input, textarea, select { font: inherit; }
2432
+ .mxrb-app-shell { min-height: 100vh; }
2433
+ .mxrb-page { min-height: 100vh; }
2434
+ .mxrb-page[aria-busy='true'] { cursor: progress; pointer-events: none; }
2435
+ .mxrb-navigation { position: fixed; z-index: 20; right: 1rem; top: 1rem; }
2436
+ .mxrb-navigation ul { display: flex; gap: .5rem; margin: 0; padding: 0; list-style: none; }
2437
+ .mxrb-navigation button { border: 1px solid currentColor; border-radius: .4rem; background: transparent; color: inherit; cursor: pointer; }
2438
+ .mxrb-text { display: block; }
2439
+ .mxrb-runtime-error { position: fixed; z-index: 50; right: 1rem; bottom: 1rem; max-width: 34rem; padding: 1rem; border-radius: .5rem; background: #7f1d1d; color: white; box-shadow: 0 .5rem 2rem #0008; }
2440
+ .mxrb-runtime-error button { float: right; border: 0; background: transparent; color: inherit; cursor: pointer; }
2441
+ .mxrb-loading { display: grid; min-height: 100vh; place-items: center; }
2442
+ .mxrb-login { display: grid; min-height: 100vh; place-items: center; padding: 1rem; }
2443
+ .mxrb-login form { display: grid; width: min(24rem, 100%); gap: 1rem; padding: 2rem; border: 1px solid #d1d5db; border-radius: .75rem; }
2444
+ .mxrb-login label { display: grid; gap: .25rem; }
2445
+ .mxrb-marketplace-widget { display: block; min-width: 0; }
2446
+ .mxrb-marketplace-widget img, .mxrb-marketplace-widget video,
2447
+ .mxrb-marketplace-chart svg { display: block; width: 100%; height: 12rem; max-width: 100%; }
2448
+ .mxrb-marketplace-chart { margin: .5rem 0; }
2449
+ .mxrb-marketplace-rating { display: inline-flex; border: 0; padding: 0; }
2450
+ .mxrb-marketplace-rating button { border: 0; background: transparent; cursor: pointer; }
2451
+ .mxrb-marketplace-badge { display: inline-block; padding: .125rem .5rem; border-radius: 999px; background: #e5e7eb; }
2452
+ .mxrb-grid-toolbar, .mxrb-grid-pagination { display: flex; align-items: center; gap: .5rem; margin-block: .5rem; }
2453
+ .mxrb-data-grid-runtime table { width: 100%; border-collapse: collapse; }
2454
+ .mxrb-data-grid-runtime th, .mxrb-data-grid-runtime td { padding: .5rem; border-bottom: 1px solid #d1d5db; text-align: left; }
2455
+ .mxrb-data-grid-runtime tbody tr { cursor: pointer; }
2456
+ .mxrb-data-grid-runtime tbody tr.is-selected { background: #dbeafe; }
2457
+ .mxrb-native-widget:empty { min-height: 1px; }
2458
+ CSS
2459
+ end
2460
+
2461
+ def readme
2462
+ <<~MARKDOWN
2463
+ # #{@project.name}
2464
+
2465
+ Executable Ruby application exported by MXRB.
2466
+
2467
+ ## Run
2468
+
2469
+ ```sh
2470
+ bundle install
2471
+ npm install --prefix frontend
2472
+ bundle exec mxrb run .
2473
+ ```
2474
+
2475
+ `mxrb run` supervises the Ruby API and React + TypeScript + Vite development server
2476
+ together. Vite proxies `/api` to Ruby, so both processes behave as one
2477
+ application. Use `--server-port` for Ruby and `--client-port` for Vite;
2478
+ `--api-port` and `--port` remain compatibility aliases. Generated models,
2479
+ DTOs, services, and pages live under `app/`.
2480
+ The generated `frontend/src/types.ts` covers domain records, pages, widgets,
2481
+ contexts, effects, and API payloads; `npm run typecheck` validates it strictly.
2482
+ Service bodies are ordinary Ruby; their default implementation delegates to
2483
+ MXRB's pure-Ruby interpreter.
2484
+
2485
+ Browser CRUD uses the same authenticated entity API as microflows. Configure
2486
+ users and bearer tokens through ignored environment files. External app/web
2487
+ services, mappings, XML imports, and documents are explicit Ruby integrations
2488
+ registered in `config/adapters.rb`; keep credentials out of that committed file.
2489
+
2490
+ ## Recompile to Mendix
2491
+
2492
+ ```sh
2493
+ bundle exec mxrb generate project.rb build/#{File.basename(@mpr_path)}
2494
+ ```
2495
+
2496
+ The complete bidirectional Mendix source remains under `.mxrb/mendix`. The
2497
+ `.mxrb/ruby-app.json` coverage manifest maps stable Mendix IDs to Ruby files and
2498
+ records artifacts that remain preserved natively.
2499
+ MARKDOWN
2500
+ end
2501
+
2502
+ def escape_html(value)
2503
+ value.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
2504
+ end
2505
+ end
2506
+ # rubocop:enable Metrics
2507
+ end
2508
+ end