Commit f6e9762d authored by Kev's avatar Kev Committed by Jose Ivan Vargas

Allow tags as target of pipeline scheduled

Changelog: changed
parent 6f86346d
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
export default class TargetBranchDropdown {
constructor() {
this.$dropdown = $('.js-target-branch-dropdown');
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
this.$input = $('#schedule_ref');
this.initDefaultBranch();
this.initDropdown();
}
initDropdown() {
initDeprecatedJQueryDropdown(this.$dropdown, {
data: this.formatBranchesList(),
filterable: true,
selectable: true,
toggleLabel: (item) => item.name,
search: {
fields: ['name'],
},
clicked: (cfg) => this.updateInputValue(cfg),
text: (item) => item.name,
});
this.setDropdownToggle();
}
formatBranchesList() {
return this.$dropdown.data('data').map((val) => ({ name: val }));
}
setDropdownToggle() {
const initialValue = this.$input.val();
this.$dropdownToggle.text(initialValue);
}
initDefaultBranch() {
const initialValue = this.$input.val();
const defaultBranch = this.$dropdown.data('defaultBranch');
if (!initialValue) {
this.$input.val(defaultBranch);
}
}
updateInputValue({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.name);
gl.pipelineScheduleFieldErrors.updateFormValidityState();
}
}
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list'; import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list';
import GlFieldErrors from '../../../../gl_field_errors'; import GlFieldErrors from '../../../../gl_field_errors';
import Translate from '../../../../vue_shared/translate'; import Translate from '../../../../vue_shared/translate';
import intervalPatternInput from './components/interval_pattern_input.vue'; import intervalPatternInput from './components/interval_pattern_input.vue';
import TargetBranchDropdown from './components/target_branch_dropdown';
import TimezoneDropdown from './components/timezone_dropdown'; import TimezoneDropdown from './components/timezone_dropdown';
Vue.use(Translate); Vue.use(Translate);
...@@ -30,6 +32,52 @@ function initIntervalPatternInput() { ...@@ -30,6 +32,52 @@ function initIntervalPatternInput() {
}); });
} }
function getEnabledRefTypes() {
const refTypes = [REF_TYPE_BRANCHES];
if (gon.features.pipelineSchedulesWithTags) {
refTypes.push(REF_TYPE_TAGS);
}
return refTypes;
}
function initTargetRefDropdown() {
const $refField = document.getElementById('schedule_ref');
const el = document.querySelector('.js-target-ref-dropdown');
const { projectId, defaultBranch } = el.dataset;
if (!$refField.value) {
$refField.value = defaultBranch;
}
const refDropdown = new Vue({
el,
render(h) {
return h(RefSelector, {
props: {
enabledRefTypes: getEnabledRefTypes(),
projectId,
value: $refField.value,
useSymbolicRefNames: true,
translations: {
dropdownHeader: gon.features.pipelineSchedulesWithTags
? __('Select target branch or tag')
: __('Select target branch'),
},
},
class: 'gl-w-full',
});
},
});
refDropdown.$children[0].$on('input', (newRef) => {
$refField.value = newRef;
});
return refDropdown;
}
export default () => { export default () => {
/* Most of the form is written in haml, but for fields with more complex behaviors, /* Most of the form is written in haml, but for fields with more complex behaviors,
* you should mount individual Vue components here. If at some point components need * you should mount individual Vue components here. If at some point components need
...@@ -48,9 +96,10 @@ export default () => { ...@@ -48,9 +96,10 @@ export default () => {
gl.pipelineScheduleFieldErrors.updateFormValidityState(); gl.pipelineScheduleFieldErrors.updateFormValidityState();
}, },
}); });
gl.targetBranchDropdown = new TargetBranchDropdown();
gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement); gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
initTargetRefDropdown();
setupNativeFormVariableList({ setupNativeFormVariableList({
container: $('.js-ci-variable-list-section'), container: $('.js-ci-variable-list-section'),
formField: 'schedule', formField: 'schedule',
......
...@@ -58,6 +58,11 @@ export default { ...@@ -58,6 +58,11 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
useSymbolicRefNames: {
type: Boolean,
required: false,
default: false,
},
/** The validation state of this component. */ /** The validation state of this component. */
state: { state: {
...@@ -121,8 +126,15 @@ export default { ...@@ -121,8 +126,15 @@ export default {
query: this.lastQuery, query: this.lastQuery,
}; };
}, },
selectedRefForDisplay() {
if (this.useSymbolicRefNames && this.selectedRef) {
return this.selectedRef.replace(/^refs\/(tags|heads)\//, '');
}
return this.selectedRef;
},
buttonText() { buttonText() {
return this.selectedRef || this.i18n.noRefSelected; return this.selectedRefForDisplay || this.i18n.noRefSelected;
}, },
}, },
watch: { watch: {
...@@ -164,9 +176,20 @@ export default { ...@@ -164,9 +176,20 @@ export default {
}, },
{ immediate: true }, { immediate: true },
); );
this.$watch(
'useSymbolicRefNames',
() => this.setUseSymbolicRefNames(this.useSymbolicRefNames),
{ immediate: true },
);
}, },
methods: { methods: {
...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']), ...mapActions([
'setEnabledRefTypes',
'setUseSymbolicRefNames',
'setProjectId',
'setSelectedRef',
]),
...mapActions({ storeSearch: 'search' }), ...mapActions({ storeSearch: 'search' }),
focusSearchBox() { focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus(); this.$refs.searchBox.$el.querySelector('input').focus();
......
...@@ -5,6 +5,9 @@ import * as types from './mutation_types'; ...@@ -5,6 +5,9 @@ import * as types from './mutation_types';
export const setEnabledRefTypes = ({ commit }, refTypes) => export const setEnabledRefTypes = ({ commit }, refTypes) =>
commit(types.SET_ENABLED_REF_TYPES, refTypes); commit(types.SET_ENABLED_REF_TYPES, refTypes);
export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) =>
commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames);
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setSelectedRef = ({ commit }, selectedRef) => export const setSelectedRef = ({ commit }, selectedRef) =>
......
export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES'; export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES';
export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF'; export const SET_SELECTED_REF = 'SET_SELECTED_REF';
......
...@@ -7,6 +7,9 @@ export default { ...@@ -7,6 +7,9 @@ export default {
[types.SET_ENABLED_REF_TYPES](state, refTypes) { [types.SET_ENABLED_REF_TYPES](state, refTypes) {
state.enabledRefTypes = refTypes; state.enabledRefTypes = refTypes;
}, },
[types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) {
state.useSymbolicRefNames = useSymbolicRefNames;
},
[types.SET_PROJECT_ID](state, projectId) { [types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId; state.projectId = projectId;
}, },
...@@ -28,6 +31,7 @@ export default { ...@@ -28,6 +31,7 @@ export default {
state.matches.branches = { state.matches.branches = {
list: convertObjectPropsToCamelCase(response.data).map((b) => ({ list: convertObjectPropsToCamelCase(response.data).map((b) => ({
name: b.name, name: b.name,
value: state.useSymbolicRefNames ? `refs/heads/${b.name}` : undefined,
default: b.default, default: b.default,
})), })),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
...@@ -46,6 +50,7 @@ export default { ...@@ -46,6 +50,7 @@ export default {
state.matches.tags = { state.matches.tags = {
list: convertObjectPropsToCamelCase(response.data).map((b) => ({ list: convertObjectPropsToCamelCase(response.data).map((b) => ({
name: b.name, name: b.name,
value: state.useSymbolicRefNames ? `refs/tags/${b.name}` : undefined,
})), })),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
error: null, error: null,
......
...@@ -10,6 +10,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController ...@@ -10,6 +10,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play] before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play]
before_action :authorize_admin_pipeline_schedule!, only: [:destroy] before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
before_action do
push_frontend_feature_flag(:pipeline_schedules_with_tags, @project, default_enabled: :yaml)
end
feature_category :continuous_integration feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
......
...@@ -66,6 +66,18 @@ module Ci ...@@ -66,6 +66,18 @@ module Ci
project.actual_limits.limit_for(:ci_daily_pipeline_schedule_triggers) project.actual_limits.limit_for(:ci_daily_pipeline_schedule_triggers)
end end
def ref_for_display
return unless ref.present?
ref.gsub(%r{^refs/(heads|tags)/}, '')
end
def for_tag?
return false unless ref.present?
ref.start_with? 'refs/tags/'
end
private private
def worker_cron_expression def worker_cron_expression
......
...@@ -15,8 +15,9 @@ ...@@ -15,8 +15,9 @@
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group.row .form-group.row
.col-md-9 .col-md-9
= f.label :ref, _('Target Branch'), class: 'label-bold' = f.label :ref, Feature.enabled?(:pipeline_schedules_with_tags) ? _('Target branch or tag') : _('Target branch'), class: 'label-bold'
= dropdown_tag(_("Select target branch"), options: { toggle_class: 'gl-button btn btn-default js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } ) %div{ data: { testid: 'schedule-target-ref' } }
.js-target-ref-dropdown{ data: { project_id: @project.id, default_branch: @project.default_branch } }
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group.row.js-ci-variable-list-section .form-group.row.js-ci-variable-list-section
.col-md-9 .col-md-9
......
...@@ -3,9 +3,12 @@ ...@@ -3,9 +3,12 @@
%td %td
= pipeline_schedule.description = pipeline_schedule.description
%td.branch-name-cell %td.branch-name-cell
= sprite_icon('fork', size: 12) - if pipeline_schedule.for_tag?
= sprite_icon('tag', size: 12)
- else
= sprite_icon('fork', size: 12)
- if pipeline_schedule.ref.present? - if pipeline_schedule.ref.present?
= link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name" = link_to pipeline_schedule.ref_for_display, project_ref_path(@project, pipeline_schedule.ref_for_display), class: "ref-name"
%td %td
- if pipeline_schedule.last_pipeline - if pipeline_schedule.last_pipeline
.status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" }
......
---
name: pipeline_schedules_with_tags
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81476
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/354421
milestone: '14.9'
type: development
group: group::pipeline execution
default_enabled: false
...@@ -33241,6 +33241,9 @@ msgstr "" ...@@ -33241,6 +33241,9 @@ msgstr ""
msgid "Select target branch" msgid "Select target branch"
msgstr "" msgstr ""
msgid "Select target branch or tag"
msgstr ""
msgid "Select timezone" msgid "Select timezone"
msgstr "" msgstr ""
...@@ -35993,6 +35996,9 @@ msgstr "" ...@@ -35993,6 +35996,9 @@ msgstr ""
msgid "Target branch" msgid "Target branch"
msgstr "" msgstr ""
msgid "Target branch or tag"
msgstr ""
msgid "Target-Branch" msgid "Target-Branch"
msgstr "" msgstr ""
......
...@@ -135,8 +135,8 @@ RSpec.describe 'Pipeline Schedules', :js do ...@@ -135,8 +135,8 @@ RSpec.describe 'Pipeline Schedules', :js do
end end
it 'shows the pipeline schedule with default ref' do it 'shows the pipeline schedule with default ref' do
page.within('.js-target-branch-dropdown') do page.within('[data-testid="schedule-target-ref"]') do
expect(first('.dropdown-toggle-text').text).to eq('master') expect(first('.gl-new-dropdown-button-text').text).to eq('master')
end end
end end
end end
...@@ -148,8 +148,8 @@ RSpec.describe 'Pipeline Schedules', :js do ...@@ -148,8 +148,8 @@ RSpec.describe 'Pipeline Schedules', :js do
end end
it 'shows the pipeline schedule with default ref' do it 'shows the pipeline schedule with default ref' do
page.within('.js-target-branch-dropdown') do page.within('[data-testid="schedule-target-ref"]') do
expect(first('.dropdown-toggle-text').text).to eq('master') expect(first('.gl-new-dropdown-button-text').text).to eq('master')
end end
end end
end end
...@@ -293,8 +293,8 @@ RSpec.describe 'Pipeline Schedules', :js do ...@@ -293,8 +293,8 @@ RSpec.describe 'Pipeline Schedules', :js do
end end
def select_target_branch def select_target_branch
find('.js-target-branch-dropdown').click find('[data-testid="schedule-target-ref"] .dropdown-toggle').click
click_link 'master' click_button 'master'
end end
def save_pipeline_schedule def save_pipeline_schedule
......
...@@ -10,30 +10,37 @@ Object { ...@@ -10,30 +10,37 @@ Object {
Object { Object {
"default": false, "default": false,
"name": "add_images_and_changes", "name": "add_images_and_changes",
"value": undefined,
}, },
Object { Object {
"default": false, "default": false,
"name": "conflict-contains-conflict-markers", "name": "conflict-contains-conflict-markers",
"value": undefined,
}, },
Object { Object {
"default": false, "default": false,
"name": "deleted-image-test", "name": "deleted-image-test",
"value": undefined,
}, },
Object { Object {
"default": false, "default": false,
"name": "diff-files-image-to-symlink", "name": "diff-files-image-to-symlink",
"value": undefined,
}, },
Object { Object {
"default": false, "default": false,
"name": "diff-files-symlink-to-image", "name": "diff-files-symlink-to-image",
"value": undefined,
}, },
Object { Object {
"default": false, "default": false,
"name": "markdown", "name": "markdown",
"value": undefined,
}, },
Object { Object {
"default": true, "default": true,
"name": "master", "name": "master",
"value": undefined,
}, },
], ],
"totalCount": 123, "totalCount": 123,
...@@ -54,12 +61,15 @@ Object { ...@@ -54,12 +61,15 @@ Object {
"list": Array [ "list": Array [
Object { Object {
"name": "v1.1.1", "name": "v1.1.1",
"value": undefined,
}, },
Object { Object {
"name": "v1.1.0", "name": "v1.1.0",
"value": undefined,
}, },
Object { Object {
"name": "v1.0.0", "name": "v1.0.0",
"value": undefined,
}, },
], ],
"totalCount": 456, "totalCount": 456,
......
...@@ -48,6 +48,14 @@ describe('Ref selector Vuex store mutations', () => { ...@@ -48,6 +48,14 @@ describe('Ref selector Vuex store mutations', () => {
}); });
}); });
describe(`${types.SET_USE_SYMBOLIC_REF_NAMES}`, () => {
it('sets useSymbolicRefNames on the state', () => {
mutations[types.SET_USE_SYMBOLIC_REF_NAMES](state, true);
expect(state.useSymbolicRefNames).toBe(true);
});
});
describe(`${types.SET_PROJECT_ID}`, () => { describe(`${types.SET_PROJECT_ID}`, () => {
it('updates the project ID', () => { it('updates the project ID', () => {
const newProjectId = '4'; const newProjectId = '4';
......
...@@ -228,6 +228,66 @@ RSpec.describe Ci::PipelineSchedule do ...@@ -228,6 +228,66 @@ RSpec.describe Ci::PipelineSchedule do
end end
end end
describe '#for_tag?' do
context 'when the target is a tag' do
before do
subject.ref = 'refs/tags/v1.0'
end
it { expect(subject.for_tag?).to eq(true) }
end
context 'when the target is a branch' do
before do
subject.ref = 'refs/heads/main'
end
it { expect(subject.for_tag?).to eq(false) }
end
context 'when there is no ref' do
before do
subject.ref = nil
end
it { expect(subject.for_tag?).to eq(false) }
end
end
describe '#ref_for_display' do
context 'when the target is a tag' do
before do
subject.ref = 'refs/tags/v1.0'
end
it { expect(subject.ref_for_display).to eq('v1.0') }
end
context 'when the target is a branch' do
before do
subject.ref = 'refs/heads/main'
end
it { expect(subject.ref_for_display).to eq('main') }
end
context 'when the ref is ambiguous' do
before do
subject.ref = 'release-2.8'
end
it { expect(subject.ref_for_display).to eq('release-2.8') }
end
context 'when there is no ref' do
before do
subject.ref = nil
end
it { expect(subject.ref_for_display).to eq(nil) }
end
end
context 'loose foreign key on ci_pipeline_schedules.project_id' do context 'loose foreign key on ci_pipeline_schedules.project_id' do
it_behaves_like 'cleanup by a loose foreign key' do it_behaves_like 'cleanup by a loose foreign key' do
let!(:parent) { create(:project) } let!(:parent) { create(:project) }
......
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