From 5bd323d4ffb9c2826db588f3473ea0155a19f211 Mon Sep 17 00:00:00 2001
From: Payton Burdette <pburdette@gitlab.com>
Date: Wed, 17 Jan 2024 19:38:53 +0000
Subject: [PATCH] Build add pipeline subscription form

---
 .../pipeline_subscriptions_form.vue           | 108 ++++++++++++++++
 .../pipeline_subscriptions_table.vue          |  21 +++-
 ...add_pipeline_subscription.mutation.graphql |   8 ++
 .../pipeline_subscriptions_app.vue            |   6 +-
 .../pipeline_subscriptions_form_spec.js       | 118 ++++++++++++++++++
 .../pipeline_subscriptions_table_spec.js      |  31 +++++
 .../ci/pipeline_subscriptions/mock_data.js    |  13 ++
 .../pipeline_subscriptions_app_spec.js        |  12 ++
 locale/gitlab.pot                             |   9 ++
 9 files changed, 324 insertions(+), 2 deletions(-)
 create mode 100644 ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_form.vue
 create mode 100644 ee/app/assets/javascripts/ci/pipeline_subscriptions/graphql/mutations/add_pipeline_subscription.mutation.graphql
 create mode 100644 ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_form_spec.js

diff --git a/ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_form.vue b/ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_form.vue
new file mode 100644
index 0000000000000..36abe0bd98996
--- /dev/null
+++ b/ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_form.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlButton, GlForm, GlFormGroup, GlFormInput, GlIcon, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __, s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import AddPipelineSubscription from '../graphql/mutations/add_pipeline_subscription.mutation.graphql';
+
+export default {
+  name: 'PipelineSubscriptionsForm',
+  i18n: {
+    formLabel: __('Project path'),
+    inputPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'),
+    subscribe: __('Subscribe'),
+    cancel: __('Cancel'),
+    addSubscription: s__('PipelineSubscriptions|Add new pipeline subscription'),
+    generalError: s__(
+      'PipelineSubscriptions|An error occurred while adding a new pipeline subscription.',
+    ),
+    addSuccess: s__('PipelineSubscriptions|Subscription successfully added.'),
+  },
+  docsLink: helpPagePath('ci/pipelines/index', {
+    anchor: 'trigger-a-pipeline-when-an-upstream-project-is-rebuilt',
+  }),
+  components: {
+    GlButton,
+    GlForm,
+    GlFormGroup,
+    GlFormInput,
+    GlIcon,
+    GlLink,
+  },
+  inject: {
+    projectPath: {
+      default: '',
+    },
+  },
+  data() {
+    return {
+      upstreamPath: '',
+    };
+  },
+  methods: {
+    async createSubscription() {
+      try {
+        const { data } = await this.$apollo.mutate({
+          mutation: AddPipelineSubscription,
+          variables: {
+            input: {
+              projectPath: this.projectPath,
+              upstreamPath: this.upstreamPath,
+            },
+          },
+        });
+
+        if (data.projectSubscriptionCreate.errors.length > 0) {
+          createAlert({ message: data.projectSubscriptionCreate.errors[0] });
+        } else {
+          createAlert({ message: this.$options.i18n.addSuccess, variant: 'success' });
+          this.upstreamPath = '';
+
+          this.$emit('addSubscriptionSuccess');
+        }
+      } catch (error) {
+        const { graphQLErrors } = error;
+
+        if (graphQLErrors.length > 0) {
+          createAlert({ message: graphQLErrors[0]?.message, variant: 'warning' });
+        } else {
+          createAlert({ message: this.$options.i18n.generalError });
+        }
+      }
+    },
+    cancelSubscription() {
+      this.upstreamPath = '';
+      this.$emit('canceled');
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="gl-new-card-add-form gl-m-3">
+    <h4 class="gl-mt-0">{{ $options.i18n.addSubscription }}</h4>
+    <gl-form>
+      <gl-form-group label-for="project-path">
+        <template #label>
+          {{ $options.i18n.formLabel }}
+          <gl-link :href="$options.docsLink" target="_blank">
+            <gl-icon class="gl-text-blue-600" name="question-o" />
+          </gl-link>
+        </template>
+        <gl-form-input
+          id="project-path"
+          v-model="upstreamPath"
+          type="text"
+          :placeholder="$options.i18n.inputPlaceholder"
+        />
+      </gl-form-group>
+
+      <gl-button variant="confirm" data-testid="subscribe-button" @click="createSubscription">
+        {{ $options.i18n.subscribe }}
+      </gl-button>
+      <gl-button class="gl-ml-3" data-testid="cancel-button" @click="cancelSubscription">
+        {{ $options.i18n.cancel }}
+      </gl-button>
+    </gl-form>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_table.vue b/ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_table.vue
index 820a37326150a..135481aec013b 100644
--- a/ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_table.vue
+++ b/ee/app/assets/javascripts/ci/pipeline_subscriptions/components/pipeline_subscriptions_table.vue
@@ -1,6 +1,7 @@
 <script>
 import { GlButton, GlCard, GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui';
 import { __, s__ } from '~/locale';
+import PipelineSubscriptionsForm from './pipeline_subscriptions_form.vue';
 
 export default {
   name: 'PipelineSubscriptionsTable',
@@ -34,6 +35,7 @@ export default {
     GlIcon,
     GlLink,
     GlTable,
+    PipelineSubscriptionsForm,
   },
   directives: {
     GlTooltip: GlTooltipDirective,
@@ -61,6 +63,16 @@ export default {
       required: true,
     },
   },
+  data() {
+    return {
+      isAddNewClicked: false,
+    };
+  },
+  computed: {
+    showForm() {
+      return this.showActions && this.isAddNewClicked;
+    },
+  },
 };
 </script>
 
@@ -81,17 +93,24 @@ export default {
         </h3>
       </div>
       <div v-if="showActions" class="gl-new-card-actions">
-        <!-- functionality will be added in https://gitlab.com/gitlab-org/gitlab/-/issues/425293 -->
         <gl-button
+          v-if="!isAddNewClicked"
           size="small"
           data-testid="add-new-subscription-btn"
           data-qa-selector="add_new_subscription"
+          @click="isAddNewClicked = true"
         >
           {{ $options.i18n.newBtnText }}
         </gl-button>
       </div>
     </template>
 
+    <pipeline-subscriptions-form
+      v-if="showForm"
+      @canceled="isAddNewClicked = false"
+      @addSubscriptionSuccess="$emit('refetchSubscriptions')"
+    />
+
     <gl-table
       :fields="$options.fields"
       :items="subscriptions"
diff --git a/ee/app/assets/javascripts/ci/pipeline_subscriptions/graphql/mutations/add_pipeline_subscription.mutation.graphql b/ee/app/assets/javascripts/ci/pipeline_subscriptions/graphql/mutations/add_pipeline_subscription.mutation.graphql
new file mode 100644
index 0000000000000..9637d9c73c83a
--- /dev/null
+++ b/ee/app/assets/javascripts/ci/pipeline_subscriptions/graphql/mutations/add_pipeline_subscription.mutation.graphql
@@ -0,0 +1,8 @@
+mutation addPipelineSubscription($input: ProjectSubscriptionCreateInput!) {
+  projectSubscriptionCreate(input: $input) {
+    subscription {
+      id
+    }
+    errors
+  }
+}
diff --git a/ee/app/assets/javascripts/ci/pipeline_subscriptions/pipeline_subscriptions_app.vue b/ee/app/assets/javascripts/ci/pipeline_subscriptions/pipeline_subscriptions_app.vue
index c235fed1469eb..d00ff50582807 100644
--- a/ee/app/assets/javascripts/ci/pipeline_subscriptions/pipeline_subscriptions_app.vue
+++ b/ee/app/assets/javascripts/ci/pipeline_subscriptions/pipeline_subscriptions_app.vue
@@ -121,7 +121,7 @@ export default {
           this.subscriptionToDelete = null;
         } else {
           createAlert({ message: this.$options.i18n.deleteSuccess, variant: 'success' });
-          this.$apollo.queries.upstreamSubscriptions.refetch();
+          this.refetchUpstreamSubscriptions();
         }
       } catch {
         createAlert({ message: this.$options.i18n.deleteError });
@@ -136,6 +136,9 @@ export default {
       this.isModalVisible = false;
       this.subscriptionToDelete = null;
     },
+    refetchUpstreamSubscriptions() {
+      this.$apollo.queries.upstreamSubscriptions.refetch();
+    },
   },
 };
 </script>
@@ -151,6 +154,7 @@ export default {
       :empty-text="$options.i18n.upstreamEmptyText"
       show-actions
       @showModal="showModal"
+      @refetchSubscriptions="refetchUpstreamSubscriptions"
     />
 
     <gl-loading-icon v-if="downstreamSubscriptionsLoading" />
diff --git a/ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_form_spec.js b/ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_form_spec.js
new file mode 100644
index 0000000000000..c97bfed7e920e
--- /dev/null
+++ b/ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_form_spec.js
@@ -0,0 +1,118 @@
+import { GlFormInput, GlLink } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { createAlert } from '~/alert';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelineSubscriptionsForm from 'ee/ci/pipeline_subscriptions/components/pipeline_subscriptions_form.vue';
+import AddPipelineSubscription from 'ee/ci/pipeline_subscriptions/graphql/mutations/add_pipeline_subscription.mutation.graphql';
+
+import { addMutationResponse } from '../mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/alert');
+
+describe('Pipeline subscriptions form', () => {
+  let wrapper;
+
+  const successHandler = jest.fn().mockResolvedValue(addMutationResponse);
+  const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL Error'));
+
+  const findInput = () => wrapper.findComponent(GlFormInput);
+  const findHelpLink = () => wrapper.findComponent(GlLink);
+  const findSubscribeBtn = () => wrapper.findByTestId('subscribe-button');
+  const findCancelBtn = () => wrapper.findByTestId('cancel-button');
+
+  const defaultHandlers = [[AddPipelineSubscription, successHandler]];
+
+  const defaultProvideOptions = {
+    projectPath: '/namespace/my-project',
+  };
+
+  const upstreamPath = 'root/project';
+
+  const createMockApolloProvider = (handlers) => {
+    return createMockApollo(handlers);
+  };
+
+  const createComponent = (handlers = defaultHandlers, mountFn = shallowMountExtended) => {
+    wrapper = mountFn(PipelineSubscriptionsForm, {
+      provide: {
+        ...defaultProvideOptions,
+      },
+      apolloProvider: createMockApolloProvider(handlers),
+    });
+  };
+
+  describe('default', () => {
+    beforeEach(() => {
+      createComponent();
+    });
+
+    it('displays the upstream input field', () => {
+      expect(findInput().exists()).toBe(true);
+    });
+
+    it('subscribes to an upstream project', async () => {
+      findInput().vm.$emit('input', upstreamPath);
+
+      findSubscribeBtn().vm.$emit('click');
+
+      await waitForPromises();
+
+      expect(successHandler).toHaveBeenCalledWith({
+        input: {
+          projectPath: defaultProvideOptions.projectPath,
+          upstreamPath,
+        },
+      });
+      expect(createAlert).toHaveBeenCalledWith({
+        message: 'Subscription successfully added.',
+        variant: 'success',
+      });
+    });
+
+    it('cancels adding a subscription and emits the canceled event', async () => {
+      findInput().vm.$emit('input', upstreamPath);
+
+      await nextTick();
+
+      expect(findInput().attributes('value')).toBe(upstreamPath);
+
+      findCancelBtn().vm.$emit('click');
+
+      await nextTick();
+
+      expect(wrapper.emitted('canceled')).toEqual([[]]);
+      expect(findInput().attributes('value')).toBe('');
+    });
+  });
+
+  describe('errors', () => {
+    beforeEach(() => {
+      createComponent([[AddPipelineSubscription, failedHandler]]);
+    });
+
+    it('shows alert when error occurs', async () => {
+      findInput().vm.$emit('input', '');
+
+      findSubscribeBtn().vm.$emit('click');
+
+      await waitForPromises();
+
+      expect(createAlert).toHaveBeenCalledWith({
+        message: 'An error occurred while adding a new pipeline subscription.',
+      });
+    });
+  });
+
+  it('displays help link to docs', () => {
+    createComponent(defaultHandlers, mountExtended);
+
+    expect(findHelpLink().attributes('href')).toBe(
+      '/help/ci/pipelines/index#trigger-a-pipeline-when-an-upstream-project-is-rebuilt',
+    );
+  });
+});
diff --git a/ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_table_spec.js b/ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_table_spec.js
index 1bd153f37480f..1479c4dc93a28 100644
--- a/ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_table_spec.js
+++ b/ee/spec/frontend/ci/pipeline_subscriptions/components/pipeline_subscriptions_table_spec.js
@@ -1,6 +1,8 @@
 import { GlTable, GlLink } from '@gitlab/ui';
+import { nextTick } from 'vue';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 import PipelineSubscriptionsTable from 'ee/ci/pipeline_subscriptions/components/pipeline_subscriptions_table.vue';
+import PipelineSubscriptionsForm from 'ee/ci/pipeline_subscriptions/components/pipeline_subscriptions_form.vue';
 import { mockUpstreamSubscriptions } from '../mock_data';
 
 describe('Pipeline Subscriptions Table', () => {
@@ -30,6 +32,7 @@ describe('Pipeline Subscriptions Table', () => {
   const findNamespace = () => wrapper.findByTestId('subscription-namespace');
   const findProject = () => wrapper.findComponent(GlLink);
   const findTable = () => wrapper.findComponent(GlTable);
+  const findForm = () => wrapper.findComponent(PipelineSubscriptionsForm);
 
   const createComponent = (props = defaultProps) => {
     wrapper = mountExtended(PipelineSubscriptionsTable, {
@@ -93,4 +96,32 @@ describe('Pipeline Subscriptions Table', () => {
       expect(findAddNewBtn().exists()).toBe(visible);
     },
   );
+
+  it('does not display form', () => {
+    createComponent();
+
+    expect(findForm().exists()).toBe(false);
+  });
+
+  it('displays the form', async () => {
+    createComponent();
+
+    findAddNewBtn().vm.$emit('click');
+
+    await nextTick();
+
+    expect(findForm().exists()).toBe(true);
+  });
+
+  it('hides new button after intial click', async () => {
+    createComponent();
+
+    expect(findAddNewBtn().exists()).toBe(true);
+
+    findAddNewBtn().vm.$emit('click');
+
+    await nextTick();
+
+    expect(findAddNewBtn().exists()).toBe(false);
+  });
 });
diff --git a/ee/spec/frontend/ci/pipeline_subscriptions/mock_data.js b/ee/spec/frontend/ci/pipeline_subscriptions/mock_data.js
index 16ad544304a3d..0e91b61501e96 100644
--- a/ee/spec/frontend/ci/pipeline_subscriptions/mock_data.js
+++ b/ee/spec/frontend/ci/pipeline_subscriptions/mock_data.js
@@ -15,4 +15,17 @@ export const deleteMutationResponse = {
   },
 };
 
+export const addMutationResponse = {
+  data: {
+    projectSubscriptionCreate: {
+      subscription: {
+        id: 'gid://gitlab/Ci::Subscriptions::Project/18',
+        __typename: 'CiSubscriptionsProject',
+      },
+      errors: [],
+      __typename: 'ProjectSubscriptionCreatePayload',
+    },
+  },
+};
+
 export { mockUpstreamSubscriptions, mockDownstreamSubscriptions };
diff --git a/ee/spec/frontend/ci/pipeline_subscriptions/pipeline_subscriptions_app_spec.js b/ee/spec/frontend/ci/pipeline_subscriptions/pipeline_subscriptions_app_spec.js
index 9be8190d7680e..71d32900e43eb 100644
--- a/ee/spec/frontend/ci/pipeline_subscriptions/pipeline_subscriptions_app_spec.js
+++ b/ee/spec/frontend/ci/pipeline_subscriptions/pipeline_subscriptions_app_spec.js
@@ -129,6 +129,18 @@ describe('Pipeline subscriptions app', () => {
         variant: 'success',
       });
     });
+
+    it('refetches subscriptions after adding a new subscription', async () => {
+      createComponent();
+
+      await waitForPromises();
+
+      expect(upstreamHanlder).toHaveBeenCalledTimes(1);
+
+      findTables().at(0).vm.$emit('refetchSubscriptions');
+
+      expect(upstreamHanlder).toHaveBeenCalledTimes(2);
+    });
   });
 
   describe('failures', () => {
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e191e928bb0a5..8612b3789790a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -35832,6 +35832,12 @@ msgstr ""
 msgid "PipelineSubscriptions|Add new"
 msgstr ""
 
+msgid "PipelineSubscriptions|Add new pipeline subscription"
+msgstr ""
+
+msgid "PipelineSubscriptions|An error occurred while adding a new pipeline subscription."
+msgstr ""
+
 msgid "PipelineSubscriptions|An error occurred while deleting this pipeline subscription."
 msgstr ""
 
@@ -35853,6 +35859,9 @@ msgstr ""
 msgid "PipelineSubscriptions|Subscription for this project will be removed. Do you want to continue?"
 msgstr ""
 
+msgid "PipelineSubscriptions|Subscription successfully added."
+msgstr ""
+
 msgid "PipelineSubscriptions|Subscription successfully deleted."
 msgstr ""
 
-- 
GitLab