Skip to content

Commit

Permalink
Fixes #35287 - Create column selector on host index page
Browse files Browse the repository at this point in the history
  • Loading branch information
pkoprda authored and pkoprda committed Oct 25, 2022
1 parent 68eb570 commit cd0972d
Show file tree
Hide file tree
Showing 12 changed files with 432 additions and 1 deletion.
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: 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'
end

def auto_complete_controller_name
controller.respond_to?(:auto_complete_controller_name) ? controller.auto_complete_controller_name : controller_name
end
Expand Down
59 changes: 59 additions & 0 deletions app/helpers/pagelets_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,67 @@ 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],
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: { checked: 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/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;
</div>
<% end %>
<div class="btn-toolbar pull-right">
<%=h yield(:title_actions) %>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 isDisabled = () => {
const hasPartialCheck = selectedColumns.map(
item => item.checkProps.checked
);
return hasPartialCheck.every(el => el === false);
};

const updateCheckBox = (treeViewItem, checked = true) => {
treeViewItem.checkProps.checked = checked;
if (treeViewItem.children) {
treeViewItem.children.forEach(item => {
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"
isDisabled={isDisabled()}
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: { 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,
},
};
Original file line number Diff line number Diff line change
@@ -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 = () => (
<ColumnSelector {...ColumnSelectorProps} />
);
testComponentSnapshotsWithFixtures(columnSelector, fixtures);
});
Loading

0 comments on commit cd0972d

Please sign in to comment.