diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 63af62a7ca6..ab4f5594876 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base before_action :session_expiry, :update_activity_time, :unless => proc { |c| c.remote_user_provided? || c.api_request? } before_action :set_taxonomy, :require_mail, :check_empty_taxonomy before_action :authorize - before_action :welcome, :only => :index, :unless => :api_request? + before_action :welcome, :find_selected_columns, :only => :index, :unless => :api_request? prepend_before_action :allow_webpack, if: -> { Rails.configuration.webpack.dev_server.enabled } around_action :set_timezone diff --git a/app/controllers/concerns/application_shared.rb b/app/controllers/concerns/application_shared.rb index 88c8aebc423..e6449bfa662 100644 --- a/app/controllers/concerns/application_shared.rb +++ b/app/controllers/concerns/application_shared.rb @@ -140,4 +140,8 @@ def find_session_taxonomy(taxonomy, user) end determined_taxonomy end + + def find_selected_columns + @selected_columns = Foreman::SelectableColumns::Storage.selected_by(User.current, controller_name) + end end diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index 6e7b4206250..9af3fb72a1d 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -33,6 +33,8 @@ class HostsController < ApplicationController before_action :set_host_type, :only => [:update] before_action :find_multiple, :only => MULTIPLE_ACTIONS before_action :validate_power_action, :only => :update_multiple_power_state + # index action is already included in ApplicationController + before_action(:only => SEARCHABLE_ACTIONS.without('index')) { find_selected_columns } helper :hosts, :reports, :interfaces diff --git a/app/helpers/selectable_columns_helper.rb b/app/helpers/selectable_columns_helper.rb new file mode 100644 index 00000000000..9207b02ea0a --- /dev/null +++ b/app/helpers/selectable_columns_helper.rb @@ -0,0 +1,52 @@ +module SelectableColumnsHelper + def render_selected_column_ths + result = "" + @selected_columns.each do |column| + result += render( + 'common/selectable_column_th', + attributes: attributes(column[:th]), + th_content: th_content(column), + callback: column[:th][:callback] + ) + end + result.html_safe + end + + def render_selected_column_tds(record) + result = "" + @selected_columns.each do |column| + result += render( + 'common/selectable_column_td', + attributes: attributes(column[:td]), + attr_callbacks: column[:td][:attr_callbacks], + subject: record, + callback: column[:td][:callback] + ) + end + result.html_safe + end + + def attr_from_callbacks(callbacks, subject) + return unless callbacks + + callbacks.map { |(k, v)| "#{k}=\"#{instance_exec(subject, &v)}\"" } + .join(' ') + .html_safe + end + + private + + def attributes(th_or_td) + th_or_td.except(:callback, :sortable, :default_sort, :attr_callbacks, :label) + .map { |(k, v)| "#{k}=\"#{v}\"" } + .join(' ') + .html_safe + end + + def th_content(col) + return if col[:th][:callback] + return col[:th][:label] unless col[:th][:sortable] + + sort col[:key], as: col[:th][:label], default: col[:th][:default_sort] || 'ASC' + end +end diff --git a/app/registries/foreman/plugin.rb b/app/registries/foreman/plugin.rb index bb82899959d..44b18ef2078 100644 --- a/app/registries/foreman/plugin.rb +++ b/app/registries/foreman/plugin.rb @@ -329,6 +329,10 @@ def settings(&block) SettingManager.define(id, &block) end + def selectable_columns(table, &block) + Foreman::SelectableColumns::Storage.register(table, &block) + end + def security_block(name, &block) @security_block = name instance_eval(&block) diff --git a/app/registries/foreman/selectable_columns/category.rb b/app/registries/foreman/selectable_columns/category.rb new file mode 100644 index 00000000000..55d143663fa --- /dev/null +++ b/app/registries/foreman/selectable_columns/category.rb @@ -0,0 +1,63 @@ +module Foreman + module SelectableColumns + class Category < Array + attr_reader :id, :label + + def initialize(id, label, table, default: false) + @id = id + @label = label + @table = table + @default = default + @columns_to_use = HashWithIndifferentAccess.new + super(0) + end + + def column(key, th:, td:) + self << { key: key.to_s, th: th, td: td }.with_indifferent_access + end + + def use_column(key, from:) + return if from.to_s == @id + + @columns_to_use[from] ||= [] + @columns_to_use[from] << key.to_s + end + + def common_th_class + 'hidden-tablet hidden-xs' + end + + def common_td_class + common_th_class + ' ellipsis' + end + + # This instance can be updated after it was created by DSL, but + # only at the initialization time. + # This method is only for actual runtime usage, + # after the Storage is fully defined/updated. + # Thus, saving once computed result. + def keys + @keys ||= columns.map { |c| c[:key] }.sort + end + + def default? + @default + end + + def columns + self + foreign_columns + end + + private + + # This is meant for actual runtime usage, + # after the categories are fully defined. + # Thus, saving once computed result. + def foreign_columns + @foreign_columns ||= @columns_to_use.keys.map do |category_id| + @table.find { |cat| cat.id == category_id }&.select { |col| @columns_to_use[category_id].include?(col[:key]) } + end.flatten.compact + end + end + end +end diff --git a/app/registries/foreman/selectable_columns/storage.rb b/app/registries/foreman/selectable_columns/storage.rb new file mode 100644 index 00000000000..6ae9cc14bb0 --- /dev/null +++ b/app/registries/foreman/selectable_columns/storage.rb @@ -0,0 +1,57 @@ +module Foreman + module SelectableColumns + class Storage + include Singleton + + class << self + def tables + @tables ||= ActiveSupport::HashWithIndifferentAccess.new + end + + def define(name, &block) + return Foreman::Logging.logger('app').warn _('Table %s is already defined, ignoring.') % name if tables[name] + + table = SelectableColumns::Table.new(name) + table.instance_eval(&block) + tables[name] = table + end + + def register(name, &block) + return Foreman::Logging.logger('app').warn _('Table %s is not defined, ignoring.') % name unless tables[name] + + tables[name].instance_eval(&block) + end + + # This is for UI data mostly + def defined_for(table) + return Foreman::Logging.logger('app').warn _('Table %s is not defined, ignoring.') % table unless tables[table] + + tables[table].map do |category| + { + id: category.id, + name: category.label, + columns: category.columns.map { |c| { id: c[:key], name: c[:th][:label] } }, + } + end + end + + def selected_by(user, table) + return unless tables[table] + + selected_keys = user.table_preferences.find_by(name: table)&.columns&.sort + result = if selected_keys + tables[table].select { |category| (category.keys & selected_keys).any? } + .map(&:columns) + .flatten + .select { |col| selected_keys.include?(col[:key]) } + else + tables[table].select { |category| category.default? } + .map(&:columns) + .flatten + end + result.uniq + end + end + end + end +end diff --git a/app/registries/foreman/selectable_columns/table.rb b/app/registries/foreman/selectable_columns/table.rb new file mode 100644 index 00000000000..0f0bf4e4f6e --- /dev/null +++ b/app/registries/foreman/selectable_columns/table.rb @@ -0,0 +1,28 @@ +module Foreman + module SelectableColumns + class Table < Array + attr_reader :name + + def initialize(name) + @name = name + super(0) + end + + def category(id, label: _('General'), default: false, &block) + category = find_or_create(id.to_s, label, default) + category.instance_eval(&block) + end + + private + + def find_or_create(id, label, default) + category = find { |c| c.id == id } + unless category + category = Category.new(id, label, self, default: default) + self << category + end + category + end + end + end +end diff --git a/app/views/common/_selectable_column_td.html.erb b/app/views/common/_selectable_column_td.html.erb new file mode 100644 index 00000000000..62795a9e9ac --- /dev/null +++ b/app/views/common/_selectable_column_td.html.erb @@ -0,0 +1 @@ + <%= attr_from_callbacks(attr_callbacks, subject) %>><%= instance_exec(subject, &callback) %> diff --git a/app/views/common/_selectable_column_th.html.erb b/app/views/common/_selectable_column_th.html.erb new file mode 100644 index 00000000000..ce2beb43a6e --- /dev/null +++ b/app/views/common/_selectable_column_th.html.erb @@ -0,0 +1 @@ +><%= th_content ? th_content : instance_eval(callback) %> diff --git a/app/views/hosts/_list.html.erb b/app/views/hosts/_list.html.erb index 5918e9a0b1f..60c246d94f2 100644 --- a/app/views/hosts/_list.html.erb +++ b/app/views/hosts/_list.html.erb @@ -7,14 +7,8 @@ <% if power_status_visible? %> <%= _('Power') %> <% end %> - <%= sort :name, :as => _('Name') %> - <%= sort :os_title, :as => _("Operating system") %> - <%= sort :model, :as => _('Model') %> - <%= sort :owner, :as => _('Owner') %> - <%= sort :hostgroup, :as => _("Host group") %> - <%= sort :last_report, :as => _('Last report'), :default => 'DESC' %> + <%= render_selected_column_ths %> <%= render_pagelets_for(:hosts_table_column_header) %> - <%= sort :comment, :as => _('Comment') %> <%= _('Actions') %> @@ -29,15 +23,8 @@ <%= react_component('PowerStatus', id: host.id, url: power_api_host_path(host)) %> <% end %> - <%= name_column(host) %> - - <%= (icon(host.operatingsystem, :size => "16x16") + " #{host.operatingsystem.to_label}").html_safe if host.operatingsystem %> - <%= host.compute_resource_or_model %> - <%= host_owner_column(host) %> - <%= label_with_link host.hostgroup, 23, @hostgroup_authorizer %> - <%= last_report_column(host) %> + <%= render_selected_column_tds(host) %> <%= render_pagelets_for(:hosts_table_column_content, :subject => host) %> - <%= icon_text('comment', '') unless host.comment.empty? %> <%= action_buttons( display_link_if_authorized(_("Edit"), hash_for_edit_host_path(:id => host).merge(:auth_object => host, :authorizer => authorizer)), diff --git a/config/initializers/foreman_register.rb b/config/initializers/foreman_register.rb index 8e8fa9e956e..bba4c30177b 100644 --- a/config/initializers/foreman_register.rb +++ b/config/initializers/foreman_register.rb @@ -4,3 +4,24 @@ partial: 'hosts/init_config_tab', priority: 100 end + +Foreman::SelectableColumns::Storage.define(:hosts) do + category :general, default: true do + column :name, th: { label: _('Name'), sortable: true, width: '25%' }, + td: { class: 'ellipsis', callback: ->(host) { name_column(host) } } + column :os_title, th: { label: _('Operating system'), sortable: true, width: '17%', class: 'hidden-xs' }, + td: { class: 'hidden-xs ellipsis', callback: ->(host) { (icon(host.operatingsystem, size: "16x16") + " #{host.operatingsystem.to_label}").html_safe if host.operatingsystem } } + column :model, th: { label: _('Model'), sortable: true, width: '10%', class: common_th_class }, + td: { class: common_td_class, callback: ->(host) { host.compute_resource_or_model } } + column :owner, th: { label: _('Owner'), sortable: true, width: '8%', class: common_th_class }, + td: { class: common_td_class, callback: ->(host) { host_owner_column(host) } } + column :hostgroup, th: { label: _('Host group'), sortable: true, width: '15%', class: common_th_class }, + td: { class: common_th_class, callback: ->(host) { label_with_link host.hostgroup, 23, @hostgroup_authorizer } } + column :last_report, th: { label: _('Last report'), sortable: true, default_sort: 'DESC', width: '10%', class: common_th_class }, + td: { class: common_td_class, callback: ->(host) { last_report_column(host) } } + column :comment, th: { label: _('Comment'), sortable: true, width: '7%', class: common_th_class }, + td: { class: common_th_class + ' ca', + attr_callbacks: { title: ->(host) { host.comment&.truncate(255) } }, + callback: ->(host) { icon_text('comment', '') unless host.comment.empty? } } + end +end diff --git a/developer_docs/how_to_create_a_plugin.asciidoc b/developer_docs/how_to_create_a_plugin.asciidoc index cae3bc6edf5..bbcffd17f42 100644 --- a/developer_docs/how_to_create_a_plugin.asciidoc +++ b/developer_docs/how_to_create_a_plugin.asciidoc @@ -429,6 +429,108 @@ widget_name". The content of the widget should be in the path: app/views/dashboard/_.html.erb .... +[[selectable-columns]] +=== Selectable columns + +_Requires Foreman 3.4 or higher, set `requires_foreman '>= 3.4'` in engine.rb or +register.rb._ + +[[adding-selectable-columns]] +===== Adding selectable columns to index pages + +Index pages always contain a table with a certain set of columns. +Since Foreman 3.4 some pages have this set extendable, e.g. hosts index page, +and provide users a way to select which columns are meant to be displayed. For +the list of available tables with selectable columns, please refer to +config/initializers/foreman_register.rb file. + + +If you want to add more selectable columns to an existing page in Foreman, +please the following syntax in +`Foreman::Plugin.register :foreman_plugin do ... end` block: + +[source, ruby] +---- +selectable_columns :table do + category :id, label: N_('Category name'), default: true do + column :key, + th: { + label: N_('Column name'), + sortable: true, + default_sort: 'DESC', + width: '10%', + class: 'hidden ca' + }, + td: { + class: 'hidden ca', + attr_callbacks: { + title: ->(record) { record.value } + }, + callback: ->(record) { record.value } + } + use_column :key, from :category_id + end +end +---- + +Where for `category`: + +* `id`: [Symbol] (Required) Internal id of the category. Used to find an existing or define a new one. +* `label`: [String] (Optional) Translated string with category name. Required only for new categories. +* `default`: [Boolean] (Optional) Flag whether this category should be visible to users by default. Default: `false`. + +Where for `column`: + +* `key`: [Symbol] (Required) Record attribute's name. Used to make column sortable and selectable. +* `th`: [Hash] (Required) Column header definition. Please, see below for further information. +* `td`: [Hash] (Required) Column data definition. Please, see below for further information. + +Where for `th`: + +* `label`: [String] (Required) Translated string with column name to be shown. +* `sortable`: [Boolean] (Optional) Makes column sortable. Default: `false`. +* `default_sort`: [String] (Optional) Defines default sort order. Default: `'ASC'` + +Where for `td`: + +* `callback`: [Lambda] (Required) A callback that will be used to process data to be shown. +* `attr_callbacks`: [Hash] (Optional) Only use if you need to process values for HTML attributes dynamically. + +Where for `use_column`: + +* `key`: [Symbol] (Required) Key of the defined column to include in the current category. +* `from`: [Symbol] (Required) ID of the category defining the column to use. Category must be in the same table. + +You can also define HTML attributes for `` and `` tags. Some common are +listed below, but you can use your own. +_Note: all except `:callback`, `:sortable`, `:default_sort`, `:attr_callbacks` +are considered as HTML tag attributes. + +* `width`: [String] (Optional) Width of the column. +* `class`: [String] (Optional) Space-separated list of CSS classes to use for the column. + +[[creating-selectable-columns]] +===== Creating selectable columns on index pages + +If you want to not only extend existing pages, but to also have pages with +selectable columns in the plugin itself, you can use +`Foreman::SelectableColumns::Storage` directly. In this case everything the same +except you no longer need to use plugin's DSL, but the storage itself to define +your tables: + +[source, ruby] +---- +# in your initializer +Foreman::SelectableColumns::Storage.define(:table) do + category ... +end +---- + +To use your columns in views, please use + +* `render_selected_column_ths`: Renders all selectable column tags for table. +* `render_selected_column_tds(record)`: Renders all selectable column tags for table. + [[adding-a-pagelet]] === Adding a Pagelet diff --git a/test/helpers/selectable_columns_helper_test.rb b/test/helpers/selectable_columns_helper_test.rb new file mode 100644 index 00000000000..a1a30c2e45e --- /dev/null +++ b/test/helpers/selectable_columns_helper_test.rb @@ -0,0 +1,39 @@ +require 'test_helper' + +class SelectableColumnsHelperTest < ActionView::TestCase + include SelectableColumnsHelper + + let(:selected_columns) do + [ + { + key: 'key1', th: { label: 'Key1', width: '5%' }, + td: { class: 'elipsis', callback: ->(o) { o.even? } } + }.with_indifferent_access, + { key: 'key2', th: { label: 'Key2', class: 'elipsis', width: '10%' }, + td: { class: 'hidden', attr_callbacks: { title: ->(o) { o.to_s } }, callback: ->(o) { o.odd? } } + }.with_indifferent_access, + ] + end + + setup do + @selected_columns = selected_columns + end + + test 'should render ths' do + expected = [ + 'Key1', + 'Key2', + '', + ].join("\n").html_safe + assert_equal expected, render_selected_column_ths + end + + test 'should render tds' do + expected = [ + 'false', + 'true', + '', + ].join("\n").html_safe + assert_equal expected, render_selected_column_tds(123) + end +end diff --git a/test/unit/selectable_columns/category_test.rb b/test/unit/selectable_columns/category_test.rb new file mode 100644 index 00000000000..a3abf7004e7 --- /dev/null +++ b/test/unit/selectable_columns/category_test.rb @@ -0,0 +1,53 @@ +require 'test_helper' + +class CategoryTest < ActiveSupport::TestCase + let(:table) { Foreman::SelectableColumns::Table.new(name: 'foo') } + + test 'should store column definitions' do + category = Foreman::SelectableColumns::Category.new(:test, 'Test', table) + category.column :key, th: {}, td: {} + category.column :key2, th: {}, td: {} + + assert_not_empty category.columns + end + + test 'should re-use column definitions' do + shared_table = table + category1 = Foreman::SelectableColumns::Category.new('test', 'Test', shared_table) + category1.column :key, th: {}, td: {} + category2 = Foreman::SelectableColumns::Category.new('test2', 'Test2', shared_table) + category2.use_column :key, from: :test + shared_table.concat([category1, category2]) + + assert_not_empty category2.columns + end + + test 'should ignore non-existing category for re-usage' do + shared_table = table + category2 = Foreman::SelectableColumns::Category.new('test2', 'Test2', shared_table) + category2.use_column :key, from: :test3 + shared_table.concat([category2]) + + assert_empty category2.columns + end + + test 'should ignore non-existing column for re-usage' do + shared_table = table + category1 = Foreman::SelectableColumns::Category.new('test', 'Test', shared_table) + category1.column :key, th: {}, td: {} + category2 = Foreman::SelectableColumns::Category.new('test2', 'Test2', shared_table) + category2.use_column :key2, from: :test + shared_table.concat([category1, category2]) + + assert_empty category2.columns + end + + test 'should ignore itself for re-usage' do + shared_table = table + category2 = Foreman::SelectableColumns::Category.new('test2', 'Test2', shared_table) + category2.use_column :key, from: :test2 + shared_table.concat([category2]) + + assert_empty category2.columns + end +end diff --git a/test/unit/selectable_columns/storage_test.rb b/test/unit/selectable_columns/storage_test.rb new file mode 100644 index 00000000000..8c682f78f4a --- /dev/null +++ b/test/unit/selectable_columns/storage_test.rb @@ -0,0 +1,136 @@ +require 'test_helper' + +class StorageTest < ActiveSupport::TestCase + setup do + Foreman::SelectableColumns::Storage.stubs(tables: HashWithIndifferentAccess.new) + end + + test 'should store table definitions' do + Foreman::SelectableColumns::Storage.define(:default) do + category(:general) {} + end + Foreman::SelectableColumns::Storage.define(:nondefault) do + category(:nongeneral) {} + end + + assert_not_empty Foreman::SelectableColumns::Storage.tables + end + + test 'should not re-define a table' do + only_name = 'general' + Foreman::SelectableColumns::Storage.define(:default) do + category(only_name) {} + end + Foreman::SelectableColumns::Storage.define(:default) do + category(:general2) {} + end + table = Foreman::SelectableColumns::Storage.tables[:default] + + assert_equal 1, table.size + assert_equal only_name, table.first.id + end + + test 'should re-use defined table' do + Foreman::SelectableColumns::Storage.define(:default) do + category(:general) {} + end + Foreman::SelectableColumns::Storage.register(:default) do + category(:nongeneral) {} + end + table = Foreman::SelectableColumns::Storage.tables[:default] + + assert_equal 2, table.size + end + + test 'should not define a table while registering categories' do + Foreman::SelectableColumns::Storage.register(:default) do + category(:nongeneral) {} + end + + assert_empty Foreman::SelectableColumns::Storage.tables + end + + test 'should return defined columns on a table in simplified form' do + expected = [ + { + id: 'general', + name: 'General', + columns: [ + { id: 'key1', name: 'Key1' }, + { id: 'key2', name: 'Key2' }, + ], + }, + { + id: 'nongeneral', + name: 'Nongeneral', + columns: [ + { id: 'key3', name: 'Key3' }, + { id: 'key1', name: 'Key1' }, + ], + }, + ] + + Foreman::SelectableColumns::Storage.define(:default) do + category :general do + column :key1, th: { label: 'Key1' }, td: {} + column :key2, th: { label: 'Key2' }, td: {} + end + category :nongeneral, label: 'Nongeneral' do + column :key3, th: { label: 'Key3' }, td: {} + use_column :key1, from: :general + end + end + + result = Foreman::SelectableColumns::Storage.defined_for(:default) + assert_equal expected, result + end + + test 'should return filtered by preferences full definitions of columns' do + Foreman::SelectableColumns::Storage.define(:default) do + category :general do + column :key1, th: { label: 'Key1' }, td: {} + column :key2, th: { label: 'Key2' }, td: {} + end + category :nongeneral, label: 'Nongeneral' do + column :key3, th: { label: 'Key3' }, td: {} + use_column :key1, from: :general + end + end + + expected = [ + { key: 'key1', th: { label: 'Key1' }, td: {} }.with_indifferent_access, + { key: 'key3', th: { label: 'Key3' }, td: {} }.with_indifferent_access, + ] + + user = FactoryBot.create(:user) + user.table_preferences.create(name: 'default', columns: ['key1', 'key3']) + + result = Foreman::SelectableColumns::Storage.selected_by(user, :default) + + assert_equal expected, result + end + + test 'should return default category if user does not have preferences' do + Foreman::SelectableColumns::Storage.define(:default) do + category :general, default: true do + column :key1, th: { label: 'Key1' }, td: {} + column :key2, th: { label: 'Key2' }, td: {} + end + category :nongeneral, label: 'Nongeneral' do + column :key3, th: { label: 'Key3' }, td: {} + use_column :key1, from: :general + end + end + + expected = [ + { key: 'key1', th: { label: 'Key1' }, td: {} }.with_indifferent_access, + { key: 'key2', th: { label: 'Key2' }, td: {} }.with_indifferent_access, + ] + + user = FactoryBot.create(:user) + + result = Foreman::SelectableColumns::Storage.selected_by(user, :default) + + assert_equal expected, result + end +end diff --git a/test/unit/selectable_columns/table_test.rb b/test/unit/selectable_columns/table_test.rb new file mode 100644 index 00000000000..005b6336b18 --- /dev/null +++ b/test/unit/selectable_columns/table_test.rb @@ -0,0 +1,19 @@ +require 'test_helper' + +class TableTest < ActiveSupport::TestCase + test 'should store category definitions' do + table = Foreman::SelectableColumns::Table.new(:test) + table.category(:test) {} + table.category(:test2) {} + + assert_not_empty table + end + + test 'should re-use category definitions' do + table = Foreman::SelectableColumns::Table.new(:test) + table.category(:test) {} + table.category(:test) {} + + assert_equal 1, table.size + end +end