diff --git a/app/controllers/api/v2/ansible_variables_controller.rb b/app/controllers/api/v2/ansible_variables_controller.rb index d1e5b2d4a..529363aec 100644 --- a/app/controllers/api/v2/ansible_variables_controller.rb +++ b/app/controllers/api/v2/ansible_variables_controller.rb @@ -12,9 +12,10 @@ class AnsibleVariablesController < ::Api::V2::BaseController api_base_url '/ansible/api' end + skip_before_action :verify_authenticity_token before_action :find_resource, :only => [:show, :destroy, :update] before_action :find_proxy, :only => [:import, :obsolete] - before_action :create_importer, :only => [:import, :obsolete] + before_action :create_importer, :only => [:import, :obsolete, :from_json, :from_yaml, :yaml_to_json] api :GET, '/ansible_variables/:id', N_('Show variable') param :id, :identifier, :required => true @@ -90,8 +91,106 @@ def obsolete @obsoleted = old_variables end + api :POST, '/ansible_variables/import/yaml_to_json', + N_('Converts the YAML-file into a JSON-representation the UI can work with') + param :data, Integer, N_('YAML-file as a base64-encoded string') + def yaml_to_json + enc_file = params.require(:data) + render json: encoded_yaml_to_hash(enc_file) + end + + api :PUT, '/ansible_variables/import/from_yaml', N_('Import Ansible variables directly from YAML files') + param :data, Hash, N_('Dictionary of role names and base64-encoded YAML files') + # param structure: + # { + # "data": { + # : { + # "data": + # }, + # : { + # "data": + # } + # ... + # } + # } + # How to document this using apipie? + def from_yaml + level_zero_data = params.require(:data) + temp_hash = {} + level_zero_data.each do |role_name, data| + enc_file = data.require(:data) + yaml_hash = encoded_yaml_to_hash(enc_file) + temp_hash[role_name] = {} unless temp_hash[role_name] + temp_hash[role_name] = yaml_hash.merge(temp_hash[role_name]) + end + from_hash(temp_hash) + end + + api :PUT, '/ansible_variables/import/from_json', N_('Import Ansible variables from JSON. Allows finer control over names, types and default values.') + param :data, Hash, N_('Dictionary of role names and dictionaries of variable names and a hash containing variable value and type.') + # param structure: + # { + # "data": { + # : { + # : { + # "value": , + # "type": + # }, + # ... + # }, + # : { + # : { + # "value": , + # "type": + # }, + # ... + # }, + # ... + # } + # } + # How to document this using apipie? + def from_json + data = params.require(:data).permit!.to_h + from_hash(data) + end + private + def from_hash(data) + data.each do |role_name, variables| + variables.each do |variable_name, variable_data| + role = find_role(role_name) + return role if performed? + existing_variable = AnsibleRole.find_by(name: role_name).ansible_variables.find_by(key: variable_name) + if existing_variable.nil? + if !variable_data.key?(:type) || variable_data["type"] == "auto" + @importer.create_base_variable(variable_name, role, variable_data["value"], @importer.infer_key_type(variable_data["value"])).save + else + @importer.create_base_variable(variable_name, role, variable_data["value"], variable_data["type"]).save + end + else + existing_variable.update({ :override => true, :key_type => variable_data["type"], :default_value => variable_data["value"] }) + end + end + end + end + + def encoded_yaml_to_hash(encoded_string) + loaded_hash = YAML.safe_load(Base64.decode64(encoded_string)) + loaded_hash.each do |variable_name, variable_value| + loaded_hash[variable_name] = { + :value => variable_value, + :type => @importer.infer_key_type(variable_value) + } + end + loaded_hash + end + + def find_role(role_name) + role = AnsibleRole.find_by(name: role_name) + !role.nil? ? role : render_error('custom_error', :status => :unprocessable_entity, :locals => { :message => _("#{role_name} does not exist") }) + end + def find_proxy return nil unless params[:proxy_id] @proxy = SmartProxy. diff --git a/app/helpers/foreman_ansible/ansible_variables_helper.rb b/app/helpers/foreman_ansible/ansible_variables_helper.rb new file mode 100644 index 000000000..4a0d1289e --- /dev/null +++ b/app/helpers/foreman_ansible/ansible_variables_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ForemanAnsible + module AnsibleVariablesHelper + def yaml_import + return '' unless authorized_for({ :controller => :ansible_variables, :action => :new }) + select_action_button('', + { :primary => true, :class => 'yaml-import' }, + link_to(_('Import from YAML-File'), '#yaml_import')) + end + end +end diff --git a/app/services/foreman_ansible/variables_importer.rb b/app/services/foreman_ansible/variables_importer.rb index c957bc0a8..88e67ce03 100644 --- a/app/services/foreman_ansible/variables_importer.rb +++ b/app/services/foreman_ansible/variables_importer.rb @@ -77,18 +77,22 @@ def import_new_role(role_name, new_roles) def initialize_variables(variables, role) variables.map do |variable_name, variable_default| - variable = AnsibleVariable.find_or_initialize_by( - :key => variable_name, - :ansible_role_id => role.id - ) - variable.assign_attributes(:hidden_value => false, - :default_value => variable_default, - :key_type => infer_key_type(variable_default)) - variable.imported = true if variable.new_record? - variable.valid? ? variable : nil + create_base_variable(variable_name, role, variable_default) end end + def create_base_variable(variable_name, role, variable_default, variable_type = nil) + variable = AnsibleVariable.find_or_initialize_by( + :key => variable_name, + :ansible_role_id => role.id + ) + variable.assign_attributes(:hidden_value => false, + :default_value => variable_default, + :key_type => variable_type || infer_key_type(variable_default)) + variable.imported = true if variable.new_record? + variable.valid? ? variable : nil + end + def detect_changes(imported) changes = {}.with_indifferent_access persisted, changes[:new] = imported.partition { |var| var.id.present? } @@ -140,6 +144,10 @@ def delete_old_variables(variables) end end + def infer_key_type(value) + VARIABLE_TYPES[value.class.to_s] || 'string' + end + private def local_variables @@ -150,9 +158,6 @@ def remote_variables proxy_api.all_variables end - def infer_key_type(value) - VARIABLE_TYPES[value.class.to_s] || 'string' - end def iterate_over_variables(variables) variables.reduce([]) do |memo, (role, vars)| diff --git a/app/views/ansible_roles/index.html.erb b/app/views/ansible_roles/index.html.erb index def0fd832..05a47db67 100644 --- a/app/views/ansible_roles/index.html.erb +++ b/app/views/ansible_roles/index.html.erb @@ -1,47 +1,21 @@ +<%= webpacked_plugins_js_for :foreman_ansible %> +<%= webpacked_plugins_css_for :foreman_ansible %> + <% title _("Ansible Roles") %> <% title_actions ansible_proxy_import(hash_for_import_ansible_roles_path), documentation_button('#4.1ImportingRoles', :root_url => ansible_doc_url) %> - - - - - - - - - - - - - <% @ansible_roles.each do |role| %> - - - - - - - - - <% end %> - -
<%= sort :name, :as => s_("Role|Name") %><%= _("Hostgroups") %><%= _("Hosts") %><%= _("Variables") %><%= sort :updated_at, :as => _("Imported at") %><%= _("Actions") %>
<%= role.name %><%= link_to role.hostgroups.count, hostgroups_path(:search => "ansible_role = #{role.name}") %><%= link_to role.hosts.count, hosts_path(:search => "ansible_role = #{role.name}")%><%= link_to(role.ansible_variables.count, ansible_variables_path(:search => "ansible_role = #{role}")) %><%= import_time role %> - <% - links = [ - link_to( - _('Variables'), - ansible_variables_path(:search => "ansible_role = #{role}") - ), - display_delete_if_authorized( - hash_for_ansible_role_path(:id => role). - merge(:auth_object => role, :authorizer => authorizer), - :data => { :confirm => _("Delete %s?") % role.name }, - :action => :delete - ) - ] - %> - <%= action_buttons(*links) %> -
+<%= react_component('AnsibleRolesTable', {:ansibleRoles => @ansible_roles.map { |role| { + :name => role.name, + :id => role.id, + :hostgroupsCount => role.hostgroups.count, + :hostsCount => role.hosts.count, + :variablesCount => role.ansible_variables.count, + :importTime => import_time(role), + :updatedAt => role.updated_at +} }})%> + +<%= react_component('YamlVariablesImporterWrapper')%> <%= will_paginate_with_info @ansible_roles %> diff --git a/app/views/ansible_variables/index.html.erb b/app/views/ansible_variables/index.html.erb index fb345fd85..a640f6361 100644 --- a/app/views/ansible_variables/index.html.erb +++ b/app/views/ansible_variables/index.html.erb @@ -1,7 +1,11 @@ +<%= webpacked_plugins_js_for :foreman_ansible %> +<%= webpacked_plugins_css_for :foreman_ansible %> + <% title _("Ansible Variables") %> <%= stylesheet 'foreman_ansible/foreman-ansible' %> <%= title_actions display_link_if_authorized(_('New Ansible Variable'), hash_for_new_ansible_variable_path, :class => "btn btn-default no-float"), + yaml_import, documentation_button('#4.3Variables', :root_url => ansible_doc_url) %> @@ -47,4 +51,6 @@ +<%= react_component('YamlVariablesImporterWrapper')%> + <%= will_paginate_with_info @ansible_variables %> diff --git a/config/routes.rb b/config/routes.rb index 1a0431e4f..83bf0776e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -97,6 +97,11 @@ collection do put :import put :obsolete + scope '/import' do + put 'from_yaml', to: 'ansible_variables#from_yaml' + put 'from_json', to: 'ansible_variables#from_json' + post 'yaml_to_json', to: 'ansible_variables#yaml_to_json' + end end end diff --git a/lib/foreman_ansible/register.rb b/lib/foreman_ansible/register.rb index 26029c0d7..00a8fb111 100644 --- a/lib/foreman_ansible/register.rb +++ b/lib/foreman_ansible/register.rb @@ -139,7 +139,8 @@ permission :import_ansible_variables, { :ansible_variables => [:import, :confirm_import], - :'api/v2/ansible_variables' => [:import] + :'api/v2/ansible_variables' => [:import], + :'api/v2/ansible_variables/import' => [:yaml_to_json, :from_yaml, :from_json] }, :resource_type => 'AnsibleVariable' permission :view_hosts, diff --git a/test/functional/api/v2/ansible_variables_controller_test.rb b/test/functional/api/v2/ansible_variables_controller_test.rb index 549a415f5..7e5f4ec3d 100644 --- a/test/functional/api/v2/ansible_variables_controller_test.rb +++ b/test/functional/api/v2/ansible_variables_controller_test.rb @@ -58,6 +58,45 @@ class AnsibleVariablesControllerTest < ActionController::TestCase res = JSON.parse(@response.body) assert_equal new_value, res['default_value'] end + + test 'should convert yaml to json' do + test_yaml = "---\nvar_0: \"test_string\"\n" + + testyaml64 = Base64.encode64 test_yaml + put :yaml_to_json, :params => { :data => testyaml64 }, :session => set_session_user + assert_response :success + assert_equal({ 'var_0' => { 'value' => 'test_string', 'type' => 'string' } }, JSON.parse(@response.body)) + end + + test 'should import from json' do + put :from_json, :params => { + "data": { + "#{FactoryBot.create(:ansible_role).name}": { + "some_variable": { + "value": 'test_value', + "type": 'string' + } + } + } + }, :session => set_session_user + assert_response :success + end + + test 'should reject if role does not exist' do + put :from_json, :params => { + "data": { + "non_existent_role": { + "some_variable": { + "value": 'test_value', + "type": 'string' + } + } + } + }, :session => set_session_user + assert_response 422 + assert_equal({ 'error' => { 'message' => 'non_existent_role does not exist' } }, + JSON.parse(@response.body)) + end end end end diff --git a/webpack/components/AnsibleRoles/AnsibleRolesTable.js b/webpack/components/AnsibleRoles/AnsibleRolesTable.js new file mode 100644 index 000000000..188deda8c --- /dev/null +++ b/webpack/components/AnsibleRoles/AnsibleRolesTable.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { TableComposable, Thead, Tr, Th, Tbody } from '@patternfly/react-table'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { AnsibleRolesTableRow } from './components/AnsibleRolesTableRow'; + +export const AnsibleRolesTable = props => { + const searchParams = new URLSearchParams(window.location.search); + const sortString = searchParams.get('order'); + + let sortIndex = null; + let sortDirection = null; + if (sortString) { + const sortStrings = sortString.split(' '); + sortIndex = sortStrings[0] === 'name' ? 0 : 6; + // eslint-disable-next-line prefer-destructuring + sortDirection = sortStrings[1]; + } + + const getSortParams = columnIndex => ({ + sortBy: { + index: sortIndex, + direction: sortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + if (direction !== null && index !== null) { + searchParams.set( + 'order', + `${index === 0 ? 'name' : 'updated_at'} ${direction}` + ); + window.location.search = searchParams.toString(); + } + }, + columnIndex, + }); + return ( + + + + {__('Name')} + {__('Hostgroups')} + {__('Hosts')} + {__('Variables')} + {__('Imported at')} + {__('Actions')} + + + + {props.ansibleRoles.map(role => ( + + ))} + + + ); +}; + +AnsibleRolesTable.propTypes = { + ansibleRoles: PropTypes.array, +}; + +AnsibleRolesTable.defaultProps = { + ansibleRoles: [], +}; diff --git a/webpack/components/AnsibleRoles/AnsibleRolesTable.test.js b/webpack/components/AnsibleRoles/AnsibleRolesTable.test.js new file mode 100644 index 000000000..803d9d9fc --- /dev/null +++ b/webpack/components/AnsibleRoles/AnsibleRolesTable.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { screen } from '@testing-library/dom'; +import { AnsibleRolesTable } from './AnsibleRolesTable'; +import { withMockedProvider, withRedux } from '../../testHelper'; + +const TestComponent = withRedux(withMockedProvider(AnsibleRolesTable)); + +describe('AnsibleRolesTable', () => { + const demoRoles = [ + { + name: 'demo_role_0', + hostgroupsCount: 0, + hostsCount: 0, + id: 1, + updatedAt: '2024-01-28', + importTime: '3 days ago', + variablesCount: 1, + }, + { + name: 'demo_role_1', + hostgroupsCount: 0, + hostsCount: 0, + id: 2, + updatedAt: '2024-01-29', + importTime: '2 days ago', + variablesCount: 2, + }, + { + name: 'demo_role_2', + hostgroupsCount: 0, + hostsCount: 0, + id: 3, + updatedAt: '2024-01-30', + importTime: '1 day ago', + variablesCount: 3, + }, + ]; + + it('should render the table', () => { + const { container } = render(); + expect(container.getElementsByTagName('tr')).toHaveLength( + demoRoles.length + 1 + ); + }); + it('should sort correctly', () => { + Object.defineProperty(window, 'location', { + value: new URL('https://test.url/ansible/ansible_roles'), + writable: true, + }); + render(); + + const importedButton = screen.getByText('Imported at'); + importedButton.click(); // asc + + expect(global.window.location.search).toContain('order=updated_at+asc'); + importedButton.click(); // asc + }); +}); diff --git a/webpack/components/AnsibleRoles/components/AnsibleRolesTableRow.js b/webpack/components/AnsibleRoles/components/AnsibleRolesTableRow.js new file mode 100644 index 000000000..e30535ddc --- /dev/null +++ b/webpack/components/AnsibleRoles/components/AnsibleRolesTableRow.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Tr, Td } from '@patternfly/react-table'; +import { AnsibleRolesTableRowActionButton } from './AnsibleRolesTableRowActionButton'; + +export const AnsibleRolesTableRow = props => ( + + {props.ansibleRole.name} + + + {props.ansibleRole.hostgroupsCount} + + + + + {props.ansibleRole.hostsCount} + + + + + {props.ansibleRole.variablesCount} + + + {props.ansibleRole.importTime} + + + + +); + +AnsibleRolesTableRow.propTypes = { + ansibleRole: PropTypes.object, +}; + +AnsibleRolesTableRow.defaultProps = { + ansibleRole: {}, +}; diff --git a/webpack/components/AnsibleRoles/components/AnsibleRolesTableRowActionButton.js b/webpack/components/AnsibleRoles/components/AnsibleRolesTableRowActionButton.js new file mode 100644 index 000000000..93779b42e --- /dev/null +++ b/webpack/components/AnsibleRoles/components/AnsibleRolesTableRowActionButton.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Dropdown, + DropdownToggle, + DropdownToggleAction, + DropdownItem, +} from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { useDispatch } from 'react-redux'; +import { OPEN_YAML_IMPORT_FROM_ROLE } from '../../YamlVariablesImporter/YamlVariablesImporterConstants'; + +export const AnsibleRolesTableRowActionButton = props => { + const dispatch = useDispatch(); + + const [isActionOpen, setIsActionOpen] = React.useState(false); + const onActionToggle = actionOpen => { + setIsActionOpen(actionOpen); + }; + + const dropdownItems = [ + + {__('Delete')} + , + + dispatch({ + type: OPEN_YAML_IMPORT_FROM_ROLE, + payload: { roleName: props.ansibleRoleName }, + }) + } + > + Import Variables from YAML + , + ]; + + return ( + + { + window.location = `/ansible/ansible_variables?search=ansible_role+%3D+${props.ansibleRoleName}`; + }} + > + {__('Variables')} + , + ]} + toggleVariant="default" + splitButtonVariant="action" + onToggle={onActionToggle} + /> + } + isOpen={isActionOpen} + dropdownItems={dropdownItems} + />{' '} + + ); +}; + +AnsibleRolesTableRowActionButton.propTypes = { + ansibleRoleId: PropTypes.number, + ansibleRoleName: PropTypes.string, +}; + +AnsibleRolesTableRowActionButton.defaultProps = { + ansibleRoleId: 0, + ansibleRoleName: '', +}; diff --git a/webpack/components/YamlVariablesImporter/YamlVariablesImporter.js b/webpack/components/YamlVariablesImporter/YamlVariablesImporter.js new file mode 100644 index 000000000..0519b8deb --- /dev/null +++ b/webpack/components/YamlVariablesImporter/YamlVariablesImporter.js @@ -0,0 +1,133 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { Modal, ModalVariant } from '@patternfly/react-core'; +import { Wizard, WizardStep, WizardHeader } from '@patternfly/react-core/next'; +import { useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { VariableSelectionComponent } from './components/VariableSelectionComponent/VariableSelectionComponent'; +import { UploadYamlFilesComponent } from './components/UploadYamlFilesComponent/UploadYamlFilesComponent'; +import { + evaluateTree, + getInstalledRoles, + installFrom, +} from './YamlVariablesImporterHelpers'; +import { RoleSelectionComponent } from './components/RoleSelectionComponent'; + +import { CLOSE_YAML_IMPORT } from './YamlVariablesImporterConstants'; + +export const YamlVariablesImporter = props => { + const dispatch = useDispatch(); + + const [tree, setTree] = useState([]); + + const [defaultRole, setDefaultRole] = useState('Select default role'); + const [installedRoles, setInstalledRoles] = useState([]); + const [currentFiles, setCurrentFiles] = useState([]); + const [startIndex, setStartIndex] = useState(1); + const haveRolesBeenRequested = useRef(false); + + if (props.roleName) { + // opened from roles page + if (defaultRole !== props.roleName) { + setDefaultRole(props.roleName); + setInstalledRoles([props.roleName]); + setStartIndex(2); + } + } else if (installedRoles.length === 0 && !haveRolesBeenRequested.current) { + const getAndSetRoles = async () => { + const request = await getInstalledRoles(); + if (request) { + setInstalledRoles(request.response); + } + }; + // eslint-disable-next-line no-unused-vars + const ignored = getAndSetRoles(); + haveRolesBeenRequested.current = true; + } + + const handleWizardFinish = async () => { + await installFrom('json', evaluateTree(tree)); + }; + + const handleModalClose = useCallback(() => { + dispatch({ type: CLOSE_YAML_IMPORT }); + + setTree([]); + setDefaultRole('Select default role'); + setInstalledRoles([]); + setCurrentFiles([]); + haveRolesBeenRequested.current = false; + + window.location.hash = ''; + }, [dispatch]); + + return ( + + + } + onClose={handleModalClose} + onSave={handleWizardFinish} + startIndex={startIndex} + > + + + + + + + + {' '} + + + + ); +}; + +YamlVariablesImporter.propTypes = { + isWizardOpen: PropTypes.bool, + roleName: PropTypes.string, + fromRole: PropTypes.bool, +}; + +YamlVariablesImporter.defaultProps = { + isWizardOpen: false, + roleName: null, + fromRole: false, +}; diff --git a/webpack/components/YamlVariablesImporter/YamlVariablesImporterConstants.js b/webpack/components/YamlVariablesImporter/YamlVariablesImporterConstants.js new file mode 100644 index 000000000..e6d049c08 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/YamlVariablesImporterConstants.js @@ -0,0 +1,9 @@ +export const OPEN_YAML_IMPORT_FROM_ROLE = 'OPEN_YAML_IMPORT_FROM_ROLE'; +export const OPEN_YAML_IMPORT = 'OPEN_YAML_IMPORT'; +export const CLOSE_YAML_IMPORT = 'CLOSE_YAML_IMPORT'; + +export const DuplicateStatus = { + NO_DUPLICATE: 0, + LOCAL_DUPLICATE: 1, + INSTALLED_DUPLICATE: 2, +}; diff --git a/webpack/components/YamlVariablesImporter/YamlVariablesImporterHelpers.js b/webpack/components/YamlVariablesImporter/YamlVariablesImporterHelpers.js new file mode 100644 index 000000000..075dd7c27 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/YamlVariablesImporterHelpers.js @@ -0,0 +1,184 @@ +import React from 'react'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { showToast } from '../../toastHelper'; +import { DuplicateStatus } from './YamlVariablesImporterConstants'; + +const installedVariables = {}; + +export const yamlToJson = async b64String => { + const response = await fetch('api/v2/ansible_variables/import/yaml_to_json', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: b64String, readonly: true }), + }); + + if (response.ok) { + return { + code: response.status, + response: await response.json(), + }; + } + showErrorToast('convertYaml', response.status); + return null; +}; + +export const getInstalledRoles = async () => { + const response = await fetch('api/v2/ansible_roles?per_page=all', { + // Max Entries = 50 + method: 'GET', + }); + if (response.ok) { + const responseJson = await response.json(); + return { + code: response.status, + response: responseJson.results.map(role => role.name), + }; + } + showErrorToast('requestRoles', response.status); + return null; +}; + +export const installFrom = async (method, data) => { + const response = await fetch( + `api/v2/ansible_variables/import/from_${method}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( + method === 'yaml' ? { data, readonly: false } : { data } + ), + } + ); + if (response.ok) { + showSuccessToast(countAll(data)); + } else { + showErrorToast('requestRoles', response.status); + } + return { + code: response.status, + response: {}, + }; +}; + +export const sha256 = async fileString => { + const msgUint8 = new TextEncoder().encode(fileString); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +}; + +export const duplicatesInstalled = async (roleName, variableName) => { + if (!installedVariables.hasOwnProperty(roleName)) { + const response = await fetch( + `api/v2/ansible_variables?search=ansible_role=${roleName}&per_page=all`, + { + method: 'GET', + } + ); + if (response.ok) { + const variables = await response.json(); + installedVariables[roleName] = new Map( + variables.results.map(item => [item.parameter, null]) + ); + } else { + showErrorToast(`requestInstalledRolesFor${roleName}`, response.status); + return false; + } + } + return installedVariables[roleName].has(variableName); +}; + +export const duplicatesLocal = (allVariables, variableName) => + allVariables.filter(variable => variable.name === variableName); + +const countAll = data => { + let count = 0; + // eslint-disable-next-line no-unused-vars + Object.keys(data).forEach(role => { + count += Object.keys(data[role]).length; + }); + return count; +}; + +export const evaluateTree = abstractTree => { + const rTree = {}; + + // eslint-disable-next-line no-unused-vars + for (const role of abstractTree) { + const roleName = role.assign_to; + const vTree = {}; + // eslint-disable-next-line no-unused-vars + for (const variable of role.variables) { + if (variable.checked) { + vTree[variable.name] = { value: variable.default, type: variable.type }; + } + } + if (!(Object.keys(vTree).length === 0)) { + rTree[roleName] = { ...rTree[roleName], ...vTree }; + } + } + return rTree; +}; + +export const treeify = (jsonified, internalId, hash, defaultRole) => { + const variables = []; + + // eslint-disable-next-line no-unused-vars + for (const variableName of Object.keys(jsonified)) { + const variable = jsonified[variableName]; + variables.push({ + name: variableName, + checked: true, + default: variable.value, + type: variable.type, + isDuplicate: DuplicateStatus.NO_DUPLICATE, + }); + } + return { + internal_id: internalId, + hash, + assign_to: defaultRole, + count: variables.length, + variables, + }; +}; + +export const fileInTree = (tree, file) => { + // eslint-disable-next-line no-unused-vars + for (const fileObj of tree) { + if (fileObj.hash === file.hash) { + return true; + } + } + return false; +}; +export const showSuccessToast = count => { + showToast({ + type: 'success', + message: ( + + {sprintf(__('Successfully imported %(count)s variables!'), { + count, + })} + + ), + }); +}; + +export const showErrorToast = (job, errorCode) => { + showToast({ + type: 'danger', + message: ( + + {sprintf(__('Error: %(job)s failed with code %(errorCode)s'), { + job, + errorCode, + })} + + ), + }); +}; diff --git a/webpack/components/YamlVariablesImporter/YamlVariablesImporterHelpers.test.js b/webpack/components/YamlVariablesImporter/YamlVariablesImporterHelpers.test.js new file mode 100644 index 000000000..1a07a3539 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/YamlVariablesImporterHelpers.test.js @@ -0,0 +1,66 @@ +import '@testing-library/jest-dom'; +import { + convertedFile0, + convertedFile1, + convertedFile0hash, + convertedFile1hash, + testAllSelectionTree, + testCopy, + testInstalledRoles, + testNoSelectionTree, + testSelectionTree, + testSelectionTreeEvaluated, +} from './testConstants'; +import { evaluateTree, treeify } from './YamlVariablesImporterHelpers'; + +describe('evaluateTree', () => { + it('should handle an empty selection', () => { + expect(evaluateTree(testNoSelectionTree)).toMatchObject({}); + }); + + it('should handle a normal selection', () => { + expect(evaluateTree(testSelectionTree)).toEqual(testSelectionTreeEvaluated); + }); +}); + +describe('treeify', () => { + it('should handle a normal response', () => { + const treeifiedFile0 = treeify( + convertedFile0, + 0, + convertedFile0hash, + testInstalledRoles[0] + ); + const treeifiedFile1 = treeify( + convertedFile1, + 1, + convertedFile1hash, + testInstalledRoles[1] + ); + + expect([treeifiedFile0, treeifiedFile1]).toEqual(testAllSelectionTree); + }); + it('should handle responses with zero variables', () => { + const tempTestNoSelectionTree = testCopy(testNoSelectionTree); + // eslint-disable-next-line no-unused-vars + for (const tempTestNoSelectionTreeElement of tempTestNoSelectionTree) { + tempTestNoSelectionTreeElement.count = 0; + tempTestNoSelectionTreeElement.variables = []; + } + + const treeifiedFile0 = treeify( + {}, + 0, + convertedFile0hash, + testInstalledRoles[0] + ); + const treeifiedFile1 = treeify( + {}, + 1, + convertedFile1hash, + testInstalledRoles[1] + ); + + expect([treeifiedFile0, treeifiedFile1]).toEqual(tempTestNoSelectionTree); + }); +}); diff --git a/webpack/components/YamlVariablesImporter/YamlVariablesImporterReducer.js b/webpack/components/YamlVariablesImporter/YamlVariablesImporterReducer.js new file mode 100644 index 000000000..e22690f24 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/YamlVariablesImporterReducer.js @@ -0,0 +1,35 @@ +import Immutable from 'seamless-immutable'; +import { + CLOSE_YAML_IMPORT, + OPEN_YAML_IMPORT, + OPEN_YAML_IMPORT_FROM_ROLE, +} from './YamlVariablesImporterConstants'; + +export const initialState = Immutable({ + isModalOpen: false, + fromRole: false, + roleName: null, +}); + +const yamlVariablesReducer = (state = initialState, action) => { + switch (action.type) { + case OPEN_YAML_IMPORT: + return state.merge({ + isModalOpen: true, + }); + case OPEN_YAML_IMPORT_FROM_ROLE: + return state.merge({ + isModalOpen: true, + fromRole: true, + roleName: action.payload ? action.payload.roleName : null, + }); + case CLOSE_YAML_IMPORT: + return state.merge({ + isModalOpen: false, + }); + default: + return state; + } +}; + +export default yamlVariablesReducer; diff --git a/webpack/components/YamlVariablesImporter/YamlVariablesImporterWrapper.js b/webpack/components/YamlVariablesImporter/YamlVariablesImporterWrapper.js new file mode 100644 index 000000000..1154b1d79 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/YamlVariablesImporterWrapper.js @@ -0,0 +1,44 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { YamlVariablesImporter } from './YamlVariablesImporter'; +import { OPEN_YAML_IMPORT } from './YamlVariablesImporterConstants'; + +export const YamlVariablesImporterWrapper = () => { + const dispatch = useDispatch(); + + const wizardState = useSelector( + state => state.foremanAnsible.yamlVariablesReducer + ); + + /** + * Watch URL-anchor and open/close modal. + */ + useEffect(() => { + // Handle direct link ...#yaml_import + if (window.location.hash === '#yaml_import') { + dispatch({ + type: OPEN_YAML_IMPORT, + payload: { roleName: 'testName' }, + }); + } + // Set event listener for anchor change + onhashchange = () => { + if (window.location.hash === '#yaml_import') { + dispatch({ + type: OPEN_YAML_IMPORT, + payload: { roleName: 'testName' }, + }); + } + }; + }, [dispatch]); + + return ( + + ); +}; diff --git a/webpack/components/YamlVariablesImporter/components/RoleSelectionComponent.js b/webpack/components/YamlVariablesImporter/components/RoleSelectionComponent.js new file mode 100644 index 000000000..85e11529c --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/RoleSelectionComponent.js @@ -0,0 +1,150 @@ +import React from 'react'; +import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; +import { + MenuToggle, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + FormGroup, + Popover, +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; +import HelpIcon from '@patternfly/react-icons/dist/esm/icons/help-icon'; +import PropTypes from 'prop-types'; + +export const RoleSelectionComponent = props => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + const [filterValue, setFilterValue] = React.useState(''); + const [selectOptions, setSelectOptions] = React.useState([]); + + const menuRef = React.useRef(null); + const textInputRef = React.useRef(); + + React.useEffect(() => { + let newSelectOptions = props.installedRoles; + if (filterValue) { + newSelectOptions = props.installedRoles.filter(menuItem => + String(menuItem) + .toLowerCase() + .includes(filterValue.toLowerCase()) + ); + if (!newSelectOptions.length) { + newSelectOptions = []; + } + } + setSelectOptions(newSelectOptions); + }, [filterValue, props.installedRoles]); + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + const onSelect = (_event, itemId) => { + if (itemId) { + setInputValue(itemId); + setFilterValue(itemId); + props.setDefaultRole(itemId); + } + setIsOpen(false); + }; + const onTextInputChange = (_event, value) => { + setInputValue(value); + setFilterValue(value); + }; + + const createSelectItems = () => { + if (selectOptions.length > 0) { + return selectOptions.map(role => ( + + {role} + + )); + } + return No results found; + }; + + const toggle = toggleRef => ( + + + + + + {!!inputValue && ( + + )} + + + + ); + return ( + + + + } + > + + + ); +}; + +RoleSelectionComponent.propTypes = { + defaultRole: PropTypes.string, + setDefaultRole: PropTypes.func, + installedRoles: PropTypes.array, +}; + +RoleSelectionComponent.defaultProps = { + defaultRole: '', + setDefaultRole: () => {}, + installedRoles: [], +}; diff --git a/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/UploadYamlFilesComponent.js b/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/UploadYamlFilesComponent.js new file mode 100644 index 000000000..8bc234874 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/UploadYamlFilesComponent.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { YamlFileUploader } from './components/YamlFileUploader'; + +export const UploadYamlFilesComponent = props => ( + +); + +UploadYamlFilesComponent.propTypes = { + defaultRole: PropTypes.string, + tree: PropTypes.array, + setTree: PropTypes.func, + currentFiles: PropTypes.array, + setCurrentFiles: PropTypes.func, +}; + +UploadYamlFilesComponent.defaultProps = { + defaultRole: '', + tree: [], + setTree: () => {}, + currentFiles: [], + setCurrentFiles: () => {}, +}; diff --git a/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/components/YamlFileUploader.js b/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/components/YamlFileUploader.js new file mode 100644 index 000000000..a432536be --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/components/YamlFileUploader.js @@ -0,0 +1,115 @@ +import React from 'react'; +import { + MultipleFileUpload, + MultipleFileUploadMain, + MultipleFileUploadStatus, + MultipleFileUploadStatusItem, +} from '@patternfly/react-core'; +import UploadIcon from '@patternfly/react-icons/dist/esm/icons/upload-icon'; +import PropTypes from 'prop-types'; +import { + fileInTree, + showErrorToast, + sha256, + treeify, + yamlToJson, +} from '../../../YamlVariablesImporterHelpers'; + +export const YamlFileUploader = props => { + const removeFile = fileId => { + props.setCurrentFiles(currentFiles => + currentFiles.filter(currentFile => currentFile.uuid !== fileId) + ); + props.setTree(currentTree => + currentTree.filter(fileObj => fileObj.internal_id !== fileId) + ); + }; + + const handleFileDrop = async droppedFiles => { + // eslint-disable-next-line no-unused-vars + for (const droppedFile of droppedFiles) { + try { + Object.defineProperties(droppedFile, { + hash: { + // eslint-disable-next-line no-await-in-loop + value: await sha256(await droppedFile.text()), + }, + uuid: { + value: crypto.randomUUID(), + }, + }); + } catch (e) { + droppedFiles.pop(droppedFile); + showErrorToast('uploadFile', e); + } + } + + props.setCurrentFiles(prevFiles => [ + ...prevFiles, + ...droppedFiles.filter( + droppedFile => + !prevFiles.some(prevFile => prevFile?.hash === droppedFile?.hash) + ), + ]); + }; + const handleReadSuccess = async (data, file) => { + if (!fileInTree(props.tree, file)) { + const converted = await yamlToJson(data.split(',')[1]); + if (converted) { + const node = treeify( + converted.response, + file.uuid, + file.hash, + props.defaultRole + ); + props.tree.push(node); + } + } + }; + + return ( + + } + titleText="Drag and drop files here" + infoText="Accepted file types: YAML" + /> + + {props.currentFiles.map(file => ( + removeFile(file.uuid)} + onReadSuccess={handleReadSuccess} + data-testid="YamlFileUploaderFileItem" + /> + ))} + + + ); +}; + +YamlFileUploader.propTypes = { + tree: PropTypes.array, + setTree: PropTypes.func, + defaultRole: PropTypes.string, + currentFiles: PropTypes.array, + setCurrentFiles: PropTypes.func, +}; + +YamlFileUploader.defaultProps = { + tree: {}, + setTree: () => {}, + defaultRole: '', + currentFiles: [], + setCurrentFiles: () => {}, +}; diff --git a/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/components/YamlFileUploader.test.js b/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/components/YamlFileUploader.test.js new file mode 100644 index 000000000..f9dcf9e51 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/UploadYamlFilesComponent/components/YamlFileUploader.test.js @@ -0,0 +1,35 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { render } from '@testing-library/react'; +import { + testCopy, + testDefaultRole, + testNoSelectionTree, +} from '../../../testConstants'; + +import { YamlFileUploader } from './YamlFileUploader'; + +describe('YamlFileUploader', () => { + it('should render', async () => { + const testTree = testCopy(testNoSelectionTree); + + const { container } = render( + + new File([''], `${fileNode.internal_id}.yaml`, { + type: 'text/plain', + }) + )} + /> + ); + + const fileListings = container.querySelectorAll( + '.pf-c-multiple-file-upload__status-item' + ); + expect(fileListings).toHaveLength(testTree.length); + }); +}); diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/VariableSelectionComponent.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/VariableSelectionComponent.js new file mode 100644 index 000000000..879e6b4cc --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/VariableSelectionComponent.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { VariableSelectionSubMenu } from './components/VariableSelectionSubMenu'; + +export const VariableSelectionComponent = props => ( + +); + +VariableSelectionComponent.propTypes = { + tree: PropTypes.array, + setTree: PropTypes.func, + installedRoles: PropTypes.array, +}; + +VariableSelectionComponent.defaultProps = { + tree: [], + setTree: () => {}, + installedRoles: [], +}; diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionSubMenu.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionSubMenu.js new file mode 100644 index 000000000..bdeca4e9d --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionSubMenu.js @@ -0,0 +1,146 @@ +import React from 'react'; +import { TableComposable, Thead, Tr, Th, Tbody } from '@patternfly/react-table'; +import PropTypes from 'prop-types'; +import { Popover } from '@patternfly/react-core'; +import HelpIcon from '@patternfly/react-icons/dist/esm/icons/help-icon'; +import styles from '@patternfly/react-styles/css/components/Form/form'; +import { VariableSelectionSecondaryRow } from './VariableSelectionTable/components/VariableSelectionSecondaryRow'; +import { VariableSelectionPrimaryRow } from './VariableSelectionTable/components/VariableSelectionPrimaryRow'; + +export const VariableSelectionSubMenu = props => { + const [expandedNodeNames, setExpandedNodeNames] = React.useState([]); + const [ + expandedDetailsNodeNames, + setExpandedDetailsNodeNames, + ] = React.useState([]); + const [installedVariables, setInstalledVariables] = React.useState({}); + + const renderPrimaryRows = ( + [node, ...remainingNodes], + level = 1, + posinset = 1, + rowIndex = 0, + isHidden = false + ) => { + if (!node) { + return []; + } + const isExpanded = expandedNodeNames.includes(node.internal_id); + const isDetailsExpanded = expandedDetailsNodeNames.includes( + node.internal_id + ); + + const childRows = + node.variables && node.variables.length + ? renderSecondaryRows( + node.variables, + level + 1, + 1, + rowIndex + 1, + !isExpanded || isHidden, + node.assign_to, + node.variables + ) + : []; + return [ + , + + ...childRows, + ...renderPrimaryRows( + remainingNodes, + level, + posinset + 1, + rowIndex + 1 + childRows.length, + isHidden + ), + ]; + }; + + const renderSecondaryRows = ( + [node, ...remainingNodes], + level = 1, + posinset = 1, + rowIndex = 0, + isHidden = false, + roleName, + allVariablesRef + ) => { + if (!node) { + return []; + } + + return [ + , + + ...renderSecondaryRows( + remainingNodes, + level, + posinset + 1, + rowIndex + 1, + isHidden, + roleName, + allVariablesRef + ), + ]; + }; + + return ( + + + + Roles/Variables + + Default Value{' '} + Inputs are not checked for validity.} + > + + + + + + {renderPrimaryRows(props.tree)} + + ); +}; + +VariableSelectionSubMenu.propTypes = { + tree: PropTypes.array, + setTree: PropTypes.func, + installedRoles: PropTypes.array, +}; + +VariableSelectionSubMenu.defaultProps = { + tree: [], + setTree: () => {}, + installedRoles: [], +}; diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/RoleSelectionDropdown.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/RoleSelectionDropdown.js new file mode 100644 index 000000000..5b6179b96 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/RoleSelectionDropdown.js @@ -0,0 +1,108 @@ +import React, { useEffect } from 'react'; +import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; +import { MenuToggle } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import { + duplicatesInstalled, + duplicatesLocal, +} from '../../../../../YamlVariablesImporterHelpers'; +import { DuplicateStatus } from '../../../../../YamlVariablesImporterConstants'; + +export const RoleSelectionDropdown = props => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(props.node.assign_to); + const menuRef = React.useRef(null); + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const checkAllVariables = async (roleName = selected) => { + // eslint-disable-next-line no-unused-vars + for (const variable of props.node.variables) { + // eslint-disable-next-line no-await-in-loop + const isInstalledDuplicate = await duplicatesInstalled( + roleName, + variable.name + ); + const localDuplicates = duplicatesLocal( + props.node.variables, + variable.name + ); + if (localDuplicates.length > 1) { + localDuplicates.forEach(duplicate => { + duplicate.isDuplicate = DuplicateStatus.LOCAL_DUPLICATE; + }); + } else if (isInstalledDuplicate) { + variable.isDuplicate = DuplicateStatus.INSTALLED_DUPLICATE; + } else { + variable.isDuplicate = DuplicateStatus.NO_DUPLICATE; + } + } + }; + + useEffect(() => { + async function request() { + // eslint-disable-next-line no-unused-vars + await checkAllVariables(); + } + // eslint-disable-next-line no-unused-vars + const ignored = request(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSelect = async (_event, itemId) => { + await checkAllVariables(itemId); + + props.node.assign_to = itemId; + setSelected(itemId); + setIsOpen(false); + props.setTree([...props.tree]); + }; + const toggle = toggleRef => ( + + {selected} + + ); + return ( + + ); +}; + +RoleSelectionDropdown.propTypes = { + installedRoles: PropTypes.array, + node: PropTypes.object, + tree: PropTypes.array, + setTree: PropTypes.func, +}; + +RoleSelectionDropdown.defaultProps = { + installedRoles: [], + node: { variables: [] }, + tree: [], + setTree: () => {}, +}; diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/RoleSelectionDropdown.test.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/RoleSelectionDropdown.test.js new file mode 100644 index 000000000..b1cd9df36 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/RoleSelectionDropdown.test.js @@ -0,0 +1,14 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { render } from '@testing-library/react'; + +import { RoleSelectionDropdown } from './RoleSelectionDropdown'; + +describe('RoleSelectionDropdown', () => { + it('should render the default component', async () => { + const { container } = render(); + + expect(container).toBeInTheDocument(); + }); +}); diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableDefaultField.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableDefaultField.js new file mode 100644 index 000000000..08744dd8f --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableDefaultField.js @@ -0,0 +1,80 @@ +import React from 'react'; +import { InputGroup, TextInput, MenuToggle } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; + +import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; + +export const VariableDefaultField = props => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(props.node.type); + const [defaultValue, setDefaultValue] = React.useState( + JSON.stringify(props.node.default) + ); + + const handleDefaultChange = (input, _event) => { + setDefaultValue(input); + props.node.default = input; + }; + const menuRef = React.useRef(null); + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event, itemId) => { + setSelected(itemId); + setIsOpen(false); + props.node.type = itemId; + }; + + return ( + + + + + + + ); +}; + +VariableDefaultField.propTypes = { + node: PropTypes.object, +}; + +VariableDefaultField.defaultProps = { + node: {}, +}; diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableDefaultField.test.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableDefaultField.test.js new file mode 100644 index 000000000..852c6b77c --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableDefaultField.test.js @@ -0,0 +1,41 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { VariableDefaultField } from './VariableDefaultField'; +import { + testCopy, + testVariableNode, + variableDefaultFieldDropdownOptions, +} from '../../../../../testConstants'; + +describe('VariableDefaultField', () => { + it('should render field', async () => { + const { container } = render( + + ); + + const inputField = container.querySelector('input'); + const menuToggle = screen.getByTestId('VariableDefaultFieldToggle'); + + await waitFor(() => { + fireEvent.click(menuToggle); + }); + + const menuItems = container.getElementsByTagName('li'); + expect(menuItems).toHaveLength(variableDefaultFieldDropdownOptions.length); + expect(inputField.value).toBe(JSON.stringify(testVariableNode.default)); + }); + it('should process inputs', async () => { + const tempNode = testCopy(testVariableNode); + + const { container } = render(); + const inputField = container.querySelector('input'); + + const newDefault = 'new_default_val'; + fireEvent.change(inputField, { target: { value: newDefault } }); + + expect(inputField.value).toBe(newDefault); + expect(tempNode.default).toBe(newDefault); + }); +}); diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableNameField.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableNameField.js new file mode 100644 index 000000000..afd702b8a --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableNameField.js @@ -0,0 +1,101 @@ +import React from 'react'; +import { + Form, + FormGroup, + TextInput, + HelperText, + HelperTextItem, + FormHelperText, +} from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; +import { + duplicatesInstalled, + duplicatesLocal, +} from '../../../../../YamlVariablesImporterHelpers'; +import { DuplicateStatus } from '../../../../../YamlVariablesImporterConstants'; + +export const VariableNameField = props => { + const [demoVariableName, setDemoVariableName] = React.useState( + props.node.name + ); + + const checkForDuplicates = async (name, oldName) => { + const isInstalledDuplicate = await duplicatesInstalled( + props.roleName, + name + ); + const localDuplicates = duplicatesLocal(props.allVariables, name); + if (localDuplicates.length > 1) { + localDuplicates.forEach(duplicate => { + duplicate.isDuplicate = DuplicateStatus.LOCAL_DUPLICATE; + }); + props.setTree([...props.tree]); + } else if (isInstalledDuplicate) { + props.node.isDuplicate = DuplicateStatus.INSTALLED_DUPLICATE; + } else if (props.node.isDuplicate !== DuplicateStatus.NO_DUPLICATE) { + props.node.isDuplicate = DuplicateStatus.NO_DUPLICATE; + const oldDuplicates = await duplicatesLocal(props.allVariables, oldName); + if (oldDuplicates.length === 1) { + const oldIsInstalledDuplicate = await duplicatesInstalled( + props.roleName, + oldName + ); + oldDuplicates[0].isDuplicate = oldIsInstalledDuplicate + ? DuplicateStatus.INSTALLED_DUPLICATE + : DuplicateStatus.NO_DUPLICATE; + } + } + props.setTree([...props.tree]); + }; + + const handleVariableNameChange = async (input, _event) => { + const oldName = props.node.name; + props.node.name = input; + await checkForDuplicates(input, oldName); + setDemoVariableName(input); + }; + return ( +
+ + + {props.node.isDuplicate !== DuplicateStatus.NO_DUPLICATE && ( + + + } variant="error"> + {props.node.isDuplicate === DuplicateStatus.INSTALLED_DUPLICATE + ? `Variable "${props.node.name}" already exists. It will be overridden!` + : 'Variable names must be unique.'} + + + + )} + +
+ ); +}; + +VariableNameField.propTypes = { + node: PropTypes.object, + roleName: PropTypes.string, + allVariables: PropTypes.array, + tree: PropTypes.array, + setTree: PropTypes.func, +}; + +VariableNameField.defaultProps = { + node: {}, + roleName: '', + allVariables: [], + tree: [], + setTree: () => {}, +}; diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableNameField.test.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableNameField.test.js new file mode 100644 index 000000000..7355b573a --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableNameField.test.js @@ -0,0 +1,83 @@ +import React from 'react'; +import '@testing-library/jest-dom'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { render, fireEvent, waitFor } from '@testing-library/react'; + +import { testCopy, testVariableNode } from '../../../../../testConstants'; +import { VariableNameField } from './VariableNameField'; +import * as helpers from '../../../../../YamlVariablesImporterHelpers'; +import { DuplicateStatus } from '../../../../../YamlVariablesImporterConstants'; + +describe('VariableNameField', () => { + it('should render the default field', async () => { + const { container } = render(); + + const inputField = container.querySelector('input'); + + expect(inputField.value).toBe(''); + }); + it('should process inputs', async () => { + jest.spyOn(helpers, 'duplicatesInstalled').mockImplementation(() => false); + jest.spyOn(helpers, 'duplicatesLocal').mockImplementation(() => []); + + const tempNode = testCopy(testVariableNode); + + const { container } = render(); + const inputField = container.querySelector('input'); + + const newName = 'new_var_name'; + await waitFor(() => { + fireEvent.change(inputField, { target: { value: newName } }); + }); + + expect(inputField.value).toBe(newName); + expect(tempNode.name).toBe(newName); + }); + + it('should alert on conflicting inputs', async () => { + const tempNode = testCopy(testVariableNode); + tempNode.isDuplicate = DuplicateStatus.INSTALLED_DUPLICATE; + + const { container } = render(); + const helperTextField = container.querySelector( + '.pf-c-helper-text__item-text' + ); + const inputField = container.querySelector('input'); + + const newName1 = 'new_var_name_1'; + const newName2 = 'new_var_name_2'; + const newName3 = 'new_var_name_3'; + + jest.spyOn(helpers, 'duplicatesInstalled').mockImplementation(() => false); + jest + .spyOn(helpers, 'duplicatesLocal') + .mockImplementation(() => [tempNode, tempNode]); + + await waitFor(() => { + fireEvent.change(inputField, { target: { value: newName1 } }); + }); + + expect(helperTextField).toHaveTextContent(`Variable names must be unique`); + expect(tempNode.isDuplicate).toBe(DuplicateStatus.LOCAL_DUPLICATE); + + jest.spyOn(helpers, 'duplicatesInstalled').mockImplementation(() => true); + jest.spyOn(helpers, 'duplicatesLocal').mockImplementation(() => []); + + await waitFor(() => { + fireEvent.change(inputField, { target: { value: newName2 } }); + }); + expect(helperTextField).toHaveTextContent( + `Variable "${newName2}" already exists. It will be overridden!` + ); + expect(tempNode.isDuplicate).toBe(DuplicateStatus.INSTALLED_DUPLICATE); + + jest.spyOn(helpers, 'duplicatesInstalled').mockImplementation(() => false); + jest.spyOn(helpers, 'duplicatesLocal').mockImplementation(() => []); + + await waitFor(() => { + fireEvent.change(inputField, { target: { value: newName3 } }); + }); + expect(tempNode.isDuplicate).toBe(DuplicateStatus.NO_DUPLICATE); + }); +}); diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionPrimaryRow.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionPrimaryRow.js new file mode 100644 index 000000000..42a194334 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionPrimaryRow.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { Td, TreeRowWrapper } from '@patternfly/react-table'; +import PropTypes from 'prop-types'; +import { RoleSelectionDropdown } from './RoleSelectionDropdown'; + +export const VariableSelectionPrimaryRow = props => { + const level = 1; + const posinset = 1; + const { isExpanded } = props; + const { isDetailsExpanded } = props; + + let isChecked; + if (props.node.variables.every(n => n.checked)) { + isChecked = true; + } else if (props.node.variables.some(n => n.checked)) { + isChecked = null; + } else { + isChecked = false; + } + + const treeRow = { + onCollapse: () => + props.setExpandedNodeNames(prevExpanded => { + const otherExpandedNodeNames = prevExpanded.filter( + name => name !== props.node.internal_id + ); + return props.isExpanded + ? otherExpandedNodeNames + : [...otherExpandedNodeNames, props.node.internal_id]; + }), + onToggleRowDetails: () => + props.setExpandedDetailsNodeNames(prevDetailsExpanded => { + const otherDetailsExpandedNodeNames = prevDetailsExpanded.filter( + name => name !== props.node.internal_id + ); + return isDetailsExpanded + ? otherDetailsExpandedNodeNames + : [...otherDetailsExpandedNodeNames, props.node.internal_id]; + }), + onCheckChange: () => { + props.node.variables.forEach(v => { + v.checked = !isChecked; + }); + props.setTree([...props.tree]); + }, + props: { + isExpanded, + isDetailsExpanded, + 'aria-level': level, + 'aria-posinset': posinset, + 'aria-setsize': props.node.variables ? props.node.variables.length : 0, + isChecked, + checkboxId: `checkbox_id_${props.node.internal_id}`, + }, + }; + + return ( + + + + + + ); +}; + +VariableSelectionPrimaryRow.propTypes = { + isExpanded: PropTypes.bool, + isDetailsExpanded: PropTypes.bool, + setExpandedNodeNames: PropTypes.func, + setExpandedDetailsNodeNames: PropTypes.func, + node: PropTypes.object, + setTree: PropTypes.func, + tree: PropTypes.array, + installedRoles: PropTypes.array, +}; + +VariableSelectionPrimaryRow.defaultProps = { + isExpanded: false, + isDetailsExpanded: false, + setExpandedNodeNames: () => {}, + setExpandedDetailsNodeNames: () => {}, + node: {}, + setTree: () => {}, + tree: [], + installedRoles: [], +}; diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionPrimaryRow.test.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionPrimaryRow.test.js new file mode 100644 index 000000000..1635d2fc3 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionPrimaryRow.test.js @@ -0,0 +1,90 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { TableComposable, Tbody } from '@patternfly/react-table'; +import { render, fireEvent } from '@testing-library/react'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { VariableSelectionPrimaryRow } from './VariableSelectionPrimaryRow'; +import { + testInstalledRoles, + testNoSelectionTree, + testFileNode, + testCopy, +} from '../../../../../testConstants'; +import * as helpers from '../../../../../YamlVariablesImporterHelpers'; + +describe('VariableSelectionPrimaryRow', () => { + const isExpanded = false; + const isDetailsExpanded = false; + const setExpandedNodeNames = () => {}; + const setExpandedDetailsNodeNames = () => {}; + const node = testFileNode; + const setTree = () => {}; + const tree = testNoSelectionTree; + const installedRoles = testInstalledRoles; + jest.spyOn(helpers, 'duplicatesInstalled').mockImplementation(() => false); + jest.spyOn(helpers, 'duplicatesLocal').mockImplementation(() => []); + + it('should render the primary row', () => { + const { container } = render( + + + + + + ); + + const roleSelectorDropdownBtn = container.querySelector( + '.pf-c-menu-toggle' + ); + + expect(container).toBeInTheDocument(); + expect( + roleSelectorDropdownBtn.querySelector('.pf-c-menu-toggle__text') + ).toHaveTextContent(testFileNode.assign_to); + }); + + it('should handle checking all', async () => { + const tempNode = testCopy(node); + + const { container } = render( + + + + + + ); + + const selectAllBox = container.querySelector('.pf-c-check__input'); + + // eslint-disable-next-line no-unused-vars + for (const variable of tempNode.variables) { + expect(variable.checked).toBe(false); + } + + await fireEvent.click(selectAllBox); + + // eslint-disable-next-line no-unused-vars + for (const variable of tempNode.variables) { + expect(variable.checked).toBe(true); + } + }); +}); diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionSecondaryRow.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionSecondaryRow.js new file mode 100644 index 000000000..1d5c992e9 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionSecondaryRow.js @@ -0,0 +1,68 @@ +import React from 'react'; +import { Td, TreeRowWrapper } from '@patternfly/react-table'; +import PropTypes from 'prop-types'; +import { VariableNameField } from './VariableNameField'; +import { VariableDefaultField } from './VariableDefaultField'; + +export const VariableSelectionSecondaryRow = props => { + const isChecked = props.node.checked; + const { isHidden } = props; + const level = 2; + const posinset = 1; + + const treeRow = { + onCheckChange: () => { + props.node.checked = !props.node.checked; + props.setTree([...props.tree]); + }, + props: { + isHidden, + 'aria-level': level, + 'aria-posinset': posinset, + 'aria-setsize': 0, + isChecked, + checkboxId: `checkbox_id_${props.checkboxId}`, + }, + }; + + return ( + + + {' '} + + + + + + ); +}; + +VariableSelectionSecondaryRow.propTypes = { + node: PropTypes.object, + isHidden: PropTypes.bool, + setTree: PropTypes.func, + tree: PropTypes.array, + roleName: PropTypes.string, + checkboxId: PropTypes.number, + allVariables: PropTypes.array, +}; + +VariableSelectionSecondaryRow.defaultProps = { + node: {}, + isHidden: false, + setTree: () => {}, + tree: [], + roleName: '', + checkboxId: 0, + allVariables: [], +}; diff --git a/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionSecondaryRow.test.js b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionSecondaryRow.test.js new file mode 100644 index 000000000..906b5c7eb --- /dev/null +++ b/webpack/components/YamlVariablesImporter/components/VariableSelectionComponent/components/VariableSelectionTable/components/VariableSelectionSecondaryRow.test.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { VariableSelectionSubMenu } from '../../VariableSelectionSubMenu'; + +describe('VariableSelectionSecondaryRow', () => { + it('should render the secondary row', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); +}); diff --git a/webpack/components/YamlVariablesImporter/testConstants.js b/webpack/components/YamlVariablesImporter/testConstants.js new file mode 100644 index 000000000..81d39c996 --- /dev/null +++ b/webpack/components/YamlVariablesImporter/testConstants.js @@ -0,0 +1,208 @@ +import { DuplicateStatus } from './YamlVariablesImporterConstants'; + +export const convertedFile0 = { + role_0_var_0: { + value: 'r0v0_val', + type: 'string', + }, + role_0_var_1: { + value: 'r0v1_val', + type: 'string', + }, +}; +export const convertedFile1 = { + role_1_var_0: { + value: 'r1v0_val', + type: 'string', + }, + role_1_var_1: { + value: 'r1v1_val', + type: 'string', + }, +}; + +export const convertedFile0hash = 'hash0'; +export const convertedFile1hash = 'hash1'; + +export const testNoSelectionTree = [ + { + internal_id: 0, + hash: convertedFile0hash, + assign_to: 'role_0', + count: 2, + variables: [ + { + name: 'role_0_var_0', + checked: false, + default: 'r0v0_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + { + name: 'role_0_var_1', + checked: false, + default: 'r0v1_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + ], + }, + { + internal_id: 1, + hash: convertedFile1hash, + assign_to: 'role_1', + count: 2, + variables: [ + { + name: 'role_1_var_0', + checked: false, + default: 'r1v0_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + { + name: 'role_1_var_1', + checked: false, + default: 'r1v1_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + ], + }, +]; + +export const testAllSelectionTree = [ + { + internal_id: 0, + hash: convertedFile0hash, + assign_to: 'role_0', + count: 2, + variables: [ + { + name: 'role_0_var_0', + checked: true, + default: 'r0v0_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + { + name: 'role_0_var_1', + checked: true, + default: 'r0v1_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + ], + }, + { + internal_id: 1, + hash: convertedFile1hash, + assign_to: 'role_1', + count: 2, + variables: [ + { + name: 'role_1_var_0', + checked: true, + default: 'r1v0_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + { + name: 'role_1_var_1', + checked: true, + default: 'r1v1_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + ], + }, +]; + +export const testSelectionTree = [ + { + internal_id: 0, + hash: convertedFile0hash, + assign_to: 'role_0', + count: 2, + variables: [ + { + name: 'role_0_var_0', + checked: true, + default: 'r0v0_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + { + name: 'role_0_var_1', + checked: false, + default: 'r0v1_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + ], + }, + { + internal_id: 1, + hash: convertedFile1hash, + assign_to: 'role_1', + count: 2, + variables: [ + { + name: 'role_1_var_0', + checked: false, + default: 'r1v0_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + { + name: 'role_1_var_1', + checked: true, + default: 'r1v1_val', + isDuplicate: DuplicateStatus.NO_DUPLICATE, + type: 'string', + }, + ], + }, +]; + +export const testSelectionTreeEvaluated = { + role_0: { + role_0_var_0: { + type: 'string', + value: 'r0v0_val', + }, + }, + role_1: { + role_1_var_1: { + type: 'string', + value: 'r1v1_val', + }, + }, +}; + +export const testFileNode = testNoSelectionTree[0]; + +export const testVariableNode = testNoSelectionTree[0].variables[0]; + +export const testInstalledRoles = [ + 'role_0', + 'role_1', + 'role_2', + 'role_3', + 'role_4', +]; +export const testDefaultRole = 'role_2'; + +export const variableDefaultFieldDropdownOptions = [ + 'auto', + 'string', + 'boolean', + 'integer', + 'real', + 'array', + 'hash', + 'yaml', + 'json', +]; + +export const testCopy = original => JSON.parse(JSON.stringify(original)); diff --git a/webpack/index.js b/webpack/index.js index 760aeda43..83db6bfec 100644 --- a/webpack/index.js +++ b/webpack/index.js @@ -4,6 +4,8 @@ import ReportJsonViewer from './components/ReportJsonViewer'; import AnsibleRolesSwitcher from './components/AnsibleRolesSwitcher'; import WrappedImportRolesAndVariables from './components/AnsibleRolesAndVariables'; import reducer from './reducer'; +import { AnsibleRolesTable } from './components/AnsibleRoles/AnsibleRolesTable'; +import { YamlVariablesImporterWrapper } from './components/YamlVariablesImporter/YamlVariablesImporterWrapper'; componentRegistry.register({ name: 'ReportJsonViewer', @@ -19,4 +21,14 @@ componentRegistry.register({ type: WrappedImportRolesAndVariables, }); +componentRegistry.register({ + name: 'YamlVariablesImporterWrapper', + type: YamlVariablesImporterWrapper, +}); + +componentRegistry.register({ + name: 'AnsibleRolesTable', + type: AnsibleRolesTable, +}); + injectReducer('foremanAnsible', reducer); diff --git a/webpack/reducer.js b/webpack/reducer.js index 8025f1556..3eff35148 100644 --- a/webpack/reducer.js +++ b/webpack/reducer.js @@ -1,7 +1,9 @@ import { combineReducers } from 'redux'; import ansibleRolesSwitcher from './components/AnsibleRolesSwitcher/AnsibleRolesSwitcherReducer'; +import yamlVariablesReducer from './components/YamlVariablesImporter/YamlVariablesImporterReducer'; export default combineReducers({ ansibleRolesSwitcher, + yamlVariablesReducer, });