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