Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #35287 - Create column selector on host index page #9323

Merged
merged 3 commits into from
Oct 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/assets/stylesheets/base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ select {
padding-bottom: 9px;
}

.title_filter {
padding-right: 8px;
}

#title_action {
padding-left: 8px;
padding-bottom: 12px;
}

Expand Down
4 changes: 2 additions & 2 deletions app/controllers/api/v2/table_preferences_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ def searchable?
end
end

def filter_columns?
controller_name == 'hosts' && controller.action_name == 'index'
pkoprda marked this conversation as resolved.
Show resolved Hide resolved
end

def auto_complete_controller_name
controller.respond_to?(:auto_complete_controller_name) ? controller.auto_complete_controller_name : controller_name
end
Expand Down
60 changes: 60 additions & 0 deletions app/helpers/pagelets_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
pkoprda marked this conversation as resolved.
Show resolved Hide resolved
}
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(
{
Expand Down
2 changes: 1 addition & 1 deletion app/registries/pagelets/filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? }
Expand Down
2 changes: 1 addition & 1 deletion app/registries/pagelets/manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions app/views/layouts/_application_content.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
<%= yield(:search_bar) %>&nbsp;
</div>
<div id="title_action" class= <%= searchable? ? "col-md-6" : "col-md-8" %>>
<% if filter_columns? %>
<div class="pull-left">
<%= mount_column_selector %>
<%= yield(:column_selector) %>&nbsp;
pkoprda marked this conversation as resolved.
Show resolved Hide resolved
</div>
<% end %>
<div class="btn-toolbar pull-right">
<%=h yield(:title_actions) %>
</div>
Expand Down
4 changes: 2 additions & 2 deletions config/initializers/foreman_register.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="pf-c-select-input">
<div className="pf-c-input-group" id="column-selector">
<Button
id="btn-select-columns"
variant="link"
icon={<ColumnsIcon />}
iconPosition="left"
className="columns-selector"
onClick={() => toggleModal()}
>
{__('Manage columns')}
</Button>
<Modal
variant={ModalVariant.small}
title={__('Manage columns')}
isOpen={isModalOpen}
onClose={toggleModal}
tabIndex={0}
description={__('Select columns to display in the table.')}
position="top"
actions={[
<Button
key="save"
variant="primary"
onClick={() => updateTablePreference()}
>
{__('Save')}
</Button>,
<Button key="cancel" variant="secondary" onClick={toggleModal}>
{__('Cancel')}
</Button>,
]}
>
<TreeView data={selectedColumns} onCheck={onCheck} hasChecks />
</Modal>
</div>
</div>
);
};

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;
Original file line number Diff line number Diff line change
@@ -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,
},
};
Loading