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