diff --git a/app/views/models/index.html.erb b/app/views/models/index.html.erb index 287e7a8fb259..c6aceebe57ea 100644 --- a/app/views/models/index.html.erb +++ b/app/views/models/index.html.erb @@ -2,32 +2,7 @@ <% title_actions new_link(_("Create Model")) %> - - - - - - - - - - - - <% @models.each do |model| %> - - - - - - - - <% end %> - -
<%= sort :name, :as => s_("Model|Name") %><%= sort :vendor_class, :as => _("Vendor Class") %><%= sort :hardware_model, :as => s_("Model|Hardware Model") %><%= _("Hosts") %><%= _('Actions') %>
- <%= link_to_if_authorized model.name, - hash_for_edit_model_path(:id => model).merge(:auth_object => model, :authorizer => authorizer) %><%= model.vendor_class %><%= model.hardware_model %><%= link_to hosts_count[model], hosts_path(:search => "model = \"#{model}\"") %> - <%= action_buttons(display_delete_if_authorized(hash_for_model_path(:id => model). - merge(:auth_object => model, :authorizer => authorizer), - :confirm => _("Delete %s?") % model.name)) %> -
-<%= will_paginate_with_info @models %> +
+<%= mount_react_component("ModelsTable", "#models_table", { + pagination: react_pagination_props(@models, "models-pagination") +}.to_json) %> diff --git a/test/controllers/models_controller_test.rb b/test/controllers/models_controller_test.rb index 1fc03864a9ff..c7b1fb560f2a 100644 --- a/test/controllers/models_controller_test.rb +++ b/test/controllers/models_controller_test.rb @@ -2,7 +2,6 @@ class ModelsControllerTest < ActionController::TestCase basic_pagination_per_page_test - basic_pagination_rendered_test def test_index get :index, session: set_session_user diff --git a/test/integration/model_js_test.rb b/test/integration/model_js_test.rb index f71f56657c62..f5a88578f330 100644 --- a/test/integration/model_js_test.rb +++ b/test/integration/model_js_test.rb @@ -1,7 +1,21 @@ require 'integration_test_helper' -class ModelJSTest < IntegrationTestWithJavascript - test "index page" do - assert_index_page(models_path, "Hardware Models", "Create Model") +class ModelIntegrationTest < IntegrationTestWithJavascript + test "create new page" do + assert_new_button(models_path, "Create Model", new_model_path) + fill_in "model_name", :with => "IBM 123" + fill_in "model_hardware_model", :with => "IBMabcde" + fill_in "model_vendor_class", :with => "ABCDE" + fill_in "model_info", :with => "description text" + assert_submit_button(models_path) + assert page.has_link? "IBM 123" + end + + test "edit page" do + visit models_path + click_link "KVM" + fill_in "model_name", :with => "RHEV 123" + assert_submit_button(models_path) + assert page.has_link? 'RHEV 123' end end diff --git a/test/integration/model_test.rb b/test/integration/model_test.rb deleted file mode 100644 index c4db3a623e45..000000000000 --- a/test/integration/model_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'integration_test_helper' - -class ModelIntegrationTest < ActionDispatch::IntegrationTest - test "create new page" do - assert_new_button(models_path, "Create Model", new_model_path) - fill_in "model_name", :with => "IBM 123" - fill_in "model_hardware_model", :with => "IBMabcde" - fill_in "model_vendor_class", :with => "ABCDE" - fill_in "model_info", :with => "description text" - assert_submit_button(models_path) - assert page.has_link? "IBM 123" - end - - test "edit page" do - visit models_path - click_link "KVM" - fill_in "model_name", :with => "RHEV 123" - assert_submit_button(models_path) - assert page.has_link? 'RHEV 123' - end -end diff --git a/webpack/assets/javascripts/react_app/common/helpers.js b/webpack/assets/javascripts/react_app/common/helpers.js index bd473aa0b497..ea2d7f8daa8d 100644 --- a/webpack/assets/javascripts/react_app/common/helpers.js +++ b/webpack/assets/javascripts/react_app/common/helpers.js @@ -1,4 +1,5 @@ import debounce from 'lodash/debounce'; +import URI from 'urijs'; import { translate as __ } from './I18n'; /** @@ -72,6 +73,13 @@ export const translateObject = obj => */ export const translateArray = arr => arr.map(str => __(str)); +/** + * Return the query in URL as Objects where keys are + * the parameters and the values are the parameters' values. + * @param {String} url - the URL + */ +export const getURIQuery = url => new URI(url).query(true); + export default { bindMethods, noop, diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTable.js b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTable.js new file mode 100644 index 000000000000..7759a0ac5d52 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTable.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { LoadingState } from 'patternfly-react'; +import PropTypes from 'prop-types'; +import { Table } from '../common/table'; +import { STATUS } from '../../constants'; +import MessageBox from '../common/MessageBox'; +import { translate as __ } from '../../common/I18n'; +import createModelsTableSchema from './ModelsTableSchema'; +import { getURIQuery } from '../../common/helpers'; +import Pagination from '../Pagination/Pagination'; + +class ModelsTable extends React.Component { + componentDidMount() { + this.props.getTableItems('models', getURIQuery(window.location.href)); + } + + render() { + const { + getTableItems, + sortBy, + sortOrder, + error, + status, + results, + data: { pagination }, + } = this.props; + + const renderTable = + status === STATUS.ERROR ? ( + + ) : ( + + + + + ); + + return ( + + {renderTable} + + ); + } +} + +ModelsTable.propTypes = { + data: PropTypes.shape({ + pagination: PropTypes.shape({ + viewType: PropTypes.string, + perPageOptions: PropTypes.arrayOf(PropTypes.number), + itemCount: PropTypes.number, + perPage: PropTypes.number, + }).isRequired, + }).isRequired, + results: PropTypes.array.isRequired, + getTableItems: PropTypes.func.isRequired, + status: PropTypes.oneOf(Object.keys(STATUS)), + sortBy: PropTypes.string, + sortOrder: PropTypes.string, + error: PropTypes.object, +}; + +ModelsTable.defaultProps = { + status: STATUS.PENDING, + sortBy: '', + sortOrder: '', + error: null, +}; + +export default ModelsTable; diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTable.test.js b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTable.test.js new file mode 100644 index 000000000000..dd2ebc203426 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTable.test.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ModelsTable from './ModelsTable'; +import { Table } from '../common/table'; +import MessageBox from '../common/MessageBox'; + +const data = { + pagination: { + vieType: 'table', + perPageOptions: [1, 2, 3], + itemCount: 5, + perPage: 1, + }, +}; + +describe('ModelsTable', () => { + it('render table on sucess', () => { + const getModelItems = jest.fn().mockReturnValue([]); + const view = shallow( + + ); + expect(getModelItems.mock.calls).toHaveLength(1); + expect(view.find(Table)).toHaveLength(1); + }); + + it('render error message box on failure', () => { + const view = shallow( + [])} + results={[]} + error={Error('some error message')} + status="ERROR" + data={data} + /> + ); + expect(view.find(MessageBox)).toHaveLength(1); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableConstants.js b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableConstants.js new file mode 100644 index 000000000000..99546a60d81b --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableConstants.js @@ -0,0 +1,6 @@ +import createTableActionTypes from '../common/table/actionsHelpers/actionTypeCreator'; + +export const MODELS_TABLE_CONTROLLER = 'models'; +export const MODELS_TABLE_ACTION_TYPES = createTableActionTypes( + MODELS_TABLE_CONTROLLER +); diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableReducer.js b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableReducer.js new file mode 100644 index 000000000000..40cfa1fd327f --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableReducer.js @@ -0,0 +1,34 @@ +import Immutable from 'seamless-immutable'; +import { STATUS } from '../../constants'; +import { MODELS_TABLE_ACTION_TYPES } from './ModelsTableConstants'; + +const initState = Immutable({ + error: null, + sortBy: '', + sortOrder: '', + results: [], + status: STATUS.PENDING, +}); +export default (state = initState, action) => { + const { REQUEST, FAILURE, SUCCESS } = MODELS_TABLE_ACTION_TYPES; + switch (action.type) { + case REQUEST: + return state.set('status', STATUS.PENDING); + case SUCCESS: + return Immutable.merge(state, { + error: null, + status: STATUS.RESOLVED, + results: action.payload.results, + sortBy: action.payload.sort.by, + sortOrder: action.payload.sort.order, + }); + case FAILURE: + return Immutable.merge(state, { + error: action.payload.error, + status: STATUS.ERROR, + results: [], + }); + default: + return state; + } +}; diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableReducer.test.js b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableReducer.test.js new file mode 100644 index 000000000000..d51987ba914a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableReducer.test.js @@ -0,0 +1,36 @@ +import { testReducerSnapshotWithFixtures } from '../../common/testHelpers'; +import reducer from './ModelsTableReducer'; +import { MODELS_TABLE_ACTION_TYPES } from './ModelsTableConstants'; + +const fixtures = { + 'should return initial state': {}, + 'should handle MODELS_TABLE_REQUEST': { + action: { + type: MODELS_TABLE_ACTION_TYPES.REQUEST, + }, + }, + 'should handle MODELS_TABLE_SUCCESS': { + action: { + type: MODELS_TABLE_ACTION_TYPES.SUCCESS, + payload: { + search: 'name=model', + results: [{ id: 23, name: 'model' }], + page: 1, + per_page: 5, + total: 20, + sort: { by: 'name', order: 'ASC' }, + }, + }, + }, + 'should handle MODELS_TABLE_FAILURE': { + action: { + type: MODELS_TABLE_ACTION_TYPES.FAILURE, + payload: { + error: new Error('ooops!'), + }, + }, + }, +}; + +describe('ModelsTable reducer', () => + testReducerSnapshotWithFixtures(reducer, fixtures)); diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableSchema.js b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableSchema.js new file mode 100644 index 000000000000..ca5cd87ce59a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/ModelsTableSchema.js @@ -0,0 +1,48 @@ +import { + sortControllerFactory, + column, + sortableColumn, + headerFormatterWithProps, + cellFormatterWithProps, + nameCellFormatter, + hostsCountCellFormatter, + deleteActionCellFormatter, + cellFormatter, +} from '../common/table'; + +/** + * Generate a table schema to the Hardware Models page. + * @param {Function} apiCall a Redux async action that fetches and stores table data in Redux. + * See actions/common/getTableItemsAction. + * @param {String} by by which column the table is sorted. + * If none then set it to undefined/null. + * @param {String} order in what order to sort a column. If none then set it to undefined/null. + * Otherwise, 'ASC' for ascending and 'DESC' for descending + * @return {Array} + */ +const createModelsTableSchema = (apiCall, by, order) => { + const sortController = sortControllerFactory(apiCall, by, order); + return [ + sortableColumn('name', __('Name'), 4, sortController, [ + nameCellFormatter('models'), + ]), + sortableColumn('vendor_class', __('Vendor Class'), 3, sortController), + sortableColumn('hardware_model', __('Hardware Model'), 3, sortController), + column( + 'hosts_count', + __('Hosts'), + [headerFormatterWithProps], + [hostsCountCellFormatter('model'), cellFormatterWithProps], + { className: 'col-md-1' }, + { align: 'right' } + ), + column( + 'actions', + __('Actions'), + [headerFormatterWithProps], + [deleteActionCellFormatter('models'), cellFormatter] + ), + ]; +}; + +export default createModelsTableSchema; diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/__snapshots__/ModelsTableReducer.test.js.snap b/webpack/assets/javascripts/react_app/components/ModelsTable/__snapshots__/ModelsTableReducer.test.js.snap new file mode 100644 index 000000000000..ad5360a746fb --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/__snapshots__/ModelsTableReducer.test.js.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModelsTable reducer should handle MODELS_TABLE_FAILURE 1`] = ` +Object { + "error": [Error: ooops!], + "results": Array [], + "sortBy": "", + "sortOrder": "", + "status": "ERROR", +} +`; + +exports[`ModelsTable reducer should handle MODELS_TABLE_REQUEST 1`] = ` +Object { + "error": null, + "results": Array [], + "sortBy": "", + "sortOrder": "", + "status": "PENDING", +} +`; + +exports[`ModelsTable reducer should handle MODELS_TABLE_SUCCESS 1`] = ` +Object { + "error": null, + "results": Array [ + Object { + "id": 23, + "name": "model", + }, + ], + "sortBy": "name", + "sortOrder": "ASC", + "status": "RESOLVED", +} +`; + +exports[`ModelsTable reducer should return initial state 1`] = ` +Object { + "error": null, + "results": Array [], + "sortBy": "", + "sortOrder": "", + "status": "PENDING", +} +`; diff --git a/webpack/assets/javascripts/react_app/components/ModelsTable/index.js b/webpack/assets/javascripts/react_app/components/ModelsTable/index.js new file mode 100644 index 000000000000..743645b73898 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/ModelsTable/index.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import ModelsTable from './ModelsTable'; +import reducer from './ModelsTableReducer'; +import * as actions from '../common/table/actions/getTableItemsAction'; + +const mapStateToProps = state => state.models; +const mapDispatchToProps = dispatch => bindActionCreators(actions, dispatch); + +export const reducers = { models: reducer }; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ModelsTable); diff --git a/webpack/assets/javascripts/react_app/components/common/table/actions/getTableItemsAction.js b/webpack/assets/javascripts/react_app/components/common/table/actions/getTableItemsAction.js new file mode 100644 index 000000000000..fc2f02abfe87 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/actions/getTableItemsAction.js @@ -0,0 +1,21 @@ +import URI from 'urijs'; +import { ajaxRequestAction } from '../../../../redux/actions/common'; + +/** + * An async Redux action that fetches and stores table data in Redux. + * @param {String} controller the controller name + * @param {Object} query the API request query + * @return {Function} Redux Thunk function + */ +export const getTableItems = (controller, query) => dispatch => { + const url = new URI(`/api/${controller}`); + url.addSearch({ ...query, include_permissions: true }); + return ajaxRequestAction({ + dispatch, + requestAction: `${controller.toUpperCase()}_TABLE_REQUEST`, + successAction: `${controller.toUpperCase()}_TABLE_SUCCESS`, + failedAction: `${controller.toUpperCase()}_TABLE_FAILURE`, + url: url.toString(), + item: { controller, url: url.toString() }, + }); +}; diff --git a/webpack/assets/javascripts/react_app/components/common/table/actions/getTableItemsAction.test.js b/webpack/assets/javascripts/react_app/components/common/table/actions/getTableItemsAction.test.js new file mode 100644 index 000000000000..c58ffe9d5a31 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/actions/getTableItemsAction.test.js @@ -0,0 +1,22 @@ +import { getTableItems } from './getTableItemsAction'; +import { ajaxRequestAction } from '../../../../redux/actions/common'; + +jest.mock('../../../../redux/actions/common'); +describe('getTableItems', () => { + it('getTableItems should call ajaxRequestAction with url ', () => { + const controller = 'models'; + const url = '/api/models?include_permissions=true'; + const dispatch = jest.fn(); + const expectedParams = { + dispatch, + failedAction: 'MODELS_TABLE_FAILURE', + requestAction: 'MODELS_TABLE_REQUEST', + successAction: 'MODELS_TABLE_SUCCESS', + url, + item: { controller, url }, + }; + const dispatcher = getTableItems(controller, {}, url); + dispatcher(dispatch); + expect(ajaxRequestAction).toBeCalledWith(expectedParams); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/actionsHelpers/actionTypeCreator.js b/webpack/assets/javascripts/react_app/components/common/table/actionsHelpers/actionTypeCreator.js new file mode 100644 index 000000000000..6432bb4b44ab --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/actionsHelpers/actionTypeCreator.js @@ -0,0 +1,7 @@ +const createTableActionTypes = controller => ({ + REQUEST: `${controller.toUpperCase()}_TABLE_REQUEST`, + SUCCESS: `${controller.toUpperCase()}_TABLE_SUCCESS`, + FAILURE: `${controller.toUpperCase()}_TABLE_FAILURE`, +}); + +export default createTableActionTypes; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/DeleteButton.js b/webpack/assets/javascripts/react_app/components/common/table/components/DeleteButton.js new file mode 100644 index 000000000000..26bfab3c1d55 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/DeleteButton.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'patternfly-react'; +import { translate as __ } from '../../../../common/I18n'; + +// TODO(bshuster): Move the confirmation to DialogModal that uses API to +// delete the item. +const DeleteButton = ({ active, id, name, controller }) => + active ? ( + + ) : null; + +DeleteButton.propTypes = { + active: PropTypes.bool, + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + controller: PropTypes.string.isRequired, +}; + +DeleteButton.defaultProps = { + active: false, +}; + +export default DeleteButton; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/DeleteButton.test.js b/webpack/assets/javascripts/react_app/components/common/table/components/DeleteButton.test.js new file mode 100644 index 000000000000..26083b2e50bd --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/DeleteButton.test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Button } from 'patternfly-react'; +import DeleteButton from './DeleteButton'; + +describe('DeleteButton', () => { + it('should render delete button on active', () => { + const view = shallow( + + ); + const button = view.find(Button); + expect(button.props()['data-method']).toBe('delete'); + expect(button.props()['data-confirm']).toBe('Delete KVM?'); + expect(button.props().href).toBe('models/1-KVM'); + expect(button.props().children).toBe('Delete'); + }); + it('should render nothing on non-active', () => { + const view = shallow( + + ); + expect(view.find(Button)).toHaveLength(0); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/HostsCountCell.js b/webpack/assets/javascripts/react_app/components/common/table/components/HostsCountCell.js new file mode 100644 index 000000000000..de846c1bbe65 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/HostsCountCell.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const HostsCountCell = ({ name, controller, children }) => ( + {children} +); + +HostsCountCell.propTypes = { + name: PropTypes.string.isRequired, + controller: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +HostsCountCell.defaultProps = {}; + +export default HostsCountCell; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/HostsCountCell.test.js b/webpack/assets/javascripts/react_app/components/common/table/components/HostsCountCell.test.js new file mode 100644 index 000000000000..cc370d60d3dc --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/HostsCountCell.test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import HostsCountCell from './HostsCountCell'; + +describe('HostsCountCell', () => { + it('should render link', () => { + const text = 3; + const view = shallow( + + {text} + + ); + expect(view.find('a').props().href).toBe( + 'hosts?search=model+%3D+"model-x.1"' + ); + expect(view.find('a').text()).toBe(`${text}`); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/NameCell.js b/webpack/assets/javascripts/react_app/components/common/table/components/NameCell.js new file mode 100644 index 000000000000..aa04bb090bd1 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/NameCell.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const NameCell = ({ active, id, name, controller, children }) => + active ? ( + + {children} + + ) : ( + {}}> + {children} + + ); + +NameCell.propTypes = { + active: PropTypes.bool, + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + controller: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +NameCell.defaultProps = { + active: false, +}; + +export default NameCell; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/NameCell.test.js b/webpack/assets/javascripts/react_app/components/common/table/components/NameCell.test.js new file mode 100644 index 000000000000..3aea43fd6eef --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/NameCell.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import NameCell from './NameCell'; + +describe('NameCell', () => { + it('should render active link', () => { + const text = 'KVM model'; + const view = shallow( + + {text} + + ); + expect(view.find('a').props().href).toBe('/models/1-KVM/edit'); + expect(view.find('a').text()).toBe(text); + }); + it('should render disabled link', () => { + const text = 'HyperV model'; + const view = shallow( + + {text} + + ); + expect(view.find('a').props().href).toBe('#'); + expect(view.find('a').props().disabled).toBe('disabled'); + expect(view.find('a').props().className).toBe('disabled'); + expect(view.find('a').text()).toBe(text); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/SortableHeader.js b/webpack/assets/javascripts/react_app/components/common/table/components/SortableHeader.js new file mode 100644 index 000000000000..7794b1e5cbec --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/SortableHeader.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const SortableHeader = ({ onClick, children, sortOrder }) => ( + + {sortOrder && } + {children} + +); + +SortableHeader.propTypes = { + onClick: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, + sortOrder: PropTypes.oneOf(['asc', 'desc', null]), +}; + +SortableHeader.defaultProps = { + sortOrder: null, +}; + +export default SortableHeader; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/SortableHeader.test.js b/webpack/assets/javascripts/react_app/components/common/table/components/SortableHeader.test.js new file mode 100644 index 000000000000..294720f541ef --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/SortableHeader.test.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import SortalbeHeader from './SortableHeader'; + +describe('SortalbeHeader', () => { + it('should render no icon if sortOrder is null', () => { + const view = shallow( + + Header Title + + ); + expect(view.find('i')).toHaveLength(0); + }); + + it('should render fa-sort-asc icon if sortOrder is asc', () => { + const view = shallow( + + Header Title + + ); + expect(view.find('i')).toHaveLength(1); + expect(view.find('i').props().className).toBe('fa fa-sort-asc'); + }); + + it('should render fa-sort-desc icon if sortOrder is desc', () => { + const view = shallow( + + Header Title + + ); + expect(view.find('i')).toHaveLength(1); + expect(view.find('i').props().className).toBe('fa fa-sort-desc'); + }); + + it('should trigger onClick when clicked', () => { + const clickFnc = jest.fn(); + const view = shallow( + Header Title + ); + expect(clickFnc).not.toBeCalled(); + view.simulate('click'); + expect(clickFnc).toBeCalled(); + }); + + it('should render children in the link text', () => { + const view = shallow( + Text + ); + expect(view.find('a').text()).toBe('Text'); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/Table.js b/webpack/assets/javascripts/react_app/components/common/table/components/Table.js new file mode 100644 index 000000000000..711f0474b058 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/Table.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table as PfTable } from 'patternfly-react'; +import TableBody from './TableBody'; + +const Table = ({ columns, rows, bodyMessage, children, ...props }) => { + const body = children || [ + , + , + ]; + + return ( +
+ + {body} + +
+ ); +}; + +Table.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + rows: PropTypes.arrayOf(PropTypes.object).isRequired, + bodyMessage: PropTypes.node, + children: PropTypes.node, +}; + +Table.defaultProps = { + bodyMessage: undefined, + children: undefined, +}; + +export default Table; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/Table.test.js b/webpack/assets/javascripts/react_app/components/common/table/components/Table.test.js new file mode 100644 index 000000000000..e0e45788369b --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/Table.test.js @@ -0,0 +1,19 @@ +import { testComponentSnapshotsWithFixtures } from '../../../../common/testHelpers'; + +import Table from './Table'; +import { columnsFixtures, rowsFixtures } from './TableFixtures'; + +const fixtures = { + 'renders Table with children': { + columns: columnsFixtures, + rows: rowsFixtures, + children: 'some children', + }, + 'renders Table with body': { + columns: columnsFixtures, + rows: rowsFixtures, + bodyMessage: 'some body message', + }, +}; + +describe('Table', () => testComponentSnapshotsWithFixtures(Table, fixtures)); diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/TableBody.js b/webpack/assets/javascripts/react_app/components/common/table/components/TableBody.js new file mode 100644 index 000000000000..bc9d5df15aaa --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/TableBody.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table as PfTable } from 'patternfly-react'; + +import TableBodyMessage from './TableBodyMessage'; + +const TableBody = ({ columns, rows, message, ...props }) => { + if (message) { + return ( + {message} + ); + } + + return ( + rowIndex} {...props} /> + ); +}; + +TableBody.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + rows: PropTypes.arrayOf(PropTypes.object).isRequired, + message: PropTypes.string, +}; + +TableBody.defaultProps = { + message: '', +}; + +export default TableBody; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/TableBody.test.js b/webpack/assets/javascripts/react_app/components/common/table/components/TableBody.test.js new file mode 100644 index 000000000000..4771b958b5d2 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/TableBody.test.js @@ -0,0 +1,19 @@ +import { testComponentSnapshotsWithFixtures } from '../../../../common/testHelpers'; + +import TableBody from './TableBody'; +import { columnsFixtures, rowsFixtures } from './TableFixtures'; + +const fixtures = { + 'renders TableBody': { + columns: columnsFixtures, + rows: rowsFixtures, + }, + 'renders TableBody with message': { + columns: columnsFixtures, + rows: rowsFixtures, + message: 'some message', + }, +}; + +describe('TableBody', () => + testComponentSnapshotsWithFixtures(TableBody, fixtures)); diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/TableBodyMessage.js b/webpack/assets/javascripts/react_app/components/common/table/components/TableBodyMessage.js new file mode 100644 index 000000000000..7cad09c1e931 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/TableBodyMessage.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const TableBodyMessage = ({ colSpan, children }) => ( +
+ + + + +); + +TableBodyMessage.propTypes = { + colSpan: PropTypes.number.isRequired, + children: PropTypes.node.isRequired, +}; + +export default TableBodyMessage; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/TableBodyMessage.test.js b/webpack/assets/javascripts/react_app/components/common/table/components/TableBodyMessage.test.js new file mode 100644 index 000000000000..b804a385102b --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/TableBodyMessage.test.js @@ -0,0 +1,13 @@ +import { testComponentSnapshotsWithFixtures } from '../../../../common/testHelpers'; + +import TableBodyMessage from './TableBodyMessage'; + +const fixtures = { + 'renders TableBodyMessage': { + colSpan: 2, + children: 'some children', + }, +}; + +describe('TableBodyMessage', () => + testComponentSnapshotsWithFixtures(TableBodyMessage, fixtures)); diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/TableFixtures.js b/webpack/assets/javascripts/react_app/components/common/table/components/TableFixtures.js new file mode 100644 index 000000000000..3d3a013394f7 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/TableFixtures.js @@ -0,0 +1,20 @@ +// see: https://reactabular.js.org/#/column-definition +export const columnsFixtures = [ + { + property: 'id', + header: { + label: 'ID', + }, + }, + { + property: 'data', + header: { + label: 'Data', + }, + }, +]; + +export const rowsFixtures = [ + { id: 1, data: 'data-1' }, + { id: 2, data: 'data-2' }, +]; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/Table.test.js.snap b/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/Table.test.js.snap new file mode 100644 index 000000000000..9bf9ca84cee8 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/Table.test.js.snap @@ -0,0 +1,102 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table renders Table with body 1`] = ` +
+ +
+ + +
+`; + +exports[`Table renders Table with children 1`] = ` +
+ + some children + +
+`; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/TableBody.test.js.snap b/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/TableBody.test.js.snap new file mode 100644 index 000000000000..259da214c303 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/TableBody.test.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TableBody renders TableBody 1`] = ` + +`; + +exports[`TableBody renders TableBody with message 1`] = ` + + some message + +`; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/TableBodyMessage.test.js.snap b/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/TableBodyMessage.test.js.snap new file mode 100644 index 000000000000..b3bb0a87f52a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/__snapshots__/TableBodyMessage.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TableBodyMessage renders TableBodyMessage 1`] = ` +
+ + + + +`; diff --git a/webpack/assets/javascripts/react_app/components/common/table/components/index.js b/webpack/assets/javascripts/react_app/components/common/table/components/index.js new file mode 100644 index 000000000000..7fd301484e80 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/components/index.js @@ -0,0 +1,3 @@ +export { default as Table } from './Table'; +export { default as TableBody } from './TableBody'; +export { default as TableBodyMessage } from './TableBodyMessage'; diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/cellFormatter.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/cellFormatter.js new file mode 100644 index 000000000000..297152540aea --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/cellFormatter.js @@ -0,0 +1,4 @@ +import React from 'react'; +import { Table as PfTable } from 'patternfly-react'; + +export default value => {value}; diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/deleteActionCellFormatter.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/deleteActionCellFormatter.js new file mode 100644 index 000000000000..7062408872b8 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/deleteActionCellFormatter.js @@ -0,0 +1,14 @@ +import React from 'react'; +import DeleteButton from '../components/DeleteButton'; + +export const deleteActionCellFormatter = controllerPluralize => ( + _, + { rowData: { can_delete: canDelete, name, id } } +) => ( + +); diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/ellipsisCellFormatter.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/ellipsisCellFormatter.js new file mode 100644 index 000000000000..3764a83df92a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/ellipsisCellFormatter.js @@ -0,0 +1,6 @@ +import React from 'react'; +import EllipsisWithTooltip from 'react-ellipsis-with-tooltip'; +import cellFormatter from './cellFormatter'; + +export default value => + cellFormatter({value}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/formatterWithProps.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/formatterWithProps.js new file mode 100644 index 000000000000..851ee0d9a64a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/formatterWithProps.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { Table as PfTable } from 'patternfly-react'; + +export const withProps = Component => ( + value, + { + column: { + header: { props }, + }, + } +) => {value}; + +export const headerFormatterWithProps = withProps(PfTable.Heading); +export const cellFormatterWithProps = withProps(PfTable.Cell); diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/hostsCountCellFormatter.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/hostsCountCellFormatter.js new file mode 100644 index 000000000000..62760bff564f --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/hostsCountCellFormatter.js @@ -0,0 +1,13 @@ +import React from 'react'; +import HostsCountCell from '../components/HostsCountCell'; + +const hostsCountCellFormatter = controllerSingular => ( + value, + { rowData: { name } } +) => ( + + {value} + +); + +export default hostsCountCellFormatter; diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/index.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/index.js new file mode 100644 index 000000000000..463b73099c75 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/index.js @@ -0,0 +1,10 @@ +export { + headerFormatterWithProps, + cellFormatterWithProps, +} from './formatterWithProps'; +export { default as cellFormatter } from './cellFormatter'; +export { default as ellipsisCellFormatter } from './ellipsisCellFormatter'; +export { default as nameCellFormatter } from './nameCellFormatter'; +export { default as hostsCountCellFormatter } from './hostsCountCellFormatter'; +export { default as sortableHeaderFormatter } from './sortableHeaderFormatter'; +export { deleteActionCellFormatter } from './deleteActionCellFormatter'; diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/nameCellFormatter.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/nameCellFormatter.js new file mode 100644 index 000000000000..e999802842ff --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/nameCellFormatter.js @@ -0,0 +1,18 @@ +import React from 'react'; +import NameCell from '../components/NameCell'; + +const nameCellFormatter = controllerPluralize => ( + value, + { rowData: { can_edit: canEdit, id, name } } +) => ( + + {value} + +); + +export default nameCellFormatter; diff --git a/webpack/assets/javascripts/react_app/components/common/table/formatters/sortableHeaderFormatter.js b/webpack/assets/javascripts/react_app/components/common/table/formatters/sortableHeaderFormatter.js new file mode 100644 index 000000000000..11a9f3eb494a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/formatters/sortableHeaderFormatter.js @@ -0,0 +1,19 @@ +import React from 'react'; +import SortableHeader from '../components/SortableHeader'; + +const sortableHeaderFormatter = sortController => (label, { property }) => { + const isSorter = property === sortController.property; + const currentOrder = isSorter ? sortController.order : ''; + const nextOrder = currentOrder === 'ASC' ? 'DESC' : 'ASC'; + + return ( + { + sortController.apply(property, nextOrder); + }} + sortOrder={isSorter ? sortController.order.toLowerCase() : null} + >{` ${label}`} + ); +}; + +export default sortableHeaderFormatter; diff --git a/webpack/assets/javascripts/react_app/components/common/table/index.js b/webpack/assets/javascripts/react_app/components/common/table/index.js new file mode 100644 index 000000000000..2524205f3d79 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/index.js @@ -0,0 +1,3 @@ +export * from './formatters'; +export * from './components'; +export * from './schemaHelpers'; diff --git a/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/column.js b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/column.js new file mode 100644 index 000000000000..a19a1afb61f1 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/column.js @@ -0,0 +1,35 @@ +/** + * Generate a column for a patternfly-3 table. + * See more in http://patternfly-react.surge.sh/patternfly-3/ + * See an example: components ModelsTableSchema + * @param {String} property the property name of the table. + * @param {String} label the column label. + * @param {Array} headFormat array of functions that format the header. Read more about format + * functions here: + * https://reactabular.js.org/#/column-definition/formatters + * @param {Array} cellFormat array of functions that format column cells. Read more about format + * functions here: + * https://reactabular.js.org/#/column-definition/formatters + * @param {Object} headProps React props that can be passed to the header. + * @param {Object} cellProps React props that can be passed to cells. + * @return {Object} the table column. + */ +export const column = ( + property, + label, + headFormat, + cellFormat, + headProps = {}, + cellProps = {} +) => ({ + property, + header: { + label: __(label), + props: headProps, + formatters: headFormat, + }, + cell: { + props: cellProps, + formatters: cellFormat, + }, +}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/index.js b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/index.js new file mode 100644 index 000000000000..9a7f44598c20 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/index.js @@ -0,0 +1,2 @@ +export { sortControllerFactory, sortableColumn } from './sortableColumn'; +export { column } from './column'; diff --git a/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/schemaHelpers.test.js b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/schemaHelpers.test.js new file mode 100644 index 000000000000..2dde29cf8e9f --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/schemaHelpers.test.js @@ -0,0 +1,18 @@ +import { sortControllerFactory } from './index'; + +describe('sortControllerFactory', () => { + it('should return a sortController', () => { + const by = 'name'; + const order = 'DESC'; + const sortCtrl = sortControllerFactory(jest.fn(), by, order); + expect(sortCtrl.property).toBe(by); + expect(sortCtrl.order).toBe(order); + }); + + it('should call apiCall when apply', () => { + const apiCall = jest.fn(); + const sortCtrl = sortControllerFactory(apiCall, '', ''); + sortCtrl.apply('nickname', 'ASC'); + expect(apiCall).toBeCalledWith('models', { order: 'nickname ASC' }); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/sortableColumn.js b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/sortableColumn.js new file mode 100644 index 000000000000..0bb9854f6451 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/table/schemaHelpers/sortableColumn.js @@ -0,0 +1,55 @@ +import URI from 'urijs'; +import { + ellipsisCellFormatter, + headerFormatterWithProps, + sortableHeaderFormatter, +} from '../formatters'; +import { column } from './column'; + +/** + * Generate a sortable column for a patternfly-3 table. + * See more in http://patternfly-react.surge.sh/patternfly-3/ + * See an example: ModelsTableSchema + * @param {String} property the property name of the table. + * @param {String} label the column label. + * @param {Number} mdWidth column size on medium devices. Note: using bootstrap + * grid convention. + * @param {Object} sortController sortController object. + * See more in sortControllerFactory. + * @param {Array} additionalCellFormatters array of functions that format column cells + * @return {Object} the table column. + */ +export const sortableColumn = ( + property, + label, + mdWidth, + sortController, + additionalCellFormatters = [] +) => + column( + property, + label, + [sortableHeaderFormatter(sortController), headerFormatterWithProps], + [...additionalCellFormatters, ellipsisCellFormatter], + { sort: true, sortDirection: '', className: `col-md-${mdWidth}` } + ); + +/** + * Creates a sort controller for Patternfly-3 table. + * @param {Function} apiCall a function that fetches and stores data into Redux. + * @param {String} sortBy the property that the table is sorted by. + * @param {String} sortOrder the order which the table is sorted by. + * @return {Object} a sort controller object. + */ +export const sortControllerFactory = (apiCall, sortBy, sortOrder) => ({ + apply: (by, order) => { + const uri = new URI(window.location.href); + uri.setSearch('order', `${by} ${order}`); + // FIXME(bshuster): Going back in the browser won't render the state. + // Using react-router will fix this completely. + window.history.pushState({ path: uri.toString() }, '', uri.toString()); + apiCall('models', uri.query(true)); + }, + property: sortBy, + order: sortOrder, +}); diff --git a/webpack/assets/javascripts/react_app/components/componentRegistry.js b/webpack/assets/javascripts/react_app/components/componentRegistry.js index 7c07ab033533..f60ef63cf763 100644 --- a/webpack/assets/javascripts/react_app/components/componentRegistry.js +++ b/webpack/assets/javascripts/react_app/components/componentRegistry.js @@ -23,6 +23,7 @@ import ChartBox from './statistics/ChartBox'; import ConfigReports from './ConfigReports/ConfigReports'; import DiffModal from './ConfigReports/DiffModal'; import { WrapperFactory } from './wrapperFactory'; +import ModelsTable from './ModelsTable'; // Pages import AuditsPage from '../pages/AuditsPage/AuditsPage'; @@ -136,6 +137,7 @@ const coreComponets = [ data: true, store: false, }, + { name: 'ModelsTable', type: ModelsTable }, // Pages { name: 'AuditsPage', type: AuditsPage }, diff --git a/webpack/assets/javascripts/react_app/redux/reducers/index.js b/webpack/assets/javascripts/react_app/redux/reducers/index.js index f3e22346d772..d98a9c23d9de 100644 --- a/webpack/assets/javascripts/react_app/redux/reducers/index.js +++ b/webpack/assets/javascripts/react_app/redux/reducers/index.js @@ -11,6 +11,7 @@ import { reducers as searchBarReducers } from '../../components/SearchBar'; import { reducers as layoutReducers } from '../../components/Layout'; import { reducers as diffModalReducers } from '../../components/ConfigReports/DiffModal'; import factChart from './factCharts'; +import { reducers as modelsReducers } from '../../components/ModelsTable'; export function combineReducersAsync(asyncReducers) { return combineReducers({ @@ -27,6 +28,7 @@ export function combineReducersAsync(asyncReducers) { ...searchBarReducers, ...diffModalReducers, factChart, + ...modelsReducers, }); }
{children}
+ some children +