diff --git a/app/assets/stylesheets/base.scss b/app/assets/stylesheets/base.scss
index 3c76c6d9c13..5c18b8452ad 100644
--- a/app/assets/stylesheets/base.scss
+++ b/app/assets/stylesheets/base.scss
@@ -143,7 +143,12 @@ select {
padding-bottom: 9px;
}
+.title_filter {
+ padding-right: 8px;
+}
+
#title_action {
+ padding-left: 8px;
padding-bottom: 12px;
}
diff --git a/app/controllers/api/v2/table_preferences_controller.rb b/app/controllers/api/v2/table_preferences_controller.rb
index 108eb18f60e..ef30acb78cc 100644
--- a/app/controllers/api/v2/table_preferences_controller.rb
+++ b/app/controllers/api/v2/table_preferences_controller.rb
@@ -31,14 +31,14 @@ def show
api :POST, "/users/:user_id/table_preferences/", N_("Creates a table preference for a given table")
param_group :table_preference
def create
- @table_preference = @user.table_preferences.build(:name => params[:name], :columns => params[:columns])
+ @table_preference = @user.table_preferences.build(:name => params[:name], :columns => params[:columns]&.uniq)
process_response @table_preference.save
end
api :PUT, "/users/:user_id/table_preferences/:name", N_("Updates a table preference for a given table")
param_group :table_preference
def update
- process_response @table_preference.update(:columns => params[:columns])
+ process_response @table_preference.update(:columns => params[:columns]&.uniq)
end
api :DELETE, "/users/:user_id/table_preferences/:name/", N_("Delete a table preference for a given table")
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 8d28d72e4e1..3cba9e9fbd4 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -155,6 +155,10 @@ def searchable?
end
end
+ def filter_columns?
+ controller_name == 'hosts' && controller.action_name == 'index'
+ end
+
def auto_complete_controller_name
controller.respond_to?(:auto_complete_controller_name) ? controller.auto_complete_controller_name : controller_name
end
diff --git a/app/helpers/pagelets_helper.rb b/app/helpers/pagelets_helper.rb
index d6323d40b58..0e56b6b01fd 100644
--- a/app/helpers/pagelets_helper.rb
+++ b/app/helpers/pagelets_helper.rb
@@ -67,8 +67,68 @@ def th_content(col)
sort col[:key], as: col[:label], default: col[:default_sort] || 'ASC'
end
+ def mount_column_selector
+ url = "#{api_users_path}/#{User.current[:id]}/table_preferences"
+ react_component("ColumnSelector", data:
+ {
+ url: url,
+ controller: controller_name,
+ categories: columns_view,
+ hasPreference: @selected_columns.present?,
+ }
+ )
+ end
+
private
+ def defined_columns
+ Pagelets::Manager.pagelets_at('hosts/_list', :hosts_table_column_header).map do |pt|
+ next unless pt.opts[:key]
+ {
+ key: pt.opts[:key],
+ label: pt.opts[:label],
+ locked: pt.opts[:locked],
+ profiles: pt.profiles,
+ checked: @selected_columns ? @selected_columns.include?(pt.opts[:key].to_s) : pt.profiles.any? { |profile| profile.default? },
+ }
+ end.compact
+ end
+
+ def defined_categories(columns)
+ columns.map { |col| col[:profiles] }.flatten.uniq { |pl| pl.id }
+ end
+
+ def all_checked?(category)
+ return true if category.all? { |column| column[:checkProps][:checked] }
+ category.any? { |column| column[:checkProps][:checked] } ? nil : false
+ end
+
+ def columns_view
+ categories = []
+ columns = defined_columns
+ defined_categories(columns).each do |category|
+ category_view = {
+ name: category.label,
+ key: category.id,
+ defaultExpanded: category.id == 'general',
+ checkProps: {},
+ children: [],
+ }
+ columns.each do |column|
+ if column[:profiles].map(&:id).include?(category_view[:key])
+ category_view[:children] << {
+ name: column[:label],
+ key: column[:key],
+ checkProps: { disabled: column[:locked], checked: column[:locked] || column[:checked] },
+ }
+ end
+ end
+ category_view[:checkProps][:checked] = all_checked?(category_view[:children])
+ categories << category_view
+ end
+ categories
+ end
+
def filter_opts(opts = {})
opts.merge(
{
diff --git a/app/registries/pagelets/filter.rb b/app/registries/pagelets/filter.rb
index d69e2672ce8..4da89c04741 100644
--- a/app/registries/pagelets/filter.rb
+++ b/app/registries/pagelets/filter.rb
@@ -8,7 +8,7 @@ def initialize(items)
def filter(opts = {})
result = if opts[:selected]
- items.select { |pagelet| opts[:selected].include?(pagelet.key.to_s) }
+ items.select { |pagelet| pagelet.locked || opts[:selected].include?(pagelet.key.to_s) }
else
items.select do |pagelet|
pagelet.profiles.empty? ? true : pagelet.profiles.any? { |profile| profile.default? }
diff --git a/app/registries/pagelets/manager.rb b/app/registries/pagelets/manager.rb
index d6b18b72732..33d0859c9fa 100644
--- a/app/registries/pagelets/manager.rb
+++ b/app/registries/pagelets/manager.rb
@@ -66,7 +66,7 @@ def pagelets_at(*args)
end
def with_profile(id, label, opts, &block)
- profile = Profile.new(id, label, self, opts)
+ profile = Profile.new(id.to_s, label, self, opts)
profile.instance_exec(&block)
profile.pagelets.each do |pagelet|
add_pagelet(*pagelet)
diff --git a/app/views/layouts/_application_content.html.erb b/app/views/layouts/_application_content.html.erb
index 1df71084b93..247958d7f0e 100644
--- a/app/views/layouts/_application_content.html.erb
+++ b/app/views/layouts/_application_content.html.erb
@@ -14,6 +14,12 @@
<%= yield(:search_bar) %>
>
+ <% if filter_columns? %>
+
+ <%= mount_column_selector %>
+ <%= yield(:column_selector) %>
+
+ <% end %>
<%=h yield(:title_actions) %>
diff --git a/config/initializers/foreman_register.rb b/config/initializers/foreman_register.rb
index 58a07bfefef..a429e57a130 100644
--- a/config/initializers/foreman_register.rb
+++ b/config/initializers/foreman_register.rb
@@ -9,8 +9,8 @@
ctx.with_profile :general, _('General'), default: true do
common_th_class = 'hidden-tablet hidden-xs'
common_td_class = common_th_class + ' ellipsis'
- add_pagelet :hosts_table_column_header, key: :name, label: _('Name'), sortable: true, width: '25%'
- add_pagelet :hosts_table_column_content, key: :name, class: 'ellipsis', callback: ->(host) { name_column(host) }
+ add_pagelet :hosts_table_column_header, key: :name, label: _('Name'), sortable: true, width: '25%', locked: true
+ add_pagelet :hosts_table_column_content, key: :name, class: 'ellipsis', callback: ->(host) { name_column(host) }, locked: true
add_pagelet :hosts_table_column_header, key: :os_title, label: _('Operating system'), sortable: true, width: '17%', class: 'hidden-xs'
add_pagelet :hosts_table_column_content, key: :os_title, class: 'hidden-xs ellipsis', callback: ->(host) { (icon(host.operatingsystem, size: "16x16") + " #{host.operatingsystem.to_label}").html_safe if host.operatingsystem }
add_pagelet :hosts_table_column_header, key: :model, label: _('Model'), sortable: true, width: '10%', class: common_th_class
diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnSelector.js b/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnSelector.js
new file mode 100644
index 00000000000..9c02c31919f
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnSelector.js
@@ -0,0 +1,186 @@
+import React, { useState } from 'react';
+import { PropTypes } from 'prop-types';
+import { Button, Modal, ModalVariant, TreeView } from '@patternfly/react-core';
+import { ColumnsIcon } from '@patternfly/react-icons';
+import { cloneDeep } from 'lodash';
+import { translate as __ } from '../../common/I18n';
+import API from '../../API';
+import { changeQuery } from '../../common/urlHelpers';
+import './column-selector.scss';
+
+const ColumnSelector = props => {
+ const {
+ data: { url, controller, categories, hasPreference },
+ } = props;
+
+ const initialColumns = cloneDeep(categories);
+ const [isModalOpen, setModalOpen] = useState(false);
+ const [selectedColumns, setSelectedColumns] = useState(categories);
+
+ const getColumnKeys = () => {
+ const keys = selectedColumns
+ .map(category => category.children)
+ .flat()
+ .map(column => {
+ if (column.checkProps.checked) {
+ return column.key;
+ }
+ return null;
+ })
+ .filter(item => item);
+ return keys;
+ };
+
+ async function updateTablePreference() {
+ if (!hasPreference) {
+ await API.post(url, { name: 'hosts', columns: getColumnKeys() });
+ } else {
+ await API.put(`${url}/${controller}`, { columns: getColumnKeys() });
+ }
+ changeQuery({});
+ }
+
+ const filterItems = (item, checkedItem) => {
+ if (item.key === checkedItem.key) {
+ return true;
+ }
+
+ if (item.children) {
+ item.children = item.children
+ .map(opt => Object.assign({}, opt))
+ .filter(column => filterItems(column, checkedItem));
+ return item.children;
+ }
+
+ return null;
+ };
+
+ const flattenTree = tree => {
+ let result = [];
+ tree.forEach(item => {
+ result.push(item);
+ if (item.children) {
+ result = result.concat(flattenTree(item.children));
+ }
+ });
+ return result;
+ };
+
+ const toggleModal = () => {
+ setSelectedColumns(initialColumns);
+ setModalOpen(!isModalOpen);
+ };
+
+ const updateCheckBox = (treeViewItem, checked = true) => {
+ treeViewItem.checkProps.checked = checked;
+ if (treeViewItem.children) {
+ treeViewItem.children.forEach(item => {
+ if (!item.checkProps.disabled) {
+ item.checkProps.checked = checked;
+ }
+ });
+ }
+ };
+
+ const onCheck = (evt, treeViewItem) => {
+ const { checked } = evt.target;
+ const checkedItemTree = selectedColumns
+ .map(column => Object.assign({}, column))
+ .filter(item => filterItems(item, treeViewItem));
+ const flatCheckedItems = flattenTree(checkedItemTree);
+
+ if (checked) {
+ updateCheckBox(treeViewItem);
+ setSelectedColumns(
+ selectedColumns
+ .concat(
+ flatCheckedItems.filter(
+ item => !selectedColumns.some(i => i.key === item.key)
+ )
+ )
+ .filter(item => item.children)
+ );
+ } else {
+ updateCheckBox(treeViewItem, false);
+ setSelectedColumns(
+ selectedColumns.filter(item =>
+ flatCheckedItems.some(i => i.key === item.key)
+ )
+ );
+ }
+ selectedColumns.map(category => areDescendantsChecked(category));
+ };
+
+ const isChecked = dataItem => dataItem.checkProps.checked;
+ const areDescendantsChecked = dataItem => {
+ if (dataItem.children) {
+ if (dataItem.children.every(child => isChecked(child))) {
+ dataItem.checkProps.checked = true;
+ } else if (dataItem.children.some(child => isChecked(child))) {
+ dataItem.checkProps.checked = null;
+ } else {
+ dataItem.checkProps.checked = false;
+ }
+ }
+ };
+
+ return (
+
+
+ }
+ iconPosition="left"
+ className="columns-selector"
+ onClick={() => toggleModal()}
+ >
+ {__('Manage columns')}
+
+ updateTablePreference()}
+ >
+ {__('Save')}
+ ,
+ ,
+ ]}
+ >
+
+
+
+
+ );
+};
+
+ColumnSelector.propTypes = {
+ data: PropTypes.shape({
+ url: PropTypes.string,
+ controller: PropTypes.string,
+ categories: PropTypes.arrayOf(PropTypes.object),
+ hasPreference: PropTypes.bool,
+ }),
+};
+
+ColumnSelector.defaultProps = {
+ data: {
+ url: '',
+ controller: '',
+ categories: [],
+ hasPreference: false,
+ },
+};
+
+export default ColumnSelector;
diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnsSelector.fixtures.js b/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnsSelector.fixtures.js
new file mode 100644
index 00000000000..f5c1c886e20
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnsSelector.fixtures.js
@@ -0,0 +1,59 @@
+export const user = {
+ impersonated_by: true,
+ id: 4,
+ login: 'admin',
+ firstname: 'Admin',
+ lastname: 'User',
+ name: 'Admin User',
+};
+
+export const ColumnSelectorProps = {
+ data: {
+ url: `api/users/${user.id}/table_preferences`,
+ controller: 'hosts',
+ categories: [{
+ name: 'General',
+ key: 'general',
+ defaultExpanded: true,
+ checkProps: { checked: true },
+ children: [
+ {
+ name: 'Name',
+ key: 'name',
+ checkProps: { locked: true, checked: true },
+ },
+ {
+ name: 'Operating system',
+ key: 'os_title',
+ checkProps: { checked: true },
+ },
+ {
+ name: 'Model',
+ key: 'model',
+ checkProps: { checked: true },
+ },
+ {
+ name: 'Owner',
+ key: 'owner',
+ checkProps: { checked: true },
+ },
+ {
+ name: 'Host group',
+ key: 'hostgroup',
+ checkProps: { checked: true },
+ },
+ {
+ name: 'Last report',
+ key: 'last_report',
+ checkProps: { checked: true },
+ },
+ {
+ name: 'Comment',
+ key: 'comment',
+ checkProps: { checked: true },
+ },
+ ],
+ }],
+ hasPreference: true,
+ },
+};
diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/__tests__/ColumnSelector.test.js b/webpack/assets/javascripts/react_app/components/ColumnSelector/__tests__/ColumnSelector.test.js
new file mode 100644
index 00000000000..c86ffc33ba1
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/__tests__/ColumnSelector.test.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import ColumnSelector from '../ColumnSelector';
+import { ColumnSelectorProps } from '../ColumnsSelector.fixtures';
+import { testComponentSnapshotsWithFixtures } from '../../../common/testHelpers';
+
+const fixtures = {
+ 'should render': ColumnSelectorProps,
+};
+describe('ColumnSelector', () => {
+ const columnSelector = () => (
+
+ );
+ testComponentSnapshotsWithFixtures(columnSelector, fixtures);
+});
diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/__tests__/__snapshots__/ColumnSelector.test.js.snap b/webpack/assets/javascripts/react_app/components/ColumnSelector/__tests__/__snapshots__/ColumnSelector.test.js.snap
new file mode 100644
index 00000000000..5845e33bc48
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/__tests__/__snapshots__/ColumnSelector.test.js.snap
@@ -0,0 +1,75 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ColumnSelector should render 1`] = `
+
+`;
diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/column-selector.scss b/webpack/assets/javascripts/react_app/components/ColumnSelector/column-selector.scss
new file mode 100644
index 00000000000..e4858917f9c
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/column-selector.scss
@@ -0,0 +1,13 @@
+.pf-c-input-group {
+ .columns-selector {
+ padding-left: 0px;
+
+ .pf-c-button__icon.pf-m-start {
+ padding-right: 4px;
+ }
+ }
+}
+
+.pf-c-tree-view {
+ padding-top: 0px
+}
diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/index.js b/webpack/assets/javascripts/react_app/components/ColumnSelector/index.js
new file mode 100644
index 00000000000..b6979da02d7
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/index.js
@@ -0,0 +1,3 @@
+import ColumnSelector from './ColumnSelector';
+
+export default ColumnSelector;
diff --git a/webpack/assets/javascripts/react_app/components/componentRegistry.js b/webpack/assets/javascripts/react_app/components/componentRegistry.js
index 1fd1a11a376..2af43c01f6d 100644
--- a/webpack/assets/javascripts/react_app/components/componentRegistry.js
+++ b/webpack/assets/javascripts/react_app/components/componentRegistry.js
@@ -22,6 +22,7 @@ import FactChart from './FactCharts';
import Pagination from './Pagination';
import AutoComplete from './AutoComplete';
import SearchBar from './SearchBar';
+import ColumnSelector from './ColumnSelector';
import Layout from './Layout';
import EmptyState from './common/EmptyState';
import ComponentWrapper from './common/ComponentWrapper/ComponentWrapper';
@@ -118,6 +119,7 @@ const componentRegistry = {
const coreComponets = [
{ name: 'ReactApp', type: ReactApp },
{ name: 'SearchBar', type: SearchBar },
+ { name: 'ColumnSelector', type: ColumnSelector },
{ name: 'AutoComplete', type: AutoComplete },
{ name: 'AreaChart', type: AreaChart },
{ name: 'DonutChart', type: DonutChart },