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.
- checksums.yaml +4 -4
- data/bin/mxrb +458 -10
- data/docs/de-DE/README.md +1 -0
- data/docs/de-DE/oql-sql.md +42 -0
- data/docs/de-DE/project-structure.md +201 -2
- data/docs/de-DE/runtime-ruby.md +104 -0
- data/docs/de-DE/runtime-schema-migrations.md +44 -0
- data/docs/de-DE/validation-matrix.md +42 -4
- data/docs/en-US/README.md +1 -0
- data/docs/en-US/oql-sql.md +41 -0
- data/docs/en-US/project-structure.md +192 -2
- data/docs/en-US/runtime-ruby.md +100 -0
- data/docs/en-US/runtime-schema-migrations.md +41 -0
- data/docs/en-US/validation-matrix.md +39 -4
- data/docs/pt-BR/README.md +1 -0
- data/docs/pt-BR/entity-dsl.md +8 -0
- data/docs/pt-BR/oql-sql.md +41 -0
- data/docs/pt-BR/project-structure.md +197 -2
- data/docs/pt-BR/runtime-ruby.md +108 -0
- data/docs/pt-BR/runtime-schema-migrations.md +40 -0
- data/docs/pt-BR/validation-matrix.md +42 -5
- data/docs/pt-BR/writing.md +28 -0
- data/lib/mxrb/cli/help.rb +426 -0
- data/lib/mxrb/cli/release.rb +137 -0
- data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
- data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
- data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
- data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
- data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
- data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
- data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
- data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
- data/lib/mxrb/compiler/packager.rb +27 -13
- data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
- data/lib/mxrb/compiler/portable_packager.rb +16 -11
- data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
- data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
- data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
- data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
- data/lib/mxrb/domain_diagram.rb +409 -0
- data/lib/mxrb/dsl/builder.rb +62 -11
- data/lib/mxrb/environment.rb +226 -0
- data/lib/mxrb/exporter.rb +265 -45
- data/lib/mxrb/http/server.rb +107 -0
- data/lib/mxrb/initializer.rb +80 -9
- data/lib/mxrb/integrity/validator.rb +32 -7
- data/lib/mxrb/io/bson_codec.rb +42 -1
- data/lib/mxrb/io/mpr_file.rb +146 -3
- data/lib/mxrb/model/attribute.rb +3 -0
- data/lib/mxrb/model/entity.rb +44 -2
- data/lib/mxrb/model/microflow.rb +3 -1
- data/lib/mxrb/model/module.rb +71 -0
- data/lib/mxrb/model/page.rb +126 -15
- data/lib/mxrb/model/project.rb +3 -0
- data/lib/mxrb/modeler/catalog.rb +178 -0
- data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
- data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
- data/lib/mxrb/official_marketplace.rb +9 -3
- data/lib/mxrb/oql/reverse_translator.rb +394 -0
- data/lib/mxrb/oql/server.rb +4 -8
- data/lib/mxrb/oql.rb +32 -4
- data/lib/mxrb/progress.rb +242 -0
- data/lib/mxrb/project_lifecycle.rb +5 -2
- data/lib/mxrb/ruby_app/exporter.rb +1749 -0
- data/lib/mxrb/ruby_app/preset.rb +506 -0
- data/lib/mxrb/ruby_app/session_manager.rb +122 -0
- data/lib/mxrb/ruby_app.rb +1361 -0
- data/lib/mxrb/runtime/access_control.rb +408 -0
- data/lib/mxrb/runtime/native.rb +1015 -93
- data/lib/mxrb/runtime/scheduler.rb +396 -0
- data/lib/mxrb/runtime/schema_migrator.rb +598 -0
- data/lib/mxrb/runtime/shared_store.rb +335 -0
- data/lib/mxrb/runtime/sqlite_store.rb +729 -0
- data/lib/mxrb/scaffold/generator.rb +11 -3
- data/lib/mxrb/team_server.rb +10 -7
- data/lib/mxrb/uml/activity_diagram.rb +118 -0
- data/lib/mxrb/uml/class_diagram.rb +118 -0
- data/lib/mxrb/uml/sequence_diagram.rb +112 -0
- data/lib/mxrb/uml/server.rb +146 -0
- data/lib/mxrb/uml/support.rb +35 -0
- data/lib/mxrb/version.rb +1 -1
- data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
- data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
- data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
- data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
- data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
- data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
- data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
- data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
- data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
- data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
- data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
- data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
- data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
- data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
- data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
- data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
- data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
- data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
- data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
- data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
- data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
- data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
- data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
- data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
- data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
- data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
- data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
- data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
- data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
- data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
- data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
- data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
- data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
- data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
- data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
- data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
- data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
- data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
- data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
- data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
- data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
- data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
- data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
- data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
- data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
- data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
- data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
- data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
- data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
- data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
- data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
- data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
- data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
- data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
- data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
- data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
- data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
- data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
- data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
- data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
- data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
- data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
- data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
- data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
- data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
- data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
- data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
- data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
- data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
- data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
- data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
- data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
- data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
- data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
- data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
- data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
- data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
- data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
- data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
- data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
- data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
- data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
- data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
- data/lib/mxrb/web_ui/domain.html +15 -0
- data/lib/mxrb/web_ui/modeler.html +16 -0
- data/lib/mxrb/web_ui/uml.html +31 -0
- data/lib/mxrb/web_ui.rb +89 -0
- data/lib/mxrb/widget_certification.rb +208 -0
- data/lib/mxrb/writer.rb +278 -41
- data/lib/mxrb.rb +38 -8
- 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('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
1745
|
+
end
|
|
1746
|
+
end
|
|
1747
|
+
# rubocop:enable Metrics
|
|
1748
|
+
end
|
|
1749
|
+
end
|