diff --git a/lib/mxrb.rb b/lib/mxrb.rb index c1c3926..892a058 100644 --- a/lib/mxrb.rb +++ b/lib/mxrb.rb @@ -109,6 +109,8 @@ require_relative "mxrb/compiler/web_operation_compiler" 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/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" diff --git a/lib/mxrb/compiler/combo_box_bundle_compiler.rb b/lib/mxrb/compiler/combo_box_bundle_compiler.rb new file mode 100644 index 0000000..520d1ed --- /dev/null +++ b/lib/mxrb/compiler/combo_box_bundle_compiler.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require 'digest' +require 'json' + +module Mxrb + module Compiler + # Compiles association- and database-backed instances of the official React Combo box widget. + class ComboBoxBundleCompiler # rubocop:disable Metrics/ClassLength + include ModelValues + + WIDGET_ID = 'com.mendix.widget.web.combobox.Combobox' + + def initialize(source, page_name, widget, scope:, entity:) + @source = source + @page_name = page_name + @widget = widget + @scope = scope + @entity = entity + @index = document_index + @values = property_values(widget['Object']) + @data_source = WebListDataSource.new(source, widget) + end + + def supported? + widget_type&.fetch('WidgetId', nil) == WIDGET_ID && supported_source? && + @data_source.supported? && !@data_source.entity.to_s.empty? && resolved_scope + end + + def render # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + properties = primitive_properties.merge( + key: widget_key, '$widgetId': widget_key, class: css_class, id: widget_key, + optionsSourceStaticDataSource: [], + ariaRequired: raw(expression(false)) + ) + properties.merge!(source_properties) + control = "React.createElement($Combobox, #{javascript(properties)})" + caption = translated_text(@widget['LabelTemplate']) + group = { + key: "#{widget_key}$formGroup", '$widgetId': "#{widget_key}$formGroup", + class: "#{css_class} mx-combobox", control: raw("[#{control}]"), + width: 3, orientation: 'horizontal', labelFor: widget_key, + caption: raw(expression(caption)), hasError: raw(expression(false)) + } + "React.createElement($FormGroup, #{javascript(group)})" + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + private + + def supported_source? + return database_source_supported? if primitive('source') == 'database' + + association_source_supported? + end + + def database_source_supported? + target_attribute && database_caption_attribute && database_value_attribute + end + + def association_source_supported? + primitive('source') == 'context' && primitive('optionsSourceType') == 'association' && + association_step && association_caption_attribute + end + + def source_properties + primitive('source') == 'database' ? database_properties : association_properties + end + + def database_properties + return database_association_properties if target_steps.any? + + { + databaseAttributeString: raw(attribute_property(target_attribute)), + optionsSourceDatabaseCaptionAttribute: raw(list_attribute_property(database_caption_attribute)), + optionsSourceDatabaseValueAttribute: raw(list_attribute_property(database_value_attribute)), + optionsSourceDatabaseDataSource: raw(list_property), + optionsSourceDatabaseItemSelection: raw(selection_property) + } + end + + def database_association_properties + { + source: 'context', + optionsSourceAssociationCaptionAttribute: raw(list_attribute_property(database_caption_attribute)), + attributeAssociation: raw(association_property(target_steps.first)), + optionsSourceAssociationDataSource: raw(list_property) + } + end + + def association_properties + { + optionsSourceAssociationCaptionAttribute: raw(list_attribute_property(association_caption_attribute)), + attributeAssociation: raw(association_property(association_step)), + optionsSourceAssociationDataSource: raw(list_property) + } + end + + def association_property(step) + "AssociationProperty(#{javascript( + type: 'Reference', entity: resolved_entity, path: '', attribute: step['Association'], + endpointEntity: step['DestinationEntity'], selectableObjectsId: data_source_id, + scope: resolved_scope, onChange: do_nothing + )})" + end + + def list_property + config = { + dataSourceId: data_source_id, entity: @data_source.entity, scope: resolved_scope, + operationId: WebOperationCompiler.operation_id(@page_name, @widget['Name']) + } + if @data_source.xpath? + "DatabaseObjectListProperty(#{javascript(config.merge(sort: []))})" + else + "MicroflowObjectListProperty(#{javascript(config.merge(argMap: {}, fetchOnlyWithAllParams: false))})" + end + end + + def selection_property + selection = value('optionsSourceDatabaseItemSelection')&.fetch('Selection', 'Single') || 'Single' + "SelectionProperty(#{javascript(selectionType: selection, dataSourceId: data_source_id)})" + end + + def attribute_property(attribute) + entity, name = split_attribute(attribute.fetch('Attribute')) + path = entity_steps(attribute).flat_map do |step| + [step['Association'], step['DestinationEntity']] + end.join('/') + config = { + scope: resolved_scope, path:, entity:, attribute: name, onChange: do_nothing, + isList: false, validation: nil, formatting: {} + } + "AttributeProperty(#{javascript(config)})" + end + + def list_attribute_property(attribute) + entity, name = split_attribute(attribute.fetch('Attribute')) + "ListAttributeProperty(#{javascript( + path: '', entity:, attribute: name, attributeType: 'String', sortable: true, + filterable: true, dataSourceId: data_source_id, isList: false + )})" + end + + def split_attribute(qualified) + entity, separator, name = qualified.to_s.rpartition('.') + raise CompilationError, "invalid Combo box attribute #{qualified.inspect}" unless separator == '.' + + [entity, name] + end + + def resolved_scope + @resolved_scope ||= begin + parameter = target_value&.dig('SourceVariable', 'PageParameter').to_s + parameter.empty? ? @scope : "$#{parameter}" + end + end + + def resolved_entity + return @entity unless @entity.to_s.empty? + + parameter = target_value&.dig('SourceVariable', 'PageParameter').to_s + page_parameter_entity(parameter) + end + + def page_parameter_entity(name) + module_name, document_name = @page_name.split('.', 2) + page = @source.units_of('Forms$Page').find do |unit| + unit.module_name == module_name && unit.document['Name'] == document_name + end + parameter = array(page&.document&.fetch('Parameters', nil)).find { _1['Name'] == name } + parameter&.dig('ParameterType', 'Entity').to_s + end + + def target_value = value('databaseAttributeString') + 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_step + entity_steps(value('attributeAssociation')).first + end + + def target_steps = entity_steps(target_attribute) + + def entity_steps(value) + array(value&.dig('EntityRef', 'Steps')) + end + + def primitive_properties + @values.each_with_object({}) do |(key, (type, property)), result| + compiled = compile_primitive(type, property) + result[key.to_sym] = compiled unless compiled.nil? + end + end + + def compile_primitive(type, property) + case type + when 'Boolean' then property['PrimitiveValue'] == 'true' + when 'Integer' then property['PrimitiveValue'].to_i + when 'Enumeration' then property['PrimitiveValue'].to_s + when 'TextTemplate' then raw(expression(translated_text(property['TextTemplate']))) + when 'Widgets' then [] if array(property['Widgets']).empty? + end + 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 expression(value) + "ExpressionProperty(#{javascript(expression: { expr: { type: 'literal', value: }, args: {} })})" + end + + def do_nothing + { type: 'doNothing', argMap: {}, config: {}, disabledDuringExecution: false } + end + + def data_source_id = "p.#{Digest::SHA256.hexdigest(widget_key)[0, 6].to_i(16)}" + def widget_key = "p.#{@page_name}.#{@widget['Name']}" + + def css_class + ["mx-name-#{@widget['Name']}", @widget.dig('Appearance', 'Class')] + .map(&:to_s).reject(&:empty?).uniq.join(' ') + end + + def primitive(key) = value(key)&.fetch('PrimitiveValue', nil) + def value(key) = @values[key]&.last + 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 + + 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 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 document_index + {}.tap { |index| @source.documents.each { index_document(_1, index) } } + 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 # rubocop:enable Metrics/ClassLength + end +end diff --git a/lib/mxrb/compiler/database_connector_action_compiler.rb b/lib/mxrb/compiler/database_connector_action_compiler.rb index b2270fc..24255f4 100644 --- a/lib/mxrb/compiler/database_connector_action_compiler.rb +++ b/lib/mxrb/compiler/database_connector_action_compiler.rb @@ -31,6 +31,25 @@ def compile(action) } end + def unconfigured_write?(action) + return false unless action&.fetch('$Type', nil) == 'DatabaseConnector$ExecuteDatabaseQueryAction' + + connection, query = resolve_query(action['Query'].to_s) + return false if selecting_query?(query) + + override = array(action['ConnectionParameterMappings']).find do |mapping| + mapping['ParameterName'].to_s.casecmp('DBSource').zero? + end + return false unless override.nil? || override['Value'].to_s.strip.empty? + + constant_name = connection['ConnectionString'].to_s + constant = @source.units.find do |unit| + unit.document['$Type'] == 'Constants$Constant' && + "#{unit.module_name}.#{unit.document['Name']}" == constant_name + end + constant && constant.document['DefaultValue'].to_s.empty? + end + private def connection_index @@ -54,7 +73,7 @@ def resolve_query(qualified) def java_action_for(query) sql = remove_comments(query['Query'].to_s).downcase - selecting = (sql.start_with?('select ') && !sql.include?(' into ')) || sql.start_with?('with ') + selecting = selecting_sql?(sql) mapped = array(query['TableMappings']).any? if selecting return mapped ? 'ExternalDatabaseConnector.ExecuteQuery' : 'ExternalDatabaseConnector.ExecuteStatement' @@ -69,6 +88,14 @@ def java_action_for(query) end end + def selecting_query?(query) + selecting_sql?(remove_comments(query['Query'].to_s).downcase) + end + + def selecting_sql?(sql) + (sql.start_with?('select ') && !sql.include?(' into ')) || sql.start_with?('with ') + end + def statement_action(mapped) mapped ? 'ExternalDatabaseConnector.ExecuteQuery' : 'ExternalDatabaseConnector.ExecuteStatement' end diff --git a/lib/mxrb/compiler/domain_document_compiler.rb b/lib/mxrb/compiler/domain_document_compiler.rb index b802cb2..0c64be9 100644 --- a/lib/mxrb/compiler/domain_document_compiler.rb +++ b/lib/mxrb/compiler/domain_document_compiler.rb @@ -136,10 +136,18 @@ def compile_validation(source) def compile_index(source) { '$ID' => source['$ID'], '$Type' => source['$Type'], - 'Attributes' => array(source['Attributes']).map { plain_document(_1) }, + 'Attributes' => array(source['Attributes']).map { compile_indexed_attribute(_1) }, 'GUID' => source['GUID'], 'IncludeInOffline' => source['IncludeInOffline'] == true } end + + def compile_indexed_attribute(source) + result = plain_document(source) + result['AssociationPointer'] ||= BSON::Binary.new( + IO::BsonCodec.uuid_to_blob('00000000-0000-0000-0000-000000000000'), :generic + ) + result + end end end end diff --git a/lib/mxrb/compiler/gallery_bundle_compiler.rb b/lib/mxrb/compiler/gallery_bundle_compiler.rb index 642fbb9..b72952d 100644 --- a/lib/mxrb/compiler/gallery_bundle_compiler.rb +++ b/lib/mxrb/compiler/gallery_bundle_compiler.rb @@ -20,11 +20,12 @@ def initialize(source, page_name, widget) @values = property_values(widget['Object']) @data_source = WebListDataSource.new(source, widget) @entity_name = data_source.entity + @xpath_arguments = resolve_xpath_arguments end def supported? widget_type&.fetch('WidgetId', nil) == WIDGET_ID && - @data_source.supported? && !entity_name.to_s.empty? + @data_source.supported? && !entity_name.to_s.empty? && !@xpath_arguments.nil? end def content_widgets = array(@values['content']&.last&.fetch('Widgets', nil)) @@ -56,7 +57,31 @@ def datasource end def xpath_datasource(config) - "DatabaseObjectListProperty(#{js_object(config.merge(entity: entity_name, sort: []))})" + values = config.merge(entity: entity_name, sort: []) + unless @xpath_arguments.empty? + values[:arguments] = @xpath_arguments + values[:fetchOnlyWithAllParams] = true + end + "DatabaseObjectListProperty(#{js_object(values)})" + end + + def resolve_xpath_arguments + return {} unless @data_source.xpath? + + names = xpath_variables(@data_source.xpath_constraint) + return {} if names.empty? + + parameters = object_page_parameters(page_document, names) + return unless parameters + + parameters.to_h { |name, _entity| [name, ["$#{name}", :undefined, false]] } + end + + def page_document + module_name, document_name = @page_name.split('.', 2) + @source.units_of('Forms$Page').find do |unit| + unit.module_name == module_name && unit.document['Name'] == document_name + end&.document end def microflow_datasource(config) diff --git a/lib/mxrb/compiler/image_bundle_compiler.rb b/lib/mxrb/compiler/image_bundle_compiler.rb new file mode 100644 index 0000000..e767157 --- /dev/null +++ b/lib/mxrb/compiler/image_bundle_compiler.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'json' + +module Mxrb + module Compiler + # Compiles the static-image subset of the official React Image widget. + class ImageBundleCompiler # rubocop:disable Metrics/ClassLength + include ModelValues + + WIDGET_ID = 'com.mendix.widget.web.image.Image' + + def self.render_static(key, css_class, uri, options) # rubocop:disable Metrics/MethodLength + properties = { + key:, '$widgetId': key, datasource: 'image', + imageObject: raw("WebStaticImageProperty({ image: { uri: #{JSON.generate(uri)} } })"), + imageUrl: raw(expression('')), isBackgroundImage: false, onClickType: 'action', + alternativeText: raw(expression('')), widthUnit: unit(options[:width_unit]), + width: number(options[:width], 100), heightUnit: unit(options[:height_unit]), + height: number(options[:height], 100), iconSize: 14, + displayAs: 'fullImage', responsive: options[:responsive], minHeightUnit: 'none', minHeight: 0, + maxHeightUnit: 'none', maxHeight: 0, class: css_class + } + "React.createElement($Image, #{javascript(properties)})" + end # rubocop:enable Metrics/MethodLength + + def self.expression(value) + "ExpressionProperty({ expression: { expr: { type: \"literal\", value: #{JSON.generate(value)} }, args: {} } })" + end + + def self.raw(value) = { '$raw' => value } + def self.unit(value) = value.to_s.downcase.then { _1.empty? ? 'auto' : _1 } + def self.number(value, fallback) = Integer(value || fallback) + + def self.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 + + def initialize(source, page_name, widget) + @source = source + @page_name = page_name + @widget = widget + @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 + 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 + ) + "React.createElement($Image, #{self.class.javascript(values)})" + end # rubocop:enable Metrics/AbcSize + + private + + def primitive_properties + @values.each_with_object({}) do |(key, (type, value)), result| + compiled = case type + when 'Boolean' then value['PrimitiveValue'] == 'true' + when 'Integer' then value['PrimitiveValue'].to_i + when 'Enumeration' then value['PrimitiveValue'].to_s + end + result[key.to_sym] = compiled unless compiled.nil? + end + end + + def primitive(key) + pair = @values[key] + return unless pair + + pair.last.fetch('PrimitiveValue', nil) + end + + def text_value(key) + pair = @values[key] + return translated_text(nil) unless pair + + translated_text(pair.last.fetch('TextTemplate', nil)) + end + + def image_uri # rubocop:disable Metrics/AbcSize + pair = @values['imageObject'] + reference = pair ? pair.last.fetch('Image', '').to_s : '' + module_name, collection_name, image_name = reference.split('.', 3) + unit = @source.units_of('Images$ImageCollection').find do |candidate| + candidate.module_name == module_name && candidate.document['Name'] == collection_name + end + return unless unit + + image = array(unit.document['Images']).find { _1['Name'] == image_name } + return unless image + + "img/#{[module_name, collection_name, image_name].join('$')}.#{image_format(image)}" + end # rubocop:enable Metrics/AbcSize + + def translated_text(template) + items = template ? array(template.dig('Template', 'Items')) : [] + items.find { _1['LanguageCode'] == 'en_US' }&.fetch('Text', '') || + items.first&.fetch('Text', '') || '' + end + + def widget_key = "p.#{@page_name}.#{@widget['Name']}" + + def css_class + ["mx-name-#{@widget['Name']}", @widget.dig('Appearance', 'Class')] + .map(&:to_s).reject(&:empty?).uniq.join(' ') + end + + def property_values(object) + properties = object ? array(object.fetch('Properties', nil)) : [] + properties.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 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 document_index + {}.tap { |index| @source.documents.each { index_document(_1, index) } } + 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 # rubocop:enable Metrics/ClassLength + end +end diff --git a/lib/mxrb/compiler/java_proxy_generator.rb b/lib/mxrb/compiler/java_proxy_generator.rb index c79f196..ded4ee0 100644 --- a/lib/mxrb/compiler/java_proxy_generator.rb +++ b/lib/mxrb/compiler/java_proxy_generator.rb @@ -6,13 +6,15 @@ module Mxrb module Compiler # Materializes the subset of Studio Pro generated Java proxies required by project sources. # Proxy syntax is intentionally kept together so generated Java remains auditable. - # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity + # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity class JavaProxyGenerator include ModelValues TYPE_MAP = { 'DomainModels$StringAttributeType' => 'java.lang.String', 'DomainModels$HashStringAttributeType' => 'java.lang.String', + 'DomainModels$HashedStringAttributeType' => 'java.lang.String', 'DomainModels$IntegerAttributeType' => 'java.lang.Integer', 'DomainModels$LongAttributeType' => 'java.lang.Long', 'DomainModels$AutoNumberAttributeType' => 'java.lang.Long', @@ -31,16 +33,25 @@ class JavaProxyGenerator def initialize(mpr_path, project_root: File.dirname(File.expand_path(mpr_path))) @source = SourceModel.read(mpr_path) @project_root = File.expand_path(project_root) - @domain_units = @source.units_of('DomainModels$DomainModel') + @system_seed = SystemModelSeed.for(@source.version) + @domain_units = @source.units_of('DomainModels$DomainModel') + [system_domain_unit] @entities = entity_index end def generate + generated = write_user_actions_registrar ? 1 : 0 source_text = java_source_text + generated += microflow_modules.count do |mod| + microflows_referenced?(source_text, mod) && write_microflows(mod, source_text) + end + source_text = java_source_text if generated.positive? requested = requested_entities(source_text) - generated = requested.count { |name| write_entity(name) } + entity_count = requested.count { |name| write_entity(name) } + generated += entity_count + source_text = java_source_text if entity_count.positive? generated += enumeration_units.count { |unit| referenced?(source_text, proxy_name(unit)) && write_enum(unit) } - generated + constant_modules.count { |mod| constants_referenced?(source_text, mod) && write_constants(mod) } + generated += constant_modules.count { |mod| constants_referenced?(source_text, mod) && write_constants(mod) } + generated end private @@ -49,20 +60,79 @@ def java_source_text Dir.glob(File.join(@project_root, 'javasource', '**', '*.java')).sort.map { File.read(_1) }.join("\n") end + def write_user_actions_registrar + classes = user_action_classes + return false if classes.empty? + + path = File.join(@project_root, 'javasource', 'system', 'UserActionsRegistrar.java') + write_missing(path, user_actions_registrar_source(classes)) + end + + def user_action_classes + @source.units_of('JavaActions$JavaAction').filter_map do |unit| + name = document_name(unit.document).to_s + package_name = java_package(unit.module_name) + source = File.join(@project_root, 'javasource', package_name, 'actions', "#{name}.java") + "#{package_name}.actions.#{name}" if !name.empty? && File.file?(source) + end.sort.uniq + end + + def user_actions_registrar_source(classes) + registrations = classes.map do |class_name| + " registrator.registerUserAction(#{class_name}.class);" + end.join("\n") + <<~JAVA + // Generated natively by mxrb from Java action documents and sources. + package system; + + public class UserActionsRegistrar { + public void registerActions(com.mendix.core.actionmanagement.IActionRegistrator registrator) { + #{registrations} + } + } + JAVA + end + def entity_index @domain_units.each_with_object({}) do |unit, index| array(unit.document['Entities']).each do |entity| - index["#{unit.module_name}.#{entity['Name']}"] = [unit, entity] + index[entity_qualified_name(unit, entity)] = [unit, entity] end end end + def system_domain_unit + document = @system_seed.domain_document + SourceModel::Unit.new( + id: identifier(document['$ID']), container_id: SystemModelSeed::MODULE_ID, + containment: 'DomainModel', document:, module_name: 'System' + ) + end + + def entity_qualified_name(unit, entity) + entity['QualifiedName'] || "#{unit.module_name}.#{entity.fetch('Name')}" + end + + def entity_name(entity) = entity['Name'] || entity['UnqualifiedName'] + def requested_entities(text) - requested = @entities.keys.select { referenced?(text, java_proxy_name(_1)) } - requested.each_with_object(requested.to_set) do |name, result| - parent = generalization(@entities.fetch(name).last) - result << parent if @entities.key?(parent) - end.to_a + result = @entities.keys.select { referenced?(text, java_proxy_name(_1)) }.to_set + loop do + before = result.length + result.to_a.each { add_entity_dependencies(_1, result) } + break if result.length == before + end + result.to_a + end + + def add_entity_dependencies(name, result) + unit, entity = @entities.fetch(name) + parent = generalization(entity) + result << parent if @entities.key?(parent) + associations_for(unit, entity).each do |association| + child = association_child_qualified(unit, association) + result << child if @entities.key?(child) + end end def referenced?(text, java_name) @@ -74,21 +144,25 @@ def java_proxy_name(qualified) "#{java_package(mod)}.proxies.#{name}" end - def proxy_name(unit) = "#{java_package(unit.module_name)}.proxies.#{unit.document['Name']}" + def proxy_name(unit) = "#{java_package(unit.module_name)}.proxies.#{document_name(unit.document)}" def java_package(mod) = mod.to_s.downcase def write_entity(qualified) unit, entity = @entities.fetch(qualified) - path = proxy_path(unit.module_name, "#{entity['Name']}.java") + path = proxy_path(unit.module_name, "#{entity_name(entity)}.java") write_missing(path, entity_source(unit, entity)) end def entity_source(unit, entity) # rubocop:disable Metrics/MethodLength - qualified = "#{unit.module_name}.#{entity['Name']}" + name = entity_name(entity) + qualified = entity_qualified_name(unit, entity) java_name = java_proxy_name(qualified) parent = generalization(entity) local_parent = @entities.key?(parent) ? java_proxy_name(parent) : nil - members = attributes(entity).map { _1['Name'] } + associations_for(unit, entity).map { _1['Name'] } + members = attributes(entity).map { [_1['Name'], _1['Name']] } + members += associations_for(unit, entity).map do |association| + [association_name(association), association_qualified_name(unit, association)] + end inheritance = if local_parent "extends #{local_parent}" else @@ -106,20 +180,20 @@ def entity_source(unit, entity) # rubocop:disable Metrics/MethodLength // Generated natively by mxrb from the MPR model. package #{java_package(unit.module_name)}.proxies; - public class #{entity['Name']} #{inheritance} { + public class #{name} #{inheritance} { #{storage} public static final java.lang.String entityName = "#{qualified}"; public enum MemberNames { - #{members.map { |name| "#{name}(\"#{name}\")" }.join(",\n ")}; + #{members.map { |member, meta| "#{member}(\"#{meta}\")" }.join(",\n ")}; private final java.lang.String metaName; MemberNames(java.lang.String value) { metaName = value; } @java.lang.Override public java.lang.String toString() { return metaName; } } - public #{entity['Name']}(com.mendix.systemwideinterfaces.core.IContext context) { + public #{name}(com.mendix.systemwideinterfaces.core.IContext context) { this(context, com.mendix.core.Core.instantiate(context, entityName)); } - protected #{entity['Name']}(com.mendix.systemwideinterfaces.core.IContext context, + protected #{name}(com.mendix.systemwideinterfaces.core.IContext context, com.mendix.systemwideinterfaces.core.IMendixObject mendixObject) { #{constructor_body} } @@ -203,19 +277,16 @@ def attribute_java_type(unit, type) def associations_for(unit, entity) id = identifier(entity['$ID']) - array(unit.document['Associations']).select { identifier(_1['ParentPointer']) == id } + associations = array(unit.document['Associations']) + array(unit.document['CrossAssociations']) + associations.select { identifier(_1['ParentPointer']) == id } end def association_methods(unit, association) + name = association_name(association) + child_type = association_child_type(unit, association) + return reference_methods(name, child_type) if association['Type'] == 'Reference' return '' unless association['Type'] == 'ReferenceSet' - name = association['Name'] - child = entity_by_id(association['ChildPointer']) - child_type = if child - java_proxy_name("#{unit.module_name}.#{child['Name']}") - else - 'com.mendix.systemwideinterfaces.core.IEntityProxy' - end <<~JAVA public final java.util.List<#{child_type}> get#{name}() throws com.mendix.core.CoreException { return get#{name}(getContext()); } public final java.util.List<#{child_type}> get#{name}(com.mendix.systemwideinterfaces.core.IContext context) throws com.mendix.core.CoreException { @@ -234,6 +305,47 @@ def association_methods(unit, association) JAVA end + def association_name(association) + association['Name'] || association['UnqualifiedName'] || association['QualifiedName'].to_s.split('.').last + end + + def association_qualified_name(unit, association) + association['QualifiedName'] || "#{unit.module_name}.#{association_name(association)}" + end + + def association_child_type(unit, association) + qualified = association_child_qualified(unit, association) + unless qualified.empty? + return java_proxy_name(qualified) if @entities.key?(qualified) + + return 'com.mendix.systemwideinterfaces.core.IEntityProxy' + end + + 'com.mendix.systemwideinterfaces.core.IEntityProxy' + end + + def association_child_qualified(unit, association) + qualified = association['Child'].to_s + return qualified unless qualified.empty? + + child = entity_by_id(association['ChildPointer']) + child ? entity_qualified_name(unit, child) : '' + end + + def reference_methods(name, child_type) + <<~JAVA + public final #{child_type} get#{name}() throws com.mendix.core.CoreException { return get#{name}(getContext()); } + public final #{child_type} get#{name}(com.mendix.systemwideinterfaces.core.IContext context) throws com.mendix.core.CoreException { + com.mendix.systemwideinterfaces.core.IMendixIdentifier id = getMendixObject().getValue(context, MemberNames.#{name}.toString()); + return id == null ? null : #{child_type}.load(context, id); + } + public final void set#{name}(#{child_type} value) { set#{name}(getContext(), value); } + public final void set#{name}(com.mendix.systemwideinterfaces.core.IContext context, #{child_type} value) { + getMendixObject().setValue(context, MemberNames.#{name}.toString(), value == null ? null : value.getMendixObject().getId()); + } + JAVA + end + def entity_by_id(id) wanted = identifier(id) @entities.values.map(&:last).find { identifier(_1['$ID']) == wanted } @@ -246,18 +358,32 @@ def identifier(value) def attributes(entity) = array(entity['Attributes']) def generalization(entity) = entity.dig('MaybeGeneralization', 'Generalization').to_s - def enumeration_units = @source.units_of('Enumerations$Enumeration') + def enumeration_units + source_units = @source.units_of('Enumerations$Enumeration') + system_units = @system_seed.package.documents.filter_map do |document| + next unless document['$Type'] == 'Enumerations$Enumeration' + + SourceModel::Unit.new( + id: identifier(document['$ID']), container_id: SystemModelSeed::MODULE_ID, + containment: 'AllDocuments', document:, module_name: 'System' + ) + end + source_units + system_units + end def write_enum(unit) names = array(unit.document['Values']).map { _1['Name'] } + name = document_name(unit.document) source = <<~JAVA // Generated natively by mxrb from the MPR model. package #{java_package(unit.module_name)}.proxies; - public enum #{unit.document['Name']} { #{names.join(', ')} } + public enum #{name} { #{names.join(', ')} } JAVA - write_missing(proxy_path(unit.module_name, "#{unit.document['Name']}.java"), source) + write_missing(proxy_path(unit.module_name, "#{name}.java"), source) end + def document_name(document) = document['Name'] || document['UnqualifiedName'] + def constant_modules = @source.units_of('Constants$Constant').map(&:module_name).uniq def constants_referenced?(text, mod) @@ -283,6 +409,88 @@ def write_constants(mod) write_missing(proxy_path(mod, 'constants', 'Constants.java'), source) end + def microflow_modules = @source.units_of('Microflows$Microflow').map(&:module_name).uniq + + def microflows_referenced?(text, mod) + referenced?(text, "#{java_package(mod)}.proxies.microflows.Microflows") + end + + def write_microflows(mod, text) + units = @source.units_of('Microflows$Microflow').select { _1.module_name == mod } + selected = units.select { |unit| referenced_microflow?(text, unit.document['Name']) } + return false if selected.empty? + + methods = selected.map { microflow_method(_1) }.join("\n") + source = <<~JAVA + // Generated natively by mxrb from the MPR model. + package #{java_package(mod)}.proxies.microflows; + public final class Microflows { + private Microflows() {} + #{methods} + } + JAVA + write_missing(proxy_path(mod, 'microflows', 'Microflows.java'), source) + end + + def referenced_microflow?(text, name) + method = lower_camel(name) + text.match?(/\bMicroflows\.#{Regexp.escape(method)}\b/) + end + + def microflow_method(unit) + parameters = microflow_parameters(unit.document) + declarations = parameters.map do |parameter| + "#{java_data_type(parameter['VariableType'])} _#{lower_camel(parameter['Name'])}" + end + arguments = parameters.map do |parameter| + ".withParam(\"#{parameter['Name']}\", _#{lower_camel(parameter['Name'])})" + end.join + return_type = java_data_type(unit.document['MicroflowReturnType'], return_type: true) + result = microflow_result(unit.document['MicroflowReturnType']) + <<~JAVA + public static #{return_type} #{lower_camel(unit.document['Name'])}( + com.mendix.systemwideinterfaces.core.IContext context#{declarations.empty? ? '' : ",\n #{declarations.join(",\n ")}"}) { + Object result = com.mendix.core.Core.microflowCall("#{unit.module_name}.#{unit.document['Name']}")#{arguments}.execute(context); + #{result} + } + JAVA + end + + def microflow_parameters(document) + array(document.dig('ObjectCollection', 'Objects')).select do |object| + object['$Type'] == 'Microflows$MicroflowParameter' + end + end + + def java_data_type(type, return_type: false) + case type&.fetch('$Type', nil) + when 'DataTypes$BooleanType' then return_type ? 'boolean' : 'java.lang.Boolean' + when 'DataTypes$IntegerType', 'DataTypes$LongType' then 'java.lang.Long' + when 'DataTypes$DecimalType' then 'java.math.BigDecimal' + when 'DataTypes$DateTimeType' then 'java.util.Date' + when 'DataTypes$StringType' then 'java.lang.String' + when 'DataTypes$ObjectType' then java_proxy_name(type['Entity']) + when 'DataTypes$VoidType', nil then 'void' + else 'java.lang.Object' + end + end + + def microflow_result(type) + case type&.fetch('$Type', nil) + when 'DataTypes$BooleanType' then 'return (boolean) result;' + when 'DataTypes$ObjectType' + proxy = java_proxy_name(type['Entity']) + "return result == null ? null : #{proxy}.initialize(context, " \ + '(com.mendix.systemwideinterfaces.core.IMendixObject) result);' + when 'DataTypes$VoidType', nil then 'return;' + else "return (#{java_data_type(type, return_type: true)}) result;" + end + end + + def lower_camel(value) + value.to_s.sub(/\A./, &:downcase) + end + def proxy_path(mod, *parts) File.join(@project_root, 'javasource', java_package(mod), 'proxies', *parts) end @@ -295,6 +503,7 @@ def write_missing(path, contents) true end end - # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity + # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity end end diff --git a/lib/mxrb/compiler/microflow_node_compiler.rb b/lib/mxrb/compiler/microflow_node_compiler.rb index 07feacc..a5e2560 100644 --- a/lib/mxrb/compiler/microflow_node_compiler.rb +++ b/lib/mxrb/compiler/microflow_node_compiler.rb @@ -59,7 +59,10 @@ def compile_hash(value) return value.to_h { |key, child| [key, compile(child)] } unless value['$Type'] return nil if EDITORIAL_TYPES.include?(value['$Type']) return compile_custom_range(value) if value['$Type'] == 'Microflows$CustomRange' + if value['$Type'] == 'DatabaseConnector$ExecuteDatabaseQueryAction' + return compile(noop_database_action(value)) if @database_connector.unconfigured_write?(value) + return compile(@database_connector.compile(value)) end @@ -73,6 +76,18 @@ def compile_custom_range(source) } end + def noop_database_action(source) + { + '$ID' => source['$ID'], '$Type' => 'Microflows$LogMessageAction', + 'MessageTemplate' => { + '$ID' => derived_id(source, 'local-fallback-message'), + '$Type' => 'Microflows$StringTemplate', 'Parameters' => [], 'Text' => '' + }, + 'ErrorHandlingType' => source.fetch('ErrorHandlingType', 'Rollback'), + 'Level' => 'Trace', 'Node' => "'Mxrb'", 'IncludeLatestStackTrace' => false + } + end + def compile_node(source) @schema.fields_for(source).to_h { |field| [field, node_value(source, field)] } end @@ -87,6 +102,9 @@ def node_value(source, field) if field == 'StringRepresentation' && source['$Type'] == 'Microflows$InheritanceCase' return derived_default(source, field) end + if field == 'ValueExpression' && source['$Type'] == 'Microflows$MicroflowParameterValue' + return "'#{source.fetch('Microflow')}'" + end existing = @schema.counterpart(source) if RUNTIME_DERIVED_FIELDS.fetch(source['$Type'], []).include?(field) && existing&.key?(field) @@ -176,7 +194,10 @@ def association_retrieve_type(source) start = @variable_types[source['StartVariableName'].to_s] target = start == association[:child] ? association[:parent] : association[:child] - association[:many] ? "[#{target}]" : target + return if target.to_s.empty? + + list = association[:reference_set] || start == association[:child] + list ? "[#{target}]" : target end def runtime_association(name) @@ -187,7 +208,7 @@ def runtime_association(name) child = @schema.counterpart_id(association['ChildPointer']) { parent: parent&.fetch('QualifiedName', nil), child: child&.fetch('QualifiedName', nil), - many: association['Type'] == 'ReferenceSet' + reference_set: association['Type'] == 'ReferenceSet' } end @@ -202,7 +223,7 @@ def association_index(source) result["#{unit.module_name}.#{association['Name']}"] = { parent: entities[IO::BsonCodec.extract_id(association['ParentPointer'])], child: entities[IO::BsonCodec.extract_id(association['ChildPointer'])], - many: association['Type'] == 'ReferenceSet' + reference_set: association['Type'] == 'ReferenceSet' } end end diff --git a/lib/mxrb/compiler/model_values.rb b/lib/mxrb/compiler/model_values.rb index 0141186..55a4e86 100644 --- a/lib/mxrb/compiler/model_values.rb +++ b/lib/mxrb/compiler/model_values.rb @@ -10,6 +10,31 @@ def array(value) = IO::BsonCodec.parse_array(value)[:items] def plain_array(value) = value ? array(value).map { plain_value(_1) } : [] def plain_document(value) = value ? plain_value(value) : nil + def xpath_variables(constraint) + constraint.to_s.scan(/\$([A-Za-z_]\w*)/).flatten.uniq + end + + def object_page_parameters(document, names) + indexed = page_parameter_types(document) + entities = names.to_h { |name| [name, object_parameter_entity(indexed[name])] } + entities if entities.values.all? + end + + def page_parameter_types(document) + array(document&.fetch('Parameters', nil)).filter_map do |parameter| + next unless parameter.is_a?(Hash) + + [parameter['Name'].to_s, parameter['ParameterType'] || {}] + end.to_h + end + + def object_parameter_entity(type) + return unless type&.fetch('$Type', nil) == 'DataTypes$ObjectType' + + entity = type['Entity'].to_s + entity unless entity.empty? + end + def image_bytes(value) value.respond_to?(:data) ? value.data : value.to_s.b end diff --git a/lib/mxrb/compiler/navigation_document_compiler.rb b/lib/mxrb/compiler/navigation_document_compiler.rb index 8ff06bf..22cd783 100644 --- a/lib/mxrb/compiler/navigation_document_compiler.rb +++ b/lib/mxrb/compiler/navigation_document_compiler.rb @@ -79,7 +79,14 @@ def form_settings(source) def text_reference(source) return nil unless source - source.slice('$ID', '$Type') + @schema.fields_for(source).to_h do |field| + value = case field + when 'Parameters' then array(source[field]).map { text_reference(_1) } + when 'Text' then text_reference(source[field]) + else source[field] + end + [field, value] + end end end end diff --git a/lib/mxrb/compiler/page_bundle_compiler.rb b/lib/mxrb/compiler/page_bundle_compiler.rb index a0d116a..bcd9f81 100644 --- a/lib/mxrb/compiler/page_bundle_compiler.rb +++ b/lib/mxrb/compiler/page_bundle_compiler.rb @@ -13,6 +13,8 @@ class PageBundleCompiler # rubocop:disable Metrics/ClassLength def initialize(source) @source = source @unsupported = [] + @uses_conditional = false + @uses_dynamic_class = false end def compile(unit) @@ -55,20 +57,29 @@ def content_function(widgets) def children(widgets) = "[#{widgets.map { render_widget(_1) }.join(', ')}]" - def render_widget(widget) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength - case widget['$Type'] - when 'Forms$DivContainer' then render_container(widget) - when 'Forms$LayoutGrid' then render_layout_grid(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$ActionButton' then render_action_button(widget) - when 'Forms$DataView' then render_data_view(widget) - when 'Forms$TextBox' then render_text_box(widget) - when 'CustomWidgets$CustomWidget' then render_custom_widget(widget) - else render_unsupported(widget) - end - end # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + def render_widget(widget) + rendered = case widget['$Type'] + when 'Forms$DivContainer' then render_container(widget) + when 'Forms$LayoutGrid' then render_layout_grid(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$ActionButton' then render_action_button(widget) + when 'Forms$DataView' then render_data_view(widget) + when 'Forms$TextBox' then render_text_box(widget) + when 'Forms$DatePicker' then render_date_picker(widget) + when 'Forms$CheckBox' then render_check_box(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 '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') @@ -98,7 +109,8 @@ def render_element(tag, widget, widgets, base_class) "React.createElement(#{JSON.generate(tag)}, #{js_props(props)})" end - def render_custom_widget(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def render_custom_widget(widget) grid = DataGridBundleCompiler.new(@source, @qualified_name, widget) if grid.supported? @uses_data_grid = true @@ -106,27 +118,71 @@ def render_custom_widget(widget) # rubocop:disable Metrics/AbcSize, Metrics/Meth end gallery = GalleryBundleCompiler.new(@source, @qualified_name, widget) - return render_unsupported(widget) unless gallery.supported? + if gallery.supported? + @uses_gallery = true + nano_reference = nanoflow_reference(gallery.data_source.nanoflow_name) if gallery.data_source.nanoflow? + return render_unsupported(widget) if gallery.data_source.nanoflow? && !nano_reference + + @list_scopes << { scope: gallery.widget_key, entity: gallery.entity_name } + content = children(gallery.content_widgets) + @list_scopes.pop + return gallery.render(content, nanoflow_reference: nano_reference) + end - @uses_gallery = true - nano_reference = nanoflow_reference(gallery.data_source.nanoflow_name) if gallery.data_source.nanoflow? - return render_unsupported(widget) if gallery.data_source.nanoflow? && !nano_reference + image = ImageBundleCompiler.new(@source, @qualified_name, widget) + if image.supported? + @uses_image = true + @uses_custom_image = true + return image.render + end - @list_scopes << { scope: gallery.widget_key, entity: gallery.entity_name } - content = children(gallery.content_widgets) - @list_scopes.pop - gallery.render(content, nanoflow_reference: nano_reference) - end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + combo = ComboBoxBundleCompiler.new( + @source, @qualified_name, widget, + scope: scope_name(@data_view_scopes.last), entity: @data_view_scopes.last&.fetch(:entity, nil) + ) + return render_unsupported(widget) unless combo.supported? + + @uses_form_widgets = true + @uses_combo_box = true + combo.render + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + + def render_container(widget) # rubocop:disable Metrics/AbcSize + action = widget['OnClickAction'] || {} + action_config = container_action_config(widget, action) + return render_action_container(widget, action_config) if action_config - def render_container(widget) props = common_props(widget).merge( className: css_class(widget), children: array(widget['Widgets']).map { render_widget(_1) } ) - "React.createElement(#{JSON.generate(render_mode(widget))}, #{js_props(props)})" + handler = action_handler(action) if action['$Type'] && action['$Type'] != 'Forms$NoAction' + props[:role] = 'button' if handler + "React.createElement(#{JSON.generate(render_mode(widget))}, " \ + "#{js_props(props, expressions: { onClick: handler }.compact)})" + end # rubocop:enable Metrics/AbcSize + + def render_action_container(widget, action_config) + @uses_form_widgets = true + @uses_container = true + key = widget_key(widget) + props = common_props(widget).merge( + '$widgetId': key, class: css_class(widget), renderMode: render_mode(widget), + content: array(widget['Widgets']).map { render_widget(_1) } + ) + "React.createElement($Container, #{js_props(props, expressions: { + onClick: "ActionProperty(#{js_literal(action_config)})" + })})" + end + + def container_action_config(widget, action) + return nanoflow_action_config(action) if nanoflow_action?(action) + + client_action_config(widget, action) end def render_text(widget) - return render_bound_text(widget) if bound_text_attribute(widget) + return render_bound_text(widget) if bound_text_attributes(widget) props = common_props(widget).merge(className: css_class(widget)) caption = translated_text(widget.dig('Content', 'Template')) @@ -134,13 +190,25 @@ def render_text(widget) end def render_bound_text(widget) - scope = @list_scopes.last - attribute = bound_text_attribute(widget) - entity, _, name = attribute.rpartition('.') + scope = current_object_scope + attributes = bound_text_attributes(widget) + parameters = array(widget.dig('Content', 'Parameters')) @uses_bound_text = true - "React.createElement($MxrbFormattedText, #{js_props(bound_text_props(widget), expressions: { - value: attribute_property(scope.fetch(:scope), entity, name) - })})" + expressions = attributes.map.with_index do |attribute, index| + ["value#{index + 1}".to_sym, bound_text_value(scope, attribute, parameters[index])] + end.to_h + "React.createElement($MxrbFormattedText, #{js_props(bound_text_props(widget), expressions:)})" + end + + def bound_text_value(scope, attribute, parameter) + entity, _, name = attribute.rpartition('.') + attribute_property(scope.fetch(:scope), entity, name, path: attribute_reference_path(parameter)) + end + + def attribute_reference_path(parameter) + steps = array(parameter&.dig('AttributeRef', 'EntityRef', 'Steps')) + steps.flat_map { [_1['Association'], _1['DestinationEntity']] } + .select { present_identifier?(_1) }.join('/') end def bound_text_props(widget) @@ -150,21 +218,217 @@ def bound_text_props(widget) ) end - def bound_text_attribute(widget) - return unless @list_scopes.any? + def bound_text_attributes(widget) + return unless current_object_scope parameters = array(widget.dig('Content', 'Parameters')) - return unless parameters.length == 1 + return if parameters.empty? + + attributes = parameters.map { text_parameter_attribute(_1) } + attributes if attributes.all? + end + + def text_parameter_attribute(parameter) + attribute = parameter.dig('AttributeRef', 'Attribute').to_s + return attribute if qualified_attribute?(attribute) - attribute = parameters.first.dig('AttributeRef', 'Attribute').to_s + match = parameter['Expression'].to_s.match( + %r{\A(?:toString\()?\$currentObject/([A-Za-z_]\w*)\)?\z} + ) + entity = current_object_scope&.fetch(:entity, '').to_s + return unless match && present_identifier?(entity) + + "#{entity}.#{match[1]}" + end + + def qualified_attribute?(attribute) entity, separator, name = attribute.rpartition('.') - attribute if separator == '.' && present_identifier?(entity) && present_identifier?(name) + separator == '.' && present_identifier?(entity) && present_identifier?(name) + end + + def current_object_scope + scope = @list_scopes.last || @data_view_scopes.last + return scope if scope.is_a?(Hash) + return unless scope + + { scope:, entity: '' } end - def render_action_button(widget) # rubocop:disable Metrics/AbcSize + def scope_name(scope) = scope.is_a?(Hash) ? scope[:scope] : scope + + def wrap_conditional_visibility(widget, rendered) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + condition = conditional_visibility(widget['ConditionalVisibilitySettings']) + return rendered unless condition + + @uses_conditional = true + attributes, predicate = condition + expressions = attributes.map.with_index do |attribute, index| + entity, _, name = attribute.rpartition('.') + ["value#{index + 1}".to_sym, + attribute_property(current_object_scope.fetch(:scope), entity, name)] + end.to_h.merge(test: "props => #{predicate}") + 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 + + def conditional_visibility(settings) + expression = settings&.fetch('Expression', '').to_s.strip + scope = current_object_scope + return if expression.empty? || scope.nil? || !present_identifier?(scope[:entity]) + + attributes = [] + predicate = visibility_logical(expression, attributes) + [attributes, predicate] if predicate + end + + def visibility_logical(expression, attributes) + parts = expression.split(/\s+or\s+/i) + return logical_predicate(parts, attributes, '||') if parts.length > 1 + + parts = expression.split(/\s+and\s+/i) + return logical_predicate(parts, attributes, '&&') if parts.length > 1 + + visibility_atom(expression, attributes) + end + + def logical_predicate(parts, attributes, operator) + predicates = parts.map { visibility_logical(_1.strip, attributes) } + return unless predicates.all? + + "(#{predicates.join(" #{operator} ")})" + end + + def visibility_atom(expression, attributes) # rubocop:disable Metrics/MethodLength + match = expression.strip.match( + %r{\A\$currentObject/([A-Za-z_]\w*)(?:\s*(=|!=)\s*(.+))?\z} + ) + return unless match + + attribute = "#{current_object_scope.fetch(:entity)}.#{match[1]}" + index = attributes.index(attribute) || attributes.length.tap { attributes << attribute } + value = "mxrbValue(props.value#{index + 1})" + return "Boolean(#{value})" unless match[2] + + comparison = visibility_comparison(value, match[3].strip) + return unless comparison + + match[2] == '!=' ? "!(#{comparison})" : comparison + end # rubocop:enable Metrics/MethodLength + + def visibility_comparison(value, expected) + case expected + when 'empty' then "(#{value} == null || #{value} === \"\")" + when 'true', 'false' then "#{value} === #{expected}" + when /\A-?\d+(?:\.\d+)?\z/ then "Number(#{value}) === #{expected}" + when /\A[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+\z/ + "String(#{value}) === #{JSON.generate(expected.split('.').last)}" + when /\A'(.*)'\z/m then "String(#{value}) === #{JSON.generate(Regexp.last_match(1))}" + 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 + return rendered if expression.empty? || scope.nil? || !present_identifier?(scope[:entity]) + + attributes = [] + resolver = dynamic_class_expression(expression, attributes) + return rendered unless resolver + + @uses_dynamic_class = true + expressions = attributes.map.with_index do |attribute, index| + entity, _, name = attribute.rpartition('.') + ["value#{index + 1}".to_sym, attribute_property(scope.fetch(:scope), entity, name)] + end.to_h.merge(resolveClass: "props => String(#{resolver} || '').trim()") + class_key = "#{widget_key(widget)}$class" + 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) + compiled = terms.map { dynamic_class_term(_1, attributes) } + return unless compiled.all? + + compiled.join(' + ') + end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:disable Metrics/PerceivedComplexity + def split_dynamic_class_terms(expression) + parts = [] + start = 0 + depth = 0 + quote = nil + expression.each_char.with_index do |character, index| + if quote + quote = nil if character == quote && expression[index - 1] != '\\' + elsif ["'", '"'].include?(character) + quote = character + elsif character == '(' + depth += 1 + elsif character == ')' + depth -= 1 + elsif character == '+' && depth.zero? + parts << expression[start...index].strip + start = index + 1 + end + end + 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) + quoted = quoted_dynamic_class(stripped) + return quoted if quoted + + if (match = stripped.match(%r{\A\$currentObject/([A-Za-z_]\w*)\z})) + return "String(#{dynamic_class_value(match[1], attributes)} ?? '')" + end + + dynamic_class_conditional(stripped, attributes) + end + + def quoted_dynamic_class(term) + match = term.match(/\A'(.*)'\z/m) || term.match(/\A"(.*)"\z/m) + JSON.generate(match[1]) if match + end + + def unwrap_dynamic_class_parentheses(term) + return term unless term.start_with?('(') && term.end_with?(')') + + term[1...-1].strip + end + + def dynamic_class_conditional(term, attributes) + match = term.match(/\Aif\s+(.+?)\s+then\s+(.+?)\s+else\s+(.+)\z/m) + return unless match + + predicate = visibility_logical(match[1].strip, attributes) + accepted = dynamic_class_expression(match[2].strip, attributes) + rejected = dynamic_class_expression(match[3].strip, attributes) + return unless predicate && accepted && rejected + + "(#{predicate} ? #{accepted} : #{rejected})" + end + + def dynamic_class_value(name, attributes) + attribute = "#{current_object_scope.fetch(:entity)}.#{name}" + index = attributes.index(attribute) || attributes.length.tap { attributes << attribute } + "mxrbValue(props.value#{index + 1})" + end + + def render_action_button(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 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) + return render_client_action_button(widget, action) if client_action_config(widget, action) caption = translated_text(widget.dig('CaptionTemplate', 'Template')) classes = ['btn', 'mx-button', button_style(widget), css_class(widget)].reject(&:empty?).join(' ') @@ -173,7 +437,98 @@ def render_action_button(widget) # rubocop:disable Metrics/AbcSize props[:disabled] = true unless handler "React.createElement(\"button\", #{js_props(props, expressions: { onClick: handler }.compact)}, " \ "#{JSON.generate(caption)})" - end # rubocop:enable Metrics/AbcSize + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def render_client_action_button(widget, action) + @uses_form_widgets = true + key = widget_key(widget) + caption = translated_text(widget.dig('CaptionTemplate', 'Template')) + "React.createElement($ActionButton, #{js_props(nanoflow_button_props(widget, key), expressions: { + caption: "TextProperty({ value: #{JSON.generate(caption)} })", + tooltip: 'TextProperty({ value: "" })', + action: "ActionProperty(#{js_literal(client_action_config(widget, action))})" + })})" + end + + def client_action_config(widget, action) + payload = open_link_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 + return unless action['$Type'] == 'Forms$OpenLinkClientAction' + + address = action['Address'] || {} + config = { schema: action['LinkType'].to_s.downcase } + arg_map = {} + if address['IsDynamic'] == true + attribute = address.dig('AttributeRef', 'Attribute').to_s + scope = scope_name(@data_view_scopes.last) + return unless scope && attribute.include?('.') + + config[:addressAttribute] = attribute.sub(/\.([^.]+)\z/, '/\\1') + arg_map[:'$object'] = { widget: scope, source: 'object' } + else + config[:address] = address['Value'].to_s + end + { type: 'openLink', argMap: arg_map, config:, + disabledDuringExecution: action.fetch('DisabledDuringExecution', true) } + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def microflow_config(widget, action) + return unless action['$Type'] == 'Forms$MicroflowAction' + + settings = action['MicroflowSettings'] || {} + return unless present_identifier?(settings['Microflow']) + + arg_map = microflow_argument_map(settings) + return unless arg_map + + { + type: 'callMicroflow', argMap: arg_map, + config: { operationId: WebOperationCompiler.operation_id(@qualified_name, widget['Name']) }, + disabledDuringExecution: action.fetch('DisabledDuringExecution', true) + } + end + + def microflow_argument_map(settings) + pairs = parameter_mappings(settings).map { microflow_argument(_1) } + return pairs.to_h if pairs.any? && pairs.all? + return unless pairs.empty? + + inferred_microflow_argument_map(settings['Microflow']) + end + + def inferred_microflow_argument_map(qualified_name) + scope = current_object_scope + return {} unless scope && present_identifier?(scope[:entity]) + + inferred_microflow_parameters(qualified_name, scope[:entity]).to_h do |parameter| + [parameter['Name'].to_sym, { widget: scope[:scope], source: 'object' }] + end + end + + def inferred_microflow_parameters(qualified_name, entity) + flow = @source.units_of('Microflows$Microflow').find do |unit| + "#{unit.module_name}.#{unit.document['Name']}" == qualified_name + end + array(flow&.document&.dig('ObjectCollection', 'Objects')).select do |object| + object['$Type'] == 'Microflows$MicroflowParameter' && + object.dig('VariableType', 'Entity') == entity + end + end + + def microflow_argument(mapping) + name = mapping['Parameter'].to_s.split('.').last + expression = mapping['Expression'].to_s + current_scope = current_object_scope&.fetch(:scope) + scope = expression == '$currentObject' ? current_scope : expression + return unless present_identifier?(name) && scope.to_s.match?(/\A\$[A-Za-z_]\w*\z|\Ap\./) + + [name.to_sym, { widget: scope, source: 'object' }] + end def nanoflow_action?(action) action['$Type'] == 'Forms$CallNanoflowClientAction' && @@ -224,7 +579,7 @@ def render_data_view(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLe object = data_view_object_property(widget, scope) return render_unsupported(widget) unless object - @data_view_scopes << scope + @data_view_scopes << { scope:, entity: data_view_entity(widget) } body = array(widget['Widgets']).map { render_widget(_1) } footer = array(widget['FooterWidgets']).map { render_widget(_1) } @data_view_scopes.pop @@ -251,6 +606,12 @@ def data_view_object_property(widget, scope) nil end + def data_view_entity(widget) + parameter = widget.dig('DataSource', 'SourceVariable', 'PageParameter').to_s + page_parameter = array(@unit.document['Parameters']).find { _1['Name'] == parameter } + page_parameter&.dig('ParameterType', 'Entity').to_s + end + def nanoflow_object_property(source, scope) reference = nanoflow_reference(source['Nanoflow']) return unless reference && parameter_mappings(source).empty? @@ -264,7 +625,7 @@ def nanoflow_object_property(source, scope) end def render_text_box(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - scope = @data_view_scopes.last + scope = scope_name(@data_view_scopes.last) attribute = widget.dig('AttributeRef', 'Attribute').to_s entity, separator, name = attribute.rpartition('.') return render_unsupported(widget) unless scope && separator == '.' && @@ -296,11 +657,129 @@ def render_text_box(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLen })})" end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength - def attribute_property(scope, entity, attribute) + def render_date_picker(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + scope = scope_name(@data_view_scopes.last) + 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_date_picker = true + key = widget_key(widget) + caption = translated_text(widget.dig('LabelTemplate', 'Template')) + placeholder = translated_text(widget.dig('PlaceholderTemplate', 'Template')) + date_format = widget.dig('FormattingInfo', 'DateFormat').to_s + mode = date_format.casecmp('time').zero? ? 'time' : 'date' + formatting = mode == 'time' ? { timeFormat: { type: 'time' } } : { dateFormat: { type: 'date' } } + input_props = common_props(widget).merge( + '$widgetId': key, mode:, showCalendarButton: widget.fetch('ShowCalendarButton', true), + readOnlyStyle: 'text', id: key + ) + input = "React.createElement($DatePicker, #{js_props(input_props, expressions: { + inputValue: attribute_property(scope, entity, name, formatting:), + placeholder: "TextProperty({ value: #{JSON.generate(placeholder)} })", + buttonLabel: 'TextProperty({ value: "Show date picker" })' + })})" + group_props = { + key: "#{key}$formGroup", '$widgetId': "#{key}$formGroup", + class: "mx-name-#{widget['Name']} mx-datepicker", 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 # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def render_check_box(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + scope = scope_name(@data_view_scopes.last) + 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 + key = widget_key(widget) + input = "React.createElement($CheckBox, #{js_props( + common_props(widget).merge('$widgetId': key, readOnlyStyle: 'text', id: key), + expressions: { value: attribute_property(scope, entity, name) } + )})" + group_props = { + key: "#{key}$formGroup", '$widgetId': "#{key}$formGroup", + class: "mx-name-#{widget['Name']} mx-checkbox", control: [input], + width: 3, orientation: 'horizontal', labelFor: key + } + caption = translated_text(widget.dig('LabelTemplate', 'Template')) + "React.createElement($FormGroup, #{js_props(group_props, expressions: { + caption: "TextProperty({ value: #{JSON.generate(caption)} })", + hasError: 'TextProperty({ value: false })' + })})" + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def render_label(widget) + @uses_form_widgets = true + key = widget_key(widget) + props = common_props(widget).merge('$widgetId': key, class: css_class(widget), id: key) + caption = translated_text(widget['Caption']) + "React.createElement($Label, #{js_props(props, expressions: { + caption: "TextProperty({ value: #{JSON.generate(caption)} })" + })})" + end + + def render_tab_control(widget) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + @uses_tab_container = true + key = widget_key(widget) + tabs = array(widget['TabPages']) + compiled = tabs.map do |tab| + { + name: tab['Name'].to_s, + caption: raw_js("TextProperty({ value: #{JSON.generate(translated_text(tab['Caption']))} })"), + isDelayed: false, refreshOnShow: tab['RefreshOnShow'] == true, + content: raw_js(children(array(tab['Widgets']))) + } + end + props = common_props(widget).merge( + '$widgetId': key, class: css_class(widget), widgetId: key, + defaultTab: default_tab_index(widget, tabs), tabs: compiled + ) + "React.createElement($TabContainer, #{js_literal(props)})" + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def default_tab_index(widget, tabs) + pointer = IO::BsonCodec.extract_id(widget['DefaultPagePointer']) + index = tabs.index { IO::BsonCodec.extract_id(_1['$ID']) == pointer } + index || 0 + end + + def render_static_image(widget) + uri = image_uri(widget['Image']) + return render_unsupported(widget) unless uri + + @uses_image = true + ImageBundleCompiler.render_static( + widget_key(widget), css_class(widget), uri, + width: widget['Width'], width_unit: widget['WidthUnit'], height: widget['Height'], + height_unit: widget['HeightUnit'], responsive: widget['Responsive'] == true + ) + end + + def image_uri(reference) + module_name, collection_name, image_name = reference.to_s.split('.', 3) + unit = @source.units_of('Images$ImageCollection').find do |candidate| + candidate.module_name == module_name && candidate.document['Name'] == collection_name + end + return unless unit + + image = array(unit.document.fetch('Images', nil)).find { _1['Name'] == image_name } + return unless image + + "img/#{[module_name, collection_name, image_name].join('$')}.#{image_format(image)}" + end + + def attribute_property(scope, entity, attribute, path: '', formatting: {}) config = { - scope:, path: '', entity:, attribute:, + scope:, path:, entity:, attribute:, onChange: { type: 'doNothing', argMap: {}, config: {}, disabledDuringExecution: true }, - isList: false, validation: nil, formatting: {} + isList: false, validation: nil, formatting: } "AttributeProperty(#{js_literal(config)})" end @@ -312,7 +791,7 @@ def data_action?(action) def render_data_action_button(widget, action) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength type = action['$Type'] == 'Forms$SaveChangesClientAction' ? 'saveChanges' : 'cancelChanges' - scope = @data_view_scopes.last + scope = scope_name(@data_view_scopes.last) key = widget_key(widget) action_config = { action: { @@ -498,7 +977,8 @@ def page_parameters # 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 + return '' unless @uses_data_grid || @uses_form_widgets || @uses_gallery || @uses_bound_text || + @uses_tab_container || @uses_image || @uses_conditional || @uses_dynamic_class imports = ['import { asPluginWidgets } from "mendix";'] widgets = [] @@ -519,10 +999,30 @@ def widget_imports 'import { TextProperty } from "mendix/TextProperty";', 'import { DataView } from "mendix/widgets/web/DataView";', 'import { TextBox } from "mendix/widgets/web/TextBox";', + 'import { CheckBox } from "mendix/widgets/web/CheckBox";', + 'import { Label } from "mendix/widgets/web/Label";', + 'import { Container } from "mendix/widgets/web/Container";', 'import { FormGroup } from "mendix/widgets/web/FormGroup";', 'import { ActionButton } from "mendix/widgets/web/ActionButton";' ]) - widgets.concat(%w[DataView TextBox FormGroup ActionButton]) + widgets.concat(%w[DataView TextBox CheckBox Label FormGroup ActionButton]) + if @uses_date_picker + imports << 'import { DatePicker } from "mendix/widgets/web/DatePicker";' + widgets << 'DatePicker' + end + widgets << 'Container' if @uses_container + end + if @uses_combo_box + imports.concat([ + 'import { AssociationProperty } from "mendix/AssociationProperty";', + 'import { DatabaseObjectListProperty } from "mendix/DatabaseObjectListProperty";', + 'import { MicroflowObjectListProperty } from "mendix/MicroflowObjectListProperty";', + 'import { ListAttributeProperty } from "mendix/ListAttributeProperty";', + 'import { SelectionProperty } from "mendix/SelectionProperty";', + 'import { ExpressionProperty } from "mendix/ExpressionProperty";', + 'import Combobox from "../widgets/com/mendix/widget/web/combobox/Combobox.mjs";' + ]) + widgets << 'Combobox' end imports << 'import { NanoflowObjectProperty } from "mendix/NanoflowObjectProperty";' \ if @uses_nanoflow_object @@ -538,16 +1038,63 @@ def widget_imports ]) widgets << 'Gallery' end + if @uses_tab_container + imports.concat([ + 'import { TextProperty } from "mendix/TextProperty";', + 'import { TabContainer } from "mendix/widgets/web/TabContainer";' + ]) + widgets << 'TabContainer' + end + if @uses_image + imports.concat([ + 'import { ExpressionProperty } from "mendix/ExpressionProperty";', + 'import { WebStaticImageProperty } from "mendix/WebStaticImageProperty";' + ]) + imports << if @uses_custom_image + 'import { Image } from "../widgets/com/mendix/widget/web/image/Image.mjs";' + else + 'import { Image } from "mendix/widgets/web/Image";' + end + widgets << 'Image' + end if @uses_bound_text imports.concat([ 'import { AttributeProperty } from "mendix/AttributeProperty";', - 'const MxrbFormattedText = ({ value, template, renderMode, class: className }) => ' \ - 'React.createElement(renderMode, { className }, ' \ - 'template.split("{1}").join(value?.displayValue ?? " "));', + 'const MxrbFormattedText = ({ template, renderMode, class: className, ...props }) => ' \ + 'React.createElement(renderMode, { className }, Object.keys(props)' \ + '.filter(key => /^value\\d+$/.test(key))' \ + '.sort((left, right) => Number(left.slice(5)) - Number(right.slice(5)))' \ + '.reduce((text, key, index) => text.split(`{${index + 1}}`)' \ + '.join(props[key]?.displayValue ?? " "), template));', 'MxrbFormattedText.displayName = "MxrbFormattedText";' ]) widgets << 'MxrbFormattedText' end + if @uses_conditional + imports.concat([ + 'import { AttributeProperty } from "mendix/AttributeProperty";', + 'const mxrbValue = property => property?.value ?? property?.displayValue;', + 'const MxrbConditional = ({ test, children, ...props }) => ' \ + 'test(props) ? children : null;', + 'MxrbConditional.displayName = "MxrbConditional";' + ]) + widgets << 'MxrbConditional' + end + if @uses_dynamic_class + imports.concat([ + 'import { AttributeProperty } from "mendix/AttributeProperty";', + 'const mxrbValue = property => property?.value ?? property?.displayValue;', + 'const MxrbDynamicClass = ({ resolveClass, children, ...props }) => {' \ + ' const dynamicClass = resolveClass(props);' \ + ' const currentClass = children.props.class || children.props.className || "";' \ + ' const mergedClass = [currentClass, dynamicClass].filter(Boolean).join(" ").trim();' \ + ' const classProp = typeof children.type === "string" ?' \ + ' { className: mergedClass } : { class: mergedClass };' \ + ' return React.cloneElement(children, classProp); };', + 'MxrbDynamicClass.displayName = "MxrbDynamicClass";' + ]) + widgets << 'MxrbDynamicClass' + end imports.uniq.push( "const { #{widgets.map { "$#{_1}" }.join(', ')} } = asPluginWidgets({ #{widgets.join(', ')} });" ).join("\n") @@ -555,7 +1102,11 @@ def widget_imports # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def page_title = translated_text(@unit.document['Title']) - def page_classes = layout&.document&.dig('Appearance', 'Class').to_s + + def page_classes + [layout&.document&.dig('Appearance', 'Class'), @unit.document.dig('Appearance', 'Class')] + .map(&:to_s).reject(&:empty?).join(' ') + end def render_content(content) JSON.pretty_generate(content).sub(/\A\{/, '{').sub(/\}\z/, '}') diff --git a/lib/mxrb/compiler/project_jar_builder.rb b/lib/mxrb/compiler/project_jar_builder.rb index e6d07f5..8d8316b 100644 --- a/lib/mxrb/compiler/project_jar_builder.rb +++ b/lib/mxrb/compiler/project_jar_builder.rb @@ -10,6 +10,8 @@ module Compiler # Compiles generated/custom Java sources and writes project.jar without MxBuild or Gradle. class ProjectJarBuilder + LEGACY_UNUSED_IMPORTS = ['com.mendix.webui.CustomJavaAction'].freeze + def initialize(mpr_path, deployment:, mendix_home:, java_home: nil) @mpr_path = File.expand_path(mpr_path) @project_root = File.dirname(@mpr_path) @@ -36,11 +38,34 @@ def compile_and_package(output, sources, classpath) Dir.mktmpdir('mxrb-java-', @deployment) do |root| classes = File.join(root, 'classes') FileUtils.mkdir_p(classes) - compile(sources, classpath, classes, root) unless sources.empty? + compile(stage_legacy_sources(sources, root), classpath, classes, root) unless sources.empty? ProjectJarArchive.new(@mpr_path, @project_root, @deployment).write(output, classes) end end + def stage_legacy_sources(sources, root) + sources.map do |source| + original = File.read(source) + rendered = strip_legacy_unused_imports(original) + next source if rendered == original + + relative = source.delete_prefix("#{@project_root}/") + staged = File.join(root, 'sources', relative) + FileUtils.mkdir_p(File.dirname(staged)) + File.write(staged, rendered) + staged + end + end + + def strip_legacy_unused_imports(source) + LEGACY_UNUSED_IMPORTS.reduce(source) do |rendered, class_name| + simple_name = class_name.split('.').last + next rendered unless rendered.scan(/\b#{Regexp.escape(simple_name)}\b/).one? + + rendered.gsub(/^import\s+#{Regexp.escape(class_name)};\r?\n/, '') + end + end + def result(output, sources, classpath) ProjectJarResult.new( path: output, sources: sources.length, classes: jar_classes(output), diff --git a/lib/mxrb/compiler/schemas/runtime-11.json b/lib/mxrb/compiler/schemas/runtime-11.json index b1790fb..0122b38 100644 --- a/lib/mxrb/compiler/schemas/runtime-11.json +++ b/lib/mxrb/compiler/schemas/runtime-11.json @@ -603,6 +603,80 @@ "$Type", "Sortings" ], + "Microflows$FindByExpression": [ + "$ID", + "$Type", + "Expression", + "ListName" + ], + "Microflows$ImportXmlAction": [ + "$ID", + "$Type", + "ResultHandling", + "IsValidationRequired", + "XmlDocumentVariableName", + "ErrorHandlingType" + ], + "Microflows$MicroflowParameterValue": [ + "$ID", + "$Type", + "Microflow", + "ValueExpression" + ], + "Microflows$DownloadFileAction": [ + "$ID", + "$Type", + "FileDocumentVariableName", + "ShowFileInBrowser", + "ErrorHandlingType" + ], + "Microflows$CustomRequestHandling": [ + "$ID", + "$Type", + "Template" + ], + "Microflows$ListOperationsAction": [ + "$ID", + "$Type", + "NewOperation", + "ResultVariableName", + "ErrorHandlingType" + ], + "Microflows$RetrieveSorting": [ + "$ID", + "$Type", + "AttributePath", + "SortOrder" + ], + "Microflows$RuleCall": [ + "$ID", + "$Type", + "ParameterMappings", + "Microflow" + ], + "Microflows$RuleCallParameterMapping": [ + "$ID", + "$Type", + "Parameter", + "Argument" + ], + "Microflows$RuleSplitCondition": [ + "$ID", + "$Type", + "RuleCall" + ], + "Microflows$Sort": [ + "$ID", + "$Type", + "Sortings", + "ListName" + ], + "Microflows$Subtract": [ + "$ID", + "$Type", + "SecondListOrObjectName", + "ListName" + ], "ScheduledEvents$DaySchedule": [ "$ID", "$Type", diff --git a/lib/mxrb/compiler/web_bundle_builder.rb b/lib/mxrb/compiler/web_bundle_builder.rb index e9db76c..d48fc66 100644 --- a/lib/mxrb/compiler/web_bundle_builder.rb +++ b/lib/mxrb/compiler/web_bundle_builder.rb @@ -25,7 +25,7 @@ def build prepare(web) run_rspack(web) - WebShellMaterializer.new(web, version: @source.version).materialize_dynamic_imports + WebShellMaterializer.new(web, version: @source.version).materialize result(web) end diff --git a/lib/mxrb/compiler/web_operation_compiler.rb b/lib/mxrb/compiler/web_operation_compiler.rb index 396c697..9eb5c10 100644 --- a/lib/mxrb/compiler/web_operation_compiler.rb +++ b/lib/mxrb/compiler/web_operation_compiler.rb @@ -7,7 +7,7 @@ module Mxrb module Compiler # Builds the Runtime operation catalog consumed by generated web data sources. - class WebOperationCompiler + class WebOperationCompiler # rubocop:disable Metrics/ClassLength include ModelValues def self.operation_id(page_name, widget_name) @@ -30,30 +30,72 @@ def write(path) 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) } + + custom_widgets(unit.document).filter_map { operation(page_name, _1, role_sets, unit.document) } + data_action_operations(page_name, unit.document, role_sets) end def data_action_operations(page_name, document, role_sets) # rubocop:disable Metrics/MethodLength - nested(document, 'Forms$ActionButton').filter_map do |widget| - type = case widget.dig('Action', '$Type') + action_widgets(document).filter_map do |widget, action| + type = case action['$Type'] when 'Forms$SaveChangesClientAction' then 'commit' when 'Forms$CancelChangesClientAction' then 'rollback' end - next unless type - - { - 'operationId' => self.class.operation_id(page_name, widget['Name']), - 'operationType' => type, 'parameters' => { 'Objects' => ['AnyObjectList'] }, - 'constants' => {}, 'allowedUserRoleSets' => role_sets - } + if type + next({ + 'operationId' => self.class.operation_id(page_name, widget['Name']), + 'operationType' => type, 'parameters' => { 'Objects' => ['AnyObjectList'] }, + 'constants' => {}, 'allowedUserRoleSets' => role_sets + }) + end + + microflow_action_operation(page_name, widget, action, role_sets) end end # rubocop:enable Metrics/MethodLength - def operation(page_name, widget, role_sets) + def action_widgets(document) + nested(document, 'Forms$ActionButton').map { [_1, _1['Action'] || {}] } + + nested(document, 'Forms$DivContainer').map { [_1, _1['OnClickAction'] || {}] } + end + + def microflow_action_operation(page_name, widget, action, role_sets) + return unless action['$Type'] == 'Forms$MicroflowAction' + + name = action.dig('MicroflowSettings', 'Microflow').to_s + return if name.empty? + + 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 microflow_parameters(qualified_name) + flow = @source.units_of('Microflows$Microflow').find do |unit| + "#{unit.module_name}.#{unit.document['Name']}" == qualified_name + end + return unless flow + + parameters = nested(flow.document, 'Microflows$MicroflowParameter') + return unless parameters.all? { supported_microflow_parameter?(_1) } + + parameters.to_h do |parameter| + [parameter['Name'].to_s, [parameter.dig('VariableType', 'Entity').to_s]] + end + end + + def supported_microflow_parameter?(parameter) + type = parameter['VariableType'] || {} + type['$Type'] == 'DataTypes$ObjectType' && !type['Entity'].to_s.empty? + end + + 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) if source.xpath? + return xpath_operation(page_name, widget, role_sets, source, page_document) if source.xpath? return if source.nanoflow? { @@ -63,12 +105,20 @@ def operation(page_name, widget, role_sets) } end - def xpath_operation(page_name, widget, role_sets, source) - entity = source.entity + 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) + return unless page_parameters + + retrieve_operation(page_name, widget, role_sets, source, page_parameters) + end + + def retrieve_operation(page_name, widget, role_sets, source, page_parameters) { 'operationId' => self.class.operation_id(page_name, widget['Name']), - 'operationType' => 'retrieve', 'parameters' => {}, - 'constants' => constants(page_name, widget, source.xpath_constraint, entity), + 'operationType' => 'retrieve', + 'parameters' => page_parameters.transform_values { [_1] }, + 'constants' => constants(page_name, widget, source.xpath_constraint, source.entity), 'allowedUserRoleSets' => role_sets } end @@ -87,18 +137,62 @@ def constants(page_name, widget, constraint, entity) { 'PageName' => page_name, 'WidgetName' => "#{page_name}.#{widget['Name']}", 'UsedAssociations' => [], 'UsedAttributes' => used_attributes(widget, entity), - 'XPath' => "//#{entity}#{constraint.empty? ? '' : "[#{constraint}]"}" + 'XPath' => xpath(entity, constraint) } end - def custom_widgets(value) = nested(value, 'CustomWidgets$CustomWidget') + def xpath(entity, constraint) + predicate = constraint.to_s.strip + return "//#{entity}" if predicate.empty? - def attribute_names(value) - nested(value, 'DomainModels$AttributeRef').filter_map { _1['Attribute'] }.uniq.sort + "//#{entity}#{predicate.start_with?('[') ? predicate : "[#{predicate}]"}" end + def custom_widgets(value) = nested(value, 'CustomWidgets$CustomWidget') + def used_attributes(value, entity) - attribute_names(value).map { "#{entity}/#{_1}" } + attribute_names(value, entity) + end + + def attribute_names(value, entity, result = []) + collect_attribute_names(value, entity, result).uniq.sort + end + + def collect_attribute_names(value, entity, result) + case value + when Hash then collect_hash_attributes(value, entity, result) + when Array then value.each { collect_attribute_names(_1, entity, result) } + when String then collect_string_attributes(value, entity, result) + end + result + end + + def collect_hash_attributes(value, entity, result) + path = attribute_inventory_path(value, entity) if value.key?('Attribute') + result << path if path + value.each_value { collect_attribute_names(_1, entity, result) } + end + + def attribute_inventory_path(reference, entity) + attribute = reference['Attribute'].to_s + steps = array(reference.dig('EntityRef', 'Steps')) + return "#{entity}/#{attribute}" if steps.empty? && attribute.start_with?("#{entity}.") + return unless qualified_reference_steps?(steps) && attribute.include?('.') + + path = steps.flat_map { [_1['Association'], _1['DestinationEntity']] } + ([entity] + path + [attribute]).join('/') + end + + def qualified_reference_steps?(steps) + steps.any? && steps.all? do |step| + !step['Association'].to_s.empty? && !step['DestinationEntity'].to_s.empty? + end + end + + def collect_string_attributes(value, entity, result) + value.scan(%r{\$currentObject/([A-Za-z_]\w*)}) do |match| + result << "#{entity}/#{entity}.#{match.first}" + end end def nested(value, type, result = []) @@ -110,6 +204,6 @@ def nested(value, type, result = []) end result end - end + end # rubocop:enable Metrics/ClassLength end end diff --git a/lib/mxrb/compiler/web_shell_materializer.rb b/lib/mxrb/compiler/web_shell_materializer.rb index 956a707..5afd6ad 100644 --- a/lib/mxrb/compiler/web_shell_materializer.rb +++ b/lib/mxrb/compiler/web_shell_materializer.rb @@ -28,6 +28,9 @@ def materialize changed += materialize_dynamic_imports write_missing(File.join(@web, 'js', 'login_i18n.js'), login_i18n) write_missing(File.join(@web, 'lib', 'bootstrap', 'css', 'bootstrap.min.css'), login_styles) + # React Client requests this conventional aggregate even when every widget + # ships its styles through JavaScript or the compiled theme. + write_missing(File.join(@web, 'dist', 'widgets.css'), '') changed end diff --git a/lib/mxrb/runtime/database_workspace.rb b/lib/mxrb/runtime/database_workspace.rb index b2d0f3d..4cb342b 100644 --- a/lib/mxrb/runtime/database_workspace.rb +++ b/lib/mxrb/runtime/database_workspace.rb @@ -292,7 +292,10 @@ def build_runtime! FileUtils.rm_f(package) build_native_package(package) FileUtils.mkdir_p(runtime_dir) - run!('unzip', '-q', package, '-d', runtime_dir) + # The package is always authoritative for this generated directory. + # Some unzip builds can still observe files created by a just-removed + # bind-mounted Runtime and prompt on stdin unless overwrite is explicit. + run!('unzip', '-oq', package, '-d', runtime_dir) File.write(runtime_marker, JSON.generate('fingerprint' => model_fingerprint)) end diff --git a/lib/mxrb/runtime/native.rb b/lib/mxrb/runtime/native.rb index 3969604..d2afb20 100644 --- a/lib/mxrb/runtime/native.rb +++ b/lib/mxrb/runtime/native.rb @@ -48,8 +48,17 @@ def restore(snapshot) # Deliberately small Mendix-expression evaluator. Unsupported syntax is # rejected rather than guessed, preserving deterministic test semantics. class Expression - def evaluate(source, variables) - text = unwrap(source).strip + COMPARISONS = { + '=' => ->(left, right) { left == right }, + '!=' => ->(left, right) { left != right }, + '>' => ->(left, right) { left > right }, + '<' => ->(left, right) { left < right }, + '>=' => ->(left, right) { left >= right }, + '<=' => ->(left, right) { left <= right } + }.freeze + + def evaluate(source, variables, node: nil) + text = strip_outer_parentheses(unwrap(source).strip) return nil if text.empty? || text == 'empty' return true if text.casecmp?('true') return false if text.casecmp?('false') @@ -57,9 +66,10 @@ def evaluate(source, variables) return Integer(text) if text.match?(/\A-?\d+\z/) return Float(text) if text.match?(/\A-?\d+\.\d+\z/) return variable(text, variables) if text.match?(%r{\A\$[A-Za-z_]\w*(?:/[A-Za-z_][\w.]*)?\z}) + return node_member(text, node) if node && bare_reference?(text) - logical(text, variables) { |part| evaluate(part, variables) } - rescue ArgumentError + logical(text, variables) { |part| evaluate(part, variables, node:) } + rescue ArgumentError, TypeError raise NativeRuntimeError, "unsupported Mendix expression: #{source.inspect}" end @@ -73,6 +83,27 @@ def quoted?(text) text.length >= 2 && text.start_with?("'") && text.end_with?("'") end + def strip_outer_parentheses(text) + text = text[1...-1].strip while text.start_with?('(') && matching_parenthesis(text, 0) == text.length - 1 + text + end + + def matching_parenthesis(text, opening) + depth = 0 + quoted = false + text.each_char.with_index do |character, index| + next if index < opening + + quoted = !quoted if character == "'" + next if quoted + + depth += 1 if character == '(' + depth -= 1 if character == ')' + return index if depth.zero? + end + nil + end + def variable(text, variables) name, member = text.delete_prefix('$').split('/', 2) value = variables.fetch(name) do @@ -85,6 +116,16 @@ def variable(text, variables) value.members[member.split('.').last] end + # An attribute reference inside an XPath predicate, e.g. `Name` or + # `Clinic.Animal/Name`, resolved against the current candidate object. + def bare_reference?(text) + text.match?(%r{\A[A-Za-z_]\w*(?:/[A-Za-z_][\w.]*)?\z}) + end + + def node_member(text, node) + node.members[text.split('/').last.split('.').last] + end + def logical(text, variables, &block) split = split_operator(text, /\s+or\s+/i) return split.any? { yield(_1) } if split.size > 1 @@ -101,12 +142,11 @@ def comparison(text, _variables) left = yield(match[1]) right = yield(match[3]) - { '=' => left == right, '!=' => left != right, '>' => left > right, - '<' => left < right, '>=' => left >= right, '<=' => left <= right }.fetch(match[2]) + COMPARISONS.fetch(match[2]).call(left, right) end def split_operator(text, operator) - text.sub(/\A\((.*)\)\z/, '\\1').split(operator) + text.split(operator) end end @@ -135,6 +175,12 @@ def call(name, arguments = {}) raise end + # Counts stored objects of an entity, optionally narrowed by an XPath + # constraint. Used by the functional Executor's count expectations. + def count(entity, xpath = nil) + filter_by_xpath(store.retrieve(entity.to_s), xpath.to_s, {}).size + end + private def execute(flow, variables) @@ -215,19 +261,72 @@ def action_retrieve(action, variables) raise NativeRuntimeError, "unsupported retrieve source #{source['$Type']}" end - xpath = source['XpathConstraint'].to_s - raise NativeRuntimeError, "native XPath retrieve is not implemented: #{xpath}" unless xpath.empty? - - sortings = items(source.dig('NewSortings', 'Sortings')) - raise NativeRuntimeError, 'native retrieve sorting is not implemented' unless sortings.empty? - - values = store.retrieve(source['Entity'].to_s) + values = filter_by_xpath(store.retrieve(source['Entity'].to_s), source['XpathConstraint'].to_s, variables) + values = sort_values(values, items(source.dig('NewSortings', 'Sortings'))) range = source['Range'] || {} limit = @expression.evaluate(range['LimitExpression'], variables) values = values.first(limit) if limit.is_a?(Integer) && limit.positive? variables[action['ResultVariableName'].to_s] = range['SingleObject'] == true ? values.first : values end + # Narrows a value set by a Mendix XPath constraint. Only attribute + # predicates (comparisons, and/or, boolean shorthand) are understood; + # anything else is rejected by the expression evaluator rather than + # silently ignored. + def filter_by_xpath(values, xpath, variables) + predicate = xpath_predicate(xpath) + return values if predicate.empty? + + values.select { @expression.evaluate(predicate, variables, node: _1) } + end + + def xpath_predicate(xpath) + text = xpath.strip + return '' if text.empty? + + groups = text.scan(/\[([^\[\]]*)\]/).flatten.map(&:strip).reject(&:empty?) + unless groups.any? && text.gsub(/\[[^\[\]]*\]/, '').strip.empty? + raise NativeRuntimeError, "unsupported native XPath constraint: #{xpath.inspect}" + end + + groups.map { "(#{_1})" }.join(' and ') + end + + def sort_values(values, sortings) + return values if sortings.empty? + + keys = sortings.map { [sort_attribute(_1), descending?(_1)] } + values.sort { |left, right| compare_by_keys(left, right, keys) } + end + + def compare_by_keys(left, right, keys) + keys.each do |attribute, descending| + comparison = compare_members(left.members[attribute], right.members[attribute]) + comparison = -comparison if descending + return comparison unless comparison.zero? + end + 0 + end + + def compare_members(left, right) + return 0 if left.nil? && right.nil? + return 1 if left.nil? + return -1 if right.nil? + + (left <=> right) || raise(NativeRuntimeError, "cannot sort #{left.inspect} and #{right.inspect}") + end + + def sort_attribute(sorting) + path = (sorting['AttributePath'] || sorting.dig('AttributeRef', 'Attribute')).to_s + raise NativeRuntimeError, 'native retrieve sorting requires an attribute' if path.empty? + + path.split(%r{[./]}).last + end + + def descending?(sorting) + sorting['SortOrder'].to_s.casecmp?('Descending') + end + def action_aggregate(action, variables) values = Array(variables.fetch(action['AggregateVariableName'].to_s)) attribute = action['Attribute'].to_s.split('.').last.to_s @@ -350,11 +449,7 @@ def expectations(interpreter, test, actual) failures << "return #{actual.inspect}, expected #{expected.inspect}" unless actual == expected end test.counts.each do |expectation| - if expectation.xpath && !expectation.xpath.empty? - raise NativeRuntimeError, "native XPath count is not implemented: #{expectation.xpath}" - end - - actual_count = interpreter.store.count(expectation.entity) + actual_count = interpreter.count(expectation.entity, expectation.xpath) unless actual_count == expectation.equals failures << "#{expectation.entity} count #{actual_count}, expected #{expectation.equals}" end diff --git a/spec/client_model_materializer_spec.rb b/spec/client_model_materializer_spec.rb index 9150032..50401e3 100644 --- a/spec/client_model_materializer_spec.rb +++ b/spec/client_model_materializer_spec.rb @@ -107,9 +107,16 @@ def counterpart(document) end def fields_for(document) - if document['$Type'] == 'Navigation$NavigationDocument' + case document['$Type'] + when 'Navigation$NavigationDocument' return %w[$ID $Type Profiles Grids CustomWidgetModules PluginWidgets] + when 'Texts$Text' + return %w[$ID $Type] + when 'Microflows$TextTemplate' + return %w[$ID $Type Parameters Text] + when 'Microflows$TemplateParameter' + return %w[$ID $Type Expression] end %w[$ID $Type HomePage HomeItems AppTitle LoginPageSettings ProgressiveWebAppSettings @@ -131,6 +138,25 @@ def fields_for(document) 'IsOffline' => true, 'HomePage' => nil, 'LoginPageSettings' => nil, 'OfflineEntityConfigsRuntime' => [], 'AppIcon' => 'offline.svg' ) + + profile = Mxrb::IO::BsonCodec.parse_array(unit.document['Profiles'])[:items].first + title = { + '$ID' => '44444444-4444-4444-8444-444444444444', '$Type' => 'Microflows$TextTemplate', + 'Parameters' => [], 'Text' => { + '$ID' => '55555555-5555-4555-8555-555555555555', '$Type' => 'Texts$Text', 'Items' => [] + } + } + settings = { + '$ID' => '66666666-6666-4666-8666-666666666666', '$Type' => 'Forms$FormSettings', + 'Form' => '', 'ParameterMappings' => [], 'TitleOverride' => title + } + rich_document = unit.document.merge('Profiles' => [profile.merge('LoginPageSettings' => settings)]) + compiled_profile = compiler.compile(unit.with(document: rich_document)).fetch('Profiles').first + expect(compiled_profile.fetch('AppTitle').keys).to eq(%w[$ID $Type]) + expect(compiled_profile.dig('LoginPageSettings', 'TitleOverride')).to include( + 'Parameters' => [], 'Text' => include('$Type' => 'Texts$Text') + ) + missing = Mxrb::Compiler::NavigationDocumentCompiler.new(schema.new({ '$ID' => 'missing' })) expect(missing.compile(unit)).to include( 'Grids' => [], 'CustomWidgetModules' => [], 'PluginWidgets' => [] diff --git a/spec/combo_box_bundle_compiler_spec.rb b/spec/combo_box_bundle_compiler_spec.rb new file mode 100644 index 0000000..6bad54e --- /dev/null +++ b/spec/combo_box_bundle_compiler_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# rubocop:disable Metrics/BlockLength +RSpec.describe Mxrb::Compiler::ComboBoxBundleCompiler do + def property_type(id, key, type) + { '$ID' => id, 'PropertyKey' => key, 'ValueType' => { 'Type' => type } } + end + + def property(id, primitive: '', **values) + { 'TypePointer' => id, 'Value' => { + 'PrimitiveValue' => primitive, 'Widgets' => [2], 'Selection' => 'None' + }.merge(values.transform_keys(&:to_s)) } + end + + def schema # rubocop:disable Metrics/MethodLength + types = [ + property_type('source', 'source', 'Enumeration'), + property_type('options-type', 'optionsSourceType', 'Enumeration'), + property_type('caption-type', 'optionsSourceAssociationCaptionType', 'Enumeration'), + property_type('association-caption', 'optionsSourceAssociationCaptionAttribute', 'Attribute'), + property_type('association', 'attributeAssociation', 'Association'), + property_type('association-source', 'optionsSourceAssociationDataSource', 'DataSource'), + property_type('database-target', 'databaseAttributeString', 'Attribute'), + property_type('database-caption', 'optionsSourceDatabaseCaptionAttribute', 'Attribute'), + property_type('database-value', 'optionsSourceDatabaseValueAttribute', 'Attribute'), + property_type('database-source', 'optionsSourceDatabaseDataSource', 'DataSource'), + property_type('database-selection', 'optionsSourceDatabaseItemSelection', 'Selection'), + property_type('clearable', 'clearable', 'Boolean'), + property_type('empty', 'emptyOptionText', 'TextTemplate'), + property_type('interval', 'filterInputDebounceInterval', 'Integer'), + property_type('footer', 'menuFooterContent', 'Widgets') + ] + { + '$ID' => 'combo-type', 'WidgetId' => described_class::WIDGET_ID, + 'ObjectType' => { '$ID' => 'combo-object', 'PropertyTypes' => [2, *types] } + } + end + + def page + Struct.new(:module_name, :document, keyword_init: true).new(module_name: 'Demo', document: { + '$Type' => 'Forms$Page', 'Name' => 'Edit', 'Parameters' => [2, { + 'Name' => 'Order', 'ParameterType' => { 'Entity' => 'Demo.Order' } + }] + }) + end + + def source + instance_double(Mxrb::Compiler::SourceModel, documents: [schema]).tap do |model| + allow(model).to receive(:units_of) { |type| type == 'Forms$Page' ? [page] : [] } + end + end + + def widget(name, properties) + { + '$Type' => 'CustomWidgets$CustomWidget', 'Name' => name, + 'LabelTemplate' => { 'Template' => { 'Items' => [ + 3, { 'LanguageCode' => 'en_US', 'Text' => name.capitalize } + ] } }, + 'Appearance' => { 'Class' => 'selector' }, + 'Object' => { 'TypePointer' => 'combo-object', 'Properties' => [2, *properties] } + } + end + + def xpath(entity) + { '$Type' => 'CustomWidgets$CustomWidgetXPathSource', + 'EntityRef' => { 'Entity' => entity }, 'XPathConstraint' => '' } + end + + it 'compiles a context association Combo box with its caption and selectable objects' do + properties = [ + property('source', primitive: 'context'), property('options-type', primitive: 'association'), + property('caption-type', primitive: 'attribute'), + property('association-caption', AttributeRef: { 'Attribute' => 'Demo.Location.Name' }), + property('association', EntityRef: { 'Steps' => [2, { + 'Association' => 'Demo.Order_Location', 'DestinationEntity' => 'Demo.Location' + }] }), + property('association-source', DataSource: xpath('Demo.Location')), + property('clearable', primitive: 'true'), property('empty', TextTemplate: nil) + ] + compiler = described_class.new( + source, 'Demo.Edit', widget('location', properties), scope: 'p.Demo.Edit.editor', entity: 'Demo.Order' + ) + + expect(compiler).to be_supported + expect(compiler.render).to include( + 'React.createElement($Combobox', 'AssociationProperty', 'DatabaseObjectListProperty', + 'Demo.Order_Location', 'Demo.Location', 'ListAttributeProperty', 'Location', + 'React.createElement($FormGroup', 'mx-name-location selector' + ) + end + + it 'compiles a database Combo box backed by an attribute reached through an association' do + target = { + 'Attribute' => 'Demo.Course.Title', 'EntityRef' => { 'Steps' => [2, { + 'Association' => 'Demo.Order_Course', 'DestinationEntity' => 'Demo.Course' + }] } + } + properties = [ + property('source', primitive: 'database'), property('options-type', primitive: 'association'), + property('database-target', AttributeRef: target, + SourceVariable: { 'PageParameter' => 'Order' }), + property('database-caption', AttributeRef: { 'Attribute' => 'Demo.Course.Title' }), + property('database-value', AttributeRef: { 'Attribute' => 'Demo.Course.Title' }), + property('database-source', DataSource: xpath('Demo.Course')), + property('database-selection', Selection: 'Single') + ] + compiler = described_class.new( + source, 'Demo.Edit', widget('course', properties), scope: nil, entity: nil + ) + + expect(compiler).to be_supported + expect(compiler.render).to include( + '"source": "context"', 'AssociationProperty', '"scope": "$Order"', + '"attribute": "Demo.Order_Course"', 'DatabaseObjectListProperty', 'Course' + ) + end + + it 'compiles a direct database attribute and exercises optional primitive values' do + target = { 'Attribute' => 'Demo.Order.Code', 'EntityRef' => { 'Steps' => [2] } } + properties = [ + property('source', primitive: 'database'), property('options-type', primitive: 'database'), + property('database-target', AttributeRef: target, + SourceVariable: { 'PageParameter' => 'Order' }), + property('database-caption', AttributeRef: { 'Attribute' => 'Demo.Order.Code' }), + property('database-value', AttributeRef: { 'Attribute' => 'Demo.Order.Code' }), + property('database-source', DataSource: xpath('Demo.Order')), + property('interval', primitive: '250'), property('footer', Widgets: [2]) + ] + compiler = described_class.new(source, 'Demo.Edit', widget('code', properties), scope: nil, entity: nil) + + expect(compiler).to be_supported + expect(compiler.render).to include( + 'AttributeProperty', '"path": ""', 'SelectionProperty', + '"selectionType": "Single"', '"filterInputDebounceInterval": 250', + '"menuFooterContent": []' + ) + expect(compiler.send(:compile_primitive, 'Widgets', 'Widgets' => [2, {}])).to be_nil + expect(compiler.send(:translated_text, 'Template' => { + 'Items' => [3, { 'LanguageCode' => 'pt_BR', 'Text' => 'Curso' }] + })).to eq('Curso') + expect(compiler.send(:translated_text, nil)).to eq('') + end + + it 'renders a microflow list source and rejects incomplete widget metadata' do + properties = [ + property('source', primitive: 'context'), property('options-type', primitive: 'association'), + property('caption-type', primitive: 'attribute'), + property('association-caption', AttributeRef: { 'Attribute' => 'Demo.Location.Name' }), + property('association', EntityRef: { 'Steps' => [2, { + 'Association' => 'Demo.Order_Location', 'DestinationEntity' => 'Demo.Location' + }] }), property('association-source', DataSource: xpath('Demo.Location')) + ] + compiler = described_class.new( + source, 'Demo.Edit', widget('location', properties), scope: 'p.Demo.Edit.editor', entity: 'Demo.Order' + ) + compiler.instance_variable_set( + :@data_source, + instance_double(Mxrb::Compiler::WebListDataSource, xpath?: false, entity: 'Demo.Location') + ) + expect(compiler.send(:list_property)).to include('MicroflowObjectListProperty') + expect(compiler.send(:page_parameter_entity, 'Missing')).to eq('') + expect(compiler.send(:page_parameter_entity, 'Order')).to eq('Demo.Order') + + blank_source = instance_double(Mxrb::Compiler::SourceModel, documents: []) + allow(blank_source).to receive(:units_of).and_return([]) + incomplete = described_class.new( + blank_source, 'Demo.Edit', widget('missing', []), scope: nil, entity: nil + ) + expect(incomplete).not_to be_supported + expect(incomplete.send(:property_values, 'Properties' => [2, { 'TypePointer' => 'missing' }])).to eq({}) + expect { incomplete.send(:split_attribute, 'invalid') } + .to raise_error(Mxrb::CompilationError, /invalid Combo box attribute/) + + expect(incomplete.send(:selection_property)).to include('Single') + expect(incomplete.send(:resolved_scope)).to be_nil + expect(incomplete.send(:resolved_entity)).to eq('') + expect(incomplete.send(:target_attribute)).to be_nil + expect(incomplete.send(:database_caption_attribute)).to be_nil + expect(incomplete.send(:database_value_attribute)).to be_nil + expect(incomplete.send(:association_caption_attribute)).to be_nil + expect(incomplete.send(:entity_steps, nil)).to eq([]) + expect(incomplete.send(:primitive, 'missing')).to be_nil + expect(incomplete.send(:property_values, nil)).to eq({}) + + stepped = { 'Attribute' => 'Demo.Course.Title', 'EntityRef' => { 'Steps' => [2, { + 'Association' => 'Demo.Order_Course', 'DestinationEntity' => 'Demo.Course' + }] } } + expect(compiler.send(:attribute_property, stepped)) + .to include('Demo.Order_Course/Demo.Course') + compiler.instance_variable_get(:@values)['optionsSourceDatabaseItemSelection'] = [ + 'Selection', { 'Selection' => 'Multiple' } + ] + expect(compiler.send(:selection_property)).to include('Multiple') + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/compiler_edge_coverage_spec.rb b/spec/compiler_edge_coverage_spec.rb index 2c02a3f..3f6ff9a 100644 --- a/spec/compiler_edge_coverage_spec.rb +++ b/spec/compiler_edge_coverage_spec.rb @@ -109,7 +109,7 @@ def counterpart(_source) = nil 'AssociationId' => 'App.Parent_Child', 'StartVariableName' => 'Child' ) expect(defaults).to include('Argument' => '42', 'Location' => 'Content', 'ParameterMappings' => []) - expect(reverse['Type']).to eq('App.Parent') + expect(reverse['Type']).to eq('[App.Parent]') end it 'derives audited aggregate result types and rejects ambiguous aggregates' do diff --git a/spec/compiler_support_spec.rb b/spec/compiler_support_spec.rb index e43a2d6..0dee8b9 100644 --- a/spec/compiler_support_spec.rb +++ b/spec/compiler_support_spec.rb @@ -150,7 +150,7 @@ def documents(_type = nil) = [] node = Mxrb::Compiler::MicroflowNodeCompiler.allocate database = instance_double(Mxrb::Compiler::DatabaseConnectorActionCompiler) - allow(database).to receive(:compile).and_return('Lowered' => true) + allow(database).to receive_messages(compile: { 'Lowered' => true }, unconfigured_write?: false) node.instance_variable_set(:@database_connector, database) expect(node.send(:compile_hash, '$Type' => 'DatabaseConnector$ExecuteDatabaseQueryAction')) .to eq('Lowered' => true) @@ -184,6 +184,30 @@ def documents(_type = nil) = [] modern = Mxrb::Compiler::RuntimeModelSchema.new(package, version: '11.12.1') expect(modern.fields_for('$Type' => 'DatabaseConnector$ExecuteDatabaseQueryAction')) .to include('Query', 'ParameterMappings', 'ConnectionParameterMappings') + expect(modern.fields_for('$Type' => 'Microflows$FindByExpression')) + .to eq(%w[$ID $Type Expression ListName]) + expect(modern.fields_for('$Type' => 'Microflows$ImportXmlAction')) + .to eq(%w[$ID $Type ResultHandling IsValidationRequired XmlDocumentVariableName ErrorHandlingType]) + expect(modern.fields_for('$Type' => 'Microflows$MicroflowParameterValue')) + .to eq(%w[$ID $Type Microflow ValueExpression]) + parameter_value = { '$ID' => id(20), '$Type' => 'Microflows$MicroflowParameterValue', + 'Microflow' => 'App.Handle' } + expect(Mxrb::Compiler::MicroflowNodeCompiler.new(modern).send(:compile_hash, parameter_value)) + .to include('Microflow' => 'App.Handle', 'ValueExpression' => "'App.Handle'") + legacy_microflow_fields = { + 'Microflows$DownloadFileAction' => %w[$ID $Type FileDocumentVariableName ShowFileInBrowser ErrorHandlingType], + 'Microflows$CustomRequestHandling' => %w[$ID $Type Template], + 'Microflows$ListOperationsAction' => %w[$ID $Type NewOperation ResultVariableName ErrorHandlingType], + 'Microflows$RetrieveSorting' => %w[$ID $Type AttributePath SortOrder], + 'Microflows$RuleCall' => %w[$ID $Type ParameterMappings Microflow], + 'Microflows$RuleCallParameterMapping' => %w[$ID $Type Parameter Argument], + 'Microflows$RuleSplitCondition' => %w[$ID $Type RuleCall], + 'Microflows$Sort' => %w[$ID $Type Sortings ListName], + 'Microflows$Subtract' => %w[$ID $Type SecondListOrObjectName ListName] + } + legacy_microflow_fields.each do |type, fields| + expect(modern.fields_for('$Type' => type)).to eq(fields) + end end it 'reads all source documents and closes safely when opening fails' do diff --git a/spec/database_connector_action_compiler_spec.rb b/spec/database_connector_action_compiler_spec.rb index 2bc7c0c..743f384 100644 --- a/spec/database_connector_action_compiler_spec.rb +++ b/spec/database_connector_action_compiler_spec.rb @@ -136,5 +136,32 @@ expect { compiler.send(:query_builder_select, query) } .to raise_error(Mxrb::CompilationError, /no executable table/) end + + it 'identifies unconfigured writes that can safely fall through to the local commit' do + query['Query'] = 'INSERT INTO users (name) VALUES ({Name})' + constant = Mxrb::Compiler::SourceModel::Unit.new( + id: 'constant', container_id: 'module', containment: 'Documents', module_name: 'Demo', + document: { '$Type' => 'Constants$Constant', 'Name' => 'Source', 'DefaultValue' => '' } + ) + model = instance_double(Mxrb::Compiler::SourceModel, units: [unit, constant]) + fallback = described_class.new(model) + + expect(fallback.unconfigured_write?(nil)).to be(false) + expect(fallback.unconfigured_write?('$Type' => 'Microflows$CreateObjectAction')).to be(false) + + expect(fallback.unconfigured_write?(action.merge( + '$Type' => 'DatabaseConnector$ExecuteDatabaseQueryAction' + ))).to be(true) + action['ConnectionParameterMappings'] = [2, { + 'ParameterName' => 'DBSource', 'Value' => '$ConfiguredSource' + }] + expect(fallback.unconfigured_write?(action.merge( + '$Type' => 'DatabaseConnector$ExecuteDatabaseQueryAction' + ))).to be(false) + query['Query'] = 'SELECT name FROM users' + expect(fallback.unconfigured_write?(action.merge( + '$Type' => 'DatabaseConnector$ExecuteDatabaseQueryAction' + ))).to be(false) + end end # rubocop:enable Metrics/BlockLength diff --git a/spec/database_workspace_spec.rb b/spec/database_workspace_spec.rb index 2686b44..5bf1e91 100644 --- a/spec/database_workspace_spec.rb +++ b/spec/database_workspace_spec.rb @@ -201,7 +201,7 @@ def workspace(path, state, &runner) subject = workspace(path, state, &runner) subject.up - expect(commands).to include(include('unzip', '-q')) + expect(commands).to include(include('unzip', '-oq')) subject.sync if name == 'missing' end end diff --git a/spec/domain_model_materializer_spec.rb b/spec/domain_model_materializer_spec.rb index dcbec31..c8a3136 100644 --- a/spec/domain_model_materializer_spec.rb +++ b/spec/domain_model_materializer_spec.rb @@ -60,7 +60,10 @@ def define_project ) expect(attributes.dig('BirthDate', 'Type', 'LocalizeDate')).to be(true) expect(entity['ValidationRules'].first['Message']).not_to have_key('Items') - expect(entity['Indexes'].first['Attributes']).to all(include('AttributePointer')) + indexed = entity['Indexes'].first['Attributes'] + expect(indexed).to all(include('AttributePointer', 'AssociationPointer')) + expect(Mxrb::IO::BsonCodec.extract_id(indexed.first['AssociationPointer'])) + .to eq('00000000-0000-0000-0000-000000000000') expect(entity['AccessRules'].first).to include( 'AllowedUserRoles' => ['User'], 'AllowCreate' => true, 'AllowDelete' => false ) diff --git a/spec/gallery_bundle_compiler_spec.rb b/spec/gallery_bundle_compiler_spec.rb index 2e61289..c58322b 100644 --- a/spec/gallery_bundle_compiler_spec.rb +++ b/spec/gallery_bundle_compiler_spec.rb @@ -68,8 +68,15 @@ def source(*units, widget_schema: schema) it 'compiles XPath data, selection and templated content through the official Gallery' do widget = gallery('$Type' => 'CustomWidgets$CustomWidgetXPathSource', - 'EntityRef' => { 'Entity' => 'Demo.Item' }) - compiler = described_class.new(source, 'Demo.Home', widget) + 'EntityRef' => { 'Entity' => 'Demo.Item' }, + 'XPathConstraint' => '[Demo.Item_Parent = $Parent]') + page = unit(module_name: 'Demo', document: { + '$Type' => 'Forms$Page', 'Name' => 'Home', 'Parameters' => [2, { + '$Type' => 'Forms$PageParameter', 'Name' => 'Parent', + 'ParameterType' => { '$Type' => 'DataTypes$ObjectType', 'Entity' => 'Demo.Parent' } + }] + }) + compiler = described_class.new(source(page), 'Demo.Home', widget) expect(compiler).to be_supported expect(compiler.entity_name).to eq('Demo.Item') @@ -77,7 +84,9 @@ def source(*units, widget_schema: schema) expect(compiler.render('[card]')).to include( 'React.createElement($Gallery', 'DatabaseObjectListProperty', 'TemplatedWidgetProperty({ children: () => [card]', 'SelectionProperty', - 'ExpressionProperty', 'mx-name-gallery1 cards' + 'ExpressionProperty', 'mx-name-gallery1 cards', + '"arguments": { "Parent": ["$Parent", undefined, false] }', + '"fetchOnlyWithAllParams": true' ) expect(compiler.send(:primitive, 'Boolean', 'PrimitiveValue' => 'true')).to be(true) expect(compiler.send(:primitive, 'Boolean', 'PrimitiveValue' => 'false')).to be(false) @@ -146,5 +155,22 @@ def source(*units, widget_schema: schema) 'Template' => { 'Items' => [3, { 'LanguageCode' => 'pt_BR', 'Text' => 'Mais' }] })) .to eq('Mais') end + + it 'covers unconstrained and unresolved XPath arguments' do + unconstrained = gallery('$Type' => 'CustomWidgets$CustomWidgetXPathSource', + 'EntityRef' => { 'Entity' => 'Demo.Item' }, 'XPathConstraint' => '') + compiler = described_class.new(source, 'Demo.Home', unconstrained) + expect(compiler.render('[]')).to include('DatabaseObjectListProperty') + expect(compiler.render('[]')).not_to include('fetchOnlyWithAllParams') + + missing = gallery('$Type' => 'CustomWidgets$CustomWidgetXPathSource', + 'EntityRef' => { 'Entity' => 'Demo.Item' }, + 'XPathConstraint' => '[Parent = $Missing]') + page = unit(module_name: 'Demo', document: { + '$Type' => 'Forms$Page', 'Name' => 'Home', 'Parameters' => [2] + }) + expect(described_class.new(source(page), 'Demo.Home', missing)).not_to be_supported + expect(described_class.new(source, 'Demo.Missing', missing)).not_to be_supported + end end # rubocop:enable Metrics/BlockLength, Metrics/MethodLength diff --git a/spec/microflow_compilers_spec.rb b/spec/microflow_compilers_spec.rb index 6c1a1a9..c7dedc0 100644 --- a/spec/microflow_compilers_spec.rb +++ b/spec/microflow_compilers_spec.rb @@ -103,6 +103,55 @@ def node(type, number, **fields) .to raise_error(Mxrb::CompilationError, /cannot derive Runtime retrieve type/) end + it 'replaces an unconfigured external write with a Runtime no-op action' do + fields = { + 'Microflows$LogMessageAction' => %w[ + $ID $Type MessageTemplate ErrorHandlingType Level Node IncludeLatestStackTrace + ], + 'Microflows$StringTemplate' => %w[$ID $Type Parameters Text] + } + compiler = Mxrb::Compiler::MicroflowNodeCompiler.new(schema(fields:, existing: {})) + connector = instance_double(Mxrb::Compiler::DatabaseConnectorActionCompiler) + allow(connector).to receive(:unconfigured_write?).and_return(true) + compiler.instance_variable_set(:@database_connector, connector) + action = node('DatabaseConnector$ExecuteDatabaseQueryAction', 29, + ErrorHandlingType: 'Rollback') + + compiled = compiler.compile(action) + expect(compiled).to include( + '$Type' => 'Microflows$LogMessageAction', 'Level' => 'Trace', 'Node' => "'Mxrb'" + ) + expect(compiled['MessageTemplate']).to include( + '$Type' => 'Microflows$StringTemplate', 'Parameters' => [], 'Text' => '' + ) + end + + it 'derives Reference association cardinality from the retrieval direction' do + fields = { 'Microflows$AssociationRetrieveSource' => %w[$ID $Type Type] } + parent = node('DomainModels$Entity', 30, QualifiedName: 'App.Cell') + child = node('DomainModels$Entity', 31, QualifiedName: 'App.Game') + association = node( + 'DomainModels$Association', 32, QualifiedName: 'App.Cell_Game', + ParentPointer: parent['$ID'], ChildPointer: child['$ID'], Type: 'Reference' + ) + existing = { id(30) => parent, id(31) => child, 'App.Cell_Game' => association } + compiler = Mxrb::Compiler::MicroflowNodeCompiler.new(schema(fields:, existing:)) + compiler.prepare(node('Microflows$Microflow', 33, Objects: [ + node('Microflows$MicroflowParameter', 34, Name: 'Cell', + VariableType: { 'Entity' => 'App.Cell' }), + node('Microflows$MicroflowParameter', 35, Name: 'Game', + VariableType: { 'Entity' => 'App.Game' }) + ])) + + from_cell = compiler.compile(node('Microflows$AssociationRetrieveSource', 36, + AssociationId: 'App.Cell_Game', StartVariableName: 'Cell')) + from_game = compiler.compile(node('Microflows$AssociationRetrieveSource', 37, + AssociationId: 'App.Cell_Game', StartVariableName: 'Game')) + + expect(from_cell['Type']).to eq('App.Game') + expect(from_game['Type']).to eq('[App.Cell]') + end + it 'rejects fields and data types that cannot be derived' do compiler = Mxrb::Compiler::MicroflowNodeCompiler.new( schema(fields: { 'Test$Node' => %w[$ID $Type Missing], diff --git a/spec/native_runtime_spec.rb b/spec/native_runtime_spec.rb index df2b22a..40b9013 100644 --- a/spec/native_runtime_spec.rb +++ b/spec/native_runtime_spec.rb @@ -182,15 +182,11 @@ def build_project(path) expect { @interpreter.send(:execute_action, nil, {}) } .to raise_error(Mxrb::NativeRuntimeError, /has no action/) expect do - @interpreter.send(:action_retrieve, { 'RetrieveSource' => { '$Type' => 'Microflows$AssociationSource' } }, {}) + @interpreter.send( + :action_retrieve, + { 'RetrieveSource' => { '$Type' => 'Microflows$AssociationRetrieveSource' } }, {} + ) end.to raise_error(Mxrb::NativeRuntimeError, /unsupported retrieve source/) - expect do - @interpreter.send(:action_retrieve, { - 'RetrieveSource' => { - '$Type' => 'Microflows$DatabaseRetrieveSource', 'XpathConstraint' => '[Name = 1]' - } - }, {}) - end.to raise_error(Mxrb::NativeRuntimeError, /XPath retrieve/) expect do @interpreter.send(:action_retrieve, { 'RetrieveSource' => { @@ -201,6 +197,44 @@ def build_project(path) end.to raise_error(Mxrb::NativeRuntimeError, /sorting/) end + it 'filters and sorts database retrieves and XPath counts' do + [['Ada', 4, true], ['Bob', 2, false], ['Cal', 4, true]].each do |name, age, active| + animal = @interpreter.store.create('Clinic.Animal') + animal.members.update('Name' => name, 'Age' => age, 'Active' => active) + end + variables = { 'minimum' => 3 } + retrieve = { + 'RetrieveSource' => { + '$Type' => 'Microflows$DatabaseRetrieveSource', 'Entity' => 'Clinic.Animal', + 'XpathConstraint' => '[Age >= $minimum][Active]', + 'NewSortings' => { 'Sortings' => [ + { 'AttributePath' => 'Clinic.Animal.Age', 'SortOrder' => 'Descending' }, + { 'AttributeRef' => { 'Attribute' => 'Clinic.Animal.Name' }, 'SortOrder' => 'Ascending' } + ] } + }, + 'ResultVariableName' => 'animals' + } + + @interpreter.send(:action_retrieve, retrieve, variables) + expect(variables['animals'].map { _1.members['Name'] }).to eq(%w[Ada Cal]) + expect(@interpreter.count('Clinic.Animal', "[Name = 'Bob']")).to eq(1) + expect(@interpreter.count('Clinic.Animal', '[Active = true or Age < 3]')).to eq(3) + expect do + @interpreter.count('Clinic.Animal', "//Clinic.Animal[Name = 'Ada']") + end.to raise_error(Mxrb::NativeRuntimeError, /unsupported native XPath/) + + expression = described_class::Expression.new + expect(expression.evaluate('4 > 3', {})).to be(true) + expect(expression.evaluate('3 <= 3', {})).to be(true) + expect(expression.send(:matching_parenthesis, '(missing', 0)).to be_nil + expect(expression.send(:matching_parenthesis, 'x()', 1)).to eq(2) + equal = described_class::ObjectValue.new(entity: 'Clinic.Animal', id: 'equal', members: { 'Age' => 4 }) + expect(@interpreter.send(:compare_by_keys, equal, equal, [['Age', false]])).to eq(0) + expect(@interpreter.send(:compare_members, nil, nil)).to eq(0) + expect(@interpreter.send(:compare_members, nil, 1)).to eq(1) + expect(@interpreter.send(:compare_members, 1, nil)).to eq(-1) + end + it 'handles lower-level graph cases, mutations, templates, and collection encodings' do animal = described_class::ObjectValue.new(entity: 'Clinic.Animal', id: '1', members: {}) split = { '$Type' => 'Microflows$InheritanceSplit', 'SplitVariableName' => 'value' } @@ -222,6 +256,7 @@ def build_project(path) it 'supports executor hooks, failure details, optional output, and portable clocks' do hook = Mxrb::Functional::Hook.new('Clinic.Child', {}) + seed_hook = Mxrb::Functional::Hook.new('Clinic.SeedAndCount', {}) definition = Mxrb::Functional::Definition.new([ Mxrb::Functional::TestCase.new('hooks', 'Clinic.Child', {}, 1.0, nil, [], hook, hook), Mxrb::Functional::TestCase.new( @@ -230,7 +265,7 @@ def build_project(path) ), Mxrb::Functional::TestCase.new( 'xpath count', 'Clinic.Child', {}, 1.0, nil, - [Mxrb::Functional::CountExpectation.new('Clinic.Animal', '[Name = 1]', 0)] + [Mxrb::Functional::CountExpectation.new('Clinic.Animal', "[Name = 'Updated']", 1)], seed_hook ), Mxrb::Functional::TestCase.new('runtime error', 'Clinic.Missing', {}, 1.0) ].freeze) @@ -242,9 +277,9 @@ def build_project(path) calls.to_f end execution = described_class::Executor.new(@mpr, definition, clock:).run - expect(execution.result.tests.map(&:passed?)).to eq([true, false, false, false]) + expect(execution.result.tests.map(&:passed?)).to eq([true, false, true, false]) expect(execution.result.tests[1].message).to include('count 0, expected 2') - expect(execution.result.tests[2].message).to include('XPath count is not implemented') + expect(execution.result.tests[2].message).to eq('passed') expect(execution.result.tests[3].message).to include('not found') expect(execution.elapsed).to eq(1.0) end diff --git a/spec/page_bundle_compiler_spec.rb b/spec/page_bundle_compiler_spec.rb index f964a35..766e29a 100644 --- a/spec/page_bundle_compiler_spec.rb +++ b/spec/page_bundle_compiler_spec.rb @@ -15,6 +15,15 @@ self.module(:Demo) do layout :Shell nanoflow :ClientAction + microflow :ServerAction + native_document :Assets, type: 'Images$ImageCollection', deep_structure: { + 'Images' => Mxrb::IO::BsonCodec.build_array([ + { + '$Type' => 'Images$Image', 'Name' => 'Logo', + 'Image' => BSON::Binary.new("\x89PNG\r\n\x1A\nimage".b) + } + ]) + } page(:Home) do layout 'Demo.Shell' title 'Welcome' @@ -28,11 +37,14 @@ it 'renders page content, layout metadata, and translated text as an ES module' do source = Mxrb::Compiler::SourceModel.read(@mpr) - bundle = described_class.new(source).compile(source.units_of('Forms$Page').first) + unit = source.units_of('Forms$Page').first + unit.document['Appearance'] = { 'Class' => 'page-identity' } + 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":', - 'mx-name-body body', '"Hello"' + 'mx-name-body body', '"Hello"', + 'export const classes = "mxrb-application-shell page-identity"' ) expect(bundle.source).not_to include('Demo.Shell.Main') expect(bundle.unsupported_widgets).to be_empty @@ -105,17 +117,85 @@ '$Type' => 'Forms$DynamicText', 'Name' => 'duration', 'RenderMode' => 'Text', 'Content' => { 'Template' => { 'Items' => [3, { 'LanguageCode' => 'en_US', 'Text' => '{1} day(s)' }] }, - 'Parameters' => [2, { 'AttributeRef' => { 'Attribute' => 'Demo.Item.Duration' } }] + 'Parameters' => [2, { 'AttributeRef' => { + 'Attribute' => 'Demo.Item.Duration', + 'EntityRef' => { 'Steps' => [2, { + 'Association' => 'Demo.Parent_Items', 'DestinationEntity' => 'Demo.Item' + }] } + } }] } } output = compiler.send(:render_text, widget) expect(output).to include( 'React.createElement($MxrbFormattedText', '"template": "{1} day(s)"', - '"value": AttributeProperty', '"attribute": "Duration"' + '"value1": AttributeProperty', '"path": "Demo.Parent_Items/Demo.Item"', + '"attribute": "Duration"' ) expect(compiler.send(:widget_imports)).to include( - 'value?.displayValue', 'template.split("{1}")', '$MxrbFormattedText' + 'props[key]?.displayValue', 'text.split(`{${index + 1}}`)', '$MxrbFormattedText' + ) + end + + it 'formats expression parameters and evaluates simple conditional visibility' do + compiler = described_class.new(Mxrb::Compiler::SourceModel.read(@mpr)) + compiler.instance_variable_set(:@qualified_name, 'Sudoku.Game_Play') + compiler.instance_variable_set(:@data_view_scopes, []) + compiler.instance_variable_set( + :@list_scopes, [{ scope: 'p.Sudoku.Game_Play.board', entity: 'Sudoku.Cell' }] + ) + widget = { + '$Type' => 'Forms$DynamicText', 'Name' => 'status', 'RenderMode' => 'Text', + 'Content' => { + 'Template' => { 'Items' => [3, { 'LanguageCode' => 'en_US', 'Text' => '{1} / {2}' }] }, + 'Parameters' => [3, + { 'Expression' => 'toString($currentObject/Value)' }, + { 'Expression' => 'toString($currentObject/Row)' }] + }, + 'ConditionalVisibilitySettings' => { + 'Expression' => '$currentObject/Value != empty and $currentObject/Row != 0' + } + } + + output = compiler.send(:render_widget, widget) + expect(output).to include( + 'React.createElement($MxrbConditional', 'React.createElement($MxrbFormattedText', + '"value1": AttributeProperty', '"attribute": "Value"', + '"value2": AttributeProperty', '"attribute": "Row"', + '"test": props =>', 'mxrbValue(props.value1)', 'Number(mxrbValue(props.value2)) === 0', + '"$widgetId": "p.Sudoku.Game_Play.status$visibility"' + ) + expect(compiler.send(:widget_imports)).to include('$MxrbConditional', 'const mxrbValue') + end + + it 'materializes attribute-backed dynamic classes on list content' do + compiler = described_class.new(Mxrb::Compiler::SourceModel.read(@mpr)) + compiler.instance_variable_set(:@qualified_name, 'Sudoku.Game_Play') + compiler.instance_variable_set(:@data_view_scopes, []) + compiler.instance_variable_set( + :@list_scopes, [{ scope: 'p.Sudoku.Game_Play.board', entity: 'Sudoku.Cell' }] + ) + widget = { + '$Type' => 'Forms$DivContainer', 'Name' => 'cell', 'RenderMode' => 'Div', 'Widgets' => [], + 'Appearance' => { + 'Class' => '', + 'DynamicClasses' => '$currentObject/CellClass + ' \ + "(if $currentObject/IsPeer then ' sd-peer' else '') + " \ + "(if $currentObject/IsInvalid then ' sd-bad' else '')" + } + } + + output = compiler.send(:render_widget, widget) + expect(output).to include( + 'React.createElement($MxrbDynamicClass', 'React.createElement("div"', + '"attribute": "CellClass"', '"attribute": "IsPeer"', '"attribute": "IsInvalid"', + 'String(mxrbValue(props.value1) ?? \'\')', + 'Boolean(mxrbValue(props.value2)) ? " sd-peer" : ""', + 'Boolean(mxrbValue(props.value3)) ? " sd-bad" : ""', + '"$widgetId": "p.Sudoku.Game_Play.cell$class"' + ) + expect(compiler.send(:widget_imports)).to include( + '$MxrbDynamicClass', 'React.cloneElement(children, classProp)' ) end @@ -142,6 +222,15 @@ 3, { 'LanguageCode' => 'en_US', 'Text' => 'Enter a name' } ] } } } + date_picker = { + '$Type' => 'Forms$DatePicker', 'Name' => 'dueDate', + 'AttributeRef' => { 'Attribute' => 'Demo.Item.DueDate' }, + 'FormattingInfo' => { 'DateFormat' => 'Date' }, + 'LabelTemplate' => { 'Template' => { 'Items' => [ + 3, { 'LanguageCode' => 'en_US', 'Text' => 'Due date' } + ] } }, + 'PlaceholderTemplate' => { 'Template' => { 'Items' => [3] } } + } actions = %w[Forms$SaveChangesClientAction Forms$CancelChangesClientAction].map.with_index do |type, index| { '$Type' => 'Forms$ActionButton', 'Name' => "action#{index}", @@ -157,11 +246,11 @@ '$Type' => 'Forms$DataView', 'Name' => 'editor', 'ShowFooter' => false, 'DataSource' => { 'SourceVariable' => { 'PageParameter' => 'Item' } }, 'NoEntityMessage' => { 'Items' => [3] }, - 'Widgets' => [2, text_box, text_box.merge( + 'Widgets' => [3, text_box, text_box.merge( 'Name' => 'code', 'IsPasswordBox' => false, 'MaxLengthCode' => -1, 'Autocomplete' => true, 'SubmitBehaviour' => 'OnEndEditing', 'AttributeRef' => { 'Attribute' => 'Demo.Item.Code' } - )], + ), date_picker], 'FooterWidgets' => [2, *actions] } unit.document['FormCall']['Arguments'].find { _1.is_a?(Hash) }['Widgets'] = [2, data_view] @@ -169,11 +258,12 @@ compiler = described_class.new(source) bundle = compiler.compile(unit) expect(bundle.source).to include( - '$DataView', '$TextBox', '$FormGroup', '$ActionButton', + '$DataView', '$TextBox', '$DatePicker', '$FormGroup', '$ActionButton', 'AssociationObjectProperty({ scope: "$Item"', 'AttributeProperty({ "scope": "p.Demo.Home.editor"', '"isPassword": true', '"maxLength": 42', '"autocomplete": "off"', '"submitWhileEditing": true', 'TextProperty({ value: "Name" })', + '"mode": "date"', '"formatting": { "dateFormat": { "type": "date" } }', '"type": "saveChanges"', '"type": "cancelChanges"', 'export const parameters = {"$Item":{"kind":"object"}}' ) @@ -190,6 +280,167 @@ expect(compiler.send(:widget_imports)).to include('$Datagrid', '$DataView') end + it 'infers an omitted microflow mapping from the current DataView entity' do + source = Mxrb::Compiler::SourceModel.read(@mpr) + unit = source.units_of('Forms$Page').first + unit.document['Parameters'] << { + '$Type' => 'Forms$PageParameter', 'Name' => 'Item', + 'ParameterType' => { '$Type' => 'DataTypes$ObjectType', 'Entity' => 'Demo.Item' } + } + flow = source.units_of('Microflows$Microflow').find { _1.document['Name'] == 'ServerAction' } + flow.document.dig('ObjectCollection', 'Objects') << { + '$Type' => 'Microflows$MicroflowParameter', 'Name' => 'CurrentItem', + 'VariableType' => { '$Type' => 'DataTypes$ObjectType', 'Entity' => 'Demo.Item' } + } + button = { + '$Type' => 'Forms$ActionButton', 'Name' => 'saveWithFlow', + 'CaptionTemplate' => { 'Template' => { 'Items' => [ + 3, { 'LanguageCode' => 'en_US', 'Text' => 'Save' } + ] } }, + 'Action' => { + '$Type' => 'Forms$MicroflowAction', + 'MicroflowSettings' => { 'Microflow' => 'Demo.ServerAction', 'ParameterMappings' => [2] } + } + } + data_view = { + '$Type' => 'Forms$DataView', 'Name' => 'editor', + 'DataSource' => { 'SourceVariable' => { 'PageParameter' => 'Item' } }, + 'Widgets' => [2], 'FooterWidgets' => [2, button] + } + unit.document['FormCall']['Arguments'].find { _1.is_a?(Hash) }['Widgets'] = [2, data_view] + + bundle = described_class.new(source).compile(unit) + expect(bundle.source).to include( + '"argMap": { "CurrentItem": { "widget": "p.Demo.Home.editor", "source": "object" } }' + ) + end + + it 'renders core labels, check boxes, tabs, images, links, and server actions' do + source = Mxrb::Compiler::SourceModel.read(@mpr) + unit = source.units_of('Forms$Page').first + unit.document['Parameters'] << { + '$Type' => 'Forms$PageParameter', 'Name' => 'Item', + 'ParameterType' => { '$Type' => 'DataTypes$ObjectType', 'Entity' => 'Demo.Item' } + } + label = { + '$Type' => 'Forms$Label', 'Name' => 'notice', + 'Caption' => { 'Items' => [3, { 'LanguageCode' => 'en_US', 'Text' => 'Notice' }] } + } + checkbox = { + '$Type' => 'Forms$CheckBox', 'Name' => 'active', + 'AttributeRef' => { 'Attribute' => 'Demo.Item.Active' }, + 'LabelTemplate' => { 'Template' => { 'Items' => [ + 3, { 'LanguageCode' => 'en_US', 'Text' => 'Active' } + ] } } + } + buttons = [ + { + '$Type' => 'Forms$ActionButton', 'Name' => 'docs', + 'CaptionTemplate' => { 'Template' => { 'Items' => [ + 3, { 'LanguageCode' => 'en_US', 'Text' => 'Docs' } + ] } }, + 'Action' => { + '$Type' => 'Forms$OpenLinkClientAction', 'LinkType' => 'Web', + 'Address' => { 'IsDynamic' => false, 'Value' => 'https://example.test' } + } + }, + { + '$Type' => 'Forms$ActionButton', 'Name' => 'run', + 'CaptionTemplate' => { 'Template' => { 'Items' => [ + 3, { 'LanguageCode' => 'en_US', 'Text' => 'Run' } + ] } }, + 'Action' => { + '$Type' => 'Forms$MicroflowAction', + 'MicroflowSettings' => { 'Microflow' => 'Demo.ServerAction', 'ParameterMappings' => [2] } + } + } + ] + tab = { + '$Type' => 'Forms$TabControl', 'Name' => 'tabs', + 'TabPages' => [2, { + '$Type' => 'Forms$TabPage', 'Name' => 'first', + 'Caption' => { 'Items' => [3, { 'LanguageCode' => 'en_US', 'Text' => 'General' }] }, + 'Widgets' => [2, label], 'RefreshOnShow' => false + }] + } + image = { + '$Type' => 'Forms$StaticImageViewer', 'Name' => 'logo', 'Image' => 'Demo.Assets.Logo', + 'Width' => 80, 'WidthUnit' => 'Pixels', 'Height' => 50, 'HeightUnit' => 'Pixels', + 'Responsive' => true + } + data_view = { + '$Type' => 'Forms$DataView', 'Name' => 'editor', + 'DataSource' => { 'SourceVariable' => { 'PageParameter' => 'Item' } }, + 'Widgets' => [2, checkbox, tab, image, *buttons, { + '$Type' => 'Forms$DivContainer', 'Name' => 'clickable', 'Widgets' => [2, label], + 'OnClickAction' => { + '$Type' => 'Forms$MicroflowAction', + 'MicroflowSettings' => { 'Microflow' => 'Demo.ServerAction', 'ParameterMappings' => [2] } + } + }], 'FooterWidgets' => [2] + } + unit.document['FormCall']['Arguments'].find { _1.is_a?(Hash) }['Widgets'] = [2, data_view] + + bundle = described_class.new(source).compile(unit) + expect(bundle.source).to include( + '$CheckBox', '$Label', '$TabContainer', '$Image', '$Container', 'WebStaticImageProperty', + 'img/Demo$Assets$Logo.png', '"type": "openLink"', 'https://example.test', + '"type": "callMicroflow"' + ) + expect(bundle.source).to include( + Mxrb::Compiler::WebOperationCompiler.operation_id('Demo.Home', 'run') + ) + expect(bundle.unsupported_widgets).to be_empty + + compiler = described_class.new(source) + compiler.compile(unit) + compiler.instance_variable_set(:@data_view_scopes, ['p.Demo.Home.editor']) + dynamic = { + '$Type' => 'Forms$OpenLinkClientAction', 'LinkType' => 'Web', + 'Address' => { + 'IsDynamic' => true, 'AttributeRef' => { 'Attribute' => 'Demo.Item.URL' } + } + } + expect(compiler.send(:open_link_config, dynamic)).to include( + argMap: { '$object': { widget: 'p.Demo.Home.editor', source: 'object' } }, + config: { schema: 'web', addressAttribute: 'Demo.Item/URL' } + ) + compiler.instance_variable_set(:@data_view_scopes, []) + expect(compiler.send(:open_link_config, dynamic)).to be_nil + expect(compiler.send(:microflow_config, {}, '$Type' => 'Forms$MicroflowAction')).to be_nil + expect(compiler.send(:microflow_argument, 'Parameter' => '', 'Expression' => '$Item')).to be_nil + invalid_mapping = { 'ParameterMappings' => [2, { 'Parameter' => '', 'Expression' => '$Item' }] } + expect(compiler.send(:microflow_argument_map, invalid_mapping)).to be_nil + expect(compiler.send( + :microflow_config, { 'Name' => 'invalid' }, + { '$Type' => 'Forms$MicroflowAction', + 'MicroflowSettings' => invalid_mapping.merge('Microflow' => 'Demo.ServerAction') } + )).to be_nil + compiler.instance_variable_set(:@list_scopes, [{ scope: 'p.Demo.Home.items' }]) + expect(compiler.send( + :microflow_argument, 'Parameter' => 'Demo.ServerAction.Item', 'Expression' => '$currentObject' + )).to eq([:Item, { widget: 'p.Demo.Home.items', source: 'object' }]) + nanoflow = { '$Type' => 'Forms$CallNanoflowClientAction', 'Nanoflow' => 'Demo.ClientAction', + 'ParameterMappings' => [2] } + expect(compiler.send(:container_action_config, {}, nanoflow)).to include( + action: include(type: 'callNanoflow') + ) + form_container = { + '$Type' => 'Forms$DivContainer', 'Name' => 'navigate', 'Widgets' => [2], + 'OnClickAction' => { + '$Type' => 'Forms$FormAction', 'FormSettings' => { 'Form' => 'Demo.Home' } + } + } + expect(compiler.send(:render_container, form_container)).to include('onClick', 'role') + 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 + expect(compiler.send(:image_uri, 'Demo.Unknown.Missing')).to be_nil + expect(compiler.send(:render_static_image, { + '$Type' => 'Forms$StaticImageViewer', 'Name' => 'missing', 'Image' => 'Demo.Unknown.Missing' + })).to include('mxrb-unsupported-widget') + end + it 'renders a parameterless nanoflow action through the client action property' do source = Mxrb::Compiler::SourceModel.read(@mpr) unit = source.units_of('Forms$Page').first @@ -323,12 +574,188 @@ 3, { 'AttributeRef' => { 'Attribute' => 'Demo.Item.First' } }, { 'AttributeRef' => { 'Attribute' => 'Demo.Item.Second' } } ] } } - expect(compiler.send(:bound_text_attribute, two_parameters)).to be_nil + expect(compiler.send(:bound_text_attributes, two_parameters)).to eq( + %w[Demo.Item.First Demo.Item.Second] + ) malformed = { 'Content' => { 'Parameters' => [ 2, { 'AttributeRef' => { 'Attribute' => 'NoSeparator' } } ] } } - expect(compiler.send(:bound_text_attribute, malformed)).to be_nil + expect(compiler.send(:bound_text_attributes, malformed)).to be_nil + end + + it 'covers guarded page-expression, Combo box, and DatePicker branches' do + source = Mxrb::Compiler::SourceModel.read(@mpr) + compiler = described_class.new(source) + compiler.compile(source.units_of('Forms$Page').first) + compiler.instance_variable_set(:@data_view_scopes, [ + { scope: 'p.Demo.Home.editor', entity: 'Demo.Item' } + ]) + compiler.instance_variable_set(:@list_scopes, []) + + allow(Mxrb::Compiler::DataGridBundleCompiler).to receive(:new) + .and_return(instance_double(Mxrb::Compiler::DataGridBundleCompiler, supported?: false)) + allow(Mxrb::Compiler::GalleryBundleCompiler).to receive(:new) + .and_return(instance_double(Mxrb::Compiler::GalleryBundleCompiler, supported?: false)) + allow(Mxrb::Compiler::ImageBundleCompiler).to receive(:new) + .and_return(instance_double(Mxrb::Compiler::ImageBundleCompiler, supported?: false)) + combo = instance_double(Mxrb::Compiler::ComboBoxBundleCompiler, + supported?: true, render: 'combo-output') + allow(Mxrb::Compiler::ComboBoxBundleCompiler).to receive(:new).and_return(combo) + + expect(compiler.send(:render_custom_widget, 'Name' => 'combo')).to eq('combo-output') + expect(compiler.send(:widget_imports)).to include('$Combobox', 'AssociationProperty') + expect(compiler.send(:attribute_reference_path, nil)).to eq('') + expect(compiler.send(:bound_text_attributes, 'Content' => { 'Parameters' => [2] })).to be_nil + + compiler.instance_variable_set(:@list_scopes, []) + compiler.instance_variable_set(:@data_view_scopes, []) + expect(compiler.send(:text_parameter_attribute, + 'Expression' => '$currentObject/Name')).to be_nil + compiler.instance_variable_set(:@list_scopes, ['legacy-scope']) + expect(compiler.send(:current_object_scope)).to eq(scope: 'legacy-scope', entity: '') + + compiler.instance_variable_set(:@list_scopes, [ + { scope: 'p.Demo.Home.items', entity: 'Demo.Item' } + ]) + attributes = [] + expect(compiler.send( + :conditional_visibility, + 'Expression' => '$currentObject/Active = true or $currentObject/State = Demo.State.Open' + )).to be_an(Array) + expect(compiler.send(:conditional_visibility, 'Expression' => 'invalid')).to be_nil + expect(compiler.send(:logical_predicate, ['$currentObject/Active', 'invalid'], attributes, '&&')) + .to be_nil + expect(compiler.send(:visibility_atom, 'invalid', attributes)).to be_nil + expect(compiler.send(:visibility_atom, '$currentObject/State = invalid value', attributes)).to be_nil + expect(compiler.send(:visibility_atom, '$currentObject/Active = true', attributes)) + .to include('=== true') + expect(compiler.send(:visibility_comparison, 'value', 'false')).to eq('value === false') + expect(compiler.send(:visibility_comparison, 'value', 'Demo.State.Open')).to include('"Open"') + expect(compiler.send(:visibility_comparison, 'value', "'ready'")).to include('"ready"') + expect(compiler.send(:visibility_comparison, 'value', 'not valid')).to be_nil + + dynamic_widget = { + 'Name' => 'invalidClass', 'Appearance' => { 'DynamicClasses' => 'not supported' } + } + expect(compiler.send(:wrap_dynamic_classes, dynamic_widget, 'content')).to eq('content') + expect(compiler.send(:dynamic_class_expression, 'not supported', [])).to be_nil + expect(compiler.send(:dynamic_class_conditional, 'invalid', [])).to be_nil + expect(compiler.send(:dynamic_class_conditional, + "if invalid then 'yes' else 'no'", [])).to be_nil + + expect(compiler.send(:microflow_argument_map, 'ParameterMappings' => [2, { + 'Parameter' => 'Demo.Item', 'Expression' => '$Item' + }])).to eq(Item: { widget: '$Item', source: 'object' }) + compiler.instance_variable_set(:@list_scopes, []) + compiler.instance_variable_set(:@data_view_scopes, []) + expect(compiler.send(:inferred_microflow_argument_map, 'Demo.ServerAction')).to eq({}) + expect(compiler.send(:inferred_microflow_parameters, 'Demo.Missing', 'Demo.Item')).to eq([]) + + compiler.instance_variable_set(:@data_view_scopes, [ + { scope: 'p.Demo.Home.editor', entity: 'Demo.Item' } + ]) + expect(compiler.send(:render_date_picker, { + '$Type' => 'Forms$DatePicker', 'Name' => 'invalid', 'AttributeRef' => { 'Attribute' => 'invalid' } + })).to include('mxrb-unsupported-widget') + expect(compiler.send(:render_date_picker, { + '$Type' => 'Forms$DatePicker', 'Name' => 'startTime', + 'AttributeRef' => { 'Attribute' => 'Demo.Item.Start' }, + 'FormattingInfo' => { 'DateFormat' => 'Time' }, 'ShowCalendarButton' => false, + 'LabelTemplate' => { 'Template' => { 'Items' => [2] } }, + 'PlaceholderTemplate' => { 'Template' => { 'Items' => [2] } } + })).to include('"mode": "time"', '"timeFormat"') + end +end + +RSpec.describe Mxrb::Compiler::ImageBundleCompiler do + def image_property_type(id, key, type) + { '$ID' => id, 'PropertyKey' => key, 'ValueType' => { 'Type' => type } } + end + + def image_property(type, **values) + { 'TypePointer' => type, 'Value' => { 'PrimitiveValue' => '' }.merge(values.transform_keys(&:to_s)) } + end + + def image_text(value, language = 'en_US') + { 'Template' => { 'Items' => [3, { 'LanguageCode' => language, 'Text' => value }] } } + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def image_compiler(datasource: 'image', image: 'Demo.Assets.Logo', widget_id: described_class::WIDGET_ID) + types = [ + image_property_type('datasource', 'datasource', 'Enumeration'), + image_property_type('image', 'imageObject', 'Image'), + image_property_type('responsive', 'responsive', 'Boolean'), + image_property_type('width', 'width', 'Integer'), + image_property_type('alt', 'alternativeText', 'TextTemplate'), + image_property_type('url', 'imageUrl', 'TextTemplate'), + image_property_type('ignored', 'onClick', 'Action') + ] + schema = { + '$ID' => 'widget-type', 'WidgetId' => widget_id, + 'ObjectType' => { '$ID' => 'object-type', 'PropertyTypes' => [2, *types] } + } + image_unit = Struct.new(:module_name, :document, keyword_init: true).new(module_name: 'Demo', document: { + 'Name' => 'Assets', 'Images' => [2, { + 'Name' => 'Logo', 'Image' => BSON::Binary.new("\x89PNG\r\n\x1A\nimage".b) + }] + }) + source = instance_double(Mxrb::Compiler::SourceModel, documents: [schema, ['nested']]) + allow(source).to receive(:units_of).with('Images$ImageCollection').and_return([image_unit]) + widget = { + '$Type' => 'CustomWidgets$CustomWidget', 'Name' => 'logo', + 'Appearance' => { 'Class' => 'brand' }, + 'Object' => { 'TypePointer' => 'object-type', 'Properties' => [ + 2, image_property('datasource', PrimitiveValue: datasource), + image_property('image', Image: image), image_property('responsive', PrimitiveValue: 'true'), + image_property('width', PrimitiveValue: '80'), + image_property('alt', TextTemplate: image_text('Logo')), + image_property('url', TextTemplate: image_text('Adresse', 'de_DE')), + image_property('ignored'), image_property('missing') + ] } + } + described_class.new(source, 'Demo.Home', widget) + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + it 'compiles the official static Image widget and its primitive properties' do + compiler = image_compiler + expect(compiler).to be_supported + expect(compiler.render).to include( + 'React.createElement($Image', 'WebStaticImageProperty', 'Demo$Assets$Logo.png', + '"responsive": true', '"width": 80', 'Logo', 'Adresse', 'mx-name-logo brand' + ) + expect(described_class.javascript(described_class.raw('raw'))).to eq('raw') + expect(described_class.javascript([true, nil])).to eq('[true, null]') + expect(described_class.javascript(test: 1)).to eq('{ "test": 1 }') + expect(described_class.unit(nil)).to eq('auto') + expect(described_class.number(nil, 7)).to eq(7) + expect(compiler.send(:translated_text, nil)).to eq('') + compiler.instance_variable_set(:@values, {}) + expect(compiler.send(:primitive, 'missing')).to be_nil + expect(compiler.send(:text_value, 'missing')).to eq('') + expect(compiler.send(:image_uri)).to be_nil + expect(compiler.send(:property_values, nil)).to eq({}) + compiler.instance_variable_set(:@index, {}) + expect(compiler).not_to be_supported + + populated = image_compiler + page_compiler = Mxrb::Compiler::PageBundleCompiler.new(populated.instance_variable_get(:@source)) + page_compiler.instance_variable_set(:@qualified_name, 'Demo.Home') + page_compiler.instance_variable_set(:@list_scopes, []) + rendered = page_compiler.send(:render_custom_widget, populated.instance_variable_get(:@widget)) + expect(rendered).to include('React.createElement($Image') + expect(page_compiler.send(:widget_imports)) + .to include('../widgets/com/mendix/widget/web/image/Image.mjs') + end + + it 'rejects another widget, a dynamic source, and an unresolved image' do + expect(image_compiler(widget_id: 'other')).not_to be_supported + expect(image_compiler(datasource: 'imageUrl')).not_to be_supported + missing = image_compiler(image: 'Demo.Assets.Missing') + expect(missing).not_to be_supported + expect(missing.send(:image_uri)).to be_nil end end diff --git a/spec/project_jar_builder_spec.rb b/spec/project_jar_builder_spec.rb index 566ca64..72d9a03 100644 --- a/spec/project_jar_builder_spec.rb +++ b/spec/project_jar_builder_spec.rb @@ -98,6 +98,80 @@ def builder expect(direct.build.classpath_entries).to eq(1) end + it 'stages sources without an unused legacy CustomJavaAction import' do + source = <<~JAVA + package demo; + import com.mendix.webui.CustomJavaAction; + class Hello extends com.mendix.systemwideinterfaces.core.UserAction {} + JAVA + used = source.sub('{}', '{ CustomJavaAction action; }') + + expect(builder.send(:strip_legacy_unused_imports, source)).not_to include('CustomJavaAction') + expect(builder.send(:strip_legacy_unused_imports, used)).to eq(used) + + Dir.mktmpdir do |staging| + path = File.join(@root, 'javasource', 'demo', 'Legacy.java') + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, source) + staged = builder.send(:stage_legacy_sources, [path], staging).first + expect(staged).not_to eq(path) + expect(File.read(staged)).not_to include('CustomJavaAction') + end + end + + it 'generates referenced microflow proxies and covers their Java type contracts' do + Mxrb.define(@mpr) do + mendix_version '11.12.1' + self.module(:Demo) { microflow(:Run) } + end + source = File.join(@root, 'javasource', 'demo', 'UseFlow.java') + FileUtils.mkdir_p(File.dirname(source)) + File.write(source, 'class UseFlow { Object x = demo.proxies.microflows.Microflows.missing; }') + expect(Mxrb::Compiler::JavaProxyGenerator.new(@mpr, project_root: @root).generate).to eq(0) + + File.write(source, 'class UseFlow { void x() { demo.proxies.microflows.Microflows.run(null); } }') + generator = Mxrb::Compiler::JavaProxyGenerator.new(@mpr, project_root: @root) + expect(generator.generate).to eq(1) + expect(File.read(File.join(@root, 'javasource/demo/proxies/microflows/Microflows.java'))) + .to include('public static void run(', 'Core.microflowCall("Demo.Run")', 'return;') + + types = { + 'DataTypes$BooleanType' => %w[java.lang.Boolean boolean], + 'DataTypes$IntegerType' => %w[java.lang.Long java.lang.Long], + 'DataTypes$LongType' => %w[java.lang.Long java.lang.Long], + 'DataTypes$DecimalType' => %w[java.math.BigDecimal java.math.BigDecimal], + 'DataTypes$DateTimeType' => %w[java.util.Date java.util.Date], + 'DataTypes$StringType' => %w[java.lang.String java.lang.String], + 'DataTypes$ObjectType' => %w[demo.proxies.Record demo.proxies.Record], + 'DataTypes$VoidType' => %w[void void], + 'DataTypes$BinaryType' => %w[java.lang.Object java.lang.Object] + } + types.each do |type, expected| + value = { '$Type' => type, 'Entity' => 'Demo.Record' } + expect(generator.send(:java_data_type, value)).to eq(expected.first) + expect(generator.send(:java_data_type, value, return_type: true)).to eq(expected.last) + end + expect(generator.send(:java_data_type, nil)).to eq('void') + expect(generator.send(:microflow_result, { '$Type' => 'DataTypes$BooleanType' })).to include('boolean') + expect(generator.send(:microflow_result, { '$Type' => 'DataTypes$ObjectType', + 'Entity' => 'Demo.Record' })).to include('Record.initialize') + expect(generator.send(:microflow_result, { '$Type' => 'DataTypes$VoidType' })).to eq('return;') + expect(generator.send(:microflow_result, nil)).to eq('return;') + expect(generator.send(:microflow_result, { '$Type' => 'DataTypes$IntegerType' })).to include('java.lang.Long') + expect(generator.send(:lower_camel, 'RunFlow')).to eq('runFlow') + + unit = Struct.new(:module_name, :document).new('Demo', { + 'Name' => 'WithInput', + 'ObjectCollection' => { 'Objects' => [2, { + '$Type' => 'Microflows$MicroflowParameter', 'Name' => 'Enabled', + 'VariableType' => { '$Type' => 'DataTypes$BooleanType' } + }] }, + 'MicroflowReturnType' => { '$Type' => 'DataTypes$BooleanType' } + }) + expect(generator.send(:microflow_method, unit)) + .to include('boolean withInput(', 'java.lang.Boolean _enabled', '.withParam("Enabled", _enabled)') + end + it 'generates only Java proxies referenced by custom project sources' do Mxrb.define(@mpr) do mendix_version '11.12.1' @@ -136,6 +210,28 @@ class UseProxy { .to include('getLimit') end + it 'generates the OSGi registrar for Java actions that have project sources' do + Mxrb.define(@mpr) do + mendix_version '11.12.1' + self.module(:Demo) do + native_document :Publish, type: 'JavaActions$JavaAction', deep_structure: { + 'Parameters' => [2], 'JavaReturnType' => { '$Type' => 'CodeActions$VoidType' } + } + end + end + action = File.join(@root, 'javasource', 'demo', 'actions', 'Publish.java') + FileUtils.mkdir_p(File.dirname(action)) + File.write(action, 'package demo.actions; public class Publish {}') + + generator = Mxrb::Compiler::JavaProxyGenerator.new(@mpr, project_root: @root) + expect(generator.generate).to eq(1) + registrar = File.read(File.join(@root, 'javasource', 'system', 'UserActionsRegistrar.java')) + expect(registrar).to include( + 'class UserActionsRegistrar', + 'registrator.registerUserAction(demo.actions.Publish.class);' + ) + end + it 'renders inherited and association proxy contracts without overwriting user files' do generator = Mxrb::Compiler::JavaProxyGenerator.allocate parent = { @@ -164,7 +260,23 @@ class UseProxy { expect(generator.send(:association_methods, unit, association)).to include('java.util.List') expect(generator.send(:association_methods, unit, association.merge('ChildPointer' => 'missing'))) .to include('IEntityProxy') - expect(generator.send(:association_methods, unit, association.merge('Type' => 'Reference'))).to eq('') + expect(generator.send(:association_methods, unit, association.merge('Type' => 'Reference'))) + .to include('demo.proxies.Child getParent_Children', 'demo.proxies.Child.load') + expect(generator.send(:association_methods, unit, association.merge('Type' => 'Association'))).to eq('') + expect(generator.send(:association_qualified_name, unit, + association.merge('QualifiedName' => 'Shared.Parent_Children'))) + .to eq('Shared.Parent_Children') + expect(generator.send(:association_child_qualified, unit, + association.merge('Child' => 'Shared.Child'))).to eq('Shared.Child') + expect(generator.send(:association_child_type, unit, + association.merge('Child' => 'Shared.Child'))) + .to eq('com.mendix.systemwideinterfaces.core.IEntityProxy') + expect(generator.send(:requested_entities, 'demo.proxies.Parent')) + .to include('Demo.Parent', 'Demo.Child') + generator.instance_variable_set(:@entities, { 'Demo.Parent' => [unit, parent] }) + expect(generator.send(:requested_entities, 'demo.proxies.Parent')).to eq(['Demo.Parent']) + generator.instance_variable_set(:@entities, entities) + expect(generator.send(:entity_source, unit, parent)).to include('Parent_Children') expect(generator.send(:identifier, Struct.new(:data).new('binary'))).to eq('binary') expect(generator.send(:attribute_java_type, unit, '$Type' => 'DomainModels$EnumerationAttributeType', 'Enumeration' => 'State')) @@ -176,6 +288,18 @@ class UseProxy { expect(generator.send(:write_missing, path, 'generated')).to be(false) expect(File.read(path)).to eq('user owned') end + + it 'ignores Java action documents without a matching source file' do + generator = Mxrb::Compiler::JavaProxyGenerator.allocate + units = [ + Struct.new(:module_name, :document).new('Demo', { 'Name' => '' }), + Struct.new(:module_name, :document).new('Demo', { 'Name' => 'Missing' }) + ] + generator.instance_variable_set(:@source, instance_double(Mxrb::Compiler::SourceModel, + units_of: units)) + generator.instance_variable_set(:@project_root, @root) + expect(generator.send(:user_action_classes)).to eq([]) + end end # rubocop:enable Metrics/BlockLength diff --git a/spec/protocols_spec.rb b/spec/protocols_spec.rb index b306d47..98208e1 100644 --- a/spec/protocols_spec.rb +++ b/spec/protocols_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'tmpdir' # rubocop:disable Metrics/BlockLength RSpec.describe Mxrb::Protocols do @@ -96,6 +97,44 @@ def project(*modules) = instance_double(Mxrb::Model::Project, modules: modules) expect(result.connectors).to be_empty end end + + it 'preserves imported modules through export, rebuild, and semantic indexing' do + fixture = '/home/mykael/Personal_Projects/mxrb-fixtures/ConnectorKitDemo/ConnectorKitDemo.mpr' + skip 'connector fixture not present in this environment' unless File.exist?(fixture) + + Dir.mktmpdir do |root| + exported = File.join(root, 'exported') + rebuilt = File.join(root, 'rebuilt.mpr') + original_imports = nil + original_artifacts = nil + Mxrb.open(fixture) do |project| + original_imports = imported_modules(project) + original_artifacts = project.semantic_index.artifacts.map(&:qualified_name).sort + end + + Mxrb::Exporter.new(fixture, exported).export!(parallel: false) + begin + ENV['MXRB_OUTPUT_PATH'] = rebuilt + load File.join(exported, 'project.rb') + ensure + ENV.delete('MXRB_OUTPUT_PATH') + end + + expect(Mxrb.validate(rebuilt)).to be_valid + Mxrb.open(rebuilt) do |project| + expect(imported_modules(project)).to eq(original_imports) + expect(project.semantic_index.artifacts.map(&:qualified_name).sort).to eq(original_artifacts) + expect(described_class.audit(project).unknown_marketplace_modules) + .to include('AppCloudServices', 'ObjectHandling') + end + end + end + + def imported_modules(project) + project.modules.select(&:from_app_store).to_h do |mod| + [mod.name, { guid: mod.app_store_guid, version: mod.app_store_version }] + end + end end RSpec.describe Mxrb::Model::Connector do diff --git a/spec/web_bundle_builder_spec.rb b/spec/web_bundle_builder_spec.rb index 215c4b3..3647c26 100644 --- a/spec/web_bundle_builder_spec.rb +++ b/spec/web_bundle_builder_spec.rb @@ -52,7 +52,8 @@ result = described_class.new( @mpr, deployment: @deployment, mendix_home: File.join(@version, 'runtime') ).build - expect(result.files).to eq(2) + expect(result.files).to eq(3) + expect(File).to exist(File.join(@deployment, 'web', 'dist', 'widgets.css')) expect(File.read(client)).to match( /let t=`\?\d+\$\{\(0,A\.g\)\(\)\.getConfig\("cachebust"\)\}`/ ) diff --git a/spec/web_operation_compiler_spec.rb b/spec/web_operation_compiler_spec.rb index 767adc0..f110a7b 100644 --- a/spec/web_operation_compiler_spec.rb +++ b/spec/web_operation_compiler_spec.rb @@ -55,6 +55,70 @@ def widget(source: true) expect(constants['XPath']).to eq('//Demo.Item') end + it 'inventories untyped attributes and current-object expressions used by list content' do + compiler = described_class.new(instance_double(Mxrb::Compiler::SourceModel)) + content = { + 'AttributeRef' => { 'Attribute' => 'Demo.Item.Name' }, + 'ConditionalVisibilitySettings' => { + 'Expression' => '$currentObject/Active = true and $currentObject/Score != 0' + }, + 'RelatedEntity' => { 'AttributeRef' => { + 'Attribute' => 'Demo.Parent.Name', 'EntityRef' => { 'Steps' => [2, { + 'Association' => 'Demo.Item_Parent', 'DestinationEntity' => 'Demo.Parent' + }] } + } }, + 'OtherEntity' => { 'Attribute' => 'Demo.Parent.Name' } + } + + expect(compiler.send(:used_attributes, content, 'Demo.Item')).to eq( + %w[Demo.Item/Demo.Item.Active Demo.Item/Demo.Item.Name Demo.Item/Demo.Item.Score + Demo.Item/Demo.Item_Parent/Demo.Parent/Demo.Parent.Name] + ) + end + + it 'preserves the brackets already stored by Studio Pro in an XPath constraint' do + compiler = described_class.new(instance_double(Mxrb::Compiler::SourceModel)) + constants = compiler.send( + :constants, 'Demo.Home', widget, '[Demo.Item_Parent = $Parent][Active]', 'Demo.Item' + ) + expect(constants['XPath']).to eq('//Demo.Item[Demo.Item_Parent = $Parent][Active]') + end + + it 'binds object page parameters referenced by an XPath constraint' do + constrained = widget + constrained.dig('Object', 'DataSource')['XPathConstraint'] = '[Demo.Item_Parent = $Parent]' + page = unit(module_name: 'Demo', document: { + '$Type' => 'Forms$Page', 'Name' => 'Home', 'Widgets' => [constrained], + 'Parameters' => [2, { + '$Type' => 'Forms$PageParameter', 'Name' => 'Parent', + 'ParameterType' => { '$Type' => 'DataTypes$ObjectType', 'Entity' => 'Demo.Parent' } + }] + }) + source = instance_double(Mxrb::Compiler::SourceModel) + allow(source).to receive(:units_of).with('Forms$Page').and_return([page]) + allow(source).to receive(:documents).with('Security$ProjectSecurity').and_return([]) + + expect(described_class.new(source).send(:page_operations, page)).to contain_exactly( + include( + 'operationType' => 'retrieve', 'parameters' => { 'Parent' => ['Demo.Parent'] }, + 'constants' => include('XPath' => '//Demo.Item[Demo.Item_Parent = $Parent]') + ) + ) + end + + it 'rejects missing and non-object XPath page parameters' do + compiler = described_class.new(instance_double(Mxrb::Compiler::SourceModel)) + expect(compiler.send(:object_page_parameters, nil, ['Missing'])).to be_nil + expect(compiler.send(:page_parameter_types, nil)).to eq({}) + expect(compiler.send(:page_parameter_types, 'Parameters' => [2, 'invalid'])).to eq({}) + expect(compiler.send(:object_parameter_entity, nil)).to be_nil + expect(compiler.send(:object_parameter_entity, + '$Type' => 'DataTypes$ObjectType', 'Entity' => '')).to be_nil + data_source = instance_double(Mxrb::Compiler::WebListDataSource, + xpath_constraint: '[Parent = $Missing]') + 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 gallery = { '$Type' => 'CustomWidgets$CustomWidget', 'Name' => 'gallery', @@ -125,6 +189,55 @@ def widget(source: true) )) end + it 'registers button and clickable-container microflow operations with object parameters' do + action = { + '$Type' => 'Forms$MicroflowAction', + 'MicroflowSettings' => { 'Microflow' => 'Demo.Update', 'ParameterMappings' => [2] } + } + page = unit(module_name: 'Demo', document: { + 'Name' => 'Edit', 'Widgets' => [ + { '$Type' => 'Forms$ActionButton', 'Name' => 'save', 'Action' => action }, + { '$Type' => 'Forms$DivContainer', 'Name' => 'card', 'OnClickAction' => action } + ] + }) + flow = unit(module_name: 'Demo', document: { + 'Name' => 'Update', 'ObjectCollection' => { 'Objects' => [2, { + '$Type' => 'Microflows$MicroflowParameter', 'Name' => 'Item', + 'VariableType' => { '$Type' => 'DataTypes$ObjectType', 'Entity' => 'Demo.Item' } + }] } + }) + source = instance_double(Mxrb::Compiler::SourceModel) + allow(source).to receive(:units_of).with('Forms$Page').and_return([page]) + allow(source).to receive(:units_of).with('Microflows$Microflow').and_return([flow]) + allow(source).to receive(:documents).with('Security$ProjectSecurity').and_return([]) + + operations = described_class.new(source).send(:page_operations, page) + expect(operations.map { _1['operationId'] }).to contain_exactly( + described_class.operation_id('Demo.Edit', 'save'), + described_class.operation_id('Demo.Edit', 'card') + ) + expect(operations).to all(include( + 'operationType' => 'callMicroflow', + 'parameters' => { 'Item' => ['Demo.Item'] }, + 'constants' => { 'MicroflowName' => 'Demo.Update' } + )) + + compiler = described_class.new(source) + expect(compiler.send(:microflow_action_operation, 'Demo.Edit', {}, {}, [])).to be_nil + expect(compiler.send( + :microflow_action_operation, 'Demo.Edit', {}, + { '$Type' => 'Forms$MicroflowAction', 'MicroflowSettings' => {} }, [] + )).to be_nil + expect(compiler.send(:microflow_parameters, 'Demo.Missing')).to be_nil + expect(compiler.send( + :microflow_action_operation, 'Demo.Edit', {}, + { '$Type' => 'Forms$MicroflowAction', + 'MicroflowSettings' => { 'Microflow' => 'Demo.Missing' } }, [] + )).to be_nil + flow.document['ObjectCollection']['Objects'][1]['VariableType'] = { '$Type' => 'DataTypes$StringType' } + expect(compiler.send(:microflow_parameters, 'Demo.Update')).to be_nil + end + it 'maps page module roles to the project user roles allowed by the Runtime' do page = unit(module_name: 'Demo', document: { 'Name' => 'Edit', 'AllowedModuleRoles' => [1, 'Demo.Editor'] diff --git a/spec/web_shell_materializer_spec.rb b/spec/web_shell_materializer_spec.rb index 729b32d..e004deb 100644 --- a/spec/web_shell_materializer_spec.rb +++ b/spec/web_shell_materializer_spec.rb @@ -32,6 +32,7 @@ expect(File.read(File.join(web, 'js/login_i18n.js'))).to include('window.i18nMap', 'http401') expect(File.read(File.join(web, 'lib/bootstrap/css/bootstrap.min.css'))) .to include('.form-control', '.btn-primary') + expect(File).to exist(File.join(web, 'dist/widgets.css')) expect(described_class.new(web, version: '11.12.1').send( :inject_navigation_compatibility, 'no head' )).to eq('no head')