Commit 059bd0af authored by Mireya Andres's avatar Mireya Andres Committed by Miguel Rincon

Migrate button toggle for shared project runners to Vue

To remove ambiguity with the current UI for enabling/disabling shared
runners in project settings, this MR replaces the button with a toggle
element. This is also consistent with our usage of toggles in the other
runner settings.

To do this, the UI has been ported to Vue while the POST endpoint
`toggle_shared_runners` has been modified to return the response in
JSON format.

The changes are gated by the `:vueify_shared_runners_toggle` feature
flag.
parent a23bc839
......@@ -4,6 +4,7 @@ import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
......@@ -32,4 +33,8 @@ document.addEventListener('DOMContentLoaded', () => {
initDeployFreeze();
initSettingsPipelinesTriggers();
if (gon?.features?.vueifySharedRunnersToggle) {
initSharedRunnersToggle();
}
});
<script>
import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.');
export default {
components: {
GlAlert,
GlToggle,
GlTooltip,
},
props: {
isDisabledAndUnoverridable: {
type: Boolean,
required: true,
},
isEnabled: {
type: Boolean,
required: true,
},
updatePath: {
type: String,
required: true,
},
},
data() {
return {
isLoading: false,
isSharedRunnerEnabled: false,
errorMessage: null,
};
},
created() {
this.isSharedRunnerEnabled = this.isEnabled;
},
methods: {
toggleSharedRunners() {
this.isLoading = true;
this.errorMessage = null;
axios
.post(this.updatePath)
.then(() => {
this.isLoading = false;
this.isSharedRunnerEnabled = !this.isSharedRunnerEnabled;
})
.catch(error => {
this.isLoading = false;
this.errorMessage = error.response?.data?.error || DEFAULT_ERROR_MESSAGE;
});
},
},
};
</script>
<template>
<div>
<section class="gl-mt-5">
<gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false">
{{ errorMessage }}
</gl-alert>
<div ref="sharedRunnersToggle">
<gl-toggle
:disabled="isDisabledAndUnoverridable"
:is-loading="isLoading"
:label="__('Enable shared runners for this project')"
:value="isSharedRunnerEnabled"
data-testid="toggle-shared-runners"
@change="toggleSharedRunners"
/>
</div>
<gl-tooltip v-if="isDisabledAndUnoverridable" :target="() => $refs.sharedRunnersToggle">
{{ __('Shared runners are disabled on group level') }}
</gl-tooltip>
</section>
</div>
</template>
import Vue from 'vue';
import SharedRunnersToggle from '~/projects/settings/components/shared_runners_toggle.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
export default (containerId = 'toggle-shared-runners-form') => {
const containerEl = document.getElementById(containerId);
const { isDisabledAndUnoverridable, isEnabled, updatePath } = containerEl.dataset;
return new Vue({
el: containerEl,
render(createElement) {
return createElement(SharedRunnersToggle, {
props: {
isDisabledAndUnoverridable: parseBoolean(isDisabledAndUnoverridable),
isEnabled: parseBoolean(isEnabled),
updatePath,
},
});
},
});
};
......@@ -53,13 +53,24 @@ class Projects::RunnersController < Projects::ApplicationController
def toggle_shared_runners
if !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
return redirect_to project_runners_path(@project), alert: _("Cannot enable shared runners because parent group does not allow it")
if Feature.enabled?(:vueify_shared_runners_toggle, @project)
render json: { error: _('Cannot enable shared runners because parent group does not allow it') }, status: :unauthorized
else
redirect_to project_runners_path(@project), alert: _('Cannot enable shared runners because parent group does not allow it')
end
return
end
project.toggle!(:shared_runners_enabled)
if Feature.enabled?(:vueify_shared_runners_toggle, @project)
render json: {}, status: :ok
else
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
end
end
def toggle_group_runners
project.toggle_ci_cd_settings!(:group_runners_enabled)
......
......@@ -11,6 +11,7 @@ module Projects
before_action :define_variables
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
push_frontend_feature_flag(:vueify_shared_runners_toggle, @project)
end
helper_method :highlight_badge
......
......@@ -49,6 +49,14 @@ module Ci
parent_shared_runners_availability: group.parent&.shared_runners_setting
}
end
def toggle_shared_runners_settings_data(project)
{
is_enabled: "#{project.shared_runners_enabled?}",
is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == 'disabled_and_unoverridable'}",
update_path: toggle_shared_runners_project_runners_path(project)
}
end
end
end
......
- isVueifySharedRunnersToggleEnabled = Feature.enabled?(:vueify_shared_runners_toggle, @project)
= render layout: 'shared/runners/shared_runners_description' do
- if !isVueifySharedRunnersToggleEnabled
%hr
- if @project.group&.shared_runners_setting == 'disabled_and_unoverridable'
%h5.gl-text-red-500
......@@ -12,6 +15,9 @@
= _('Enable shared runners')
&nbsp; for this project
- if isVueifySharedRunnersToggleEnabled
#toggle-shared-runners-form{ data: toggle_shared_runners_settings_data(@project) }
- if @shared_runners_count == 0
= _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.')
- else
......
---
name: vueify_shared_runners_toggle
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48452
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/292441
milestone: '13.7'
type: development
group: group::continuous integration
default_enabled: false
......@@ -3312,6 +3312,9 @@ msgstr ""
msgid "An error occurred while updating the comment"
msgstr ""
msgid "An error occurred while updating the configuration."
msgstr ""
msgid "An error occurred while updating the milestone."
msgstr ""
......@@ -10449,6 +10452,9 @@ msgstr ""
msgid "Enable shared runners for this group"
msgstr ""
msgid "Enable shared runners for this project"
msgstr ""
msgid "Enable smartcn custom analyzer: Indexing"
msgstr ""
......@@ -25255,6 +25261,9 @@ msgstr ""
msgid "Shared runners are disabled for the parent group"
msgstr ""
msgid "Shared runners are disabled on group level"
msgstr ""
msgid "Shared runners disabled on group level"
msgstr ""
......
......@@ -78,6 +78,11 @@ RSpec.describe Projects::RunnersController do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
context 'without feature flag' do
before do
stub_feature_flags(vueify_shared_runners_toggle: false)
end
it 'toggles shared_runners_enabled when the group allows shared runners' do
project.update!(shared_runners_enabled: true)
......@@ -111,7 +116,46 @@ RSpec.describe Projects::RunnersController do
expect(response).to have_gitlab_http_status(:found)
expect(project.shared_runners_enabled).to eq(false)
expect(flash[:alert]).to eq("Cannot enable shared runners because parent group does not allow it")
expect(flash[:alert]).to eq('Cannot enable shared runners because parent group does not allow it')
end
end
context 'with feature flag: vueify_shared_runners_toggle' do
it 'toggles shared_runners_enabled when the group allows shared runners' do
project.update!(shared_runners_enabled: true)
post :toggle_shared_runners, params: params
project.reload
expect(response).to have_gitlab_http_status(:ok)
expect(project.shared_runners_enabled).to eq(false)
end
it 'toggles shared_runners_enabled when the group disallows shared runners but allows overrides' do
group.update!(shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: true)
project.update!(shared_runners_enabled: false)
post :toggle_shared_runners, params: params
project.reload
expect(response).to have_gitlab_http_status(:ok)
expect(project.shared_runners_enabled).to eq(true)
end
it 'does not enable if the group disallows shared runners' do
group.update!(shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: false)
project.update!(shared_runners_enabled: false)
post :toggle_shared_runners, params: params
project.reload
expect(response).to have_gitlab_http_status(:unauthorized)
expect(project.shared_runners_enabled).to eq(false)
expect(json_response['error']).to eq('Cannot enable shared runners because parent group does not allow it')
end
end
end
end
......@@ -179,7 +179,9 @@ RSpec.describe 'Runners' do
context 'when a project has disabled shared_runners' do
let(:project) { create(:project, shared_runners_enabled: false) }
context 'when feature flag: vueify_shared_runners_toggle is disabled' do
before do
stub_feature_flags(vueify_shared_runners_toggle: false)
project.add_maintainer(user)
end
......@@ -189,6 +191,20 @@ RSpec.describe 'Runners' do
click_on 'Enable shared runners'
expect(page.find('.shared-runners-description')).to have_content('Disable shared runners')
expect(page).not_to have_selector('#toggle-shared-runners-form')
end
end
context 'when feature flag: vueify_shared_runners_toggle is enabled' do
before do
project.add_maintainer(user)
end
it 'user enables shared runners' do
visit project_runners_path(project)
expect(page).to have_selector('#toggle-shared-runners-form')
end
end
end
......
import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAxiosAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import SharedRunnersToggleComponent from '~/projects/settings/components/shared_runners_toggle.vue';
import axios from '~/lib/utils/axios_utils';
const TEST_UPDATE_PATH = '/test/update_shared_runners';
jest.mock('~/flash');
describe('projects/settings/components/shared_runners', () => {
let wrapper;
let mockAxios;
const createComponent = (props = {}) => {
wrapper = shallowMount(SharedRunnersToggleComponent, {
propsData: {
isEnabled: false,
isDisabledAndUnoverridable: false,
isLoading: false,
updatePath: TEST_UPDATE_PATH,
...props,
},
});
};
const findErrorAlert = () => wrapper.find(GlAlert);
const findSharedRunnersToggle = () => wrapper.find(GlToggle);
const findToggleTooltip = () => wrapper.find(GlTooltip);
const getToggleValue = () => findSharedRunnersToggle().props('value');
const isToggleLoading = () => findSharedRunnersToggle().props('isLoading');
const isToggleDisabled = () => findSharedRunnersToggle().props('disabled');
beforeEach(() => {
mockAxios = new MockAxiosAdapter(axios);
mockAxios.onPost(TEST_UPDATE_PATH).reply(200);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockAxios.restore();
});
describe('with group share settings DISABLED', () => {
beforeEach(() => {
createComponent({
isDisabledAndUnoverridable: true,
});
});
it('toggle should be disabled', () => {
expect(isToggleDisabled()).toBe(true);
});
it('tooltip should exist explaining why the toggle is disabled', () => {
expect(findToggleTooltip().exists()).toBe(true);
});
});
describe('with group share settings ENABLED', () => {
beforeEach(() => {
createComponent();
});
it('toggle should be enabled', () => {
expect(isToggleDisabled()).toBe(false);
});
it('loading icon, error message, and tooltip should not exist', () => {
expect(isToggleLoading()).toBe(false);
expect(findErrorAlert().exists()).toBe(false);
expect(findToggleTooltip().exists()).toBe(false);
});
describe('with shared runners DISABLED', () => {
beforeEach(() => {
createComponent();
});
it('toggle should be turned off', () => {
expect(getToggleValue()).toBe(false);
});
it('can enable toggle', async () => {
findSharedRunnersToggle().vm.$emit('change', true);
await waitForPromises();
expect(mockAxios.history.post[0].data).toEqual(undefined);
expect(mockAxios.history.post).toHaveLength(1);
expect(findErrorAlert().exists()).toBe(false);
expect(getToggleValue()).toBe(true);
});
});
describe('with shared runners ENABLED', () => {
beforeEach(() => {
createComponent({ isEnabled: true });
});
it('toggle should be turned on', () => {
expect(getToggleValue()).toBe(true);
});
it('can disable toggle', async () => {
findSharedRunnersToggle().vm.$emit('change', true);
await waitForPromises();
expect(mockAxios.history.post[0].data).toEqual(undefined);
expect(mockAxios.history.post).toHaveLength(1);
expect(findErrorAlert().exists()).toBe(false);
expect(getToggleValue()).toBe(false);
});
});
describe('loading icon', () => {
it('should show and hide on request', async () => {
createComponent();
expect(isToggleLoading()).toBe(false);
findSharedRunnersToggle().vm.$emit('change', true);
await wrapper.vm.$nextTick();
expect(isToggleLoading()).toBe(true);
await waitForPromises();
expect(isToggleLoading()).toBe(false);
});
});
describe('when request encounters an error', () => {
it('should show custom error message from API if it exists', async () => {
mockAxios.onPost(TEST_UPDATE_PATH).reply(401, { error: 'Custom API Error message' });
createComponent();
expect(getToggleValue()).toBe(false);
findSharedRunnersToggle().vm.$emit('change', true);
await waitForPromises();
expect(findErrorAlert().text()).toBe('Custom API Error message');
expect(getToggleValue()).toBe(false); // toggle value should not change
});
it('should show default error message if API does not return a custom error message', async () => {
mockAxios.onPost(TEST_UPDATE_PATH).reply(401);
createComponent();
expect(getToggleValue()).toBe(false);
findSharedRunnersToggle().vm.$emit('change', true);
await waitForPromises();
expect(findErrorAlert().text()).toBe('An error occurred while updating the configuration.');
expect(getToggleValue()).toBe(false); // toggle value should not change
});
});
});
});
......@@ -74,4 +74,57 @@ RSpec.describe Ci::RunnersHelper do
expect(data[:parent_shared_runners_availability]).to eq('enabled')
end
end
describe '#toggle_shared_runners_settings_data' do
let_it_be(:group) { create(:group) }
let(:project_with_runners) { create(:project, namespace: group, shared_runners_enabled: true) }
let(:project_without_runners) { create(:project, namespace: group, shared_runners_enabled: false) }
context 'when project has runners' do
it 'returns the correct value for is_enabled' do
data = toggle_shared_runners_settings_data(project_with_runners)
expect(data[:is_enabled]).to eq("true")
end
end
context 'when project does not have runners' do
it 'returns the correct value for is_enabled' do
data = toggle_shared_runners_settings_data(project_without_runners)
expect(data[:is_enabled]).to eq("false")
end
end
context 'for all projects' do
it 'returns the update path for toggling the shared runners setting' do
data = toggle_shared_runners_settings_data(project_with_runners)
expect(data[:update_path]).to eq(toggle_shared_runners_project_runners_path(project_with_runners))
end
it 'returns false for is_disabled_and_unoverridable when project has no group' do
project = create(:project)
data = toggle_shared_runners_settings_data(project)
expect(data[:is_disabled_and_unoverridable]).to eq("false")
end
using RSpec::Parameterized::TableSyntax
where(:shared_runners_setting, :is_disabled_and_unoverridable) do
'enabled' | "false"
'disabled_with_override' | "false"
'disabled_and_unoverridable' | "true"
end
with_them do
it 'returns the override runner status for project with group' do
group = create(:group)
project = create(:project, group: group)
allow(group).to receive(:shared_runners_setting).and_return(shared_runners_setting)
data = toggle_shared_runners_settings_data(project)
expect(data[:is_disabled_and_unoverridable]).to eq(is_disabled_and_unoverridable)
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment