diff --git a/app/controllers/ops_controller/ops_rbac.rb b/app/controllers/ops_controller/ops_rbac.rb index 3c781ff7b9e2..526329815dc6 100644 --- a/app/controllers/ops_controller/ops_rbac.rb +++ b/app/controllers/ops_controller/ops_rbac.rb @@ -958,13 +958,20 @@ def rbac_group_right_tree(selected) def rbac_role_get_details(id) @edit = nil @record = @role = MiqUserRole.find_by_id(from_cid(id)) - @role_features = @role.feature_identifiers.sort - @features_tree = rbac_build_features_tree + @rbac_menu_tree = build_rbac_menu_tree end - def rbac_build_features_tree + def build_rbac_menu_tree @role = @sb[:typ] == "copy" ? @record.dup : @record if @role.nil? # if on edit screen use @record - TreeBuilder.convert_bs_tree(OpsController::RbacTree.build(@role, @role_features, !@edit.nil?)).to_json + + TreeBuilderOpsRbacMenu.new( + "features_tree", + "features", + @sb, + true, + role: @role, + editable: @edit.present? + ) end # Set form variables for role edit @@ -1166,7 +1173,7 @@ def rbac_role_set_form_vars @edit[:current] = copy_hash(@edit[:new]) @role_features = @record.feature_identifiers.sort - @features_tree = rbac_build_features_tree + @rbac_menu_tree = build_rbac_menu_tree end # Get array of total set of features from the children of selected features diff --git a/app/presenters/menu/item.rb b/app/presenters/menu/item.rb index be2536dc204f..3eda7233457b 100644 --- a/app/presenters/menu/item.rb +++ b/app/presenters/menu/item.rb @@ -1,5 +1,15 @@ module Menu Item = Struct.new(:id, :name, :feature, :rbac_feature, :href, :type) do + extend ActiveModel::Naming + + def self.base_class + Menu::Item + end + + def self.base_model + model_name + end + def initialize(an_id, a_name, features, rbac_feature, href, type = :default) super @parent = nil diff --git a/app/presenters/menu/manager.rb b/app/presenters/menu/manager.rb index ad05ea3530bb..f435b306ae23 100644 --- a/app/presenters/menu/manager.rb +++ b/app/presenters/menu/manager.rb @@ -1,11 +1,17 @@ module Menu class Manager + include Enumerable include Singleton class << self extend Forwardable - delegate %i(menu item_in_section? item section section_id_string_to_symbol each) => :instance + delegate %i(menu item_in_section? item section section_id_string_to_symbol + each map detect select) => :instance + end + + def each + @menu.map { |section| yield section } end private @@ -39,10 +45,6 @@ def item_in_section?(item_id, section_id) @id_to_section[section_id].contains_item_id?(item_id) end - def each - @menu.each { |section| yield section } - end - def initialize load_default_items load_custom_items diff --git a/app/presenters/menu/section.rb b/app/presenters/menu/section.rb index a13129fa7ab3..e2c921df7f86 100644 --- a/app/presenters/menu/section.rb +++ b/app/presenters/menu/section.rb @@ -1,5 +1,15 @@ module Menu Section = Struct.new(:id, :name, :icon, :items, :placement, :before, :type, :href) do + extend ActiveModel::Naming + + def self.base_class + Menu::Section + end + + def self.base_model + model_name + end + def initialize(an_id, name, icon, *args) super self.items ||= [] @@ -17,7 +27,7 @@ def features end def features_recursive - Array(items).collect { |el| el.try(:feature) || el.try(:features) }.flatten.compact + Array(items).flat_map { |el| el.try(:feature) || el.try(:features) }.compact end def visible? @@ -84,5 +94,9 @@ def parent_path(acc = []) acc << id @parent.present? ? @parent.parent_path(acc) : acc end + + def load_children? + true + end end end diff --git a/app/presenters/tree_builder.rb b/app/presenters/tree_builder.rb index c0ca15df2b49..083ed4b6ff5f 100644 --- a/app/presenters/tree_builder.rb +++ b/app/presenters/tree_builder.rb @@ -257,9 +257,15 @@ def x_build_node(object, pid, options) node = x_build_single_node(object, pid, options) # Process the node's children + load_children = if object.kind_of?(Struct) + object.respond_to?(:load_children?) && object.load_children? + else + object[:load_children] + end + node[:expand] = Array(@tree_state.x_tree(@name)[:open_nodes]).include?(node[:key]) || !!options[:open_all] || node[:expand] if ancestry_kids || - object[:load_children] || + load_children || node[:expand] || @options[:lazy] == false diff --git a/app/presenters/tree_builder_ops_rbac_menu.rb b/app/presenters/tree_builder_ops_rbac_menu.rb new file mode 100644 index 000000000000..90fa05eecb4f --- /dev/null +++ b/app/presenters/tree_builder_ops_rbac_menu.rb @@ -0,0 +1,183 @@ +class TreeBuilderOpsRbacMenu < TreeBuilder + include CompressedIds + + has_kids_for Menu::Section, [:x_get_tree_section_kids] + has_kids_for Menu::Item, [:x_get_tree_item_kids] + has_kids_for MiqProductFeature, [:x_get_tree_feature_kids] + + attr_reader :role, :features, :editable, :root_counter + + def initialize(name, type, sandbox, build, role:, editable: false) + @role = role + @editable = editable + @features = @role.miq_product_features.order(:identifier).pluck(:identifier) + + @root_counter = [] + + super(name, type, sandbox, build) + end + + private + + def filtered_menus + menus = Menu::Manager.map do |section| + next if section.id == :cons && !Settings.product.consumption + next if section.name.nil? + next unless Vmdb::PermissionStores.instance.can?(section.id) + + section + end + + menus + end + + def set_locals_for_render + locals = { + :checkboxes => true, + :three_checks => true, + :check_url => "/ops/rbac_role_field_changed/" + } + + if editable + locals[:oncheck] = "miqOnCheckHandler" + end + + super.merge!(locals) + end + + def x_get_tree_roots(count_only = false, _options) + top_nodes = filtered_menus + top_nodes << MiqProductFeature.find_by_identifier("all_vm_rules") + + count_only_or_objects(count_only, top_nodes) + end + + def x_get_tree_hash_kids(parent, count_only = false) + count_only_or_objects(count_only, parent[:data][:kids]) + end + + def x_get_tree_section_kids(parent, count_only = false) + kids = parent.items.reject do |item| + if item.kind_of?(Menu::Item) + item.feature.nil? || !MiqProductFeature.feature_exists?(item.feature) + end + end + + count_only_or_objects(count_only, kids) + end + + # FIXME: This is inefficient, but done to use MiqProductFeature objects rather than hashes + def x_get_tree_item_kids(parent, count_only = false) + parent_feature = all_features.find_by(:identifier => parent.feature) + + if parent_feature + details = MiqProductFeature.features[parent.feature] + + kids = parent_feature.children.where(:hidden => [false, nil]).sort_by do |c| + details[:children].index(c.identifier) + end + + count_only_or_objects(count_only, kids) + else + [] + end + end + + def x_get_tree_feature_kids(parent, count_only = false) + count_only_or_objects(count_only, parent.children) + end + + def tree_init_options(_tree_name) + { + :lazy => false, + :add_root => true, + :role => role, + :features => features, + :editable => editable, + :node_id_prefix => node_id_prefix + } + end + + def root_options + root_node + end + + def root_node + @root_node ||= { + :key => root_key, + :title => root_title, + :tooltip => root_tooltip, + :expand => true, + :cfmeNoClick => true, + :select => root_select_state, + :checkable => editable + } + end + + def root_details + @root_details ||= MiqProductFeature.feature_details(root_feature) + end + + def root_tooltip + _(root_details[:description]) || _(root_details[:name]) + end + + def root_title + _(root_details[:name]) + end + + def root_key + "#{node_id_prefix}__#{root_feature}" + end + + def root_select_state + features.include?(root_feature) || select_state_from_counter + end + + def select_state_from_counter + return false if root_counter.empty? + return true if root_counter.all? { |n| n } + return 'undefined' if root_counter.any? { |n| n || n == 'undefined' } + + false + end + + def node_id_prefix + role.id ? to_cid(role.id) : "new" + end + + def root_feature + @root_feature ||= MiqProductFeature.feature_root + end + + def all_vm_options + text = _("Access Rules for all Virtual Machines") + checked = features.include?("all_vm_rules") || root_select_state + + { + :key => "#{node_id_prefix}___tab_all_vm_rules", + :title => text, + :tooltip => text, + :select => checked + } + end + + def all_features + @all_features ||= MiqProductFeature.where(:hidden => [false, nil]).load + end + + def add_to_root_counter(node) + root_counter << node[:select] + end + + def override(node, object, _, _) + case object + when Menu::Section + add_to_root_counter(node) + when MiqProductFeature + if object.identifier == "all_vm_rules" + node.merge!(all_vm_options) + end + end + end +end diff --git a/app/presenters/tree_node/menu/item.rb b/app/presenters/tree_node/menu/item.rb new file mode 100644 index 000000000000..8c62efaa612c --- /dev/null +++ b/app/presenters/tree_node/menu/item.rb @@ -0,0 +1,73 @@ +module TreeNode + module Menu + class Item < Node + set_attribute(:key) { "#{@options[:node_id_prefix]}__#{@object.feature}" } + + set_attribute(:title) { _(details[:name]) } + + set_attribute(:tooltip) { _(details[:description]) || _(details[:name]) } + + set_attribute(:image, "100/feature_node") + + set_attribute(:checkable) { @options[:editable] } + + set_attribute(:selected) do + self_selected? || select_by_kids_selected + end + + set_attribute(:no_click, true) + + def selected? + self_selected? || any_children_selected? + end + + def self_selected? + @options[:features].include?(@object.feature) + end + + def fully_selected? + self_selected? + end + + def partially_selected? + any_children_selected? + end + + private + + def any_children_selected? + feature_children.any? do |feature| + @options[:features].include?(feature) + end + end + + def all_children_selected? + feature_children.all? do |feature| + @options[:features].include?(feature) + end + end + + def select_by_kids_selected + return if feature_children.none? + + if all_children_selected? + return true + end + + if any_children_selected? + return 'undefined' + end + + false + end + + def feature_children + ::MiqProductFeature.feature_children(@object.feature) + end + + def details + @details ||= ::MiqProductFeature.feature_details(@object.feature) + end + end + end +end diff --git a/app/presenters/tree_node/menu/section.rb b/app/presenters/tree_node/menu/section.rb new file mode 100644 index 000000000000..836370e6f0ea --- /dev/null +++ b/app/presenters/tree_node/menu/section.rb @@ -0,0 +1,71 @@ +module TreeNode + module Menu + class Section < Node + set_attribute(:key) do + "#{@options[:node_id_prefix]}___tab_#{@object.id}" + end + + set_attribute(:title) { _(@object.name) } + + set_attribute(:image) { "100/feature_node" } + + set_attribute(:checkable) { @options[:editable] } + + set_attribute(:selected) do + select_state_from_descendents + end + + set_attribute(:tooltip) { _("%{title} Main Tab") % {:title => @object.name} } + + set_attribute(:no_click, true) + + private + + def select_state_from_descendents + return false if children.none? + + return true if all_selected? + + return 'undefined' if any_selected? + end + + def all_selected? + return false if children.empty? + + children.all? do |child| + child_selected?(child) + end + end + + def any_selected? + return false if children.empty? + + children.any? do |child| + child_selected?(child) + end + end + + def children + @children ||= begin + @object.items.flat_map { |i| collect_children(i) } + end + end + + def collect_children(item) + if item.kind_of?(::Menu::Section) + item.items.flat_map { |i| collect_children(i) }.unshift(item) + else + item + end + end + + def child_selected?(child) + if child.kind_of?(::Menu::Section) + child.items.any? { |item| child_selected?(item) } + else # assume Menu::Item + @options[:features].include?(child.feature) + end + end + end + end +end diff --git a/app/presenters/tree_node/miq_product_feature.rb b/app/presenters/tree_node/miq_product_feature.rb new file mode 100644 index 000000000000..b774acc2d5fc --- /dev/null +++ b/app/presenters/tree_node/miq_product_feature.rb @@ -0,0 +1,83 @@ +module TreeNode + class MiqProductFeature < Node + set_attribute(:key) { "#{@options[:node_id_prefix]}__#{@object.identifier}" } + + set_attribute(:title) { _(@object.name) } + + set_attribute(:tooltip) { _(@object.description) || _(@object.name) } + + set_attribute(:image) { "100/feature_#{@object.feature_type}.png" } + + set_attribute(:checkable) { @options[:editable] } + + set_attribute(:selected) do + self_selected? || parent_selected? || select_by_kids_selected + end + + set_attribute(:no_click, true) + + def self_selected? + @options[:features].include?(identifier_without_accords) + end + + def fully_selected? + self_selected? || all_children_selected? + end + + def partially_selected? + any_children_selected? + end + + private + + def identifier_without_accords + @object.identifier.sub(/_accords$/, '') + end + + def parent + ::Menu::Manager.item(@parent_id.split('_').last) + end + + def select_by_kids_selected + return true if all_children_selected? + return 'undefined' if any_children_selected? + + false + end + + def parent_selected? + if parent.nil? + # not a Menu::Item, try something else. + @object.ancestors.pluck(:identifier).any? do |feature| + @options[:features].include?(feature) + end + else + TreeNode::Menu::Item.new(parent, nil, @options).self_selected? + end + end + + def any_children_selected? + return false if @object.children.none? + + child_nodes.any? do |node| + node.self_selected? || node.partially_selected? + end + end + + def all_children_selected? + return false if @object.children.none? + + child_nodes.all? do |node| + node.self_selected? || node.fully_selected? + end + end + + def child_nodes + @child_nodes ||= begin + @object.children.map do |child| + TreeNode::MiqProductFeature.new(child, key, @options) + end + end + end + end +end diff --git a/app/presenters/tree_node/node.rb b/app/presenters/tree_node/node.rb index 682c50ddf93b..2bb953ac59f6 100644 --- a/app/presenters/tree_node/node.rb +++ b/app/presenters/tree_node/node.rb @@ -129,7 +129,7 @@ def to_h :checkable => checkable ? nil : false, } - node[:image] = if !image + node[:image] = if !respond_to?(:image) || !image nil elsif image.start_with?("/") image diff --git a/app/views/ops/_rbac_role_details.html.haml b/app/views/ops/_rbac_role_details.html.haml index ba6ebf264326..33d7c50e42f5 100644 --- a/app/views/ops/_rbac_role_details.html.haml +++ b/app/views/ops/_rbac_role_details.html.haml @@ -2,72 +2,62 @@ - url = url_for(:action => 'rbac_role_field_changed', :id => "#{@edit[:role_id] || "new"}") = render :partial => "layouts/flash_msg" -#main_div - .row - .col-md-12.col-lg-6 - %h3 - = _("Role Information") - .form-horizontal - .form-group - %label.col-md-4.control-label - = _("Name") - .col-md-8 - - if !@edit - %p.form-control-static - = h(@role.name) + +.row + .col-md-12.col-lg-6 + %h3 + = _("Role Information") + .form-horizontal + .form-group + %label.col-md-4.control-label + = _("Name") + .col-md-8 + - if !@edit + %p.form-control-static + = h(@role.name) + - else + = text_field_tag("name", + @edit[:new][:name], + :maxlength => 50, + :class => "form-control", + "data-miq_observe" => {:interval => '.5', :url => url}.to_json) + = javascript_tag(javascript_focus('name')) + .form-group + %label.col-md-4.control-label + = _('Access Restriction for Services, VMs, and Templates') + .col-md-8 + - if !@edit + - if @role.settings.kind_of?(Hash) && @role.settings.fetch_path(:restrictions, :vms) + = h(MiqUserRole::RESTRICTIONS[@role.settings.fetch_path(:restrictions, :vms)]) - else - = text_field_tag("name", - @edit[:new][:name], - :maxlength => 50, - :class => "form-control", - "data-miq_observe" => {:interval => '.5', :url => url}.to_json) - = javascript_tag(javascript_focus('name')) + = _("None") + - else + = select_tag('vm_restriction', + options_for_select([[_("None"), _("none")]] + MiqUserRole::RESTRICTIONS.invert.sort_by { |name, _value| name.downcase }, + @edit[:new][:vm_restriction].to_sym), + :class => "selectpicker") + :javascript + miqInitSelectPicker(); + miqSelectPickerEvent('vm_restriction', "#{url}") + - unless @edit .form-group %label.col-md-4.control-label - = _('Access Restriction for Services, VMs, and Templates') + = _("Groups Using this Role") .col-md-8 - - if !@edit - - if @role.settings.kind_of?(Hash) && @role.settings.fetch_path(:restrictions, :vms) - = h(MiqUserRole::RESTRICTIONS[@role.settings.fetch_path(:restrictions, :vms)]) + - @role.miq_groups.sort_by { |a| a.description.downcase }.each do |g| + - if role_allows?(:feature => "rbac_group_show") + - params = {:class => "pointer", + :onclick => "miqTreeActivateNode('rbac_tree', 'g-#{to_cid(g.id)}');", + :title => _("View this Group")} - else - = _("None") - - else - = select_tag('vm_restriction', - options_for_select([[_("None"), _("none")]] + MiqUserRole::RESTRICTIONS.invert.sort_by { |name, _value| name.downcase }, - @edit[:new][:vm_restriction].to_sym), - :class => "selectpicker") - :javascript - miqInitSelectPicker(); - miqSelectPickerEvent('vm_restriction', "#{url}") - - unless @edit - .form-group - %label.col-md-4.control-label - = _("Groups Using this Role") - .col-md-8 - - @role.miq_groups.sort_by { |a| a.description.downcase }.each do |g| - - if role_allows?(:feature => "rbac_group_show") - - params = {:class => "pointer", - :onclick => "miqTreeActivateNode('rbac_tree', 'g-#{to_cid(g.id)}');", - :title => _("View this Group")} - - else - - params = {} - %i.product.product-group{params} - = link_to(g.description, "#", params) - .col-md-12.col-lg-6 - %hr - - if @edit - = _("Product Features (Editing)") - - else - = _("Product Features (Read Only)") - %h3 - #features_treebox.treeview-pf-hover.treeview-pf-select{:style => "width:100%;height:100%;color:#000"} - = render(:partial => "layouts/tree", - :locals => {:tree_id => "features_treebox", - :tree_name => "features_tree", - :bs_tree => @features_tree, - :checkboxes => true, - :three_checks => true, - :check_url => "/ops/rbac_role_field_changed/", - :oncheck => @edit.nil? ? nil : "miqOnCheckHandler"}) -   * - = _("Double click a feature to open/close all children.") + - params = {} + %i.product.product-group{params} + = link_to(g.description, "#", params) + .col-md-12.col-lg-6 + %hr + - if @edit + = _("Product Features (Editing)") + - else + = _("Product Features (Read Only)") + %h3 + = render(:partial => "shared/tree", :locals => {:tree => @rbac_menu_tree, :name => @rbac_menu_tree.name}) diff --git a/spec/presenters/tree_builder_ops_rbac_menu_spec.rb b/spec/presenters/tree_builder_ops_rbac_menu_spec.rb new file mode 100644 index 000000000000..72ee60b1ba69 --- /dev/null +++ b/spec/presenters/tree_builder_ops_rbac_menu_spec.rb @@ -0,0 +1,63 @@ +describe TreeBuilderOpsRbacMenu do + let(:features) do + %w{ + all_vm_rules + instance + instance_view + instance_show_list + instance_control + instance_scan + } + end + + let(:cid) { ApplicationRecord.uncompress_id("10r2") } + + let(:role) do + FactoryGirl.create(:miq_user_role, :id => cid, :features => features) + end + + let(:tree) do + TreeBuilderOpsRbacMenu.new( + "features_tree", + "features", + {}, + true, + role: role, + editable: false + ) + end + + let(:main_keys) { bs_tree.first["nodes"].map{|n| n['key']} } + + describe 'bs_tree' do + subject(:bs_tree) { JSON.parse(tree.locals_for_render[:bs_tree]) } + + it 'builds the bs_tree' do + t = bs_tree.first + + expect(t['key']).to match(/all_vm_rules/) + expect(t['title']).to be_nil + expect(t['tooltip']).to be_nil + expect(t['checkable']).to eq(false) + end + # + it 'includes main sections' do + expect(main_keys).to include("xx-10r2___tab_aut") + expect(main_keys).to include("xx-10r2___tab_compute") + expect(main_keys).to include("xx-10r2___tab_con") + expect(main_keys).to include("xx-10r2___tab_conf") + expect(main_keys).to include("xx-10r2___tab_mdl") + expect(main_keys).to include("xx-10r2___tab_net") + expect(main_keys).to include("xx-10r2___tab_opt") + expect(main_keys).to include("xx-10r2___tab_set") + expect(main_keys).to include("xx-10r2___tab_sto") + expect(main_keys).to include("xx-10r2___tab_svc") + expect(main_keys).to include("xx-10r2___tab_vi") + end + + it 'does not include blank nodes' do + expect(main_keys).not_to include("xx-10r2__") + expect(main_keys).not_to include("xx-10r2___tab_") + end + end +end