mxrb 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -0,0 +1,1749 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+
7
+ module Mxrb
8
+ module RubyApp
9
+ # Projects a Mendix model into conventional executable Ruby source while
10
+ # retaining the complete Mendix-mode tree as the reversible sidecar.
11
+ # rubocop:disable Metrics
12
+ class Exporter
13
+ REST_STATUS_CODES = {
14
+ 'ok' => 200, 'created' => 201, 'accepted' => 202,
15
+ 'nocontent' => 204, 'movedpermanently' => 301, 'found' => 302,
16
+ 'badrequest' => 400, 'unauthorized' => 401, 'forbidden' => 403,
17
+ 'notfound' => 404, 'conflict' => 409, 'internalservererror' => 500
18
+ }.freeze
19
+ RUBY_KEYWORDS = %w[
20
+ alias and begin break case class def defined do else elsif end ensure false
21
+ for if in module next nil not or redo rescue retry return self super then
22
+ true undef unless until when while yield
23
+ ].freeze
24
+ RECORD_RESERVED = %w[attributes id initialize mendix_id mendix_name to_h type].freeze
25
+
26
+ def initialize(mpr_path, output_dir, mendix_sidecar:)
27
+ @mpr_path = File.expand_path(mpr_path)
28
+ @output_dir = File.expand_path(output_dir)
29
+ @mendix_sidecar = File.expand_path(mendix_sidecar)
30
+ end
31
+
32
+ def export!
33
+ FileUtils.mkdir_p(@output_dir)
34
+ runtime_mpr = copy_runtime_mpr
35
+ embedded_sources = read_embedded_sources
36
+ Mxrb.open(@mpr_path) do |project|
37
+ @project = project
38
+ @coverage = []
39
+ @nanoflow_entries = []
40
+ modules = project.modules.map { export_module(_1) }
41
+ @module_manifests = modules
42
+ write_support_files
43
+ copy_frontend_theme
44
+ restore_embedded_sources(embedded_sources)
45
+ write_manifest(project, modules, runtime_mpr)
46
+ end
47
+ @output_dir
48
+ ensure
49
+ @project = nil
50
+ @module_manifests = nil
51
+ end
52
+
53
+ private
54
+
55
+ def read_embedded_sources
56
+ mpr = IO::MprFile.open(@mpr_path, readonly: true)
57
+ mpr.ruby_app_sources
58
+ ensure
59
+ mpr&.close
60
+ end
61
+
62
+ def restore_embedded_sources(files)
63
+ files.each do |file|
64
+ contents = file.fetch(:contents)
65
+ checksum = Digest::SHA256.hexdigest(contents)
66
+ raise SerializationError, "embedded Ruby source checksum mismatch: #{file.fetch(:path)}" \
67
+ unless checksum == file.fetch(:sha256)
68
+
69
+ path = RubyApp.safe_source_path(@output_dir, file.fetch(:path))
70
+ FileUtils.mkdir_p(File.dirname(path))
71
+ File.binwrite(path, contents)
72
+ File.chmod(RubyApp.safe_source_mode(file[:mode], file.fetch(:path)), path)
73
+ end
74
+ end
75
+
76
+ def copy_runtime_mpr
77
+ directory = File.join(@output_dir, '.mxrb', 'runtime')
78
+ FileUtils.mkdir_p(directory)
79
+ destination = File.join(directory, File.basename(@mpr_path))
80
+ FileUtils.cp(@mpr_path, destination)
81
+ source_contents = File.join(File.dirname(@mpr_path), 'mprcontents')
82
+ FileUtils.cp_r(source_contents, directory, remove_destination: true) if File.directory?(source_contents)
83
+ destination
84
+ end
85
+
86
+ def export_module(mod)
87
+ namespace = ruby_constant(mod.name)
88
+ root = underscore(mod.name)
89
+ entities = mod.entities.map { export_entity(_1, mod, namespace, root) }
90
+ microflows = mod.microflows.map { export_service(_1, mod, namespace, root, :microflow) }
91
+ nanoflows = mod.nanoflows.map { export_nanoflow(_1, mod, root) }
92
+ pages = mod.pages.map { export_page(_1, mod, namespace, root) }
93
+ endpoints = export_endpoints(mod)
94
+ {
95
+ 'name' => mod.name, 'ruby_namespace' => namespace,
96
+ 'module_roles' => runtime_value(mod.module_roles),
97
+ 'models' => entities.reject { _1['dto'] },
98
+ 'dtos' => entities.select { _1['dto'] },
99
+ 'services' => microflows, 'nanoflows' => nanoflows, 'pages' => pages,
100
+ 'endpoints' => endpoints,
101
+ 'enumerations' => mod.enumerations.map { enumeration_manifest(mod, _1) },
102
+ 'associations' => mod.associations.map { association_manifest(mod, _1) },
103
+ 'scheduled_events' => mod.scheduled_events.map { runtime_value(_1) }
104
+ }
105
+ end
106
+
107
+ def export_entity(entity, mod, namespace, root)
108
+ dto = !entity.persistable && !entity.oql_view?
109
+ class_name = ruby_constant(entity.name, suffix: dto ? 'Dto' : nil)
110
+ base_name = underscore(entity.name)
111
+ base_name = "#{base_name}_dto" if dto && !base_name.end_with?('_dto')
112
+ category = dto ? 'dtos' : 'models'
113
+ relative = File.join('app', category, root, "#{base_name}.rb")
114
+ qualified = "#{mod.name}.#{entity.name}"
115
+ attributes = entity.attributes.map { attribute_manifest(_1) }
116
+ write(
117
+ relative,
118
+ entity_source(
119
+ namespace, class_name, qualified, entity.id, attributes,
120
+ dto:, persistable: entity.persistable == true
121
+ )
122
+ )
123
+ add_coverage(entity.id, qualified, dto ? 'dto' : 'model', relative, 'executable_bidirectional')
124
+ {
125
+ 'name' => qualified, 'id' => entity.id, 'ruby_class' => "#{namespace}::#{class_name}",
126
+ 'path' => relative, 'dto' => dto, 'persistable' => entity.persistable == true,
127
+ 'attributes' => attributes,
128
+ 'system_members' => runtime_value(entity.system_members || {}),
129
+ 'access_rules' => runtime_value(entity.access_rules || []),
130
+ 'lifecycle' => runtime_value(entity.respond_to?(:lifecycle) ? entity.lifecycle : [])
131
+ }
132
+ end
133
+
134
+ def association_manifest(mod, association)
135
+ entities = @project.modules.flat_map do |project_module|
136
+ project_module.entities.map do |entity|
137
+ [entity.id.to_s, "#{project_module.name}.#{entity.name}"]
138
+ end
139
+ end.to_h
140
+ {
141
+ 'name' => "#{mod.name}.#{association.name}", 'id' => association.id,
142
+ 'type' => association.association_type.to_s,
143
+ 'from_entity' => entities[association.from_entity_id.to_s] || association.from_entity_id.to_s,
144
+ 'to_entity' => entities[association.to_entity_id.to_s] || association.to_entity_id.to_s
145
+ }
146
+ end
147
+
148
+ def export_service(flow, mod, namespace, root, kind)
149
+ class_name = ruby_constant(flow.name)
150
+ relative = File.join('app', 'services', root, "#{underscore(flow.name)}.rb")
151
+ qualified = "#{mod.name}.#{flow.name}"
152
+ write(relative, service_source(namespace, class_name, qualified, flow.id))
153
+ add_coverage(flow.id, qualified, kind.to_s, relative, 'runtime_source_preserved')
154
+ {
155
+ 'name' => qualified, 'id' => flow.id,
156
+ 'ruby_class' => "#{namespace}::#{class_name}", 'path' => relative,
157
+ 'kind' => kind.to_s, 'parameters' => flow.parameters.map { flow_parameter_manifest(_1) },
158
+ 'allowed_module_roles' => flow.allowed_module_roles.map(&:to_s)
159
+ }
160
+ end
161
+
162
+ def flow_parameter_manifest(parameter)
163
+ variable_type = parameter['VariableType'] || parameter['Type'] || {}
164
+ {
165
+ 'name' => parameter['Name'].to_s,
166
+ 'type' => variable_type['$Type'].to_s,
167
+ 'entity' => variable_type['Entity'],
168
+ 'required' => parameter['IsRequired'] != false
169
+ }.compact
170
+ end
171
+
172
+ def export_endpoints(mod)
173
+ mod.infrastructure_documents.filter_map do |document|
174
+ next unless document[:type] == 'Rest$PublishedRestService'
175
+
176
+ rest_service_manifest(mod, document)
177
+ end
178
+ end
179
+
180
+ def rest_service_manifest(mod, document)
181
+ source = document.fetch(:doc)
182
+ operations = IO::BsonCodec.parse_array(source['Resources'])[:items].flat_map do |resource|
183
+ IO::BsonCodec.parse_array(resource['Operations'])[:items].map do |operation|
184
+ rest_operation_manifest(mod, source, resource, operation)
185
+ end
186
+ end
187
+ add_coverage(
188
+ document.fetch(:id), "#{mod.name}.#{document.fetch(:name)}", 'published_rest_service',
189
+ relative(@mendix_sidecar), 'executable_backend_route'
190
+ )
191
+ {
192
+ 'name' => "#{mod.name}.#{document.fetch(:name)}", 'path' => source['Path'].to_s,
193
+ 'version' => source['Version'].to_s, 'enable_cors' => source['EnableCors'] == true,
194
+ 'requires_authentication' => source['RequiresAuthentication'] == true,
195
+ 'operations' => operations
196
+ }
197
+ end
198
+
199
+ def rest_operation_manifest(mod, service, resource, operation)
200
+ microflow = operation['Microflow'].to_s
201
+ microflow = "#{mod.name}.#{microflow}" unless microflow.empty? || microflow.include?('.')
202
+ service_path = service['Path'].to_s.sub(%r{\A/+}, '').sub(%r{/+\z}, '')
203
+ operation_path = operation['Path'].to_s.sub(%r{\A/+}, '')
204
+ {
205
+ 'name' => resource['Name'].to_s, 'method' => operation['HttpMethod'].to_s.upcase,
206
+ 'path' => "/#{[service_path, operation_path].reject(&:empty?).join('/')}",
207
+ 'microflow' => microflow, 'success_status' => rest_success_status(operation['SuccessStatusCode'])
208
+ }
209
+ end
210
+
211
+ def rest_success_status(value)
212
+ return 200 if value.nil? || value.to_s.empty?
213
+
214
+ raw = value.is_a?(Hash) ? (value['Value'] || value['Name'] || value['$Type']) : value
215
+ integer = raw.to_s[/\d{3}/]
216
+ return integer.to_i if integer
217
+
218
+ REST_STATUS_CODES.fetch(raw.to_s.gsub(/[^A-Za-z]/, '').downcase, 200)
219
+ end
220
+
221
+ def export_nanoflow(flow, mod, root)
222
+ qualified = "#{mod.name}.#{flow.name}"
223
+ relative = File.join('frontend', 'src', 'nanoflows', root, "#{underscore(flow.name)}.ts")
224
+ plan = nanoflow_plan(flow, qualified)
225
+ write(
226
+ relative,
227
+ "import type { NanoflowPlan } from '../../types';\n\n" \
228
+ "export default #{JSON.pretty_generate(plan)} satisfies NanoflowPlan;\n"
229
+ )
230
+ entry = {
231
+ 'name' => qualified, 'id' => flow.id, 'path' => relative,
232
+ 'kind' => 'nanoflow', 'runtime' => 'frontend'
233
+ }
234
+ @nanoflow_entries << entry.merge('import_name' => "Nanoflow#{@nanoflow_entries.size}")
235
+ add_coverage(flow.id, qualified, 'nanoflow', relative, 'frontend_executable')
236
+ entry
237
+ end
238
+
239
+ def nanoflow_plan(flow, qualified)
240
+ {
241
+ 'name' => qualified, 'id' => flow.id,
242
+ 'parameters' => flow.parameters.filter_map { _1['Name'] if _1.is_a?(Hash) },
243
+ 'objects' => flow.objects.filter_map { nanoflow_object(_1) },
244
+ 'flows' => flow.flows.reject { _1['IsErrorHandler'] == true }.map do |edge|
245
+ {
246
+ 'origin' => native_identifier(edge['OriginPointer']),
247
+ 'destination' => native_identifier(edge['DestinationPointer']),
248
+ 'case' => nanoflow_case(edge)
249
+ }
250
+ end
251
+ }
252
+ end
253
+
254
+ def nanoflow_object(object)
255
+ return unless object.is_a?(Hash)
256
+
257
+ type = object['$Type'].to_s.delete_prefix('Microflows$')
258
+ result = { 'id' => native_identifier(object), 'type' => type }
259
+ result['return'] = object['ReturnValue'].to_s if type == 'EndEvent'
260
+ result['condition'] = object.dig('SplitCondition', 'Expression').to_s if type == 'ExclusiveSplit'
261
+ result['action'] = nanoflow_action(object['Action']) if type == 'ActionActivity'
262
+ result
263
+ end
264
+
265
+ def nanoflow_action(action)
266
+ return {} unless action.is_a?(Hash)
267
+
268
+ type = action['$Type'].to_s.delete_prefix('Microflows$').delete_suffix('Action')
269
+ result = { 'type' => type }
270
+ case type
271
+ when 'CreateVariable'
272
+ result.merge!('variable' => action['VariableName'].to_s, 'value' => action['InitialValue'].to_s)
273
+ when 'ChangeVariable'
274
+ result.merge!('variable' => action['ChangeVariableName'].to_s, 'value' => action['Value'].to_s)
275
+ when 'Change'
276
+ changes = native_items(action['Items']).map do |item|
277
+ member = (item['Attribute'].to_s.empty? ? item['Association'] : item['Attribute']).to_s
278
+ { 'member' => member.split(%r{[./]}).last, 'value' => item['Value'].to_s }
279
+ end
280
+ result.merge!('variable' => action['ChangeVariableName'].to_s, 'changes' => changes)
281
+ when 'LogMessage'
282
+ result['message'] = action.dig('MessageTemplate', 'Text').to_s
283
+ end
284
+ result
285
+ end
286
+
287
+ def nanoflow_case(edge)
288
+ value = native_items(edge['CaseValues']).first || edge['NewCaseValue'] || {}
289
+ value['$Type'] == 'Microflows$NoCase' ? '' : value['Value'].to_s
290
+ end
291
+
292
+ def native_items(value)
293
+ IO::BsonCodec.parse_array(value)[:items]
294
+ rescue StandardError
295
+ Array(value).drop(value.is_a?(Array) && value.first.is_a?(Integer) ? 1 : 0)
296
+ end
297
+
298
+ def native_identifier(value)
299
+ IO::BsonCodec.extract_id(value.is_a?(Hash) ? value['$ID'] : value).to_s
300
+ end
301
+
302
+ def export_page(page, mod, namespace, root)
303
+ class_name = ruby_constant(page.name, suffix: 'Page')
304
+ relative = File.join('app', 'pages', root, "#{underscore(page.name)}_page.rb")
305
+ qualified = "#{mod.name}.#{page.name}"
306
+ widgets = page.widgets.map { widget_manifest(_1) }
307
+ write(
308
+ relative,
309
+ page_source(namespace, class_name, qualified, page.id, page.title, widgets,
310
+ appearance_class: page.appearance_class,
311
+ appearance_style: page.appearance_style,
312
+ data_source: page.data_source)
313
+ )
314
+ add_coverage(page.id, qualified, 'page', relative, 'native_projection_source_preserved')
315
+ {
316
+ 'name' => qualified, 'id' => page.id, 'title' => page.title,
317
+ 'ruby_class' => "#{namespace}::#{class_name}", 'path' => relative,
318
+ 'appearance_class' => page.appearance_class,
319
+ 'appearance_style' => page.appearance_style,
320
+ 'data_source' => page.data_source,
321
+ 'allowed_module_roles' => page.allowed_module_roles.map(&:to_s),
322
+ 'widgets' => widgets
323
+ }
324
+ end
325
+
326
+ def attribute_manifest(attribute)
327
+ value = {
328
+ 'name' => attribute.name, 'ruby_name' => ruby_method_name(attribute.name),
329
+ 'type' => attribute.type.to_s, 'required' => attribute.required == true,
330
+ 'default' => attribute.default_value, 'id' => attribute.id
331
+ }
332
+ enumeration = native_identifier(attribute.respond_to?(:enumeration) ? attribute.enumeration : nil)
333
+ value['enumeration'] = enumeration unless enumeration.empty?
334
+ value
335
+ end
336
+
337
+ def enumeration_manifest(mod, enumeration)
338
+ name = enumeration['Name'].to_s
339
+ {
340
+ 'name' => "#{mod.name}.#{name}",
341
+ 'id' => native_identifier(enumeration['$ID']),
342
+ 'values' => native_items(enumeration['Values']).map do |value|
343
+ value_name = value['Name'].to_s
344
+ { 'name' => value_name, 'caption' => translated_caption(value['Caption'], value_name) }
345
+ end
346
+ }
347
+ end
348
+
349
+ def translated_caption(caption, fallback)
350
+ translation = native_items(caption.is_a?(Hash) ? caption['Items'] : nil).first
351
+ text = translation.is_a?(Hash) ? translation['Text'].to_s : ''
352
+ text.empty? ? fallback : text
353
+ end
354
+
355
+ def widget_manifest(widget)
356
+ options = runtime_value(widget.fetch(:options, {}))
357
+ value = { 'type' => runtime_widget_type(widget), 'name' => widget.fetch(:name, '').to_s }
358
+ value['options'] = options unless options.empty?
359
+ caption = options['caption']
360
+ value['caption'] = caption unless caption.to_s.empty?
361
+ events = runtime_value(widget.fetch(:events, []))
362
+ value['events'] = events unless events.empty?
363
+ children = Array(widget[:children]).map { widget_manifest(_1) }
364
+ value['children'] = children unless children.empty?
365
+ value
366
+ end
367
+
368
+ def runtime_widget_type(widget)
369
+ type = widget.fetch(:type).to_s
370
+ return type unless type == 'text_box'
371
+
372
+ path = widget.dig(:options, :attribute).to_s.tr('/', '.')
373
+ module_name, entity_name, attribute_name = path.split('.', 3)
374
+ return type unless attribute_name
375
+
376
+ entity = @project.modules.find { _1.name == module_name }
377
+ &.entities&.find { _1.name == entity_name }
378
+ attribute = entity&.attributes&.find { _1.name == attribute_name }
379
+ %i[integer long decimal autonumber].include?(attribute&.type) ? 'number_input' : type
380
+ end
381
+
382
+ def runtime_value(value)
383
+ case value
384
+ when Hash
385
+ value.each_with_object({}) do |(key, child), result|
386
+ next if key.to_s == 'deep_structure'
387
+
388
+ result[key.to_s] = runtime_value(child)
389
+ end
390
+ when Array then value.map { runtime_value(_1) }
391
+ when Symbol then value.to_s
392
+ when String, Numeric, TrueClass, FalseClass, NilClass then value
393
+ else value.to_s
394
+ end
395
+ end
396
+
397
+ def entity_source(namespace, class_name, qualified, id, attributes, dto:, persistable:)
398
+ declarations = attributes.map do |attribute|
399
+ " attribute :#{attribute.fetch('ruby_name')}, type: :#{attribute.fetch('type')}, " \
400
+ "mendix_name: #{attribute.fetch('name').inspect}, required: #{attribute.fetch('required')}, " \
401
+ "default: #{attribute.fetch('default').inspect}"
402
+ end
403
+ <<~RUBY
404
+ # frozen_string_literal: true
405
+
406
+ module #{namespace}
407
+ class #{class_name} < Mxrb::RubyApp::#{dto ? 'DTO' : 'Record'}
408
+ mendix_name #{qualified.inspect}, id: #{id.inspect}
409
+ persistence #{persistable}
410
+ #{declarations.join("\n")}
411
+ end
412
+ end
413
+ RUBY
414
+ end
415
+
416
+ def service_source(namespace, class_name, qualified, id)
417
+ <<~RUBY
418
+ # frozen_string_literal: true
419
+
420
+ module #{namespace}
421
+ class #{class_name} < Mxrb::RubyApp::Service
422
+ mendix_name #{qualified.inspect}, id: #{id.inspect}
423
+
424
+ def call(**arguments)
425
+ native_call(arguments)
426
+ end
427
+ end
428
+ end
429
+ RUBY
430
+ end
431
+
432
+ def page_source(namespace, class_name, qualified, id, title, widgets,
433
+ appearance_class:, appearance_style:, data_source:)
434
+ <<~RUBY
435
+ # frozen_string_literal: true
436
+
437
+ module #{namespace}
438
+ class #{class_name} < Mxrb::RubyApp::Page
439
+ mendix_name #{qualified.inspect}, id: #{id.inspect}
440
+ configure title: #{title.inspect}, widgets: #{widgets.inspect},
441
+ appearance_class: #{appearance_class.inspect},
442
+ appearance_style: #{appearance_style.inspect},
443
+ data_source: #{data_source.inspect}
444
+ end
445
+ end
446
+ RUBY
447
+ end
448
+
449
+ def write_support_files
450
+ write('Gemfile', gemfile)
451
+ write('.gitignore', ruby_gitignore)
452
+ write('.env.example', ruby_env_example)
453
+ %w[development qa staging production].each do |name|
454
+ write(File.join('config', 'environments', "#{name}.env.example"), environment_example(name))
455
+ end
456
+ write('project.rb', project_source)
457
+ write(File.join('config', 'application.rb'), application_source)
458
+ write(File.join('config', 'adapters.rb'), adapters_source)
459
+ write(File.join('bin', 'server'), server_source)
460
+ File.chmod(0o755, File.join(@output_dir, 'bin', 'server'))
461
+ write(File.join('frontend', 'package.json'), frontend_package)
462
+ write(File.join('frontend', 'vite.config.ts'), vite_config)
463
+ write(File.join('frontend', 'tsconfig.json'), frontend_tsconfig)
464
+ write(File.join('frontend', 'index.html'), frontend_index)
465
+ write(File.join('frontend', 'src', 'vite-env.d.ts'), "/// <reference types=\"vite/client\" />\n")
466
+ write(File.join('frontend', 'src', 'main.tsx'), frontend_main)
467
+ write(File.join('frontend', 'src', 'types.ts'), frontend_types)
468
+ write(File.join('frontend', 'src', 'nanoflows.ts'), frontend_nanoflows)
469
+ write(File.join('frontend', 'src', 'App.tsx'), frontend_app)
470
+ write(File.join('frontend', 'src', 'app.css'), frontend_css)
471
+ write('README.md', readme)
472
+ end
473
+
474
+ def copy_frontend_theme
475
+ root = File.join(@output_dir, 'frontend', 'src', 'mendix')
476
+ FileUtils.mkdir_p(root)
477
+ %w[theme themesource].each do |directory|
478
+ source = File.join(@mendix_sidecar, directory)
479
+ next unless File.directory?(source)
480
+
481
+ FileUtils.cp_r(source, File.join(root, directory), remove_destination: true)
482
+ end
483
+ fallback = File.join(root, 'theme', 'web', 'main.scss')
484
+ write(relative(fallback), '') unless File.file?(fallback)
485
+ end
486
+
487
+ def write_manifest(project, modules, runtime_mpr)
488
+ native_coverage(project)
489
+ payload = {
490
+ 'format_version' => 1, 'mode' => 'ruby',
491
+ 'project' => { 'name' => project.name, 'mendix_version' => project.mendix_version },
492
+ 'navigation' => runtime_value(project.navigation.to_h),
493
+ 'source' => {
494
+ 'name' => File.basename(@mpr_path),
495
+ 'sha256' => Digest::SHA256.file(@mpr_path).hexdigest
496
+ },
497
+ 'modules' => modules, 'coverage' => @coverage,
498
+ 'frontend' => {
499
+ 'framework' => 'react', 'language' => 'typescript', 'bundler' => 'vite',
500
+ 'source' => 'frontend/src', 'types' => 'frontend/src/types.ts',
501
+ 'typecheck' => 'npm run typecheck', 'build' => 'frontend/dist'
502
+ },
503
+ 'round_trip' => {
504
+ 'compiler' => 'project.rb',
505
+ 'mendix_project' => relative(File.join(@mendix_sidecar, 'project.rb')),
506
+ 'runtime_mpr' => relative(runtime_mpr),
507
+ 'editable_mendix_source' => relative(@mendix_sidecar)
508
+ }
509
+ }
510
+ preset = Preset.detect(@output_dir)
511
+ payload['ruby_stack'] = Preset.manifest(preset) if preset
512
+ write(MANIFEST_PATH, JSON.pretty_generate(payload) << "\n")
513
+ end
514
+
515
+ def native_coverage(project)
516
+ known_ids = @coverage.to_h { [_1.fetch('id').to_s, true] }
517
+ project.all_units.each do |unit|
518
+ next if known_ids[unit.fetch('UnitID').to_s]
519
+
520
+ document = project.parse_bson(unit)
521
+ name = document['Name'] || document['name'] || document['$Type']
522
+ add_coverage(
523
+ unit['UnitID'], name.to_s, document['$Type'].to_s,
524
+ relative(@mendix_sidecar), 'preserved_native'
525
+ )
526
+ end
527
+ end
528
+
529
+ def add_coverage(id, name, kind, path, status)
530
+ @coverage << {
531
+ 'id' => id.to_s, 'name' => name.to_s, 'kind' => kind.to_s,
532
+ 'ruby_path' => path, 'status' => status
533
+ }
534
+ end
535
+
536
+ def ruby_constant(value, suffix: nil)
537
+ parts = value.to_s.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
538
+ .split(/[^A-Za-z0-9]+/).reject(&:empty?)
539
+ constant = parts.map { _1[0].upcase + _1[1..].to_s.downcase }.join
540
+ constant = "Artifact#{constant}" if constant.empty? || constant.match?(/\A\d/)
541
+ constant = "#{constant}#{suffix}" if suffix && !constant.end_with?(suffix)
542
+ constant
543
+ end
544
+
545
+ def ruby_method_name(value)
546
+ name = underscore(value)
547
+ name = "field_#{name}" if name.match?(/\A\d/) || RUBY_KEYWORDS.include?(name) || RECORD_RESERVED.include?(name)
548
+ name.empty? ? 'field' : name
549
+ end
550
+
551
+ def underscore(value)
552
+ value.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
553
+ .gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
554
+ .gsub(/[^A-Za-z0-9]+/, '_').gsub(/\A_+|_+\z/, '').downcase
555
+ end
556
+
557
+ def relative(path)
558
+ Pathname.new(path).relative_path_from(Pathname.new(@output_dir)).to_s
559
+ end
560
+
561
+ def write(relative_path, contents)
562
+ path = File.join(@output_dir, relative_path)
563
+ FileUtils.mkdir_p(File.dirname(path))
564
+ File.write(path, contents)
565
+ end
566
+
567
+ def gemfile
568
+ <<~RUBY
569
+ # frozen_string_literal: true
570
+
571
+ source 'https://rubygems.org'
572
+ gem 'mxrb'
573
+ RUBY
574
+ end
575
+
576
+ def ruby_gitignore
577
+ <<~TEXT
578
+ .env
579
+ .env.*
580
+ !.env.example
581
+ config/environments/*.env
582
+ !config/environments/*.env.example
583
+ .mxrb/runtime/*.sqlite3
584
+ frontend/node_modules/
585
+ frontend/dist/
586
+ TEXT
587
+ end
588
+
589
+ def ruby_env_example
590
+ <<~TEXT
591
+ # Base values; environment profiles override this file.
592
+ MXRB_ENV=development
593
+ TEXT
594
+ end
595
+
596
+ def environment_example(name)
597
+ <<~TEXT
598
+ # Copy to #{name}.env. Process ENV has highest precedence.
599
+ MXRB_DATABASE_PATH=.mxrb/runtime/#{name}.sqlite3
600
+ MXRB_SHARED_STORE_PATH=.mxrb/runtime/#{name}-shared.sqlite3
601
+ MXRB_SESSION_TTL=3600
602
+ MXRB_SCHEDULER_LEASE_TTL=300
603
+ MXRB_ALLOW_DESTRUCTIVE_MIGRATIONS=false
604
+ MXRB_AUTH_TOKENS=
605
+ MXRB_USERS_JSON=
606
+ TEXT
607
+ end
608
+
609
+ def project_source
610
+ <<~RUBY
611
+ # frozen_string_literal: true
612
+
613
+ require 'mxrb'
614
+
615
+ mendix_version = #{@project.mendix_version.inspect}
616
+ Mxrb::RubyApp.compile(__dir__, mendix_version:)
617
+ RUBY
618
+ end
619
+
620
+ def application_source
621
+ <<~RUBY
622
+ # frozen_string_literal: true
623
+
624
+ require 'mxrb'
625
+
626
+ MXRB_APPLICATION_ROOT = File.expand_path('..', __dir__)
627
+ RUBY
628
+ end
629
+
630
+ def adapters_source
631
+ <<~RUBY
632
+ # frozen_string_literal: true
633
+
634
+ # Register only the integrations this application uses. Credentials
635
+ # belong in ignored environment files or a deployment secret manager.
636
+ #
637
+ # Mxrb::RubyApp::Registry.register_adapter(:app_service) do |name, document, variables|
638
+ # MyAppServiceClient.call(name, document:, variables:)
639
+ # end
640
+ #
641
+ # Legacy Custom Actions use Ruby implementations only. Register every
642
+ # permitted action explicitly by its qualified Mendix name.
643
+ #
644
+ # Mxrb::RubyApp::Registry.register_java_custom_action('MyModule.MyAction') do |arguments|
645
+ # MyRubyAction.call(arguments)
646
+ # end
647
+ #
648
+ # Supported kinds: :app_service, :web_service, :import_xml,
649
+ # :import_mapping, :export_mapping, and :document.
650
+ RUBY
651
+ end
652
+
653
+ def server_source
654
+ <<~RUBY
655
+ #!/usr/bin/env ruby
656
+ # frozen_string_literal: true
657
+
658
+ require_relative '../config/application'
659
+
660
+ host = ENV.fetch('HOST', '127.0.0.1')
661
+ port = Integer(ENV.fetch('PORT', '9292'))
662
+ frontend_port = Integer(ENV.fetch('FRONTEND_PORT', '5173'))
663
+ supervisor = Mxrb::RubyApp::Supervisor.new(
664
+ MXRB_APPLICATION_ROOT, host:, api_port: port, frontend_port:
665
+ )
666
+ puts "[mxrb] Ruby API: http://\#{host}:\#{port}"
667
+ puts "[mxrb] React + Vite: http://\#{host}:\#{frontend_port}"
668
+ trap('INT') { Thread.new { supervisor.shutdown } }
669
+ supervisor.start
670
+ RUBY
671
+ end
672
+
673
+ def frontend_package
674
+ JSON.pretty_generate(
675
+ 'name' => underscore(@project.name), 'private' => true, 'version' => '0.0.0',
676
+ 'type' => 'module',
677
+ 'scripts' => {
678
+ 'dev' => 'vite', 'typecheck' => 'tsc --noEmit',
679
+ 'build' => 'npm run typecheck && vite build', 'preview' => 'vite preview'
680
+ },
681
+ 'dependencies' => { 'react' => '^19.2.8', 'react-dom' => '^19.2.8' },
682
+ 'devDependencies' => {
683
+ '@types/node' => '^26.2.0', '@types/react' => '^19.2.18',
684
+ '@types/react-dom' => '^19.2.4', '@vitejs/plugin-react' => '^6.0.5',
685
+ 'sass-embedded' => '^1.90.0', 'typescript' => '^7.0.2', 'vite' => '^8.2.1'
686
+ }
687
+ ) << "\n"
688
+ end
689
+
690
+ def frontend_tsconfig
691
+ JSON.pretty_generate(
692
+ 'compilerOptions' => {
693
+ 'target' => 'ES2022', 'useDefineForClassFields' => true,
694
+ 'lib' => %w[ES2022 DOM DOM.Iterable], 'allowJs' => false,
695
+ 'skipLibCheck' => true, 'esModuleInterop' => true,
696
+ 'allowSyntheticDefaultImports' => true, 'strict' => true,
697
+ 'noImplicitAny' => false, 'useUnknownInCatchVariables' => false,
698
+ 'forceConsistentCasingInFileNames' => true, 'module' => 'ESNext',
699
+ 'moduleResolution' => 'Bundler', 'resolveJsonModule' => true,
700
+ 'isolatedModules' => true, 'noEmit' => true, 'jsx' => 'react-jsx'
701
+ },
702
+ 'include' => ['src', 'vite.config.ts']
703
+ ) << "\n"
704
+ end
705
+
706
+ def vite_config
707
+ <<~JS
708
+ import { defineConfig } from 'vite';
709
+ import react from '@vitejs/plugin-react';
710
+
711
+ const apiPort = process.env.MXRB_API_PORT || '9292';
712
+
713
+ export default defineConfig({
714
+ plugins: [react()],
715
+ server: { proxy: { '/api': `http://127.0.0.1:${apiPort}` } }
716
+ });
717
+ JS
718
+ end
719
+
720
+ def frontend_index
721
+ <<~HTML
722
+ <!doctype html>
723
+ <html lang="en">
724
+ <head>
725
+ <meta charset="utf-8">
726
+ <meta name="viewport" content="width=device-width,initial-scale=1">
727
+ <title>#{escape_html(@project.name)} · MXRB Ruby</title>
728
+ </head>
729
+ <body>
730
+ <div id="root"></div>
731
+ <script type="module" src="/src/main.tsx"></script>
732
+ </body>
733
+ </html>
734
+ HTML
735
+ end
736
+
737
+ def frontend_main
738
+ <<~JS
739
+ import React from 'react';
740
+ import { createRoot } from 'react-dom/client';
741
+ import App from './App';
742
+ import './app.css';
743
+ import './mendix/theme/web/main.scss';
744
+
745
+ const root = document.getElementById('root');
746
+ if (!root) throw new Error('MXRB frontend root element is missing');
747
+
748
+ createRoot(root).render(
749
+ <React.StrictMode><App /></React.StrictMode>
750
+ );
751
+ JS
752
+ end
753
+
754
+ def frontend_nanoflows
755
+ imports = @nanoflow_entries.map do |entry|
756
+ relative = entry.fetch('path').delete_prefix('frontend/src/')
757
+ "import #{entry.fetch('import_name')} from './#{relative.delete_suffix('.ts')}';"
758
+ end
759
+ mappings = @nanoflow_entries.map do |entry|
760
+ " #{entry.fetch('name').inspect}: #{entry.fetch('import_name')}"
761
+ end
762
+ <<~TS
763
+ import type { NanoflowPlan } from './types';
764
+
765
+ #{imports.join("\n")}
766
+
767
+ const nanoflows = {
768
+ #{mappings.join(",\n")}
769
+ } satisfies Record<string, NanoflowPlan>;
770
+
771
+ export default nanoflows;
772
+ TS
773
+ end
774
+
775
+ def frontend_types # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
776
+ modules = @module_manifests || []
777
+ entities = modules.flat_map { |mod| Array(mod['models']) + Array(mod['dtos']) }
778
+ pages = modules.flat_map { |mod| Array(mod['pages']) }
779
+ enumerations = modules.flat_map { |mod| Array(mod['enumerations']) }
780
+ enumeration_types = enumerations.map do |enumeration|
781
+ name = typescript_identifier(enumeration.fetch('name'))
782
+ values = enumeration.fetch('values', []).map { JSON.generate(_1.fetch('name')) }
783
+ "export type #{name} = #{values.empty? ? 'never' : values.join(' | ')};"
784
+ end
785
+ entity_types = entities.flat_map do |entity|
786
+ name = typescript_identifier(entity.fetch('name'))
787
+ attributes = entity.fetch('attributes', []).map do |attribute|
788
+ optional = attribute['required'] ? '' : '?'
789
+ type = typescript_attribute_type(attribute, enumerations)
790
+ " #{JSON.generate(attribute.fetch('name'))}#{optional}: #{type};"
791
+ end
792
+ [
793
+ "export interface #{name}Attributes {\n#{attributes.join("\n")}\n}",
794
+ "export type #{name}Record = EntityRecord<#{name}Attributes, #{JSON.generate(entity.fetch('name'))}>;"
795
+ ]
796
+ end
797
+ entity_map = entities.map do |entity|
798
+ " #{JSON.generate(entity.fetch('name'))}: #{typescript_identifier(entity.fetch('name'))}Record;"
799
+ end
800
+ page_types = pages.map do |page|
801
+ name = typescript_identifier(page.fetch('name'))
802
+ widgets = page.fetch('widgets', []).flat_map { frontend_widget_names(_1) }.uniq
803
+ widget_names = widgets.empty? ? 'never' : widgets.map { JSON.generate(_1) }.join(' | ')
804
+ "export type #{name}WidgetName = #{widget_names};"
805
+ end
806
+ page_map = pages.map do |page|
807
+ name = typescript_identifier(page.fetch('name'))
808
+ qualified = JSON.generate(page.fetch('name'))
809
+ " #{qualified}: PageDefinition<#{qualified}, #{name}WidgetName>;"
810
+ end
811
+ <<~TS
812
+ // Generated from the Mendix domain, page, widget, effect, and API contracts.
813
+ export type RuntimeScalar = string | number | boolean | null;
814
+ export type RuntimeValue = RuntimeScalar | EntityRecord | RuntimeValue[] | { [key: string]: RuntimeValue };
815
+ export type RuntimeVariables = Record<string, RuntimeValue | undefined>;
816
+
817
+ export interface EntityRecord<
818
+ Attributes extends object = Record<string, RuntimeValue | undefined>,
819
+ Name extends string = string
820
+ > {
821
+ id: string;
822
+ type: Name;
823
+ attributes: Attributes;
824
+ }
825
+
826
+ export interface WidgetEvent {
827
+ event: string;
828
+ kind: 'microflow' | 'nanoflow' | 'page' | string;
829
+ handler: string;
830
+ arguments?: Record<string, string>;
831
+ }
832
+
833
+ export interface WidgetDefinition<Name extends string = string> {
834
+ type: string;
835
+ name: Name;
836
+ caption?: string;
837
+ options?: Record<string, any>;
838
+ events?: WidgetEvent[];
839
+ children?: WidgetDefinition[];
840
+ }
841
+
842
+ export interface PageDefinition<Name extends string = string, WidgetName extends string = string> {
843
+ name: Name;
844
+ title: string;
845
+ appearance_class?: string;
846
+ appearance_style?: string;
847
+ widgets: WidgetDefinition<WidgetName>[];
848
+ }
849
+
850
+ export interface AttributeDefinition {
851
+ name: string;
852
+ type: string;
853
+ enumeration?: string;
854
+ }
855
+
856
+ export interface EntityDefinition {
857
+ name: string;
858
+ attributes?: AttributeDefinition[];
859
+ }
860
+
861
+ export interface EnumerationDefinition {
862
+ id: string;
863
+ name: string;
864
+ values: Array<{ name: string; caption: string }>;
865
+ }
866
+
867
+ export interface AssociationDefinition {
868
+ name: string;
869
+ from_entity: string;
870
+ to_entity: string;
871
+ type: string;
872
+ }
873
+
874
+ export interface NavigationItem {
875
+ page?: string;
876
+ caption?: Record<string, string>;
877
+ items?: NavigationItem[];
878
+ }
879
+
880
+ export interface NavigationProfile {
881
+ kind: string;
882
+ home_page?: string;
883
+ items?: NavigationItem[];
884
+ }
885
+
886
+ export interface RuntimeModule {
887
+ name: string;
888
+ models?: EntityDefinition[];
889
+ dtos?: EntityDefinition[];
890
+ pages: PageDefinition[];
891
+ enumerations?: EnumerationDefinition[];
892
+ associations?: AssociationDefinition[];
893
+ }
894
+
895
+ export interface ApplicationSchema {
896
+ project: { name: string; mendix_version: string };
897
+ navigation?: { profiles?: NavigationProfile[] };
898
+ modules: RuntimeModule[];
899
+ }
900
+
901
+ export interface OpenPageEffect {
902
+ type: 'open_page';
903
+ page: string;
904
+ arguments?: Record<string, RuntimeValue>;
905
+ }
906
+
907
+ export interface RuntimeEffect {
908
+ type: string;
909
+ [key: string]: RuntimeValue | undefined;
910
+ }
911
+
912
+ export interface InvocationResult {
913
+ result?: RuntimeValue;
914
+ context?: EntityRecord | null;
915
+ effects?: Array<OpenPageEffect | RuntimeEffect>;
916
+ }
917
+
918
+ export interface EntityCollectionResponse<T extends EntityRecord = EntityRecord> {
919
+ records: T[];
920
+ }
921
+
922
+ export interface Session {
923
+ id?: string;
924
+ username?: string;
925
+ [key: string]: RuntimeValue | undefined;
926
+ }
927
+
928
+ export interface LoginResponse {
929
+ token: string;
930
+ }
931
+
932
+ export interface ApiFailure extends Error {
933
+ status?: number;
934
+ }
935
+
936
+ export type ApiRequest = <T = any>(path: string, options?: RequestInit) => Promise<T>;
937
+
938
+ export interface NanoflowObject {
939
+ id: string;
940
+ type: string;
941
+ action?: Record<string, any>;
942
+ condition?: string;
943
+ return?: string;
944
+ }
945
+
946
+ export interface NanoflowEdge {
947
+ origin: string;
948
+ destination: string;
949
+ case?: string;
950
+ }
951
+
952
+ export interface NanoflowPlan {
953
+ name: string;
954
+ id: string;
955
+ parameters: string[];
956
+ objects: NanoflowObject[];
957
+ flows: NanoflowEdge[];
958
+ }
959
+
960
+ #{enumeration_types.join("\n")}
961
+
962
+ #{entity_types.join("\n\n")}
963
+
964
+ export interface EntityTypeMap {
965
+ #{entity_map.join("\n")}
966
+ }
967
+ export type EntityName = keyof EntityTypeMap;
968
+
969
+ #{page_types.join("\n")}
970
+
971
+ export interface PageTypeMap {
972
+ #{page_map.join("\n")}
973
+ }
974
+ export type PageName = keyof PageTypeMap;
975
+ TS
976
+ end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
977
+
978
+ def frontend_widget_names(widget)
979
+ [widget['name'].to_s, *Array(widget['children']).flat_map { frontend_widget_names(_1) }]
980
+ .reject(&:empty?)
981
+ end
982
+
983
+ def typescript_attribute_type(attribute, enumerations)
984
+ if attribute['type'] == 'enum'
985
+ enumeration = enumerations.find do |candidate|
986
+ [candidate['id'], candidate['name']].include?(attribute['enumeration'])
987
+ end
988
+ return typescript_identifier(enumeration['name']) if enumeration
989
+ end
990
+
991
+ {
992
+ 'boolean' => 'boolean', 'integer' => 'number', 'long' => 'number',
993
+ 'autonumber' => 'number', 'decimal' => 'number', 'datetime' => 'string',
994
+ 'binary' => 'string'
995
+ }.fetch(attribute['type'].to_s, 'string')
996
+ end
997
+
998
+ def typescript_identifier(value)
999
+ parts = value.to_s.split(/[^A-Za-z0-9]+/).reject(&:empty?)
1000
+ identifier = parts.map { _1[0].to_s.upcase + _1[1..].to_s }.join
1001
+ return 'MxrbType' if identifier.empty?
1002
+
1003
+ identifier.match?(/\A[A-Za-z_]/) ? identifier : "Mx#{identifier}"
1004
+ end
1005
+
1006
+ def frontend_app
1007
+ <<~'JS'
1008
+ import { useCallback, useEffect, useRef, useState } from 'react';
1009
+ import nanoflows from './nanoflows';
1010
+ import type {
1011
+ ApiFailure, ApiRequest, ApplicationSchema, EntityRecord, InvocationResult,
1012
+ LoginResponse, NanoflowPlan, NavigationItem, OpenPageEffect, PageDefinition, Session
1013
+ } from './types';
1014
+
1015
+ const TOKEN_KEY = 'mxrb.session.token';
1016
+ const api = async <T = any>(path: string, options: RequestInit = {}, token: string | null = null): Promise<T> => {
1017
+ const headers = new Headers(options.headers);
1018
+ headers.set('Content-Type', 'application/json');
1019
+ if (token) headers.set('Authorization', `Bearer ${token}`);
1020
+ const response = await fetch(path, {
1021
+ ...options, headers
1022
+ });
1023
+ const payload = await response.json();
1024
+ if (!response.ok) {
1025
+ const error: ApiFailure = new Error(payload.error?.message || `HTTP ${response.status}`);
1026
+ error.status = response.status;
1027
+ throw error;
1028
+ }
1029
+ return payload;
1030
+ };
1031
+
1032
+ const classes = (...values) => values.filter(Boolean).join(' ');
1033
+ const attributes = object => object?.attributes || {};
1034
+ const memberName = value => (value || '').split(/[./]/).pop();
1035
+ const entityCollectionPath = (entity, association, context) => {
1036
+ const path = `/api/entities/${encodeURIComponent(entity)}`;
1037
+ if (!association || !context?.type || !context?.id) return path;
1038
+ const query = new URLSearchParams({
1039
+ association, context_type: context.type, context_id: context.id
1040
+ });
1041
+ return `${path}?${query}`;
1042
+ };
1043
+ const expressionValue = (source, context, variables = {}) => {
1044
+ const text = (source || '').trim();
1045
+ const wrapped = text.match(/^toString\((.*)\)$/);
1046
+ if (wrapped) return String(expressionValue(wrapped[1], context, variables) ?? '');
1047
+ if (text === '$currentObject') return context;
1048
+ const variable = text.match(/^\$([A-Za-z_]\w*)$/);
1049
+ if (variable) return variables[variable[1]] ?? context;
1050
+ const member = text.match(/^\$([A-Za-z_]\w*)\/([A-Za-z_][\w.]*)$/);
1051
+ if (member) return attributes(variables[member[1]] ?? context)[memberName(member[2])];
1052
+ if (text === 'empty') return null;
1053
+ if (text === 'true') return true;
1054
+ if (text === 'false') return false;
1055
+ if (/^'.*'$/.test(text)) return text.slice(1, -1).replaceAll("''", "'");
1056
+ return text;
1057
+ };
1058
+ const conditionValue = (source, context, variables = {}) => {
1059
+ const text = (source || '').trim().replace(/^\((.*)\)$/, '$1');
1060
+ const orParts = text.split(/\s+or\s+/);
1061
+ if (orParts.length > 1) return orParts.some(part => conditionValue(part, context, variables));
1062
+ const andParts = text.split(/\s+and\s+/);
1063
+ if (andParts.length > 1) return andParts.every(part => conditionValue(part, context, variables));
1064
+ const comparison = text.match(/^(.*?)\s*(=|!=|>=|<=|>|<)\s*(.*?)$/);
1065
+ if (!comparison) return Boolean(expressionValue(text, context, variables));
1066
+ const left = expressionValue(comparison[1], context, variables);
1067
+ const right = expressionValue(comparison[3], context, variables);
1068
+ return ({
1069
+ '=': left === right, '!=': left !== right, '>': left > right,
1070
+ '<': left < right, '>=': left >= right, '<=': left <= right
1071
+ })[comparison[2]];
1072
+ };
1073
+ const nanoflowValue = (source, context, variables = {}) => {
1074
+ const text = (source || '').trim();
1075
+ if (/\s(?:and|or)\s|(?:=|!=|>=|<=|>|<)/.test(text)) {
1076
+ return conditionValue(text, context, variables);
1077
+ }
1078
+ return expressionValue(text, context, variables);
1079
+ };
1080
+ const isVisible = (source, context) => {
1081
+ return !source || conditionValue(source, context);
1082
+ };
1083
+ const dynamicClass = (source, context) => {
1084
+ let text = source || '';
1085
+ text = text.replace(/\(?if\s+(.+?)\s+then\s+'([^']*)'\s+else\s+'([^']*)'\)?/g,
1086
+ (_, condition, yes, no) => conditionValue(condition, context) ? yes : no);
1087
+ text = text.replace(/toString\(\$[A-Za-z_]\w*\/([A-Za-z_][\w.]*)\)/g,
1088
+ (_, member) => String(attributes(context)[memberName(member)] ?? ''));
1089
+ text = text.replace(/\$[A-Za-z_]\w*\/([A-Za-z_][\w.]*)/g,
1090
+ (_, member) => attributes(context)[memberName(member)] ?? '');
1091
+ return text.replace(/[+()']/g, ' ').replace(/\s+/g, ' ').trim();
1092
+ };
1093
+ const caption = (widget, options, context) => {
1094
+ let value = options.caption || widget.caption || widget.name;
1095
+ (options.parameters || []).forEach((parameter, index) => {
1096
+ value = value.replaceAll(`{${index + 1}}`, expressionValue(parameter, context) ?? '');
1097
+ });
1098
+ return value;
1099
+ };
1100
+ const inlineStyle = value => Object.fromEntries((value || '').split(';').filter(Boolean).map(rule => {
1101
+ const [property, ...parts] = rule.split(':');
1102
+ const name = property.trim().replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1103
+ return [name, parts.join(':').trim()];
1104
+ }));
1105
+
1106
+ const eventArguments = (event, context) => Object.fromEntries(
1107
+ Object.entries(event?.arguments || {}).map(([name, expression]) => [name, expressionValue(expression, context)])
1108
+ );
1109
+
1110
+ const recordValue = (record, attribute) => attributes(record)[memberName(attribute)];
1111
+ const displayValue = value => {
1112
+ if (value == null) return '';
1113
+ if (Array.isArray(value)) return value.map(displayValue).join(', ');
1114
+ if (value?.attributes) return Object.values(value.attributes).find(item =>
1115
+ ['string', 'number', 'boolean'].includes(typeof item)) ?? value.id;
1116
+ if (typeof value === 'object') return value.id || JSON.stringify(value);
1117
+ return String(value);
1118
+ };
1119
+
1120
+ const sortRecords = (
1121
+ records: EntityRecord[], sortings: Array<{ attribute: string; direction?: string }> = []
1122
+ ) => {
1123
+ const result = records.slice();
1124
+ sortings.slice().reverse().forEach(sorting => {
1125
+ const member = memberName(sorting.attribute);
1126
+ const direction = sorting.direction === 'Descending' ? -1 : 1;
1127
+ result.sort((left, right) => direction * String(
1128
+ left.attributes?.[member] ?? ''
1129
+ ).localeCompare(String(right.attributes?.[member] ?? ''), undefined, { numeric: true }));
1130
+ });
1131
+ return result;
1132
+ };
1133
+
1134
+ const executeNanoflow = async (
1135
+ plan: NanoflowPlan | undefined, parameters: Record<string, any>
1136
+ ): Promise<{ result: any; variables: Record<string, any> }> => {
1137
+ if (!plan) throw new Error('Nanoflow frontend not found');
1138
+ const variables = structuredClone(parameters || {});
1139
+ const objects = Object.fromEntries(plan.objects.map(object => [object.id, object]));
1140
+ const outgoing = {};
1141
+ plan.flows.forEach(flow => { (outgoing[flow.origin] ||= []).push(flow); });
1142
+ let current = plan.objects.find(object => object.type === 'StartEvent');
1143
+ for (let step = 0; step < 10000; step += 1) {
1144
+ if (!current) throw new Error(`Nanoflow ${plan.name} points to a missing object`);
1145
+ if (current.type === 'EndEvent') {
1146
+ return { result: expressionValue(current.return, null, variables), variables };
1147
+ }
1148
+ if (current.type === 'ActionActivity') {
1149
+ const action = current.action || {};
1150
+ if (action.type === 'LogMessage') console.info(`[nanoflow] ${action.message || plan.name}`);
1151
+ else if (action.type === 'CreateVariable' || action.type === 'ChangeVariable') {
1152
+ variables[action.variable] = nanoflowValue(action.value, null, variables);
1153
+ } else if (action.type === 'Change') {
1154
+ const object = variables[action.variable];
1155
+ if (!object?.attributes) throw new Error(`Nanoflow object $${action.variable} is missing`);
1156
+ (action.changes || []).forEach(change => {
1157
+ object.attributes[change.member] = nanoflowValue(change.value, object, variables);
1158
+ });
1159
+ } else throw new Error(`Unsupported frontend nanoflow action: ${action.type}`);
1160
+ }
1161
+ const edges = outgoing[current.id] || [];
1162
+ let edge = edges[0];
1163
+ if (current.type === 'ExclusiveSplit') {
1164
+ const value = String(conditionValue(current.condition, null, variables));
1165
+ edge = edges.find(item => item.case === value) || edges.find(item => !item.case);
1166
+ }
1167
+ if (!edge) throw new Error(`Nanoflow ${plan.name} stops at ${current.type}`);
1168
+ current = objects[edge.destination];
1169
+ }
1170
+ throw new Error(`Nanoflow ${plan.name} exceeded 10000 steps`);
1171
+ };
1172
+
1173
+ function BoundField({ widget, record, schema, request, saveRecord, onChanged, onError }) {
1174
+ const options = widget.options || {};
1175
+ const member = memberName(options.attribute || widget.name);
1176
+ const kind = widget.type;
1177
+ const value = recordValue(record, member);
1178
+ const [draft, setDraft] = useState(kind === 'check_box' ? Boolean(value) : (value ?? ''));
1179
+ const [references, setReferences] = useState<EntityRecord[]>([]);
1180
+ const associations = (schema?.modules || []).flatMap(module => module.associations || []);
1181
+ const association = associations.find(item =>
1182
+ item.name === options.attribute || memberName(item.name) === member
1183
+ );
1184
+ const referenceEntity = options.entity || options.target_entity || association?.to_entity;
1185
+ const entityDefinition = (schema?.modules || []).flatMap(module =>
1186
+ [...(module.models || []), ...(module.dtos || [])]
1187
+ ).find(entity => entity.name === record?.type);
1188
+ const attributeDefinition = (entityDefinition?.attributes || []).find(attribute =>
1189
+ attribute.name === member
1190
+ );
1191
+ const enumeration = (schema?.modules || []).flatMap(module =>
1192
+ module.enumerations || []
1193
+ ).find(item => item.id === attributeDefinition?.enumeration
1194
+ || item.name === attributeDefinition?.enumeration);
1195
+
1196
+ useEffect(() => {
1197
+ setDraft(kind === 'check_box' ? Boolean(value) : (value?.id || value || ''));
1198
+ }, [kind, record?.id, value?.id, value]);
1199
+
1200
+ useEffect(() => {
1201
+ if (kind !== 'reference_selector' || !referenceEntity) return;
1202
+ request(`/api/entities/${encodeURIComponent(referenceEntity)}`)
1203
+ .then(payload => setReferences(payload.records || [])).catch(onError);
1204
+ }, [kind, referenceEntity, request, onError]);
1205
+
1206
+ const persist = next => {
1207
+ setDraft(next);
1208
+ if (!record?.type || !record?.id || !member) return Promise.resolve(record);
1209
+ let normalized = next;
1210
+ if (kind === 'number_input') normalized = next === '' ? null : Number(next);
1211
+ if (kind === 'reference_selector') {
1212
+ normalized = references.find(item => item.id === next) || null;
1213
+ }
1214
+ return saveRecord(record, { [member]: normalized }).then(updated => {
1215
+ if (!updated) return null;
1216
+ if (onChanged) return onChanged(updated);
1217
+ return updated;
1218
+ });
1219
+ };
1220
+ const disabled = !record?.id || !member || options.read_only === true;
1221
+
1222
+ if (kind === 'text_area') {
1223
+ return <textarea rows={options.lines || 4} value={draft} disabled={disabled}
1224
+ onChange={event => setDraft(event.target.value)} onBlur={() => persist(draft)} />;
1225
+ }
1226
+ if (kind === 'check_box') {
1227
+ return <input type="checkbox" checked={Boolean(draft)} disabled={disabled}
1228
+ onChange={event => persist(event.target.checked)} />;
1229
+ }
1230
+ if (kind === 'drop_down' || kind === 'reference_selector') {
1231
+ const enumValues = (enumeration?.values || []).map(item => ({
1232
+ id: item.name, label: item.caption || item.name
1233
+ }));
1234
+ const configuredValue = options.values || options.items || options.options || enumValues;
1235
+ const configured = Array.isArray(configuredValue) ? configuredValue : Object.values(configuredValue);
1236
+ const choices = kind === 'reference_selector' ? references : configured.map(item =>
1237
+ typeof item === 'object' ? item : { id: item, label: item }
1238
+ );
1239
+ return <select value={draft} disabled={disabled} onChange={event => persist(event.target.value)}>
1240
+ <option value="">—</option>
1241
+ {draft && !choices.some(item => (item.id || item.value) === draft) ?
1242
+ <option value={draft}>{displayValue(value)}</option> : null}
1243
+ {choices.map(item => <option key={item.id || item.value} value={item.id || item.value}>
1244
+ {displayValue(item.label || item.caption || recordValue(item, options.display_attribute)
1245
+ || item.id || item.value)}
1246
+ </option>)}
1247
+ </select>;
1248
+ }
1249
+ const inputType = kind === 'date_picker' ? 'date' : kind === 'number_input' ? 'number' : 'text';
1250
+ const inputValue = inputType === 'date' ? String(draft).slice(0, 10) : draft;
1251
+ return <input type={inputType} value={inputValue} disabled={disabled}
1252
+ onChange={event => setDraft(event.target.value)} onBlur={() => persist(draft)} />;
1253
+ }
1254
+
1255
+ function DataGrid({ widget, request, pageContext, revision, onError, onMutation,
1256
+ onRowAction, onSelectRecord }) {
1257
+ const options = widget.options || {};
1258
+ const [records, setRecords] = useState<EntityRecord[]>([]);
1259
+ const [pageNumber, setPageNumber] = useState(0);
1260
+ const [reload, setReload] = useState(0);
1261
+ const [selected, setSelected] = useState<EntityRecord | null>(null);
1262
+ const [loading, setLoading] = useState(false);
1263
+ const pageSize = Math.max(1, Number(options.page_size || options.pageSize || 20));
1264
+
1265
+ useEffect(() => {
1266
+ if (!options.entity) return;
1267
+ setLoading(true);
1268
+ request(entityCollectionPath(options.entity, options.association, pageContext)).then(payload => {
1269
+ let values = payload.records || [];
1270
+ values = sortRecords(values, options.sort || []);
1271
+ setRecords(values);
1272
+ setPageNumber(current => Math.min(current, Math.max(0, Math.ceil(values.length / pageSize) - 1)));
1273
+ }).catch(onError).finally(() => setLoading(false));
1274
+ }, [options.entity, options.association, pageContext?.type, pageContext?.id,
1275
+ pageSize, reload, revision, request, onError]);
1276
+
1277
+ const mutate = operation => operation.then(result => {
1278
+ setReload(value => value + 1);
1279
+ onMutation();
1280
+ return result;
1281
+ }).catch(onError);
1282
+ const createRecord = () => mutate(request(`/api/entities/${encodeURIComponent(options.entity)}`, {
1283
+ method: 'POST', body: '{}'
1284
+ })).then(record => {
1285
+ setSelected(record);
1286
+ if (record) onSelectRecord(record);
1287
+ });
1288
+ const deleteRecord = () => selected && mutate(request(
1289
+ `/api/entities/${encodeURIComponent(options.entity)}/${encodeURIComponent(selected.id)}`,
1290
+ { method: 'DELETE' }
1291
+ )).then(() => { setSelected(null); onSelectRecord(null); });
1292
+ const toolbar = options.toolbar?.buttons || [];
1293
+ const pageCount = Math.max(1, Math.ceil(records.length / pageSize));
1294
+ const visible = records.slice(pageNumber * pageSize, (pageNumber + 1) * pageSize);
1295
+
1296
+ return <div className={classes('mxrb-data-grid-runtime', loading && 'is-loading')}
1297
+ data-entity={options.entity || ''}>
1298
+ <div className="mxrb-grid-toolbar">
1299
+ {toolbar.some(button => button.type === 'new') ? <button type="button" onClick={createRecord}>New</button> : null}
1300
+ {toolbar.some(button => button.type === 'delete') ? <button type="button" disabled={!selected} onClick={deleteRecord}>Delete</button> : null}
1301
+ <button type="button" onClick={() => setReload(value => value + 1)}>Reload</button>
1302
+ </div>
1303
+ <table><thead><tr>{(options.columns || []).map(column =>
1304
+ <th key={column.name || column.attribute}>{column.caption || column.name}</th>)}</tr></thead>
1305
+ <tbody>{visible.map(record => <tr key={record.id}
1306
+ className={selected?.id === record.id ? 'is-selected' : ''}
1307
+ onClick={() => { setSelected(record); onSelectRecord(record); onRowAction(record); }}>
1308
+ {(options.columns || []).map(column => <td key={column.name || column.attribute}>
1309
+ {displayValue(recordValue(record, column.attribute || column.name))}
1310
+ </td>)}
1311
+ </tr>)}</tbody></table>
1312
+ <div className="mxrb-grid-pagination">
1313
+ <button type="button" disabled={pageNumber === 0}
1314
+ onClick={() => setPageNumber(value => value - 1)}>Previous</button>
1315
+ <span>Page {pageNumber + 1} of {pageCount} · {records.length} rows</span>
1316
+ <button type="button" disabled={pageNumber + 1 >= pageCount}
1317
+ onClick={() => setPageNumber(value => value + 1)}>Next</button>
1318
+ </div>
1319
+ </div>;
1320
+ }
1321
+
1322
+ function Gallery({ widget, moduleName, invoke, invokeNanoflow, navigate, pageContext, revision,
1323
+ schema, request, saveRecord, onError, onMutation, onSelectRecord }) {
1324
+ const options = widget.options || {};
1325
+ const [records, setRecords] = useState<EntityRecord[]>([]);
1326
+ useEffect(() => {
1327
+ if (!options.entity) return;
1328
+ request(entityCollectionPath(options.entity, options.association, pageContext)).then(payload => {
1329
+ setRecords(sortRecords(payload.records || [], options.sort || []));
1330
+ }).catch(onError);
1331
+ }, [options.entity, options.association, pageContext?.type, pageContext?.id,
1332
+ revision, request, onError]);
1333
+ return <div className={classes('mxrb-widget', 'mxrb-gallery', options.class)}
1334
+ data-widget-name={widget.name} data-widget-type={widget.type}>
1335
+ <div className="mxrb-gallery-items gallery-items">
1336
+ {records.map(record => <div className="mxrb-gallery-item gallery-item" key={record.id}>
1337
+ {(widget.children || []).map((child, index) => <Widget key={`${child.name}-${index}`}
1338
+ widget={child} moduleName={moduleName} invoke={invoke} invokeNanoflow={invokeNanoflow}
1339
+ navigate={navigate} context={record} schema={schema} request={request}
1340
+ saveRecord={saveRecord} onError={onError} onMutation={onMutation}
1341
+ onSelectRecord={onSelectRecord}
1342
+ pageContext={pageContext} revision={revision} />)}
1343
+ </div>)}
1344
+ </div>
1345
+ </div>;
1346
+ }
1347
+
1348
+ function Widget({ widget, moduleName, invoke, invokeNanoflow, navigate,
1349
+ context, pageContext, revision, schema, request, saveRecord,
1350
+ onError, onMutation, onSelectRecord }) {
1351
+ const options = widget.options || {};
1352
+ if (!isVisible(options.visible, context || pageContext)) return null;
1353
+ const className = classes('mxrb-widget', `mxrb-${widget.type}`, options.class,
1354
+ dynamicClass(options.dynamic_class, context || pageContext));
1355
+ const runtimeProps = {
1356
+ 'data-widget-name': widget.name,
1357
+ 'data-widget-type': widget.type
1358
+ };
1359
+ const children = (widget.children || []).map((child, index) =>
1360
+ <Widget key={`${child.name}-${index}`} widget={child} moduleName={moduleName} invoke={invoke}
1361
+ invokeNanoflow={invokeNanoflow} navigate={navigate}
1362
+ context={context} pageContext={pageContext} revision={revision} schema={schema}
1363
+ request={request} saveRecord={saveRecord} onError={onError} onMutation={onMutation}
1364
+ onSelectRecord={onSelectRecord} />);
1365
+ const click = (widget.events || []).find(event => event.event === 'on_click');
1366
+ const change = (widget.events || []).find(event => event.event === 'on_change');
1367
+ const runEvent = (event, eventContext = context || pageContext) => {
1368
+ if (!event) return Promise.resolve();
1369
+ const handler = event.handler.includes('.') ? event.handler : `${moduleName}.${event.handler}`;
1370
+ const parameters = eventArguments(event, eventContext);
1371
+ if (event.kind === 'nanoflow') return invokeNanoflow(handler, parameters, eventContext);
1372
+ if (event.kind === 'page') {
1373
+ const targetContext = Object.values(parameters)[0] || pageContext || context || null;
1374
+ return navigate(handler, targetContext);
1375
+ }
1376
+ return invoke(handler, parameters, eventContext);
1377
+ };
1378
+ const onClick = click ? () => runEvent(click) : undefined;
1379
+ const onChanged = updated => runEvent(change, updated);
1380
+
1381
+ switch (widget.type) {
1382
+ case 'container':
1383
+ return <div {...runtimeProps} className={className} style={inlineStyle(options.style)} onClick={onClick}
1384
+ role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined}>
1385
+ {children}
1386
+ </div>;
1387
+ case 'text':
1388
+ return <span {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}</span>;
1389
+ case 'button':
1390
+ return <button {...runtimeProps} type="button" className={className} onClick={onClick}>
1391
+ {caption(widget, options, context || pageContext)}
1392
+ </button>;
1393
+ case 'text_area':
1394
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1395
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
1396
+ request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
1397
+ </label>;
1398
+ case 'text_box':
1399
+ case 'number_input':
1400
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1401
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
1402
+ request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
1403
+ </label>;
1404
+ case 'check_box':
1405
+ return <label {...runtimeProps} className={className}>
1406
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
1407
+ request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
1408
+ {caption(widget, options, context || pageContext)}
1409
+ </label>;
1410
+ case 'date_picker':
1411
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1412
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
1413
+ request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
1414
+ </label>;
1415
+ case 'drop_down':
1416
+ case 'reference_selector':
1417
+ return <label {...runtimeProps} className={className}>{caption(widget, options, context || pageContext)}
1418
+ <BoundField widget={widget} record={context || pageContext} schema={schema}
1419
+ request={request} saveRecord={saveRecord} onChanged={onChanged} onError={onError} />
1420
+ </label>;
1421
+ case 'tab_control':
1422
+ return <div {...runtimeProps} className={className}>{(options.tabs || []).map(tab =>
1423
+ <section key={tab.name}><h3>{tab.caption || tab.name}</h3>
1424
+ {(tab.widgets || []).map((child, index) =>
1425
+ <Widget key={`${child.name}-${index}`} widget={child} moduleName={moduleName} invoke={invoke}
1426
+ invokeNanoflow={invokeNanoflow} navigate={navigate}
1427
+ context={context} pageContext={pageContext} revision={revision} schema={schema}
1428
+ request={request} saveRecord={saveRecord} onError={onError} onMutation={onMutation}
1429
+ onSelectRecord={onSelectRecord} />)}
1430
+ </section>)}</div>;
1431
+ case 'data_grid':
1432
+ return <div {...runtimeProps} className={className}><DataGrid widget={widget} request={request}
1433
+ pageContext={pageContext} revision={revision} onError={onError}
1434
+ onMutation={onMutation} onSelectRecord={onSelectRecord}
1435
+ onRowAction={record => runEvent(change || click, record)} />
1436
+ </div>;
1437
+ case 'gallery':
1438
+ return <Gallery widget={widget} moduleName={moduleName} invoke={invoke}
1439
+ invokeNanoflow={invokeNanoflow} navigate={navigate}
1440
+ pageContext={pageContext} revision={revision} schema={schema} request={request}
1441
+ saveRecord={saveRecord} onError={onError} onMutation={onMutation}
1442
+ onSelectRecord={onSelectRecord} />;
1443
+ case 'native_widget':
1444
+ return <div {...runtimeProps} className={classes(className, 'mxrb-native-widget')}
1445
+ data-native-type={options.native_type || ''} role="alert">
1446
+ Could not render widget {widget.name}: unsupported native type {options.native_type || 'unknown'}
1447
+ </div>;
1448
+ default:
1449
+ return <div {...runtimeProps} className={className} role="alert">
1450
+ Could not render widget {widget.name}: unsupported type {widget.type}
1451
+ </div>;
1452
+ }
1453
+ }
1454
+
1455
+ function navigationItems(items: NavigationItem[], openPage: (name: string) => unknown) {
1456
+ return (items || []).map((item, index) => <li key={`${item.page || item.caption?.en_US}-${index}`}>
1457
+ {item.page ? <button type="button" onClick={() => openPage(item.page!)}>
1458
+ {item.caption?.en_US || item.page}
1459
+ </button> : <span>{item.caption?.en_US || ''}</span>}
1460
+ {item.items?.length ? <ul>{navigationItems(item.items, openPage)}</ul> : null}
1461
+ </li>);
1462
+ }
1463
+
1464
+ function Login({ onLogin, error, busy }) {
1465
+ const [username, setUsername] = useState('');
1466
+ const [password, setPassword] = useState('');
1467
+ const submit = event => {
1468
+ event.preventDefault();
1469
+ onLogin(username, password).finally(() => setPassword(''));
1470
+ };
1471
+ return <main className="mxrb-login">
1472
+ <form onSubmit={submit}>
1473
+ <h1>Sign in</h1>
1474
+ <label>Username<input autoComplete="username" value={username}
1475
+ onChange={event => setUsername(event.target.value)} /></label>
1476
+ <label>Password<input type="password" autoComplete="current-password" value={password}
1477
+ onChange={event => setPassword(event.target.value)} /></label>
1478
+ <button type="submit" disabled={busy || !username || !password}>Sign in</button>
1479
+ {error ? <p role="alert">{error.message}</p> : null}
1480
+ </form>
1481
+ </main>;
1482
+ }
1483
+
1484
+ export default function App() {
1485
+ const [schema, setSchema] = useState<ApplicationSchema | null>(null);
1486
+ const [page, setPage] = useState<PageDefinition | null>(null);
1487
+ const [pageContext, setPageContext] = useState<EntityRecord | null>(null);
1488
+ const [error, setError] = useState<ApiFailure | null>(null);
1489
+ const [busy, setBusy] = useState(false);
1490
+ const invocationInFlight = useRef(false);
1491
+ const [revision, setRevision] = useState(0);
1492
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY));
1493
+ const [session, setSession] = useState<Session | null>(null);
1494
+ const [authRequired, setAuthRequired] = useState(false);
1495
+
1496
+ const handleError = useCallback(failure => {
1497
+ if (failure?.status === 401) setAuthRequired(true);
1498
+ setError(failure);
1499
+ }, []);
1500
+ const request: ApiRequest = useCallback(
1501
+ (path: string, options: RequestInit = {}) => api(path, options, token), [token]
1502
+ );
1503
+
1504
+ const openPage = (name: string, context: EntityRecord | null = null, activeToken = token) =>
1505
+ api<PageDefinition>(`/api/pages/${encodeURIComponent(name)}`, {}, activeToken)
1506
+ .then(value => { setPage(value); setPageContext(context); setError(null); })
1507
+ .catch(handleError);
1508
+
1509
+ const loadApplication = async (activeToken = token) => {
1510
+ try {
1511
+ if (activeToken) {
1512
+ const currentSession = await api<Session>('/api/session', {}, activeToken);
1513
+ setSession(currentSession);
1514
+ }
1515
+ const value = await api<ApplicationSchema>('/api/schema', {}, activeToken);
1516
+ setSchema(value);
1517
+ setAuthRequired(false);
1518
+ setError(null);
1519
+ const profile = value.navigation?.profiles?.find(item => item.kind === 'Responsive')
1520
+ || value.navigation?.profiles?.[0];
1521
+ const fallback = value.modules.flatMap(module => module.pages)[0]?.name;
1522
+ await openPage(profile?.home_page || fallback, null, activeToken);
1523
+ } catch (failure) {
1524
+ if (failure?.status === 401) {
1525
+ localStorage.removeItem(TOKEN_KEY);
1526
+ setToken(null);
1527
+ setSession(null);
1528
+ setAuthRequired(true);
1529
+ }
1530
+ setError(failure);
1531
+ }
1532
+ };
1533
+
1534
+ useEffect(() => {
1535
+ loadApplication(token);
1536
+ }, []);
1537
+
1538
+ const login = async (username, password) => {
1539
+ setBusy(true);
1540
+ try {
1541
+ const authenticated = await api<LoginResponse>('/api/login', {
1542
+ method: 'POST', body: JSON.stringify({ username, password })
1543
+ });
1544
+ localStorage.setItem(TOKEN_KEY, authenticated.token);
1545
+ setToken(authenticated.token);
1546
+ await loadApplication(authenticated.token);
1547
+ } catch (failure) {
1548
+ setError(failure);
1549
+ } finally {
1550
+ setBusy(false);
1551
+ }
1552
+ };
1553
+
1554
+ const logout = async () => {
1555
+ try {
1556
+ await api('/api/logout', { method: 'POST' }, token);
1557
+ } catch (failure) {
1558
+ if (failure?.status !== 401) setError(failure);
1559
+ } finally {
1560
+ localStorage.removeItem(TOKEN_KEY);
1561
+ setToken(null);
1562
+ setSession(null);
1563
+ setSchema(null);
1564
+ setPage(null);
1565
+ setAuthRequired(true);
1566
+ }
1567
+ };
1568
+
1569
+ const refreshPageContext = () => {
1570
+ if (!pageContext?.type || !pageContext?.id) return Promise.resolve();
1571
+ return request(
1572
+ `/api/entities/${encodeURIComponent(pageContext.type)}/${encodeURIComponent(pageContext.id)}`
1573
+ ).then(setPageContext).catch(handleError);
1574
+ };
1575
+
1576
+ const saveRecord = useCallback((record, changes) => {
1577
+ if (!record?.type || !record?.id) return Promise.resolve(record);
1578
+ return request<EntityRecord>(`/api/entities/${encodeURIComponent(record.type)}/${encodeURIComponent(record.id)}`, {
1579
+ method: 'PATCH', body: JSON.stringify(changes)
1580
+ }).then(updated => {
1581
+ setPageContext(current => current?.id === updated.id ? updated : current);
1582
+ setRevision(value => value + 1);
1583
+ setError(null);
1584
+ return updated;
1585
+ }).catch(failure => {
1586
+ handleError(failure);
1587
+ return null;
1588
+ });
1589
+ }, [request, handleError]);
1590
+
1591
+ const markMutation = useCallback(() => setRevision(value => value + 1), []);
1592
+ const selectRecord = useCallback(record => setPageContext(record), []);
1593
+
1594
+ const invoke = (name, parameters = {}, contextOverride = null) => {
1595
+ if (invocationInFlight.current) return Promise.resolve(null);
1596
+ invocationInFlight.current = true;
1597
+ setBusy(true);
1598
+ const activeContext = pageContext || contextOverride;
1599
+ return request<InvocationResult>(`/api/microflows/${encodeURIComponent(name)}`, {
1600
+ method: 'POST', body: JSON.stringify({
1601
+ ...parameters, ...(activeContext ? { __mxrb_context: activeContext } : {})
1602
+ })
1603
+ })
1604
+ .then(payload => {
1605
+ setRevision(value => value + 1);
1606
+ if (payload.context) setPageContext(payload.context);
1607
+ const navigation = (payload.effects || []).find(
1608
+ (effect): effect is OpenPageEffect => effect.type === 'open_page'
1609
+ );
1610
+ if (navigation?.page) {
1611
+ const context = Object.values(navigation.arguments || {})[0]
1612
+ || payload.context || payload.result || null;
1613
+ return openPage(navigation.page, context as EntityRecord | null).then(() => payload);
1614
+ }
1615
+ return payload.context ? Promise.resolve(payload) : refreshPageContext().then(() => payload);
1616
+ }).catch(handleError).finally(() => {
1617
+ invocationInFlight.current = false;
1618
+ setBusy(false);
1619
+ });
1620
+ };
1621
+
1622
+ const invokeNanoflow = async (name, parameters = {}, contextOverride = null) => {
1623
+ setBusy(true);
1624
+ try {
1625
+ const plan = nanoflows[name];
1626
+ const resolvedParameters = { ...parameters };
1627
+ const activeContext = contextOverride || pageContext;
1628
+ if (plan?.parameters?.length === 1 && !(plan.parameters[0] in resolvedParameters)
1629
+ && activeContext) {
1630
+ resolvedParameters[plan.parameters[0]] = activeContext;
1631
+ }
1632
+ const execution = await executeNanoflow(plan, resolvedParameters);
1633
+ const changedContext = Object.values(execution.variables).find(value =>
1634
+ value?.id && value.id === activeContext?.id
1635
+ );
1636
+ if (changedContext) await saveRecord(changedContext, changedContext.attributes || {});
1637
+ setError(null);
1638
+ return execution.result;
1639
+ } catch (failure) {
1640
+ setError(failure);
1641
+ return null;
1642
+ } finally {
1643
+ setBusy(false);
1644
+ }
1645
+ };
1646
+
1647
+ if (authRequired) return <Login onLogin={login} error={error} busy={busy} />;
1648
+ if (!schema || !page) return <main className="mxrb-loading">Loading application…</main>;
1649
+ const profile = schema.navigation?.profiles?.find(item => item.kind === 'Responsive')
1650
+ || schema.navigation?.profiles?.[0];
1651
+ const moduleName = page.name.split('.')[0];
1652
+
1653
+ return <div className={classes('mxrb-app-shell', 'mx-page', page.appearance_class)} style={inlineStyle(page.appearance_style)}>
1654
+ {profile?.items?.length || session ? <nav className="mxrb-navigation region-sidebar">
1655
+ {profile?.items?.length ? <ul>{navigationItems(profile.items, openPage)}</ul> : null}
1656
+ {session ? <button type="button" onClick={logout}>Sign out</button> : null}
1657
+ </nav> : null}
1658
+ <main className="mxrb-page region-content mx-scrollcontainer-wrapper" aria-busy={busy}>
1659
+ {(page.widgets || []).map((widget, index) =>
1660
+ <Widget key={`${widget.name}-${index}`} widget={widget} moduleName={moduleName} invoke={invoke}
1661
+ invokeNanoflow={invokeNanoflow} navigate={openPage}
1662
+ context={pageContext} pageContext={pageContext} revision={revision} schema={schema}
1663
+ request={request} saveRecord={saveRecord} onError={handleError}
1664
+ onMutation={markMutation} onSelectRecord={selectRecord} />)}
1665
+ </main>
1666
+ {error ? <aside className="mxrb-runtime-error" role="alert">
1667
+ <button type="button" onClick={() => setError(null)}>×</button>{error.message}
1668
+ </aside> : null}
1669
+ </div>;
1670
+ }
1671
+ JS
1672
+ end
1673
+
1674
+ def frontend_css
1675
+ <<~CSS
1676
+ :root { font: 16px/1.5 system-ui, sans-serif; }
1677
+ * { box-sizing: border-box; }
1678
+ html, body, #root { min-height: 100%; margin: 0; }
1679
+ button, input, textarea, select { font: inherit; }
1680
+ .mxrb-app-shell { min-height: 100vh; }
1681
+ .mxrb-page { min-height: 100vh; }
1682
+ .mxrb-page[aria-busy='true'] { cursor: progress; pointer-events: none; }
1683
+ .mxrb-navigation { position: fixed; z-index: 20; right: 1rem; top: 1rem; }
1684
+ .mxrb-navigation ul { display: flex; gap: .5rem; margin: 0; padding: 0; list-style: none; }
1685
+ .mxrb-navigation button { border: 1px solid currentColor; border-radius: .4rem; background: transparent; color: inherit; cursor: pointer; }
1686
+ .mxrb-text { display: block; }
1687
+ .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; }
1688
+ .mxrb-runtime-error button { float: right; border: 0; background: transparent; color: inherit; cursor: pointer; }
1689
+ .mxrb-loading { display: grid; min-height: 100vh; place-items: center; }
1690
+ .mxrb-login { display: grid; min-height: 100vh; place-items: center; padding: 1rem; }
1691
+ .mxrb-login form { display: grid; width: min(24rem, 100%); gap: 1rem; padding: 2rem; border: 1px solid #d1d5db; border-radius: .75rem; }
1692
+ .mxrb-login label { display: grid; gap: .25rem; }
1693
+ .mxrb-grid-toolbar, .mxrb-grid-pagination { display: flex; align-items: center; gap: .5rem; margin-block: .5rem; }
1694
+ .mxrb-data-grid-runtime table { width: 100%; border-collapse: collapse; }
1695
+ .mxrb-data-grid-runtime th, .mxrb-data-grid-runtime td { padding: .5rem; border-bottom: 1px solid #d1d5db; text-align: left; }
1696
+ .mxrb-data-grid-runtime tbody tr { cursor: pointer; }
1697
+ .mxrb-data-grid-runtime tbody tr.is-selected { background: #dbeafe; }
1698
+ .mxrb-native-widget:empty { min-height: 1px; }
1699
+ CSS
1700
+ end
1701
+
1702
+ def readme
1703
+ <<~MARKDOWN
1704
+ # #{@project.name}
1705
+
1706
+ Executable Ruby application exported by MXRB.
1707
+
1708
+ ## Run
1709
+
1710
+ ```sh
1711
+ bundle install
1712
+ npm install --prefix frontend
1713
+ bundle exec mxrb run .
1714
+ ```
1715
+
1716
+ `mxrb run` supervises the Ruby API and React + TypeScript + Vite development server
1717
+ together. Vite proxies `/api` to Ruby, so both processes behave as one
1718
+ application. Use `--server-port` for Ruby and `--client-port` for Vite;
1719
+ `--api-port` and `--port` remain compatibility aliases. Generated models,
1720
+ DTOs, services, and pages live under `app/`.
1721
+ The generated `frontend/src/types.ts` covers domain records, pages, widgets,
1722
+ contexts, effects, and API payloads; `npm run typecheck` validates it strictly.
1723
+ Service bodies are ordinary Ruby; their default implementation delegates to
1724
+ MXRB's pure-Ruby interpreter.
1725
+
1726
+ Browser CRUD uses the same authenticated entity API as microflows. Configure
1727
+ users and bearer tokens through ignored environment files. External app/web
1728
+ services, mappings, XML imports, and documents are explicit Ruby integrations
1729
+ registered in `config/adapters.rb`; keep credentials out of that committed file.
1730
+
1731
+ ## Recompile to Mendix
1732
+
1733
+ ```sh
1734
+ bundle exec mxrb generate project.rb build/#{File.basename(@mpr_path)}
1735
+ ```
1736
+
1737
+ The complete bidirectional Mendix source remains under `.mxrb/mendix`. The
1738
+ `.mxrb/ruby-app.json` coverage manifest maps stable Mendix IDs to Ruby files and
1739
+ records artifacts that remain preserved natively.
1740
+ MARKDOWN
1741
+ end
1742
+
1743
+ def escape_html(value)
1744
+ value.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
1745
+ end
1746
+ end
1747
+ # rubocop:enable Metrics
1748
+ end
1749
+ end