From 147d2f6d8f6c3008b1357d7e31e9b723765772e6 Mon Sep 17 00:00:00 2001 From: Simon Knox <simon@gitlab.com> Date: Tue, 29 Jun 2021 12:21:39 +1000 Subject: [PATCH] Create and edit iterations within a cadence Adds edit/new button for iterations in cadences list page And new routes for iteration form pages --- .../iteration_cadence_list_item.vue | 12 + .../iterations/components/iteration_form.vue | 144 +++++++---- ee/app/assets/javascripts/iterations/index.js | 12 +- .../queries/iteration_create.mutation.graphql | 12 + .../assets/javascripts/iterations/router.js | 75 ++++-- .../groups/iteration_cadences_controller.rb | 14 - .../iteration_cadences/_js_app.html.haml | 9 - .../groups/iteration_cadences/edit.html.haml | 5 - .../groups/iteration_cadences/index.html.haml | 11 +- .../groups/iteration_cadences/new.html.haml | 5 - ee/config/routes/group.rb | 4 +- .../iteration_cadences_controller_spec.rb | 21 +- .../user_edits_iteration_cadence_spec.rb | 82 ++++++ .../iterations/user_edits_iteration_spec.rb | 132 ++++++---- .../components/iteration_form_spec.js | 244 ++++++++++++++++++ .../routing/groups/cadences_routing_spec.rb | 40 +++ locale/gitlab.pot | 12 +- 17 files changed, 645 insertions(+), 189 deletions(-) create mode 100644 ee/app/assets/javascripts/iterations/queries/iteration_create.mutation.graphql delete mode 100644 ee/app/views/groups/iteration_cadences/_js_app.html.haml delete mode 100644 ee/app/views/groups/iteration_cadences/edit.html.haml delete mode 100644 ee/app/views/groups/iteration_cadences/new.html.haml create mode 100644 ee/spec/features/groups/iterations/user_edits_iteration_cadence_spec.rb create mode 100644 ee/spec/frontend/iterations/components/iteration_form_spec.js create mode 100644 ee/spec/routing/groups/cadences_routing_spec.rb diff --git a/ee/app/assets/javascripts/iterations/components/iteration_cadence_list_item.vue b/ee/app/assets/javascripts/iterations/components/iteration_cadence_list_item.vue index fc84d5916ca..686346c5efa 100644 --- a/ee/app/assets/javascripts/iterations/components/iteration_cadence_list_item.vue +++ b/ee/app/assets/javascripts/iterations/components/iteration_cadence_list_item.vue @@ -123,6 +123,14 @@ export default { }, }; }, + newIteration() { + return { + name: 'newIteration', + params: { + cadenceId: getIdFromGraphQLId(this.cadenceId), + }, + }; + }, }, methods: { fetchMore() { @@ -204,6 +212,10 @@ export default { text-sr-only no-caret > + <gl-dropdown-item v-if="!durationInWeeks" :to="newIteration"> + {{ s__('Iterations|Add iteration') }} + </gl-dropdown-item> + <gl-dropdown-item :to="editCadence"> {{ s__('Iterations|Edit cadence') }} </gl-dropdown-item> diff --git a/ee/app/assets/javascripts/iterations/components/iteration_form.vue b/ee/app/assets/javascripts/iterations/components/iteration_form.vue index abc045f80c4..162bba8337c 100644 --- a/ee/app/assets/javascripts/iterations/components/iteration_form.vue +++ b/ee/app/assets/javascripts/iterations/components/iteration_form.vue @@ -1,66 +1,87 @@ <script> -import { GlButton, GlForm, GlFormInput } from '@gitlab/ui'; +import { GlAlert, GlButton, GlForm, GlFormInput } from '@gitlab/ui'; import initDatePicker from '~/behaviors/date_picker'; import createFlash from '~/flash'; -import { visitUrl } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import createIteration from '../queries/create_iteration.mutation.graphql'; +import readIteration from '../queries/iteration.query.graphql'; +import createIteration from '../queries/iteration_create.mutation.graphql'; import updateIteration from '../queries/update_iteration.mutation.graphql'; export default { + cadencesList: { + name: 'index', + }, components: { + GlAlert, GlButton, GlForm, GlFormInput, MarkdownField, }, - props: { - groupPath: { - type: String, - required: true, - }, - previewMarkdownPath: { - type: String, - required: false, - default: '', - }, - iterationsListPath: { - type: String, - required: false, - default: '', - }, - isEditing: { - type: Boolean, - required: false, - default: false, - }, - iteration: { - type: Object, - required: false, - default: () => ({}), + apollo: { + group: { + query: readIteration, + skip() { + return !this.iterationId; + }, + /* eslint-disable @gitlab/require-i18n-strings */ + variables() { + return { + fullPath: this.fullPath, + id: convertToGraphQLId('Iteration', this.iterationId), + isGroup: true, + }; + }, + /* eslint-enable @gitlab/require-i18n-strings */ + result({ data }) { + const iteration = data.group.iterations?.nodes[0]; + + if (!iteration) { + this.error = s__('Iterations|Unable to find iteration.'); + return; + } + + this.title = iteration.title; + this.description = iteration.description; + this.startDate = iteration.startDate; + this.dueDate = iteration.dueDate; + }, + error(err) { + this.error = err.message; + }, }, }, + inject: ['fullPath', 'previewMarkdownPath'], data() { return { - iterations: [], loading: false, - title: this.iteration.title, - description: this.iteration.description, - startDate: this.iteration.startDate, - dueDate: this.iteration.dueDate, + error: '', + group: { iteration: {} }, + title: '', + description: '', + startDate: '', + dueDate: '', }; }, computed: { + cadenceId() { + return this.$router.currentRoute.params.cadenceId; + }, + iterationId() { + return this.$router.currentRoute.params.iterationId; + }, + isEditing() { + return Boolean(this.iterationId); + }, variables() { return { - input: { - groupPath: this.groupPath, - title: this.title, - description: this.description, - startDate: this.startDate, - dueDate: this.dueDate, - }, + groupPath: this.fullPath, + title: this.title, + description: this.description, + startDate: this.startDate, + dueDate: this.dueDate, }; }, }, @@ -73,21 +94,18 @@ export default { this.loading = true; return this.isEditing ? this.updateIteration() : this.createIteration(); }, - cancel() { - if (this.iterationsListPath) { - visitUrl(this.iterationsListPath); - } else { - this.$emit('cancel'); - } - }, createIteration() { return this.$apollo .mutate({ mutation: createIteration, - variables: this.variables, + variables: { + ...this.variables, + iterationsCadenceId: convertToGraphQLId('Iterations::Cadence', this.cadenceId), + }, }) .then(({ data }) => { - const { errors, iteration } = data.createIteration; + const { iteration, errors } = data.iterationCreate; + if (errors.length > 0) { this.loading = false; createFlash({ @@ -96,7 +114,13 @@ export default { return; } - visitUrl(iteration.webUrl); + this.$router.push({ + name: 'iteration', + params: { + cadenceId: this.cadenceId, + iterationId: getIdFromGraphQLId(iteration.id), + }, + }); }) .catch(() => { this.loading = false; @@ -111,8 +135,8 @@ export default { mutation: updateIteration, variables: { input: { - ...this.variables.input, - id: this.iteration.id, + ...this.variables, + id: this.iterationId, }, }, }) @@ -125,7 +149,13 @@ export default { return; } - this.$emit('updated'); + this.$router.push({ + name: 'iteration', + params: { + cadenceId: this.cadenceId, + iterationId: this.iterationId, + }, + }); }) .catch(() => { createFlash({ @@ -154,6 +184,10 @@ export default { </h3> </div> <hr class="gl-mt-0" /> + + <gl-alert v-if="error" class="gl-mb-5" variant="danger" @dismiss="error = ''">{{ + error + }}</gl-alert> <gl-form class="row common-note-form"> <div class="col-md-6"> <div class="form-group row"> @@ -248,13 +282,13 @@ export default { <gl-button :loading="loading" data-testid="save-iteration" - variant="success" + variant="confirm" data-qa-selector="save_iteration_button" @click="save" > {{ isEditing ? __('Update iteration') : __('Create iteration') }} </gl-button> - <gl-button class="ml-auto" data-testid="cancel-iteration" @click="cancel"> + <gl-button class="gl-ml-3" data-testid="cancel-iteration" :to="$options.cadencesList"> {{ __('Cancel') }} </gl-button> </div> diff --git a/ee/app/assets/javascripts/iterations/index.js b/ee/app/assets/javascripts/iterations/index.js index a2b3b4b05f2..aa53a86d009 100644 --- a/ee/app/assets/javascripts/iterations/index.js +++ b/ee/app/assets/javascripts/iterations/index.js @@ -107,13 +107,22 @@ export function initCadenceApp({ namespaceType }) { cadencesListPath, canCreateCadence, canEditCadence, + canCreateIteration, canEditIteration, hasScopedLabelsFeature, labelsFetchPath, previewMarkdownPath, noIssuesSvgPath, } = el.dataset; - const router = createRouter({ base: cadencesListPath }); + const router = createRouter({ + base: cadencesListPath, + permissions: { + canCreateCadence: parseBoolean(canCreateCadence), + canEditCadence: parseBoolean(canEditCadence), + canCreateIteration: parseBoolean(canCreateIteration), + canEditIteration: parseBoolean(canEditIteration), + }, + }); return new Vue({ el, @@ -126,6 +135,7 @@ export function initCadenceApp({ namespaceType }) { canCreateCadence: parseBoolean(canCreateCadence), canEditCadence: parseBoolean(canEditCadence), namespaceType, + canCreateIteration: parseBoolean(canCreateIteration), canEditIteration: parseBoolean(canEditIteration), hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), labelsFetchPath, diff --git a/ee/app/assets/javascripts/iterations/queries/iteration_create.mutation.graphql b/ee/app/assets/javascripts/iterations/queries/iteration_create.mutation.graphql new file mode 100644 index 00000000000..02a97530af9 --- /dev/null +++ b/ee/app/assets/javascripts/iterations/queries/iteration_create.mutation.graphql @@ -0,0 +1,12 @@ +mutation createIteration($input: iterationCreateInput!) { + iterationCreate(input: $input) { + iteration { + title + description + descriptionHtml + startDate + dueDate + } + errors + } +} diff --git a/ee/app/assets/javascripts/iterations/router.js b/ee/app/assets/javascripts/iterations/router.js index e51a98f0d5f..f0e48e25347 100644 --- a/ee/app/assets/javascripts/iterations/router.js +++ b/ee/app/assets/javascripts/iterations/router.js @@ -2,34 +2,63 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import IterationCadenceForm from './components/iteration_cadence_form.vue'; import IterationCadenceList from './components/iteration_cadences_list.vue'; +import IterationForm from './components/iteration_form.vue'; import IterationReport from './components/iteration_report.vue'; Vue.use(VueRouter); -const routes = [ - { - name: 'new', - path: '/new', - component: IterationCadenceForm, - }, - { - name: 'edit', - path: '/:cadenceId/edit', - component: IterationCadenceForm, - }, - { - name: 'index', - path: '/', - component: IterationCadenceList, - }, - { - name: 'iteration', - path: '/:cadenceId/iterations/:iterationId', - component: IterationReport, - }, -]; +function checkPermission(permission) { + return (to, from, next) => { + if (permission) { + next(); + } else { + next({ path: '/' }); + } + }; +} + +export default function createRouter({ base, permissions = {} }) { + const routes = [ + { + name: 'index', + path: '/', + component: IterationCadenceList, + }, + { + name: 'new', + path: '/new', + component: IterationCadenceForm, + beforeEnter: checkPermission(permissions.canCreateCadence), + }, + { + name: 'edit', + path: '/:cadenceId/edit', + component: IterationCadenceForm, + beforeEnter: checkPermission(permissions.canEditCadence), + }, + { + name: 'newIteration', + path: '/:cadenceId/iterations/new', + component: IterationForm, + beforeEnter: checkPermission(permissions.canCreateIteration), + }, + { + name: 'iteration', + path: '/:cadenceId/iterations/:iterationId', + component: IterationReport, + }, + { + name: 'editIteration', + path: '/:cadenceId/iterations/:iterationId/edit', + component: IterationForm, + beforeEnter: checkPermission(permissions.canEditIteration), + }, + { + path: '*', + redirect: '/', + }, + ]; -export default function createRouter({ base }) { const router = new VueRouter({ base, mode: 'history', diff --git a/ee/app/controllers/groups/iteration_cadences_controller.rb b/ee/app/controllers/groups/iteration_cadences_controller.rb index 04aef308c03..e3063a79a81 100644 --- a/ee/app/controllers/groups/iteration_cadences_controller.rb +++ b/ee/app/controllers/groups/iteration_cadences_controller.rb @@ -3,15 +3,9 @@ class Groups::IterationCadencesController < Groups::ApplicationController before_action :check_cadences_available! before_action :authorize_show_cadence!, only: [:index] - before_action :authorize_admin_cadence!, only: [:edit] - before_action :authorize_create_cadence!, only: [:new] feature_category :issue_tracking - def new; end - - def edit; end - def index; end private @@ -20,14 +14,6 @@ class Groups::IterationCadencesController < Groups::ApplicationController render_404 unless group.iteration_cadences_feature_flag_enabled? end - def authorize_admin_cadence! - render_404 unless can?(current_user, :admin_iteration_cadence, group) - end - - def authorize_create_cadence! - render_404 unless can?(current_user, :create_iteration_cadence, group) - end - def authorize_show_cadence! render_404 unless can?(current_user, :read_iteration_cadence, group) end diff --git a/ee/app/views/groups/iteration_cadences/_js_app.html.haml b/ee/app/views/groups/iteration_cadences/_js_app.html.haml deleted file mode 100644 index 6dcaed8473e..00000000000 --- a/ee/app/views/groups/iteration_cadences/_js_app.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -.js-iteration-cadence-app{ data: { group_full_path: @group.full_path, - cadences_list_path: group_iteration_cadences_path(@group), - can_create_cadence: can?(current_user, :create_iteration_cadence, @group).to_s, - can_edit_cadence: can?(current_user, :admin_iteration_cadence, @group).to_s, - can_edit_iteration: can?(current_user, :admin_iteration, @group).to_s, - has_scoped_labels_feature: @group.licensed_feature_available?(:scoped_labels).to_s, - labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true), - preview_markdown_path: preview_markdown_path(@group), - no_issues_svg_path: image_path('illustrations/issues.svg') } } diff --git a/ee/app/views/groups/iteration_cadences/edit.html.haml b/ee/app/views/groups/iteration_cadences/edit.html.haml deleted file mode 100644 index 1e4bc1bb896..00000000000 --- a/ee/app/views/groups/iteration_cadences/edit.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- add_to_breadcrumbs _('Iteration cadences'), group_iteration_cadences_path(@group) -- breadcrumb_title params[:id] -- page_title _('Edit iteration cadence') - -= render 'js_app' diff --git a/ee/app/views/groups/iteration_cadences/index.html.haml b/ee/app/views/groups/iteration_cadences/index.html.haml index 583f8b2312e..9bcbe92de31 100644 --- a/ee/app/views/groups/iteration_cadences/index.html.haml +++ b/ee/app/views/groups/iteration_cadences/index.html.haml @@ -1,3 +1,12 @@ - page_title _('Iteration cadences') -= render 'js_app' +.js-iteration-cadence-app{ data: { group_full_path: @group.full_path, + cadences_list_path: group_iteration_cadences_path(@group), + can_create_cadence: can?(current_user, :create_iteration_cadence, @group).to_s, + can_edit_cadence: can?(current_user, :admin_iteration_cadence, @group).to_s, + can_create_iteration: can?(current_user, :create_iteration, @group).to_s, + can_edit_iteration: can?(current_user, :admin_iteration, @group).to_s, + has_scoped_labels_feature: @group.licensed_feature_available?(:scoped_labels).to_s, + labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true), + preview_markdown_path: preview_markdown_path(@group), + no_issues_svg_path: image_path('illustrations/issues.svg') } } diff --git a/ee/app/views/groups/iteration_cadences/new.html.haml b/ee/app/views/groups/iteration_cadences/new.html.haml deleted file mode 100644 index 2977f9d7fe6..00000000000 --- a/ee/app/views/groups/iteration_cadences/new.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- add_to_breadcrumbs _('Iteration cadences'), group_iteration_cadences_path(@group) -- breadcrumb_title _('New') -- page_title _('New iteration cadence') - -= render 'js_app' diff --git a/ee/config/routes/group.rb b/ee/config/routes/group.rb index 1664679cd81..26c67c8717e 100644 --- a/ee/config/routes/group.rb +++ b/ee/config/routes/group.rb @@ -120,7 +120,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do resources :iterations, only: [:index, :new, :edit, :show], constraints: { id: /\d+/ } - resources :iteration_cadences, only: [:index, :new, :edit], path: 'cadences', constraints: { id: /\d+/ } + resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index do + resources :iterations, only: [:index, :new, :edit, :show], constraints: { id: /\d+/ }, controller: :iteration_cadences, action: :index + end resources :issues, only: [] do collection do diff --git a/ee/spec/controllers/groups/iteration_cadences_controller_spec.rb b/ee/spec/controllers/groups/iteration_cadences_controller_spec.rb index 653e9e1cfce..d8d7bf4a427 100644 --- a/ee/spec/controllers/groups/iteration_cadences_controller_spec.rb +++ b/ee/spec/controllers/groups/iteration_cadences_controller_spec.rb @@ -15,28 +15,13 @@ RSpec.describe Groups::IterationCadencesController do sign_in(user) end - describe 'new' do - subject { get :new, params: { group_id: group } } + describe 'index' do + subject { get :index, params: { group_id: group } } where(:feature_flag_available, :role, :status) do false | :developer | :not_found true | :none | :not_found - true | :guest | :not_found - true | :developer | :success - end - - with_them do - it_behaves_like 'returning response status', params[:status] - end - end - - describe 'edit' do - subject { get :edit, params: { group_id: group, id: cadence } } - - where(:feature_flag_available, :role, :status) do - false | :developer | :not_found - true | :none | :not_found - true | :guest | :not_found + true | :guest | :success true | :developer | :success end diff --git a/ee/spec/features/groups/iterations/user_edits_iteration_cadence_spec.rb b/ee/spec/features/groups/iterations/user_edits_iteration_cadence_spec.rb new file mode 100644 index 00000000000..5642d36cec8 --- /dev/null +++ b/ee/spec/features/groups/iterations/user_edits_iteration_cadence_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User edits iteration cadence', :js do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } + let_it_be(:guest_user) { create(:group_member, :guest, user: create(:user), group: group ).user } + let_it_be(:cadence) { create(:iterations_cadence, group: group, description: 'an example iteration cadence', duration_in_weeks: 3, iterations_in_advance: 2) } + + dropdown_selector = '[data-testid="actions-dropdown"]' + + context 'with license' do + before do + stub_licensed_features(iterations: true) + end + + context 'as authorized user' do + before do + sign_in(user) + end + + it 'prefills fields and allows updating values' do + visit edit_group_iteration_cadence_path(cadence.group, id: cadence.id) + + wait_for_requests + + aggregate_failures do + expect(title_input.value).to eq(cadence.title) + expect(description_input.value).to eq(cadence.description) + expect(start_date_input.value).to have_content(cadence.start_date) + end + + updated_title = 'Updated cadence title' + + fill_in('Title', with: updated_title) + click_button('Save cadence') + + expect(page).to have_content(updated_title) + end + end + + context 'as guest user' do + before do + sign_in(guest_user) + end + + it 'does not show edit dropdown' do + visit group_iteration_cadences_path(cadence.group) + + expect(page).to have_content(cadence.title) + expect(page).not_to have_selector(dropdown_selector) + end + + it 'redirects to list page when loading edit cadence page' do + visit edit_group_iteration_cadence_path(cadence.group, id: cadence.id) + + # vue-router has trailing slash but _path helper doesn't + expect(page).to have_current_path("#{group_iteration_cadences_path(cadence.group)}/") + end + + it 'redirects to list page when loading new cadence page' do + visit new_group_iteration_cadence_path(cadence.group) + + # vue-router has trailing slash but _path helper doesn't + expect(page).to have_current_path("#{group_iteration_cadences_path(cadence.group)}/") + end + end + + def title_input + page.find('#cadence-title') + end + + def description_input + page.find('#cadence-description') + end + + def start_date_input + page.find('#cadence-start-date') + end + end +end diff --git a/ee/spec/features/groups/iterations/user_edits_iteration_spec.rb b/ee/spec/features/groups/iterations/user_edits_iteration_spec.rb index 7a895f4af4e..60d8b01bf90 100644 --- a/ee/spec/features/groups/iterations/user_edits_iteration_spec.rb +++ b/ee/spec/features/groups/iterations/user_edits_iteration_spec.rb @@ -2,16 +2,19 @@ require 'spec_helper' -RSpec.describe 'User views iteration' do +RSpec.describe 'User edits iteration' do let_it_be(:now) { Time.now } let_it_be(:group) { create(:group) } let_it_be(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } let_it_be(:guest_user) { create(:group_member, :guest, user: create(:user), group: group ).user } - let_it_be(:iteration) { create(:iteration, :skip_future_date_validation, group: group, title: 'Correct Iteration', description: 'Iteration description', start_date: now - 1.day, due_date: now) } + let_it_be(:cadence) { create(:iterations_cadence, group: group) } + let_it_be(:iteration) { create(:iteration, :skip_future_date_validation, group: group, title: 'Correct Iteration', description: 'Iteration description', start_date: now - 1.day, due_date: now, iterations_cadence: cadence) } dropdown_selector = '[data-testid="actions-dropdown"]' context 'with license' do + using RSpec::Parameterized::TableSyntax + before do stub_licensed_features(iterations: true) end @@ -21,55 +24,64 @@ RSpec.describe 'User views iteration' do sign_in(user) end - context 'load edit page directly', :js do - before do - visit edit_group_iteration_path(group, iteration.id) - end + where(using_cadences: [true, false]) - it 'prefills fields and allows updating all values' do - aggregate_failures do - expect(title_input.value).to eq(iteration.title) - expect(description_input.value).to eq(iteration.description) - expect(start_date_input.value).to have_content(iteration.start_date) - expect(due_date_input.value).to have_content(iteration.due_date) - end + with_them do + let(:iteration_page) { using_cadences ? group_iteration_cadence_iteration_path(group, iteration_cadence_id: cadence.id, id: iteration.id) : group_iteration_path(iteration.group, iteration.id) } + let(:edit_iteration_page) { using_cadences ? edit_group_iteration_cadence_iteration_path(group, iteration_cadence_id: cadence.id, id: iteration.id) : edit_group_iteration_path(iteration.group, iteration.id) } - updated_title = 'Updated iteration title' - updated_desc = 'Updated iteration desc' - updated_start_date = now + 4.days - updated_due_date = now + 5.days - - fill_in('Title', with: updated_title) - fill_in('Description', with: updated_desc) - fill_in('Start date', with: updated_start_date.strftime('%Y-%m-%d')) - fill_in('Due date', with: updated_due_date.strftime('%Y-%m-%d')) - click_button('Update iteration') - - aggregate_failures do - expect(page).to have_content(updated_title) - expect(page).to have_content(updated_desc) - expect(page).to have_content(updated_start_date.strftime('%b %-d, %Y')) - expect(page).to have_content(updated_due_date.strftime('%b %-d, %Y')) - expect(page).to have_current_path(group_iteration_path(group, iteration.id)) + context 'load edit page directly', :js do + before do + visit edit_iteration_page + + wait_for_requests end - end - end - context 'load edit page from report', :js do - before do - visit group_iteration_path(iteration.group, iteration.id) + it 'prefills fields and allows updating all values' do + aggregate_failures do + expect(title_input.value).to eq(iteration.title) + expect(description_input.value).to eq(iteration.description) + expect(start_date_input.value).to have_content(iteration.start_date) + expect(due_date_input.value).to have_content(iteration.due_date) + end + + updated_title = 'Updated iteration title' + updated_desc = 'Updated iteration desc' + updated_start_date = now + 4.days + updated_due_date = now + 5.days + + fill_in('Title', with: updated_title) + fill_in('Description', with: updated_desc) + fill_in('Start date', with: updated_start_date.strftime('%Y-%m-%d')) + fill_in('Due date', with: updated_due_date.strftime('%Y-%m-%d')) + click_button('Update iteration') + + aggregate_failures do + expect(page).to have_content(updated_title) + expect(page).to have_content(updated_desc) + expect(page).to have_content(updated_start_date.strftime('%b %-d, %Y')) + expect(page).to have_content(updated_due_date.strftime('%b %-d, %Y')) + expect(page).to have_current_path(iteration_page) + end + end end - it 'prefills fields and updates URL' do - find(dropdown_selector).click - click_button('Edit iteration') + context 'load edit page from report', :js do + before do + visit iteration_page + end - aggregate_failures do - expect(title_input.value).to eq(iteration.title) - expect(description_input.value).to eq(iteration.description) - expect(start_date_input.value).to have_content(iteration.start_date) - expect(due_date_input.value).to have_content(iteration.due_date) - expect(page).to have_current_path(edit_group_iteration_path(iteration.group, iteration.id)) + it 'prefills fields and updates URL' do + find(dropdown_selector).click + click_link_or_button('Edit iteration') + + aggregate_failures do + expect(title_input.value).to eq(iteration.title) + expect(description_input.value).to eq(iteration.description) + expect(start_date_input.value).to have_content(iteration.start_date) + expect(due_date_input.value).to have_content(iteration.due_date) + expect(page).to have_current_path(edit_iteration_page) + end end end end @@ -80,17 +92,35 @@ RSpec.describe 'User views iteration' do sign_in(guest_user) end - it 'does not show edit dropdown', :js do - visit group_iteration_path(iteration.group, iteration.id) + context 'with cadences', :js do + it 'does not show edit dropdown' do + visit group_iteration_cadence_iteration_path(iteration.group, iteration_cadence_id: cadence.id, id: iteration.id) - expect(page).to have_content(iteration.title) - expect(page).not_to have_selector(dropdown_selector) + expect(page).to have_content(iteration.title) + expect(page).not_to have_selector(dropdown_selector) + end + + it 'redirects to cadence list page when loading edit page directly' do + visit edit_group_iteration_cadence_iteration_path(iteration.group, iteration_cadence_id: cadence.id, id: iteration.id) + + expect(page).to have_content(cadence.title) + expect(page).to have_current_path("#{group_iteration_cadences_path(group)}/") + end end - it '404s when loading edit page directly' do - visit edit_group_iteration_path(iteration.group, iteration.id) + context 'without cadences' do + it 'does not show edit dropdown', :js do + visit group_iteration_path(iteration.group, iteration.id) - expect(page).to have_gitlab_http_status(:not_found) + expect(page).to have_content(iteration.title) + expect(page).not_to have_selector(dropdown_selector) + end + + it '404s when loading edit page directly' do + visit edit_group_iteration_path(iteration.group, iteration.id) + + expect(page).to have_gitlab_http_status(:not_found) + end end end diff --git a/ee/spec/frontend/iterations/components/iteration_form_spec.js b/ee/spec/frontend/iterations/components/iteration_form_spec.js new file mode 100644 index 00000000000..aecd6f7df51 --- /dev/null +++ b/ee/spec/frontend/iterations/components/iteration_form_spec.js @@ -0,0 +1,244 @@ +import { mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import IterationForm from 'ee/iterations/components/iteration_form.vue'; +import readIteration from 'ee/iterations/queries/iteration.query.graphql'; +import createIteration from 'ee/iterations/queries/iteration_create.mutation.graphql'; +import updateIteration from 'ee/iterations/queries/update_iteration.mutation.graphql'; +import createRouter from 'ee/iterations/router'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; + +const baseUrl = '/cadences/'; + +function createMockApolloProvider(requestHandlers) { + Vue.use(VueApollo); + + return createMockApollo(requestHandlers); +} + +describe('Iteration Form', () => { + let wrapper; + let router; + const groupPath = 'gitlab-org'; + const iterationId = 72; + const cadenceId = 2; + const iteration = { + id: `gid://gitlab/Iteration/${iterationId}`, + iid: 70, + title: 'An iteration', + state: 'opened', + webPath: '/test', + description: 'The words', + descriptionHtml: '<p>The words</p>', + startDate: '2020-06-28', + dueDate: '2020-07-05', + }; + + const readMutationSuccess = { + data: { group: { iterations: { nodes: [iteration] }, errors: [] } }, + }; + const createMutationSuccess = { data: { iterationCreate: { iteration, errors: [] } } }; + const createMutationFailure = { + data: { iterationCreate: { iteration, errors: ['alas, your data is unchanged'] } }, + }; + const updateMutationSuccess = { data: { updateIteration: { iteration, errors: [] } } }; + + function createComponent({ + mutationQuery = createIteration, + mutationResult = createMutationSuccess, + query = readIteration, + result = readMutationSuccess, + resolverMock = jest.fn().mockResolvedValue(mutationResult), + } = {}) { + const apolloProvider = createMockApolloProvider([ + [query, jest.fn().mockResolvedValue(result)], + [mutationQuery, resolverMock], + ]); + wrapper = extendedWrapper( + mount(IterationForm, { + provide: { + fullPath: groupPath, + previewMarkdownPath: '', + }, + apolloProvider, + router, + }), + ); + } + + beforeEach(() => { + router = createRouter({ + base: baseUrl, + permissions: { canCreateIteration: true, canEditIteration: true }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findPageTitle = () => wrapper.find({ ref: 'pageTitle' }); + const findTitle = () => wrapper.find('#iteration-title'); + const findDescription = () => wrapper.find('#iteration-description'); + const findStartDate = () => wrapper.find('#iteration-start-date'); + const findDueDate = () => wrapper.find('#iteration-due-date'); + const findSaveButton = () => wrapper.findByTestId('save-iteration'); + const findCancelButton = () => wrapper.findByTestId('cancel-iteration'); + const clickSave = () => findSaveButton().trigger('click'); + + describe('New iteration', () => { + const resolverMock = jest.fn().mockResolvedValue(createMutationSuccess); + + beforeEach(() => { + router.replace({ name: 'newIteration', params: { cadenceId, iterationId: undefined } }); + createComponent({ resolverMock }); + }); + + afterEach(() => { + router.replace({ name: 'index' }); + }); + + it('cancel button links to list page', () => { + expect(findCancelButton().attributes('href')).toBe(baseUrl); + }); + + describe('save', () => { + it('triggers mutation with form data', async () => { + const title = 'Iteration 5'; + const description = 'The fifth iteration'; + const startDate = '2020-05-05'; + const dueDate = '2020-05-25'; + + findTitle().vm.$emit('input', title); + findDescription().setValue(description); + findStartDate().vm.$emit('input', startDate); + findDueDate().vm.$emit('input', dueDate); + + await clickSave(); + + expect(resolverMock).toHaveBeenCalledWith({ + groupPath, + title, + description, + iterationsCadenceId: convertToGraphQLId('Iterations::Cadence', cadenceId), + startDate, + dueDate, + }); + }); + + it('redirects to Iteration page on success', async () => { + createComponent(); + + await clickSave(); + + expect(findSaveButton().props('loading')).toBe(true); + + await waitForPromises(); + + expect(router.currentRoute.name).toBe('iteration'); + expect(router.currentRoute.params).toEqual({ + cadenceId, + iterationId, + }); + }); + + it('loading=false on error', () => { + createComponent({ mutationResult: createMutationFailure }); + + clickSave(); + + return waitForPromises().then(() => { + expect(findSaveButton().props('loading')).toBe(false); + }); + }); + }); + }); + + describe('Edit iteration', () => { + beforeEach(() => { + router.replace({ name: 'editIteration', params: { cadenceId, iterationId } }); + }); + + afterEach(() => { + router.replace({ name: 'index' }); + }); + + it('shows update text title', () => { + createComponent(); + + expect(findPageTitle().text()).toBe('Edit iteration'); + }); + + it('prefills form fields', async () => { + createComponent(); + + await waitForPromises(); + + expect(findTitle().element.value).toBe(iteration.title); + expect(findDescription().element.value).toBe(iteration.description); + expect(findStartDate().element.value).toBe(iteration.startDate); + expect(findDueDate().element.value).toBe(iteration.dueDate); + }); + + it('shows update text on submit button', () => { + createComponent(); + + expect(findSaveButton().text()).toBe('Update iteration'); + }); + + it('triggers mutation with form data', () => { + const resolverMock = jest.fn().mockResolvedValue(updateMutationSuccess); + createComponent({ mutationQuery: updateIteration, resolverMock }); + + const title = 'Updated title'; + const description = 'Updated description'; + const startDate = '2020-05-06'; + const dueDate = '2020-05-26'; + + findTitle().vm.$emit('input', title); + findDescription().setValue(description); + findStartDate().vm.$emit('input', startDate); + findDueDate().vm.$emit('input', dueDate); + + clickSave(); + + expect(resolverMock).toHaveBeenCalledWith({ + input: { + groupPath, + id: iterationId, + title, + description, + startDate, + dueDate, + }, + }); + }); + + it('calls update mutation', async () => { + const resolverMock = jest.fn().mockResolvedValue(updateMutationSuccess); + createComponent({ + mutationQuery: updateIteration, + resolverMock, + }); + + clickSave(); + + await nextTick(); + + expect(findSaveButton().props('loading')).toBe(true); + expect(resolverMock).toHaveBeenCalledWith({ + input: { + groupPath, + id: iterationId, + startDate: '', + dueDate: '', + title: '', + description: '', + }, + }); + }); + }); +}); diff --git a/ee/spec/routing/groups/cadences_routing_spec.rb b/ee/spec/routing/groups/cadences_routing_spec.rb new file mode 100644 index 00000000000..9f99fc7221b --- /dev/null +++ b/ee/spec/routing/groups/cadences_routing_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'cadences routing' do + let_it_be(:group_path) { 'group.abc123' } + let_it_be(:group) { create(:group, path: group_path) } + + let(:cadence) do + create(:iterations_cadence, group: group, title: 'test cadence') + end + + it "routes to show cadences list" do + expect(get("/groups/#{group_path}/-/cadences")).to route_to('groups/iteration_cadences#index', group_id: group_path) + end + + it "routes to new cadence" do + expect(get("/groups/#{group_path}/-/cadences/new")).to route_to('groups/iteration_cadences#index', vueroute: "new", group_id: group_path) + end + + it "routes to edit cadence" do + expect(get("/groups/#{group_path}/-/cadences/1/edit")).to route_to('groups/iteration_cadences#index', group_id: group_path, vueroute: "1/edit") + end + + it "routes to list iterations within cadence" do + expect(get("/groups/#{group_path}/-/cadences/1/iterations")).to route_to('groups/iteration_cadences#index', group_id: group_path, iteration_cadence_id: "1") + end + + it "routes to show iteration within cadence" do + expect(get("/groups/#{group_path}/-/cadences/1/iterations/2")).to route_to('groups/iteration_cadences#index', group_id: group_path, iteration_cadence_id: "1", id: "2") + end + + it "routes to edit iteration within cadence" do + expect(get("/groups/#{group_path}/-/cadences/1/iterations/2/edit")).to route_to('groups/iteration_cadences#index', group_id: group_path, iteration_cadence_id: "1", id: "2") + end + + it "routes to new iteration within cadence" do + expect(get("/groups/#{group_path}/-/cadences/1/iterations/new")).to route_to('groups/iteration_cadences#index', group_id: group_path, iteration_cadence_id: "1") + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ec5e21c2739..3e1e152fcc8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -11710,9 +11710,6 @@ msgstr "" msgid "Edit iteration" msgstr "" -msgid "Edit iteration cadence" -msgstr "" - msgid "Edit public deploy key" msgstr "" @@ -18199,6 +18196,9 @@ msgstr "" msgid "Iterations" msgstr "" +msgid "Iterations|Add iteration" +msgstr "" + msgid "Iterations|Automated scheduling" msgstr "" @@ -18280,6 +18280,9 @@ msgstr "" msgid "Iterations|Title" msgstr "" +msgid "Iterations|Unable to find iteration." +msgstr "" + msgid "Iteration|Dates cannot overlap with other existing Iterations within this group" msgstr "" @@ -21781,9 +21784,6 @@ msgstr "" msgid "New iteration" msgstr "" -msgid "New iteration cadence" -msgstr "" - msgid "New iteration created" msgstr "" -- 2.30.9