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 ( +
+
+ + 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 },