diff --git a/src/lib/y2configuration_management/salt/form.rb b/src/lib/y2configuration_management/salt/form.rb index 88a7e898..c2fb3650 100644 --- a/src/lib/y2configuration_management/salt/form.rb +++ b/src/lib/y2configuration_management/salt/form.rb @@ -80,6 +80,7 @@ def find_element_by(arg) # # scalar values, groups and collections class FormElement + include FormElementHelpers # @return [String] the key for the pillar attr_reader :id # @return [Symbol] @@ -104,7 +105,7 @@ class FormElement def initialize(id, spec, parent:) @id = id @name = spec.fetch("$name", humanize(id)) - @type = spec.fetch("$type", "text").to_sym + @type = type_for(spec) @help = spec["$help"] if spec ["$help"] @scope = spec.fetch("$scope", "system").to_sym @optional = spec["$optional"] if spec["$optional"] @@ -129,6 +130,18 @@ def locator def humanize(s) s.split(/[-_]/).reject(&:empty?).map(&:capitalize).join(" ") end + + # Returns the type for a given form element specification + # + # @param spec [Hash] Form element specification + # @return [Symbol] Form element type + def type_for(spec) + if spec["$type"] == "text" && spec.key?("$key") && form_elements_in(spec).size <= 1 + :key_value + else + spec.fetch("$type", "text").to_sym + end + end end # Scalar value FormElement @@ -177,8 +190,6 @@ def collection_key? # Container Element class Container < FormElement - include FormElementHelpers - # @return [Array] attr_reader :elements diff --git a/src/lib/y2configuration_management/salt/form_builder.rb b/src/lib/y2configuration_management/salt/form_builder.rb index 9db03040..bdc7f89b 100644 --- a/src/lib/y2configuration_management/salt/form_builder.rb +++ b/src/lib/y2configuration_management/salt/form_builder.rb @@ -27,17 +27,18 @@ module Salt # https://www.suse.com/documentation/suse-manager-3/3.2/susemanager-best-practices/html/book.suma.best.practices/best.practice.salt.formulas.and.forms.html class FormBuilder INPUT_WIDGET_CLASS = { - color: Y2ConfigurationManagement::Widgets::Color, - text: Y2ConfigurationManagement::Widgets::Text, - number: Y2ConfigurationManagement::Widgets::Text, - email: Y2ConfigurationManagement::Widgets::Email, - password: Y2ConfigurationManagement::Widgets::Password, - url: Y2ConfigurationManagement::Widgets::URL, - select: Y2ConfigurationManagement::Widgets::Select, - boolean: Y2ConfigurationManagement::Widgets::Boolean, - date: Y2ConfigurationManagement::Widgets::Date, - datetime: Y2ConfigurationManagement::Widgets::DateTime, - time: Y2ConfigurationManagement::Widgets::Time + color: Y2ConfigurationManagement::Widgets::Color, + text: Y2ConfigurationManagement::Widgets::Text, + number: Y2ConfigurationManagement::Widgets::Text, + email: Y2ConfigurationManagement::Widgets::Email, + password: Y2ConfigurationManagement::Widgets::Password, + url: Y2ConfigurationManagement::Widgets::URL, + select: Y2ConfigurationManagement::Widgets::Select, + boolean: Y2ConfigurationManagement::Widgets::Boolean, + date: Y2ConfigurationManagement::Widgets::Date, + datetime: Y2ConfigurationManagement::Widgets::DateTime, + time: Y2ConfigurationManagement::Widgets::Time, + key_value: Y2ConfigurationManagement::Widgets::KeyValue }.freeze # Constructor diff --git a/src/lib/y2configuration_management/salt/form_data.rb b/src/lib/y2configuration_management/salt/form_data.rb index d91a9f69..d4de70fe 100644 --- a/src/lib/y2configuration_management/salt/form_data.rb +++ b/src/lib/y2configuration_management/salt/form_data.rb @@ -179,7 +179,8 @@ def hash_collection_for_pillar(collection) collection.reduce({}) do |all, item| new_item = item.clone key = new_item.delete("$key") - all.merge(key => data_for_pillar(new_item)) + val = new_item.delete("$value") || data_for_pillar(new_item) + all.merge(key => val) end end diff --git a/src/lib/y2configuration_management/salt/form_data_reader.rb b/src/lib/y2configuration_management/salt/form_data_reader.rb index 0e58efa3..fff790e2 100644 --- a/src/lib/y2configuration_management/salt/form_data_reader.rb +++ b/src/lib/y2configuration_management/salt/form_data_reader.rb @@ -29,7 +29,37 @@ module Salt # The format used in the pillar is slightly different from the internal representation of this # module, so this class takes care of the conversion. However, the initial intentation is to not # use it directly but through the {FormDate.from_pillar} class method. + # + # ## Handling collections + # + # There might be different kind of collections: + # + # * Array with simple values (strings, integers, etc). + # * Array of hashes. They allow a more complex collection. + # * Hash based collections which index is provided by the user. + # + # To simplify things in the UI layer, all collections are handled as arrays so, in the third + # case, some conversion is needed. Given an specification with three fields `$key`, `url`, + # and `license`, the collection would be stored in the Pillar like this: + # + # { "yast2" => + # { "url" => "https://yast.opensuse.org", "license" => "GPL" } + # } + # + # Internally, it will be handled as an array: + # + # [{ "$key" => "yast2", "url" => "https://yast.opensuse.org", "license" => "GPL" }] + # + # Something similar applies to hash based simple collections which are originally like this (it + # is a hash where the keys are specified by the user): + # + # { "vers" => "4", "timeout" => "0" } + # + # It will be converted to: + # + # [{ "$key" => "vers", "$value" => "4" }, { "$key" => "timeout", "$value" => "0" }] class FormDataReader + include Yast::Logger # @return [Form] Form definition attr_reader :form # @return [Pillar] @@ -48,57 +78,104 @@ def initialize(form, pillar) # # @return [FormData] Form data object def form_data - data_from_pillar = data_for_element(form.root, pillar.data) - FormData.new(form, data_from_pillar) + data_from_pillar = { "root" => hash_from_pillar(pillar.data, form.root.locator) } + defaults = defaults_for_element(form.root) + FormData.new(form, simple_merge(defaults, data_from_pillar)) end private - # Builds a hash to keep the form element data + # Extracts data from the pillar # - # @param element [Y2ConfigurationManagement::Salt::FormElement] - # @param data [Hash] Pillar data - # @return [Hash] - def data_for_element(element, data) - if element.is_a?(Container) - defaults = element.elements.reduce({}) { |a, e| a.merge(data_for_element(e, data)) } - { element.id => defaults } + # @param data [Hash] Pillar data + # @param locator [FormElementLocator] Locator + # @return [Hash] + def data_from_pillar(data, locator) + element = form.find_element_by(locator: locator.unbounded) + case element + when Collection + collection_from_pillar(data, locator) + when Container + hash_from_pillar(data, locator) else - value = find_in_pillar_data(data, element.locator.rest) # FIXME: remove '.root' - value ||= element.default - { element.id => data_from_pillar_collection(value, element) } + data end end - # Converts from a Pillar collection into form data + # Reads a hash from the pillar for a given locator # - # Basically, a collection might be an array or a hash. The internal representation, however, - # is always an array, so it is needed to do the conversion. + # @param data [Hash] Pillar data + # @param locator [FormElementLocator] Element locator + # @return [Hash] + def hash_from_pillar(data, locator) + data.reduce({}) do |all, (k, v)| + all.merge(k => data_from_pillar(v, locator.join(k.to_sym))) + end + end + + # Converts a collection from the pillar # - # @param element [Y2ConfigurationManagement::Salt::FormElement] - # @param value [Array,Hash] - # @return - def data_from_pillar_collection(collection, element) - return nil if collection.nil? - return collection unless element.respond_to?(:keyed?) && element.keyed? - collection.map do |k, v| - { "$key" => k }.merge(v) + # @param data [Hash] Pillar data + # @param locator [FormElementLocator] Element locator + # @return [Array] + def collection_from_pillar(data, locator) + element = form.find_element_by(locator: locator.unbounded) + if element.keyed? + data.map { |k, v| { "$key" => k }.merge(hash_from_pillar(v, locator.join(k))) } + elsif element.prototype.is_a?(FormInput) + if element.prototype.type == :key_value + data.map { |k, v| { "$key" => k, "$value" => v } } + else + data + end + else + data.map { |d| hash_from_pillar(d, locator) } + end + end + + # Extracts default values for a given element + # + # @param element [FormElement] + # @return [Object] + def defaults_for_element(element) + case element + when Container + defaults = element.elements.reduce({}) { |a, e| a.merge(defaults_for_element(e)) } + { element.id => defaults } + when Collection + { element.id => defaults_for_collection(element) } + else + { element.id => element.default } end end - # Finds a value within a Pillar + # Extracts default values for a given collection # - # @todo This API might be available through the Pillar class. + # @param collection [Collection] + # @return [Array] + def defaults_for_collection(collection) + if collection.keyed? + collection.default.map { |k, v| { "$key" => k }.merge(v) } + elsif collection.prototype.is_a?(FormInput) && collection.prototype.type == :key_value + collection.default.map { |k, v| { "$key" => k, "$value" => v } } + else + collection.default + end + end + + # Simple deep merge # - # @param data [Hash,Array] Data structure from the Pillar - # @param locator [FormElementLocator] Value locator - # @return [Object] Value - def find_in_pillar_data(data, locator) - return nil if data.nil? - return data if locator.first.nil? - key = locator.first - key = key.is_a?(Symbol) ? key.to_s : key - find_in_pillar_data(data[key], locator.rest) + # @param defaults [Hash] Default values + # @param data [Hash] Pillar data + def simple_merge(defaults, data) + defaults.reduce({}) do |all, (k, v)| + next all.merge(k => v) if data[k].nil? + if v.is_a?(Hash) + all.merge(k => simple_merge(defaults[k], data[k])) + else + all.merge(k => data[k]) + end + end end end end diff --git a/src/lib/y2configuration_management/salt/form_element_locator.rb b/src/lib/y2configuration_management/salt/form_element_locator.rb index 55a49d47..55322d7f 100644 --- a/src/lib/y2configuration_management/salt/form_element_locator.rb +++ b/src/lib/y2configuration_management/salt/form_element_locator.rb @@ -143,7 +143,7 @@ def ==(other) # # @return [FormElementLocator] def unbounded - self.class.new(parts.reject { |i| i.is_a?(Integer) }) + self.class.new(parts.select { |i| i.is_a?(Symbol) }) end end end diff --git a/src/lib/y2configuration_management/widgets.rb b/src/lib/y2configuration_management/widgets.rb index bccb90d8..abf9813a 100644 --- a/src/lib/y2configuration_management/widgets.rb +++ b/src/lib/y2configuration_management/widgets.rb @@ -26,6 +26,7 @@ require "y2configuration_management/widgets/email" require "y2configuration_management/widgets/form" require "y2configuration_management/widgets/group" +require "y2configuration_management/widgets/key_value" require "y2configuration_management/widgets/password" require "y2configuration_management/widgets/select" require "y2configuration_management/widgets/text" diff --git a/src/lib/y2configuration_management/widgets/collection.rb b/src/lib/y2configuration_management/widgets/collection.rb index ac3b7ec5..b2df4e0c 100644 --- a/src/lib/y2configuration_management/widgets/collection.rb +++ b/src/lib/y2configuration_management/widgets/collection.rb @@ -129,11 +129,7 @@ def selected_row # @return [Array>] def format_items(items_list) items_list.each_with_index.map do |item, index| - values = if item.is_a? Hash - headers_ids.map { |h| item[h] } - else - [item] - end + values = item.is_a?(Hash) ? format_hash_item(item) : [item] formatted_values = values.map { |v| format_value(v) } Item(Id(index.to_s), *formatted_values) end @@ -167,6 +163,20 @@ def format_value(val) # TRANSLATORS: items count in a list format(n_("%s item", "%s items", val.size), val.size) end + + # Returns a list of the hash values to be shown in the table + # + # When it is a text dictionary it returns a formatted string with the + # $key and the $value + # + # @return [Array] + def format_hash_item(item) + if item.keys == ["$key", "$value"] + ["#{item["$key"]}: #{item["$value"]}"] + else + headers_ids.map { |h| item[h] } + end + end end end end diff --git a/src/lib/y2configuration_management/widgets/key_value.rb b/src/lib/y2configuration_management/widgets/key_value.rb new file mode 100644 index 00000000..b334975d --- /dev/null +++ b/src/lib/y2configuration_management/widgets/key_value.rb @@ -0,0 +1,116 @@ +# encoding: utf-8 +# +# Copyright (c) [2019] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "cwm" +require "y2configuration_management/widgets/base_mixin" + +module Y2ConfigurationManagement + module Widgets + # This class represents a key value field + class KeyValue < ::CWM::CustomWidget + include BaseMixin + + attr_reader :default + + # Constructor + # + # @param spec [Y2ConfigurationManagement::Salt::FormInput] Element specification + def initialize(spec) + textdomain "configuration_management" + + initialize_base(spec) + @default = spec.default + self.widget_id = "key_value:#{spec.id}" + @value = nil + end + + # @see CWM::AbstractWidget + def init + self.value = @value || default + end + + # @see CWM::AbstractWidget + def contents + VBox( + key_widget, + value_widget + ) + end + + # @see CWM::AbstractWidget + # @return [Hash] + def value + return {} if key_widget.value.to_s.empty? + + { "$key" => key_widget.value, "$value" => value_widget.value } + end + + # @see CWM::AbstractWidget + # @param val [Hash] + def value=(val) + key_widget.value = (val || {}).dig("$key") + value_widget.value = (val || {}).dig("$value") + @value = val + end + + # It returns false and report an error if the $key input is empty + # + # @see CWM::AbstractWidget + # @return [Boolean] true if at least the $key input is not empty + def validate + if key_widget.value.to_s.empty? + # TRANSLATORS: It reports that %s cannot be empty. + Yast::Report.Error(_("%s: cannot be empty.") % label) + return false + end + + true + end + + private + + # Input field for the key/value widget + class KeyValueField < ::CWM::InputField + attr_reader :label + + def initialize(id, label) + @label = label + self.widget_id = "key_value:#{id}" + super() + end + end + + # Widget for the $key field + # + # @return [KeyValueField] + def key_widget + @key ||= KeyValueField.new("#{id}:key", label) + end + + # Widget for the $value field + # + # @return [KeyValueField] + def value_widget + @key_value ||= KeyValueField.new("#{id}:value", _("Value")) + end + end + end +end diff --git a/test/fixtures/form.yml b/test/fixtures/form.yml index 0458899a..a98d9ea9 100644 --- a/test/fixtures/form.yml +++ b/test/fixtures/form.yml @@ -80,3 +80,23 @@ person: "$name": Project name url: "$type": text + properties: + "$type": edit-group + "$minItems": 0 + "$name": Properties + "$default": + license: + GPL + "$prototype": + "$type": text + "$name": Property + "$key": + "$type": text + platforms: + "$type": edit-group + "$minItems": 0 + "$name": Platforms + "$default": + - Linux + "$prototype": + "$type": text diff --git a/test/fixtures/pillar/test-formula.sls b/test/fixtures/pillar/test-formula.sls index fff7bf90..10607e49 100644 --- a/test/fixtures/pillar/test-formula.sls +++ b/test/fixtures/pillar/test-formula.sls @@ -17,3 +17,10 @@ person: disks: - type: HDD size: 1TB + projects: + yast2: + url: https://yast.opensuse.org + properties: + license: GPL-2.0-only + platforms: + - Linux diff --git a/test/spec_helper.rb b/test/spec_helper.rb index 00c8a5fb..7d97715a 100644 --- a/test/spec_helper.rb +++ b/test/spec_helper.rb @@ -50,6 +50,7 @@ RSpec.configure do |config| config.include Y2ConfigurationManagement::TestHelpers + config.include Yast::I18n config.expect_with :rspec do |expectations| # This option will default to `true` in RSpec 4. It makes the `description` diff --git a/test/y2configuration_management/salt/form_data_reader_test.rb b/test/y2configuration_management/salt/form_data_reader_test.rb index e86ae5dd..d5b73979 100644 --- a/test/y2configuration_management/salt/form_data_reader_test.rb +++ b/test/y2configuration_management/salt/form_data_reader_test.rb @@ -55,5 +55,38 @@ expect(form_data.get(locator)).to eq("somebody@example.net") end end + + context "when a hash based collection is given" do + let(:locator) { locator_from_string(".root.person.projects") } + + it "converts it to an array of hashes adding a '$key' key" do + form_data = reader.form_data + projects = form_data.get(locator) + expect(projects).to be_a(Array) + expect(projects[0]).to include("$key" => "yast2") + end + end + + context "when a simple hash based collection is given" do + let(:locator) { locator_from_string(".root.person.projects[0].properties") } + + it "converts it to an array of hashes adding '$key' and '$value' keys" do + form_data = reader.form_data + expect(form_data.get(locator)).to eq( + [ + { "$key" => "license", "$value" => "GPL-2.0-only" } + ] + ) + end + end + + context "when a simple values based collection is given" do + let(:locator) { locator_from_string(".root.person.projects[0].platforms") } + + it "keeps it as an array" do + form_data = reader.form_data + expect(form_data.get(locator)).to eq(["Linux"]) + end + end end end diff --git a/test/y2configuration_management/salt/form_element_locator_test.rb b/test/y2configuration_management/salt/form_element_locator_test.rb index b925d37b..dfc110c0 100644 --- a/test/y2configuration_management/salt/form_element_locator_test.rb +++ b/test/y2configuration_management/salt/form_element_locator_test.rb @@ -72,6 +72,8 @@ end describe "#unbounded" do + let(:locator) { locator_from_string(".root.hosts[1].interfaces[eth0]") } + it "removes specific elements" do expect(locator.unbounded.to_s).to eq(".root.hosts.interfaces") end diff --git a/test/y2configuration_management/widgets/key_value_test.rb b/test/y2configuration_management/widgets/key_value_test.rb new file mode 100755 index 00000000..c88eccf1 --- /dev/null +++ b/test/y2configuration_management/widgets/key_value_test.rb @@ -0,0 +1,156 @@ +#!/usr/bin/env rspec +# Copyright (c) [2019] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../spec_helper" +require "y2configuration_management/widgets/key_value" +require "y2configuration_management/salt/form" +require "cwm/rspec" + +describe Y2ConfigurationManagement::Widgets::KeyValue do + let(:form) { Y2ConfigurationManagement::Salt::Form.new(form_spec) } + let(:form_spec) do + { + "servers" => { + "$type" => "edit-group", + "$prototype" => { + "$type" => "text", + "$key" => { "$type" => "$text" } + } + } + } + end + let(:spec) { form.find_element_by(locator: locator) } + let(:locator) { locator_from_string(".root.servers") } + let(:key_widget) { dictionary.send(:key_widget) } + let(:value_widget) { dictionary.send(:value_widget) } + subject(:dictionary) { described_class.new(spec) } + include_examples "CWM::CustomWidget" + + describe "#init" do + let(:cached_value) { { "$key" => "YaST", "$value" => "Team" } } + + before do + subject.value = cached_value + end + + context "when there is some value cached" do + it "initializes the widget with the cached value" do + expect(subject).to_not receive(:default) + subject.init + end + end + + context "when there is no value cached" do + let(:cached_value) { nil } + + it "initializes the widget with the default" do + expect(subject).to receive(:default) + subject.init + end + end + end + + describe "#contents" do + it "contains a InputFIeld for the $key and $value" do + key_input = subject.contents.nested_find { |i| i.label == subject.label } + value_input = subject.contents.nested_find { |i| i.label == _("Value") } + + expect(key_input).to_not eql(nil) + expect(value_input).to_not eql(nil) + end + end + + describe "#value=" do + let(:value) { { "$key" => "example.com", "$value" => "1.2.3.4" } } + + it "fills the $key input properly" do + expect(key_widget).to receive(:value=).with("example.com") + subject.value = value + end + + it "fills the $value input properly" do + expect(value_widget).to receive(:value=).with("1.2.3.4") + subject.value = value + end + + it "caches the value" do + subject.value = value + expect(subject).to_not receive(:default) + expect(subject).to receive(:value=).with(value) + subject.init + end + + context "when nil or an empty array is given" do + it "resets the value of the $value and $key widgets" do + expect(key_widget).to receive(:value=).with(nil) + expect(value_widget).to receive(:value=).with(nil) + subject.value = {} + end + end + end + + describe "#value" do + let(:key_widget_value) { "YaST" } + let(:value_widget_value) { "team" } + + before do + allow(key_widget).to receive(:value).and_return(key_widget_value) + end + + context "when the $key input is empty" do + let(:key_widget_value) { "" } + + it "returns an empty hash" do + expect(subject.value).to be_a(Hash) + expect(subject.value).to be_empty + end + end + + context "when the $key input is not empty" do + it "returns a hash with $key and $value keys" do + expect(subject.value).to be_a(Hash) + expect(subject.value.keys).to eql(["$key", "$value"]) + end + end + end + + describe "#validate" do + let(:key_widget_value) { "YaST" } + let(:value_widget_value) { "Team" } + + before do + allow(key_widget).to receive(:value).and_return(key_widget_value) + end + + context "when the $key input is empty" do + let(:key_widget_value) { "" } + + it "returns false" do + expect(subject.validate).to eql(false) + end + end + + context "when the $key input is not empty" do + it "returns true" do + expect(subject.validate).to eql(true) + end + end + end +end