From f06f1b3511a06b1ff67cc24f32e7dcf2d87cda6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Pra=C5=BE=C3=A1k?= Date: Tue, 12 Mar 2019 15:40:56 +0100 Subject: [PATCH] Fixes #25309 - Switcher for ansible roles (#217) --- .bablerc => .babelrc | 0 .eslintrc | 53 +-- .travis.yml | 5 + .../ui_ansible_roles_controller.rb | 14 + .../foreman_ansible/ansible_roles_helper.rb | 4 + app/models/ansible_role.rb | 1 + .../_select_tab_content.html.erb | 26 +- app/views/ui_ansible_roles/index.json.rabl | 3 + app/views/ui_ansible_roles/main.json.rabl | 3 + app/views/ui_ansible_roles/show.json.rabl | 3 + config/routes.rb | 2 + lib/foreman_ansible/register.rb | 3 +- package.json | 59 ++- script/travis_run_js_tests.sh | 4 + .../ui_ansible_roles_controller_test.rb | 14 + .../Pagination/PaginationWrapper.js | 2 + .../components/common/EmptyState.js | 5 + .../AnsibleRolesSwitcher.js | 140 ++++++ .../AnsibleRolesSwitcher.scss | 45 ++ .../AnsibleRolesSwitcherActions.js | 69 +++ .../AnsibleRolesSwitcherConstants.js | 7 + .../AnsibleRolesSwitcherHelpers.js | 7 + .../AnsibleRolesSwitcherReducer.js | 69 +++ .../AnsibleRolesSwitcherSelectors.js | 68 +++ .../__fixtures__/ansibleRolesData.fixtures.js | 20 + .../ansibleRolesSwitcherReducer.fixtures.js | 36 ++ .../__tests__/AnsibleRolesSwitcher.test.js | 30 ++ .../AnsibleRolesSwitcherReducer.test.js | 73 ++++ .../AnsibleRolesSwitcherSelectors.test.js | 43 ++ .../AnsibleRolesSwitcher.test.js.snap | 79 ++++ .../AnsibleRolesSwitcherReducer.test.js.snap | 399 ++++++++++++++++++ ...AnsibleRolesSwitcherSelectors.test.js.snap | 60 +++ .../components/AnsiblePermissionDenied.js | 33 ++ .../AnsiblePermissionDenied.test.js | 9 + .../components/AnsibleRole.js | 56 +++ .../components/AnsibleRole.test.js | 26 ++ .../components/AnsibleRoleActionButton.js | 16 + .../components/AnsibleRolesSwitcherError.js | 32 ++ .../components/AssignedRolesList.js | 67 +++ .../components/AssignedRolesList.test.js | 19 + .../components/AvailableRolesList.js | 52 +++ .../components/AvailableRolesList.test.js | 22 + .../AnsiblePermissionDenied.test.js.snap | 26 ++ .../__snapshots__/AnsibleRole.test.js.snap | 108 +++++ .../AssignedRolesList.test.js.snap | 64 +++ .../AvailableRolesList.test.js.snap | 54 +++ .../components/withProtectedView.js | 14 + .../components/AnsibleRolesSwitcher/index.js | 44 ++ webpack/components/ReportJsonViewer.js | 18 +- webpack/index.js | 15 +- webpack/reducer.js | 7 + webpack/test_setup.js | 11 + 52 files changed, 1971 insertions(+), 68 deletions(-) rename .bablerc => .babelrc (100%) create mode 100644 .travis.yml create mode 100644 app/controllers/ui_ansible_roles_controller.rb create mode 100644 app/views/ui_ansible_roles/index.json.rabl create mode 100644 app/views/ui_ansible_roles/main.json.rabl create mode 100644 app/views/ui_ansible_roles/show.json.rabl create mode 100755 script/travis_run_js_tests.sh create mode 100644 test/functional/ui_ansible_roles_controller_test.rb create mode 100644 webpack/__mocks__/foremanReact/components/Pagination/PaginationWrapper.js create mode 100644 webpack/__mocks__/foremanReact/components/common/EmptyState.js create mode 100644 webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.js create mode 100644 webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.scss create mode 100644 webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherActions.js create mode 100644 webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherConstants.js create mode 100644 webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherHelpers.js create mode 100644 webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherReducer.js create mode 100644 webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherSelectors.js create mode 100644 webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesData.fixtures.js create mode 100644 webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesSwitcherReducer.fixtures.js create mode 100644 webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcher.test.js create mode 100644 webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherReducer.test.js create mode 100644 webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherSelectors.test.js create mode 100644 webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcher.test.js.snap create mode 100644 webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherReducer.test.js.snap create mode 100644 webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherSelectors.test.js.snap create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.test.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.test.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AnsibleRoleActionButton.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AnsibleRolesSwitcherError.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.test.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.test.js create mode 100644 webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsiblePermissionDenied.test.js.snap create mode 100644 webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsibleRole.test.js.snap create mode 100644 webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AssignedRolesList.test.js.snap create mode 100644 webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AvailableRolesList.test.js.snap create mode 100644 webpack/components/AnsibleRolesSwitcher/components/withProtectedView.js create mode 100644 webpack/components/AnsibleRolesSwitcher/index.js create mode 100644 webpack/reducer.js create mode 100644 webpack/test_setup.js diff --git a/.bablerc b/.babelrc similarity index 100% rename from .bablerc rename to .babelrc diff --git a/.eslintrc b/.eslintrc index 37342e117..28eb60587 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,15 +1,17 @@ { - "root": true, - "extends": "airbnb-base", - "plugins": [ - "react" - ], - "env": { - "browser": true, - "es6": true, - "node": true, - "jasmine": true, - "jest": true + "plugins": ["patternfly-react"], + "extends": ["plugin:patternfly-react/recommended"], + "rules": { + "prettier/prettier": ["error", { + "singleQuote": true, + "trailingComma": "es5" + }], + "import/no-unresolved": ["error", { + "ignore": ['foremanReact/.*'] + }], + "import/extensions": ["error", { + "ignore": ['foremanReact/.*'] + }], }, "globals": { "document": false, @@ -19,31 +21,8 @@ "window": false, "$": true, "_": true, - "__": true - }, - "parser": "babel-eslint", - "rules": { - "react/jsx-uses-vars": "error", - "react/jsx-uses-react": "error", - "no-unused-vars": [ - "error", - { - "vars": "all", - "args": "none" - } - ], - "no-underscore-dangle": "off", - "no-use-before-define": "off", - "import/prefer-default-export": "off", - "import/no-extraneous-dependencies": [ - "error", - { - // Allow importing devDependencies like @storybook - "devDependencies": true - } - ], - // Import rules off for now due to HoundCI issue - "import/no-unresolved": "off", - "import/extensions": "off" + "__": true, + "n__": true, + "d3": true } } diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..2c790fa8d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: node_js +node_js: + - '8' # current LTS + - '10' # future LTS +script: ./script/travis_run_js_tests.sh diff --git a/app/controllers/ui_ansible_roles_controller.rb b/app/controllers/ui_ansible_roles_controller.rb new file mode 100644 index 000000000..806be2388 --- /dev/null +++ b/app/controllers/ui_ansible_roles_controller.rb @@ -0,0 +1,14 @@ +class UiAnsibleRolesController < ::Api::V2::BaseController + def resource_name(resource = 'AnsibleRole') + super resource + end + + def index + @ui_ansible_roles = resource_scope_for_index(:permission => :view_ansible_roles) + end + + # restore original method from find_common to ignore resource nesting + def resource_scope(options = {}) + @resource_scope ||= scope_for(resource_class, options) + end +end diff --git a/app/helpers/foreman_ansible/ansible_roles_helper.rb b/app/helpers/foreman_ansible/ansible_roles_helper.rb index a5dae97a7..61c1dbd8f 100644 --- a/app/helpers/foreman_ansible/ansible_roles_helper.rb +++ b/app/helpers/foreman_ansible/ansible_roles_helper.rb @@ -20,5 +20,9 @@ def ansible_proxy_import(hash) def import_time(role) _('%s ago') % time_ago_in_words(role.updated_at) end + + def roles_attrs(roles) + roles.map { |item| { :id => item.id, :name => item.name } } + end end end diff --git a/app/models/ansible_role.rb b/app/models/ansible_role.rb index bc3570a2f..c6bbfb7c3 100644 --- a/app/models/ansible_role.rb +++ b/app/models/ansible_role.rb @@ -17,6 +17,7 @@ class AnsibleRole < ApplicationRecord :class_name => 'AnsibleVariable' scoped_search :on => :name, :complete_value => true + scoped_search :on => :id, :complete_value => false scoped_search :on => :updated_at scoped_search :relation => :hosts, :on => :id, :rename => :host_id, :only_explicit => true diff --git a/app/views/foreman_ansible/ansible_roles/_select_tab_content.html.erb b/app/views/foreman_ansible/ansible_roles/_select_tab_content.html.erb index c904d81f6..187607251 100644 --- a/app/views/foreman_ansible/ansible_roles/_select_tab_content.html.erb +++ b/app/views/foreman_ansible/ansible_roles/_select_tab_content.html.erb @@ -1,15 +1,15 @@ +<%= webpacked_plugins_js_for :foreman_ansible %> +<%= webpacked_plugins_css_for :foreman_ansible %> +
- <%= multiple_selects( - f, - :ansible_roles, - AnsibleRole, - f.object.is_a?(Hostgroup) ? (f.object.inherited_and_own_ansible_roles).map(&:id) : f.object.all_ansible_roles.map(&:id), - { - :disabled => f.object.inherited_ansible_roles.map(&:id), - :label => _('Available roles'), - :label_help => _('This list of roles will be applied when the host finishes
'\ - 'provisioning. Users can also play these roles through the API
'\ - 'or by clicking on the Play Roles button on the Host page ').html_safe - }, - { 'data-inheriteds' => f.object.inherited_ansible_roles.map(&:id).to_json }) %> +
+ <% roles = f.object.is_a?(Hostgroup) ? roles_attrs(f.object.inherited_and_own_ansible_roles) : roles_attrs(f.object.all_ansible_roles) %> + <% class_name = f.object.is_a?(Hostgroup) ? 'Hostgroup' : 'Host' %> + <%= mount_react_component('AnsibleRolesSwitcher', '#ansible_roles_switcher', { :initialAssignedRoles => roles, + :inheritedRoleIds => f.object.inherited_ansible_roles.map(&:id), + :availableRolesUrl => ui_ansible_roles_path, + :resourceId => f.object.id, + :resourceName => class_name, + :canView => User.current.can?(:view_ansible_roles) + }.to_json) %>
diff --git a/app/views/ui_ansible_roles/index.json.rabl b/app/views/ui_ansible_roles/index.json.rabl new file mode 100644 index 000000000..ad4597795 --- /dev/null +++ b/app/views/ui_ansible_roles/index.json.rabl @@ -0,0 +1,3 @@ +collection @ui_ansible_roles + +extends "ui_ansible_roles/main" diff --git a/app/views/ui_ansible_roles/main.json.rabl b/app/views/ui_ansible_roles/main.json.rabl new file mode 100644 index 000000000..02f372811 --- /dev/null +++ b/app/views/ui_ansible_roles/main.json.rabl @@ -0,0 +1,3 @@ +object @ansible_role + +extends "ui_ansible_roles/show" diff --git a/app/views/ui_ansible_roles/show.json.rabl b/app/views/ui_ansible_roles/show.json.rabl new file mode 100644 index 000000000..a90699275 --- /dev/null +++ b/app/views/ui_ansible_roles/show.json.rabl @@ -0,0 +1,3 @@ +object @ansible_role + +extends "api/v2/ansible_roles/show" diff --git a/config/routes.rb b/config/routes.rb index 4c7e70ff1..25812bc1e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -58,6 +58,8 @@ end end + resources :ui_ansible_roles, :only => [:index] + resources :ansible_variables, :except => [:show, :new, :create] do resources :lookup_values, :only => [:index, :create, :update, :destroy] collection do diff --git a/lib/foreman_ansible/register.rb b/lib/foreman_ansible/register.rb index 7f15ecf19..6d6733032 100644 --- a/lib/foreman_ansible/register.rb +++ b/lib/foreman_ansible/register.rb @@ -17,7 +17,8 @@ :resource_type => 'Hostgroup' permission :view_ansible_roles, { :ansible_roles => [:index, :auto_complete_search], - :'api/v2/ansible_roles' => [:index, :show, :fetch] }, + :'api/v2/ansible_roles' => [:index, :show, :fetch], + :ui_ansible_roles => [:index] }, :resource_type => 'AnsibleRole' permission :destroy_ansible_roles, { :ansible_roles => [:destroy], diff --git a/package.json b/package.json index dec60c5b8..4a5dc48fb 100644 --- a/package.json +++ b/package.json @@ -7,34 +7,75 @@ "test": "test" }, "dependencies": { - "react-json-tree": "^0.11.0" + "babel-polyfill": "^6.26.0", + "classnames": "^2.2.6", + "lodash": "4.17.10", + "patternfly": "^3.58.0", + "patternfly-react": "^2.25.4", + "prop-types": "^15.7.2", + "react": "^16.6.3", + "react-bootstrap": "^0.32.4", + "react-dom": "^16.6.3", + "react-json-tree": "^0.11.0", + "reselect": "^3.0.1", + "seamless-immutable": "7.1.2" }, "devDependencies": { "babel-eslint": "^8.2.1", - "babel-preset-env": "^1.6.0", - "babel-preset-react": "^6.24.1", "babel-plugin-lodash": "^3.3.2", - "babel-plugin-transform-object-assign": "^6.22.0", "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-object-assign": "^6.22.0", "babel-plugin-transform-object-rest-spread": "^6.26.0", + "babel-preset-env": "^1.6.0", + "babel-preset-react": "^6.24.1", + "enzyme": "^3.7.0", + "enzyme-adapter-react-16": "^1.7.0", + "enzyme-to-json": "^3.3.5", "eslint": "^4.18.1", "eslint-config-airbnb": "^16.0.0", "eslint-plugin-import": "^2.8.0", "eslint-plugin-jest": "^21.2.0", "eslint-plugin-jsx-a11y": "^6.0.2", - "eslint-plugin-react": "^7.4.0" + "eslint-plugin-patternfly-react": "^0.2.1", + "eslint-plugin-react": "^7.4.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^23.6.0", + "prettier": "^1.16.4", + "react-redux": "^6.0.0", + "react-redux-test-utils": "^0.1.1", + "redux": "^4.0.1", + "redux-thunk": "^2.3.0" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node node_modules/.bin/jest webpack", + "lint": "./node_modules/.bin/eslint -c .eslintrc webpack/ || exit 0" }, "repository": { "type": "git", - "url": "git+https://github.com/bastilian/foreman_ansible.git" + "url": "git+https://github.com/theforeman/foreman_ansible.git" }, "author": "", "license": "ISC", "bugs": { - "url": "https://github.com/bastilian/foreman_ansible/issues" + "url": "https://projects.theforeman.org/projects/ansible" }, - "homepage": "https://github.com/bastilian/foreman_ansible#readme" + "homepage": "https://theforeman.org/plugins/foreman_ansible/", + "jest": { + "verbose": true, + "moduleDirectories": [ + "node_modules", + "webpack" + ], + "setupFiles": [ + "raf/polyfill", + "./webpack/test_setup.js" + ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/foreman/" + ], + "moduleNameMapper": { + "^.+\\.(css|scss)$": "identity-obj-proxy" + } + } } diff --git a/script/travis_run_js_tests.sh b/script/travis_run_js_tests.sh new file mode 100755 index 000000000..c5c5a0209 --- /dev/null +++ b/script/travis_run_js_tests.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -ev +npm run lint; +npm run test; diff --git a/test/functional/ui_ansible_roles_controller_test.rb b/test/functional/ui_ansible_roles_controller_test.rb new file mode 100644 index 000000000..37f4fa260 --- /dev/null +++ b/test/functional/ui_ansible_roles_controller_test.rb @@ -0,0 +1,14 @@ +require 'test_plugin_helper' + +class UiAnsibleRolesControllerTest < ActionController::TestCase + setup do + @role = FactoryBot.create(:ansible_role) + end + + test 'should respond with roles' do + get :index, :params => {}, :session => set_session_user + assert_response :success + res = JSON.parse @response.body + assert_equal res['total'], res['results'].size + end +end diff --git a/webpack/__mocks__/foremanReact/components/Pagination/PaginationWrapper.js b/webpack/__mocks__/foremanReact/components/Pagination/PaginationWrapper.js new file mode 100644 index 000000000..0cfc248b2 --- /dev/null +++ b/webpack/__mocks__/foremanReact/components/Pagination/PaginationWrapper.js @@ -0,0 +1,2 @@ +const PaginationWrapper = () => jest.fn(); +export default PaginationWrapper; diff --git a/webpack/__mocks__/foremanReact/components/common/EmptyState.js b/webpack/__mocks__/foremanReact/components/common/EmptyState.js new file mode 100644 index 000000000..e10021371 --- /dev/null +++ b/webpack/__mocks__/foremanReact/components/common/EmptyState.js @@ -0,0 +1,5 @@ +const EmptyState = () => jest.fn(); + +export const EmptyStatePattern = () => jest.fn(); + +export default EmptyState; diff --git a/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.js b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.js new file mode 100644 index 000000000..097aff94b --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { Grid, Row, Col } from 'patternfly-react'; +import { lowerCase } from 'lodash'; +import PropTypes from 'prop-types'; + +import AvailableRolesList from './components/AvailableRolesList'; +import AssignedRolesList from './components/AssignedRolesList'; +import AnsibleRolesSwitcherError from './components/AnsibleRolesSwitcherError'; +import { excludeAssignedRolesSearch } from './AnsibleRolesSwitcherHelpers'; + +class AnsibleRolesSwitcher extends React.Component { + componentDidMount() { + const { + initialAssignedRoles, + availableRolesUrl, + inheritedRoleIds, + resourceId, + resourceName, + } = this.props.data; + + this.props.getAnsibleRoles( + availableRolesUrl, + initialAssignedRoles, + inheritedRoleIds, + resourceId, + resourceName, + { page: 1, perPage: 10 }, + excludeAssignedRolesSearch(initialAssignedRoles) + ); + } + + render() { + const { + loading, + pagination, + itemCount, + addAnsibleRole, + removeAnsibleRole, + getAnsibleRoles, + changeAssignedPage, + assignedPagination, + assignedRolesCount, + assignedRoles, + allAssignedRoles, + unassignedRoles, + error, + } = this.props; + + const { + availableRolesUrl, + inheritedRoleIds, + resourceId, + resourceName, + } = this.props.data; + + const onListingChange = paginationArgs => + getAnsibleRoles( + availableRolesUrl, + allAssignedRoles, + inheritedRoleIds, + resourceId, + resourceName, + paginationArgs, + excludeAssignedRolesSearch(allAssignedRoles) + ); + + return ( + + + + +
+

{__('Available Ansible Roles')}

+
+ + + + +
+

{__('Assigned Ansible Roles')}

+
+ + +
+
+ ); + } +} + +AnsibleRolesSwitcher.propTypes = { + data: PropTypes.shape({ + initialAssignedRoles: PropTypes.arrayOf(PropTypes.object), + availableRolesUrl: PropTypes.string, + inheritedRoleIds: PropTypes.arrayOf(PropTypes.number), + resourceId: PropTypes.number, + resourceName: PropTypes.string, + }).isRequired, + getAnsibleRoles: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, + pagination: PropTypes.shape({ + page: PropTypes.number, + perPage: PropTypes.number, + }).isRequired, + itemCount: PropTypes.number.isRequired, + addAnsibleRole: PropTypes.func.isRequired, + removeAnsibleRole: PropTypes.func.isRequired, + changeAssignedPage: PropTypes.func.isRequired, + assignedPagination: PropTypes.shape({ + page: PropTypes.number, + perPage: PropTypes.number, + }).isRequired, + assignedRolesCount: PropTypes.number.isRequired, + assignedRoles: PropTypes.arrayOf(PropTypes.object).isRequired, + allAssignedRoles: PropTypes.arrayOf(PropTypes.object).isRequired, + unassignedRoles: PropTypes.arrayOf(PropTypes.object).isRequired, + error: PropTypes.shape({ + errorMsg: PropTypes.string, + statusText: PropTypes.string, + }), +}; + +AnsibleRolesSwitcher.defaultProps = { + error: {}, +}; + +export default AnsibleRolesSwitcher; diff --git a/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.scss b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.scss new file mode 100644 index 000000000..5c75abeb6 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcher.scss @@ -0,0 +1,45 @@ +@import '~patternfly/dist/sass/patternfly/_color-variables'; +@import '~patternfly/dist/sass/patternfly/_variables'; +@import '~patternfly/dist/sass/patternfly/_loading-state'; + +#ansibleRolesSwitcher { + .list-view-pf { + .list-group-item.ansible-role-disabled { + background-color: $color-pf-black-200; + color: $color-pf-black-500; + border-left-color: $color-pf-black-200; + border-right-color: $color-pf-black-200; + } + + .list-group-item.ansible-role-movable:hover { + cursor: pointer; + } + } + + .loading-state-pf { + padding-top: 5%; + background-color: $color-pf-white; + } + + .role-add-remove-btn { + background-color: initial; + border: none; + color: #0388ce; + } + + .list-view-pf-main-info { + padding-top: 10px; + padding-bottom: 10px; + } + + .list-view-pf-actions { + margin-top: 0; + margin-bottom: 0; + } + + .list-group-item-heading { + margin-bottom: 0; + font-size: 12px; + font-weight: normal; + } +} diff --git a/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherActions.js b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherActions.js new file mode 100644 index 000000000..6e627332e --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherActions.js @@ -0,0 +1,69 @@ +import api from 'foremanReact/API'; +import { + propsToSnakeCase, + propsToCamelCase, +} from 'foremanReact/common/helpers'; + +import { + ANSIBLE_ROLES_REQUEST, + ANSIBLE_ROLES_SUCCESS, + ANSIBLE_ROLES_FAILURE, + ANSIBLE_ROLES_ADD, + ANSIBLE_ROLES_REMOVE, + ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE, +} from './AnsibleRolesSwitcherConstants'; + +export const getAnsibleRoles = ( + url, + initialAssignedRoles, + inheritedRoleIds, + resourceId, + resourceName, + pagination, + search +) => dispatch => { + dispatch({ type: ANSIBLE_ROLES_REQUEST }); + + const params = { + ...propsToSnakeCase(pagination || {}), + ...(search || {}), + ...propsToSnakeCase({ resourceId, resourceName }), + }; + + return api + .get(url, {}, params) + .then(({ data }) => + dispatch({ + type: ANSIBLE_ROLES_SUCCESS, + payload: { + initialAssignedRoles, + inheritedRoleIds, + ...propsToCamelCase(data), + }, + }) + ) + .catch(error => dispatch(errorHandler(ANSIBLE_ROLES_FAILURE, error))); +}; + +const errorHandler = (msg, err) => { + const error = { + errorMsg: 'Failed to fetch Ansible Roles from server.', + statusText: err.response.statusText, + }; + return { type: msg, payload: { error } }; +}; + +export const addAnsibleRole = role => ({ + type: ANSIBLE_ROLES_ADD, + payload: { role }, +}); + +export const removeAnsibleRole = role => ({ + type: ANSIBLE_ROLES_REMOVE, + payload: { role }, +}); + +export const changeAssignedPage = pagination => ({ + type: ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE, + payload: { pagination }, +}); diff --git a/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherConstants.js b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherConstants.js new file mode 100644 index 000000000..e0ae799c8 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherConstants.js @@ -0,0 +1,7 @@ +export const ANSIBLE_ROLES_REQUEST = 'ANSIBLE_ROLES_REQUEST'; +export const ANSIBLE_ROLES_SUCCESS = 'ANSIBLE_ROLES_SUCCESS'; +export const ANSIBLE_ROLES_FAILURE = 'ANSIBLE_ROLES_FAILURE'; +export const ANSIBLE_ROLES_ADD = 'ANSIBLE_ROLES_ADD'; +export const ANSIBLE_ROLES_REMOVE = 'ANSIBLE_ROLES_REMOVE'; +export const ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE = + 'ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE'; diff --git a/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherHelpers.js b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherHelpers.js new file mode 100644 index 000000000..3722cb096 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherHelpers.js @@ -0,0 +1,7 @@ +export const excludeAssignedRolesSearch = assignedRoles => { + const searchString = + assignedRoles.length === 0 + ? '' + : `id !^ (${assignedRoles.map(role => role.id).join(', ')})`; + return { search: searchString }; +}; diff --git a/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherReducer.js b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherReducer.js new file mode 100644 index 000000000..32b12174c --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherReducer.js @@ -0,0 +1,69 @@ +import Immutable from 'seamless-immutable'; + +import { + ANSIBLE_ROLES_REQUEST, + ANSIBLE_ROLES_SUCCESS, + ANSIBLE_ROLES_FAILURE, + ANSIBLE_ROLES_ADD, + ANSIBLE_ROLES_REMOVE, + ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE, +} from './AnsibleRolesSwitcherConstants'; + +export const initialState = Immutable({ + loading: false, + itemCount: 0, + pagination: { + page: 1, + perPage: 10, + }, + assignedRoles: [], + inheritedRoleIds: [], + results: [], + assignedPagination: { + page: 1, + perPage: 10, + }, + error: { errorMsg: '', status: '', statusText: '' }, +}); + +const ansibleRoles = (state = initialState, action) => { + const { payload } = action; + + switch (action.type) { + case ANSIBLE_ROLES_REQUEST: + return state.set('loading', true); + case ANSIBLE_ROLES_SUCCESS: + return state.merge({ + loading: false, + itemCount: Number(payload.subtotal), + pagination: { + page: Number(payload.page), + perPage: Number(payload.perPage), + }, + results: payload.results, + assignedRoles: payload.initialAssignedRoles, + inheritedRoleIds: payload.inheritedRoleIds, + }); + case ANSIBLE_ROLES_FAILURE: + return state.merge({ error: payload.error, loading: false }); + case ANSIBLE_ROLES_ADD: + return state.merge({ + assignedRoles: state.assignedRoles.concat([payload.role]), + itemCount: state.itemCount - 1, + }); + case ANSIBLE_ROLES_REMOVE: + return state.merge({ + assignedRoles: Immutable.flatMap(state.assignedRoles, item => + item.id === payload.role.id ? [] : item + ), + results: state.results.concat([payload.role]), + itemCount: state.itemCount + 1, + }); + case ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE: + return state.set('assignedPagination', payload.pagination); + default: + return state; + } +}; + +export default ansibleRoles; diff --git a/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherSelectors.js b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherSelectors.js new file mode 100644 index 000000000..69b674316 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/AnsibleRolesSwitcherSelectors.js @@ -0,0 +1,68 @@ +import { differenceBy, slice, includes, uniq } from 'lodash'; +import Immutable from 'seamless-immutable'; +import { createSelector } from 'reselect'; + +const compare = (a, b) => { + if (a.name < b.name) { + return -1; + } + if (a.name > b.name) { + return 1; + } + return 0; +}; + +const switcherState = state => state.foremanAnsible.ansibleRolesSwitcher; + +const markInheritedRoles = (roles, inheritedRoleIds) => + roles.map(role => + includes(inheritedRoleIds, role.id) ? { ...role, inherited: true } : role + ); + +export const selectResults = state => + Immutable( + Immutable.asMutable(uniq(switcherState(state).results)).sort(compare) + ); + +export const selectItemCount = state => switcherState(state).itemCount; + +export const selectAssignedRoles = state => + Immutable.asMutable( + markInheritedRoles( + switcherState(state).assignedRoles, + switcherState(state).inheritedRoleIds + ) + ).sort(compare); + +export const selectAssignedRolesCount = state => + selectAssignedRoles(state).length; +export const selectLoading = state => switcherState(state).loading; +export const selectAssignedPagination = state => + switcherState(state).assignedPagination; +export const selectError = state => switcherState(state).error; +export const selectPagination = state => switcherState(state).pagination; + +export const selectPaginationMemoized = createSelector( + selectPagination, + selectResults, + (pagination, results) => + results.length > pagination.perPage + ? { ...pagination, perPage: results.length } + : pagination +); + +export const selectUnassignedRoles = createSelector( + selectResults, + selectAssignedRoles, + (results, assignedRoles) => differenceBy(results, assignedRoles, 'id') +); + +export const selectAssignedRolesPage = createSelector( + selectAssignedPagination, + selectAssignedRoles, + (assignedPagination, assignedRoles) => { + const offset = (assignedPagination.page - 1) * assignedPagination.perPage; + + return slice(assignedRoles, offset, offset + assignedPagination.perPage); + } +); diff --git a/webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesData.fixtures.js b/webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesData.fixtures.js new file mode 100644 index 000000000..f1b9daa15 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesData.fixtures.js @@ -0,0 +1,20 @@ +export const ansibleRolesShort = [ + { id: 1, name: 'sthirugn.motd' }, + { id: 2, name: 'jtyr.ntp' }, + { id: 3, name: 'rvm.ruby' }, + { id: 4, name: 'geerlingguy.java' }, +]; + +export const ansibleRolesLong = [ + { id: 1, name: 'sthirugn.motd' }, + { id: 2, name: 'jtyr.ntp' }, + { id: 3, name: 'rvm.ruby' }, + { id: 4, name: 'geerlingguy.java' }, + { id: 5, name: 'naftulikay.golang' }, + { id: 6, name: 'theforeman.foreman_scap_client' }, + { id: 7, name: 'ansible.ansible' }, + { id: 8, name: 'puppet.puppet' }, + { id: 9, name: 'chef.chef' }, + { id: 10, name: 'salt.salt' }, + { id: 11, name: 'anonymous.something' }, +]; diff --git a/webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesSwitcherReducer.fixtures.js b/webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesSwitcherReducer.fixtures.js new file mode 100644 index 000000000..51991be70 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__fixtures__/ansibleRolesSwitcherReducer.fixtures.js @@ -0,0 +1,36 @@ +import Immutable from 'seamless-immutable'; + +import { ansibleRolesLong } from './ansibleRolesData.fixtures'; + +export const successPayload = { + page: 1, + perPage: 5, + subtotal: 11, + results: ansibleRolesLong, + initialAssignedRoles: ansibleRolesLong.slice(3, 6), + inheritedRoleIds: [4], +}; + +export const successState = Immutable({ + loading: false, + itemCount: 11, + pagination: { + page: 1, + perPage: 5, + }, + assignedRoles: [ + { ...ansibleRolesLong[3], inherited: true }, + ...ansibleRolesLong.slice(4, 6), + ], + results: ansibleRolesLong, + assignedPagination: { + page: 1, + perPage: 20, + }, + error: { errorMsg: '', status: '', statusText: '' }, +}); + +export const errorPayload = { + errorMsg: 'Failed to fetch Ansible Roles from server.', + statusText: '500', +}; diff --git a/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcher.test.js b/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcher.test.js new file mode 100644 index 000000000..97db4ec53 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcher.test.js @@ -0,0 +1,30 @@ +import { testComponentSnapshotsWithFixtures } from 'react-redux-test-utils'; + +import AnsibleRolesSwitcher from '../AnsibleRolesSwitcher'; + +jest.mock('foremanReact/components/Pagination/PaginationWrapper'); + +const noop = () => {}; + +const fixtures = { + 'should render': { + loading: false, + pagination: { page: 1, perPage: 12 }, + itemCount: 20, + addAnsibleRole: noop, + removeAnsibleRole: noop, + getAnsibleRoles: noop, + changeAssignedPage: noop, + assignedPagination: { page: 1, perPage: 12 }, + assignedRolesCount: 2, + assignedRoles: [], + unassignedRoles: [], + data: { + initialAssignedRoles: [], + }, + error: { statusText: '', errorMsg: '' }, + }, +}; + +describe('AnsibleRolesSwitcher', () => + testComponentSnapshotsWithFixtures(AnsibleRolesSwitcher, fixtures)); diff --git a/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherReducer.test.js b/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherReducer.test.js new file mode 100644 index 000000000..3c2837f34 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherReducer.test.js @@ -0,0 +1,73 @@ +import { testReducerSnapshotWithFixtures } from 'react-redux-test-utils'; + +import reducer, { initialState } from '../AnsibleRolesSwitcherReducer'; +import { ansibleRolesLong } from '../__fixtures__/ansibleRolesData.fixtures'; + +import { + successPayload, + successState, + errorPayload, +} from '../__fixtures__/ansibleRolesSwitcherReducer.fixtures'; + +import { + ANSIBLE_ROLES_REQUEST, + ANSIBLE_ROLES_SUCCESS, + ANSIBLE_ROLES_FAILURE, + ANSIBLE_ROLES_ADD, + ANSIBLE_ROLES_REMOVE, + ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE, +} from '../AnsibleRolesSwitcherConstants'; + +const fixtures = { + 'should return initial state': { + state: initialState, + action: { + type: undefined, + payload: {}, + }, + }, + 'should start loading on Ansible roles request': { + state: initialState, + action: { + type: ANSIBLE_ROLES_REQUEST, + }, + }, + 'should stop loading on Ansible roles success': { + state: initialState.set('loading', true), + action: { + type: ANSIBLE_ROLES_SUCCESS, + payload: successPayload, + }, + }, + 'should stop loading on Ansible roles error': { + state: initialState.set('loading', true), + action: { + type: ANSIBLE_ROLES_FAILURE, + payload: { error: errorPayload }, + }, + }, + 'should add Ansible role to assigned': { + state: successState, + action: { + type: ANSIBLE_ROLES_ADD, + payload: { role: ansibleRolesLong[8] }, + }, + }, + 'should remove Ansible role from assigned': { + state: successState, + action: { + type: ANSIBLE_ROLES_REMOVE, + payload: { role: ansibleRolesLong[5] }, + }, + }, + 'should change pagination for assigned roles': { + state: successState, + action: { + type: ANSIBLE_ROLES_ASSIGNED_PAGE_CHANGE, + payload: { pagination: { page: 20, perPage: 5 } }, + }, + }, +}; + +describe('AnsibleRolesSwitcherReducer', () => + testReducerSnapshotWithFixtures(reducer, fixtures)); diff --git a/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherSelectors.test.js b/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherSelectors.test.js new file mode 100644 index 000000000..3fa399a7a --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__tests__/AnsibleRolesSwitcherSelectors.test.js @@ -0,0 +1,43 @@ +import { testSelectorsSnapshotWithFixtures } from 'react-redux-test-utils'; + +import { + selectUnassignedRoles, + selectAssignedRolesPage, +} from '../AnsibleRolesSwitcherSelectors'; +import { + ansibleRolesShort, + ansibleRolesLong, +} from '../__fixtures__/ansibleRolesData.fixtures'; + +const stateFactory = obj => ({ + foremanAnsible: { + ansibleRolesSwitcher: obj, + }, +}); + +const state1 = { + results: ansibleRolesShort, + assignedRoles: [{ id: 2 }, { id: 4 }], +}; + +const state2 = { + results: ansibleRolesShort, + assignedRoles: [], +}; + +const state3 = { + assignedRoles: ansibleRolesLong, + assignedPagination: { page: 2, perPage: 5 }, +}; + +const fixtures = { + 'should return unassigned roles': () => + selectUnassignedRoles(stateFactory(state1)), + 'should return all roles when no roles assigned': () => + selectUnassignedRoles(stateFactory(state2)), + 'should return requested page': () => + selectAssignedRolesPage(stateFactory(state3)), +}; + +describe('AnsibleRolesSwitcherSelectors', () => + testSelectorsSnapshotWithFixtures(fixtures)); diff --git a/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcher.test.js.snap b/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcher.test.js.snap new file mode 100644 index 000000000..d37e04ffe --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcher.test.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnsibleRolesSwitcher should render 1`] = ` + + + + +
+

+ Available Ansible Roles +

+
+ + + +
+

+ Assigned Ansible Roles +

+
+ + +
+
+`; diff --git a/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherReducer.test.js.snap b/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherReducer.test.js.snap new file mode 100644 index 000000000..d353e6d99 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherReducer.test.js.snap @@ -0,0 +1,399 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnsibleRolesSwitcherReducer should add Ansible role to assigned 1`] = ` +Object { + "assignedPagination": Object { + "page": 1, + "perPage": 20, + }, + "assignedRoles": Array [ + Object { + "id": 4, + "inherited": true, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + Object { + "id": 9, + "name": "chef.chef", + }, + ], + "error": Object { + "errorMsg": "", + "status": "", + "statusText": "", + }, + "itemCount": 10, + "loading": false, + "pagination": Object { + "page": 1, + "perPage": 5, + }, + "results": Array [ + Object { + "id": 1, + "name": "sthirugn.motd", + }, + Object { + "id": 2, + "name": "jtyr.ntp", + }, + Object { + "id": 3, + "name": "rvm.ruby", + }, + Object { + "id": 4, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + Object { + "id": 7, + "name": "ansible.ansible", + }, + Object { + "id": 8, + "name": "puppet.puppet", + }, + Object { + "id": 9, + "name": "chef.chef", + }, + Object { + "id": 10, + "name": "salt.salt", + }, + Object { + "id": 11, + "name": "anonymous.something", + }, + ], +} +`; + +exports[`AnsibleRolesSwitcherReducer should change pagination for assigned roles 1`] = ` +Object { + "assignedPagination": Object { + "page": 20, + "perPage": 5, + }, + "assignedRoles": Array [ + Object { + "id": 4, + "inherited": true, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + ], + "error": Object { + "errorMsg": "", + "status": "", + "statusText": "", + }, + "itemCount": 11, + "loading": false, + "pagination": Object { + "page": 1, + "perPage": 5, + }, + "results": Array [ + Object { + "id": 1, + "name": "sthirugn.motd", + }, + Object { + "id": 2, + "name": "jtyr.ntp", + }, + Object { + "id": 3, + "name": "rvm.ruby", + }, + Object { + "id": 4, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + Object { + "id": 7, + "name": "ansible.ansible", + }, + Object { + "id": 8, + "name": "puppet.puppet", + }, + Object { + "id": 9, + "name": "chef.chef", + }, + Object { + "id": 10, + "name": "salt.salt", + }, + Object { + "id": 11, + "name": "anonymous.something", + }, + ], +} +`; + +exports[`AnsibleRolesSwitcherReducer should remove Ansible role from assigned 1`] = ` +Object { + "assignedPagination": Object { + "page": 1, + "perPage": 20, + }, + "assignedRoles": Array [ + Object { + "id": 4, + "inherited": true, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + ], + "error": Object { + "errorMsg": "", + "status": "", + "statusText": "", + }, + "itemCount": 12, + "loading": false, + "pagination": Object { + "page": 1, + "perPage": 5, + }, + "results": Array [ + Object { + "id": 1, + "name": "sthirugn.motd", + }, + Object { + "id": 2, + "name": "jtyr.ntp", + }, + Object { + "id": 3, + "name": "rvm.ruby", + }, + Object { + "id": 4, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + Object { + "id": 7, + "name": "ansible.ansible", + }, + Object { + "id": 8, + "name": "puppet.puppet", + }, + Object { + "id": 9, + "name": "chef.chef", + }, + Object { + "id": 10, + "name": "salt.salt", + }, + Object { + "id": 11, + "name": "anonymous.something", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + ], +} +`; + +exports[`AnsibleRolesSwitcherReducer should return initial state 1`] = ` +Object { + "assignedPagination": Object { + "page": 1, + "perPage": 10, + }, + "assignedRoles": Array [], + "error": Object { + "errorMsg": "", + "status": "", + "statusText": "", + }, + "inheritedRoleIds": Array [], + "itemCount": 0, + "loading": false, + "pagination": Object { + "page": 1, + "perPage": 10, + }, + "results": Array [], +} +`; + +exports[`AnsibleRolesSwitcherReducer should start loading on Ansible roles request 1`] = ` +Object { + "assignedPagination": Object { + "page": 1, + "perPage": 10, + }, + "assignedRoles": Array [], + "error": Object { + "errorMsg": "", + "status": "", + "statusText": "", + }, + "inheritedRoleIds": Array [], + "itemCount": 0, + "loading": true, + "pagination": Object { + "page": 1, + "perPage": 10, + }, + "results": Array [], +} +`; + +exports[`AnsibleRolesSwitcherReducer should stop loading on Ansible roles error 1`] = ` +Object { + "assignedPagination": Object { + "page": 1, + "perPage": 10, + }, + "assignedRoles": Array [], + "error": Object { + "errorMsg": "Failed to fetch Ansible Roles from server.", + "statusText": "500", + }, + "inheritedRoleIds": Array [], + "itemCount": 0, + "loading": false, + "pagination": Object { + "page": 1, + "perPage": 10, + }, + "results": Array [], +} +`; + +exports[`AnsibleRolesSwitcherReducer should stop loading on Ansible roles success 1`] = ` +Object { + "assignedPagination": Object { + "page": 1, + "perPage": 10, + }, + "assignedRoles": Array [ + Object { + "id": 4, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + ], + "error": Object { + "errorMsg": "", + "status": "", + "statusText": "", + }, + "inheritedRoleIds": Array [ + 4, + ], + "itemCount": 11, + "loading": false, + "pagination": Object { + "page": 1, + "perPage": 5, + }, + "results": Array [ + Object { + "id": 1, + "name": "sthirugn.motd", + }, + Object { + "id": 2, + "name": "jtyr.ntp", + }, + Object { + "id": 3, + "name": "rvm.ruby", + }, + Object { + "id": 4, + "name": "geerlingguy.java", + }, + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 6, + "name": "theforeman.foreman_scap_client", + }, + Object { + "id": 7, + "name": "ansible.ansible", + }, + Object { + "id": 8, + "name": "puppet.puppet", + }, + Object { + "id": 9, + "name": "chef.chef", + }, + Object { + "id": 10, + "name": "salt.salt", + }, + Object { + "id": 11, + "name": "anonymous.something", + }, + ], +} +`; diff --git a/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherSelectors.test.js.snap b/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherSelectors.test.js.snap new file mode 100644 index 000000000..2945c5799 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/__tests__/__snapshots__/AnsibleRolesSwitcherSelectors.test.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnsibleRolesSwitcherSelectors should return all roles when no roles assigned 1`] = ` +Array [ + Object { + "id": 4, + "name": "geerlingguy.java", + }, + Object { + "id": 2, + "name": "jtyr.ntp", + }, + Object { + "id": 3, + "name": "rvm.ruby", + }, + Object { + "id": 1, + "name": "sthirugn.motd", + }, +] +`; + +exports[`AnsibleRolesSwitcherSelectors should return requested page 1`] = ` +Array [ + Object { + "id": 5, + "name": "naftulikay.golang", + }, + Object { + "id": 8, + "name": "puppet.puppet", + }, + Object { + "id": 3, + "name": "rvm.ruby", + }, + Object { + "id": 10, + "name": "salt.salt", + }, + Object { + "id": 1, + "name": "sthirugn.motd", + }, +] +`; + +exports[`AnsibleRolesSwitcherSelectors should return unassigned roles 1`] = ` +Array [ + Object { + "id": 3, + "name": "rvm.ruby", + }, + Object { + "id": 1, + "name": "sthirugn.motd", + }, +] +`; diff --git a/webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.js b/webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.js new file mode 100644 index 000000000..8c4db82ff --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { EmptyStatePattern as EmptyState } from 'foremanReact/components/common/EmptyState'; + +const AnsiblePermissionDenied = props => { + const description = ( + + {__('You are not authorized to perform this action.')} +
+ {__( + 'Please request one of the required permissions listed below from a Foreman administrator:' + )} +
+
+ ); + + const doc = ( + + ); + + return ( + + ); +}; + +export default AnsiblePermissionDenied; diff --git a/webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.test.js b/webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.test.js new file mode 100644 index 000000000..b66e2d74e --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AnsiblePermissionDenied.test.js @@ -0,0 +1,9 @@ +import { testComponentSnapshotsWithFixtures } from 'react-redux-test-utils'; +import AnsiblePermissionDenied from './AnsiblePermissionDenied'; + +jest.mock('foremanReact/components/common/EmptyState'); + +describe('AnsiblePermissionDenied', () => + testComponentSnapshotsWithFixtures(AnsiblePermissionDenied, { + 'should render': {}, + })); diff --git a/webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.js b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.js new file mode 100644 index 000000000..e682b378c --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { ListView, Tooltip, OverlayTrigger } from 'patternfly-react'; +import classNames from 'classnames'; + +import AnsibleRoleActionButton from './AnsibleRoleActionButton'; +import '../AnsibleRolesSwitcher.scss'; + +const AnsibleRole = ({ role, icon, onClick, resourceName }) => { + const text = + resourceName === 'hostgroup' + ? __('This Ansible role is inherited from parent host group') + : __('This Ansible role is inherited from host group'); + + const tooltip = ( + + {text} + + ); + + const clickHandler = (onClickFn, ansibleRole) => event => { + event.preventDefault(); + onClickFn(ansibleRole); + }; + + const listItem = (click = undefined) => ( + + ) + } + stacked + onClick={typeof click === 'function' ? click(onClick, role) : click} + /> + ); + + if (role.inherited) { + return ( + + {listItem()} + + ); + } + + return listItem(clickHandler); +}; + +export default AnsibleRole; diff --git a/webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.test.js b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.test.js new file mode 100644 index 000000000..a1782f216 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRole.test.js @@ -0,0 +1,26 @@ +import { testComponentSnapshotsWithFixtures } from 'react-redux-test-utils'; + +import AnsibleRole from './AnsibleRole'; + +const noop = () => {}; + +const fixtures = { + 'should render a role to add': { + role: { name: 'test.role', id: 5 }, + icon: 'fa fa-plus-circle', + onClick: noop, + }, + 'should render a role to remove': { + role: { name: 'test.role', id: 5 }, + icon: 'fa fa-minus-circle', + onClick: noop, + }, + 'should render inherited role to remove': { + role: { name: 'test.role', id: 5, inherited: true }, + icon: 'fa fa-minus-circle', + onClick: noop, + }, +}; + +describe('AnsibleRole', () => + testComponentSnapshotsWithFixtures(AnsibleRole, fixtures)); diff --git a/webpack/components/AnsibleRolesSwitcher/components/AnsibleRoleActionButton.js b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRoleActionButton.js new file mode 100644 index 000000000..77f720f6b --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRoleActionButton.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Icon } from 'patternfly-react'; + +const AnsibleRoleActionButton = ({ icon }) => ( + +); + +AnsibleRoleActionButton.propTypes = { + icon: PropTypes.string.isRequired, +}; + +export default AnsibleRoleActionButton; diff --git a/webpack/components/AnsibleRolesSwitcher/components/AnsibleRolesSwitcherError.js b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRolesSwitcherError.js new file mode 100644 index 000000000..952c9e840 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AnsibleRolesSwitcherError.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { Col, Alert } from 'patternfly-react'; +import PropTypes from 'prop-types'; + +const ErrorMsg = ({ error }) => { + const status = error.statusText ? `${error.statusText}: ` : ''; + return `${status}${error.errorMsg}`; +}; + +const AnsibleRolesSwitcherError = ({ error }) => + error && error.errorMsg ? ( + + + + + + ) : ( + '' + ); + +AnsibleRolesSwitcherError.propTypes = { + error: PropTypes.shape({ + errorMsg: PropTypes.string, + statusText: PropTypes.string, + }), +}; + +AnsibleRolesSwitcherError.defaultProps = { + error: {}, +}; + +export default AnsibleRolesSwitcherError; diff --git a/webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.js b/webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.js new file mode 100644 index 000000000..83a8f336a --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { ListView } from 'patternfly-react'; +import PaginationWrapper from 'foremanReact/components/Pagination/PaginationWrapper'; +import { reject } from 'lodash'; +import PropTypes from 'prop-types'; + +import AnsibleRole from './AnsibleRole'; + +const AssignedRolesList = ({ + assignedRoles, + pagination, + itemCount, + onPaginationChange, + onRemoveRole, + resourceName, +}) => { + const directlyAssignedRoles = reject(assignedRoles, role => role.inherited); + + return ( +
+ +
+ +
+ {assignedRoles.map(role => ( + + ))} +
+
+ {directlyAssignedRoles.map(role => ( + + ))} +
+
+ ); +}; + +AssignedRolesList.propTypes = { + assignedRoles: PropTypes.arrayOf(PropTypes.object).isRequired, + pagination: PropTypes.shape({ + page: PropTypes.number, + perPage: PropTypes.number, + }).isRequired, + itemCount: PropTypes.number.isRequired, + onPaginationChange: PropTypes.func.isRequired, + onRemoveRole: PropTypes.func.isRequired, + resourceName: PropTypes.string.isRequired, +}; + +export default AssignedRolesList; diff --git a/webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.test.js b/webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.test.js new file mode 100644 index 000000000..8befd9ff1 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AssignedRolesList.test.js @@ -0,0 +1,19 @@ +import { testComponentSnapshotsWithFixtures } from 'react-redux-test-utils'; + +import AssignedRolesList from './AssignedRolesList'; + +const noop = () => {}; + +const fixtures = { + 'should render': { + assignedRoles: [{ id: 1, name: 'fake.role' }, { id: 2, name: 'test.role' }], + pagination: { page: 1, perPage: 25 }, + itemCount: 2, + onPaginationChange: noop, + onRemoveRole: noop, + resourceName: 'host', + }, +}; + +describe('AssignedRolesList', () => + testComponentSnapshotsWithFixtures(AssignedRolesList, fixtures)); diff --git a/webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.js b/webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.js new file mode 100644 index 000000000..103bd28d5 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ListView, LoadingState } from 'patternfly-react'; +import PaginationWrapper from 'foremanReact/components/Pagination/PaginationWrapper'; + +import AnsibleRole from './AnsibleRole'; + +const AvailableRolesList = ({ + unassignedRoles, + pagination, + itemCount, + onListingChange, + onAddRole, + loading, +}) => ( + +
+ +
+ + {unassignedRoles.map(role => ( + + ))} + +
+); + +AvailableRolesList.propTypes = { + unassignedRoles: PropTypes.arrayOf(PropTypes.object).isRequired, + pagination: PropTypes.shape({ + page: PropTypes.number, + perPage: PropTypes.number, + }).isRequired, + itemCount: PropTypes.number.isRequired, + onListingChange: PropTypes.func.isRequired, + onAddRole: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, +}; + +export default AvailableRolesList; diff --git a/webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.test.js b/webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.test.js new file mode 100644 index 000000000..511914146 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/AvailableRolesList.test.js @@ -0,0 +1,22 @@ +import { testComponentSnapshotsWithFixtures } from 'react-redux-test-utils'; + +import AvailableRolesList from './AvailableRolesList'; + +const noop = () => {}; + +const fixtures = { + 'should render': { + unassignedRoles: [ + { id: 1, name: 'fake.role' }, + { id: 2, name: 'test.role' }, + ], + pagination: { page: 1, perPage: 25 }, + itemCount: 2, + onListingChange: noop, + onAddRole: noop, + loading: false, + }, +}; + +describe('AvailableRolesList', () => + testComponentSnapshotsWithFixtures(AvailableRolesList, fixtures)); diff --git a/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsiblePermissionDenied.test.js.snap b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsiblePermissionDenied.test.js.snap new file mode 100644 index 000000000..80fef3ee8 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsiblePermissionDenied.test.js.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnsiblePermissionDenied should render 1`] = ` + + You are not authorized to perform this action. +
+ Please request one of the required permissions listed below from a Foreman administrator: +
+ + } + documentation={ +
    +
  • + view_ansible_roles +
  • +
+ } + header="Permission Denied" + icon="lock" + iconType="fa" +/> +`; diff --git a/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsibleRole.test.js.snap b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsibleRole.test.js.snap new file mode 100644 index 000000000..c93acc805 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AnsibleRole.test.js.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnsibleRole should render a role to add 1`] = ` + + } + additionalInfo={null} + checkboxInput={null} + className="listViewItem--listItemVariants ansible-role-movable" + compoundExpand={false} + compoundExpanded={false} + description={null} + heading="test.role" + hideCloseIcon={false} + id={5} + initExpanded={false} + leftContent={null} + onClick={[Function]} + onCloseCompoundExpand={[Function]} + onExpand={[Function]} + onExpandClose={[Function]} + stacked={true} +/> +`; + +exports[`AnsibleRole should render a role to remove 1`] = ` + + } + additionalInfo={null} + checkboxInput={null} + className="listViewItem--listItemVariants ansible-role-movable" + compoundExpand={false} + compoundExpanded={false} + description={null} + heading="test.role" + hideCloseIcon={false} + id={5} + initExpanded={false} + leftContent={null} + onClick={[Function]} + onCloseCompoundExpand={[Function]} + onExpand={[Function]} + onExpandClose={[Function]} + stacked={true} +/> +`; + +exports[`AnsibleRole should render inherited role to remove 1`] = ` + + + This Ansible role is inherited from host group + + + } + placement="top" + trigger={ + Array [ + "hover", + "focus", + ] + } +> + + +`; diff --git a/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AssignedRolesList.test.js.snap b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AssignedRolesList.test.js.snap new file mode 100644 index 000000000..0d1e8df3d --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AssignedRolesList.test.js.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssignedRolesList should render 1`] = ` +
+ +
+ +
+ + +
+
+ + +
+
+`; diff --git a/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AvailableRolesList.test.js.snap b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AvailableRolesList.test.js.snap new file mode 100644 index 000000000..1189bb87a --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/__snapshots__/AvailableRolesList.test.js.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AvailableRolesList should render 1`] = ` + +
+ +
+ + + + +
+`; diff --git a/webpack/components/AnsibleRolesSwitcher/components/withProtectedView.js b/webpack/components/AnsibleRolesSwitcher/components/withProtectedView.js new file mode 100644 index 000000000..1f8f0722d --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/components/withProtectedView.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const withProtectedView = ( + ProtectedComponent, + ProtectionComponent, + protectionFn +) => props => + protectionFn(props) ? ( + + ) : ( + + ); + +export default withProtectedView; diff --git a/webpack/components/AnsibleRolesSwitcher/index.js b/webpack/components/AnsibleRolesSwitcher/index.js new file mode 100644 index 000000000..de0a5c462 --- /dev/null +++ b/webpack/components/AnsibleRolesSwitcher/index.js @@ -0,0 +1,44 @@ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import AnsibleRolesSwitcher from './AnsibleRolesSwitcher'; +import * as AnsibleRolesSwitcherActions from './AnsibleRolesSwitcherActions'; +import AnsiblePermissionDenied from './components/AnsiblePermissionDenied'; +import withProtectedView from './components/withProtectedView'; +import { + selectUnassignedRoles, + selectAssignedRolesPage, + selectAssignedRoles, + selectAssignedRolesCount, + selectResults, + selectPaginationMemoized, + selectItemCount, + selectLoading, + selectAssignedPagination, + selectError, +} from './AnsibleRolesSwitcherSelectors'; + +const mapStateToProps = state => ({ + results: selectResults(state), + pagination: selectPaginationMemoized(state), + itemCount: selectItemCount(state), + loading: selectLoading(state), + error: selectError(state), + assignedPagination: selectAssignedPagination(state), + assignedRolesCount: selectAssignedRolesCount(state), + assignedRoles: selectAssignedRolesPage(state), + allAssignedRoles: selectAssignedRoles(state), + unassignedRoles: selectUnassignedRoles(state), +}); + +const mapDispatchToProps = dispatch => + bindActionCreators(AnsibleRolesSwitcherActions, dispatch); + +export default withProtectedView( + connect( + mapStateToProps, + mapDispatchToProps + )(AnsibleRolesSwitcher), + AnsiblePermissionDenied, + props => props.data && props.data.canView +); diff --git a/webpack/components/ReportJsonViewer.js b/webpack/components/ReportJsonViewer.js index c30b96fa2..10d42f04a 100644 --- a/webpack/components/ReportJsonViewer.js +++ b/webpack/components/ReportJsonViewer.js @@ -1,5 +1,6 @@ import React from 'react'; import JSONTree from 'react-json-tree'; +import PropTypes from 'prop-types'; const theme = { scheme: 'foreman', @@ -7,11 +8,14 @@ const theme = { base00: 'rgba(0, 0, 0, 0)', }; -class ReportJsonViewer extends React.Component { - render() { - return
- -
; - } -} +const ReportJsonViewer = ({ data }) => ( +
+ +
+); + +ReportJsonViewer.propTypes = { + data: PropTypes.object.isRequired, +}; + export default ReportJsonViewer; diff --git a/webpack/index.js b/webpack/index.js index a51ee6d4a..3a6d8e656 100644 --- a/webpack/index.js +++ b/webpack/index.js @@ -1,4 +1,17 @@ import componentRegistry from 'foremanReact/components/componentRegistry'; +import injectReducer from 'foremanReact/redux/reducers/registerReducer'; import ReportJsonViewer from './components/ReportJsonViewer'; +import AnsibleRolesSwitcher from './components/AnsibleRolesSwitcher'; -componentRegistry.register({ name: 'ReportJsonViewer', type: ReportJsonViewer }); +import reducer from './reducer'; + +componentRegistry.register({ + name: 'ReportJsonViewer', + type: ReportJsonViewer, +}); +componentRegistry.register({ + name: 'AnsibleRolesSwitcher', + type: AnsibleRolesSwitcher, +}); + +injectReducer('foremanAnsible', reducer); diff --git a/webpack/reducer.js b/webpack/reducer.js new file mode 100644 index 000000000..8025f1556 --- /dev/null +++ b/webpack/reducer.js @@ -0,0 +1,7 @@ +import { combineReducers } from 'redux'; + +import ansibleRolesSwitcher from './components/AnsibleRolesSwitcher/AnsibleRolesSwitcherReducer'; + +export default combineReducers({ + ansibleRolesSwitcher, +}); diff --git a/webpack/test_setup.js b/webpack/test_setup.js new file mode 100644 index 000000000..79c55c258 --- /dev/null +++ b/webpack/test_setup.js @@ -0,0 +1,11 @@ +import 'babel-polyfill'; + +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +configure({ adapter: new Adapter() }); + +// Mocking translation function +global.__ = str => str; +global.n__ = str => str; +global.Jed = { sprintf: str => str };