diff --git a/developer_docs/how_to_create_a_plugin.asciidoc b/developer_docs/how_to_create_a_plugin.asciidoc index 57859dbb9eb..5ce63e147d2 100644 --- a/developer_docs/how_to_create_a_plugin.asciidoc +++ b/developer_docs/how_to_create_a_plugin.asciidoc @@ -506,6 +506,52 @@ You can find usage of those pagelets in the https://github.com/theforeman/forema _Requires Foreman 1.18 or higher, set `requires_foreman '>= 1.18'` in engine.rb_ +[[adding-columns-to-the-react-hosts-index-page]] +==== Adding columns to the React hosts index page + +Similar to the way the legacy hosts index page can be extended via pagelets, columns can also be added to the React hosts index page, or any other page that uses the ColumnSelector component and user TablePreferences. +These columns will then be available in the ColumnSelector so that users can customize which columns are displayed in the table. +Instead of pagelets, column data is defined in the plugin's `webpack/global_index.js` file. +The following example demonstrates how to add a new column to the React hosts index page: + +[source, javascript] +---- +import React from 'react'; +import { RelativeDateTime } from 'foremanReact/components/RelativeDateTime'; +import { registerColumns } from 'foremanReact/components/HostsIndex/Columns/core'; +import { __ as translate } from 'foremanReact/common/i18n'; + +const hostsIndexColumnExtensions = [ + { + columnName: 'last_checkin', + title: __('Last seen'), + wrapper: (hostDetails) => { + const lastCheckin = + hostDetails?.subscription_facet_attributes?.last_checkin; + return ; + }, + weight: 400, + tableName: 'hosts', + categoryName: __('Content'), + categoryKey: 'content', + isSorted: false, + }, +]; + +registerColumns(hostsIndexColumnExtensions); +---- + +Each column extension object must contain the following properties: + +* `columnName` - the name of the column, which must match the column name in the API response. +* `title` - the title of the column to be displayed on screen in the element. Should be translated. +* `wrapper` - a function that returns the content (as JSX) to be displayed in the table cell. The function receives the host details as an argument. +* `weight` - the weight of the column, which determines the order in which columns are displayed. Lower weights are displayed first. +* `tableName` - the name of the table. Should match the `name` of the user's TablePreference. +* `categoryName` - the name of the category to which the column belongs. Displayed on screen in the ColumnSelector. Should be translated. +* `categoryKey` - the key of the category to which the column belongs. Used to group columns in the ColumnSelector. Should not be translated. +* `isSorted` - whether the column is sortable. Sortable columns must have a `columnName` that matches a sortable column in the API response. + [[new-structure-for-assets]] ===== New structure for assets. diff --git a/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/Breadcrumb.js b/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/Breadcrumb.js index 228d02bf701..21f13f3a91a 100644 --- a/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/Breadcrumb.js +++ b/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/Breadcrumb.js @@ -57,6 +57,7 @@ const Breadcrumb = ({ active, 'breadcrumb-item-with-icon': icon && active, })} + {...{ item }} > {icon && {icon.alt}}{' '} {inner} diff --git a/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/__snapshots__/Breadcrumb.test.js.snap b/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/__snapshots__/Breadcrumb.test.js.snap index 11605b492cd..c5730a7fd26 100644 --- a/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/__snapshots__/Breadcrumb.test.js.snap +++ b/webpack/assets/javascripts/react_app/components/BreadcrumbBar/components/__snapshots__/Breadcrumb.test.js.snap @@ -5,6 +5,12 @@ exports[`Breadcrumbs renders breadcrumbs menu 1`] = ` @@ -14,6 +20,12 @@ exports[`Breadcrumbs renders breadcrumbs menu 1`] = ` @@ -23,6 +35,11 @@ exports[`Breadcrumbs renders breadcrumbs menu 1`] = ` @@ -40,6 +57,11 @@ exports[`Breadcrumbs renders h1 title 1`] = ` @@ -57,6 +79,12 @@ exports[`Breadcrumbs renders title override 1`] = ` @@ -66,6 +94,11 @@ exports[`Breadcrumbs renders title override 1`] = ` diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnSelector.js b/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnSelector.js index b05583862f9..ea18b0bf9c1 100644 --- a/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnSelector.js +++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/ColumnSelector.js @@ -16,6 +16,7 @@ const ColumnSelector = props => { const initialColumns = cloneDeep(categories); const [isModalOpen, setModalOpen] = useState(false); const [selectedColumns, setSelectedColumns] = useState(categories); + const [saving, setSaving] = useState(false); const getColumnKeys = () => { const keys = selectedColumns @@ -32,8 +33,10 @@ const ColumnSelector = props => { }; async function updateTablePreference() { + if (!url || !controller) return; + setSaving(true); if (!hasPreference) { - await API.post(url, { name: 'hosts', columns: getColumnKeys() }); + await API.post(url, { name: controller, columns: getColumnKeys() }); } else { await API.put(`${url}/${controller}`, { columns: getColumnKeys() }); } @@ -69,6 +72,7 @@ const ColumnSelector = props => { const toggleModal = () => { setSelectedColumns(initialColumns); setModalOpen(!isModalOpen); + setSaving(false); }; const updateCheckBox = (treeViewItem, checked = true) => { @@ -167,6 +171,8 @@ const ColumnSelector = props => { ouiaId="save-columns-button" key="save" variant="primary" + isLoading={saving} + isDisabled={saving} onClick={() => updateTablePreference()} > {__('Save')} diff --git a/webpack/assets/javascripts/react_app/components/ColumnSelector/helpers.js b/webpack/assets/javascripts/react_app/components/ColumnSelector/helpers.js new file mode 100644 index 00000000000..5e6ddb848fa --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ColumnSelector/helpers.js @@ -0,0 +1,89 @@ +const getCheckedStateForCategory = (category = { children: [] }) => { + // return true if all children are checked + // return null if some children are checked + // return false if no children are checked + const checked = category.children.map(child => child.checkProps?.checked); + if (checked.every(Boolean)) return true; + if (checked.some(Boolean)) return null; + return false; +}; + +export const categoriesFromFrontendColumnData = ({ + registeredColumns, + userId, + controller = 'hosts', + userColumns = ['name'], + hasPreference = false, +}) => { + // need to build an object like + // { + // "url": "/api/users/4/table_preferences", + // "controller": "hosts", + // "categories": [ + // { + // "name": "General", + // "key": "general", + // "defaultExpanded": true, + // "checkProps": { + // "checked": true + // }, + // "children": [ + // { + // "name": "Power", + // "key": "power_status", + // "checkProps": { + // "disabled": null, + // "checked": true + // } + // }, + // ] + // }, + // ], + // "hasPreference": true + // } + + const result = { + url: userId ? `/api/users/${userId}/table_preferences` : null, + controller, + hasPreference, + }; + + const categories = []; + Object.keys(registeredColumns).forEach(column => { + const { + categoryName, + categoryKey, + tableName, + columnName, + title, + isRequired, + } = registeredColumns[column]; + if (tableName !== controller) return; + const category = categories.find(cat => cat.key === categoryKey); + if (!category) { + categories.push({ + name: categoryName, + key: categoryKey, + defaultExpanded: true, + checkProps: { + checked: false, + }, + children: [], + }); + } + const categoryIndex = categories.findIndex(cat => cat.key === categoryKey); + categories[categoryIndex].children.push({ + name: title, + key: columnName, + checkProps: { + checked: isRequired || userColumns.includes(columnName), + disabled: isRequired ?? null, + }, + }); + }); + categories.forEach(category => { + category.checkProps.checked = getCheckedStateForCategory(category); + }); + result.categories = categories; + return result; +}; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/index.js b/webpack/assets/javascripts/react_app/components/HostDetails/index.js index 69449fdedd3..0c08d4da102 100644 --- a/webpack/assets/javascripts/react_app/components/HostDetails/index.js +++ b/webpack/assets/javascripts/react_app/components/HostDetails/index.js @@ -54,7 +54,7 @@ const HostDetails = ({ location: { hash }, history, }) => { - const { displayFqdnForHosts } = useForemanSettings(); + const { displayFqdnForHosts, displayNewHostsPage } = useForemanSettings(); const { response, status } = useAPI( 'get', `/api/hosts/${id}?show_hidden_parameters=true`, @@ -116,7 +116,13 @@ const HostDetails = ({ switcherItemUrl: '/new/hosts/:name', }} breadcrumbItems={[ - { caption: __('Hosts'), url: hostsIndexUrl }, + { + caption: __('Hosts'), + url: hostsIndexUrl, + render: displayNewHostsPage + ? ({ caption }) => {caption} + : ({ caption }) => {caption}, + }, { caption: displayFqdnForHosts ? response.name diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/Columns/core.js b/webpack/assets/javascripts/react_app/components/HostsIndex/Columns/core.js new file mode 100644 index 00000000000..be89edb9bdd --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/Columns/core.js @@ -0,0 +1,236 @@ +/* eslint-disable camelcase */ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { TableText } from '@patternfly/react-table'; +import { UserIcon, UsersIcon } from '@patternfly/react-icons'; +import { translate as __ } from '../../../common/I18n'; +import forceSingleton from '../../../common/forceSingleton'; +import RelativeDateTime from '../../common/dates/RelativeDateTime'; + +const coreHostsIndexColumns = [ + // { // TODO: make power status work + // columnName: 'power_status', + // title: __('Power'), + // wrapper: ({ power_status: powerStatus }) => powerStatus ?? __('Unknown'), + // isSorted: false, + // weight: 0, + // }, + { + columnName: 'name', + title: __('Name'), + wrapper: ({ name, display_name: displayName }) => ( + {displayName} + ), + isSorted: true, + weight: 50, + isRequired: true, + }, + { + columnName: 'hostgroup', + title: __('Host group'), + wrapper: hostDetails => ( + + {hostDetails?.hostgroup_name} + + ), + isSorted: true, + weight: 100, + }, + { + columnName: 'os_title', + title: __('OS'), + wrapper: hostDetails => hostDetails?.operatingsystem_name, + isSorted: true, + weight: 200, + }, + { + columnName: 'owner', + title: __('Owner'), + wrapper: hostDetails => { + if (!hostDetails?.owner_name) return null; + const OwnerIcon = + hostDetails?.owner_type !== 'User' ? UsersIcon : UserIcon; + return ( + + + {hostDetails?.owner_name} + + ); + }, + isSorted: true, + weight: 300, + }, + { + columnName: 'boot_time', + title: __('Boot time'), + wrapper: hostDetails => { + const bootTime = hostDetails?.reported_data?.boot_time; + return ; + }, + isSorted: true, + weight: 400, + }, + { + columnName: 'last_report', + title: __('Last report'), + wrapper: hostDetails => { + const lastReport = hostDetails?.last_report; + return ( + + ); + }, + isSorted: true, + weight: 500, + }, + { + columnName: 'comment', + title: __('Comment'), + wrapper: hostDetails => ( + + {hostDetails?.comment ?? ''} + + ), + isSorted: true, + weight: 600, + }, +]; + +coreHostsIndexColumns.forEach(column => { + column.tableName = 'hosts'; + column.categoryName = 'General'; + column.categoryKey = 'general'; +}); + +const networkColumns = [ + { + columnName: 'ip', + title: 'IPv4', + wrapper: hostDetails => hostDetails?.ip, + isSorted: true, + weight: 700, + }, + { + columnName: 'ip6', + title: 'IPv6', + wrapper: hostDetails => hostDetails?.ip6, + isSorted: true, + weight: 800, + }, + { + columnName: 'mac', + title: 'MAC', + wrapper: hostDetails => hostDetails?.mac, + isSorted: true, + weight: 900, + }, +]; + +networkColumns.forEach(column => { + column.tableName = 'hosts'; + column.categoryName = 'Network'; + column.categoryKey = 'network'; +}); + +const reportedDataColumns = [ + { + columnName: 'model', + title: __('Model'), + wrapper: hostDetails => + hostDetails?.compute_resource_name || hostDetails?.model_name, + isSorted: true, + weight: 1000, + }, + { + columnName: 'sockets', + title: __('Sockets'), + wrapper: hostDetails => hostDetails?.reported_data?.sockets, + isSorted: false, + weight: 1100, + }, + { + columnName: 'cores', + title: __('Cores'), + wrapper: hostDetails => hostDetails?.reported_data?.cores, + isSorted: false, + weight: 1200, + }, + { + columnName: 'ram', + title: __('RAM'), + wrapper: hostDetails => hostDetails?.reported_data?.ram, + isSorted: false, + weight: 1300, + }, + // { // TODO: make virtual work + // columnName: 'virtual', + // title: __('Virtual'), + // wrapper: hostDetails => hostDetails?.reported_data?.virtual, + // isSorted: false, + // weight: 1400, + // }, + { + columnName: 'disks_total', + title: __('Total disk space'), + wrapper: hostDetails => hostDetails?.reported_data?.disks_total, + isSorted: false, + weight: 1500, + }, + { + columnName: 'kernel_version', + title: __('Kernel version'), + wrapper: hostDetails => hostDetails?.reported_data?.kernel_version, + isSorted: false, + weight: 1600, + }, + { + columnName: 'bios_vendor', + title: __('BIOS vendor'), + wrapper: hostDetails => hostDetails?.reported_data?.bios_vendor, + isSorted: false, + weight: 1700, + }, + { + columnName: 'bios_release_date', + title: __('BIOS release date'), + wrapper: hostDetails => hostDetails?.reported_data?.bios_release_date, + isSorted: false, + weight: 1800, + }, + { + columnName: 'bios_version', + title: __('BIOS version'), + wrapper: hostDetails => hostDetails?.reported_data?.bios_version, + isSorted: false, + weight: 1900, + }, +]; + +reportedDataColumns.forEach(column => { + column.tableName = 'hosts'; + column.categoryName = 'Reported data'; + column.categoryKey = 'reported_data'; +}); + +const coreColumnRegistry = forceSingleton('coreColumnRegistry', () => ({})); + +export const registerColumns = columns => { + columns.forEach(column => { + coreColumnRegistry[column.columnName] = column; + }); +}; + +registerColumns(coreHostsIndexColumns); +registerColumns(networkColumns); +registerColumns(reportedDataColumns); + +export const RegisteredColumns = ({ tableName = 'hosts' }) => { + const result = {}; + Object.keys(coreColumnRegistry).forEach(column => { + if (coreColumnRegistry[column]?.tableName === tableName) { + result[column] = coreColumnRegistry[column]; + } + }); + return result; +}; + +export default RegisteredColumns; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index e36441ff417..a22e80031c2 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -1,5 +1,6 @@ +/* eslint-disable max-lines */ import React, { createContext, useState } from 'react'; -import { useHistory, Link } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { useSelector, useDispatch, shallowEqual } from 'react-redux'; import { Tr, Td, ActionsColumn } from '@patternfly/react-table'; import { @@ -23,6 +24,7 @@ import { selectKebabItems } from './Selectors'; import { useBulkSelect } from '../PF4/TableIndexPage/Table/TableHooks'; import SelectAllCheckbox from '../PF4/TableIndexPage/Table/SelectAllCheckbox'; import { + filterColumnDataByUserPreferences, getColumnHelpers, getPageStats, } from '../PF4/TableIndexPage/Table/helpers'; @@ -37,9 +39,14 @@ import './index.scss'; import { STATUS } from '../../constants'; import { RowSelectTd } from './RowSelectTd'; import { + useCurrentUserTablePreferences, useSetParamsAndApiAndSearch, useTableIndexAPIResponse, } from '../PF4/TableIndexPage/Table/TableIndexHooks'; +import getColumnData from './Columns/core'; +import { categoriesFromFrontendColumnData } from '../ColumnSelector/helpers'; +import ColumnSelector from '../ColumnSelector'; +import { ForemanActionsBarContext } from '../HostDetails/ActionsBar'; export const ForemanHostsIndexActionsBarContext = forceSingleton( 'ForemanHostsIndexActionsBarContext', @@ -47,14 +54,6 @@ export const ForemanHostsIndexActionsBarContext = forceSingleton( ); const HostsIndex = () => { - const columns = { - name: { - title: __('Name'), - wrapper: ({ name }) => {name}, - isSorted: true, - }, - }; - const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); const history = useHistory(); const { location: { search: historySearch } = {} } = history || {}; const urlParams = new URLSearchParams(historySearch); @@ -69,12 +68,6 @@ const HostsIndex = () => { defaultParams, }); - const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ - defaultParams, - apiOptions, - setAPIOptions: response.setAPIOptions, - }); - const { response: { search: apiSearchQuery, @@ -89,6 +82,36 @@ const HostsIndex = () => { setAPIOptions, } = response; + const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ + defaultParams, + apiOptions, + setAPIOptions: response.setAPIOptions, + }); + + const allColumns = getColumnData({ tableName: 'hosts' }); + const { + hasPreference, + columns: userColumns, + currentUserId, + } = useCurrentUserTablePreferences({ + tableName: 'hosts', + }); + const isLoading = status === STATUS.PENDING; + const columns = filterColumnDataByUserPreferences( + isLoading, + userColumns, + allColumns + ); + const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); + + const columnSelectData = categoriesFromFrontendColumnData({ + registeredColumns: allColumns, + userId: currentUserId, + tableName: 'hosts', + userColumns, + hasPreference, + }); + const { pageRowCount } = getPageStats({ total, page, perPage }); const { fetchBulkParams, @@ -160,6 +183,7 @@ const HostsIndex = () => { value={{ ...selectAllOptions, fetchBulkParams }} > + ); @@ -304,6 +328,11 @@ const HostsIndex = () => { ); })} + + + ); }; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js index dbab54c1c66..2d03dedf2b9 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js @@ -77,3 +77,31 @@ export const useSetParamsAndApiAndSearch = ({ params, }; }; + +/** + * A hook that fetches the current user's preferences for which columns to display in a table + * @param {string} tableName the name of the table, such as 'hosts' + * @return {object} returns the current user's id and the columns + */ +export const useCurrentUserTablePreferences = ({ tableName }) => { + const currentUserResponse = useAPI('get', '/api/v2/current_user'); + const currentUserId = currentUserResponse.response?.id; + + const userTablePreferenceResponse = useAPI( + currentUserId ? 'get' : null, // only make the request if we have the id + `/api/v2/users/${currentUserId}/table_preferences/${tableName}` + ); + + const userTablePreferenceColumns = + userTablePreferenceResponse.response?.columns; + + const hasPreference = !( + userTablePreferenceResponse.response?.response?.status === 404 + ); + + return { + currentUserId, + hasPreference, + columns: userTablePreferenceColumns, + }; +}; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js index 8d7ef805e7a..21cf5dbc35c 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js @@ -21,7 +21,7 @@ export const getPageStats = ({ total, page, perPage }) => { /** * Assembles column data into various forms needed * @param {Object} columns - Object with column sort params as keys and column objects as values. Column objects must have a title key - * @returns {Array} - an array of column sort params and a map of keys to column names + * @returns {Array} - an array of column sort params, sorted by weight, and a map of keys to column names */ export const getColumnHelpers = columns => { const columnNamesKeys = Object.keys(columns); @@ -29,5 +29,44 @@ export const getColumnHelpers = columns => { columnNamesKeys.forEach(key => { keysToColumnNames[key] = columns[key].title; }); + columnNamesKeys.sort((a, b) => { + const columnBWeight = columns[b]?.weight; + const columnAWeight = columns[a]?.weight; + if (columnBWeight === undefined) { + return -1; + } + if (columnAWeight === undefined) { + return 1; + } + return columnAWeight - columnBWeight; + }); return [columnNamesKeys, keysToColumnNames]; }; + +export const DEFAULT_USER_COLUMNS = [ + 'name', + 'hostgroup', + 'os_title', + 'owner', + 'last_report', +]; + +/** + * Filters column data by user preferences + * @param {Array} columnNames - Array of column names from user preferences + * @param {Object} allColumnData - Object with column sort params as keys and column objects as values + * @returns {Object} - The filtered object with column sort params as keys and column objects as values + */ +export const filterColumnDataByUserPreferences = ( + isLoading, + columnNames = isLoading ? [] : DEFAULT_USER_COLUMNS, + allColumnData +) => { + const filteredColumns = {}; + columnNames.forEach(key => { + if (allColumnData[key]) { + filteredColumns[key] = allColumnData[key]; + } + }); + return filteredColumns; +};