diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content.vue
index 9c3b85634c7f629b3e10d50a1ec43acc0f75ff3d..18e7857b85bd968ab43d80c198f099e47f35b2d2 100644
--- a/ee/app/assets/javascripts/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content.vue
+++ b/ee/app/assets/javascripts/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content.vue
@@ -1,12 +1,63 @@
 <script>
-import { PRESET_OPTIONS_DEFAULT } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
+import { GlAlert, GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui';
+import { cloneDeep, uniqueId } from 'lodash';
+import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
+import { mapState, mapActions } from 'vuex';
+import { filterStagesByHiddenStatus } from '~/analytics/cycle_analytics/utils';
+import { swapArrayItems } from '~/lib/utils/array_utility';
+import { sprintf } from '~/locale';
+import Tracking from '~/tracking';
+import { visitUrl } from '~/lib/utils/url_utility';
+import {
+  STAGE_SORT_DIRECTION,
+  i18n,
+  defaultCustomStageFields,
+  PRESET_OPTIONS,
+  PRESET_OPTIONS_DEFAULT,
+} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
+import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue';
+import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
+import {
+  validateValueStreamName,
+  cleanStageName,
+  validateStage,
+  formatStageDataForSubmission,
+  hasDirtyStage,
+} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
 import ValueStreamFormContentHeader from './value_stream_form_content_header.vue';
 
+const initializeStageErrors = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) =>
+  selectedPreset === PRESET_OPTIONS_DEFAULT ? defaultStageConfig.map(() => ({})) : [{}];
+
+const initializeStages = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) => {
+  const stages =
+    selectedPreset === PRESET_OPTIONS_DEFAULT
+      ? defaultStageConfig
+      : [{ ...defaultCustomStageFields }];
+  return stages.map((stage) => ({ ...stage, transitionKey: uniqueId('stage-') }));
+};
+
+const initializeEditingStages = (stages = []) =>
+  filterStagesByHiddenStatus(cloneDeep(stages), false).map((stage) => ({
+    ...stage,
+    transitionKey: uniqueId(`stage-${stage.name}-`),
+  }));
+
 export default {
   name: 'ValueStreamFormContent',
   components: {
+    GlAlert,
+    GlButton,
+    GlForm,
+    GlFormInput,
+    GlFormGroup,
+    GlFormRadioGroup,
+    DefaultStageFields,
+    CustomStageFields,
     ValueStreamFormContentHeader,
   },
+  mixins: [Tracking.mixin()],
   props: {
     initialData: {
       type: Object,
@@ -38,13 +89,361 @@ export default {
       default: null,
     },
   },
+  data() {
+    const {
+      defaultStageConfig = [],
+      initialData: { name: initialName, stages: initialStages = [] },
+      initialFormErrors,
+      initialPreset,
+    } = this;
+    const { name: nameErrors = [], stages: stageErrors = [{}] } = initialFormErrors;
+    const additionalFields = {
+      stages: this.isEditing
+        ? initializeEditingStages(initialStages)
+        : initializeStages(defaultStageConfig, initialPreset),
+      stageErrors:
+        cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset),
+    };
+
+    return {
+      hiddenStages: filterStagesByHiddenStatus(initialStages),
+      selectedPreset: initialPreset,
+      presetOptions: PRESET_OPTIONS,
+      name: initialName,
+      nameErrors,
+      stageErrors,
+      showSubmitError: false,
+      isRedirecting: false,
+      ...additionalFields,
+    };
+  },
+  computed: {
+    ...mapState({
+      isCreating: 'isCreatingValueStream',
+      isSaving: 'isEditingValueStream',
+      isFetchingGroupLabels: 'isFetchingGroupLabels',
+      formEvents: 'formEvents',
+      defaultGroupLabels: 'defaultGroupLabels',
+    }),
+    isValueStreamNameValid() {
+      return !this.nameErrors?.length;
+    },
+    invalidNameFeedback() {
+      return this.nameErrors?.length ? this.nameErrors.join('\n\n') : null;
+    },
+    hasInitialFormErrors() {
+      const { initialFormErrors } = this;
+      return Boolean(Object.keys(initialFormErrors).length);
+    },
+    isSubmitting() {
+      return this.isCreating || this.isSaving;
+    },
+    hasFormErrors() {
+      return Boolean(
+        this.nameErrors.length || this.stageErrors.some((obj) => Object.keys(obj).length),
+      );
+    },
+    isDirtyEditing() {
+      return (
+        this.isEditing &&
+        (this.hasDirtyName(this.name, this.initialData.name) ||
+          hasDirtyStage(this.stages, this.initialData.stages))
+      );
+    },
+    canRestore() {
+      return this.hiddenStages.length || this.isDirtyEditing;
+    },
+    currentValueStreamStageNames() {
+      return this.stages.map(({ name }) => cleanStageName(name));
+    },
+  },
+  methods: {
+    ...mapActions(['createValueStream', 'updateValueStream']),
+    onSubmit() {
+      this.showSubmitError = false;
+      this.validate();
+      if (this.hasFormErrors) return false;
+
+      let req = this.createValueStream;
+      let params = {
+        name: this.name,
+        stages: formatStageDataForSubmission(this.stages, this.isEditing),
+      };
+      if (this.isEditing) {
+        req = this.updateValueStream;
+        params = {
+          ...params,
+          id: this.initialData.id,
+        };
+      }
+
+      return req(params).then(() => {
+        if (this.hasInitialFormErrors) {
+          const { name: nameErrors = [], stages: stageErrors = [{}] } = this.initialFormErrors;
+
+          this.isRedirecting = false;
+          this.nameErrors = nameErrors;
+          this.stageErrors = stageErrors;
+          this.showSubmitError = true;
+
+          return;
+        }
+
+        const msg = this.isEditing
+          ? this.$options.i18n.FORM_EDITED
+          : this.$options.i18n.FORM_CREATED;
+        this.$toast.show(sprintf(msg, { name: this.name }));
+        this.nameErrors = [];
+        this.stageErrors = initializeStageErrors(this.defaultStageConfig, this.selectedPreset);
+        this.track('submit_form', {
+          label: this.isEditing ? 'edit_value_stream' : 'create_value_stream',
+        });
+
+        if (!this.isEditing && this.valueStreamPath) {
+          this.isRedirecting = true;
+
+          visitUrl(this.valueStreamPath);
+        }
+      });
+    },
+    stageGroupLabel(index) {
+      return sprintf(this.$options.i18n.STAGE_INDEX, { index: index + 1 });
+    },
+    recoverStageTitle(name) {
+      return sprintf(this.$options.i18n.HIDDEN_DEFAULT_STAGE, { name });
+    },
+    hasDirtyName(current, original) {
+      return current.trim().toLowerCase() !== original.trim().toLowerCase();
+    },
+    validateStages() {
+      return this.stages.map((stage) => validateStage(stage, this.currentValueStreamStageNames));
+    },
+    validate() {
+      const { name } = this;
+      Vue.set(this, 'nameErrors', validateValueStreamName({ name }));
+      Vue.set(this, 'stageErrors', this.validateStages());
+    },
+    moveItem(arr, index, direction) {
+      return direction === STAGE_SORT_DIRECTION.UP
+        ? swapArrayItems(arr, index - 1, index)
+        : swapArrayItems(arr, index, index + 1);
+    },
+    handleMove({ index, direction }) {
+      const newStages = this.moveItem(this.stages, index, direction);
+      const newErrors = this.moveItem(this.stageErrors, index, direction);
+      Vue.set(this, 'stages', cloneDeep(newStages));
+      Vue.set(this, 'stageErrors', cloneDeep(newErrors));
+    },
+    validateStageFields(index) {
+      Vue.set(this.stageErrors, index, validateStage(this.stages[index]));
+    },
+    fieldErrors(index) {
+      return this.stageErrors && this.stageErrors[index] ? this.stageErrors[index] : {};
+    },
+    onHide(index) {
+      const target = this.stages[index];
+      Vue.set(this, 'stages', [...this.stages.filter((_, i) => i !== index)]);
+      Vue.set(this, 'hiddenStages', [...this.hiddenStages, target]);
+    },
+    onRemove(index) {
+      const newErrors = this.stageErrors.filter((_, idx) => idx !== index);
+      const newStages = this.stages.filter((_, idx) => idx !== index);
+      Vue.set(this, 'stages', [...newStages]);
+      Vue.set(this, 'stageErrors', [...newErrors]);
+    },
+    onRestore(hiddenStageIndex) {
+      const target = this.hiddenStages[hiddenStageIndex];
+      Vue.set(this, 'hiddenStages', [
+        ...this.hiddenStages.filter((_, i) => i !== hiddenStageIndex),
+      ]);
+      Vue.set(this, 'stages', [
+        ...this.stages,
+        { ...target, transitionKey: uniqueId(`stage-${target.name}-`) },
+      ]);
+    },
+    lastStage() {
+      const stages = this.$refs.formStages;
+      return stages[stages.length - 1];
+    },
+    async scrollToLastStage() {
+      await this.$nextTick();
+      // Scroll to the new stage we have added
+      this.lastStage().focus();
+      this.lastStage().scrollIntoView({ behavior: 'smooth' });
+    },
+    addNewStage() {
+      // validate previous stages only and add a new stage
+      this.validate();
+      Vue.set(this, 'stages', [
+        ...this.stages,
+        { ...defaultCustomStageFields, transitionKey: uniqueId('stage-') },
+      ]);
+      Vue.set(this, 'stageErrors', [...this.stageErrors, {}]);
+    },
+    onAddStage() {
+      this.addNewStage();
+      this.scrollToLastStage();
+    },
+    onFieldInput(activeStageIndex, { field, value }) {
+      const updatedStage = { ...this.stages[activeStageIndex], [field]: value };
+      Vue.set(this.stages, activeStageIndex, updatedStage);
+    },
+    resetAllFieldsToDefault() {
+      Vue.set(this, 'stages', initializeStages(this.defaultStageConfig, this.selectedPreset));
+      Vue.set(
+        this,
+        'stageErrors',
+        initializeStageErrors(this.defaultStageConfig, this.selectedPreset),
+      );
+    },
+    handleResetDefaults() {
+      if (this.isEditing) {
+        const {
+          initialData: { name: initialName, stages: initialStages },
+        } = this;
+        Vue.set(this, 'name', initialName);
+        Vue.set(this, 'nameErrors', []);
+        Vue.set(this, 'stages', initializeStages(initialStages));
+        Vue.set(this, 'stageErrors', [{}]);
+      } else {
+        this.resetAllFieldsToDefault();
+      }
+    },
+    onSelectPreset() {
+      if (this.selectedPreset === PRESET_OPTIONS_DEFAULT) {
+        this.handleResetDefaults();
+      } else {
+        this.resetAllFieldsToDefault();
+      }
+    },
+    restoreActionTestId(index) {
+      return `stage-action-restore-${index}`;
+    },
+  },
+  i18n,
 };
 </script>
 <template>
   <div>
+    <gl-alert
+      v-if="showSubmitError"
+      variant="danger"
+      class="gl-mb-3"
+      @dismiss="showSubmitError = false"
+    >
+      {{ $options.i18n.SUBMIT_FAILED }}
+    </gl-alert>
     <value-stream-form-content-header
+      class="gl-mb-6"
       :is-editing="isEditing"
+      :is-loading="isSubmitting || isRedirecting"
       :value-stream-path="valueStreamPath"
+      @clickedPrimaryAction="onSubmit"
     />
+    <gl-form>
+      <gl-form-group
+        data-testid="create-value-stream-name"
+        label-for="create-value-stream-name"
+        :label="$options.i18n.FORM_FIELD_NAME_LABEL"
+        :invalid-feedback="invalidNameFeedback"
+        :state="isValueStreamNameValid"
+      >
+        <div class="gl-display-flex gl-justify-content-space-between">
+          <gl-form-input
+            id="create-value-stream-name"
+            v-model.trim="name"
+            name="create-value-stream-name"
+            data-testid="create-value-stream-name-input"
+            :placeholder="$options.i18n.FORM_FIELD_NAME_PLACEHOLDER"
+            :state="isValueStreamNameValid"
+            required
+          />
+          <transition name="fade">
+            <gl-button
+              v-if="canRestore"
+              data-testid="vsa-reset-button"
+              class="gl-ml-3"
+              variant="link"
+              @click="handleResetDefaults"
+              >{{ $options.i18n.RESTORE_DEFAULTS }}</gl-button
+            >
+          </transition>
+        </div>
+      </gl-form-group>
+      <gl-form-radio-group
+        v-if="!isEditing"
+        v-model="selectedPreset"
+        class="gl-mb-4"
+        data-testid="vsa-preset-selector"
+        :options="presetOptions"
+        name="preset"
+        @input="onSelectPreset"
+      />
+      <div data-testid="extended-form-fields">
+        <transition-group name="stage-list" tag="div">
+          <div
+            v-for="(stage, activeStageIndex) in stages"
+            ref="formStages"
+            :key="stage.id || stage.transitionKey"
+          >
+            <hr class="gl-my-5" />
+            <custom-stage-fields
+              v-if="stage.custom"
+              :stage-label="stageGroupLabel(activeStageIndex)"
+              :stage="stage"
+              :stage-events="formEvents"
+              :index="activeStageIndex"
+              :total-stages="stages.length"
+              :errors="fieldErrors(activeStageIndex)"
+              :default-group-labels="defaultGroupLabels"
+              @move="handleMove"
+              @remove="onRemove"
+              @input="onFieldInput(activeStageIndex, $event)"
+            />
+            <default-stage-fields
+              v-else
+              :stage-label="stageGroupLabel(activeStageIndex)"
+              :stage="stage"
+              :stage-events="formEvents"
+              :index="activeStageIndex"
+              :total-stages="stages.length"
+              :errors="fieldErrors(activeStageIndex)"
+              @move="handleMove"
+              @hide="onHide"
+              @input="validateStageFields(activeStageIndex)"
+            />
+          </div>
+        </transition-group>
+        <div>
+          <hr class="gl-mt-2 gl-mb-5" />
+          <gl-button
+            data-testid="vsa-add-stage-button"
+            category="secondary"
+            variant="confirm"
+            icon="plus"
+            @click="onAddStage"
+            >{{ $options.i18n.BTN_ADD_ANOTHER_STAGE }}</gl-button
+          >
+        </div>
+        <div v-if="hiddenStages.length">
+          <hr />
+          <gl-form-group
+            v-for="(stage, hiddenStageIndex) in hiddenStages"
+            :key="stage.id"
+            data-testid="vsa-hidden-stage"
+          >
+            <span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
+              recoverStageTitle(stage.name)
+            }}</span>
+            <gl-button
+              variant="link"
+              :data-testid="restoreActionTestId(hiddenStageIndex)"
+              @click="onRestore(hiddenStageIndex)"
+              >{{ $options.i18n.RESTORE_HIDDEN_STAGE }}</gl-button
+            >
+          </gl-form-group>
+        </div>
+      </div>
+    </gl-form>
   </div>
 </template>
diff --git a/ee/spec/frontend/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content_spec.js b/ee/spec/frontend/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..bf6715682d3db8d9f4c9d10be1917fabf033942b
--- /dev/null
+++ b/ee/spec/frontend/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content_spec.js
@@ -0,0 +1,692 @@
+import { GlAlert, GlFormInput } from '@gitlab/ui';
+import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
+import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+  PRESET_OPTIONS_BLANK,
+  PRESET_OPTIONS_DEFAULT,
+} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
+import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue';
+import CustomStageEventField from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_event_field.vue';
+import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
+import ValueStreamFormContent from 'ee/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content.vue';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { visitUrl } from '~/lib/utils/url_utility';
+import {
+  convertObjectPropsToCamelCase,
+  convertObjectPropsToSnakeCase,
+} from '~/lib/utils/common_utils';
+import ValueStreamFormContentHeader from 'ee/analytics/cycle_analytics/vsa_settings/components/value_stream_form_content_header.vue';
+import {
+  customStageEvents as formEvents,
+  defaultStageConfig,
+  rawCustomStage,
+  groupLabels as defaultGroupLabels,
+  valueStreamPath,
+} from '../../mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+  ...jest.requireActual('~/lib/utils/url_utility'),
+  visitUrl: jest.fn(),
+}));
+
+const scrollIntoViewMock = jest.fn();
+HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
+
+Vue.use(Vuex);
+
+describe('ValueStreamFormContent', () => {
+  let wrapper = null;
+  let trackingSpy = null;
+
+  const createValueStreamMock = jest.fn(() => Promise.resolve());
+  const updateValueStreamMock = jest.fn(() => Promise.resolve());
+  const mockToastShow = jest.fn();
+  const streamName = 'Cool stream';
+  const initialFormNameErrors = { name: ['Name field required'] };
+  const initialFormStageErrors = {
+    stages: [
+      {
+        name: ['Name field is required'],
+        endEventIdentifier: ['Please select a start event first'],
+      },
+    ],
+  };
+  const formSubmissionErrors = {
+    name: ['has already been taken'],
+    stages: [
+      {
+        name: ['has already been taken'],
+      },
+    ],
+  };
+
+  const initialData = {
+    stages: [convertObjectPropsToCamelCase(rawCustomStage)],
+    id: 1337,
+    name: 'Editable value stream',
+  };
+
+  const initialPreset = PRESET_OPTIONS_DEFAULT;
+
+  const fakeStore = ({ state }) =>
+    new Vuex.Store({
+      state: {
+        isCreatingValueStream: false,
+        isEditingValueStream: false,
+        formEvents,
+        defaultGroupLabels,
+        ...state,
+      },
+      actions: {
+        createValueStream: createValueStreamMock,
+        updateValueStream: updateValueStreamMock,
+      },
+    });
+
+  const createComponent = ({ props = {}, data = {}, stubs = {}, state = {} } = {}) =>
+    shallowMountExtended(ValueStreamFormContent, {
+      store: fakeStore({ state }),
+      data() {
+        return {
+          ...data,
+        };
+      },
+      propsData: {
+        defaultStageConfig,
+        valueStreamPath,
+        ...props,
+      },
+      mocks: {
+        $toast: {
+          show: mockToastShow,
+        },
+      },
+      stubs: {
+        ...stubs,
+      },
+    });
+
+  const findFormHeader = () => wrapper.findComponent(ValueStreamFormContentHeader);
+  const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
+  const findDefaultStages = () => findExtendedFormFields().findAllComponents(DefaultStageFields);
+  const findCustomStages = () => findExtendedFormFields().findAllComponents(CustomStageFields);
+  const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector');
+  const findRestoreButton = () => wrapper.findByTestId('vsa-reset-button');
+  const findRestoreStageButton = (index) => wrapper.findByTestId(`stage-action-restore-${index}`);
+  const findHiddenStages = () => wrapper.findAllByTestId('vsa-hidden-stage').wrappers;
+  const findAddStageButton = () => wrapper.findByTestId('vsa-add-stage-button');
+  const findCustomStageEventField = (index = 0) =>
+    wrapper.findAllComponents(CustomStageEventField).at(index);
+  const findFieldErrors = (testId) => wrapper.findByTestId(testId).attributes('invalid-feedback');
+  const findNameInput = () =>
+    wrapper.findByTestId('create-value-stream-name').findComponent(GlFormInput);
+  const findSubmitErrorAlert = () => wrapper.findComponent(GlAlert);
+
+  const fillStageNameAtIndex = (name, index) =>
+    findCustomStages().at(index).findComponent(GlFormInput).vm.$emit('input', name);
+
+  const clickSubmit = () => findFormHeader().vm.$emit('clickedPrimaryAction');
+  const clickAddStage = () => findAddStageButton().vm.$emit('click');
+  const clickRestoreStageAtIndex = (index) => findRestoreStageButton(index).vm.$emit('click');
+  const expectFieldError = (testId, error = '') => expect(findFieldErrors(testId)).toBe(error);
+  const expectCustomFieldError = (index, attr, error = '') =>
+    expect(findCustomStageEventField(index).attributes(attr)).toBe(error);
+  const expectStageTransitionKeys = (stages) =>
+    stages.forEach((stage) => expect(stage.transitionKey).toContain('stage-'));
+
+  describe('default state', () => {
+    beforeEach(() => {
+      wrapper = createComponent({ state: { defaultGroupLabels: null } });
+    });
+
+    it('has the form header', () => {
+      expect(findFormHeader().props()).toMatchObject({
+        isLoading: false,
+        isEditing: false,
+        valueStreamPath,
+      });
+    });
+
+    it('has the extended fields', () => {
+      expect(findExtendedFormFields().exists()).toBe(true);
+    });
+
+    describe('Preset selector', () => {
+      it('has the preset button', () => {
+        expect(findPresetSelector().exists()).toBe(true);
+      });
+
+      it('will toggle between the blank and default templates', async () => {
+        expect(findDefaultStages()).toHaveLength(defaultStageConfig.length);
+        expect(findCustomStages()).toHaveLength(0);
+
+        await findPresetSelector().vm.$emit('input', PRESET_OPTIONS_BLANK);
+
+        expect(findDefaultStages()).toHaveLength(0);
+        expect(findCustomStages()).toHaveLength(1);
+
+        await findPresetSelector().vm.$emit('input', PRESET_OPTIONS_DEFAULT);
+
+        expect(findDefaultStages()).toHaveLength(defaultStageConfig.length);
+        expect(findCustomStages()).toHaveLength(0);
+      });
+
+      it('does not clear name when toggling templates', async () => {
+        await findNameInput().vm.$emit('input', initialData.name);
+
+        expect(findNameInput().attributes('value')).toBe(initialData.name);
+
+        await findPresetSelector().vm.$emit('input', PRESET_OPTIONS_BLANK);
+
+        expect(findNameInput().attributes('value')).toBe(initialData.name);
+
+        await findPresetSelector().vm.$emit('input', PRESET_OPTIONS_DEFAULT);
+
+        expect(findNameInput().attributes('value')).toBe(initialData.name);
+      });
+
+      it('each stage has a transition key when toggling', async () => {
+        await findPresetSelector().vm.$emit('input', PRESET_OPTIONS_BLANK);
+
+        expectStageTransitionKeys(wrapper.vm.stages);
+
+        await findPresetSelector().vm.$emit('input', PRESET_OPTIONS_DEFAULT);
+
+        expectStageTransitionKeys(wrapper.vm.stages);
+      });
+
+      it('does not display any hidden stages', () => {
+        expect(findHiddenStages()).toHaveLength(0);
+      });
+    });
+
+    describe('Add stage button', () => {
+      beforeEach(() => {
+        wrapper = createComponent({
+          stubs: {
+            CustomStageFields,
+          },
+        });
+      });
+
+      it('has the add stage button', () => {
+        expect(findAddStageButton().exists()).toBe(true);
+      });
+
+      it('adds a blank custom stage when clicked', async () => {
+        expect(findDefaultStages()).toHaveLength(defaultStageConfig.length);
+        expect(findCustomStages()).toHaveLength(0);
+
+        await clickAddStage();
+
+        expect(findDefaultStages()).toHaveLength(defaultStageConfig.length);
+        expect(findCustomStages()).toHaveLength(1);
+      });
+
+      it('each stage has a transition key', () => {
+        expectStageTransitionKeys(wrapper.vm.stages);
+      });
+    });
+
+    describe('field validation', () => {
+      beforeEach(() => {
+        wrapper = createComponent({
+          stubs: {
+            CustomStageFields,
+          },
+        });
+      });
+
+      it('validates existing fields when clicked', async () => {
+        const fieldTestId = 'create-value-stream-name';
+        expect(findFieldErrors(fieldTestId)).toBeUndefined();
+
+        await clickAddStage();
+
+        expectFieldError(fieldTestId, 'Name is required');
+      });
+
+      it('does not allow duplicate stage names', async () => {
+        const [firstDefaultStage] = defaultStageConfig;
+        await findNameInput().vm.$emit('input', streamName);
+
+        await clickAddStage();
+        await fillStageNameAtIndex(firstDefaultStage.name, 0);
+
+        // Trigger the field validation
+        await clickAddStage();
+
+        expectFieldError('custom-stage-name-3', 'Stage name already exists');
+      });
+    });
+
+    describe('initial form stage errors', () => {
+      const commonExtendedData = {
+        props: {
+          initialFormErrors: initialFormStageErrors,
+        },
+      };
+
+      it('renders errors for a default stage field', () => {
+        wrapper = createComponent({
+          ...commonExtendedData,
+          stubs: {
+            DefaultStageFields,
+          },
+        });
+
+        expectFieldError('default-stage-name-0', initialFormStageErrors.stages[0].name[0]);
+      });
+
+      it('renders errors for a custom stage field', () => {
+        wrapper = createComponent({
+          props: {
+            ...commonExtendedData.props,
+            initialPreset: PRESET_OPTIONS_BLANK,
+          },
+          stubs: {
+            CustomStageFields,
+          },
+        });
+
+        expectFieldError('custom-stage-name-0', initialFormStageErrors.stages[0].name[0]);
+        expectCustomFieldError(
+          1,
+          'identifiererror',
+          initialFormStageErrors.stages[0].endEventIdentifier[0],
+        );
+      });
+    });
+
+    describe('initial form name errors', () => {
+      beforeEach(() => {
+        wrapper = createComponent({
+          props: {
+            initialFormErrors: initialFormNameErrors,
+          },
+        });
+      });
+
+      it('renders errors for the name field', () => {
+        expectFieldError('create-value-stream-name', initialFormNameErrors.name[0]);
+      });
+    });
+
+    describe('with valid fields', () => {
+      beforeEach(() => {
+        trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+      });
+
+      afterEach(() => {
+        unmockTracking();
+      });
+
+      describe('form submitting', () => {
+        beforeEach(() => {
+          wrapper = createComponent({
+            state: {
+              isCreatingValueStream: true,
+            },
+          });
+        });
+
+        it("enables form header's loading state", () => {
+          expect(findFormHeader().props('isLoading')).toBe(true);
+        });
+      });
+
+      describe('form submitted successfully', () => {
+        beforeEach(async () => {
+          wrapper = createComponent();
+
+          await findNameInput().vm.$emit('input', streamName);
+          clickSubmit();
+        });
+
+        it('calls the "createValueStream" event when submitted', () => {
+          expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
+            name: streamName,
+            stages: [
+              {
+                custom: false,
+                name: 'issue',
+              },
+              {
+                custom: false,
+                name: 'plan',
+              },
+              {
+                custom: false,
+                name: 'code',
+              },
+            ],
+          });
+        });
+
+        it('displays a toast message', () => {
+          expect(mockToastShow).toHaveBeenCalledWith(`'${streamName}' Value Stream created`);
+        });
+
+        it('sends tracking information', () => {
+          expect(trackingSpy).toHaveBeenCalledWith(undefined, 'submit_form', {
+            label: 'create_value_stream',
+          });
+        });
+
+        it('form header should be in loading state', () => {
+          expect(findFormHeader().props('isLoading')).toBe(true);
+        });
+
+        it('redirects to the new value stream page', () => {
+          expect(visitUrl).toHaveBeenCalledWith(valueStreamPath);
+        });
+      });
+
+      describe('form submission fails', () => {
+        beforeEach(async () => {
+          wrapper = createComponent({
+            props: {
+              initialFormErrors: formSubmissionErrors,
+            },
+            stubs: {
+              CustomStageFields,
+            },
+          });
+
+          await findNameInput().vm.$emit('input', streamName);
+          clickSubmit();
+        });
+
+        it('calls the createValueStream action', () => {
+          expect(createValueStreamMock).toHaveBeenCalled();
+        });
+
+        it('does not clear the name field', () => {
+          expect(findNameInput().attributes('value')).toBe(streamName);
+        });
+
+        it('does not display a toast message', () => {
+          expect(mockToastShow).not.toHaveBeenCalled();
+        });
+
+        it('does not redirect to the new value stream page', () => {
+          expect(visitUrl).not.toHaveBeenCalled();
+        });
+
+        it('form header should not be in loading state', () => {
+          expect(findFormHeader().props('isLoading')).toBe(false);
+        });
+
+        it('renders errors for the name field', () => {
+          expectFieldError('create-value-stream-name', formSubmissionErrors.name[0]);
+        });
+
+        it('renders a dismissible generic alert error', async () => {
+          expect(findSubmitErrorAlert().exists()).toBe(true);
+          await findSubmitErrorAlert().vm.$emit('dismiss');
+          expect(findSubmitErrorAlert().exists()).toBe(false);
+        });
+      });
+    });
+  });
+
+  describe('isEditing=true', () => {
+    const stageCount = initialData.stages.length;
+    beforeEach(() => {
+      wrapper = createComponent({
+        props: {
+          initialPreset,
+          initialData,
+          isEditing: true,
+        },
+      });
+    });
+
+    it('does not have the preset button', () => {
+      expect(findPresetSelector().exists()).toBe(false);
+    });
+
+    it("enables form header's editing state", () => {
+      expect(findFormHeader().props('isEditing')).toBe(true);
+    });
+
+    it('does not display any hidden stages', () => {
+      expect(findHiddenStages()).toHaveLength(0);
+    });
+
+    it('each stage has a transition key', () => {
+      expectStageTransitionKeys(wrapper.vm.stages);
+    });
+
+    describe('restore defaults button', () => {
+      it('restores the original name', async () => {
+        const newName = 'name';
+
+        await findNameInput().vm.$emit('input', newName);
+
+        expect(findNameInput().attributes('value')).toBe(newName);
+
+        await findRestoreButton().vm.$emit('click');
+
+        expect(findNameInput().attributes('value')).toBe(initialData.name);
+      });
+
+      it('will clear the form fields', async () => {
+        expect(findCustomStages()).toHaveLength(stageCount);
+
+        await clickAddStage();
+
+        expect(findCustomStages()).toHaveLength(stageCount + 1);
+
+        await findRestoreButton().vm.$emit('click');
+
+        expect(findCustomStages()).toHaveLength(stageCount);
+      });
+    });
+
+    describe('with hidden stages', () => {
+      const hiddenStages = defaultStageConfig.map((s) => ({ ...s, hidden: true }));
+
+      beforeEach(() => {
+        wrapper = createComponent({
+          props: {
+            initialPreset,
+            initialData: { ...initialData, stages: [...initialData.stages, ...hiddenStages] },
+            isEditing: true,
+          },
+        });
+      });
+
+      it('displays hidden each stage', () => {
+        expect(findHiddenStages()).toHaveLength(hiddenStages.length);
+
+        findHiddenStages().forEach((s) => {
+          expect(s.text()).toContain('Restore stage');
+        });
+      });
+
+      it('when `Restore stage` is clicked, the stage is restored', async () => {
+        expect(findHiddenStages()).toHaveLength(hiddenStages.length);
+        expect(findDefaultStages()).toHaveLength(0);
+        expect(findCustomStages()).toHaveLength(stageCount);
+
+        await clickRestoreStageAtIndex(1);
+
+        expect(findHiddenStages()).toHaveLength(hiddenStages.length - 1);
+        expect(findDefaultStages()).toHaveLength(1);
+        expect(findCustomStages()).toHaveLength(stageCount);
+      });
+
+      it('when a stage is restored it has a transition key', async () => {
+        await clickRestoreStageAtIndex(1);
+
+        expect(wrapper.vm.stages[stageCount].transitionKey).toContain(
+          `stage-${hiddenStages[1].name}-`,
+        );
+      });
+    });
+
+    describe('Add stage button', () => {
+      beforeEach(() => {
+        wrapper = createComponent({
+          props: {
+            initialPreset,
+            initialData,
+            isEditing: true,
+          },
+          stubs: {
+            CustomStageFields,
+          },
+        });
+      });
+
+      it('has the add stage button', () => {
+        expect(findAddStageButton().exists()).toBe(true);
+      });
+
+      it('adds a blank custom stage when clicked', async () => {
+        expect(findCustomStages()).toHaveLength(stageCount);
+
+        await clickAddStage();
+
+        expect(findCustomStages()).toHaveLength(stageCount + 1);
+      });
+
+      it('validates existing fields when clicked', async () => {
+        const fieldTestId = 'create-value-stream-name';
+        expect(findFieldErrors(fieldTestId)).toBeUndefined();
+
+        await findNameInput().vm.$emit('input', '');
+        await clickAddStage();
+
+        expectFieldError(fieldTestId, 'Name is required');
+      });
+    });
+
+    describe('with valid fields', () => {
+      beforeEach(() => {
+        trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+      });
+
+      afterEach(() => {
+        unmockTracking();
+      });
+
+      describe('form submitting', () => {
+        beforeEach(() => {
+          wrapper = createComponent({
+            props: {
+              initialPreset,
+              initialData,
+              isEditing: true,
+            },
+            state: {
+              isEditingValueStream: true,
+            },
+          });
+        });
+
+        it("enables form header's loading state", () => {
+          expect(findFormHeader().props('isLoading')).toBe(true);
+        });
+      });
+
+      describe('form submitted successfully', () => {
+        beforeEach(() => {
+          wrapper = createComponent({
+            props: {
+              initialPreset,
+              initialData,
+              isEditing: true,
+            },
+          });
+
+          clickSubmit();
+        });
+
+        it('calls the "updateValueStreamMock" event when submitted', () => {
+          expect(updateValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
+            ...initialData,
+            stages: initialData.stages.map((stage) =>
+              convertObjectPropsToSnakeCase(stage, { deep: true }),
+            ),
+          });
+        });
+
+        it('displays a toast message', () => {
+          expect(mockToastShow).toHaveBeenCalledWith(`'${initialData.name}' Value Stream saved`);
+        });
+
+        it('sends tracking information', () => {
+          expect(trackingSpy).toHaveBeenCalledWith(undefined, 'submit_form', {
+            label: 'edit_value_stream',
+          });
+        });
+
+        it('does not redirect to the value stream page', () => {
+          expect(visitUrl).not.toHaveBeenCalled();
+        });
+
+        it('form header should not be in loading state', () => {
+          expect(findFormHeader().props('isLoading')).toBe(false);
+        });
+      });
+
+      describe('form submission fails', () => {
+        beforeEach(() => {
+          wrapper = createComponent({
+            props: {
+              initialFormErrors: formSubmissionErrors,
+              initialData,
+              initialPreset,
+              isEditing: true,
+            },
+            stubs: {
+              CustomStageFields,
+            },
+          });
+
+          clickSubmit();
+        });
+
+        it('calls the updateValueStreamMock action', () => {
+          expect(updateValueStreamMock).toHaveBeenCalled();
+        });
+
+        it('does not clear the name field', () => {
+          const { name } = initialData;
+
+          expect(findNameInput().attributes('value')).toBe(name);
+        });
+
+        it('does not display a toast message', () => {
+          expect(mockToastShow).not.toHaveBeenCalled();
+        });
+
+        it('does not redirect to the value stream page', () => {
+          expect(visitUrl).not.toHaveBeenCalled();
+        });
+
+        it('form header should not be in loading state', () => {
+          expect(findFormHeader().props('isLoading')).toBe(false);
+        });
+
+        it('renders errors for the name field', () => {
+          expectFieldError('create-value-stream-name', formSubmissionErrors.name[0]);
+        });
+
+        it('renders errors for a custom stage field', () => {
+          expectFieldError('custom-stage-name-0', formSubmissionErrors.stages[0].name[0]);
+        });
+
+        it('renders a dismissible generic alert error', async () => {
+          expect(findSubmitErrorAlert().exists()).toBe(true);
+          await findSubmitErrorAlert().vm.$emit('dismiss');
+          expect(findSubmitErrorAlert().exists()).toBe(false);
+        });
+      });
+    });
+  });
+});