diff --git a/app/controllers/api/v2/vcs_clone_controller.rb b/app/controllers/api/v2/vcs_clone_controller.rb
new file mode 100644
index 000000000..cb3a8f65b
--- /dev/null
+++ b/app/controllers/api/v2/vcs_clone_controller.rb
@@ -0,0 +1,150 @@
+module Api
+ module V2
+ class VcsCloneController < ::Api::V2::BaseController
+ include ::ForemanAnsible::ProxyAPI
+ include ::Api::Version2
+
+ resource_description do
+ api_version 'v2'
+ api_base_url '/ansible/api'
+ end
+
+ def_param_group :repo_information do
+ param :repo_info, Hash, :desc => N_('Hash containing info about the Ansible role to be installed') do
+ param :vcs_url, String, :desc => N_('URL of the repository'), :required => true
+ param :role_name, String, :desc => N_('Name of the Ansible role'), :required => true
+ param :ref, String, :desc => N_('Branch / Tag / Commit reference'), :required => true
+ end
+ end
+
+ rescue_from ActionController::ParameterMissing do |e|
+ render json: { 'error' => e.message }, status: :bad_request
+ end
+
+ skip_before_action :verify_authenticity_token
+
+ before_action :set_proxy_api
+
+ api :GET, '/smart_proxies/:smart_proxy_id/ansible/vcs_clone/repository_metadata',
+ N_('Retrieve metadata about the repository associated with a Smart Proxy.')
+ param :smart_proxy_id, Array, N_('Name of the Smart Proxy'), :required => true
+ param :vcs_url, String, N_('URL of the repository'), :required => true
+ error 400, :desc => N_('Invalid or missing parameters')
+ def repository_metadata
+ vcs_url = params.require(:vcs_url)
+ render json: @proxy_api.repo_information(vcs_url)
+ end
+
+ api :GET, '/smart_proxies/:smart_proxy_id/ansible/vcs_clone/roles',
+ N_('Returns an array of Ansible roles installed on the provided Smart Proxy')
+ formats ['json']
+ param :smart_proxy_id, Array, N_('Name of the SmartProxy'), :required => true
+ error 400, :desc => N_('Invalid or missing parameters')
+ def installed_roles
+ render json: @proxy_api.list_installed
+ end
+
+ api :POST, '/smart_proxies/:smart_proxy_id/ansible/vcs_clone/roles',
+ N_('Launches a task to install the provided role')
+ formats ['json']
+ param_group :repo_information
+ param :smart_proxy_id, Array, N_('Smart Proxy where the role should be installed')
+ error 400, :desc => N_('Invalid or missing parameters')
+ def install_role
+ payload = verify_install_role_parameters(params)
+ start_vcs_task(payload, :install)
+ end
+
+ api :PUT, '/smart_proxies/:smart_proxy_id/ansible/vcs_clone/roles',
+ N_('Launches a task to update the provided role')
+ formats ['json']
+ param_group :repo_information
+ param :smart_proxy_id, Array, N_('Smart Proxy where the role should be installed')
+ error 400, :desc => N_('Invalid or missing parameters')
+ def update_role
+ payload = verify_update_role_parameters(params)
+ payload['name'] = params.require(:role_name)
+ start_vcs_task(payload, :update)
+ end
+
+ api :DELETE, '/smart_proxies/:smart_proxy_id/ansible/vcs_clone/roles/:role_name',
+ N_('Launches a task to delete the provided role')
+ formats ['json']
+ param :role_name, String, :desc => N_('Name of the role to be deleted')
+ param :smart_proxy_id, Array, N_('Smart Proxy to delete the role from')
+ error 400, :desc => N_('Invalid or missing parameters')
+ def delete_role
+ payload = params.require(:role_name)
+ start_vcs_task(payload, :delete)
+ end
+
+ private
+
+ def set_proxy_api
+ unless params[:id]
+ msg = _('Smart proxy id is required')
+ return render_error('custom_error', :status => :unprocessable_entity, :locals => { :message => msg })
+ end
+ ansible_proxy = SmartProxy.find_by(id: params[:id])
+ if ansible_proxy.nil?
+ msg = _('Smart proxy does not exist')
+ return render_error('custom_error', :status => :bad_request, :locals => { :message => msg })
+ else unless ansible_proxy.has_capability?('Ansible', 'vcs_clone')
+ msg = _('Smart Proxy is missing foreman_ansible installation or Git cloning capability')
+ return render_error('custom_error', :status => :bad_request, :locals => { :message => msg })
+ end
+ end
+ @proxy = ansible_proxy
+ @proxy_api = find_proxy_api(ansible_proxy)
+ end
+
+ def permit_parameters(params)
+ params.require(:vcs_clone).
+ permit(
+ repo_info: [
+ :vcs_url,
+ :role_name,
+ :ref
+ ]
+ ).to_h
+ end
+
+ def verify_install_role_parameters(params)
+ payload = permit_parameters params
+ %w[vcs_url role_name ref].each do |param|
+ raise ActionController::ParameterMissing.new(param) unless payload['repo_info'].key?(param)
+ end
+ payload
+ end
+
+ def verify_update_role_parameters(params)
+ payload = permit_parameters params
+ %w[vcs_url ref].each do |param|
+ raise ActionController::ParameterMissing.new(param) unless payload['repo_info'].key?(param)
+ end
+ payload
+ end
+
+ def start_vcs_task(op_info, operation)
+ case operation
+ when :update
+ job = UpdateAnsibleRole.perform_later(op_info, @proxy)
+ when :install
+ job = CloneAnsibleRole.perform_later(op_info, @proxy)
+ when :delete
+ job = DeleteAnsibleRole.perform_later(op_info, @proxy)
+ else
+ raise Foreman::Exception.new(N_('Unsupported operation'))
+ end
+
+ task = ForemanTasks::Task.find_by(external_id: job.provider_job_id)
+
+ render json: {
+ task: task
+ }, status: :ok
+ rescue Foreman::Exception
+ head :internal_server_error
+ end
+ end
+ end
+end
diff --git a/app/helpers/foreman_ansible/ansible_roles_helper.rb b/app/helpers/foreman_ansible/ansible_roles_helper.rb
index b211a568b..825371452 100644
--- a/app/helpers/foreman_ansible/ansible_roles_helper.rb
+++ b/app/helpers/foreman_ansible/ansible_roles_helper.rb
@@ -31,6 +31,12 @@ def ansible_proxy_import(hash)
ansible_proxy_links(hash))
end
+ def vcs_import
+ select_action_button("",
+ { :primary => true, :class => 'roles-import' },
+ link_to(_("Download from Git"), "#vcs_download"))
+ end
+
def import_time(role)
_('%s ago') % time_ago_in_words(role.updated_at)
end
diff --git a/app/jobs/clone_ansible_role.rb b/app/jobs/clone_ansible_role.rb
new file mode 100644
index 000000000..f370524f8
--- /dev/null
+++ b/app/jobs/clone_ansible_role.rb
@@ -0,0 +1,12 @@
+class CloneAnsibleRole < ::ApplicationJob
+ queue_as :default
+
+ def humanized_name
+ _('Download Ansible Role from Git')
+ end
+
+ def perform(repo_info, proxy)
+ vcs_cloner = ForemanAnsible::VcsCloner.new(proxy)
+ vcs_cloner.install_role repo_info
+ end
+end
diff --git a/app/jobs/delete_ansible_role.rb b/app/jobs/delete_ansible_role.rb
new file mode 100644
index 000000000..a98223ea7
--- /dev/null
+++ b/app/jobs/delete_ansible_role.rb
@@ -0,0 +1,12 @@
+class DeleteAnsibleRole < ::ApplicationJob
+ queue_as :default
+
+ def humanized_name
+ _('Delete Ansible Role from Smart Proxy')
+ end
+
+ def perform(role_name, proxy)
+ vcs_cloner = ForemanAnsible::VcsCloner.new(proxy)
+ vcs_cloner.delete_role role_name
+ end
+end
diff --git a/app/jobs/update_ansible_role.rb b/app/jobs/update_ansible_role.rb
new file mode 100644
index 000000000..c695bcc90
--- /dev/null
+++ b/app/jobs/update_ansible_role.rb
@@ -0,0 +1,12 @@
+class UpdateAnsibleRole < ::ApplicationJob
+ queue_as :default
+
+ def humanized_name
+ _('Update Ansible Role from Git')
+ end
+
+ def perform(repo_info, proxy)
+ vcs_cloner = ForemanAnsible::VcsCloner.new(proxy)
+ vcs_cloner.update_role repo_info
+ end
+end
diff --git a/app/lib/proxy_api/ansible.rb b/app/lib/proxy_api/ansible.rb
index f54eaec98..5a1fc825f 100644
--- a/app/lib/proxy_api/ansible.rb
+++ b/app/lib/proxy_api/ansible.rb
@@ -4,7 +4,7 @@ module ProxyAPI
# ProxyAPI for Ansible
class Ansible < ::ProxyAPI::Resource
def initialize(args)
- @url = args[:url] + '/ansible/'
+ @url = "#{args[:url]}/ansible/"
super args
end
@@ -53,5 +53,62 @@ def playbooks(playbooks_names = [])
rescue *PROXY_ERRORS => e
raise ProxyException.new(url, e, N_('Unable to get playbooks from Ansible'))
end
+
+ def repo_information(vcs_url)
+ parse(get("vcs_clone/repo_information?vcs_url=#{vcs_url}"))
+ rescue *PROXY_ERRORS, RestClient::Exception => e
+ raise e unless e.is_a? RestClient::RequestFailed
+ case e.http_code
+ when 400
+ raise Foreman::Exception.new N_('Error requesting repository metadata. Check Smart Proxy log.')
+ else
+ raise
+ end
+ end
+
+ def list_installed
+ parse(get('vcs_clone/roles'))
+ rescue *PROXY_ERRORS
+ raise Foreman::Exception.new N_('Error requesting installed roles. Check log.')
+ end
+
+ def install_role(repo_info)
+ parse(post(repo_info, 'vcs_clone/roles'))
+ rescue *PROXY_ERRORS, RestClient::Exception => e
+ raise e unless e.is_a? RestClient::RequestFailed
+ case e.http_code
+ when 409
+ raise Foreman::Exception.new N_('A repo with the name %s already exists.') % repo_info['repo_info']&.[]('name')
+ when 400
+ raise Foreman::Exception.new N_('Git Error. Check log.')
+ else
+ raise
+ end
+ end
+
+ def update_role(repo_info)
+ name = repo_info.delete('name')
+ parse(put(repo_info, "vcs_clone/roles/#{name}"))
+ rescue *PROXY_ERRORS, RestClient::Exception => e
+ raise e unless e.is_a? RestClient::RequestFailed
+ case e.http_code
+ when 400
+ raise Foreman::Exception.new N_('Error updating %s. Check Smartproxy log.') % name
+ else
+ raise
+ end
+ end
+
+ def delete_role(role_name)
+ parse(delete("vcs_clone/roles/#{role_name}"))
+ rescue *PROXY_ERRORS, RestClient::Exception => e
+ raise e unless e.is_a? RestClient::RequestFailed
+ case e.http_code
+ when 400
+ raise Foreman::Exception.new N_('Error deleting %s. Check Smartproxy log.') % role_name
+ else
+ raise
+ end
+ end
end
end
diff --git a/app/services/foreman_ansible/vcs_cloner.rb b/app/services/foreman_ansible/vcs_cloner.rb
new file mode 100644
index 000000000..abc994929
--- /dev/null
+++ b/app/services/foreman_ansible/vcs_cloner.rb
@@ -0,0 +1,15 @@
+module ForemanAnsible
+ class VcsCloner
+ include ::ForemanAnsible::ProxyAPI
+
+ def initialize(proxy = nil)
+ @ansible_proxy = proxy
+ end
+
+ delegate :install_role, to: :proxy_api
+
+ delegate :update_role, to: :proxy_api
+
+ delegate :delete_role, to: :proxy_api
+ end
+end
diff --git a/app/views/ansible_roles/index.html.erb b/app/views/ansible_roles/index.html.erb
index def0fd832..472679d51 100644
--- a/app/views/ansible_roles/index.html.erb
+++ b/app/views/ansible_roles/index.html.erb
@@ -1,7 +1,13 @@
+
+<%= webpacked_plugins_js_for :foreman_ansible %>
+
+<%= csrf_meta_tag %>
+
<% title _("Ansible Roles") %>
-<% title_actions ansible_proxy_import(hash_for_import_ansible_roles_path),
- documentation_button('#4.1ImportingRoles', :root_url => ansible_doc_url) %>
+<% title_actions ansible_proxy_import(hash_for_import_ansible_roles_path), vcs_import,
+ documentation_button('#4.1ImportingRoles', :root_url => ansible_doc_url)
+%>
+<%= react_component('VcsCloneModalContent')%>
+
<%= will_paginate_with_info @ansible_roles %>
diff --git a/app/views/ansible_roles/welcome.html.erb b/app/views/ansible_roles/welcome.html.erb
index fd2011862..6b1b8b5ed 100644
--- a/app/views/ansible_roles/welcome.html.erb
+++ b/app/views/ansible_roles/welcome.html.erb
@@ -1,3 +1,6 @@
+<%= webpacked_plugins_js_for :foreman_ansible %>
+<%= webpacked_plugins_css_for :foreman_ansible %>
+
<% content_for(:title, _("Ansible Roles")) %>
@@ -10,5 +13,8 @@
<%= link_to(_('Learn more about this in the documentation.'), documentation_url('#4.1ImportingRoles', :root_url => ansible_doc_url), target: '_blank') %>
<%= ansible_proxy_import(hash_for_import_ansible_roles_path) %>
+ <%= vcs_import %>
+
+<%= react_component('VcsCloneModalContent', {:title => "Get Ansible-Roles from VCS"})%>
diff --git a/config/routes.rb b/config/routes.rb
index 1a0431e4f..15ff4a02a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -32,6 +32,15 @@
post :multiple_play_roles
end
end
+ resources :smart_proxies, :only => [] do
+ member do
+ get 'repository_metadata', to: 'vcs_clone#repository_metadata'
+ get 'roles', to: 'vcs_clone#installed_roles'
+ post 'roles', to: 'vcs_clone#install_role'
+ put 'roles/:role_name', to: 'vcs_clone#update_role', constraints: { role_name: %r{[^\/]+} }
+ delete 'roles/:role_name', to: 'vcs_clone#delete_role', constraints: { role_name: %r{[^\/]+} }
+ end
+ end
end
end
end
diff --git a/lib/foreman_ansible/register.rb b/lib/foreman_ansible/register.rb
index 97e973527..b36a67a94 100644
--- a/lib/foreman_ansible/register.rb
+++ b/lib/foreman_ansible/register.rb
@@ -160,6 +160,8 @@
{ :'api/v2/ansible_inventories' => [:schedule] }
permission :import_ansible_playbooks,
{ :'api/v2/ansible_playbooks' => [:sync, :fetch] }
+ permission :clone_from_vcs,
+ { :'api/v2/vcs_clone' => [:repository_metadata, :installed_roles, :install_role, :update_role, :delete_role] }
end
role 'Ansible Roles Manager',
@@ -170,7 +172,7 @@
:import_ansible_roles, :view_ansible_variables, :view_lookup_values,
:create_lookup_values, :edit_lookup_values, :destroy_lookup_values,
:create_ansible_variables, :import_ansible_variables,
- :edit_ansible_variables, :destroy_ansible_variables, :import_ansible_playbooks]
+ :edit_ansible_variables, :destroy_ansible_variables, :import_ansible_playbooks, :clone_from_vcs]
role 'Ansible Tower Inventory Reader',
[:view_hosts, :view_hostgroups, :view_facts, :generate_report_templates, :generate_ansible_inventory,
diff --git a/test/functional/api/v2/vcs_clone_controller_test.rb b/test/functional/api/v2/vcs_clone_controller_test.rb
new file mode 100644
index 000000000..aae844cc1
--- /dev/null
+++ b/test/functional/api/v2/vcs_clone_controller_test.rb
@@ -0,0 +1,105 @@
+require 'test_plugin_helper'
+
+module Api
+ module V2
+ class VcsCloneControllerTest < ActionController::TestCase
+ describe 'input' do
+ test 'handles missing proxy capability' do
+ proxy = FactoryBot.create(:smart_proxy, :with_ansible)
+
+ get :repository_metadata,
+ params: { id: proxy.id, vcs_url: 'https://github.com/theforeman/foreman_ansible.git' },
+ session: set_session_user
+
+ response = JSON.parse(@response.body)
+ assert_response :bad_request
+ assert_equal({ 'error' =>
+ { 'message' => 'Smart Proxy is missing foreman_ansible installation or Git cloning capability' } }, response)
+ end
+ end
+ describe '#repo_information' do
+ test 'requests repo information' do
+ proxy = FactoryBot.create(:smart_proxy, :with_ansible)
+ SmartProxy.any_instance.stubs(:has_capability?).returns(true)
+ ProxyAPI::Ansible.any_instance.expects(:repo_information).returns({
+ 'head' => {},
+ 'branches' => {},
+ 'tags' => {},
+ 'vcs_url' => 'https://github.com/theforeman/foreman_ansible.git'
+ })
+
+ get :repository_metadata,
+ params: { id: proxy.id, vcs_url: 'https://github.com/theforeman/foreman_ansible.git' },
+ session: set_session_user
+
+ response = JSON.parse(@response.body)
+ assert_response :success
+ assert_equal({ 'head' => {},
+ 'branches' => {},
+ 'tags' => {},
+ 'vcs_url' => 'https://github.com/theforeman/foreman_ansible.git' }, response)
+ end
+ end
+ describe '#installed_roles' do
+ test 'requests installed roles' do
+ proxy = FactoryBot.create(:smart_proxy, :with_ansible)
+ SmartProxy.any_instance.stubs(:has_capability?).returns(true)
+ ProxyAPI::Ansible.any_instance.expects(:list_installed).returns(%w[role1 role2])
+
+ get :installed_roles,
+ params: { id: proxy.id },
+ session: set_session_user
+
+ response = JSON.parse(@response.body)
+ assert_response :success
+ assert_equal(%w[role1 role2], response)
+ end
+ end
+ describe '#install_role' do
+ test 'installes a role' do
+ proxy = FactoryBot.create(:smart_proxy, :with_ansible)
+ SmartProxy.any_instance.stubs(:has_capability?).returns(true)
+
+ post :install_role,
+ params: { id: proxy.id, repo_info: {
+ 'vcs_url' => 'https://github.com/theforeman/foreman_ansible.git',
+ 'role_name' => 'best.role.ever',
+ 'ref' => 'master'
+ } },
+ session: set_session_user
+ assert_response :success
+ end
+ test 'handles faulty parameters' do
+ proxy = FactoryBot.create(:smart_proxy, :with_ansible)
+ SmartProxy.any_instance.stubs(:has_capability?).returns(true)
+
+ post :install_role,
+ params: { id: proxy.id, 'repo_info': {
+ 'vcs_urll' => 'https://github.com/theforeman/foreman_ansible.git',
+ 'role_name' => 'best.role.ever',
+ 'ref' => 'master'
+ } },
+ session: set_session_user
+ response = JSON.parse(@response.body)
+ assert_response :bad_request
+ assert_equal({ 'error' => 'param is missing or the value is empty: vcs_url' }, response)
+ end
+ end
+ describe '#update_role' do
+ # With the difference of the http-method being PUT, this is
+ # identical to #install_role
+ end
+ describe '#delete_role' do
+ test 'deletes a role' do
+ proxy = FactoryBot.create(:smart_proxy, :with_ansible)
+ SmartProxy.any_instance.stubs(:has_capability?).returns(true)
+
+ delete :delete_role,
+ params: { id: proxy.id, role_name: 'best.role.ever' },
+ session: set_session_user
+ assert_response :success
+ end
+ end
+ end
+ end
+end
diff --git a/webpack/components/VcsCloneModalContent/VcsCloneModalContent.js b/webpack/components/VcsCloneModalContent/VcsCloneModalContent.js
new file mode 100644
index 000000000..5e416285a
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/VcsCloneModalContent.js
@@ -0,0 +1,245 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Modal,
+ ModalVariant,
+ Button,
+ Grid,
+ GridItem,
+ Alert,
+ Popover,
+} from '@patternfly/react-core';
+import { HelpIcon } from '@patternfly/react-icons';
+import { translate as __, sprintf } from 'foremanReact/common/I18n';
+import { GitLinkInputComponent } from './components/GitLinkInputComponent';
+import { BranchTagSelectionMenu } from './components/BranchTagSelectionMenu';
+import { SmartProxySelector } from './components/SmartProxySelector';
+import { ModalConfirmButton } from './components/ModalConfirmButton';
+import { fetchSmartProxies } from './VcsCloneModalContentHelpers';
+import { RoleNameInput } from './components/RoleNameInput';
+import { UpdateExistingSwitch } from './components/UpdateExistingSwitch';
+
+export const VcsCloneModalContent = () => {
+ const [gitRef, setGitRef] = useState('main');
+ const [installedRoles, setInstalledRoles] = useState({});
+ const [repoName, setRepoName] = useState('');
+ const [isModalButtonLoading, setIsModalButtonLoading] = useState(false);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [repoInfo, setRepoInfo] = useState({
+ branches: {},
+ tags: {},
+ vcs_url: null,
+ });
+ const [branchTagsEnabled, setBranchTagsEnabled] = useState(false);
+ const [alertText, setAlertText] = useState('');
+ const [originalRepoName, setOriginalRepoName] = useState('');
+ const [smartProxies, setSmartProxies] = useState({});
+ const [smartProxySelection, setSmartProxySelection] = useState([]);
+ const [updateExisting, setUpdateExisting] = useState(false);
+ const [isErrorState, setIsErrorState] = useState(false);
+ const [isModalButtonActive, setIsModalButtonActive] = useState(false);
+
+ // EFFECT-HANDLERS
+
+ /**
+ * Watch URL-anchor and open/close modal.
+ */
+ useEffect(() => {
+ // Handle direct link ...#vcs_import
+ if (window.location.hash === '#vcs_download') {
+ handleModalToggle();
+ }
+ // Set event listener for anchor change
+ onhashchange = () => {
+ if (window.location.hash === '#vcs_download') {
+ handleModalToggle();
+ }
+ };
+ }, [handleModalToggle]);
+
+ /**
+ * Fetch SmartProxies when the modal is opened.
+ */
+ useEffect(() => {
+ async function request() {
+ if (isModalOpen) {
+ const smartProxiesRequest = await fetchSmartProxies();
+ if (
+ Object.keys(smartProxiesRequest.proxies).length === 0 ||
+ !smartProxiesRequest.ok
+ ) {
+ setAlertText(
+ __('No smartproxies with support for cloning from Git found')
+ );
+ setIsErrorState(true);
+ } else {
+ setSmartProxies(smartProxiesRequest.proxies);
+ }
+ }
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ const ignored = request();
+ }, [isModalOpen]);
+
+ useEffect(() => {
+ setIsModalButtonActive(
+ !isErrorState &&
+ repoName !== '' &&
+ smartProxySelection.length !== 0 &&
+ gitRef !== ''
+ );
+ }, [isErrorState, repoName, gitRef, smartProxySelection.length]);
+
+ /**
+ * Method to check whether a role is already present on the selected SmartProxy.
+ * Called when one of the deps is updated
+ * Checks whether 'Repo name' is a role that is already present on the selected SmartProxy.
+ * -> Shows the alert if a collision is present.
+ */
+ useEffect(() => {
+ setIsErrorState(false);
+ if (smartProxySelection.length !== 0) {
+ // eslint-disable-next-line no-unused-vars
+ for (const [_, proxyId] of Object.entries(smartProxies)) {
+ const roles = installedRoles[proxyId];
+
+ if (roles !== undefined) {
+ if (roles.has(repoName) && !updateExisting) {
+ setAlertText(
+ sprintf(
+ __(
+ 'A repository with the name %(rName)s is already present on %(pName)s'
+ ),
+ {
+ rName: repoName,
+ pName: Object.keys(smartProxies).filter(
+ key => smartProxies[key] === proxyId
+ ),
+ }
+ )
+ );
+ setIsErrorState(true);
+ }
+ }
+ }
+ }
+ }, [
+ smartProxySelection,
+ repoName,
+ smartProxies,
+ installedRoles,
+ updateExisting,
+ ]);
+
+ // CALLBACKS
+
+ /**
+ * To be called when the modal is to be opened or closed.
+ * Called by: 'Cancel'- and 'x'-button
+ * Resets all the states and toggles modal visibility.
+ */
+ const handleModalToggle = useCallback(() => {
+ if (isModalOpen) {
+ setGitRef('main');
+ setInstalledRoles({});
+ setRepoName('');
+ setIsModalButtonLoading(false);
+ setIsModalButtonActive(false);
+ setIsModalOpen(false);
+ setRepoInfo({
+ branches: {},
+ tags: {},
+ vcs_url: null,
+ });
+ setIsErrorState(false);
+ setAlertText('');
+ setOriginalRepoName('');
+ setSmartProxies({});
+ setSmartProxySelection([]);
+ setUpdateExisting(false);
+ setBranchTagsEnabled(false);
+ window.location.hash = '';
+ }
+ setIsModalOpen(!isModalOpen);
+ }, [isModalOpen]);
+
+ return (
+
+ ,
+
+ {__('Cancel')}
+ ,
+ ]}
+ help={
+ TODO: Link to Foreman doc }>
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js b/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js
new file mode 100644
index 000000000..adf2120d0
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/VcsCloneModalContentHelpers.js
@@ -0,0 +1,115 @@
+import React from 'react';
+import { translate as __, sprintf } from 'foremanReact/common/I18n';
+import { showToast } from '../../toastHelper';
+import { foremanUrl } from '../AnsibleRolesAndVariables/AnsibleRolesAndVariablesActions';
+
+export const fetchSmartProxies = async () => {
+ const response = await fetch('/api/smart_proxies');
+ const responseJson = await response.json();
+ const tempSmartProxies = {};
+ responseJson.results.forEach(proxy =>
+ proxy.features.forEach(feature => {
+ if (feature.name === 'Ansible') {
+ if (feature.capabilities.includes('vcs_clone')) {
+ tempSmartProxies[proxy.name] = proxy.id;
+ }
+ }
+ })
+ );
+ return {
+ ok: response.ok,
+ proxies: tempSmartProxies,
+ };
+};
+
+export const getRepoInfo = async (smartProxyId, repoUrl) => {
+ const response = await fetch(
+ `/api/v2/smart_proxies/${smartProxyId}/repository_metadata?${new URLSearchParams(
+ {
+ vcs_url: repoUrl,
+ }
+ )}`,
+ {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ }
+ );
+
+ return {
+ ok: response.ok,
+ result: await response.json(),
+ };
+};
+
+export const installRole = async (
+ updateExisting,
+ smartProxyId,
+ repoUrl,
+ repoName,
+ repoRef
+) => {
+ const response = await fetch(
+ updateExisting
+ ? `/api/v2/smart_proxies/${smartProxyId}/roles/${repoName}`
+ : `/api/v2/smart_proxies/${smartProxyId}/roles`,
+ {
+ method: updateExisting ? 'PUT' : 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ repo_info: {
+ vcs_url: repoUrl,
+ role_name: repoName,
+ ref: repoRef,
+ },
+ }),
+ }
+ );
+
+ return {
+ ok: response.ok,
+ status: response.status,
+ result: await response.json(),
+ };
+};
+
+export const showSuccessToast = (taskId, repoName) => {
+ showToast({
+ type: 'success',
+ message: (
+
+ {sprintf(__('Cloning of %(rName)s from Git started:'), {
+ rName: repoName,
+ })}
+
+
+ {sprintf(__('View task %(tId)s'), { tId: taskId })}
+
+
+ ),
+ });
+};
+
+export const showErrorToast = (statusCode, repoName) => {
+ showToast({
+ type: 'danger',
+ message: (
+
+ {sprintf(__('Could not start cloning %(rName)s from Git'), {
+ rName: repoName,
+ })}
+
+ {sprintf(__('Status-Code: %(status)s'), { status: statusCode })}
+
+ ),
+ });
+};
diff --git a/webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js b/webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js
new file mode 100644
index 000000000..3d1178410
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/BranchTagSelectionMenu.js
@@ -0,0 +1,123 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Tabs,
+ Tab,
+ TabTitleText,
+ Form,
+ FormGroup,
+ TextInput,
+ Popover,
+} from '@patternfly/react-core';
+import { HelpIcon } from '@patternfly/react-icons';
+import PropTypes from 'prop-types';
+import { translate as __ } from 'foremanReact/common/I18n';
+import { MultiSelectorMenu } from './MultiSelectorMenu';
+
+export const BranchTagSelectionMenu = props => {
+ const [activeTabKey, setActiveTabKey] = useState(2);
+
+ useEffect(() => {
+ if (!props.branchTagsEnabled) {
+ setActiveTabKey(2);
+ }
+ }, [props.branchTagsEnabled]);
+
+ return (
+ setActiveTabKey(tabIndex)}
+ isBox
+ >
+ {__('Branches')}}
+ isDisabled={!props.branchTagsEnabled}
+ >
+
+
+ {__('Tags')}}
+ isDisabled={!props.branchTagsEnabled}
+ >
+
+
+ {__('Manual input')}}
+ >
+
+
+
+ );
+};
+
+BranchTagSelectionMenu.propTypes = {
+ repoInfo: PropTypes.object,
+ gitRef: PropTypes.string,
+ setGitRef: PropTypes.func,
+ branchTagsEnabled: PropTypes.bool,
+};
+
+BranchTagSelectionMenu.defaultProps = {
+ repoInfo: {
+ branches: {},
+ tags: {},
+ vcs_url: null,
+ },
+ gitRef: 'main',
+ setGitRef: () => {},
+ branchTagsEnabled: false,
+};
diff --git a/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js b/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js
new file mode 100644
index 000000000..784a6c09e
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/GitLinkInputComponent.js
@@ -0,0 +1,175 @@
+import React, { useCallback, useEffect, useState, useRef } from 'react';
+import {
+ Button,
+ InputGroup,
+ TextInput,
+ Form,
+ FormGroup,
+ Popover,
+} from '@patternfly/react-core';
+import { HelpIcon } from '@patternfly/react-icons';
+import PropTypes from 'prop-types';
+import { translate as __ } from 'foremanReact/common/I18n';
+import { getRepoInfo } from '../VcsCloneModalContentHelpers';
+
+export const GitLinkInputComponent = props => {
+ const [isButtonActive, setButtonActive] = useState(false);
+ const [textInput, setTextInput] = useState('');
+ const [validated, setValidated] = useState('default');
+ const [invalidText, setInvalidText] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const isFirstRender = useRef(true);
+
+ const primaryLoadingProps = {};
+ primaryLoadingProps.spinnerAriaValueText = 'Loading';
+ primaryLoadingProps.spinnerAriaLabelledBy = 'primary-loading-button';
+ primaryLoadingProps.isLoading = isLoading;
+
+ /**
+ * To be called when Repo-information is to be requested.
+ * Called by: 'Examine'-Button
+ * Sends a request to the server, which responds with information about the provided repository.
+ */
+ const handleExamineButton = useCallback(
+ async repoUrl => {
+ const roleNameExpr = new RegExp(`^.*/(.*/.*).git$`);
+ const matched = roleNameExpr.exec(repoUrl);
+ try {
+ props.setRepoName(matched[1].replace('/', '_').toLowerCase());
+ props.setOriginalRepoName(matched[1]);
+ } catch (e) {
+ props.setRepoName(__('COULD NOT EXTRACT NAME'));
+ }
+
+ setIsLoading(true);
+
+ const repoInfoRequest = await getRepoInfo(
+ props.smartProxies[props.smartProxySelection],
+ repoUrl
+ );
+
+ if (!repoInfoRequest.ok) {
+ setIsLoading(false);
+ props.setAlertText(__('Could not request metadata. Use manual input.'));
+ props.setIsErrorState(true);
+ props.setBranchTagsEnabled(false);
+ } else {
+ props.setRepoInfo(repoInfoRequest.result);
+ setIsLoading(false);
+ props.setBranchTagsEnabled(true);
+ }
+ },
+ [props]
+ );
+ const handleTextInput = (gitLink, event) => {
+ setTextInput(gitLink);
+ props.setRepoInfo({
+ branches: {},
+ tags: {},
+ vcs_url: gitLink,
+ });
+ props.setBranchTagsEnabled(false);
+ };
+
+ useEffect(() => {
+ const validLink = /^.*\.git$/.test(textInput);
+ const httpUrl = /^https?:\/\/.*$/.test(textInput);
+
+ if (!isFirstRender.current) {
+ if (validLink && httpUrl) {
+ if (props.smartProxySelection.length !== 0) {
+ setButtonActive(true);
+ }
+ setValidated('success');
+ } else {
+ setValidated('error');
+ if (!validLink) {
+ setInvalidText(__('Not a valid Git URL'));
+ } else if (!httpUrl) {
+ setInvalidText(__('Only URLs using http/https are supported'));
+ }
+ setButtonActive(false);
+ }
+ } else {
+ isFirstRender.current = false;
+ }
+ }, [textInput, props.smartProxySelection]);
+
+ return (
+
+
+
+ );
+};
+
+GitLinkInputComponent.propTypes = {
+ setRepoName: PropTypes.func,
+ setOriginalRepoName: PropTypes.func,
+ smartProxies: PropTypes.object,
+ smartProxySelection: PropTypes.array,
+ setAlertText: PropTypes.func,
+ setIsErrorState: PropTypes.func,
+ setRepoInfo: PropTypes.func,
+ repoInfo: PropTypes.object,
+ setBranchTagsEnabled: PropTypes.func,
+};
+
+GitLinkInputComponent.defaultProps = {
+ setRepoName: () => {},
+ setOriginalRepoName: () => {},
+ smartProxies: {},
+ smartProxySelection: [],
+ setAlertText: () => {},
+ setIsErrorState: () => {},
+ setRepoInfo: () => {},
+ repoInfo: {},
+ setBranchTagsEnabled: () => {},
+};
diff --git a/webpack/components/VcsCloneModalContent/components/ModalConfirmButton.js b/webpack/components/VcsCloneModalContent/components/ModalConfirmButton.js
new file mode 100644
index 000000000..01b58ceb8
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/ModalConfirmButton.js
@@ -0,0 +1,77 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { translate as __ } from 'foremanReact/common/I18n';
+import { Button } from '@patternfly/react-core';
+import {
+ installRole,
+ showErrorToast,
+ showSuccessToast,
+} from '../VcsCloneModalContentHelpers';
+
+export const ModalConfirmButton = props => {
+ /**
+ * To be called when all the inputs are verified and the repo should be cloned.
+ * Called by: 'Confirm'-Button
+ * Sends a request to the server, which starts a new VcsClone-Task .
+ */
+ const handleConfirmButton = useCallback(async () => {
+ props.setIsModalButtonLoading(true);
+
+ const installRequest = await installRole(
+ props.updateExisting,
+ props.smartProxyId,
+ props.repoInfo.vcs_url,
+ props.repoName,
+ props.gitRef
+ );
+
+ if (!installRequest.ok) {
+ showErrorToast(installRequest.status);
+ } else {
+ showSuccessToast(installRequest.result.task.id, props.originalRepoName);
+ }
+ props.handleModalToggle();
+ }, [props]);
+
+ return (
+
+ {__('Confirm')}
+
+ );
+};
+
+ModalConfirmButton.propTypes = {
+ setIsModalButtonLoading: PropTypes.func,
+ updateExisting: PropTypes.bool,
+ smartProxyId: PropTypes.number,
+ repoInfo: PropTypes.object,
+ repoName: PropTypes.string,
+ gitRef: PropTypes.string,
+ originalRepoName: PropTypes.string,
+ isModalButtonLoading: PropTypes.bool,
+ isModalButtonActive: PropTypes.bool,
+ handleModalToggle: PropTypes.func,
+};
+
+ModalConfirmButton.defaultProps = {
+ setIsModalButtonLoading: () => {},
+ updateExisting: false,
+ smartProxyId: 0,
+ repoInfo: {
+ branches: {},
+ tags: {},
+ vcs_url: null,
+ },
+ repoName: '',
+ gitRef: 'main',
+ originalRepoName: '',
+ isModalButtonLoading: false,
+ isModalButtonActive: false,
+ handleModalToggle: () => {},
+};
diff --git a/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js b/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js
new file mode 100644
index 000000000..d1a004a5a
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/MultiSelectorMenu.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Menu, MenuContent, MenuList, MenuItem } from '@patternfly/react-core';
+
+export const MultiSelectorMenu = props => {
+ const createSelectItems = () => {
+ const items = [];
+ const inputItems = props.repoInfo?.[props.displayData];
+ if (inputItems !== undefined) {
+ // eslint-disable-next-line no-unused-vars
+ for (const item of Object.keys(inputItems)) {
+ items.push(
+
+ {item}
+
+ );
+ }
+ }
+ return items;
+ };
+
+ return (
+ props.setGitRef(item)}
+ selected={props.gitRef}
+ isScrollable
+ >
+
+
+ {createSelectItems()}
+
+
+
+ );
+};
+
+MultiSelectorMenu.propTypes = {
+ repoInfo: PropTypes.object,
+ displayData: PropTypes.string,
+ setGitRef: PropTypes.func,
+ gitRef: PropTypes.string,
+};
+
+MultiSelectorMenu.defaultProps = {
+ repoInfo: {},
+ displayData: 'branches',
+ setGitRef: () => {},
+ gitRef: '',
+};
diff --git a/webpack/components/VcsCloneModalContent/components/RoleNameInput.js b/webpack/components/VcsCloneModalContent/components/RoleNameInput.js
new file mode 100644
index 000000000..ca45454a4
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/RoleNameInput.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import { Form, FormGroup, TextInput, Popover } from '@patternfly/react-core';
+import { HelpIcon } from '@patternfly/react-icons';
+import PropTypes from 'prop-types';
+import { translate as __ } from 'foremanReact/common/I18n';
+
+export const RoleNameInput = props => (
+
+);
+
+RoleNameInput.propTypes = {
+ repoName: PropTypes.string,
+ setRepoName: PropTypes.func,
+};
+
+RoleNameInput.defaultProps = {
+ repoName: '',
+ setRepoName: () => {},
+};
diff --git a/webpack/components/VcsCloneModalContent/components/SmartProxySelector.js b/webpack/components/VcsCloneModalContent/components/SmartProxySelector.js
new file mode 100644
index 000000000..08e13271f
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/SmartProxySelector.js
@@ -0,0 +1,159 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { translate as __ } from 'foremanReact/common/I18n';
+import {
+ Form,
+ FormGroup,
+ Select,
+ SelectVariant,
+ SelectOption,
+ Popover,
+} from '@patternfly/react-core';
+import { HelpIcon } from '@patternfly/react-icons';
+
+export const SmartProxySelector = props => {
+ const [isSmartProxyDropdownOpen, setIsSmartProxyDropdownOpen] = useState(
+ false
+ );
+ const [isValidSmartProxy, setIsValidSmartProxy] = useState('default');
+
+ /**
+ * To be called when a SmartProxy should be selected.
+ * Called by: 'SmartProxies'-field
+ * Updates the smartProxySelection-state with the new selection.
+ * Note: Currently only one SmartProxy may be selected. Still, an array
+ * is used to allow the selection of multiple SmartProxies in the future.
+ */
+ const handleSmartProxySelect = useCallback(
+ async (_event, value) => {
+ /**
+ * Method to query which roles are installed on a given SmartProxy.
+ * Called by: smartProxySelection-Effect
+ * Sends a request to the server, which responds with an array of roles that are installed on the provided proxy.
+ * @param proxyId SmartProxy from which the roles should be queried.
+ */
+ const getInstalledRolesAtProxy = async proxyId => {
+ const response = await fetch(`/api/v2/smart_proxies/${proxyId}/roles`, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (response.ok) {
+ const responseJson = await response.json();
+ const installedRolesMap = new Map();
+ responseJson.forEach(key => installedRolesMap.set(key, null));
+ const temp = props.installedRoles;
+ temp[proxyId] = installedRolesMap;
+ props.setInstalledRoles(temp);
+ }
+ };
+
+ let updatedSmartProxySelection;
+ if (props.smartProxySelection.includes(value)) {
+ updatedSmartProxySelection = [];
+ } else {
+ updatedSmartProxySelection = [value];
+ await getInstalledRolesAtProxy(props.smartProxies[value]);
+ }
+
+ props.setSmartProxySelection(updatedSmartProxySelection);
+ },
+ [props]
+ );
+
+ useEffect(() => {
+ if (props.smartProxySelection.length !== 0) {
+ setIsValidSmartProxy('success');
+ } else {
+ setIsValidSmartProxy('error');
+ }
+ }, [props.smartProxySelection]);
+
+ /**
+ * Dynamically creates the child-elements of the 'SmartProxies'-Field.
+ * Called by: Render of 'SmartProxies' FormGroup.
+ * @returns {*[]} Array of values.
+ */
+ function createSmartProxySelectItems() {
+ const smartProxyArray = [];
+ let proxy0;
+ // eslint-disable-next-line no-unused-vars
+ for (const proxy of Object.keys(props.smartProxies)) {
+ if (!proxy0) {
+ proxy0 = proxy;
+ }
+ smartProxyArray.push( );
+ }
+ if (
+ smartProxyArray.length === 1 &&
+ props.smartProxySelection.length === 0
+ ) {
+ props.setSmartProxySelection([proxy0]);
+ }
+ return smartProxyArray;
+ }
+ return (
+
+ );
+};
+
+SmartProxySelector.propTypes = {
+ smartProxies: PropTypes.object,
+ smartProxySelection: PropTypes.array,
+ setSmartProxySelection: PropTypes.func,
+ installedRoles: PropTypes.object,
+ setInstalledRoles: PropTypes.func,
+};
+
+SmartProxySelector.defaultProps = {
+ smartProxies: {},
+ smartProxySelection: [],
+ setSmartProxySelection: () => {},
+ installedRoles: {},
+ setInstalledRoles: () => {},
+};
diff --git a/webpack/components/VcsCloneModalContent/components/UpdateExistingSwitch.js b/webpack/components/VcsCloneModalContent/components/UpdateExistingSwitch.js
new file mode 100644
index 000000000..3fc63f5ef
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/UpdateExistingSwitch.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import { Switch, Popover, Form, FormGroup } from '@patternfly/react-core';
+import { HelpIcon } from '@patternfly/react-icons';
+import PropTypes from 'prop-types';
+import { translate as __ } from 'foremanReact/common/I18n';
+
+import './UpdateExistingSwitch.scss';
+
+export const UpdateExistingSwitch = props => (
+
+);
+
+UpdateExistingSwitch.propTypes = {
+ updateExisting: PropTypes.bool,
+ setUpdateExisting: PropTypes.func,
+};
+
+UpdateExistingSwitch.defaultProps = {
+ updateExisting: false,
+ setUpdateExisting: () => {},
+};
diff --git a/webpack/components/VcsCloneModalContent/components/UpdateExistingSwitch.scss b/webpack/components/VcsCloneModalContent/components/UpdateExistingSwitch.scss
new file mode 100644
index 000000000..230eb8074
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/UpdateExistingSwitch.scss
@@ -0,0 +1,5 @@
+
+
+.pf-c-switch__toggle{
+ --pf-c-switch__input--focus__toggle--OutlineWidth: none
+}
diff --git a/webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js b/webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js
new file mode 100644
index 000000000..a27645e62
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/__test__/BranchTagSelectionMenu.test.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { BranchTagSelectionMenu } from '../BranchTagSelectionMenu';
+
+describe('BranchTagSelectionMenu', () => {
+ it('tests the default component', () => {
+ const { container } = render( );
+
+ expect(container).toBeInTheDocument();
+ });
+
+ it('test whether branches/tags tabs are enabled', () => {
+ const { container } = render( );
+ const branchTab = screen
+ .getByTestId('BranchTagSelectionMenuBranchTab')
+ .closest('li');
+ const tagTab = screen
+ .getByTestId('BranchTagSelectionMenuTagTab')
+ .closest('li');
+ const manualTab = screen
+ .getByTestId('BranchTagSelectionMenuManualTab')
+ .closest('li');
+
+ expect(container).toBeInTheDocument();
+
+ expect(branchTab).not.toHaveClass('pf-m-disabled');
+ expect(tagTab).not.toHaveClass('pf-m-disabled');
+ expect(manualTab).not.toHaveClass('pf-m-disabled');
+ });
+
+ it('test whether branches/tags tabs are disabled', () => {
+ const { container } = render( );
+
+ const branchTab = screen
+ .getByTestId('BranchTagSelectionMenuBranchTab')
+ .closest('li');
+ const tagTab = screen
+ .getByTestId('BranchTagSelectionMenuTagTab')
+ .closest('li');
+ const manualTab = screen
+ .getByTestId('BranchTagSelectionMenuManualTab')
+ .closest('li');
+
+ expect(container).toBeInTheDocument();
+
+ expect(branchTab).toHaveClass('pf-m-disabled');
+ expect(tagTab).toHaveClass('pf-m-disabled');
+ expect(manualTab).not.toHaveClass('pf-m-disabled');
+ });
+
+ it('test whether tab selection works', () => {
+ const { container } = render( );
+
+ // [bts_button ^ tab_list_item] ^ tab_list
+ const tabs = screen
+ .getByTestId('BranchTagSelectionMenuBranchTab')
+ .closest('li')
+ .closest('ul').children;
+
+ expect(container).toBeInTheDocument();
+
+ for (let i = 0; i < tabs.length; i++) {
+ const currentTab = tabs[i];
+ const nestedButton = currentTab.querySelector('button');
+
+ fireEvent.click(nestedButton);
+
+ expect(currentTab).toHaveClass('pf-m-current');
+ }
+ });
+});
diff --git a/webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js b/webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js
new file mode 100644
index 000000000..f55444afe
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/__test__/GitLinkInputComponent.test.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { GitLinkInputComponent } from '../GitLinkInputComponent';
+
+describe('GitLinkInputComponent', () => {
+ it('tests the default component', () => {
+ const { container } = render( );
+
+ const textInput = screen.getByTestId('GitLinkInputComponentTextInput');
+ const examineButton = screen.getByText('Load metadata');
+
+ expect(container).toBeInTheDocument();
+ expect(textInput).toHaveValue('');
+
+ expect(examineButton).toBeDisabled();
+ });
+
+ it('tests an accepted input value', () => {
+ const { container } = render(
+
+ );
+ const textInput = screen.getByTestId('GitLinkInputComponentTextInput');
+ const examineButton = screen.getByText('Load metadata');
+
+ fireEvent.change(textInput, {
+ target: { value: 'https://github.com/theforeman/foreman_ansible.git' },
+ });
+
+ expect(container).toBeInTheDocument();
+ expect(textInput).toHaveValue(
+ 'https://github.com/theforeman/foreman_ansible.git'
+ );
+ expect(textInput).toHaveClass('pf-m-success');
+
+ expect(examineButton).toBeEnabled();
+ });
+
+ it('tests an invalid input value', () => {
+ const { container } = render(
+
+ );
+ const textInput = screen.getByTestId('GitLinkInputComponentTextInput');
+ const examineButton = screen.getByText('Load metadata');
+
+ fireEvent.change(textInput, {
+ target: { value: 'https://github.com/theforeman/foreman_ansible' },
+ });
+
+ expect(container).toBeInTheDocument();
+ expect(textInput).toHaveValue(
+ 'https://github.com/theforeman/foreman_ansible'
+ );
+ expect(textInput).toHaveAttribute('aria-invalid', 'true');
+
+ expect(examineButton).toBeDisabled();
+ });
+});
diff --git a/webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js b/webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js
new file mode 100644
index 000000000..b4f42c93d
--- /dev/null
+++ b/webpack/components/VcsCloneModalContent/components/__test__/MultiSelectorMenu.test.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { MultiSelectorMenu } from '../MultiSelectorMenu';
+
+const repoInfo = {
+ head: {
+ ref: 'HEAD',
+ sha: '4d4f55f7988728c46f47022e2e354405ba41ff83',
+ },
+ branches: {
+ '98-verify-checksums': {
+ ref: 'refs',
+ sha: 'de1378b11a514fd21e4b1ca6528d97937bfbe911',
+ },
+ master: {
+ ref: 'refs',
+ sha: '4d4f55f7988728c46f47022e2e354405ba41ff83',
+ },
+ },
+ tags: {
+ '1.0.0': {
+ ref: 'refs',
+ sha: 'e4c795e6d037ebc590224243d8cec54423f015cd',
+ },
+ '1.0.0^{}': {
+ ref: 'refs',
+ sha: '9408a6ce1f718c3f0c459887b7bc5bc9c2fc3829',
+ },
+ '1.0.1': {
+ ref: 'refs',
+ sha: '03ce696243a45742da4b72259ad1faf7a6ce8a80',
+ },
+ },
+ vcs_url: 'https://github.com/DavidWittman/ansible-redis.git',
+};
+
+describe('MultiSelectorMenu', () => {
+ it('tests the default component', () => {
+ const { container } = render( );
+
+ expect(container).toBeInTheDocument();
+ });
+
+ it('tests the adding of items', () => {
+ const { container } = render( );
+
+ const menuContent = screen.getByTestId('MultiSelectorMenuMenuContent');
+
+ expect(container).toBeInTheDocument();
+ expect(menuContent.children).toHaveLength(2);
+ });
+
+ it('tests the selection of items', () => {
+ // eslint-disable-next-line no-unused-vars
+ for (const toTest of ['branches', 'tags']) {
+ const setState = jest.fn();
+
+ const { container } = render(
+
+ );
+
+ const menuContent = screen.getByTestId('MultiSelectorMenuMenuContent');
+ const menuItems = menuContent.children;
+
+ const items = Object.keys(repoInfo[toTest]);
+
+ expect(container).toBeInTheDocument();
+ for (let i = 0; i < menuItems.length; i++) {
+ const item = menuItems[i];
+ const button = item.querySelector('button');
+
+ fireEvent.click(button);
+ expect(setState).toBeCalledWith(items[i]);
+ }
+ expect(setState).toBeCalledTimes(items.length);
+ cleanup();
+ }
+ });
+});
diff --git a/webpack/index.js b/webpack/index.js
index 760aeda43..1f7b3d932 100644
--- a/webpack/index.js
+++ b/webpack/index.js
@@ -4,6 +4,7 @@ import ReportJsonViewer from './components/ReportJsonViewer';
import AnsibleRolesSwitcher from './components/AnsibleRolesSwitcher';
import WrappedImportRolesAndVariables from './components/AnsibleRolesAndVariables';
import reducer from './reducer';
+import { VcsCloneModalContent } from './components/VcsCloneModalContent/VcsCloneModalContent';
componentRegistry.register({
name: 'ReportJsonViewer',
@@ -19,4 +20,9 @@ componentRegistry.register({
type: WrappedImportRolesAndVariables,
});
+componentRegistry.register({
+ name: 'VcsCloneModalContent',
+ type: VcsCloneModalContent,
+});
+
injectReducer('foremanAnsible', reducer);