diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 31500b919f3b66f7573b029a9a14226fa621ff64..d84a9a4a4b57cad5c77002c62bc2963a13e94e5c 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -8,17 +8,22 @@ import { GlFormGroup, GlFormInput, GlFormTextarea, + GlLoadingIcon, } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { createAlert } from '~/alert'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { visitUrl, queryToObject } from '~/lib/utils/url_utility'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import RefSelector from '~/ref/components/ref_selector.vue'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; import createPipelineScheduleMutation from '../graphql/mutations/create_pipeline_schedule.mutation.graphql'; +import updatePipelineScheduleMutation from '../graphql/mutations/update_pipeline_schedule.mutation.graphql'; +import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; +const scheduleId = queryToObject(window.location.search).id; + export default { components: { GlButton, @@ -29,20 +34,12 @@ export default { GlFormGroup, GlFormInput, GlFormTextarea, + GlLoadingIcon, RefSelector, TimezoneDropdown, IntervalPatternInput, }, - inject: [ - 'fullPath', - 'projectId', - 'defaultBranch', - 'cron', - 'cronTimezone', - 'dailyLimit', - 'settingsLink', - 'schedulesPath', - ], + inject: ['fullPath', 'projectId', 'defaultBranch', 'dailyLimit', 'settingsLink', 'schedulesPath'], props: { timezoneData: { type: Array, @@ -58,24 +55,74 @@ export default { required: true, }, }, + apollo: { + schedule: { + query: getPipelineSchedulesQuery, + variables() { + return { + projectPath: this.fullPath, + ids: scheduleId, + }; + }, + update(data) { + return data.project?.pipelineSchedules?.nodes[0] || {}; + }, + result({ data }) { + if (data) { + const { + project: { + pipelineSchedules: { nodes }, + }, + } = data; + + const schedule = nodes[0]; + const variables = schedule.variables?.nodes || []; + + this.description = schedule.description; + this.cron = schedule.cron; + this.cronTimezone = schedule.cronTimezone; + this.scheduleRef = schedule.ref; + this.variables = variables.map((variable) => { + return { + id: variable.id, + variableType: variable.variableType, + key: variable.key, + value: variable.value, + destroy: false, + }; + }); + this.addEmptyVariable(); + this.activated = schedule.active; + } + }, + skip() { + return !this.editing; + }, + error() { + createAlert({ message: this.$options.i18n.scheduleFetchError }); + }, + }, + }, data() { return { - cronValue: this.cron, + cron: '', description: '', scheduleRef: this.defaultBranch, activated: true, - timezone: this.cronTimezone, + cronTimezone: '', variables: [], + schedule: {}, }; }, i18n: { activated: __('Activated'), - cronTimezone: s__('PipelineSchedules|Cron timezone'), + cronTimezoneText: s__('PipelineSchedules|Cron timezone'), description: s__('PipelineSchedules|Description'), shortDescriptionPipeline: s__( 'PipelineSchedules|Provide a short description for this pipeline', ), - savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'), + editScheduleBtnText: s__('PipelineSchedules|Edit pipeline schedule'), + createScheduleBtnText: s__('PipelineSchedules|Create pipeline schedule'), cancel: __('Cancel'), targetBranchTag: __('Select target branch or tag'), intervalPattern: s__('PipelineSchedules|Interval Pattern'), @@ -87,6 +134,12 @@ export default { scheduleCreateError: s__( 'PipelineSchedules|An error occurred while creating the pipeline schedule.', ), + scheduleUpdateError: s__( + 'PipelineSchedules|An error occurred while updating the pipeline schedule.', + ), + scheduleFetchError: s__( + 'PipelineSchedules|An error occurred while trying to fetch the pipeline schedule.', + ), }, typeOptions: { [VARIABLE_TYPE]: __('Variable'), @@ -114,9 +167,26 @@ export default { getEnabledRefTypes() { return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; }, - preparedVariables() { + preparedVariablesUpdate() { return this.variables.filter((variable) => variable.key !== ''); }, + preparedVariablesCreate() { + return this.preparedVariablesUpdate.map((variable) => { + return { + key: variable.key, + value: variable.value, + variableType: variable.variableType, + }; + }); + }, + loading() { + return this.$apollo.queries.schedule.loading; + }, + buttonText() { + return this.editing + ? this.$options.i18n.editScheduleBtnText + : this.$options.i18n.createScheduleBtnText; + }, }, created() { this.addEmptyVariable(); @@ -133,6 +203,7 @@ export default { variableType: VARIABLE_TYPE, key: '', value: '', + destroy: false, }); }, setVariableAttribute(key, attribute, value) { @@ -140,16 +211,11 @@ export default { variable[attribute] = value; }, removeVariable(index) { - this.variables.splice(index, 1); + this.variables[index].destroy = true; }, canRemove(index) { return index < this.variables.length - 1; }, - scheduleHandler() { - if (!this.editing) { - this.createPipelineSchedule(); - } - }, async createPipelineSchedule() { try { const { @@ -161,10 +227,10 @@ export default { variables: { input: { description: this.description, - cron: this.cronValue, - cronTimezone: this.timezone, + cron: this.cron, + cronTimezone: this.cronTimezone, ref: this.scheduleRef, - variables: this.preparedVariables, + variables: this.preparedVariablesCreate, active: this.activated, projectPath: this.fullPath, }, @@ -180,11 +246,48 @@ export default { createAlert({ message: this.$options.i18n.scheduleCreateError }); } }, + async updatePipelineSchedule() { + try { + const { + data: { + pipelineScheduleUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updatePipelineScheduleMutation, + variables: { + input: { + id: this.schedule.id, + description: this.description, + cron: this.cron, + cronTimezone: this.cronTimezone, + ref: this.scheduleRef, + variables: this.preparedVariablesUpdate, + active: this.activated, + }, + }, + }); + + if (errors.length > 0) { + createAlert({ message: errors[0] }); + } else { + visitUrl(this.schedulesPath); + } + } catch { + createAlert({ message: this.$options.i18n.scheduleUpdateError }); + } + }, + scheduleHandler() { + if (this.editing) { + this.updatePipelineSchedule(); + } else { + this.createPipelineSchedule(); + } + }, setCronValue(cron) { - this.cronValue = cron; + this.cron = cron; }, setTimezone(timezone) { - this.timezone = timezone.identifier || ''; + this.cronTimezone = timezone.identifier || ''; }, }, }; @@ -192,7 +295,8 @@ export default { <template> <div class="col-lg-8 gl-pl-0"> - <gl-form> + <gl-loading-icon v-if="loading && editing" size="lg" /> + <gl-form v-else> <!--Description--> <gl-form-group :label="$options.i18n.description" label-for="schedule-description"> <gl-form-input @@ -215,10 +319,10 @@ export default { /> </gl-form-group> <!--Timezone--> - <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone"> + <gl-form-group :label="$options.i18n.cronTimezoneText" label-for="schedule-timezone"> <timezone-dropdown id="schedule-timezone" - :value="timezone" + :value="cronTimezone" :timezone-data="timezoneData" name="schedule-timezone" @input="setTimezone" @@ -242,12 +346,12 @@ export default { <div v-for="(variable, index) in variables" :key="`var-${index}`" - class="gl-mb-3 gl-pb-2" - data-testid="ci-variable-row" data-qa-selector="ci_variable_row_container" > <div - class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + v-if="!variable.destroy" + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row gl-mb-3 gl-pb-2" + data-testid="ci-variable-row" > <gl-dropdown :text="$options.typeOptions[variable.variableType]" @@ -308,7 +412,7 @@ export default { </gl-form-checkbox> <gl-button variant="confirm" data-testid="schedule-submit-button" @click="scheduleHandler"> - {{ $options.i18n.savePipelineSchedule }} + {{ buttonText }} </gl-button> <gl-button :href="schedulesPath" data-testid="schedule-cancel-button"> {{ $options.i18n.cancel }} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a6a937af74af70b706b5464c1bcd0bc3143b09ff --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation updatePipelineSchedule($input: PipelineScheduleUpdateInput!) { + pipelineScheduleUpdate(input: $input) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql index 0e091afb9d71cefe7bb7bd529c24b1291bf09ace..29a26be034473aa374457ea44dd10297b3895351 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql @@ -1,15 +1,22 @@ -query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) { +query getPipelineSchedulesQuery( + $projectPath: ID! + $status: PipelineScheduleStatus + $ids: [ID!] = null +) { currentUser { id username } project(fullPath: $projectPath) { id - pipelineSchedules(status: $status) { + pipelineSchedules(status: $status, ids: $ids) { count nodes { id description + cron + cronTimezone + ref forTag editPath refPath @@ -35,6 +42,14 @@ query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStat name webPath } + variables { + nodes { + id + variableType + key + value + } + } userPermissions { playPipelineSchedule updatePipelineSchedule diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js index 749e3d0a69fc20a48c2a90460dab88f95816c435..6bf121d39b6940236c390472efe0dfca0050306b 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -18,10 +18,8 @@ export default (selector, editing = false) => { const { fullPath, - cron, dailyLimit, timezoneData, - cronTimezone, projectId, defaultBranch, settingsLink, @@ -37,8 +35,6 @@ export default (selector, editing = false) => { projectId, defaultBranch, dailyLimit: dailyLimit ?? '', - cronTimezone: cronTimezone ?? '', - cron: cron ?? '', settingsLink, schedulesPath, }, diff --git a/app/graphql/mutations/ci/pipeline_schedule/update.rb b/app/graphql/mutations/ci/pipeline_schedule/update.rb index a0b5e793ecbf73e5f82d250e7371572986c26ef8..aff0a5494e722bdc8b33c5abfdcb2b542e942a87 100644 --- a/app/graphql/mutations/ci/pipeline_schedule/update.rb +++ b/app/graphql/mutations/ci/pipeline_schedule/update.rb @@ -43,7 +43,7 @@ class Update < Base def resolve(id:, variables: [], **pipeline_schedule_attrs) schedule = authorized_find!(id: id) - params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h)) + params = pipeline_schedule_attrs.merge(variables_attributes: variable_attributes_for(variables)) service_response = ::Ci::PipelineSchedules::UpdateService .new(schedule, current_user, params) @@ -54,6 +54,18 @@ def resolve(id:, variables: [], **pipeline_schedule_attrs) errors: service_response.errors } end + + private + + def variable_attributes_for(variables) + variables.map do |variable| + variable.to_h.tap do |hash| + hash[:id] = GlobalID::Locator.locate(hash[:id]).id if hash[:id] + + hash[:_destroy] = hash.delete(:destroy) + end + end + end end end end diff --git a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb index 54a6ad92448dd49377228306c674abfb0d5dc8af..eb6a78eb67a131fb4ae7ff5213210f33b1f9d6e6 100644 --- a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb +++ b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb @@ -8,11 +8,18 @@ class VariableInputType < Types::BaseInputObject description 'Attributes for the pipeline schedule variable.' + PipelineScheduleVariableID = ::Types::GlobalIDType[::Ci::PipelineScheduleVariable] + + argument :id, PipelineScheduleVariableID, required: false, description: 'ID of the variable to mutate.' + argument :key, GraphQL::Types::String, required: true, description: 'Name of the variable.' argument :value, GraphQL::Types::String, required: true, description: 'Value of the variable.' argument :variable_type, Types::Ci::VariableTypeEnum, required: true, description: 'Type of the variable.' + + argument :destroy, GraphQL::Types::Boolean, required: false, + description: 'Boolean option to destroy the variable.' end end end diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..e5125353b993906c5e7775d8124828263ec37c7e --- /dev/null +++ b/app/helpers/ci/pipeline_schedules_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + module PipelineSchedulesHelper + def js_pipeline_schedules_form_data(project, schedule) + { + full_path: project.full_path, + daily_limit: schedule.daily_limit, + timezone_data: timezone_data.to_json, + project_id: project.id, + default_branch: project.default_branch, + settings_link: project_settings_ci_cd_path(project), + schedules_path: pipeline_schedules_path(project) + } + end + end +end + +Ci::PipelineSchedulesHelper.prepend_mod_with('Ci::PipelineSchedulesHelper') diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml index 3f843ce6aec21e6a856d86ba3b9aa1f792482bcc..4e1ae53a101a6d5c86371a86e22deb0a86832bc3 100644 --- a/app/views/projects/pipeline_schedules/edit.html.haml +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -5,9 +5,8 @@ %h1.page-title.gl-font-size-h-display = _("Edit Pipeline Schedule") -%hr - if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-form-edit{ data: { full_path: @project.full_path } } + #pipeline-schedules-form-edit{ data: js_pipeline_schedules_form_data(@project, @schedule) } - else = render "form" diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml index 89836e6d09121f312727bb031a46a327215d9637..ef99a79b06fe22b541df3432c703271940cb69e2 100644 --- a/app/views/projects/pipeline_schedules/new.html.haml +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -9,6 +9,6 @@ = _("Schedule a new pipeline") - if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-form-new{ data: { full_path: @project.full_path, cron: @schedule.cron, daily_limit: @schedule.daily_limit, timezone_data: timezone_data.to_json, cron_timezone: @schedule.cron_timezone, project_id: @project.id, default_branch: @project.default_branch, settings_link: project_settings_ci_cd_path(@project), schedules_path: pipeline_schedules_path(@project) } } + #pipeline-schedules-form-new{ data: js_pipeline_schedules_form_data(@project, @schedule) } - else = render "form" diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index d4ada9791da572e0ee8b92663b8ac0dc424c71e3..0979185def3a6b062c31c4215db1db620cc7cc0d 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -27110,6 +27110,12 @@ A `CiPipelineScheduleID` is a global ID. It is encoded as a string. An example `CiPipelineScheduleID` is: `"gid://gitlab/Ci::PipelineSchedule/1"`. +### `CiPipelineScheduleVariableID` + +A `CiPipelineScheduleVariableID` is a global ID. It is encoded as a string. + +An example `CiPipelineScheduleVariableID` is: `"gid://gitlab/Ci::PipelineScheduleVariable/1"`. + ### `CiRunnerID` A `CiRunnerID` is a global ID. It is encoded as a string. @@ -28961,6 +28967,8 @@ Attributes for the pipeline schedule variable. | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="pipelineschedulevariableinputdestroy"></a>`destroy` | [`Boolean`](#boolean) | Boolean option to destroy the variable. | +| <a id="pipelineschedulevariableinputid"></a>`id` | [`CiPipelineScheduleVariableID`](#cipipelineschedulevariableid) | ID of the variable to mutate. | | <a id="pipelineschedulevariableinputkey"></a>`key` | [`String!`](#string) | Name of the variable. | | <a id="pipelineschedulevariableinputvalue"></a>`value` | [`String!`](#string) | Value of the variable. | | <a id="pipelineschedulevariableinputvariabletype"></a>`variableType` | [`CiVariableType!`](#civariabletype) | Type of the variable. | diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d4f3c649df6d41bd7915d5973c643ff2f663071f..0367a77421aaf24a521932a43bc687d10817b561 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -33336,6 +33336,12 @@ msgstr "" msgid "PipelineSchedules|An error occurred while creating the pipeline schedule." msgstr "" +msgid "PipelineSchedules|An error occurred while trying to fetch the pipeline schedule." +msgstr "" + +msgid "PipelineSchedules|An error occurred while updating the pipeline schedule." +msgstr "" + msgid "PipelineSchedules|Are you sure you want to delete this pipeline schedule?" msgstr "" @@ -33345,6 +33351,9 @@ msgstr "" msgid "PipelineSchedules|Create a new pipeline schedule" msgstr "" +msgid "PipelineSchedules|Create pipeline schedule" +msgstr "" + msgid "PipelineSchedules|Cron timezone" msgstr "" @@ -33402,9 +33411,6 @@ msgstr "" msgid "PipelineSchedules|Runs with the same project permissions as the schedule owner." msgstr "" -msgid "PipelineSchedules|Save pipeline schedule" -msgstr "" - msgid "PipelineSchedules|Successfully scheduled a pipeline to run. Go to the %{linkStart}Pipelines page%{linkEnd} for details. " msgstr "" diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js index 1697533803aa088dd091f0facf63db121389a691..bb48d4dc38dcc18f6e72af6c896ab4a6cb0f4507 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import { GlForm } from '@gitlab/ui'; +import { GlForm, GlLoadingIcon } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -14,8 +14,14 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; import createPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql'; +import updatePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql'; +import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; import { timezoneDataFixture } from '../../../vue_shared/components/timezone_dropdown/helpers'; -import { createScheduleMutationResponse } from '../mock_data'; +import { + createScheduleMutationResponse, + updateScheduleMutationResponse, + mockSinglePipelineScheduleNode, +} from '../mock_data'; Vue.use(VueApollo); @@ -23,8 +29,20 @@ jest.mock('~/alert'); jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), joinPaths: jest.fn().mockReturnValue(''), + queryToObject: jest.fn().mockReturnValue({ id: '1' }), })); +const { + data: { + project: { + pipelineSchedules: { nodes }, + }, + }, +} = mockSinglePipelineScheduleNode; + +const schedule = nodes[0]; +const variables = schedule.variables.nodes; + describe('Pipeline schedules form', () => { let wrapper; const defaultBranch = 'main'; @@ -32,8 +50,13 @@ describe('Pipeline schedules form', () => { const cron = ''; const dailyLimit = ''; + const querySuccessHandler = jest.fn().mockResolvedValue(mockSinglePipelineScheduleNode); + const queryFailedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const createMutationHandlerSuccess = jest.fn().mockResolvedValue(createScheduleMutationResponse); const createMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const updateMutationHandlerSuccess = jest.fn().mockResolvedValue(updateScheduleMutationResponse); + const updateMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); const createMockApolloProvider = ( requestHandlers = [[createPipelineScheduleMutation, createMutationHandlerSuccess]], @@ -52,8 +75,6 @@ describe('Pipeline schedules form', () => { fullPath: 'gitlab-org/gitlab', projectId, defaultBranch, - cron, - cronTimezone: '', dailyLimit, settingsLink: '', schedulesPath: '/root/ci-project/-/pipeline_schedules', @@ -69,6 +90,7 @@ describe('Pipeline schedules form', () => { const findRefSelector = () => wrapper.findComponent(RefSelector); const findSubmitButton = () => wrapper.findByTestId('schedule-submit-button'); const findCancelButton = () => wrapper.findByTestId('schedule-cancel-button'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); // Variables const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); @@ -187,7 +209,38 @@ describe('Pipeline schedules form', () => { }); }); - describe('schedule creation', () => { + describe('Button text', () => { + it.each` + editing | expectedText + ${true} | ${'Edit pipeline schedule'} + ${false} | ${'Create pipeline schedule'} + `( + 'button text is $expectedText when editing is $editing', + async ({ editing, expectedText }) => { + createComponent(shallowMountExtended, editing, [ + [getPipelineSchedulesQuery, querySuccessHandler], + ]); + + await waitForPromises(); + + expect(findSubmitButton().text()).toBe(expectedText); + }, + ); + }); + + describe('Schedule creation', () => { + it('when creating a schedule the query is not called', () => { + createComponent(); + + expect(querySuccessHandler).not.toHaveBeenCalled(); + }); + + it('does not show loading state when creating new schedule', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + describe('schedule creation success', () => { let mock; @@ -259,4 +312,125 @@ describe('Pipeline schedules form', () => { }); }); }); + + describe('Schedule editing', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('shows loading state when editing', async () => { + createComponent(shallowMountExtended, true, [ + [getPipelineSchedulesQuery, querySuccessHandler], + ]); + + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('schedule fetch success', () => { + it('fetches schedule and sets form data correctly', async () => { + createComponent(mountExtended, true, [[getPipelineSchedulesQuery, querySuccessHandler]]); + + expect(querySuccessHandler).toHaveBeenCalled(); + + await waitForPromises(); + + expect(findDescription().element.value).toBe(schedule.description); + expect(findIntervalComponent().props('initialCronInterval')).toBe(schedule.cron); + expect(findTimezoneDropdown().props('value')).toBe(schedule.cronTimezone); + expect(findRefSelector().props('value')).toBe(schedule.ref); + expect(findVariableRows()).toHaveLength(3); + expect(findKeyInputs().at(0).element.value).toBe(variables[0].key); + expect(findKeyInputs().at(1).element.value).toBe(variables[1].key); + expect(findValueInputs().at(0).element.value).toBe(variables[0].value); + expect(findValueInputs().at(1).element.value).toBe(variables[1].value); + }); + }); + + it('schedule fetch failure', async () => { + createComponent(shallowMountExtended, true, [ + [getPipelineSchedulesQuery, queryFailedHandler], + ]); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while trying to fetch the pipeline schedule.', + }); + }); + + it('edit schedule success', async () => { + createComponent(mountExtended, true, [ + [getPipelineSchedulesQuery, querySuccessHandler], + [updatePipelineScheduleMutation, updateMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDescription().element.value = 'Updated schedule'; + findDescription().trigger('change'); + + findIntervalComponent().vm.$emit('cronValue', '0 22 16 * *'); + + // Ensures variable is sent with destroy property set true + findRemoveIcons().at(0).vm.$emit('click'); + + findSubmitButton().vm.$emit('click'); + + await waitForPromises(); + + expect(updateMutationHandlerSuccess).toHaveBeenCalledWith({ + input: { + active: schedule.active, + cron: '0 22 16 * *', + cronTimezone: schedule.cronTimezone, + id: schedule.id, + ref: schedule.ref, + description: 'Updated schedule', + variables: [ + { + destroy: true, + id: variables[0].id, + key: variables[0].key, + value: variables[0].value, + variableType: variables[0].variableType, + }, + { + destroy: false, + id: variables[1].id, + key: variables[1].key, + value: variables[1].value, + variableType: variables[1].variableType, + }, + ], + }, + }); + }); + + it('edit schedule failure', async () => { + createComponent(shallowMountExtended, true, [ + [getPipelineSchedulesQuery, querySuccessHandler], + [updatePipelineScheduleMutation, updateMutationHandlerFailed], + ]); + + await waitForPromises(); + + findSubmitButton().vm.$emit('click'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while updating the pipeline schedule.', + }); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 58fc4a616e44c284be720544174fba766073d835..81283a7170b4b43648299d2bf16bba22b73db914 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -2,6 +2,7 @@ import mockGetPipelineSchedulesGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.json'; import mockGetPipelineSchedulesAsGuestGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.as_guest.json'; import mockGetPipelineSchedulesTakeOwnershipGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.take_ownership.json'; +import mockGetSinglePipelineScheduleGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.single.json'; const { data: { @@ -30,10 +31,10 @@ const { export const mockPipelineScheduleNodes = nodes; export const mockPipelineScheduleCurrentUser = currentUser; - export const mockPipelineScheduleAsGuestNodes = guestNodes; - export const mockTakeOwnershipNodes = takeOwnershipNodes; +export const mockSinglePipelineScheduleNode = mockGetSinglePipelineScheduleGraphQLResponse; + export const emptyPipelineSchedulesResponse = { data: { project: { @@ -89,4 +90,14 @@ export const createScheduleMutationResponse = { }, }; +export const updateScheduleMutationResponse = { + data: { + pipelineScheduleUpdate: { + clientMutationId: null, + errors: [], + __typename: 'PipelineScheduleUpdatePayload', + }, + }, +}; + export { mockGetPipelineSchedulesGraphQLResponse }; diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb index 3bfe9113e834885db689d22003c068c066a8b60f..7bba7910b873854210d569f4beffd88875ee504c 100644 --- a/spec/frontend/fixtures/pipeline_schedules.rb +++ b/spec/frontend/fixtures/pipeline_schedules.rb @@ -63,6 +63,12 @@ expect_graphql_errors_to_be_empty end + it "#{fixtures_path}#{get_pipeline_schedules_query}.single.json" do + post_graphql(query, current_user: user, variables: { projectPath: project.full_path, ids: pipeline_schedule_populated.id }) + + expect_graphql_errors_to_be_empty + end + it "#{fixtures_path}#{get_pipeline_schedules_query}.as_guest.json" do guest = create(:user) project.add_guest(user) diff --git a/spec/graphql/mutations/ci/pipeline_schedule/variable_input_type_spec.rb b/spec/graphql/mutations/ci/pipeline_schedule/variable_input_type_spec.rb index 564bc95b352cd7f4917f4e4754cd88a95cad714a..a932002d61498320199da8d0572721ae69967544 100644 --- a/spec/graphql/mutations/ci/pipeline_schedule/variable_input_type_spec.rb +++ b/spec/graphql/mutations/ci/pipeline_schedule/variable_input_type_spec.rb @@ -5,5 +5,5 @@ RSpec.describe Mutations::Ci::PipelineSchedule::VariableInputType, feature_category: :continuous_integration do specify { expect(described_class.graphql_name).to eq('PipelineScheduleVariableInput') } - it { expect(described_class.arguments.keys).to match_array(%w[key value variableType]) } + it { expect(described_class.arguments.keys).to match_array(%w[id key value variableType destroy]) } end diff --git a/spec/helpers/ci/pipeline_schedules_helper_spec.rb b/spec/helpers/ci/pipeline_schedules_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ba24a08b5876a1e46bd8d19709b53ce108121d4 --- /dev/null +++ b/spec/helpers/ci/pipeline_schedules_helper_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::PipelineSchedulesHelper, feature_category: :continuous_integration do + let_it_be(:project) { build_stubbed(:project) } + let_it_be(:user) { build_stubbed(:user) } + let_it_be(:pipeline_schedule) { build_stubbed(:ci_pipeline_schedule, project: project, owner: user) } + let_it_be(:timezones) { [{ identifier: "Pacific/Honolulu", name: "Hawaii" }] } + + let_it_be(:pipeline_schedule_variable) do + build_stubbed(:ci_pipeline_schedule_variable, key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule) + end + + describe '#js_pipeline_schedules_form_data' do + before do + allow(helper).to receive(:timezone_data).and_return(timezones) + end + + it 'returns pipeline schedule form data' do + expect(helper.js_pipeline_schedules_form_data(project, pipeline_schedule)).to include({ + full_path: project.full_path, + daily_limit: nil, + project_id: project.id, + schedules_path: pipeline_schedules_path(project), + settings_link: project_settings_ci_cd_path(project), + timezone_data: timezones.to_json + }) + end + end +end diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_schedule_update_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_schedule_update_spec.rb index c1da231a4a6c632cc262ee986e1c1bffb544ec55..3c3dcfc0a2dac94144274a28bf96f6e0614e9137 100644 --- a/spec/requests/api/graphql/mutations/ci/pipeline_schedule_update_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/pipeline_schedule_update_spec.rb @@ -9,6 +9,14 @@ let_it_be(:project) { create(:project, :public, :repository) } let_it_be(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: user) } + let_it_be(:variable_one) do + create(:ci_pipeline_schedule_variable, key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule) + end + + let_it_be(:variable_two) do + create(:ci_pipeline_schedule_variable, key: 'bar', value: 'barvalue', pipeline_schedule: pipeline_schedule) + end + let(:mutation) do variables = { id: pipeline_schedule.to_global_id.to_s, @@ -30,6 +38,7 @@ nodes { key value + variableType } } } @@ -88,8 +97,37 @@ expect(mutation_response['pipelineSchedule']['refForDisplay']).to eq(pipeline_schedule_parameters[:ref]) - expect(mutation_response['pipelineSchedule']['variables']['nodes'][0]['key']).to eq('AAA') - expect(mutation_response['pipelineSchedule']['variables']['nodes'][0]['value']).to eq('AAA123') + expect(mutation_response['pipelineSchedule']['variables']['nodes'][2]['key']).to eq('AAA') + expect(mutation_response['pipelineSchedule']['variables']['nodes'][2]['value']).to eq('AAA123') + end + end + + context 'when updating and removing variables' do + let(:pipeline_schedule_parameters) do + { + variables: [ + { key: 'ABC', value: "ABC123", variableType: 'ENV_VAR', destroy: false }, + { id: variable_one.to_global_id.to_s, + key: 'foo', value: "foovalue", + variableType: 'ENV_VAR', + destroy: true }, + { id: variable_two.to_global_id.to_s, key: 'newbar', value: "newbarvalue", variableType: 'ENV_VAR' } + ] + } + end + + it 'processes variables correctly' do + post_graphql_mutation(mutation, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + + expect(mutation_response['pipelineSchedule']['variables']['nodes']) + .to match_array( + [ + { "key" => 'newbar', "value" => 'newbarvalue', "variableType" => 'ENV_VAR' }, + { "key" => 'ABC', "value" => "ABC123", "variableType" => 'ENV_VAR' } + ] + ) end end diff --git a/spec/services/ci/pipeline_schedules/update_service_spec.rb b/spec/services/ci/pipeline_schedules/update_service_spec.rb index 6899f6c7d63121c232974260168b8afbdf7c15a4..c31a652ed937af6d335b8d956d6416928c8ab960 100644 --- a/spec/services/ci/pipeline_schedules/update_service_spec.rb +++ b/spec/services/ci/pipeline_schedules/update_service_spec.rb @@ -8,9 +8,16 @@ let_it_be(:project) { create(:project, :public, :repository) } let_it_be(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: user) } + let_it_be(:pipeline_schedule_variable) do + create(:ci_pipeline_schedule_variable, + key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule) + end + before_all do project.add_maintainer(user) project.add_reporter(reporter) + + pipeline_schedule.reload end describe "execute" do @@ -35,7 +42,10 @@ description: 'updated_desc', ref: 'patch-x', active: false, - cron: '*/1 * * * *' + cron: '*/1 * * * *', + variables_attributes: [ + { id: pipeline_schedule_variable.id, key: 'bar', secret_value: 'barvalue' } + ] } end @@ -47,6 +57,42 @@ .and change { pipeline_schedule.ref }.from('master').to('patch-x') .and change { pipeline_schedule.active }.from(true).to(false) .and change { pipeline_schedule.cron }.from('0 1 * * *').to('*/1 * * * *') + .and change { pipeline_schedule.variables.last.key }.from('foo').to('bar') + .and change { pipeline_schedule.variables.last.value }.from('foovalue').to('barvalue') + end + + context 'when creating a variable' do + let(:params) do + { + variables_attributes: [ + { key: 'ABC', secret_value: 'ABC123' } + ] + } + end + + it 'creates the new variable' do + expect { service.execute }.to change { Ci::PipelineScheduleVariable.count }.by(1) + + expect(pipeline_schedule.variables.last.key).to eq('ABC') + expect(pipeline_schedule.variables.last.value).to eq('ABC123') + end + end + + context 'when deleting a variable' do + let(:params) do + { + variables_attributes: [ + { + id: pipeline_schedule_variable.id, + _destroy: true + } + ] + } + end + + it 'deletes the existing variable' do + expect { service.execute }.to change { Ci::PipelineScheduleVariable.count }.by(-1) + end end it 'returns ServiceResponse.success' do