diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cad592b..0dac205 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,17 @@ jobs: ruby-version: "4.0" bundler-cache: true + - name: Check out pinned Mendix acceptance fixture + run: | + git clone --quiet https://github.com/ako/mxcli-sudoku.git /tmp/mxcli-sudoku + git -C /tmp/mxcli-sudoku checkout --quiet 918af72e43ca8e1d61fb0c32ad13d8da94b944e2 + - name: Run strict test suite env: MXRB_COVERAGE: "1" + MXRB_LINE_COVERAGE_MIN: "98" + MXRB_BRANCH_COVERAGE_MIN: "95" + MXRB_ACCEPTANCE_MPRS: /tmp/mxcli-sudoku/Sudoku/Sudoku.mpr run: bundle exec rspec - name: Run gradual Ruby quality gate diff --git a/README.md b/README.md index 9f4e304..2830efb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ remain optional external gates for compatibility and functional execution. - write, inspect and compare deterministic MDA containers without invoking `mxbuild`; - assemble executable portable Runtime ZIPs from materialized deployments and installed version-matched Runtime files without invoking `mx` or `mxbuild`; +- preflight an existing MPR against its version-selected native web pipeline, + with precise unsupported widget, action, layout and nanoflow findings; - clone, inspect and synchronize official Team Server Git repositories while keeping PAT values in user-managed files. diff --git a/bin/mxrb b/bin/mxrb index 58fcfcf..c97e642 100755 --- a/bin/mxrb +++ b/bin/mxrb @@ -273,6 +273,25 @@ when "validate" exit 1 end +when "preflight" + path = ARGV.shift or abort "Usage: mxrb preflight [--json]" + json = ARGV.delete("--json") + abort "Unknown arguments: #{ARGV.join(' ')}" unless ARGV.empty? + report = Mxrb.compatibility(path) + if json + puts JSON.pretty_generate(report.to_h) + else + puts "Project : #{report.path}" + puts "Mendix : #{report.mendix_version}" + puts "Units : #{report.stats[:units]}" + report.findings.each do |finding| + puts "[#{finding.severity.to_s.upcase}] #{finding.category} #{finding.type} " \ + "at #{finding.location}: #{finding.message}" + end + puts "[mxrb] #{report.errors.size} error(s), #{report.warnings.size} warning(s)" + end + exit 1 unless report.compatible? + when "pack" path = ARGV.shift or abort "Usage: mxrb pack --output FILE.mda [--deployment DIR] [--force]" option = lambda do |name| @@ -1385,6 +1404,7 @@ else widgets sync Synchronize pluggable widget schemas export Convert an MPR to editable Ruby validate Check MPR structure, hashes and v2 contents + preflight [--json] Audit native compiler/runtime compatibility compare Compare structural MPR snapshots diff Print typed semantic changes refs Find references to a Mendix artifact diff --git a/docs/de-DE/compiler.md b/docs/de-DE/compiler.md index 32d8386..e00ce75 100644 --- a/docs/de-DE/compiler.md +++ b/docs/de-DE/compiler.md @@ -44,14 +44,23 @@ sowie Save/Cancel-Aktionen. Create übergibt die GUID des neuen Objekts über registriert, die aus den erlaubten Modulrollen der Seite abgeleitet werden. Damit bleibt die Runtime-Autorisierung erhalten und wird nicht umgangen. +Layouts, Platzhalter, Scrollbereiche, Sidebars, Header, Navigationsmenüs, +Snippets, DataViews, ListViews, Formularelemente, Bilder, Combo Boxes, Gallery, +Data Grid 2 und schemagestützte Pluggable Widgets werden über die nativen +React-Client-Verträge kompiliert. Seiten-, Menü- und Widget-Aktionen decken +Navigation, Microflows, Nanoflows, Objektlebenszyklus, Links und Sign-out ab. +Nanoflow-Programme unterstützen außerdem Entscheidungen, Fehlerbehandlung, +JavaScript-Aktionen und serverseitige Microflow-/Commit- +Operationen, sofern der Modellvertrag sicher abgebildet werden kann. + Der React-Pfad kompiliert außerdem die offizielle Gallery mit XPath- und Microflow-Listenquellen, Template-Inhalten, Auswahl und formatierten dynamischen Attributen. Nanoflow-Quellen und -Aktionen verwenden die Mendix-Verträge `NanoflowObjectListProperty`, `NanoflowObjectProperty` und `ActionProperty`. -MXRB erzeugt echte Clientprogramme `{ name, instructions }` für den auditierten -linearen Teilumfang: Return, Variablenerzeugung, Objekterzeugung und verschachtelte -Nanoflow-Aufrufe. Fehlende Flows, nicht sicher abbildbare Parameter, Verzweigungen, -JavaScript-Aktionen, Microflow-Aufrufe und andere unübersetzte Clientinstruktionen +MXRB erzeugt echte Clientprogramme `{ name, instructions }` für auditierten +Graph-Kontrollfluss, Returns, Variablen, Objekte, Nanoflow-/Microflow-Aufrufe, +JavaScript-, Seiten-, Nachrichten-, Validierungs- und Commit-Aktionen. Fehlende +Flows, unsichere Parameterabbildungen und nicht übersetzte Clientinstruktionen schlagen geschlossen fehl und bleiben Befunde im Support-Manifest. In lokalen Runtime-Sitzungen im Developer-Modus versioniert mxrb außerdem @@ -81,6 +90,18 @@ end Bestehende Projekte können exportiert, in der DSL geändert und neu erzeugt werden. Nicht unterstützte Widgets bleiben im Support-Manifest sichtbar. +Vor einem nativen Build kann das gesamte MPR mit dem für seine Mendix-Version +gewählten Renderer geprüft werden: + +```bash +mxrb preflight App.mpr +mxrb preflight App.mpr --json +``` + +Der Nur-Lese-Befehl liefert bei nicht unterstützten Versionen oder +Client-Features einen Fehlerstatus. Mendix 11 wird über den React-Compiler +geprüft; 6/7/9 verwenden dieselbe Legacy-Dojo-Prüfung wie `WebBundleBuilder`. + Der Dojo-Pfad kompiliert Data Grid 1 mit Datenbank-, XPath- und Microflow-Quellen sowie auditierten Such-, Sortier-, Paging-, Auswahl- und Button-Verträgen. Alle anderen sichtbaren Widgets werden pro Seite in @@ -130,10 +151,9 @@ Seitenbundles und fuhr sauber herunter. Danach bestand der Funktionstest aus Version 11 einzusetzen; - Data Grid 1 ist abgedeckt, andere Dojo-Widgets bleiben explizite Manifestbefunde; -- Data Grid 2 ist für XPath-Datenquellen und Attributspalten abgedeckt. Gallery - deckt XPath/Microflow und den auditierten Nanoflow-Instruktionsumfang ab. - React-Formulare decken parameter- oder unterstützte Nanoflow-DataViews, - TextBox und Save/Cancel ab; andere Clientinstruktionen verwenden den Fallback; +- der Mendix-11-React-Pfad besitzt saubere Preflights und vollständige + Rspack-Builds für LearnNow, MyFirstModule, SLATaskApp, Sudoku und VetClinic. + Diese Fixture-Matrix belegt auditierte Verträge, keine universelle Kompatibilität; - native Web-Bundles werden erzeugt; React Native verwendet weiterhin bestehende Versions-Template- und Projektassets; - `mx` und `mxbuild` sind niemals Fallbacks. Nicht unterstützte Eingaben führen diff --git a/docs/en-US/compiler.md b/docs/en-US/compiler.md index 738e041..83be84e 100644 --- a/docs/en-US/compiler.md +++ b/docs/en-US/compiler.md @@ -41,15 +41,23 @@ commit/rollback operations are registered with the project user roles derived from each page's allowed module roles. Runtime authorization is preserved instead of bypassed. +Layouts, placeholders, scroll regions, sidebars, headers, navigation menus, +snippets, DataViews, ListViews, form controls, images, combo boxes, Gallery, +Data Grid 2 and schema-backed pluggable widgets are compiled through their +native React-client contracts. Page, menu and widget actions cover navigation, +microflows, nanoflows, object lifecycle, links and sign-out. Nanoflow programs +also cover decisions, error handlers, JavaScript actions and server-side +microflow/commit operations when their model contracts can be mapped safely. + The React path also compiles the official Gallery with XPath and microflow list sources, templated item content, selection, and formatted dynamic attributes. Client-side nanoflow sources and actions use the Mendix `NanoflowObjectListProperty`, `NanoflowObjectProperty`, and `ActionProperty` -contracts. MXRB emits real `{ name, instructions }` client programs for the -audited linear instruction subset (return, variable creation, object creation, -and nested nanoflow calls). Missing flows, parameters that cannot be mapped, -branches, JavaScript actions, microflow calls, and other untranslated client -instructions fail closed and remain support-manifest findings. +contracts. MXRB emits real `{ name, instructions }` client programs for audited +graph control flow, returns, variables, objects, nested nanoflow/microflow calls, +JavaScript actions, page/message/validation actions and commits. Missing flows, +unsafe parameter mappings and untranslated client instructions fail closed and +remain support-manifest findings. For local developer Runtime sessions, mxrb also versions dynamic page imports, content-hashes the patched React Client chunk, and gives Rspack's self-import @@ -78,6 +86,19 @@ end Existing projects can be exported, changed in the DSL, and regenerated. Unsupported widgets remain visible in the support manifest. +Before a native build, audit the complete MPR through the renderer selected for +its Mendix version: + +```bash +mxrb preflight App.mpr +mxrb preflight App.mpr --json +``` + +The command is read-only and exits non-zero for unsupported versions or client +features. Mendix 11 is audited through the React compiler, while 6/7/9 use the +same legacy Dojo audit as `WebBundleBuilder`; this prevents a report from +claiming compatibility through a renderer the build will not use. + The Dojo path compiles Data Grid 1 database, XPath, and microflow sources plus audited search, sorting, paging, selection, and buttons. Every other visible widget is recorded per page in `web/mxrb-legacy-pages.json` and is not claimed @@ -125,10 +146,9 @@ cleanly. The `ACT Create Animal` functional test then passed. instead of substituting the Java-21 launcher from 11; - Data Grid 1 is covered while other Dojo widgets remain explicit manifest findings; -- Data Grid 2 is covered for XPath datasource and attribute columns. Gallery - covers XPath/microflow and the audited nanoflow instruction subset. React - edit forms cover parameter- or supported-nanoflow-backed DataView/TextBox and - Save/Cancel; other widgets and client instructions use the manifest fallback; +- the Mendix 11 React path is covered by clean preflights and complete Rspack + builds of LearnNow, MyFirstModule, SLATaskApp, Sudoku and VetClinic. This + fixture matrix is evidence for the audited contracts, not a universal claim; - native web bundles are generated; React Native still relies on existing version-template and project assets; - `mx` and `mxbuild` are never fallbacks. Unsupported input produces a clear diff --git a/docs/pt-BR/compiler.md b/docs/pt-BR/compiler.md index 9939214..c4d3dd5 100644 --- a/docs/pt-BR/compiler.md +++ b/docs/pt-BR/compiler.md @@ -44,14 +44,23 @@ editáveis e ações Save/Cancel. Create passa o GUID do novo objeto por projeto derivados dos module roles permitidos pela página. A autorização da Runtime é preservada, sem contorno de segurança. +Layouts, placeholders, regiões de scroll, sidebars, headers, menus de navegação, +snippets, DataViews, ListViews, controles de formulário, imagens, combo boxes, +Gallery, Data Grid 2 e widgets pluggable respaldados por schema são compilados +pelos contratos nativos do cliente React. Ações de páginas, menus e widgets +cobrem navegação, microflows, nanoflows, ciclo de vida de objetos, links e +sign-out. Programas nanoflow também cobrem decisões, error handlers, +ações JavaScript e operações server-side de microflow/commit quando o contrato +do modelo pode ser mapeado com segurança. + O caminho React também compila a Gallery oficial com listas XPath e microflow, conteúdo de item por template, seleção e atributos dinâmicos formatados. Fontes e ações nanoflow usam os contratos `NanoflowObjectListProperty`, `NanoflowObjectProperty` e `ActionProperty` do cliente Mendix. O MXRB emite -programas cliente reais `{ name, instructions }` para o subconjunto linear -auditado: retorno, criação de variável, criação de objeto e chamadas de -nanoflow. Fluxos ausentes, parâmetros sem mapeamento seguro, decisões, ações -JavaScript, chamadas de microflow e demais instruções não traduzidas falham +programas cliente reais `{ name, instructions }` para controle de fluxo em +grafo, retornos, variáveis, objetos, chamadas de nanoflow/microflow, ações +JavaScript, páginas, mensagens, validações e commits. Fluxos ausentes, +mapeamentos de parâmetro inseguros e instruções cliente não traduzidas falham fechado e permanecem achados no manifesto de suporte. Nas sessões locais da Runtime em modo developer, o mxrb também versiona imports @@ -81,6 +90,19 @@ end Projetos existentes podem ser exportados, alterados na DSL e regenerados. Os widgets fora do subconjunto continuam no manifesto, sem descarte silencioso. +Antes do build nativo, audite o MPR completo pelo renderer selecionado para sua +versão Mendix: + +```bash +mxrb preflight App.mpr +mxrb preflight App.mpr --json +``` + +O comando é somente leitura e retorna código diferente de zero para versões ou +recursos cliente não suportados. Mendix 11 é auditado pelo compilador React; +6/7/9 usam a mesma auditoria Dojo legada do `WebBundleBuilder`. Assim o relatório +não declara compatibilidade por um renderer que o build real não utilizará. + No Dojo, o Data Grid 1 cobre fontes database, XPath e microflow, pesquisa, ordenação, paginação, seleção e botões auditados. Outros widgets visuais são registrados por página em `web/mxrb-legacy-pages.json`; não são declarados como @@ -129,10 +151,9 @@ encerrou limpo. O teste funcional `ACT Create Animal` passou em seguida. substituir pelo launcher Java 21 da versão 11; - Data Grid 1 está coberto, mas os demais widgets Dojo continuam explicitamente pendentes no manifesto legado; -- Data Grid 2 está coberto para datasource XPath e colunas de atributo. Gallery - cobre XPath/microflow e o subconjunto auditado de instruções nanoflow. Os - formulários React cobrem DataView/TextBox por parâmetro ou nanoflow suportado - e Save/Cancel; outros widgets e instruções cliente usam o fallback do manifesto; +- o caminho React do Mendix 11 tem preflight limpo e builds Rspack completos de + LearnNow, MyFirstModule, SLATaskApp, Sudoku e VetClinic. Essa matriz comprova + os contratos auditados, não compatibilidade universal; - bundles web nativos são gerados; o pipeline React Native ainda depende dos assets de template/projeto existentes; - `mx` e `mxbuild` não são fallbacks. Um recurso sem compilador produz erro ou diff --git a/lib/mxrb.rb b/lib/mxrb.rb index 892a058..6a2ad97 100644 --- a/lib/mxrb.rb +++ b/lib/mxrb.rb @@ -110,10 +110,12 @@ require_relative "mxrb/compiler/data_grid_bundle_compiler" require_relative "mxrb/compiler/gallery_bundle_compiler" require_relative "mxrb/compiler/image_bundle_compiler" +require_relative "mxrb/compiler/generic_widget_bundle_compiler" require_relative "mxrb/compiler/combo_box_bundle_compiler" require_relative "mxrb/compiler/legacy_data_grid_compiler" require_relative "mxrb/compiler/legacy_page_builder" require_relative "mxrb/compiler/page_bundle_compiler" +require_relative "mxrb/compiler/compatibility_analyzer" require_relative "mxrb/compiler/page_bundle_builder" require_relative "mxrb/compiler/web_bundle_builder" require_relative "mxrb/compiler/portable_packager" @@ -177,4 +179,8 @@ def self.functional_definition(path) def self.runtime_plan(path, **options) Runtime::Toolchain.new(path, **options).plan end + + def self.compatibility(path) + Compiler::CompatibilityAnalyzer.new(path).analyze + end end diff --git a/lib/mxrb/compiler/combo_box_bundle_compiler.rb b/lib/mxrb/compiler/combo_box_bundle_compiler.rb index 520d1ed..68b5bf1 100644 --- a/lib/mxrb/compiler/combo_box_bundle_compiler.rb +++ b/lib/mxrb/compiler/combo_box_bundle_compiler.rb @@ -6,7 +6,8 @@ module Mxrb module Compiler # Compiles association- and database-backed instances of the official React Combo box widget. - class ComboBoxBundleCompiler # rubocop:disable Metrics/ClassLength + # rubocop:disable Metrics + class ComboBoxBundleCompiler include ModelValues WIDGET_ID = 'com.mendix.widget.web.combobox.Combobox' @@ -59,7 +60,7 @@ def database_source_supported? def association_source_supported? primitive('source') == 'context' && primitive('optionsSourceType') == 'association' && - association_step && association_caption_attribute + association_step && (association_caption_attribute || association_caption_expression) end def source_properties @@ -88,11 +89,30 @@ def database_association_properties end def association_properties - { - optionsSourceAssociationCaptionAttribute: raw(list_attribute_property(association_caption_attribute)), + properties = { attributeAssociation: raw(association_property(association_step)), optionsSourceAssociationDataSource: raw(list_property) } + if association_caption_attribute + properties[:optionsSourceAssociationCaptionAttribute] = raw( + list_attribute_property(association_caption_attribute) + ) + else + properties[:optionsSourceAssociationCaptionExpression] = raw(list_expression_property) + end + properties + end + + def list_expression_property + source = value('optionsSourceAssociationCaptionExpression')&.fetch('Expression', '').to_s + path = source[%r{\A\$currentObject/(.+)\z}, 1] + "ListExpressionProperty(#{javascript( + expression: { + expr: { type: 'variable', variable: 'currentObject', path: }, + args: { currentObject: { widget: widget_key, source: 'object' } } + }, + dataSourceId: data_source_id + )})" end def association_property(step) @@ -175,6 +195,7 @@ def target_attribute = target_value&.fetch('AttributeRef', nil) def database_caption_attribute = value('optionsSourceDatabaseCaptionAttribute')&.fetch('AttributeRef', nil) def database_value_attribute = value('optionsSourceDatabaseValueAttribute')&.fetch('AttributeRef', nil) def association_caption_attribute = value('optionsSourceAssociationCaptionAttribute')&.fetch('AttributeRef', nil) + def association_caption_expression = value('optionsSourceAssociationCaptionExpression')&.fetch('Expression', nil) def association_step entity_steps(value('attributeAssociation')).first @@ -256,6 +277,8 @@ def widget_type end def document_index + return @source.document_index if @source.is_a?(SourceModel) + {}.tap { |index| @source.documents.each { index_document(_1, index) } } end @@ -268,6 +291,7 @@ def index_document(value, index) when Array then value.each { index_document(_1, index) } end end - end # rubocop:enable Metrics/ClassLength + end + # rubocop:enable Metrics end end diff --git a/lib/mxrb/compiler/compatibility_analyzer.rb b/lib/mxrb/compiler/compatibility_analyzer.rb new file mode 100644 index 0000000..608e096 --- /dev/null +++ b/lib/mxrb/compiler/compatibility_analyzer.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +module Mxrb + module Compiler + CompatibilityFinding = Data.define(:severity, :category, :type, :location, :message, :count) do + def to_h = { severity:, category:, type:, location:, message:, count: } + end + + CompatibilityReport = Data.define(:path, :mendix_version, :findings, :stats) do + def compatible? = findings.none? { _1.severity == :error } + def errors = findings.select { _1.severity == :error } + def warnings = findings.select { _1.severity == :warning } + + def to_h + { + path:, mendix_version:, compatible: compatible?, stats:, + findings: findings.map(&:to_h) + } + end + end + + # Read-only preflight for features consumed by the native compiler and web runtime. + # rubocop:disable Metrics + class CompatibilityAnalyzer + include ModelValues + + def initialize(path, source: nil) + @path = File.expand_path(path) + @source = source || SourceModel.read(@path) + @findings = [] + end + + def analyze + analyze_version + analyze_features if @adapter + CompatibilityReport.new( + path: @path, mendix_version: @source.version, findings: collapsed_findings.freeze, + stats: statistics.freeze + ) + end + + private + + def analyze_features + if @adapter.web_profiles.include?(:react) + analyze_pages + analyze_layouts + analyze_nanoflows + else + analyze_legacy_pages + end + end + + def analyze_version + @adapter = Adapter.for(@source.version) + rescue UnsupportedVersion => e + add(:error, :version, "Mendix #{@source.version}", 'project', e.message) + end + + def analyze_legacy_pages + LegacyPageBuilder.new(@source, '', profiles: @adapter.web_profiles).audit.each do |location, types| + types.each do |type| + add(:error, page_category(type), type, location, + "#{type} is not compiled by the native legacy web renderer") + end + end + rescue CompilationError, KeyError, TypeError => e + add(:error, :page, 'Forms$Page', 'project', e.message) + end + + def analyze_pages + @source.units_of('Forms$Page').select { web_page?(_1) }.each do |unit| + location = qualified_name(unit) + bundle = PageBundleCompiler.new(@source).compile(unit) + bundle.unsupported_widgets.each do |type| + if type == 'CustomWidgets$CustomWidget' + bundle.unsupported_custom_widgets.each do |identifier| + add(:error, :custom_widget, identifier, location, + "#{identifier} is not compiled by the native web renderer") + end + else + add(:error, page_category(type), type, location, + "#{type} is not compiled by the native web renderer") + end + end + rescue CompilationError, KeyError, TypeError => e + add(:error, :page, 'Forms$Page', location, e.message) + end + end + + def analyze_nanoflows + compiler = NanoflowProgramCompiler.new(@source) + @source.units_of('Microflows$Nanoflow').each { compiler.reference(qualified_name(_1)) } + compiler.unsupported.each do |entry| + location, type = entry.split(':', 2) + add(:error, :nanoflow, type, location, "#{type} is not compiled by the native nanoflow runtime") + end + end + + def analyze_layouts + @source.units_of('Forms$Layout').select { web_layout?(_1) }.each do |unit| + location = qualified_name(unit) + bundle = PageBundleCompiler.new(@source).compile_layout(unit) + add_bundle_findings(bundle, location) + rescue CompilationError, KeyError, TypeError => e + add(:error, :layout, 'Forms$Layout', location, e.message) + end + end + + def add_bundle_findings(bundle, location) + bundle.unsupported_widgets.each do |type| + if type == 'CustomWidgets$CustomWidget' + bundle.unsupported_custom_widgets.each do |identifier| + add(:error, :custom_widget, identifier, location, + "#{identifier} is not compiled by the native web renderer") + end + else + add(:error, page_category(type), type, location, + "#{type} is not compiled by the native web renderer") + end + end + end + + def page_category(type) + return :client_action if type.end_with?('Action') + return :custom_widget if type == 'CustomWidgets$CustomWidget' + + :widget + end + + def web_page?(unit) = !@source.is_a?(SourceModel) || @source.web_page?(unit) + def web_layout?(unit) = !@source.is_a?(SourceModel) || @source.web_layout?(unit) + + def add(severity, category, type, location, message) + @findings << CompatibilityFinding.new( + severity:, category:, type: type.to_s, location: location.to_s, message:, count: 1 + ) + end + + def collapsed_findings + grouped = @findings.group_by { finding_key(_1) } + findings = grouped.map { |key, matches| collapsed_finding(key, matches.size) } + findings.sort_by { [_1.severity.to_s, _1.category.to_s, _1.location, _1.type] } + end + + def finding_key(finding) + [finding.severity, finding.category, finding.type, finding.location, finding.message] + end + + def collapsed_finding(key, count) + severity, category, type, location, message = key + CompatibilityFinding.new(severity:, category:, type:, location:, message:, count:) + end + + def statistics + { + units: @source.units.size, + pages: @source.units_of('Forms$Page').size, + layouts: @source.units_of('Forms$Layout').size, + nanoflows: @source.units_of('Microflows$Nanoflow').size, + microflows: @source.units_of('Microflows$Microflow').size + } + end + + def qualified_name(unit) = [unit.module_name, unit.document['Name']].compact.join('.') + end + # rubocop:enable Metrics + end +end diff --git a/lib/mxrb/compiler/data_grid_bundle_compiler.rb b/lib/mxrb/compiler/data_grid_bundle_compiler.rb index cc574fe..9bab287 100644 --- a/lib/mxrb/compiler/data_grid_bundle_compiler.rb +++ b/lib/mxrb/compiler/data_grid_bundle_compiler.rb @@ -5,21 +5,23 @@ module Mxrb module Compiler # Compiles the Data Grid 2 XPath/attribute-column subset into pluggable-widget properties. - class DataGridBundleCompiler # rubocop:disable Metrics/ClassLength + # rubocop:disable Metrics + class DataGridBundleCompiler include ModelValues WIDGET_ID = 'com.mendix.widget.web.datagrid.Datagrid' - def initialize(source, page_name, widget) + def initialize(source, page_name, widget = nil, render_widgets: nil, **widget_keywords) @source = source @page_name = page_name - @widget = widget + @widget = widget || widget_keywords + @render_widgets = render_widgets @index = document_index end def supported? widget_type&.fetch('WidgetId', nil) == WIDGET_ID && xpath_source && - columns.all? { attribute_column?(_1) } + columns.all? { supported_column?(_1) } end def render @@ -65,17 +67,109 @@ def datasource } end - def compile_column(object) # rubocop:disable Metrics/AbcSize + def compile_column(object) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength values = property_values(object) - attribute = values.fetch('attribute').last.dig('AttributeRef', 'Attribute') + mode = values.fetch('showContentAs').last['PrimitiveValue'] + attribute = attribute_name(values) entity, name = attribute.rpartition('.').then { |prefix, _, suffix| [prefix, suffix] } - primitives(values).merge( - showContentAs: values.fetch('showContentAs').last['PrimitiveValue'], - attribute: raw_attribute(entity, name), dynamicText: :undefined, - header: expression(text_value(values['header']&.last)), tooltip: :undefined, - filter: [], visible: expression(true), exportValue: :undefined + result = primitives(values).merge( + showContentAs: mode, attribute: attribute.empty? ? :undefined : raw_attribute(entity, name), + dynamicText: :undefined, header: expression(text_value(values['header']&.last)), + tooltip: :undefined, filter: compile_widgets(values['filter']&.last), + visible: expression(true), exportValue: :undefined ) - end # rubocop:enable Metrics/AbcSize + result[:dynamicText] = dynamic_text(values, entity, name) if mode == 'dynamicText' + result[:content] = templated_content(values) if mode == 'customContent' + result.merge!(association_filter(values)) if association_filter?(values) + result + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def association_filter?(values) + !association_steps(values).empty? && !filter_widget(values).nil? + end + + def association_filter(values) # rubocop:disable Metrics/AbcSize + filter = filter_widget(values) + steps = association_steps(values) + steps.flat_map { [_1['Association'], _1['DestinationEntity']] }.join('/') + association = steps.first['Association'].to_s + endpoint = steps.last['DestinationEntity'].to_s + caption = attribute_name(values).split('.').last + selectable_id = "#{data_source_id}$#{filter['Name']}" + operation_id = WebOperationCompiler.operation_id( + @page_name, "#{@widget['Name']}$#{filter['Name']}" + ) + { + filterAssociation: raw_property('ListAssociationProperty', { + type: filter_boolean(filter, 'multiSelect') ? 'ReferenceSet' : 'Reference', + entity: :undefined, path: '', attribute: association, endpointEntity: endpoint, + selectableObjectsId: selectable_id, filterable: true, dataSourceId: data_source_id + }), + filterAssociationOptions: raw_property('DatabaseObjectListProperty', { + dataSourceId: selectable_id, entity: endpoint, operationId: operation_id, + sort: [[caption, 'asc']] + }), + filterAssociationOptionLabel: raw_property('ListExpressionProperty', { + expression: { + expr: { type: 'variable', variable: 'currentObject', path: caption }, + args: { currentObject: { widget: widget_key, source: 'object' } } + }, dataSourceId: selectable_id + }) + } + end + + def raw_property(name, config) = { '$raw' => "#{name}(#{js_object(config)})" } + + def filter_widget(values) + array(values['filter']&.last&.fetch('Widgets', nil)).first + end + + def association_steps(values) + array(values['attribute']&.last&.dig('AttributeRef', 'EntityRef', 'Steps')) + end + + def filter_boolean(widget, key) + property_values(widget['Object'])[key]&.last&.fetch('PrimitiveValue', nil) == 'true' + end + + def dynamic_text(values, entity, name) + template = values['dynamicText']&.last&.fetch('TextTemplate', nil) + parameters = array(template&.fetch('Parameters', nil)) + return :undefined unless parameters.one? && !name.empty? + + variable = { type: 'variable', variable: 'currentObject', path: name } + expr = if attribute_type(entity, name) == 'DateTime' + { type: 'function', name: '_format', + parameters: [variable, { type: 'literal', value: JSON.generate(type: 'datetime') }] } + else + variable + end + raw_list_expression(expr) + end + + def raw_list_expression(expr) + payload = { + expression: { expr:, args: { currentObject: { widget: widget_key, source: 'object' } } }, + dataSourceId: data_source_id + } + { '$raw' => "ListExpressionProperty(#{js_object(payload)})" } + end + + def templated_content(values) + widgets = array(values['content']&.last&.fetch('Widgets', nil)) + rendered = @render_widgets.call(widgets, widget_key, entity_name) + { '$raw' => "TemplatedWidgetProperty(#{js_object( + dataSourceId: data_source_id, editable: false, + children: { '$raw' => "() => #{rendered}" } + )})" } + end + + def compile_widgets(value) + widgets = array(value&.fetch('Widgets', nil)) + return [] if widgets.empty? + + { '$raw' => @render_widgets.call(widgets, widget_key, entity_name) } + end def raw_attribute(entity, name) { @@ -119,11 +213,17 @@ def columns array(value['Objects']) end - def attribute_column?(object) - value = property_values(object)['attribute']&.last - value&.dig('AttributeRef', 'Attribute').to_s.include?('.') + def supported_column?(object) + values = property_values(object) + mode = values['showContentAs']&.last&.fetch('PrimitiveValue', nil) + return attribute_name(values).include?('.') if %w[attribute dynamicText].include?(mode) + return false unless mode == 'customContent' && @render_widgets + + array(values['content']&.last&.fetch('Widgets', nil)).any? end + def attribute_name(values) = values['attribute']&.last&.dig('AttributeRef', 'Attribute').to_s + def xpath_source property_values(@widget['Object'])['datasource']&.last&.fetch('DataSource', nil) end @@ -170,6 +270,8 @@ def widget_type end def document_index + return @source.document_index if @source.is_a?(SourceModel) + {}.tap { |index| @source.documents.each { index_document(_1, index) } } end @@ -182,6 +284,7 @@ def index_document(value, index) when Array then value.each { index_document(_1, index) } end end - end # rubocop:enable Metrics/ClassLength + end + # rubocop:enable Metrics end end diff --git a/lib/mxrb/compiler/gallery_bundle_compiler.rb b/lib/mxrb/compiler/gallery_bundle_compiler.rb index b72952d..ab8ecc9 100644 --- a/lib/mxrb/compiler/gallery_bundle_compiler.rb +++ b/lib/mxrb/compiler/gallery_bundle_compiler.rb @@ -171,6 +171,8 @@ def widget_type end def document_index + return @source.document_index if @source.is_a?(SourceModel) + {}.tap { |index| @source.documents.each { index_document(_1, index) } } end diff --git a/lib/mxrb/compiler/generic_widget_bundle_compiler.rb b/lib/mxrb/compiler/generic_widget_bundle_compiler.rb new file mode 100644 index 0000000..17c9bf1 --- /dev/null +++ b/lib/mxrb/compiler/generic_widget_bundle_compiler.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require 'json' +require 'zip' + +module Mxrb + module Compiler + # Compiles schema-described pluggable widgets whose properties use the standard client contracts. + # rubocop:disable Metrics + class GenericWidgetBundleCompiler + include ModelValues + + attr_reader :component_name, :module_path + + def initialize(source, page_name, widget, scope:, entity:, render_widgets: nil, key_prefix: 'p', + action_property: nil) + @source = source + @page_name = page_name + @widget = widget + @scope = scope + @entity = entity.to_s + @render_widgets = render_widgets + @key_prefix = key_prefix + @action_property = action_property + @index = source.document_index + @widget_type = find_widget_type + @values = property_values(widget['Object']) + @list_data_source = @values.values.find { _1.first == 'DataSource' }&.last + @component_name = widget_id.to_s.split('.').last + @module_path = widget_id.to_s.tr('.', '/') + end + + def supported? + present_identifier?(@component_name) && package_module? && + @values.values.all? { supported_property?(_1) } + end + + def render + properties = @values.each_with_object({}) do |(key, pair), result| + compiled = compile_property(pair) + result[key.to_sym] = compiled unless compiled.equal?(:undefined) + end + properties.merge!(key: widget_key, '$widgetId': widget_key, class: css_class) + "React.createElement($#{@component_name}, #{javascript(properties)})" + end + + private + + def widget_id = @widget_type&.fetch('WidgetId', nil) + + def find_widget_type + object_type_id = IO::BsonCodec.extract_id(@widget.dig('Object', 'TypePointer')) + @index.values.find do |document| + IO::BsonCodec.extract_id(document.dig('ObjectType', '$ID')) == object_type_id + end + end + + def property_values(object) + array(object&.fetch('Properties', nil)).filter_map do |property| + type = @index[IO::BsonCodec.extract_id(property['TypePointer'])] + next unless type + + [type.fetch('PropertyKey'), [type.dig('ValueType', 'Type'), property['Value']]] + end.to_h + end + + def supported_property?(pair) + type, value = pair + return true if %w[Boolean Integer Decimal Enumeration String System].include?(type) + return value['DataSource'].nil? || !database_list_property(value).nil? if type == 'DataSource' + return true if type == 'Association' + return true if type == 'Attribute' && value['AttributeRef'].nil? + return true if %w[Expression TextTemplate].include?(type) && expression_property(type, value) + return true if type == 'Attribute' && attribute_property(value) + return no_action?(value) || !compiled_action(value).nil? if type == 'Action' + return array(value['Widgets']).empty? || @render_widgets if type == 'Widgets' + return array(value['Objects']).empty? if type == 'Object' + + false + end + + def compile_property(pair) + type, value = pair + case type + when 'Boolean' then value['PrimitiveValue'] == 'true' + when 'Integer' then value['PrimitiveValue'].to_i + when 'Decimal' then value['PrimitiveValue'].to_f + when 'Enumeration', 'String' then value['PrimitiveValue'].to_s + when 'Expression', 'TextTemplate' then raw(expression_property(type, value)) + when 'DataSource' then database_list_property(value)&.then { raw(_1) } || :undefined + when 'Action' then compiled_action(value)&.then { raw(_1) } || :undefined + when 'Association' then :undefined + when 'Attribute' then attribute_property(value)&.then { raw(_1) } || :undefined + when 'Widgets' then compile_widgets(value) + when 'Object' then [] + else :undefined + end + end + + def compile_widgets(value) + widgets = array(value['Widgets']) + return [] if widgets.empty? + + @render_widgets.call(widgets) + end + + def expression_property(type, value) + expression = type == 'Expression' ? compile_expression(value['Expression']) : compile_template(value) + return unless expression + + args = expression_variables(expression).to_h do |variable| + [variable.to_sym, { widget: @scope, source: 'object' }] + end + property = @list_data_source ? 'ListExpressionProperty' : 'ExpressionProperty' + config = { expression: { expr: expression, args: } } + config[:dataSourceId] = widget_key if @list_data_source + "#{property}(#{javascript(config)})" + end + + def database_list_property(value) + source = value['DataSource'] || {} + return unless source['$Type'] == 'CustomWidgets$CustomWidgetXPathSource' + + entity = source.dig('EntityRef', 'Entity').to_s + return unless present_qualified_name?(entity) + + config = { + dataSourceId: widget_key, entity:, + operationId: WebOperationCompiler.operation_id(@page_name, @widget['Name']), + sort: sort_items(source) + } + "DatabaseObjectListProperty(#{javascript(config)})" + end + + def compiled_action(value) + action = value['Action'] || {} + return if action['$Type'].to_s.empty? || action['$Type'] == 'Forms$NoAction' + + @action_property&.call(action) + end + + def sort_items(source) + array(source.dig('SortBar', 'SortItems')).filter_map do |item| + attribute = item.dig('AttributeRef', 'Attribute').to_s.split('.').last + next unless present_identifier?(attribute) + + [attribute, item['SortOrder'].to_s.casecmp('descending').zero? ? 'desc' : 'asc'] + end + end + + def compile_expression(source) + value = source.to_s.strip + return { type: 'literal', value: nil } if value.empty? || value == 'empty' + return variable_expression(value) if value.match?(%r{\A\$[A-Za-z_]\w*(?:/[A-Za-z_]\w*)*\z}) + return { type: 'literalNumeric', value: } if value.match?(/\A-?\d+(?:\.\d+)?\z/) + return { type: 'literal', value: value == 'true' } if %w[true false].include?(value) + return { type: 'literal', value: value[1..-2] } if quoted?(value) + + nil + end + + def variable_expression(source) + _variable, *path = source.delete_prefix('$').split('/') + { type: 'variable', variable: 'currentObject' }.tap do |result| + result[:path] = path.join('/') unless path.empty? + end + end + + def compile_template(value) + template = value['TextTemplate'] + text = translated_text(template) + parameters = array(template&.fetch('Parameters', nil)) + return { type: 'literal', value: text } if parameters.empty? + + expressions = parameters.map { template_parameter(_1) } + return unless expressions.all? + return expressions.first if text == '{1}' && expressions.one? + + format_expression(text, expressions) + end + + def template_parameter(parameter) + attribute = parameter.dig('AttributeRef', 'Attribute').to_s + return compile_expression(parameter['Expression']) if attribute.empty? + + name = attribute.split('.').last + variable = { type: 'variable', variable: 'currentObject', path: name } + enumeration = enumeration_name(attribute) + return variable unless enumeration + + { type: 'function', name: 'getCaption', + parameters: [variable, { type: 'literal', value: enumeration }] } + end + + def format_expression(text, expressions) + pieces = text.split(/(\{\d+\})/).filter_map do |piece| + index = piece[/\A\{(\d+)\}\z/, 1] + index ? expressions[index.to_i - 1] : ({ type: 'literal', value: piece } unless piece.empty?) + end + pieces.compact.reduce { |left, right| { type: 'function', name: '+', parameters: [left, right] } } + end + + def attribute_property(value) + attribute = value.dig('AttributeRef', 'Attribute').to_s + return unless @scope && attribute.include?('.') + + entity, _, name = attribute.rpartition('.') + "AttributeProperty(#{javascript( + scope: @scope, path: '', entity:, attribute: name, + onChange: { type: 'doNothing', argMap: {}, config: {}, disabledDuringExecution: true }, + isList: false, validation: nil, formatting: {} + )})" + end + + def expression_variables(expression) + case expression + when Hash + own = expression[:type] == 'variable' ? [expression[:variable]] : [] + own + expression.values.flat_map { expression_variables(_1) } + when Array then expression.flat_map { expression_variables(_1) } + else [] + end.uniq + end + + def enumeration_name(attribute) + module_name, entity_name, attribute_name = attribute.split('.', 3) + domain = @source.units_of('DomainModels$DomainModel').find { _1.module_name == module_name } + entity = array(domain&.document&.fetch('Entities', nil)).find { _1['Name'] == entity_name } + member = array(entity&.fetch('Attributes', nil)).find { _1['Name'] == attribute_name } + member&.dig('NewType', 'Enumeration').to_s.then { _1.empty? ? nil : _1 } + end + + def translated_text(template) + items = array(template&.dig('Template', 'Items')) + items.find { _1['LanguageCode'] == 'en_US' }&.fetch('Text', '') || + items.first&.fetch('Text', '') || '' + end + + def package_module? + root = File.dirname(@source.path) + direct = File.join(root, 'widgets', "#{@module_path}.mjs") + return true if File.file?(direct) + + entry = "#{@module_path}.mjs" + Dir.glob(File.join(root, 'widgets', '*.mpk')).any? do |path| + Zip::File.open(path) { _1.find_entry(entry) } + rescue Zip::Error + false + end + end + + def no_action?(value) = value['Action'].nil? || value.dig('Action', '$Type') == 'Forms$NoAction' + def widget_key = "#{@key_prefix}.#{@page_name}.#{@widget['Name']}" + + def css_class + ["mx-name-#{@widget['Name']}", @widget.dig('Appearance', 'Class')] + .map(&:to_s).reject(&:empty?).uniq.join(' ') + end + + def present_identifier?(value) = value.to_s.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/) + + def present_qualified_name?(value) + value.to_s.split('.').length >= 2 && value.to_s.split('.').all? { present_identifier?(_1) } + end + + def quoted?(value) = value.start_with?("'", '"') && value.end_with?(value[0]) + def raw(value) = { '$raw' => value } + + def javascript(value) + return value['$raw'] if value.is_a?(Hash) && value.key?('$raw') + return "[#{value.map { javascript(_1) }.join(', ')}]" if value.is_a?(Array) + if value.is_a?(Hash) + return "{ #{value.map { |key, item| "#{JSON.generate(key)}: #{javascript(item)}" }.join(', ')} }" + end + + JSON.generate(value) + end + end + # rubocop:enable Metrics + end +end diff --git a/lib/mxrb/compiler/image_bundle_compiler.rb b/lib/mxrb/compiler/image_bundle_compiler.rb index e767157..c96030e 100644 --- a/lib/mxrb/compiler/image_bundle_compiler.rb +++ b/lib/mxrb/compiler/image_bundle_compiler.rb @@ -5,7 +5,8 @@ module Mxrb module Compiler # Compiles the static-image subset of the official React Image widget. - class ImageBundleCompiler # rubocop:disable Metrics/ClassLength + # rubocop:disable Metrics + class ImageBundleCompiler include ModelValues WIDGET_ID = 'com.mendix.widget.web.image.Image' @@ -42,33 +43,88 @@ def self.javascript(value) JSON.generate(value) end - def initialize(source, page_name, widget) + def initialize(source, page_name, widget, scope: nil, entity: nil, key_prefix: 'p', action_property: nil) @source = source @page_name = page_name @widget = widget + @scope = scope + @entity = entity + @key_prefix = key_prefix + @action_property = action_property @index = document_index @values = property_values(widget['Object']) end def supported? type = widget_type - type && type.fetch('WidgetId', nil) == WIDGET_ID && - primitive('datasource') == 'image' && image_uri + type && type.fetch('WidgetId', nil) == WIDGET_ID && supported_source? && supported_action? end def render # rubocop:disable Metrics/AbcSize values = primitive_properties.merge( key: widget_key, '$widgetId': widget_key, - imageObject: self.class.raw("WebStaticImageProperty({ image: { uri: #{JSON.generate(image_uri)} } })"), - imageUrl: self.class.raw(self.class.expression(text_value('imageUrl'))), alternativeText: self.class.raw(self.class.expression(text_value('alternativeText'))), class: css_class ) + if primitive('datasource') == 'image' + values[:imageObject] = self.class.raw( + "WebStaticImageProperty({ image: { uri: #{JSON.generate(image_uri)} } })" + ) + values[:imageUrl] = self.class.raw(self.class.expression(text_value('imageUrl'))) + else + values[:imageUrl] = self.class.raw(dynamic_image_url) + end + action = compiled_action + values[:onClick] = self.class.raw(action) if action "React.createElement($Image, #{self.class.javascript(values)})" end # rubocop:enable Metrics/AbcSize private + def supported_source? + return !image_uri.nil? if primitive('datasource') == 'image' + + primitive('datasource') == 'imageUrl' && !dynamic_image_url.nil? + end + + def supported_action? + pair = @values['onClick'] + action = pair&.last&.fetch('Action', nil) + action.nil? || action['$Type'] == 'Forms$NoAction' || !compiled_action.nil? + end + + def compiled_action + pair = @values['onClick'] + action = pair&.last&.fetch('Action', nil) + return if action.nil? || action['$Type'] == 'Forms$NoAction' + + @action_property&.call(action) + end + + def dynamic_image_url + parameter = image_url_parameter + attribute = parameter&.dig('AttributeRef', 'Attribute').to_s + return unless @scope && attribute.include?('.') + + path = array(parameter.dig('AttributeRef', 'EntityRef', 'Steps')).flat_map do |step| + [step['Association'], step['DestinationEntity']] + end.map(&:to_s).reject(&:empty?).join('/') + member = [path, attribute.split('.').last].reject(&:empty?).join('/') + config = { + expression: { + expr: { type: 'variable', variable: 'currentObject', path: member }, + args: { currentObject: { widget: @scope, source: 'object' } } + } + } + "ExpressionProperty(#{self.class.javascript(config)})" + end + + def image_url_parameter + template = @values['imageUrl']&.last&.fetch('TextTemplate', nil) + parameters = array(template&.fetch('Parameters', nil)) + parameters.one? ? parameters.first : nil + end + def primitive_properties @values.each_with_object({}) do |(key, (type, value)), result| compiled = case type @@ -115,7 +171,7 @@ def translated_text(template) items.first&.fetch('Text', '') || '' end - def widget_key = "p.#{@page_name}.#{@widget['Name']}" + def widget_key = "#{@key_prefix}.#{@page_name}.#{@widget['Name']}" def css_class ["mx-name-#{@widget['Name']}", @widget.dig('Appearance', 'Class')] @@ -140,6 +196,8 @@ def widget_type end def document_index + return @source.document_index if @source.is_a?(SourceModel) + {}.tap { |index| @source.documents.each { index_document(_1, index) } } end @@ -152,6 +210,7 @@ def index_document(value, index) when Array then value.each { index_document(_1, index) } end end - end # rubocop:enable Metrics/ClassLength + end + # rubocop:enable Metrics end end diff --git a/lib/mxrb/compiler/legacy_page_builder.rb b/lib/mxrb/compiler/legacy_page_builder.rb index 8a5dc82..96ab546 100644 --- a/lib/mxrb/compiler/legacy_page_builder.rb +++ b/lib/mxrb/compiler/legacy_page_builder.rb @@ -19,7 +19,7 @@ class LegacyPageBuilder Forms$DivContainer Forms$DropDown Forms$DynamicText Forms$ImageViewer Forms$Label Forms$LayoutGrid Forms$ListView Forms$RadioButtonGroup Forms$ReferenceSelector Forms$SnippetCallWidget Forms$StaticImageViewer Forms$TabControl Forms$TextArea - Forms$TextBox Forms$InputReferenceSetSelector + Forms$TextBox Forms$InputReferenceSetSelector CustomWidgets$CustomWidget ].freeze def initialize(source, web_root, profiles: [:dojo]) @@ -40,6 +40,21 @@ def build ) end + # Performs the same widget audit as #build without writing deployment files. + def audit + @unsupported.clear + @source.units_of('Forms$Page').each do |unit| + name = "#{unit.module_name}.#{unit.document['Name']}" + audit_unsupported_widgets(unit.document, name) + descendants(unit.document).select { _1['$Type'] == 'Forms$DataGrid' }.each_with_index do |grid, index| + compiler = LegacyDataGridCompiler.new(@source, name, grid, language: 'en_US', sequence: index + 1) + compiler.html + @unsupported[name].concat(compiler.unsupported) + end + end + frozen_unsupported + end + private def write_page(unit, language) diff --git a/lib/mxrb/compiler/nanoflow_program_compiler.rb b/lib/mxrb/compiler/nanoflow_program_compiler.rb index 9a53e61..a253b0a 100644 --- a/lib/mxrb/compiler/nanoflow_program_compiler.rb +++ b/lib/mxrb/compiler/nanoflow_program_compiler.rb @@ -6,13 +6,17 @@ module Mxrb module Compiler # Lowers supported client-flow graphs to the instruction programs consumed by the Mendix client. - class NanoflowProgramCompiler # rubocop:disable Metrics/ClassLength + # rubocop:disable Metrics + class NanoflowProgramCompiler include ModelValues attr_reader :unsupported def initialize(source) @source = source + @nanoflows = source.units_of('Microflows$Nanoflow').to_h do |unit| + ["#{unit.module_name}.#{unit.document['Name']}", unit] + end @programs = {} @unsupported = [] end @@ -38,48 +42,128 @@ def declarations def compile(unit, qualified_name) reference = "mxrbNanoflow_#{Digest::SHA256.hexdigest(qualified_name)[0, 12]}" @programs[qualified_name] = { reference:, declaration: nil } + (@variable_kind_stack ||= []) << variable_kinds(unit.document) + (@flow_stack ||= []) << qualified_name unsupported_count = @unsupported.length - instructions = ordered_nodes(unit.document).filter_map { compile_node(_1) } + instructions = compile_graph(unit.document) return @programs.delete(qualified_name) && nil if @unsupported.length > unsupported_count - program = { name: qualified_name, instructions: } + program = { name: qualified_name, useListParameterByReference: true, instructions: } @programs[qualified_name][:declaration] = "const #{reference} = #{js_literal(program)};" "() => #{reference}" + ensure + @flow_stack&.pop + @variable_kind_stack&.pop + end + + def variable_kinds(document) + array(document.dig('ObjectCollection', 'Objects')).each_with_object({}) do |object, result| + case object['$Type'] + when 'Microflows$MicroflowParameter' + result[object['Name']] = variable_kind(object['VariableType']) + when 'Microflows$ActionActivity' + action = object['Action'] || {} + result[action['VariableName']] = variable_kind(action['VariableType']) if action['VariableName'] + if %w[Microflows$CreateObjectAction + Microflows$CreateChangeAction].include?(action['$Type']) && action['VariableName'] + result[action['VariableName']] = 'object' + end + result[action['OutputVariableName']] = 'object' if action['OutputVariableName'] + result[action['ResultVariableName']] = 'primitive' if action['ResultVariableName'] + end + end end - def ordered_nodes(document) + def graph(document) objects = array(document.dig('ObjectCollection', 'Objects')) by_id = objects.to_h { [model_id(_1), _1] } - traverse(objects, by_id, flow_destinations(document)) + flows = array(document['Flows']).group_by { model_id(_1['OriginPointer']) } + [objects, by_id, flows] end - def flow_destinations(document) - array(document['Flows']).to_h do |flow| - [model_id(flow['OriginPointer']), model_id(flow['DestinationPointer'])] - end + def compile_graph(document) + objects, by_id, flows = graph(document) + start = objects.find { _1['$Type'] == 'Microflows$StartEvent' } + ordered = reachable_nodes(start, by_id, flows) + ordered.flat_map { compile_graph_node(_1, flows.fetch(model_id(_1), [])) }.compact end - def traverse(objects, by_id, destinations) - current = objects.find { _1['$Type'] == 'Microflows$StartEvent' } + def reachable_nodes(start, by_id, flows) + queue = (start ? [start] : []).flat_map { flows.fetch(model_id(_1), []) } + .map { by_id[model_id(_1['DestinationPointer'])] } visited = {} [].tap do |result| - while current && !visited[model_id(current)] + until queue.empty? + current = queue.shift + next unless current + next if visited[model_id(current)] + visited[model_id(current)] = true - result << current unless current['$Type'] == 'Microflows$StartEvent' - current = by_id[destinations[model_id(current)]] + result << current + queue.concat(flows.fetch(model_id(current), []).map do |flow| + by_id[model_id(flow['DestinationPointer'])] + end) end end end - def compile_node(node) + def compile_graph_node(node, flows) + error_flows = flows.select { _1['IsErrorHandler'] == true } + normal_flows = flows.reject { _1['IsErrorHandler'] == true } + instruction = if error_flows.empty? + compile_node(node, normal_flows) + else + compile_try_catch(node, normal_flows, error_flows) + end + instructions = instruction.is_a?(Array) ? instruction : [instruction].compact + return instructions if terminal_node?(node) || instruction.nil? + return instructions unless normal_flows.one? + + instructions << { type: 'jump', label: "#{model_id(node)}$next", + target: model_id(normal_flows.first['DestinationPointer']) } + end + + def compile_try_catch(node, normal_flows, error_flows) + return unsupported!(node, 'Microflows$ErrorHandler') unless normal_flows.one? && error_flows.one? + + compiled = compile_node(node, normal_flows) + body = (compiled.is_a?(Array) ? compiled : [compiled]).compact.map { _1.except(:label) } + return if body.empty? + + body << { type: 'return', result: { type: 'literal', value: true }, resultKind: 'primitive' } + { + type: 'tryCatch', label: model_id(node), + catchTarget: model_id(error_flows.first['DestinationPointer']), body: + } + end + + def terminal_node?(node) + %w[Microflows$EndEvent Microflows$ExclusiveSplit Microflows$ExclusiveMerge] + .include?(node['$Type']) + end + + def compile_node(node, flows = []) return compile_end(node) if node['$Type'] == 'Microflows$EndEvent' + return compile_split(node, flows) if node['$Type'] == 'Microflows$ExclusiveSplit' + return compile_merge(node, flows) if node['$Type'] == 'Microflows$ExclusiveMerge' return unsupported!(node, node['$Type']) unless node['$Type'] == 'Microflows$ActionActivity' action = node['Action'] || {} case action['$Type'] - when 'Microflows$CreateVariableAction' then compile_variable(action) - when 'Microflows$CreateObjectAction' then compile_create_object(action) - when 'Microflows$NanoflowCallAction' then compile_nanoflow_call(action) + when 'Microflows$CreateVariableAction' then compile_variable(action, node) + when 'Microflows$ChangeVariableAction' then compile_change_variable(action, node) + when 'Microflows$CreateObjectAction' then compile_create_object(action, node) + when 'Microflows$CreateChangeAction' then compile_create_object(action, node) + when 'Microflows$ChangeAction' then compile_change_object(action, node) + when 'Microflows$NanoflowCallAction' then compile_nanoflow_call(action, node) + when 'Microflows$MicroflowCallAction' then compile_microflow_call(action, node) + when 'Microflows$JavaScriptActionCallAction' then compile_javascript_call(action, node) + when 'Microflows$ShowFormAction' then compile_show_form(action, node) + when 'Microflows$CloseFormAction' then compile_close_form(action, node) + when 'Microflows$ShowMessageAction' then compile_show_message(action, node) + when 'Microflows$ValidationFeedbackAction' then compile_validation(action, node) + when 'Microflows$LogMessageAction' then compile_log(action, node) + when 'Microflows$CommitAction' then compile_commit(action, node) else unsupported!(node, action['$Type']) end end @@ -89,29 +173,209 @@ def compile_end(node) resultKind: expression_kind(node['ReturnValue']) } end - def compile_variable(action) - { type: 'createVariable', label: model_id(action), outputVar: action['VariableName'], - value: expression(action['InitialValue']) } + def compile_variable(action, node) + { type: 'setVariable', label: model_id(node), outputVar: action['VariableName'], + outputKind: variable_kind(action['VariableType']), value: expression(action['InitialValue']) } end - def compile_create_object(action) - { type: 'createObject', label: model_id(action), objectType: action['Entity'], - outputVar: action['OutputVariableName'] || action['VariableName'] } + def compile_change_variable(action, node) + { type: 'setVariable', label: model_id(node), outputVar: action['ChangeVariableName'], + outputKind: 'primitive', value: expression(action['Value']) } end - def compile_nanoflow_call(action) + def compile_create_object(action, node) + variable = action['OutputVariableName'] || action['VariableName'] + create = { type: 'createObject', label: model_id(node), objectType: action['Entity'], + outputVar: variable } + instructions = [create] + change_instructions(action, variable, "#{model_id(node)}$change") + instructions << compile_commit(action, node, label: false) if action['Commit'] == 'Yes' + instructions.compact + end + + def compile_change_object(action, node) + variable = action['ChangeVariableName'] + changes = change_instructions(action, variable, model_id(node)) + changes << compile_commit(action, node, label: false) if action['Commit'] == 'Yes' + changes.compact + end + + def change_instructions(action, variable, label) + array(action['Items']).map.with_index do |item, index| + member = item['Attribute'].to_s.split('.').last + next unsupported!(item, item['$Type']) if member.empty? || item['Type'] != 'Set' + + { type: 'changeObject', label: index.zero? ? label : "#{label}$#{index}", + inputVar: variable, member:, value: expression(item['Value']) } + end.compact + end + + def compile_nanoflow_call(action, node = action) call = action['NanoflowCall'] || {} target = call['Nanoflow'].to_s reference = reference(target) return unsupported!(action, action['$Type']) unless reference - result = { type: 'nanoflowCall', label: model_id(action), flow: raw(reference), + result = { type: 'nanoflowCall', label: model_id(node), flow: raw(reference), parameters: call_parameters(call) } output = action['OutputVariableName'].to_s result[:outputVar] = output unless output.empty? result end + def compile_microflow_call(action, node) + call = action['MicroflowCall'] || {} + name = call['Microflow'].to_s + return unsupported!(action, action['$Type']) if name.empty? + + result = { + type: 'microflowCall', label: model_id(node), + operationId: WebOperationCompiler.operation_id(current_flow, model_id(node)), + parameters: call_parameters(call) + } + output = action['ResultVariableName'].to_s + result[:outputVar] = output if action['UseReturnVariable'] == true && !output.empty? + result + end + + def compile_javascript_call(action, node) + qualified = action['JavaScriptAction'].to_s + unit = @source.units_of('JavaScriptActions$JavaScriptAction').find do |candidate| + "#{candidate.module_name}.#{candidate.document['Name']}" == qualified + end + return unsupported!(action, action['$Type']) unless unit + + result = { + type: 'javaScriptActionCall', label: model_id(node), action: raw(javascript_reference(unit)), + parameters: javascript_parameters(action, unit) + } + output = action['OutputVariableName'].to_s + result[:outputVar] = output if action['UseReturnVariable'] == true && !output.empty? + result + end + + def javascript_reference(unit) + module_path = unit.module_name.downcase + name = unit.document['Name'] + path = File.join(File.dirname(@source.path), 'javascriptsource', module_path, 'actions', name) + "() => require(#{JSON.generate(path)}).#{name}" + end + + def javascript_parameters(action, unit) + parameter_types = array(unit.document['Parameters']).to_h do |parameter| + [parameter['Name'], javascript_parameter_kind(parameter)] + end + array(action['ParameterMappings']).map do |mapping| + name = mapping['Parameter'].to_s.split('.').last + value = mapping['ParameterValue'] || {} + argument = value['Argument'] || quoted_entity(value['Entity']) + { kind: parameter_types.fetch(name, 'primitive'), value: expression(argument) } + end + end + + def quoted_entity(entity) + entity.to_s.empty? ? nil : JSON.generate(entity.to_s) + end + + def javascript_parameter_kind(parameter) + type = parameter.dig('ParameterType', 'Type', '$Type').to_s + return 'object' if type.include?('EntityType') + return 'list' if type.include?('ListType') + + 'primitive' + end + + def compile_split(node, flows) + targets = flows.to_h do |flow| + case_value = array(flow['CaseValues']).first + key = case_value&.fetch('Value', nil).to_s + key = '' if case_value&.fetch('$Type', nil) == 'Microflows$NoCase' + [key, model_id(flow['DestinationPointer'])] + end + condition = node.dig('SplitCondition', 'Expression') + return unsupported!(node, node['$Type']) if targets.empty? || condition.to_s.empty? + + { type: 'switch', label: model_id(node), condition: expression(condition), targets: } + end + + def compile_merge(node, flows) + target = flows.one? && model_id(flows.first['DestinationPointer']) + return unsupported!(node, node['$Type']) unless target + + { type: 'jump', label: model_id(node), target: } + end + + def compile_show_form(action, node) + settings = action['FormSettings'] || {} + form = settings['Form'].to_s + return unsupported!(action, action['$Type']) if form.empty? + + path = "#{form.tr('.', '/')}.page.xml" + params = { name: path, location: 'modal', resizable: false } + mappings = array(settings['ParameterMappings']).to_h do |mapping| + ["$#{mapping['Parameter'].to_s.split('.').last}", expression(mapping['Argument'])] + end + { type: 'openForm', label: model_id(node), path:, params: }.tap do |instruction| + instruction[:inputArgs] = mappings unless mappings.empty? + end + end + + def compile_close_form(action, node) + value = action['NumberOfPagesToClose'].to_s + { type: 'closeForm', label: model_id(node) }.tap do |instruction| + instruction[:numberOfPagesToClose] = expression(value) unless value.empty? + end + end + + def compile_show_message(action, node) + { type: 'showMessage', label: model_id(node), + message: text_template_expression(action['Template']), + messageType: action['Type'].to_s.downcase, blocking: action['Blocking'] == true } + end + + def compile_validation(action, node) + member = action['Attribute'].to_s.split('.').last + return unsupported!(action, action['$Type']) if member.empty? + + { type: 'showValidation', label: model_id(node), inputVar: action['ValidationVariableName'], + member:, text: text_template_expression(action['FeedbackTemplate']) } + end + + def compile_log(action, node) + { type: 'writeLog', label: model_id(node), level: action['Level'].to_s.downcase, + message: text_template_expression(action['MessageTemplate']) } + end + + def compile_commit(action, node, label: true) + input = (action['CommitVariableName'] || action['ChangeVariableName'] || + action['OutputVariableName'] || action['VariableName']).to_s + return unsupported!(action, action['$Type']) if input.empty? + + { type: 'commitObjects', inputVar: input, + operationId: WebOperationCompiler.operation_id(current_flow, model_id(node)) }.tap do |result| + result[:label] = model_id(node) if label + end + end + + def text_template_expression(template) + text = template&.fetch('Text', nil) + value = if text.is_a?(Hash) + items = array(text['Items']) + items.find { _1['LanguageCode'] == 'en_US' }&.fetch('Text', nil) || + items.first&.fetch('Text', nil).to_s + else + text.to_s + end + parameters = array(template&.fetch('Parameters', nil)) + return expression(value) if parameters.empty? + + arguments = parameters.map { expression(_1['Expression'] || _1['Argument']) } + { type: 'function', name: 'formatString', parameters: [{ type: 'literal', value: }] + arguments } + end + + def variable_kind(type) + type&.fetch('$Type', nil).to_s.include?('Object') ? 'object' : 'primitive' + end + def call_parameters(call) array(call['ParameterMappings']).filter_map do |mapping| next unless mapping.is_a?(Hash) @@ -124,7 +388,17 @@ def call_parameters(call) def expression(value) source = value.to_s.strip return { type: 'literal', value: nil } if source.empty? || source == 'empty' - return { type: 'variable', variable: source.delete_prefix('$') } if source.start_with?('$') + + conditional = conditional_expression(source) + return conditional if conditional + + function = function_expression(source) + return function if function + + binary = binary_expression(source) + return binary if binary + return variable_expression(source) if source.match?(%r{\A\$[A-Za-z_]\w*(?:/[A-Za-z_]\w*)*\z}) + return { type: 'constant', name: source.delete_prefix('@') } if source.start_with?('@') return { type: 'literal', value: source == 'true' } if %w[true false].include?(source) return { type: 'literal', value: source[1..-2] } if quoted?(source) return { type: 'literalNumeric', value: source } if source.match?(/\A-?\d+(?:\.\d+)?\z/) @@ -133,7 +407,40 @@ def expression(value) end def expression_kind(value) - value.to_s.strip.start_with?('$') ? 'object' : 'primitive' + name = value.to_s.strip[/\A\$([A-Za-z_]\w*)/, 1] + name ? current_variable_kinds.fetch(name, 'primitive') : 'primitive' + end + + def current_variable_kinds = @variable_kind_stack&.last || {} + + def variable_expression(source) + variable, *path = source.delete_prefix('$').split('/') + { type: 'variable', variable: }.tap do |result| + result[:path] = path.join('/') unless path.empty? + end + end + + def conditional_expression(source) + match = source.match(/\Aif\s+(.+?)\s+then\s+(.+?)\s+else\s+(.+)\z/im) + return unless match + + { type: 'conditional', condition: expression(match[1]), + then: expression(match[2]), else: expression(match[3]) } + end + + def function_expression(source) + match = source.match(/\A(not|isNew|isSynced)\((.*)\)\z/im) + return unless match + + { type: 'function', name: match[1], parameters: [expression(match[2])] } + end + + def binary_expression(source) + match = source.match(/\A(.+?)\s+(and|or|!=|=|>=|<=|>|<)\s+(.+)\z/im) + return unless match + + { type: 'function', name: match[2].downcase, + parameters: [expression(match[1]), expression(match[3])] } end def quoted?(value) @@ -142,16 +449,16 @@ def quoted?(value) end def unsupported!(node, type) - unit = @programs.keys.last + unit = current_flow label = type.to_s.empty? ? node['$Type'] : type @unsupported << "#{unit}:#{label}" nil end + def current_flow = @flow_stack&.last || @programs.keys.last + def nanoflow_unit(qualified_name) - @source.units_of('Microflows$Nanoflow').find do |unit| - "#{unit.module_name}.#{unit.document['Name']}" == qualified_name - end + @nanoflows[qualified_name] end def model_id(value) @@ -170,6 +477,7 @@ def js_literal(value) JSON.generate(value) end - end # rubocop:enable Metrics/ClassLength + end + # rubocop:enable Metrics end end diff --git a/lib/mxrb/compiler/page_bundle_builder.rb b/lib/mxrb/compiler/page_bundle_builder.rb index 6dc2c40..ee56c37 100644 --- a/lib/mxrb/compiler/page_bundle_builder.rb +++ b/lib/mxrb/compiler/page_bundle_builder.rb @@ -6,6 +6,7 @@ module Mxrb module Compiler # Writes one page entry module per source page and an auditable support manifest. + # rubocop:disable Metrics class PageBundleBuilder def initialize(source, web_root) @source = source @@ -16,24 +17,40 @@ def build destination = File.join(@web_root, 'pages') FileUtils.mkdir_p(destination) Dir.glob(File.join(destination, '**', '*.js')).each { FileUtils.rm_f(_1) } - bundles = @source.units_of('Forms$Page').map { PageBundleCompiler.new(@source).compile(_1) } + bundles = @source.units_of('Forms$Page').select { web_page?(_1) } + .map { PageBundleCompiler.new(@source).compile(_1) } bundles.each { write_bundle(destination, _1) } + layout_bundles = build_layouts write_manifest(bundles) - bundles + bundles + layout_bundles end private + def web_page?(unit) = !@source.is_a?(SourceModel) || @source.web_page?(unit) + def web_layout?(unit) = !@source.is_a?(SourceModel) || @source.web_layout?(unit) + def write_bundle(destination, bundle) path = File.join(destination, "#{bundle.qualified_name}.js") FileUtils.mkdir_p(File.dirname(path)) File.write(path, bundle.source) end + def build_layouts + destination = File.join(@web_root, 'layouts') + FileUtils.mkdir_p(destination) + Dir.glob(File.join(destination, '**', '*.js')).each { FileUtils.rm_f(_1) } + bundles = @source.units_of('Forms$Layout').select { web_layout?(_1) } + .map { PageBundleCompiler.new(@source).compile_layout(_1) } + bundles.each { write_bundle(destination, _1) } + bundles + end + def write_manifest(bundles) manifest = bundles.to_h { [_1.qualified_name, _1.unsupported_widgets] } File.write(File.join(@web_root, 'mxrb-pages.json'), JSON.pretty_generate(manifest)) end end + # rubocop:enable Metrics end end diff --git a/lib/mxrb/compiler/page_bundle_compiler.rb b/lib/mxrb/compiler/page_bundle_compiler.rb index bcd9f81..f4ac102 100644 --- a/lib/mxrb/compiler/page_bundle_compiler.rb +++ b/lib/mxrb/compiler/page_bundle_compiler.rb @@ -4,34 +4,56 @@ module Mxrb module Compiler - PageBundle = Data.define(:qualified_name, :source, :unsupported_widgets) + PageBundle = Data.define(:qualified_name, :source, :unsupported_widgets, :unsupported_custom_widgets) # Converts the web-page subset of the source model into a Runtime-loadable ES module. - class PageBundleCompiler # rubocop:disable Metrics/ClassLength + # rubocop:disable Metrics, Style/MultilineBlockChain + class PageBundleCompiler include ModelValues def initialize(source) @source = source @unsupported = [] + @unsupported_custom = [] @uses_conditional = false @uses_dynamic_class = false end def compile(unit) + prepare_compile(unit, 'p') + build_bundle(module_source(page_content)) + end + + def compile_layout(unit) + prepare_compile(unit, 'l') + @layout_mode = true + widgets = array(unit.document.dig('Content', 'Widgets')) + build_bundle(layout_module_source(children(widgets))) + end + + private + + def prepare_compile(unit, prefix) @unit = unit @qualified_name = "#{unit.module_name}.#{unit.document['Name']}" + @scope_prefix = prefix + @layout_mode = false @data_view_scopes = [] @list_scopes = [] + @snippet_scopes = [] + @snippet_stack = [] + @generic_widgets = {} @nanoflow_programs = NanoflowProgramCompiler.new(@source) - content = page_content + end + + def build_bundle(source) PageBundle.new( - qualified_name: @qualified_name, source: module_source(content), - unsupported_widgets: @unsupported.uniq.sort.freeze + qualified_name: @qualified_name, source:, + unsupported_widgets: @unsupported.uniq.sort.freeze, + unsupported_custom_widgets: @unsupported_custom.uniq.sort.freeze ) end - private - def arguments = array(@unit.document.dig('FormCall', 'Arguments')) def page_content @@ -45,8 +67,9 @@ def page_content end def slot_name(parameter) - name = parameter.to_s.split('.').last.to_s - raise CompilationError, "invalid page slot #{parameter.inspect}" unless present_identifier?(name) + name = parameter.to_s + valid = name.split('.').length >= 2 && name.split('.').all? { present_identifier?(_1) } + raise CompilationError, "invalid page slot #{parameter.inspect}" unless valid name end @@ -57,7 +80,6 @@ def content_function(widgets) def children(widgets) = "[#{widgets.map { render_widget(_1) }.join(', ')}]" - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def render_widget(widget) rendered = case widget['$Type'] when 'Forms$DivContainer' then render_container(widget) @@ -65,21 +87,34 @@ def render_widget(widget) when 'Forms$LayoutGridRow' then render_grid_row(widget) when 'Forms$LayoutGridColumn' then render_grid_column(widget) when 'Forms$DynamicText' then render_text(widget) + when 'Forms$Title' then render_title(widget) when 'Forms$ActionButton' then render_action_button(widget) when 'Forms$DataView' then render_data_view(widget) + when 'Forms$ListView' then render_list_view(widget) when 'Forms$TextBox' then render_text_box(widget) + when 'Forms$TextArea' then render_text_area(widget) when 'Forms$DatePicker' then render_date_picker(widget) when 'Forms$CheckBox' then render_check_box(widget) + when 'Forms$RadioButtonGroup' then render_radio_button_group(widget) + when 'Forms$FileManager' then render_file_manager(widget) + when 'Forms$GroupBox' then render_group_box(widget) + when 'Forms$SnippetCallWidget' then render_snippet_call(widget) when 'Forms$Label' then render_label(widget) when 'Forms$TabControl' then render_tab_control(widget) when 'Forms$StaticImageViewer' then render_static_image(widget) + when 'Forms$ScrollContainer' then render_scroll_container(widget) + when 'Forms$Placeholder' then render_placeholder(widget) + when 'Forms$SidebarToggleButton' then render_sidebar_toggle(widget) + when 'Forms$Header' then render_header(widget) + when 'Forms$NavigationTree' then render_menu(widget, 'NavigationTree') + when 'Forms$MenuBar' then render_menu(widget, 'MenuBar') + when 'Forms$SimpleMenuBar' then render_menu(widget, 'SimpleMenuBar') when 'CustomWidgets$CustomWidget' then render_custom_widget(widget) else render_unsupported(widget) end rendered = wrap_dynamic_classes(widget, rendered) wrap_conditional_visibility(widget, rendered) end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def render_layout_grid(widget) render_element('div', widget, array(widget['Rows']), 'mx-layoutgrid mx-layoutgrid-fluid') @@ -96,6 +131,139 @@ def render_grid_column(widget) render_element('div', widget, array(widget['Widgets']), classes) end + def render_scroll_container(widget) + @uses_layout_widgets = true + props = common_props(widget).merge( + '$widgetId': widget_key(widget), class: css_class(widget), + scrollPerRegion: widget['ScrollBehavior'] == 'PerRegion', + layoutMode: widget['LayoutMode'].to_s.downcase, + top: scroll_region(widget['Top']), bottom: scroll_region(widget['Bottom']), + left: scroll_region(widget['Left']), right: scroll_region(widget['Right']), + center: scroll_region(widget['CenterRegion']) + ) + "React.createElement($ScrollContainer, #{js_literal(props)})" + end + + def scroll_region(region) + return { enabled: false } unless region + + { + enabled: true, content: raw_js(children(array(region['Widgets']))), + sizeMode: region['SizeMode'].to_s.downcase, sizeValue: region['Size'], + class: region.dig('Appearance', 'Class').to_s, + toggleMode: region['ToggleMode'].to_s.downcase, + initiallyOpen: !region['ToggleMode'].to_s.include?('InitiallyClosed') + } + end + + def render_placeholder(widget) + @uses_layout_widgets = true + key = widget_key(widget) + identifier = "#{@qualified_name}.#{widget['Name']}" + "React.createElement($Placeholder, #{js_props( + common_props(widget).merge('$widgetId': key), + expressions: { content: "PlaceholderProperty({ id: #{JSON.generate(identifier)} })" } + )})" + end + + def render_sidebar_toggle(widget) + @uses_layout_widgets = true + key = widget_key(widget) + props = common_props(widget).merge( + '$widgetId': key, buttonId: key, class: css_class(widget), + renderType: 'button', buttonClass: button_style(widget) + ) + "React.createElement($SidebarToggle, #{js_props(props)})" + end + + def render_header(widget) + @uses_layout_widgets = true + props = common_props(widget).merge( + '$widgetId': widget_key(widget), class: css_class(widget), content: [], + leftWidgets: array(widget['LeftWidgets']).map { render_widget(_1) }, + rightWidgets: array(widget['RightWidgets']).map { render_widget(_1) } + ) + "React.createElement($Header, #{js_props(props)})" + end + + def render_menu(widget, component) + items = menu_items(widget['MenuSource']) + return render_unsupported(widget) unless items + + @uses_layout_widgets = true + props = common_props(widget).merge( + '$widgetId': widget_key(widget), class: css_class(widget), + menu: items.map.with_index { |item, index| compile_menu_item(item, widget, [index]) } + ) + props[:orientation] = widget['Orientation'].to_s.downcase if component == 'SimpleMenuBar' + "React.createElement($#{component}, #{js_literal(props)})" + end + + def menu_items(source) + case source&.fetch('$Type', nil) + when 'Forms$MenuDocumentSource' + qualified = source['Menu'].to_s + unit = @source.units_of('Menus$MenuDocument').find do |candidate| + "#{candidate.module_name}.#{candidate.document['Name']}" == qualified + end + array(unit&.document&.dig('ItemCollection', 'Items')) if unit + when 'Forms$NavigationSource' + profile = @source.units_of('Navigation$NavigationDocument').flat_map do |unit| + array(unit.document['Profiles']) + end.find { _1['Name'] == source['NavigationProfile'] } + array(profile&.dig('Menu', 'Items')) if profile + end + end + + def compile_menu_item(item, widget, path) + result = { + caption: raw_js("TextProperty({ value: #{JSON.generate(translated_text(item['Caption']))} })") + } + action = menu_action_config(item['Action'] || {}) + result[:action] = raw_js("ActionProperty(#{js_literal(action)})") if action + icon = menu_icon_property(item['Icon']) + result[:icon] = raw_js(icon) if icon + nested_items = array(item['Items']) + unless nested_items.empty? + result[:items] = nested_items.map.with_index do |child, index| + compile_menu_item(child, widget, path + [index]) + end + end + result + end + + def menu_action_config(action) + payload = open_page_config(action) || menu_microflow_config(action) + return unless payload + + { action: payload, skipClientValidation: true } + end + + def menu_microflow_config(action) + return unless action['$Type'] == 'Forms$MicroflowAction' + + name = action.dig('MicroflowSettings', 'Microflow').to_s + return unless present_identifier?(name) && microflow_argument_map(action['MicroflowSettings'] || {}) == {} + + { + type: 'callMicroflow', argMap: {}, + config: { + operationId: WebOperationCompiler.menu_operation_id(action), validate: 'view', + allowedRoles: allowed_roles_for('Microflows$Microflow', name) + }, + disabledDuringExecution: action.fetch('DisabledDuringExecution', true) + } + end + + def menu_icon_property(icon) + reference = icon&.fetch('Image', '').to_s + name = reference.split('.').last + return if name.to_s.empty? + + css = name.gsub(/([a-z\d])([A-Z])/, '\\1-\\2').tr('_', '-').downcase + "WebIconProperty({ icon: { type: \"icon\", iconClass: \"mx-icon-filled mx-icon-#{css}\" } })" + end + def grid_weight_class(size, weight) value = Integer(weight || -1) "col-#{size}-#{value}" if (1..12).cover?(value) @@ -109,9 +277,11 @@ def render_element(tag, widget, widgets, base_class) "React.createElement(#{JSON.generate(tag)}, #{js_props(props)})" end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def render_custom_widget(widget) - grid = DataGridBundleCompiler.new(@source, @qualified_name, widget) + grid = DataGridBundleCompiler.new( + @source, @qualified_name, widget, + render_widgets: ->(widgets, scope, entity) { render_scoped_widgets(widgets, scope, entity) } + ) if grid.supported? @uses_data_grid = true return grid.render @@ -129,7 +299,16 @@ def render_custom_widget(widget) return gallery.render(content, nanoflow_reference: nano_reference) end - image = ImageBundleCompiler.new(@source, @qualified_name, widget) + object_scope = current_object_scope + image = ImageBundleCompiler.new( + @source, @qualified_name, widget, + scope: object_scope&.fetch(:scope, nil), entity: object_scope&.fetch(:entity, nil), + key_prefix: @scope_prefix, + action_property: lambda do |action| + config = client_action_config(widget, action) + "ActionProperty(#{js_literal(config)})" if config + end + ) if image.supported? @uses_image = true @uses_custom_image = true @@ -138,17 +317,41 @@ def render_custom_widget(widget) combo = ComboBoxBundleCompiler.new( @source, @qualified_name, widget, - scope: scope_name(@data_view_scopes.last), entity: @data_view_scopes.last&.fetch(:entity, nil) + scope: scope_name(@data_view_scopes&.last), entity: @data_view_scopes&.last&.fetch(:entity, nil) ) - return render_unsupported(widget) unless combo.supported? + if combo.supported? + @uses_form_widgets = true + @uses_combo_box = true + return combo.render + end - @uses_form_widgets = true - @uses_combo_box = true - combo.render + generic = GenericWidgetBundleCompiler.new( + @source, @qualified_name, widget, + scope: object_scope&.fetch(:scope, nil), entity: object_scope&.fetch(:entity, nil), + render_widgets: ->(widgets) { raw_js(children(widgets)) }, key_prefix: @scope_prefix, + action_property: lambda do |action| + config = if nanoflow_action?(action) + nanoflow_action_config(action) + else + client_action_config(widget, action) + end + "ActionProperty(#{js_literal(config)})" if config + end + ) + return render_unsupported(widget) unless generic.supported? + + @generic_widgets[generic.component_name] = generic.module_path + generic.render end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - def render_container(widget) # rubocop:disable Metrics/AbcSize + def render_scoped_widgets(widgets, scope, entity) + @list_scopes << { scope:, entity: } + children(widgets) + ensure + @list_scopes.pop + end + + def render_container(widget) action = widget['OnClickAction'] || {} action_config = container_action_config(widget, action) return render_action_container(widget, action_config) if action_config @@ -160,7 +363,7 @@ def render_container(widget) # rubocop:disable Metrics/AbcSize props[:role] = 'button' if handler "React.createElement(#{JSON.generate(render_mode(widget))}, " \ "#{js_props(props, expressions: { onClick: handler }.compact)})" - end # rubocop:enable Metrics/AbcSize + end def render_action_container(widget, action_config) @uses_form_widgets = true @@ -189,6 +392,64 @@ def render_text(widget) "React.createElement(#{JSON.generate(text_mode(widget))}, #{js_props(props)}, #{JSON.generate(caption)})" end + def render_title(widget) + props = common_props(widget).merge(className: css_class(widget)) + "React.createElement(\"h1\", #{js_props(props)}, #{JSON.generate(page_title)})" + end + + def render_group_box(widget) + @uses_form_widgets = true + @uses_group_box = true + collapsible = case widget['Collapsible'] + when 'YesInitiallyCollapsed' then 'yes' + when 'YesInitiallyExpanded' then 'expanded' + else 'no' + end + props = common_props(widget).merge( + '$widgetId': widget_key(widget), class: css_class(widget), id: widget_key(widget), + renderMode: render_mode('RenderMode' => widget['HeaderMode']), collapsible:, + content: array(widget['Widgets']).map { render_widget(_1) } + ) + caption = translated_text(widget.dig('CaptionTemplate', 'Template')) + "React.createElement($GroupBox, #{js_props(props, expressions: { + header: "TextProperty({ value: #{JSON.generate(caption)} })" + })})" + end + + def render_snippet_call(widget) + qualified = widget.dig('FormCall', 'Form').to_s + snippet = snippet_index[qualified] + return render_unsupported(widget) unless snippet && !@snippet_stack.include?(qualified) + + @snippet_stack << qualified + @snippet_scopes << snippet_scope_map(snippet.document) + rendered = children(array(snippet.document['Widgets'])) + @snippet_scopes.pop + @snippet_stack.pop + "React.createElement(React.Fragment, #{js_props(common_props(widget))}, #{rendered})" + ensure + if @snippet_stack&.last == qualified + @snippet_scopes.pop + @snippet_stack.pop + end + end + + def snippet_scope_map(document) + scope = current_object_scope + return {} unless scope + + object_parameters = array(document['Parameters']).select do |parameter| + parameter.dig('ParameterType', '$Type') == 'DataTypes$ObjectType' + end + object_parameters.to_h { [_1['Name'].to_s, scope] } + end + + def snippet_index + @snippet_index ||= @source.units_of('Forms$Snippet').to_h do |unit| + ["#{unit.module_name}.#{unit.document['Name']}", unit] + end + end + def render_bound_text(widget) scope = current_object_scope attributes = bound_text_attributes(widget) @@ -247,7 +508,7 @@ def qualified_attribute?(attribute) end def current_object_scope - scope = @list_scopes.last || @data_view_scopes.last + scope = @list_scopes&.last || @data_view_scopes&.last return scope if scope.is_a?(Hash) return unless scope @@ -256,7 +517,7 @@ def current_object_scope def scope_name(scope) = scope.is_a?(Hash) ? scope[:scope] : scope - def wrap_conditional_visibility(widget, rendered) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def wrap_conditional_visibility(widget, rendered) condition = conditional_visibility(widget['ConditionalVisibilitySettings']) return rendered unless condition @@ -270,7 +531,7 @@ def wrap_conditional_visibility(widget, rendered) # rubocop:disable Metrics/AbcS visibility_key = "#{widget_key(widget)}$visibility" props = common_props(widget).merge(key: visibility_key, '$widgetId': visibility_key) "React.createElement($MxrbConditional, #{js_props(props, expressions:)}, #{rendered})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end def conditional_visibility(settings) expression = settings&.fetch('Expression', '').to_s.strip @@ -299,7 +560,7 @@ def logical_predicate(parts, attributes, operator) "(#{predicates.join(" #{operator} ")})" end - def visibility_atom(expression, attributes) # rubocop:disable Metrics/MethodLength + def visibility_atom(expression, attributes) match = expression.strip.match( %r{\A\$currentObject/([A-Za-z_]\w*)(?:\s*(=|!=)\s*(.+))?\z} ) @@ -314,7 +575,7 @@ def visibility_atom(expression, attributes) # rubocop:disable Metrics/MethodLeng return unless comparison match[2] == '!=' ? "!(#{comparison})" : comparison - end # rubocop:enable Metrics/MethodLength + end def visibility_comparison(value, expected) case expected @@ -327,7 +588,6 @@ def visibility_comparison(value, expected) end end - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def wrap_dynamic_classes(widget, rendered) expression = widget.dig('Appearance', 'DynamicClasses').to_s.strip scope = current_object_scope @@ -346,7 +606,6 @@ def wrap_dynamic_classes(widget, rendered) props = common_props(widget).merge(key: class_key, '$widgetId': class_key) "React.createElement($MxrbDynamicClass, #{js_props(props, expressions:)}, #{rendered})" end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def dynamic_class_expression(expression, attributes) terms = split_dynamic_class_terms(expression) @@ -356,8 +615,6 @@ def dynamic_class_expression(expression, attributes) compiled.join(' + ') end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength - # rubocop:disable Metrics/PerceivedComplexity def split_dynamic_class_terms(expression) parts = [] start = 0 @@ -380,8 +637,6 @@ def split_dynamic_class_terms(expression) parts << expression[start..].to_s.strip parts.reject(&:empty?) end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength - # rubocop:enable Metrics/PerceivedComplexity def dynamic_class_term(term, attributes) stripped = unwrap_dynamic_class_parentheses(term.strip) @@ -424,7 +679,7 @@ def dynamic_class_value(name, attributes) "mxrbValue(props.value#{index + 1})" end - def render_action_button(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def render_action_button(widget) action = widget['Action'] || {} return render_data_action_button(widget, action) if data_action?(action) return render_nanoflow_action_button(widget, action) if nanoflow_action?(action) @@ -437,7 +692,7 @@ def render_action_button(widget) # rubocop:disable Metrics/AbcSize, Metrics/Meth props[:disabled] = true unless handler "React.createElement(\"button\", #{js_props(props, expressions: { onClick: handler }.compact)}, " \ "#{JSON.generate(caption)})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end def render_client_action_button(widget, action) @uses_form_widgets = true @@ -451,13 +706,92 @@ def render_client_action_button(widget, action) end def client_action_config(widget, action) - payload = open_link_config(action) || microflow_config(widget, action) + payload = close_page_config(action) || open_link_config(action) || open_page_config(action) || + create_object_config(widget, action) || sign_out_config(action) || + microflow_config(widget, action) return unless payload { action: payload, abortOnServerValidation: true } end - def open_link_config(action) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def open_page_config(action) + return unless action['$Type'] == 'Forms$FormAction' + + form = action.dig('FormSettings', 'Form').to_s + return unless present_identifier?(form) + return unless parameter_mappings(action['FormSettings'] || {}).empty? + + config = { + name: "#{form.tr('.', '/')}.page.xml", location: 'content', + allowedRoles: allowed_roles_for('Forms$Page', form) + } + { + type: 'openPage', argMap: {}, config:, + disabledDuringExecution: action.fetch('DisabledDuringExecution', true) + } + end + + def sign_out_config(action) + return unless action['$Type'] == 'Forms$SignOutClientAction' + + { + type: 'signOut', argMap: {}, config: { namedUser: true }, + disabledDuringExecution: action.fetch('DisabledDuringExecution', true) + } + end + + def create_object_config(widget, action) + return unless action['$Type'] == 'Forms$CreateObjectClientAction' + + entity = action.dig('EntityRef', 'Entity').to_s + form = action.dig('PageSettings', 'Form').to_s + parameter = target_page_parameter(form, entity) + target = page_unit(form) + return unless present_identifier?(entity) && target && parameter + + { + type: 'createObject', argMap: {}, + config: { + entity:, operationId: WebOperationCompiler.operation_id(@qualified_name, widget['Name']), + pageSettings: { + name: "#{form.tr('.', '/')}.page.xml", location: page_location(target), + resizable: target.document.fetch('PopupResizable', true), + allowedRoles: allowed_roles_for('Forms$Page', form) + }, + allowedRoles: allowed_roles_for('Forms$Page', form), + objectParameter: "param#{parameter}" + }, + disabledDuringExecution: action.fetch('DisabledDuringExecution', true) + } + end + + def page_location(unit) + layout_name = unit.document.dig('FormCall', 'Form').to_s + target_layout = @source.units_of('Forms$Layout').find do |candidate| + "#{candidate.module_name}.#{candidate.document['Name']}" == layout_name + end + popup_layout_document?(target_layout&.document) ? 'modal' : 'content' + end + + def allowed_roles_for(type, qualified_name) + unit = @source.units_of(type).find do |candidate| + "#{candidate.module_name}.#{candidate.document['Name']}" == qualified_name + end + module_roles = array(unit&.document&.fetch('AllowedModuleRoles', nil)).map(&:to_s) + security = @source.documents('Security$ProjectSecurity').first + array(security&.fetch('UserRoles', nil)).filter_map do |role| + role['Name'].to_s if (array(role['ModuleRoles']).map(&:to_s) & module_roles).any? + end + end + + def close_page_config(action) + return unless action['$Type'] == 'Forms$ClosePageClientAction' + + { type: 'closePage', argMap: {}, config: {}, + disabledDuringExecution: action.fetch('DisabledDuringExecution', true) } + end + + def open_link_config(action) return unless action['$Type'] == 'Forms$OpenLinkClientAction' address = action['Address'] || {} @@ -475,7 +809,7 @@ def open_link_config(action) # rubocop:disable Metrics/AbcSize, Metrics/MethodLe end { type: 'openLink', argMap: arg_map, config:, disabledDuringExecution: action.fetch('DisabledDuringExecution', true) } - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end def microflow_config(widget, action) return unless action['$Type'] == 'Forms$MicroflowAction' @@ -532,7 +866,7 @@ def microflow_argument(mapping) def nanoflow_action?(action) action['$Type'] == 'Forms$CallNanoflowClientAction' && - present_identifier?(action['Nanoflow']) && parameter_mappings(action).empty? && + present_identifier?(action['Nanoflow']) && nanoflow_argument_map(action) && nanoflow_reference(action['Nanoflow']) end @@ -562,18 +896,45 @@ def nanoflow_action_config(action) nanoflow = raw_js(nanoflow_reference(action['Nanoflow'])) { action: { - type: 'callNanoflow', argMap: {}, config: { nanoflow: }, + type: 'callNanoflow', argMap: nanoflow_argument_map(action), config: { nanoflow: }, disabledDuringExecution: action.fetch('DisabledDuringExecution', true) }, abortOnServerValidation: false, skipClientValidation: false } end + def nanoflow_argument_map(action) + parameter_mappings(action).map { nanoflow_argument(_1) }.then do |pairs| + pairs.all? ? pairs.to_h : nil + end + end + + def nanoflow_argument(mapping) + name = mapping['Parameter'].to_s.split('.').last + variable = mapping['Expression'].to_s[/\A\$([A-Za-z_]\w*)\z/, 1] + return unless present_identifier?(name) && variable + + scope = current_object_scope + page_parameter = array(@unit.document['Parameters']).any? { _1['Name'] == variable } + widget = if page_parameter + "$#{variable}" + elsif scope && variable == scope[:entity].to_s.split('.').last + scope[:scope] + else + "$#{variable}" + end + expression = { + expr: { type: 'variable', variable: }, + args: { variable.to_sym => { widget:, source: 'object' } } + } + [name.to_sym, { expression:, kind: 'object' }] + end + def nanoflow_reference(name) = @nanoflow_programs.reference(name.to_s) def raw_js(value) = { '$raw' => value } - def render_data_view(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def render_data_view(widget) @uses_form_widgets = true scope = widget_key(widget) object = data_view_object_property(widget, scope) @@ -592,24 +953,72 @@ def render_data_view(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLe emptyMessage: "TextProperty({ value: #{JSON.generate(translated_text(widget['NoEntityMessage']))} })" } "React.createElement($DataView, #{js_props(props, expressions:)})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end def data_view_object_property(widget, scope) source = widget['DataSource'] || {} - parameter = source.dig('SourceVariable', 'PageParameter').to_s - if present_identifier?(parameter) - return "AssociationObjectProperty({ scope: #{JSON.generate("$#{parameter}")}, " \ - 'path: "", editable: true })' + source_scope = data_source_scope(source) + path = entity_ref_path(source['EntityRef']) + if source_scope + if path.empty? && source.dig('SourceVariable', 'PageParameter') + return "AssociationObjectProperty({ scope: #{JSON.generate(source_scope[:scope])}, " \ + 'path: "", editable: true })' + end + return association_object_property(source_scope[:scope], path, widget['Name']) end + current_scope = current_object_scope + return association_object_property(current_scope[:scope], path, widget['Name']) if current_scope && !path.empty? + return microflow_object_property(source, widget, scope) if source['$Type'] == 'Forms$MicroflowSource' return nanoflow_object_property(source, scope) if source['$Type'] == 'Forms$NanoflowSource' nil end def data_view_entity(widget) - parameter = widget.dig('DataSource', 'SourceVariable', 'PageParameter').to_s + source = widget['DataSource'] || {} + entity = entity_ref_destination(source['EntityRef']) + return entity unless entity.empty? + + parameter = source.dig('SourceVariable', 'PageParameter').to_s page_parameter = array(@unit.document['Parameters']).find { _1['Name'] == parameter } - page_parameter&.dig('ParameterType', 'Entity').to_s + page_parameter&.dig('ParameterType', 'Entity').to_s.then do |parameter_entity| + parameter_entity.empty? ? flow_return_entity(source) : parameter_entity + end + end + + def data_source_scope(source) + variable = source['SourceVariable'] || {} + parameter = variable['PageParameter'].to_s + return { scope: "$#{parameter}" } if present_identifier?(parameter) + + snippet_parameter = variable['SnippetParameter'].to_s + return unless present_identifier?(snippet_parameter) + + @snippet_scopes.reverse_each do |mapping| + return mapping[snippet_parameter] if mapping.key?(snippet_parameter) + end + nil + end + + def association_object_property(source_scope, path, widget_name) + @uses_association_object = true + config = { scope: source_scope, path:, editable: true } + config[:operationId] = WebOperationCompiler.operation_id(@qualified_name, widget_name) unless path.empty? + "AssociationObjectProperty(#{js_literal(config)})" + end + + def microflow_object_property(source, widget, scope) + settings = source['MicroflowSettings'] || source + name = settings['Microflow'].to_s + arg_map = microflow_argument_map(settings) + return unless present_identifier?(name) && arg_map + + @uses_microflow_object = true + config = { + dataSourceId: scope, operationId: WebOperationCompiler.operation_id(@qualified_name, widget['Name']), + editable: true, argMap: arg_map + } + "MicroflowObjectProperty(#{js_literal(config)})" end def nanoflow_object_property(source, scope) @@ -624,8 +1033,101 @@ def nanoflow_object_property(source, scope) "NanoflowObjectProperty(#{js_literal(config)})" end - def render_text_box(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - scope = scope_name(@data_view_scopes.last) + def entity_ref_destination(reference) + steps = array(reference&.fetch('Steps', nil)) + steps.last&.fetch('DestinationEntity', nil).to_s.then do |entity| + entity.empty? ? reference&.fetch('Entity', nil).to_s : entity + end + end + + def entity_ref_path(reference) + array(reference&.fetch('Steps', nil)).flat_map do |step| + [step['Association'], step['DestinationEntity']] + end.map(&:to_s).reject(&:empty?).join('/') + end + + def flow_return_entity(source) + type, name = if source['$Type'] == 'Forms$MicroflowSource' + ['Microflows$Microflow', source.dig('MicroflowSettings', 'Microflow')] + elsif source['$Type'] == 'Forms$NanoflowSource' + ['Microflows$Nanoflow', source['Nanoflow']] + end + return '' unless type && present_identifier?(name) + + flow = @source.units_of(type).find { "#{_1.module_name}.#{_1.document['Name']}" == name } + flow&.document&.dig('MicroflowReturnType', 'Entity').to_s + end + + def render_list_view(widget) + data_source = WebListDataSource.new(@source, widget) + return render_unsupported(widget) unless data_source.supported? && present_identifier?(data_source.entity) + + key = widget_key(widget) + list_value = list_view_property(widget, key, data_source) + return render_unsupported(widget) unless list_value + + @uses_list_view = true + @list_scopes << { scope: key, entity: data_source.entity } + content = children(array(widget['Widgets'])) + @list_scopes.pop + props = common_props(widget).merge( + '$widgetId': key, class: css_class(widget), pageSize: positive_integer(widget['PageSize'], 20) + ) + expressions = { + listValue: list_value, + itemTemplate: "TemplatedWidgetProperty({ children: () => #{content}, " \ + "dataSourceId: #{JSON.generate(key)}, editable: #{widget['Editable'] == true} })" + } + "React.createElement($ListView, #{js_props(props, expressions:)})" + end + + def list_view_property(widget, key, source) + config = { dataSourceId: key } + if source.xpath? + config.merge!( + operationId: WebOperationCompiler.operation_id(@qualified_name, widget['Name']), + entity: source.entity, sort: [] + ) + return "DatabaseObjectListProperty(#{js_literal(config)})" + end + if source.association? + current = current_object_scope + return unless current + + config.merge!( + operationId: WebOperationCompiler.operation_id(@qualified_name, widget['Name']), + scope: current[:scope], directPath: source.association_path, sort: [] + ) + return "AssociationObjectListProperty(#{js_literal(config)})" + end + if source.microflow? + settings = widget.dig('DataSource', 'MicroflowSettings') || widget['DataSource'] || {} + arg_map = microflow_argument_map(settings) + return unless arg_map + + config.merge!( + operationId: WebOperationCompiler.operation_id(@qualified_name, widget['Name']), + argMap: arg_map, fetchOnlyWithAllParams: false + ) + return "MicroflowObjectListProperty(#{js_literal(config)})" + end + + reference = nanoflow_reference(source.nanoflow_name) + return unless reference + + config.merge!(source: { nanoflow: raw_js(reference) }, argMap: {}, fetchOnlyWithAllParams: false) + "NanoflowObjectListProperty(#{js_literal(config)})" + end + + def positive_integer(value, fallback) + number = Integer(value || 0) + number.positive? ? number : fallback + rescue ArgumentError, TypeError + fallback + end + + def render_text_box(widget) + scope = current_object_scope&.fetch(:scope, nil) attribute = widget.dig('AttributeRef', 'Attribute').to_s entity, separator, name = attribute.rpartition('.') return render_unsupported(widget) unless scope && separator == '.' && @@ -655,10 +1157,88 @@ def render_text_box(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLen caption: "TextProperty({ value: #{JSON.generate(caption)} })", hasError: 'TextProperty({ value: false })' })})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end + + def render_text_area(widget) + scope = current_object_scope&.fetch(:scope, nil) + entity, separator, name = widget.dig('AttributeRef', 'Attribute').to_s.rpartition('.') + return render_unsupported(widget) unless scope && separator == '.' && + present_identifier?(entity) && present_identifier?(name) + + @uses_form_widgets = true + @uses_text_area = true + key = widget_key(widget) + caption = translated_text(widget.dig('LabelTemplate', 'Template')) + placeholder = translated_text(widget.dig('PlaceholderTemplate', 'Template')) + max_length = widget['MaxLengthCode'].to_i + input_props = common_props(widget).merge( + '$widgetId': key, readOnlyStyle: 'text', numberOfLines: positive_integer(widget['NumberOfLines'], 5), + autoGrow: widget['AutoGrow'] == true, maxLength: max_length.positive? ? max_length : nil, + autocomplete: widget['Autocomplete'] == false ? 'off' : 'on', + submitWhileEditing: %w[WhileEditing OnTyping].include?(widget['SubmitBehaviour']), + submitDelay: widget['SubmitOnInputDelay'].to_i, id: key + ) + input = "React.createElement($TextArea, #{js_props(input_props, expressions: { + inputValue: attribute_property(scope, entity, name), + placeholder: "TextProperty({ value: #{JSON.generate(placeholder)} })", + textTooLongMessage: 'TextProperty({ value: "Text is too long" })', + counterMessage: 'TextProperty({ value: "" })' + })})" + render_form_group(widget, key, caption, input, 'mx-textarea') + end + + def render_radio_button_group(widget) + scope = current_object_scope&.fetch(:scope, nil) + entity, separator, name = widget.dig('AttributeRef', 'Attribute').to_s.rpartition('.') + return render_unsupported(widget) unless scope && separator == '.' && + present_identifier?(entity) && present_identifier?(name) + + @uses_form_widgets = true + @uses_radio_button_group = true + key = widget_key(widget) + input = "React.createElement($RadioButtonGroup, #{js_props( + common_props(widget).merge('$widgetId': key, readOnlyStyle: 'text', id: key, + ariaRequired: false, tabIndex: widget['TabIndex'].to_i), + expressions: { value: attribute_property(scope, entity, name) } + )})" + caption = translated_text(widget.dig('LabelTemplate', 'Template')) + render_form_group(widget, key, caption, input, 'mx-radiogroup') + end + + def render_file_manager(widget) + scope = current_object_scope&.fetch(:scope, nil) + return render_unsupported(widget) unless scope + + @uses_form_widgets = true + @uses_file_manager = true + key = widget_key(widget) + config = { scope:, path: '', isEditable: true, allowUpload: widget['Type'] != 'Download' } + props = common_props(widget).merge( + '$widgetId': key, class: css_class(widget), id: key, + widgetType: widget['Type'].to_s.downcase.then { _1.empty? ? 'both' : _1 }, + extensions: widget['AllowedExtensions'].to_s, + maxFileSize: positive_integer(widget['MaxFileSize'], 200), + showInBrowser: widget['ShowFileInBrowser'] == true + ) + "React.createElement($FileManager, #{js_props(props, expressions: { + content: "DynamicFileProperty(#{js_literal(config)})" + })})" + end + + def render_form_group(widget, key, caption, input, widget_class) + group_props = { + key: "#{key}$formGroup", '$widgetId': "#{key}$formGroup", + class: "mx-name-#{widget['Name']} #{widget_class}", control: [input], + width: 3, orientation: 'horizontal', labelFor: key + } + "React.createElement($FormGroup, #{js_props(group_props, expressions: { + caption: "TextProperty({ value: #{JSON.generate(caption)} })", + hasError: 'TextProperty({ value: false })' + })})" + end - def render_date_picker(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - scope = scope_name(@data_view_scopes.last) + def render_date_picker(widget) + scope = current_object_scope&.fetch(:scope, nil) entity, separator, name = widget.dig('AttributeRef', 'Attribute').to_s.rpartition('.') return render_unsupported(widget) unless scope && separator == '.' && present_identifier?(entity) && present_identifier?(name) @@ -689,10 +1269,10 @@ def render_date_picker(widget) # rubocop:disable Metrics/AbcSize, Metrics/Method caption: "TextProperty({ value: #{JSON.generate(caption)} })", hasError: 'TextProperty({ value: false })' })})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end - def render_check_box(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - scope = scope_name(@data_view_scopes.last) + def render_check_box(widget) + scope = current_object_scope&.fetch(:scope, nil) entity, separator, name = widget.dig('AttributeRef', 'Attribute').to_s.rpartition('.') return render_unsupported(widget) unless scope && separator == '.' && present_identifier?(entity) && present_identifier?(name) @@ -713,7 +1293,7 @@ def render_check_box(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLe caption: "TextProperty({ value: #{JSON.generate(caption)} })", hasError: 'TextProperty({ value: false })' })})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end def render_label(widget) @uses_form_widgets = true @@ -725,7 +1305,7 @@ def render_label(widget) })})" end - def render_tab_control(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def render_tab_control(widget) @uses_tab_container = true key = widget_key(widget) tabs = array(widget['TabPages']) @@ -742,7 +1322,7 @@ def render_tab_control(widget) # rubocop:disable Metrics/AbcSize, Metrics/Method defaultTab: default_tab_index(widget, tabs), tabs: compiled ) "React.createElement($TabContainer, #{js_literal(props)})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end def default_tab_index(widget, tabs) pointer = IO::BsonCodec.extract_id(widget['DefaultPagePointer']) @@ -785,17 +1365,24 @@ def attribute_property(scope, entity, attribute, path: '', formatting: {}) end def data_action?(action) - @data_view_scopes.any? && %w[Forms$SaveChangesClientAction Forms$CancelChangesClientAction] + current_object_scope && %w[ + Forms$SaveChangesClientAction Forms$CancelChangesClientAction Forms$DeleteClientAction + ] .include?(action['$Type']) end - def render_data_action_button(widget, action) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - type = action['$Type'] == 'Forms$SaveChangesClientAction' ? 'saveChanges' : 'cancelChanges' - scope = scope_name(@data_view_scopes.last) + def render_data_action_button(widget, action) + type = { + 'Forms$SaveChangesClientAction' => 'saveChanges', + 'Forms$CancelChangesClientAction' => 'cancelChanges', + 'Forms$DeleteClientAction' => 'deleteObject' + }.fetch(action['$Type']) + scope = current_object_scope.fetch(:scope) key = widget_key(widget) + object_argument = %w[saveChanges deleteObject].include?(type) action_config = { action: { - type:, argMap: type == 'saveChanges' ? { '$object': { widget: scope, source: 'object' } } : {}, + type:, argMap: object_argument ? { '$object': { widget: scope, source: 'object' } } : {}, config: { operationId: WebOperationCompiler.operation_id(@qualified_name, widget['Name']), closePage: action.fetch('ClosePage', true) @@ -813,15 +1400,17 @@ def render_data_action_button(widget, action) # rubocop:disable Metrics/AbcSize, caption: "TextProperty({ value: #{JSON.generate(caption)} })", tooltip: 'TextProperty({ value: "" })', action: "ActionProperty(#{js_literal(action_config)})" })})" - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end def button_style(widget) style = widget['ButtonStyle'].to_s.downcase "btn-#{style.empty? ? 'default' : style}" end - def action_handler(action) # rubocop:disable Metrics/MethodLength + def action_handler(action) case action['$Type'] + when 'Forms$NoAction', nil + nil when 'Forms$FormAction' open_form_handler(action.dig('FormSettings', 'Form')) when 'Forms$CreateObjectClientAction' @@ -832,7 +1421,7 @@ def action_handler(action) # rubocop:disable Metrics/MethodLength @unsupported << action['$Type'].to_s unless action['$Type'].to_s.empty? nil end - end # rubocop:enable Metrics/MethodLength + end def open_form_handler(form) return unless present_identifier?(form) @@ -884,6 +1473,7 @@ def present_identifier?(value) = value.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_.]*\z/ def render_unsupported(widget) type = widget['$Type'].to_s @unsupported << type + @unsupported_custom << custom_widget_identifier(widget) if type == 'CustomWidgets$CustomWidget' props = common_props(widget).merge( className: "#{css_class(widget)} mxrb-unsupported-widget".strip, 'data-mxrb-widget-type': type @@ -891,6 +1481,14 @@ def render_unsupported(widget) "React.createElement(\"div\", #{js_props(props)})" end + def custom_widget_identifier(widget) + type_id = IO::BsonCodec.extract_id(widget.dig('Object', 'TypePointer')) + schema = @source.document_index.values.find do |document| + IO::BsonCodec.extract_id(document.dig('ObjectType', '$ID')) == type_id + end + schema&.fetch('WidgetId', nil).to_s.then { _1.empty? ? 'CustomWidgets$CustomWidget' : _1 } + end + def common_props(widget) { key: widget_key(widget), 'data-widget-id': widget_key(widget) } end @@ -898,7 +1496,7 @@ def common_props(widget) def widget_key(widget) name = widget['Name'].to_s encoded = widget_id(widget)[0, 12] - "p.#{@qualified_name}.#{name.empty? ? encoded : name}" + "#{@scope_prefix || 'p'}.#{@qualified_name}.#{name.empty? ? encoded : name}" end def widget_id(widget) @@ -950,21 +1548,103 @@ def js_literal(value) end def module_source(content) + rendered_content = render_content(content) + content_source = if parent_layout_name.empty? + rendered_content + else + "Object.assign({}, parentContent, #{rendered_content})" + end <<~JS import React from "react"; import { PageFragment } from "mendix/PageFragment"; + #{parent_layout_import} #{widget_imports} #{@nanoflow_programs.declarations} export const title = #{JSON.generate(page_title)}; export const classes = #{JSON.generate(page_classes)}; export const autofocus = "off"; + #{cancel_changes_export} export const style = {}; export const parameters = #{JSON.generate(page_parameters)}; - export const content = #{render_content(content)}; + export const content = #{content_source}; JS end + def cancel_changes_export + return '' unless popup_page? + + "export const cancelChangesOperationId = #{JSON.generate(cancel_changes_operation_id)};" + end + + def cancel_changes_operation_id + WebOperationCompiler.operation_id(@qualified_name, '$cancelChanges') + end + + def popup_page? + !@layout_mode && popup_layout_document?(layout&.document) + end + + def popup_layout_document?(document) + return false unless document + + name = document['Name'].to_s + return true if name.match?(/popup/i) + + document['CanvasWidth'].to_i.positive? && document['CanvasWidth'].to_i <= 800 && + %w[Forms$NavigationTree Forms$MenuBar Forms$SimpleMenuBar Forms$Header].none? do |type| + nested_widgets(document, type).any? + end + end + + def nested_widgets(value, type, result = []) + case value + when Hash + result << value if value['$Type'] == type + value.each_value { nested_widgets(_1, type, result) } + when Array then value.each { nested_widgets(_1, type, result) } + end + result + end + + def layout_module_source(rendered) + parent = parent_layout_name.empty? ? '{}' : 'parentContent' + <<~JS + import React from "react"; + #{parent_layout_import} + #{widget_imports} + #{@nanoflow_programs.declarations} + + export const content = Object.assign({}, #{parent}, { + "Main": #{rendered} + }); + JS + end + + def parent_layout_import + return '' if parent_layout_name.empty? + + "import { content as parentContent } from \"../layouts/#{parent_layout_name}.js\";" + end + + def parent_layout_name + reference = if @layout_mode + @unit.document.dig('Content', 'LayoutCall', 'Form') + else + @unit.document.dig('FormCall', 'Form') + end.to_s + return '' unless present_qualified_name?(reference) + + layout = @source.units_of('Forms$Layout').find do |unit| + "#{unit.module_name}.#{unit.document['Name']}" == reference + end + layout && @source.web_layout?(layout) ? reference : '' + end + + def present_qualified_name?(value) + value.split('.').length >= 2 && value.split('.').all? { present_identifier?(_1) } + end + def page_parameters array(@unit.document['Parameters']).filter_map do |parameter| name = parameter['Name'] @@ -975,18 +1655,53 @@ def page_parameters end.to_h end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def widget_imports return '' unless @uses_data_grid || @uses_form_widgets || @uses_gallery || @uses_bound_text || - @uses_tab_container || @uses_image || @uses_conditional || @uses_dynamic_class + @uses_tab_container || @uses_image || @uses_conditional || @uses_dynamic_class || + @uses_list_view || @generic_widgets&.any? || @uses_layout_widgets imports = ['import { asPluginWidgets } from "mendix";'] widgets = [] + if @uses_layout_widgets + imports.concat([ + 'import { PlaceholderProperty } from "mendix/PlaceholderProperty";', + 'import { TextProperty } from "mendix/TextProperty";', + 'import { ActionProperty } from "mendix/ActionProperty";', + 'import { WebIconProperty } from "mendix/WebIconProperty";', + 'import { ScrollContainer } from "mendix/widgets/web/ScrollContainer";', + 'import { Placeholder } from "mendix/widgets/web/Placeholder";', + 'import { SidebarToggle } from "mendix/widgets/web/SidebarToggle";', + 'import { Header } from "mendix/widgets/web/Header";', + 'import { NavigationTree } from "mendix/widgets/web/NavigationTree";', + 'import { MenuBar } from "mendix/widgets/web/MenuBar";', + 'import { SimpleMenuBar } from "mendix/widgets/web/SimpleMenuBar";' + ]) + widgets.concat(%w[ScrollContainer Placeholder SidebarToggle Header NavigationTree MenuBar SimpleMenuBar]) + end + (@generic_widgets || {}).sort.each do |name, path| + imports.concat([ + "import * as #{name}WidgetModule from \"../widgets/#{path}.mjs\";", + "const #{name} = Object.getOwnPropertyDescriptor(#{name}WidgetModule, " \ + "#{JSON.generate(name)})?.value || " \ + "Object.getOwnPropertyDescriptor(#{name}WidgetModule, \"default\")?.value;" + ]) + widgets << name + end + if @generic_widgets&.any? + imports.concat([ + 'import { AttributeProperty } from "mendix/AttributeProperty";', + 'import { ExpressionProperty } from "mendix/ExpressionProperty";', + 'import { ListExpressionProperty } from "mendix/ListExpressionProperty";', + 'import { DatabaseObjectListProperty } from "mendix/DatabaseObjectListProperty";' + ]) + end if @uses_data_grid imports.concat([ 'import { DatabaseObjectListProperty } from "mendix/DatabaseObjectListProperty";', 'import { AttributeProperty } from "mendix/AttributeProperty";', 'import { ExpressionProperty } from "mendix/ExpressionProperty";', + 'import { ListExpressionProperty } from "mendix/ListExpressionProperty";', + 'import { TemplatedWidgetProperty } from "mendix/TemplatedWidgetProperty";', 'import Datagrid from "../widgets/com/mendix/widget/web/datagrid/Datagrid.mjs";' ]) widgets << 'Datagrid' @@ -1010,6 +1725,27 @@ def widget_imports imports << 'import { DatePicker } from "mendix/widgets/web/DatePicker";' widgets << 'DatePicker' end + if @uses_text_area + imports << 'import { TextArea } from "mendix/widgets/web/TextArea";' + widgets << 'TextArea' + end + if @uses_radio_button_group + imports << 'import { RadioButtonGroup } from "mendix/widgets/web/RadioButtonGroup";' + widgets << 'RadioButtonGroup' + end + if @uses_group_box + imports << 'import { GroupBox } from "mendix/widgets/web/GroupBox";' + widgets << 'GroupBox' + end + if @uses_file_manager + imports.concat([ + 'import { DynamicFileProperty } from "mendix/DynamicFileProperty";', + 'import { FileManager } from "mendix/widgets/web/FileManager";' + ]) + widgets << 'FileManager' + end + imports << 'import { MicroflowObjectProperty } from "mendix/MicroflowObjectProperty";' \ + if @uses_microflow_object widgets << 'Container' if @uses_container end if @uses_combo_box @@ -1018,6 +1754,8 @@ def widget_imports 'import { DatabaseObjectListProperty } from "mendix/DatabaseObjectListProperty";', 'import { MicroflowObjectListProperty } from "mendix/MicroflowObjectListProperty";', 'import { ListAttributeProperty } from "mendix/ListAttributeProperty";', + 'import { ListAssociationProperty } from "mendix/ListAssociationProperty";', + 'import { ListExpressionProperty } from "mendix/ListExpressionProperty";', 'import { SelectionProperty } from "mendix/SelectionProperty";', 'import { ExpressionProperty } from "mendix/ExpressionProperty";', 'import Combobox from "../widgets/com/mendix/widget/web/combobox/Combobox.mjs";' @@ -1026,6 +1764,17 @@ def widget_imports end imports << 'import { NanoflowObjectProperty } from "mendix/NanoflowObjectProperty";' \ if @uses_nanoflow_object + if @uses_list_view + imports.concat([ + 'import { DatabaseObjectListProperty } from "mendix/DatabaseObjectListProperty";', + 'import { AssociationObjectListProperty } from "mendix/AssociationObjectListProperty";', + 'import { MicroflowObjectListProperty } from "mendix/MicroflowObjectListProperty";', + 'import { NanoflowObjectListProperty } from "mendix/NanoflowObjectListProperty";', + 'import { TemplatedWidgetProperty } from "mendix/TemplatedWidgetProperty";', + 'import { ListView } from "mendix/widgets/web/ListView";' + ]) + widgets << 'ListView' + end if @uses_gallery imports.concat([ 'import { DatabaseObjectListProperty } from "mendix/DatabaseObjectListProperty";', @@ -1099,7 +1848,6 @@ def widget_imports "const { #{widgets.map { "$#{_1}" }.join(', ')} } = asPluginWidgets({ #{widgets.join(', ')} });" ).join("\n") end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def page_title = translated_text(@unit.document['Title']) @@ -1119,6 +1867,7 @@ def layout "#{unit.module_name}.#{unit.document['Name']}" == qualified end end - end # rubocop:enable Metrics/ClassLength + end + # rubocop:enable Metrics, Style/MultilineBlockChain end end diff --git a/lib/mxrb/compiler/source_model.rb b/lib/mxrb/compiler/source_model.rb index 1a29fdd..8d6e709 100644 --- a/lib/mxrb/compiler/source_model.rb +++ b/lib/mxrb/compiler/source_model.rb @@ -21,7 +21,31 @@ def documents(type = nil) selected.map(&:document) end - def units_of(type) = units.select { _1.document['$Type'] == type } + def units_of(type) + @units_by_type ||= units.group_by { _1.document['$Type'] }.transform_values(&:freeze).freeze + @units_by_type.fetch(type, EMPTY_UNITS) + end + + def document_index + @document_index ||= {}.tap do |index| + units.each { index_document(_1.document, index) } + end.freeze + end + + def web_page?(unit) + return false if unit.document['Excluded'] == true + + layout_name = unit.document.dig('FormCall', 'Form').to_s + layout = units_of('Forms$Layout').find do |candidate| + "#{candidate.module_name}.#{candidate.document['Name']}" == layout_name + end + layout.nil? || layout.document.dig('Content', '$Type') != 'Forms$NativeLayoutContent' + end + + def web_layout?(unit) + unit.document['Excluded'] != true && + unit.document.dig('Content', '$Type') == 'Forms$WebLayoutContent' + end def client_reference?(qualified_name) reference = /(?:\A|[^A-Za-z0-9_.])@#{Regexp.escape(qualified_name)}(?![A-Za-z0-9_.])/ @@ -32,6 +56,8 @@ def client_reference?(qualified_name) private + EMPTY_UNITS = [].freeze + def read_units mpr = IO::MprFile.open(path, readonly: true) @version = mpr.mendix_version @@ -73,6 +99,16 @@ def deep_string_match?(value, pattern) else value.is_a?(String) && value.match?(pattern) end end + + def index_document(value, index) + case value + when Hash + id = IO::BsonCodec.extract_id(value['$ID']) + index[id] = value if id + value.each_value { index_document(_1, index) } + when Array then value.each { index_document(_1, index) } + end + end end end end diff --git a/lib/mxrb/compiler/web_bundle_builder.rb b/lib/mxrb/compiler/web_bundle_builder.rb index d48fc66..553cc60 100644 --- a/lib/mxrb/compiler/web_bundle_builder.rb +++ b/lib/mxrb/compiler/web_bundle_builder.rb @@ -8,6 +8,7 @@ module Compiler WebBundleResult = Data.define(:directory, :files, :bytes) # Generates the web-client entrypoint and runs the version-owned Rspack toolchain directly. + # rubocop:disable Metrics class WebBundleBuilder include ModelValues @@ -38,11 +39,49 @@ def run_rspack(web) def prepare(web) WidgetPackageExtractor.new(File.dirname(@mpr_path), web).extract + materialize_react_dom_compatibility(web) WebOperationCompiler.new(@source).write(File.join(@deployment, 'model', 'operations.json')) PageBundleBuilder.new(@source, web).build write_entry(File.join(web, 'index.js')) end + def materialize_react_dom_compatibility(web) + shim = File.join(web, 'react-dom-compat.mjs') + react_dom = File.join(@version_root, 'modeler', 'tools', 'node', 'node_modules', + 'react-dom', 'index.js') + File.write(shim, <<~JS) + import * as ReactDOM from #{JSON.generate(react_dom)}; + export * from #{JSON.generate(react_dom)}; + export default ReactDOM; + export const findDOMNode = value => { + if (typeof Element !== "undefined" && value instanceof Element) return value; + const seen = new Set(); + const pending = [value?._reactInternals, value?._reactInternalFiber].filter(Boolean); + while (pending.length) { + const fiber = pending.shift(); + if (!fiber || seen.has(fiber)) continue; + seen.add(fiber); + if (typeof Element !== "undefined" && fiber.stateNode instanceof Element) { + return fiber.stateNode; + } + pending.push(fiber.child, fiber.sibling); + } + return null; + }; + JS + config_path = File.join(web, 'rspack.config.mjs') + return unless File.file?(config_path) + + config = File.read(config_path) + return if config.include?(JSON.generate(shim)) + + marker = 'alias: {' + replacement = "#{marker}\n \"react-dom\": #{JSON.generate(shim)}," + raise CompilationError, 'Rspack config has no resolve alias block' unless config.include?(marker) + + File.write(config_path, config.sub(marker, replacement)) + end + def version_root(path) root = File.expand_path(path) File.basename(root) == 'runtime' ? File.dirname(root) : root @@ -103,5 +142,6 @@ def collect_translations end end end + # rubocop:enable Metrics end end diff --git a/lib/mxrb/compiler/web_list_data_source.rb b/lib/mxrb/compiler/web_list_data_source.rb index 3598b3f..a0399ec 100644 --- a/lib/mxrb/compiler/web_list_data_source.rb +++ b/lib/mxrb/compiler/web_list_data_source.rb @@ -3,10 +3,11 @@ module Mxrb module Compiler # Resolves React list data sources and safely lowers unusable simple connector reads to XPath. + # rubocop:disable Metrics class WebListDataSource include ModelValues - attr_reader :entity, :microflow_name, :nanoflow_name, :xpath_constraint + attr_reader :association_path, :entity, :microflow_name, :nanoflow_name, :xpath_constraint def initialize(source, widget) @source = source @@ -18,11 +19,18 @@ def initialize(source, widget) end def resolve_sources - @xpath = nested(widget, 'CustomWidgets$CustomWidgetXPathSource').first - @microflow_source = nested(widget, 'Forms$MicroflowSource').first - @nanoflow_source = nested(widget, 'Forms$NanoflowSource').first + direct = widget['DataSource'] if widget.is_a?(Hash) + @xpath = direct if database_source?(direct) + @xpath ||= nested(widget, 'CustomWidgets$CustomWidgetXPathSource').first + @association_source = direct if direct&.fetch('$Type', nil) == 'Forms$AssociationSource' + @association_source ||= nested(widget, 'Forms$AssociationSource').first + @microflow_source = direct if direct&.fetch('$Type', nil) == 'Forms$MicroflowSource' + @microflow_source ||= nested(widget, 'Forms$MicroflowSource').first + @nanoflow_source = direct if direct&.fetch('$Type', nil) == 'Forms$NanoflowSource' + @nanoflow_source ||= nested(widget, 'Forms$NanoflowSource').first @microflow_name = flow_name(@microflow_source, 'Microflow') @nanoflow_name = @nanoflow_source&.fetch('Nanoflow', '').to_s + @association_path = entity_ref_path(@association_source&.fetch('EntityRef', nil)) end def flow_name(source, key) @@ -38,14 +46,16 @@ def resolve_flows def resolve_entity @entity = [ - @xpath&.dig('EntityRef', 'Entity'), + entity_ref_destination(@xpath&.fetch('EntityRef', nil)), + entity_ref_destination(@association_source&.fetch('EntityRef', nil)), @flow&.document&.dig('MicroflowReturnType', 'Entity'), @nanoflow&.document&.dig('MicroflowReturnType', 'Entity') ].map(&:to_s).find { !_1.empty? }.to_s end - def supported? = xpath? || microflow? || nanoflow? + def supported? = xpath? || association? || microflow? || nanoflow? def xpath? = !@xpath.nil? || connector_fallback? + def association? = !@association_source.nil? && !association_path.empty? && !entity.empty? def microflow? = !@microflow_source.nil? && !microflow_name.empty? && !@flow.nil? def nanoflow? = !@nanoflow_source.nil? && !nanoflow_name.empty? && !@nanoflow.nil? @@ -53,6 +63,22 @@ def nanoflow? = !@nanoflow_source.nil? && !nanoflow_name.empty? && !@nanoflow.ni attr_reader :widget + def database_source?(source) + %w[Forms$DatabaseSource Forms$NewListViewDatabaseSource] + .include?(source&.fetch('$Type', nil)) + end + + def entity_ref_destination(reference) + steps = array(reference&.fetch('Steps', nil)) + steps.last&.fetch('DestinationEntity', nil) || reference&.fetch('Entity', nil) + end + + def entity_ref_path(reference) + array(reference&.fetch('Steps', nil)).flat_map do |step| + [step['Association'], step['DestinationEntity']] + end.map(&:to_s).reject(&:empty?).join('/') + end + def connector_fallback? return @connector_fallback unless @connector_fallback.nil? @@ -108,5 +134,6 @@ def nested(value, type, result = []) result end end + # rubocop:enable Metrics end end diff --git a/lib/mxrb/compiler/web_operation_compiler.rb b/lib/mxrb/compiler/web_operation_compiler.rb index 9eb5c10..a46fcc3 100644 --- a/lib/mxrb/compiler/web_operation_compiler.rb +++ b/lib/mxrb/compiler/web_operation_compiler.rb @@ -7,7 +7,8 @@ module Mxrb module Compiler # Builds the Runtime operation catalog consumed by generated web data sources. - class WebOperationCompiler # rubocop:disable Metrics/ClassLength + # rubocop:disable Metrics, Style/HashLikeCase, Style/MultilineBlockChain + class WebOperationCompiler include ModelValues def self.operation_id(page_name, widget_name) @@ -15,36 +16,201 @@ def self.operation_id(page_name, widget_name) .delete_suffix('==') end + def self.menu_operation_id(action) + identifier = IO::BsonCodec.extract_id(action['$ID']).to_s + identifier = action.dig('MicroflowSettings', 'Microflow').to_s if identifier.empty? + operation_id('Navigation', identifier) + end + def initialize(source) @source = source end def write(path) - operations = @source.units_of('Forms$Page').flat_map { page_operations(_1) } + operations = @source.units_of('Forms$Page').select { web_page?(_1) } + .flat_map { page_operations(_1) } + if @source.is_a?(SourceModel) + operations.concat(@source.units_of('Forms$Layout').select { web_layout?(_1) } + .flat_map { page_operations(_1) }) + end + operations.concat(nanoflow_operations) + operations = operations.uniq { _1['operationId'] } File.write(path, JSON.generate(operations)) operations end private + def web_page?(unit) = !@source.is_a?(SourceModel) || @source.web_page?(unit) + def web_layout?(unit) = !@source.is_a?(SourceModel) || @source.web_layout?(unit) + def page_operations(unit) page_name = "#{unit.module_name}.#{unit.document['Name']}" role_sets = allowed_user_role_sets(unit) - custom_widgets(unit.document).filter_map { operation(page_name, _1, role_sets, unit.document) } + - data_action_operations(page_name, unit.document, role_sets) + operations = page_related_documents(unit.document).flat_map do |document| + custom_widgets(document).filter_map { operation(page_name, _1, role_sets, document) } + + standard_data_source_widgets(document).filter_map do |widget| + standard_data_source_operation(page_name, widget, role_sets, document) + end + data_action_operations(page_name, document, role_sets) + menu_action_operations(document) + + data_grid_filter_operations(page_name, document, role_sets) + end + operations << page_cancel_operation(page_name, role_sets) if popup_page?(unit) + operations.uniq { _1['operationId'] } + end + + def data_grid_filter_operations(page_name, document, role_sets) + return [] unless @source.is_a?(SourceModel) + + custom_widgets(document).flat_map do |grid| + next [] unless custom_widget_id(grid) == DataGridBundleCompiler::WIDGET_ID + + grid_filter_specs(grid).map do |filter, endpoint, caption| + { + 'operationId' => self.class.operation_id(page_name, "#{grid['Name']}$#{filter['Name']}"), + 'operationType' => 'retrieve', 'parameters' => {}, + 'constants' => { + 'PageName' => page_name, 'WidgetName' => "#{page_name}.#{grid['Name']}", + 'UsedAssociations' => [], 'UsedAttributes' => ["#{endpoint}/#{endpoint}.#{caption}"], + 'XPath' => "//#{endpoint}" + }, + 'allowedUserRoleSets' => role_sets + } + end + end + end + + def grid_filter_specs(grid) + values = custom_property_values(grid['Object']) + array(values.dig('columns', 1, 'Objects')).filter_map do |column| + column_values = custom_property_values(column) + steps = array(column_values.dig('attribute', 1, 'AttributeRef', 'EntityRef', 'Steps')) + filter = array(column_values.dig('filter', 1, 'Widgets')).first + next if steps.empty? || filter.nil? + + endpoint = steps.last['DestinationEntity'].to_s + caption = column_values.dig('attribute', 1, 'AttributeRef', 'Attribute').to_s.split('.').last + [filter, endpoint, caption] unless endpoint.empty? || caption.empty? + end + end + + def custom_widget_id(widget) + pointer = IO::BsonCodec.extract_id(widget.dig('Object', 'TypePointer')) + @source.document_index.values.find do |document| + IO::BsonCodec.extract_id(document.dig('ObjectType', '$ID')) == pointer + end&.fetch('WidgetId', nil) + end + + def custom_property_values(object) + array(object&.fetch('Properties', nil)).filter_map do |property| + type = @source.document_index[IO::BsonCodec.extract_id(property['TypePointer'])] + [type['PropertyKey'], [type.dig('ValueType', 'Type'), property['Value']]] if type + end.to_h + end + + def page_cancel_operation(page_name, role_sets) + { + 'operationId' => self.class.operation_id(page_name, '$cancelChanges'), + 'operationType' => 'rollback', 'parameters' => { 'Objects' => ['AnyObjectList'] }, + 'constants' => {}, 'allowedUserRoleSets' => role_sets + } + end + + def popup_page?(unit) + return false unless @source.is_a?(SourceModel) && unit.document['$Type'] == 'Forms$Page' + + name = unit.document.dig('FormCall', 'Form').to_s + layout = qualified_unit('Forms$Layout', name) + popup_layout_document?(layout&.document) + end + + def popup_layout_document?(document) + return false unless document + return true if document['Name'].to_s.match?(/popup/i) + + document['CanvasWidth'].to_i.positive? && document['CanvasWidth'].to_i <= 800 && + menu_widgets(document).empty? && nested(document, 'Forms$Header').empty? + end + + def menu_action_operations(document) + menu_widgets(document).flat_map do |widget| + resolved_menu_items(widget['MenuSource']).flat_map { menu_item_operations(_1) } + end + end + + def menu_item_operations(item) + action = item['Action'] || {} + own = action['$Type'] == 'Forms$MicroflowAction' ? [menu_microflow_operation(action)].compact : [] + own + array(item['Items']).flat_map { menu_item_operations(_1) } + end + + def menu_microflow_operation(action) + name = action.dig('MicroflowSettings', 'Microflow').to_s + flow = qualified_unit('Microflows$Microflow', name) + parameters = microflow_parameters(name) + return unless flow && parameters + + { + 'operationId' => self.class.menu_operation_id(action), + 'operationType' => 'callMicroflow', 'parameters' => parameters, + 'constants' => { 'MicroflowName' => name }, + 'allowedUserRoleSets' => allowed_user_role_sets(flow) + } + end + + def menu_widgets(document) + %w[Forms$NavigationTree Forms$MenuBar Forms$SimpleMenuBar].flat_map { nested(document, _1) } + end + + def resolved_menu_items(source) + case source&.fetch('$Type', nil) + when 'Forms$MenuDocumentSource' + unit = qualified_unit('Menus$MenuDocument', source['Menu'].to_s) + array(unit&.document&.dig('ItemCollection', 'Items')) + when 'Forms$NavigationSource' + profile = @source.units_of('Navigation$NavigationDocument').flat_map do |unit| + array(unit.document['Profiles']) + end.find { _1['Name'] == source['NavigationProfile'] } + array(profile&.dig('Menu', 'Items')) + else [] + end + end + + def page_related_documents(document, seen = {}) + references = nested(document, 'Forms$SnippetCallWidget').map { _1.dig('FormCall', 'Form').to_s } + references.filter_map do |qualified| + next if qualified.empty? || seen[qualified] + + seen[qualified] = true + unit = @source.units_of('Forms$Snippet').find do |candidate| + "#{candidate.module_name}.#{candidate.document['Name']}" == qualified + end + unit && page_related_documents(unit.document, seen) + end.flatten(1).prepend(document) end def data_action_operations(page_name, document, role_sets) # rubocop:disable Metrics/MethodLength + entities = widget_entities(document, document_scope_entity(document)) action_widgets(document).filter_map do |widget, action| type = case action['$Type'] when 'Forms$SaveChangesClientAction' then 'commit' when 'Forms$CancelChangesClientAction' then 'rollback' + when 'Forms$DeleteClientAction' then 'delete' + when 'Forms$CreateObjectClientAction' then 'create' end if type + entity = entities[widget] + parameters = if type == 'create' + {} + elsif type == 'delete' && !entity.to_s.empty? + { 'Objects' => ["[#{entity}]"] } + else + { 'Objects' => ['AnyObjectList'] } + end + constants = type == 'create' ? { 'ObjectType' => action.dig('EntityRef', 'Entity').to_s } : {} next({ 'operationId' => self.class.operation_id(page_name, widget['Name']), - 'operationType' => type, 'parameters' => { 'Objects' => ['AnyObjectList'] }, - 'constants' => {}, 'allowedUserRoleSets' => role_sets + 'operationType' => type, 'parameters' => parameters, + 'constants' => constants, 'allowedUserRoleSets' => role_sets }) end @@ -54,7 +220,13 @@ def data_action_operations(page_name, document, role_sets) # rubocop:disable Met def action_widgets(document) nested(document, 'Forms$ActionButton').map { [_1, _1['Action'] || {}] } + - nested(document, 'Forms$DivContainer').map { [_1, _1['OnClickAction'] || {}] } + nested(document, 'Forms$DivContainer').map { [_1, _1['OnClickAction'] || {}] } + + custom_widgets(document).flat_map do |widget| + nested(widget['Object'], 'CustomWidgets$WidgetValue').filter_map do |value| + action = value['Action'] || {} + [widget, action] unless action['$Type'].to_s.empty? || action['$Type'] == 'Forms$NoAction' + end + end end def microflow_action_operation(page_name, widget, action, role_sets) @@ -77,6 +249,7 @@ def microflow_parameters(qualified_name) flow = @source.units_of('Microflows$Microflow').find do |unit| "#{unit.module_name}.#{unit.document['Name']}" == qualified_name end + return {} if flow.nil? && qualified_name == 'System.ShowHomePage' return unless flow parameters = nested(flow.document, 'Microflows$MicroflowParameter') @@ -96,15 +269,243 @@ def operation(page_name, widget, role_sets, page_document) source = WebListDataSource.new(@source, widget) return unless source.supported? return xpath_operation(page_name, widget, role_sets, source, page_document) if source.xpath? + return association_operation(page_name, widget, role_sets, source) if source.association? return if source.nanoflow? { 'operationId' => self.class.operation_id(page_name, widget['Name']), - 'operationType' => 'callMicroflow', 'parameters' => {}, - 'constants' => { 'MicroflowName' => source.microflow_name }, 'allowedUserRoleSets' => role_sets + 'operationType' => 'retrieveByMicroflow', 'parameters' => microflow_parameters(source.microflow_name) || {}, + 'constants' => data_source_constants(page_name, widget, source.microflow_name), + 'allowedUserRoleSets' => role_sets } end + def standard_data_source_widgets(document) + nested(document, 'Forms$ListView') + nested(document, 'Forms$DataView') + end + + def standard_data_source_operation(page_name, widget, role_sets, page_document) + entities = widget_entities(page_document, document_scope_entity(page_document)) + source_entity = entities[widget] + return data_view_operation(page_name, widget, role_sets, source_entity) if widget['$Type'] == 'Forms$DataView' + + source = WebListDataSource.new(@source, widget) + if source.association? + return association_retrieve_operation( + page_name, widget, role_sets, source.association_path, source_entity + ) + end + operation(page_name, widget, role_sets, page_document) + end + + def data_view_operation(page_name, widget, role_sets, source_entity = nil) + source = widget['DataSource'] || {} + if source['$Type'] == 'Forms$MicroflowSource' + name = source.dig('MicroflowSettings', 'Microflow').to_s + return unless present_name?(name) + + return retrieve_by_microflow_operation(page_name, widget, role_sets, name) + end + path = entity_ref_path(source['EntityRef']) + return if path.empty? + + association_retrieve_operation(page_name, widget, role_sets, path, source_entity) + end + + def association_operation(page_name, widget, role_sets, source) + association_retrieve_operation(page_name, widget, role_sets, source.association_path) + end + + def association_retrieve_operation(page_name, widget, role_sets, path, source_entity = nil) + source_entity ||= association_source_entity(path) + return unless source_entity + + destination = path.split('/').last.to_s + { + 'operationId' => self.class.operation_id(page_name, widget['Name']), + 'operationType' => 'retrieve', 'parameters' => { 'CurrentObject' => [source_entity] }, + 'constants' => { + 'PageName' => page_name, 'WidgetName' => "#{page_name}.#{widget['Name']}", + 'UsedAssociations' => used_associations(widget, destination), + 'UsedAttributes' => used_attributes(widget, destination), 'EntityPath' => path + }, + 'allowedUserRoleSets' => role_sets + } + end + + def call_microflow_operation(page_name, widget, role_sets, name) + parameters = microflow_parameters(name) + return unless parameters + + { + 'operationId' => self.class.operation_id(page_name, widget['Name']), + 'operationType' => 'callMicroflow', 'parameters' => parameters, + 'constants' => { 'MicroflowName' => name }, 'allowedUserRoleSets' => role_sets + } + end + + def retrieve_by_microflow_operation(page_name, widget, role_sets, name) + parameters = microflow_parameters(name) + return unless parameters + + { + 'operationId' => self.class.operation_id(page_name, widget['Name']), + 'operationType' => 'retrieveByMicroflow', 'parameters' => parameters, + 'constants' => data_source_constants(page_name, widget, name), + 'allowedUserRoleSets' => role_sets + } + end + + def data_source_constants(page_name, widget, microflow_name) + { + 'MicroflowName' => microflow_name, 'PageName' => page_name, + 'WidgetName' => "#{page_name}.#{widget['Name']}" + } + end + + def present_name?(value) = value.match?(/\A[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+\z/) + + def qualified_unit(type, qualified_name) + @source.units_of(type).find do |unit| + "#{unit.module_name}.#{unit.document['Name']}" == qualified_name + end + end + + def entity_ref_path(reference) + array(reference&.fetch('Steps', nil)).flat_map do |step| + [step['Association'], step['DestinationEntity']] + end.map(&:to_s).reject(&:empty?).join('/') + end + + def association_source_entity(path) + association_name = path.split('/').first.to_s + module_name, _, name = association_name.rpartition('.') + domain = @source.units_of('DomainModels$DomainModel').find { _1.module_name == module_name } + association = array(domain&.document&.fetch('Associations', nil)).find { _1['Name'] == name } + pointer = IO::BsonCodec.extract_id(association&.fetch('ParentPointer', nil)) + entity = array(domain&.document&.fetch('Entities', nil)).find do |candidate| + IO::BsonCodec.extract_id(candidate['$ID']) == pointer + end + "#{module_name}.#{entity['Name']}" if entity + end + + def used_associations(value, entity) + attribute_names(value, entity).select { _1.count('/') > 1 }.map do |path| + path.split('/')[0...-1].join('/') + end.uniq.sort + end + + def widget_entities(document, initial_scope = nil) + {}.compare_by_identity.tap { collect_widget_entities(document, initial_scope, _1) } + end + + def document_scope_entity(document) + array(document['Parameters']).filter_map do |parameter| + parameter.dig('ParameterType', 'Entity') || parameter.dig('ParameterType', 'ObjectType', 'Entity') + end.first + end + + def collect_widget_entities(value, scope_entity, result) + case value + when Hash + result[value] = scope_entity if %w[ + Forms$ActionButton Forms$ListView Forms$DataView CustomWidgets$CustomWidget + ].include?(value['$Type']) + child_entity = widget_scope_entity(value) || scope_entity + value.each_value { collect_widget_entities(_1, child_entity, result) } + when Array then value.each { collect_widget_entities(_1, scope_entity, result) } + end + end + + def widget_scope_entity(widget) + if %w[Forms$ListView CustomWidgets$CustomWidget].include?(widget['$Type']) + entity = WebListDataSource.new(@source, widget).entity + return entity unless entity.empty? + end + return unless widget['$Type'] == 'Forms$DataView' + + source = widget['DataSource'] || {} + steps = array(source.dig('EntityRef', 'Steps')) + steps.last&.fetch('DestinationEntity', nil) || source.dig('EntityRef', 'Entity') || + microflow_return_entity(source) + end + + def microflow_return_entity(source) + name = source.dig('MicroflowSettings', 'Microflow').to_s + flow = @source.units_of('Microflows$Microflow').find do |unit| + "#{unit.module_name}.#{unit.document['Name']}" == name + end + flow&.document&.dig('MicroflowReturnType', 'Entity') + end + + def nanoflow_operations + return [] unless @source.is_a?(SourceModel) + + @source.units_of('Microflows$Nanoflow').flat_map do |unit| + qualified_name = "#{unit.module_name}.#{unit.document['Name']}" + nanoflow_action_operations(qualified_name, unit.document) + end + end + + def nanoflow_action_operations(qualified_name, document) + entities = nanoflow_variable_entities(document) + nested(document, 'Microflows$ActionActivity').filter_map do |activity| + action = activity['Action'] || {} + operation = nanoflow_server_operation(qualified_name, activity, action, entities) + operation&.merge('allowedUserRoleSets' => []) + end + end + + def nanoflow_server_operation(qualified_name, activity, action, entities) + operation_id = self.class.operation_id(qualified_name, model_id(activity)) + if action['$Type'] == 'Microflows$MicroflowCallAction' + name = action.dig('MicroflowCall', 'Microflow').to_s + parameters = microflow_parameters(name) + return if name.empty? || !parameters + + return operation_record(operation_id, 'callMicroflow', parameters, 'MicroflowName' => name) + end + return unless commit_action?(action) + + variable = action['CommitVariableName'] || action['ChangeVariableName'] || + action['OutputVariableName'] || action['VariableName'] + entity = entities[variable] + parameter = entity.to_s.empty? ? 'AnyObjectList' : "[#{entity}]" + operation_record(operation_id, 'commit', { 'Objects' => [parameter] }, {}) + end + + def commit_action?(action) + action['$Type'] == 'Microflows$CommitAction' || + %w[Microflows$ChangeAction Microflows$CreateChangeAction].include?(action['$Type']) && + action['Commit'] == 'Yes' + end + + def operation_record(operation_id, type, parameters, constants) + { + 'operationId' => operation_id, 'operationType' => type, + 'parameters' => parameters, 'constants' => constants + } + end + + def nanoflow_variable_entities(document) + nested(document, 'Microflows$MicroflowParameter').each_with_object({}) do |parameter, result| + entity = parameter.dig('VariableType', 'Entity').to_s + result[parameter['Name']] = entity unless entity.empty? + end.tap do |result| + nested(document, 'Microflows$ActionActivity').each do |activity| + action = activity['Action'] || {} + variable = action['OutputVariableName'] || action['VariableName'] + entity = action['Entity'].to_s + result[variable] = entity if !variable.to_s.empty? && !entity.empty? + end + end + end + + def model_id(value) + identifier = value.is_a?(Hash) && value.key?('$ID') ? value['$ID'] : value + IO::BsonCodec.extract_id(identifier).to_s + end + def xpath_operation(page_name, widget, role_sets, source, page_document) variables = xpath_variables(source.xpath_constraint) page_parameters = object_page_parameters(page_document, variables) @@ -204,6 +605,7 @@ def nested(value, type, result = []) end result end - end # rubocop:enable Metrics/ClassLength + end + # rubocop:enable Metrics, Style/HashLikeCase, Style/MultilineBlockChain end end diff --git a/lib/mxrb/compiler/widget_package_extractor.rb b/lib/mxrb/compiler/widget_package_extractor.rb index 742b27b..5034358 100644 --- a/lib/mxrb/compiler/widget_package_extractor.rb +++ b/lib/mxrb/compiler/widget_package_extractor.rb @@ -6,6 +6,7 @@ module Mxrb module Compiler # Expands project-owned pluggable widget packages into the web build tree. + # rubocop:disable Metrics class WidgetPackageExtractor def initialize(project_root, web_root) @source = File.join(project_root, 'widgets') @@ -24,8 +25,20 @@ def extract def extract_package(path) Zip::File.open(path) do |archive| entries = archive.reject(&:directory?) - entries.each { write_entry(_1) } - return entries.length + entries.each { safe_destination(_1.name) } + runtime_roots = entries.select { _1.name.end_with?('.mjs') } + .map { File.dirname(_1.name) }.uniq + runtime_entries = entries.select do |entry| + runtime_roots.any? do |root| + if root == '.' + entry.name.match?(/\.(?:m?js|css)\z/) || entry.name.start_with?('assets/') + else + entry.name.start_with?("#{root}/") + end + end + end + runtime_entries.each { write_entry(_1) } + return runtime_entries.length end rescue Zip::Error => e raise CompilationError, "invalid widget package #{path}: #{e.message}" @@ -45,5 +58,6 @@ def safe_destination(name) raise CompilationError, "unsafe widget package entry #{name}" end end + # rubocop:enable Metrics end end diff --git a/spec/compatibility_analyzer_spec.rb b/spec/compatibility_analyzer_spec.rb new file mode 100644 index 0000000..974a7c3 --- /dev/null +++ b/spec/compatibility_analyzer_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# rubocop:disable Metrics/BlockLength +RSpec.describe Mxrb::Compiler::CompatibilityAnalyzer do + def unit(type, name, document = {}) + Mxrb::Compiler::SourceModel::Unit.new( + id: name, container_id: 'module', containment: 'Documents', module_name: 'Demo', + document: { '$Type' => type, 'Name' => name }.merge(document) + ) + end + + def source(version:, units:, documents: []) + instance_double( + Mxrb::Compiler::SourceModel, version:, units:, documents: + ).tap do |model| + allow(model).to receive(:units_of) { |type| units.select { _1.document['$Type'] == type } } + end + end + + it 'reports page widgets, actions, and nanoflow activities in the React pipeline' do + page = unit('Forms$Page', 'Home', 'FormCall' => { 'Arguments' => [2, { + 'Parameter' => 'Demo.Shell.Main', 'Widgets' => [2, + { '$Type' => 'Forms$UnknownWidget', 'Name' => 'group' }, + { '$Type' => 'Forms$ActionButton', 'Name' => 'delete', + 'Action' => { '$Type' => 'Forms$DeleteClientAction' } }] + }] }) + nanoflow = unit('Microflows$Nanoflow', 'ClientFlow', + 'ObjectCollection' => { 'Objects' => [2, + { '$ID' => 'start', '$Type' => 'Microflows$StartEvent' }, + { '$ID' => 'action', '$Type' => 'Microflows$ActionActivity', + 'Action' => { + '$ID' => 'log', + '$Type' => 'Microflows$UnsupportedAction' + } }] }, + 'Flows' => [2, { + 'OriginPointer' => 'start', 'DestinationPointer' => 'action' + }]) + model = source(version: '11.12.1', units: [page, nanoflow]) + report = described_class.new('/tmp/App.mpr', source: model).analyze + + expect(report).not_to be_compatible + expect(report.errors.map(&:category)).to include(:widget, :client_action, :nanoflow) + expect(report.stats).to include(units: 2, pages: 1, nanoflows: 1, microflows: 0) + expect(report.to_h).to include(compatible: false, mendix_version: '11.12.1') + end + + it 'stops at an unsupported Mendix version instead of auditing it with the wrong renderer' do + page = unit('Forms$Page', 'Home', 'Widgets' => [2, { '$Type' => 'Forms$UnknownWidget' }]) + report = described_class.new('/tmp/App.mpr', source: source(version: '10.18.0', units: [page])).analyze + + expect(report.errors.map(&:category)).to eq([:version]) + end + + it 'audits Mendix 6, 7, and 9 pages through the legacy renderer contract' do + page = unit('Forms$Page', 'Home', 'Widgets' => [2, + { '$Type' => 'Forms$TextBox', 'Name' => 'name' }, + { '$Type' => 'CustomWidgets$CustomWidget', 'Name' => 'map' }]) + report = described_class.new('/tmp/App.mpr', source: source(version: '9.24.0', units: [page])).analyze + + expect(report.errors.map(&:type)).to contain_exactly('CustomWidgets$CustomWidget', 'Forms$TextBox') + expect(report.errors.map(&:message)).to all(include('legacy web renderer')) + end + + it 'accepts a supported project whose pages use the compiled subset' do + page = unit('Forms$Page', 'Home', 'FormCall' => { 'Arguments' => [2, { + 'Parameter' => 'Demo.Shell.Main', 'Widgets' => [2, { + '$Type' => 'Forms$DynamicText', 'Name' => 'caption', + 'Content' => { 'Template' => { 'Items' => [2] } } + }] + }] }) + model = source(version: '11.12.1', units: [page]) + report = described_class.new('/tmp/App.mpr', source: model).analyze + + expect(report).to be_compatible + expect(report.findings).to be_empty + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/coverage_helper.rb b/spec/coverage_helper.rb index c50e227..1a79de5 100644 --- a/spec/coverage_helper.rb +++ b/spec/coverage_helper.rb @@ -46,9 +46,13 @@ def report payload[:branches][:percent], payload[:branches][:covered], payload[:branches][:total] ) - minimum = ENV.fetch("MXRB_COVERAGE_MIN", "100").to_f + minimums = { + lines: ENV.fetch("MXRB_LINE_COVERAGE_MIN", ENV.fetch("MXRB_COVERAGE_MIN", "100")).to_f, + branches: ENV.fetch("MXRB_BRANCH_COVERAGE_MIN", ENV.fetch("MXRB_COVERAGE_MIN", "100")).to_f + } failures = %i[lines branches].filter_map do |metric_name| percent = payload.fetch(metric_name).fetch(:percent) + minimum = minimums.fetch(metric_name) "#{metric_name} coverage #{percent}% is below #{minimum}%" if percent < minimum end return if failures.empty? diff --git a/spec/generic_widget_bundle_compiler_spec.rb b/spec/generic_widget_bundle_compiler_spec.rb new file mode 100644 index 0000000..1d70e1a --- /dev/null +++ b/spec/generic_widget_bundle_compiler_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# rubocop:disable Metrics/BlockLength +RSpec.describe Mxrb::Compiler::GenericWidgetBundleCompiler do + def property_type(id, key, type) + { '$ID' => id, 'PropertyKey' => key, 'ValueType' => { 'Type' => type } } + end + + def property(id, value) + { 'TypePointer' => id, 'Value' => value } + end + + it 'compiles schema-backed primitive, data, text, action and content properties' do + Dir.mktmpdir do |root| + FileUtils.mkdir_p(File.join(root, 'widgets', 'example')) + File.write(File.join(root, 'widgets', 'example', 'LanguageSelector.mjs'), 'export default {};') + types = [ + property_type('enabled', 'enabled', 'Boolean'), + property_type('options', 'options', 'DataSource'), + property_type('caption', 'caption', 'TextTemplate'), + property_type('action', 'onClick', 'Action'), + property_type('content', 'content', 'Widgets') + ] + index = types.to_h { [_1['$ID'], _1] } + index['widget'] = { + 'WidgetId' => 'example.LanguageSelector', 'ObjectType' => { '$ID' => 'widget-object' } + } + source = instance_double( + Mxrb::Compiler::SourceModel, + path: File.join(root, 'App.mpr'), document_index: index + ) + allow(source).to receive(:units_of).and_return([]) + widget = { + '$Type' => 'CustomWidgets$CustomWidget', 'Name' => 'language', + 'Appearance' => { 'Class' => 'language-picker' }, + 'Object' => { 'TypePointer' => 'widget-object', 'Properties' => [ + 2, + property('enabled', 'PrimitiveValue' => 'true'), + property('options', 'DataSource' => { + '$Type' => 'CustomWidgets$CustomWidgetXPathSource', + 'EntityRef' => { 'Entity' => 'System.Language' }, + 'SortBar' => { 'SortItems' => [2, { + 'AttributeRef' => { 'Attribute' => 'System.Language.Description' }, + 'SortOrder' => 'Ascending' + }] } + }), + property('caption', 'TextTemplate' => { + 'Template' => { 'Items' => [3, { + 'LanguageCode' => 'en_US', 'Text' => 'Choose language' + }] } + }), + property('action', 'Action' => { '$Type' => 'Forms$SignOutClientAction' }), + property('content', 'Widgets' => [3, { '$Type' => 'Forms$DynamicText' }]) + ] } + } + compiler = described_class.new( + source, 'Demo.Home', widget, scope: nil, entity: nil, + render_widgets: ->(_widgets) { { '$raw' => '[content]' } }, + action_property: ->(_action) { 'ActionProperty({ action: { type: "signOut" } })' } + ) + + expect(compiler).to be_supported + expect(compiler.render).to include( + 'React.createElement($LanguageSelector', '"enabled": true', + 'DatabaseObjectListProperty', 'System.Language', 'Description', + 'ExpressionProperty', 'Choose language', 'ActionProperty', + '"content": [content]', 'mx-name-language language-picker' + ) + end + end + + it 'fails closed for missing package modules and unsupported object properties' do + type = property_type('config', 'config', 'Object') + source = instance_double( + Mxrb::Compiler::SourceModel, + path: '/tmp/Missing.mpr', + document_index: { + 'config' => type, + 'widget' => { 'WidgetId' => 'example.Missing', 'ObjectType' => { '$ID' => 'widget-object' } } + } + ) + allow(source).to receive(:units_of).and_return([]) + widget = { + 'Name' => 'missing', 'Object' => { + 'TypePointer' => 'widget-object', + 'Properties' => [2, property('config', 'Objects' => [3, {}])] + } + } + + expect(described_class.new(source, 'Demo.Home', widget, scope: nil, entity: nil)) + .not_to be_supported + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/legacy_page_builder_spec.rb b/spec/legacy_page_builder_spec.rb index 3aec2f9..2a0f38d 100644 --- a/spec/legacy_page_builder_spec.rb +++ b/spec/legacy_page_builder_spec.rb @@ -163,5 +163,18 @@ def grid(source_type) # rubocop:disable Metrics/MethodLength .dig('unsupportedWidgets', 'Demo.Home')).to eq(['Forms$TextBox']) end end + + it 'audits pages without writing files and includes custom widgets' do + page = Mxrb::Compiler::SourceModel::Unit.new( + id: SecureRandom.uuid, container_id: nil, containment: nil, module_name: 'Demo', + document: { '$Type' => 'Forms$Page', 'Name' => 'Home', + 'Widgets' => [3, { '$Type' => 'Forms$TextBox' }, + { '$Type' => 'CustomWidgets$CustomWidget' }] } + ) + source = instance_double(Mxrb::Compiler::SourceModel, units_of: [page], documents: [page.document]) + + expect(described_class.new(source, '/not-used').audit) + .to eq('Demo.Home' => %w[CustomWidgets$CustomWidget Forms$TextBox]) + end end # rubocop:enable Metrics/BlockLength diff --git a/spec/nanoflow_program_compiler_spec.rb b/spec/nanoflow_program_compiler_spec.rb index f146b22..f2db882 100644 --- a/spec/nanoflow_program_compiler_spec.rb +++ b/spec/nanoflow_program_compiler_spec.rb @@ -31,7 +31,7 @@ def build_source # rubocop:disable Metrics/MethodLength expect(reference).to match(/\A\(\) => mxrbNanoflow_[a-f0-9]{12}\z/) expect(output).to include( - '"name": "Demo.Load"', '"type": "createVariable"', + '"name": "Demo.Load"', '"type": "setVariable"', '"type": "nanoflowCall"', '"flow": () => mxrbNanoflow_', '"type": "return"', '"variable": "ready"', '"name": "Demo.Child"' ) @@ -66,7 +66,7 @@ def build_source # rubocop:disable Metrics/MethodLength 'Entity' => 'Demo.Item', 'VariableName' => 'Item') ) expect(compiler.send(:compile_node, create)).to include( - type: 'createObject', objectType: 'Demo.Item', outputVar: 'Item' + include(type: 'createObject', objectType: 'Demo.Item', outputVar: 'Item') ) call = id.merge( '$Type' => 'Microflows$NanoflowCallAction', 'OutputVariableName' => '', @@ -87,7 +87,7 @@ def build_source # rubocop:disable Metrics/MethodLength expect(expressions.map { compiler.send(:expression, _1) }).to include( { type: 'literal', value: nil }, { type: 'variable', variable: 'Item' }, { type: 'literal', value: false }, { type: 'literalNumeric', value: '-12.5' }, - { type: 'literal', value: '@Constant' } + { type: 'constant', name: 'Constant' } ) compiler.instance_variable_get(:@programs)['Demo.Nil'] = nil expect(compiler.reference('Demo.Nil')).to be_nil @@ -96,7 +96,7 @@ def build_source # rubocop:disable Metrics/MethodLength end end - it 'fails closed instead of silently dropping unsupported control flow' do + it 'compiles decisions as explicit switch control flow' do Dir.mktmpdir do |root| path = File.join(root, 'Branching.mpr') Mxrb.define(path) do @@ -112,9 +112,11 @@ def build_source # rubocop:disable Metrics/MethodLength end compiler = described_class.new(Mxrb::Compiler::SourceModel.read(path)) - expect(compiler.reference('Demo.Branch')).to be_nil - expect(compiler.declarations).to eq('') - expect(compiler.unsupported).to include(/Microflows\$ExclusiveSplit/) + expect(compiler.reference('Demo.Branch')).to match(/mxrbNanoflow/) + expect(compiler.declarations).to include( + '"type": "switch"', '"targets": { "true":', '"false":' + ) + expect(compiler.unsupported).to be_empty end end end diff --git a/spec/official_mpr_compatibility_spec.rb b/spec/official_mpr_compatibility_spec.rb new file mode 100644 index 0000000..53b8d4c --- /dev/null +++ b/spec/official_mpr_compatibility_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'official MPR native web compatibility' do + it 'preflights and compiles the pinned Mendix acceptance project' do + paths = ENV.fetch('MXRB_ACCEPTANCE_MPRS', '').split(File::PATH_SEPARATOR).select { File.file?(_1) } + skip 'pinned acceptance fixtures not present in this environment' if paths.empty? + + paths.each do |path| + source = Mxrb::Compiler::SourceModel.read(path) + report = Mxrb::Compiler::CompatibilityAnalyzer.new(path, source:).analyze + expect(report).to be_compatible + + Dir.mktmpdir do |root| + target = File.join(root, 'operations.json') + operations = Mxrb::Compiler::WebOperationCompiler.new(source).write(target) + expect(operations).not_to be_empty + expect(JSON.parse(File.read(target))).to eq(operations) + end + end + end +end diff --git a/spec/page_bundle_compiler_spec.rb b/spec/page_bundle_compiler_spec.rb index 766e29a..c858fb0 100644 --- a/spec/page_bundle_compiler_spec.rb +++ b/spec/page_bundle_compiler_spec.rb @@ -42,11 +42,11 @@ bundle = described_class.new(source).compile(unit) expect(bundle.qualified_name).to eq('Demo.Home') expect(bundle.source).to include( - 'PageFragment', 'export const title = "Welcome"', '"Main":', + 'PageFragment', 'export const title = "Welcome"', '"Demo.Shell.Main":', 'mx-name-body body', '"Hello"', 'export const classes = "mxrb-application-shell page-identity"' ) - expect(bundle.source).not_to include('Demo.Shell.Main') + expect(bundle.source).to include('import { content as parentContent } from "../layouts/Demo.Shell.js";') expect(bundle.unsupported_widgets).to be_empty end @@ -92,7 +92,7 @@ expect(bundle.source).to include( 'mx-layoutgrid mx-layoutgrid-fluid', 'row', 'col-md-6 col-sm-12 col-xs-12', 'React.createElement("h1"', '"Dashboard"', 'btn-primary', - 'window.mx?.ui?.openForm2?.("Demo.Home", {}, undefined, undefined' + '"type": "openPage"', '"name": "Demo/Home.page.xml"' ) expect(bundle.unsupported_widgets).to be_empty end @@ -431,7 +431,9 @@ '$Type' => 'Forms$FormAction', 'FormSettings' => { 'Form' => 'Demo.Home' } } } - expect(compiler.send(:render_container, form_container)).to include('onClick', 'role') + expect(compiler.send(:render_container, form_container)).to include( + '$Container', 'ActionProperty', '"type": "openPage"' + ) expect(compiler.send(:render_check_box, '$Type' => 'Forms$CheckBox', 'Name' => 'loose')) .to include('mxrb-unsupported-widget') expect(compiler.send(:image_uri, 'Demo.Assets.Missing')).to be_nil @@ -557,9 +559,9 @@ expect(compiler.send(:widget_id, {})).to eq('') arguments = unit.document['FormCall']['Arguments'] - arguments << { 'Parameter' => 'Other.Main', 'Widgets' => [] } + arguments << { 'Parameter' => arguments.find { _1.is_a?(Hash) }['Parameter'], 'Widgets' => [] } expect { described_class.new(source).compile(unit) } - .to raise_error(Mxrb::CompilationError, /duplicate page slot "Main"/) + .to raise_error(Mxrb::CompilationError, /duplicate page slot "Demo\.Shell\.Main"/) arguments.pop arguments.find { _1.is_a?(Hash) }['Parameter'] = '../unsafe' expect { described_class.new(source).compile(unit) } diff --git a/spec/web_operation_compiler_spec.rb b/spec/web_operation_compiler_spec.rb index f110a7b..a378fb5 100644 --- a/spec/web_operation_compiler_spec.rb +++ b/spec/web_operation_compiler_spec.rb @@ -119,7 +119,7 @@ def widget(source: true) expect(compiler.send(:xpath_operation, 'Demo.Home', {}, [], data_source, nil)).to be_nil end - it 'registers a microflow list data source as a callMicroflow operation' do + it 'registers a microflow list data source as a retrieveByMicroflow operation' do gallery = { '$Type' => 'CustomWidgets$CustomWidget', 'Name' => 'gallery', 'Object' => { 'DataSource' => { @@ -142,8 +142,8 @@ def widget(source: true) expect(described_class.new(source).send(:page_operations, page)).to contain_exactly( include( - 'operationType' => 'callMicroflow', 'parameters' => {}, - 'constants' => { 'MicroflowName' => 'Demo.LoadItems' } + 'operationType' => 'retrieveByMicroflow', 'parameters' => {}, + 'constants' => include('MicroflowName' => 'Demo.LoadItems') ) ) end