diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index ba02f21bd6eadb9c2569a6938bdbba60484e3b74..8fd72cfceb5c829756f282ff822f9d8fbf6f3542 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -1,9 +1,9 @@
 <script>
 import { GlDrawer } from '@gitlab/ui';
 import { mapState, mapActions, mapGetters } from 'vuex';
+import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
 import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
 import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
-import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
 import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
 import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
 import { ISSUABLE } from '~/boards/constants';
@@ -23,11 +23,9 @@ export default {
     BoardSidebarLabelsSelect,
     BoardSidebarDueDate,
     SidebarSubscriptionsWidget,
-    BoardSidebarMilestoneSelect,
+    SidebarDropdownWidget,
     BoardSidebarWeightInput: () =>
       import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
-    SidebarDropdownWidget: () =>
-      import('ee_component/sidebar/components/sidebar_dropdown_widget.vue'),
   },
   inject: {
     multipleAssigneesFeatureAvailable: {
@@ -97,7 +95,14 @@ export default {
         data-testid="sidebar-epic"
       />
       <div>
-        <board-sidebar-milestone-select />
+        <sidebar-dropdown-widget
+          :iid="activeBoardItem.iid"
+          issuable-attribute="milestone"
+          :workspace-path="projectPathForActiveIssue"
+          :attr-workspace-path="projectPathForActiveIssue"
+          :issuable-type="issuableType"
+          data-testid="sidebar-milestones"
+        />
         <sidebar-dropdown-widget
           v-if="iterationFeatureAvailable"
           :iid="activeBoardItem.iid"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..277e1400bf213703e54ea0effa8a9c41f6d87677
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -0,0 +1,344 @@
+<script>
+import {
+  GlLink,
+  GlDropdown,
+  GlDropdownItem,
+  GlDropdownText,
+  GlSearchBoxByType,
+  GlDropdownDivider,
+  GlLoadingIcon,
+  GlIcon,
+  GlTooltipDirective,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { IssuableType } from '~/issue_show/constants';
+import { __, s__, sprintf } from '~/locale';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import {
+  IssuableAttributeState,
+  IssuableAttributeType,
+  issuableAttributesQueries,
+  noAttributeId,
+} from '../constants';
+
+export default {
+  noAttributeId,
+  IssuableAttributeState,
+  issuableAttributesQueries,
+  i18n: {
+    [IssuableAttributeType.Milestone]: __('Milestone'),
+    none: __('None'),
+  },
+  directives: {
+    GlTooltip: GlTooltipDirective,
+  },
+  components: {
+    SidebarEditableItem,
+    GlLink,
+    GlDropdown,
+    GlDropdownItem,
+    GlDropdownText,
+    GlDropdownDivider,
+    GlSearchBoxByType,
+    GlIcon,
+    GlLoadingIcon,
+  },
+  inject: {
+    isClassicSidebar: {
+      default: false,
+    },
+  },
+  props: {
+    issuableAttribute: {
+      type: String,
+      required: true,
+      validator(value) {
+        return [IssuableAttributeType.Milestone].includes(value);
+      },
+    },
+    workspacePath: {
+      required: true,
+      type: String,
+    },
+    iid: {
+      required: true,
+      type: String,
+    },
+    attrWorkspacePath: {
+      required: true,
+      type: String,
+    },
+    issuableType: {
+      type: String,
+      required: true,
+      validator(value) {
+        return value === IssuableType.Issue;
+      },
+    },
+  },
+  apollo: {
+    currentAttribute: {
+      query() {
+        const { current } = this.issuableAttributeQuery;
+        const { query } = current[this.issuableType];
+
+        return query;
+      },
+      variables() {
+        return {
+          fullPath: this.workspacePath,
+          iid: this.iid,
+        };
+      },
+      update(data) {
+        return data?.workspace?.issuable.attribute;
+      },
+      error(error) {
+        createFlash({
+          message: this.i18n.currentFetchError,
+          captureError: true,
+          error,
+        });
+      },
+    },
+    attributesList: {
+      query() {
+        const { list } = this.issuableAttributeQuery;
+        const { query } = list[this.issuableType];
+
+        return query;
+      },
+      skip() {
+        return !this.editing;
+      },
+      debounce: 250,
+      variables() {
+        return {
+          fullPath: this.attrWorkspacePath,
+          title: this.searchTerm,
+          state: this.$options.IssuableAttributeState[this.issuableAttribute],
+        };
+      },
+      update(data) {
+        if (data?.workspace) {
+          return data?.workspace?.attributes.nodes;
+        }
+        return [];
+      },
+      error(error) {
+        createFlash({ message: this.i18n.listFetchError, captureError: true, error });
+      },
+    },
+  },
+  data() {
+    return {
+      searchTerm: '',
+      editing: false,
+      updating: false,
+      selectedTitle: null,
+      currentAttribute: null,
+      attributesList: [],
+      tracking: {
+        label: 'right_sidebar',
+        event: 'click_edit_button',
+        property: this.issuableAttribute,
+      },
+    };
+  },
+  computed: {
+    issuableAttributeQuery() {
+      return this.$options.issuableAttributesQueries[this.issuableAttribute];
+    },
+    attributeTitle() {
+      return this.currentAttribute?.title || this.i18n.noAttribute;
+    },
+    attributeUrl() {
+      return this.currentAttribute?.webUrl;
+    },
+    dropdownText() {
+      return this.currentAttribute
+        ? this.currentAttribute?.title
+        : this.$options.i18n[this.issuableAttribute];
+    },
+    loading() {
+      return this.$apollo.queries.currentAttribute.loading;
+    },
+    emptyPropsList() {
+      return this.attributesList.length === 0;
+    },
+    attributeTypeTitle() {
+      return this.$options.i18n[this.issuableAttribute];
+    },
+    i18n() {
+      return {
+        noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
+          issuableAttribute: this.issuableAttribute,
+        }),
+        assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), {
+          issuableAttribute: this.issuableAttribute,
+        }),
+        noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), {
+          issuableAttribute: this.issuableAttribute,
+        }),
+        updateError: sprintf(
+          s__(
+            'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.',
+          ),
+          { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
+        ),
+        listFetchError: sprintf(
+          s__(
+            'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.',
+          ),
+          { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
+        ),
+        currentFetchError: sprintf(
+          s__(
+            'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.',
+          ),
+          { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
+        ),
+      };
+    },
+  },
+  methods: {
+    updateAttribute(attributeId) {
+      if (this.currentAttribute === null && attributeId === null) return;
+      if (attributeId === this.currentAttribute?.id) return;
+
+      this.updating = true;
+
+      const selectedAttribute =
+        Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId);
+      this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none;
+
+      const { current } = this.issuableAttributeQuery;
+      const { mutation } = current[this.issuableType];
+
+      this.$apollo
+        .mutate({
+          mutation,
+          variables: {
+            fullPath: this.workspacePath,
+            attributeId:
+              this.issuableAttribute === IssuableAttributeType.Milestone
+                ? getIdFromGraphQLId(attributeId)
+                : attributeId,
+            iid: this.iid,
+          },
+        })
+        .then(({ data }) => {
+          if (data.issuableSetAttribute?.errors?.length) {
+            createFlash({
+              message: data.issuableSetAttribute.errors[0],
+              captureError: true,
+              error: data.issuableSetAttribute.errors[0],
+            });
+          } else {
+            this.$emit('attribute-updated', data);
+          }
+        })
+        .catch((error) => {
+          createFlash({ message: this.i18n.updateError, captureError: true, error });
+        })
+        .finally(() => {
+          this.updating = false;
+          this.searchTerm = '';
+          this.selectedTitle = null;
+        });
+    },
+    isAttributeChecked(attributeId = undefined) {
+      return (
+        attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
+      );
+    },
+    showDropdown() {
+      this.$refs.newDropdown.show();
+    },
+    handleOpen() {
+      this.editing = true;
+      this.showDropdown();
+    },
+    handleClose() {
+      this.editing = false;
+    },
+    setFocus() {
+      this.$refs.search.focusInput();
+    },
+  },
+};
+</script>
+
+<template>
+  <sidebar-editable-item
+    ref="editable"
+    :title="attributeTypeTitle"
+    :data-testid="`${issuableAttribute}-edit`"
+    :tracking="tracking"
+    :loading="updating || loading"
+    @open="handleOpen"
+    @close="handleClose"
+  >
+    <template #collapsed>
+      <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
+        <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" />
+        <span class="collapse-truncated-title">{{ attributeTitle }}</span>
+      </div>
+      <div
+        :data-testid="`select-${issuableAttribute}`"
+        :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
+      >
+        <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
+        <span v-else-if="!currentAttribute" class="gl-text-gray-500">
+          {{ $options.i18n.none }}
+        </span>
+        <gl-link v-else class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl">
+          {{ attributeTitle }}
+        </gl-link>
+      </div>
+    </template>
+    <template #default>
+      <gl-dropdown
+        ref="newDropdown"
+        lazy
+        :header-text="i18n.assignAttribute"
+        :text="dropdownText"
+        :loading="loading"
+        class="gl-w-full"
+        @shown="setFocus"
+      >
+        <gl-search-box-by-type ref="search" v-model="searchTerm" />
+        <gl-dropdown-item
+          :data-testid="`no-${issuableAttribute}-item`"
+          :is-check-item="true"
+          :is-checked="isAttributeChecked($options.noAttributeId)"
+          @click="updateAttribute($options.noAttributeId)"
+        >
+          {{ i18n.noAttribute }}
+        </gl-dropdown-item>
+        <gl-dropdown-divider />
+        <gl-loading-icon
+          v-if="$apollo.queries.attributesList.loading"
+          class="gl-py-4"
+          data-testid="loading-icon-dropdown"
+        />
+        <template v-else>
+          <gl-dropdown-text v-if="emptyPropsList">
+            {{ i18n.noAttributesFound }}
+          </gl-dropdown-text>
+          <gl-dropdown-item
+            v-for="attrItem in attributesList"
+            :key="attrItem.id"
+            :is-check-item="true"
+            :is-checked="isAttributeChecked(attrItem.id)"
+            :data-testid="`${issuableAttribute}-items`"
+            @click="updateAttribute(attrItem.id)"
+          >
+            {{ attrItem.title }}
+          </gl-dropdown-item>
+        </template>
+      </gl-dropdown>
+    </template>
+  </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 95b0de38a8ed5d86991c456e3f53280db7a44c78..6b16c47ba85d4aee9d0a0f7ebf14f4f274c1f5e6 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -29,6 +29,9 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries
 import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
 import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
 import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
+import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql';
+import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
+import projectMilestonesQuery from './queries/project_milestones.query.graphql';
 
 export const ASSIGNEES_DEBOUNCE_DELAY = 250;
 
@@ -143,3 +146,33 @@ export const timelogQueries = {
     query: getMrTimelogsQuery,
   },
 };
+
+export const noAttributeId = null;
+
+export const issuableMilestoneQueries = {
+  [IssuableType.Issue]: {
+    query: projectIssueMilestoneQuery,
+    mutation: projectIssueMilestoneMutation,
+  },
+};
+
+export const milestonesQueries = {
+  [IssuableType.Issue]: {
+    query: projectMilestonesQuery,
+  },
+};
+
+export const IssuableAttributeType = {
+  Milestone: 'milestone',
+};
+
+export const IssuableAttributeState = {
+  [IssuableAttributeType.Milestone]: 'active',
+};
+
+export const issuableAttributesQueries = {
+  [IssuableAttributeType.Milestone]: {
+    current: issuableMilestoneQueries,
+    list: milestonesQueries,
+  },
+};
diff --git a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..8db5359dac03e78b584728dbaa58c3765c982ebb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql
@@ -0,0 +1,5 @@
+fragment MilestoneFragment on Milestone {
+  id
+  title
+  webUrl: webPath
+}
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..d88ad8b10874ab367ede8725b7dc836c17fcc28a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
@@ -0,0 +1,17 @@
+mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attributeId: ID) {
+  issuableSetAttribute: updateIssue(
+    input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
+  ) {
+    __typename
+    errors
+    issuable: issue {
+      __typename
+      id
+      attribute: milestone {
+        title
+        id
+        state
+      }
+    }
+  }
+}
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..2bc42a0b0113c3803630a97fd10a9f0b93aa8017
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
@@ -0,0 +1,14 @@
+#import "./milestone.fragment.graphql"
+
+query projectIssueMilestone($fullPath: ID!, $iid: String!) {
+  workspace: project(fullPath: $fullPath) {
+    __typename
+    issuable: issue(iid: $iid) {
+      __typename
+      id
+      attribute: milestone {
+        ...MilestoneFragment
+      }
+    }
+  }
+}
diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..1237640c46887a28c75b8bacc7e1754263535914
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
@@ -0,0 +1,13 @@
+#import "./milestone.fragment.graphql"
+
+query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
+  workspace: project(fullPath: $fullPath) {
+    __typename
+    attributes: milestones(searchTitle: $title, state: $state) {
+      nodes {
+        ...MilestoneFragment
+        state
+      }
+    }
+  }
+}
diff --git a/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index dee1f67bf7f652bd9a5ac28a3099a48761cd750e..97722f444576986690d9b41d4b9fe67281266098 100644
--- a/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/ee/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -1,339 +1,37 @@
 <script>
-import {
-  GlLink,
-  GlDropdown,
-  GlDropdownItem,
-  GlDropdownText,
-  GlSearchBoxByType,
-  GlDropdownDivider,
-  GlLoadingIcon,
-  GlIcon,
-  GlTooltipDirective,
-} from '@gitlab/ui';
-import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
-import { __, s__, sprintf } from '~/locale';
-import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+// This is a false violation of @gitlab/no-runtime-template-compiler, since it
+// extends a valid Vue single file component.
+/* eslint-disable @gitlab/no-runtime-template-compiler */
+import { __ } from '~/locale';
+import SidebarDropdownWidgetFoss from '~/sidebar/components/sidebar_dropdown_widget.vue';
 import {
   IssuableAttributeState,
   IssuableAttributeType,
   issuableAttributesQueries,
-  noAttributeId,
 } from '../constants';
 
 export default {
-  noAttributeId,
+  extends: SidebarDropdownWidgetFoss,
+  IssuableAttributeState,
+  issuableAttributesQueries,
   i18n: {
+    [IssuableAttributeType.Milestone]: __('Milestone'),
     [IssuableAttributeType.Iteration]: __('Iteration'),
     [IssuableAttributeType.Epic]: __('Epic'),
     none: __('None'),
   },
-  directives: {
-    GlTooltip: GlTooltipDirective,
-  },
-  components: {
-    SidebarEditableItem,
-    GlLink,
-    GlDropdown,
-    GlDropdownItem,
-    GlDropdownText,
-    GlDropdownDivider,
-    GlSearchBoxByType,
-    GlIcon,
-    GlLoadingIcon,
-  },
-  inject: {
-    isClassicSidebar: {
-      default: false,
-    },
-  },
   props: {
     issuableAttribute: {
       type: String,
       required: true,
       validator(value) {
-        return [IssuableAttributeType.Iteration, IssuableAttributeType.Epic].includes(value);
-      },
-    },
-    workspacePath: {
-      required: true,
-      type: String,
-    },
-    iid: {
-      required: true,
-      type: String,
-    },
-    attrWorkspacePath: {
-      required: true,
-      type: String,
-    },
-    issuableType: {
-      type: String,
-      required: true,
-      validator(value) {
-        return value === IssuableType.Issue;
-      },
-    },
-  },
-  apollo: {
-    currentAttribute: {
-      query() {
-        const { current } = this.issuableAttributeQuery;
-        const { query } = current[this.issuableType];
-
-        return query;
-      },
-      variables() {
-        return {
-          fullPath: this.workspacePath,
-          iid: this.iid,
-        };
-      },
-      update(data) {
-        return data?.workspace?.issuable.attribute;
-      },
-      error(error) {
-        createFlash({
-          message: this.i18n.currentFetchError,
-          captureError: true,
-          error,
-        });
-      },
-    },
-    attributesList: {
-      query() {
-        const { list } = this.issuableAttributeQuery;
-        const { query } = list[this.issuableType];
-
-        return query;
-      },
-      skip() {
-        return !this.editing;
-      },
-      debounce: 250,
-      variables() {
-        return {
-          fullPath: this.attrWorkspacePath,
-          title: this.searchTerm,
-          state: IssuableAttributeState[this.issuableAttribute],
-        };
-      },
-      update(data) {
-        if (data?.workspace) {
-          return data?.workspace?.attributes.nodes;
-        }
-        return [];
-      },
-      error(error) {
-        createFlash({ message: this.i18n.listFetchError, captureError: true, error });
+        return [
+          IssuableAttributeType.Milestone,
+          IssuableAttributeType.Iteration,
+          IssuableAttributeType.Epic,
+        ].includes(value);
       },
     },
   },
-  data() {
-    return {
-      searchTerm: '',
-      editing: false,
-      updating: false,
-      selectedTitle: null,
-      currentAttribute: null,
-      attributesList: [],
-      tracking: {
-        label: 'right_sidebar',
-        event: 'click_edit_button',
-        property: this.issuableAttribute,
-      },
-    };
-  },
-  computed: {
-    issuableAttributeQuery() {
-      return issuableAttributesQueries[this.issuableAttribute];
-    },
-    attributeTitle() {
-      return this.currentAttribute?.title || this.i18n.noAttribute;
-    },
-    attributeUrl() {
-      return this.currentAttribute?.webUrl;
-    },
-    dropdownText() {
-      return this.currentAttribute
-        ? this.currentAttribute?.title
-        : this.$options.i18n[this.issuableAttribute];
-    },
-    loading() {
-      return this.$apollo.queries.currentAttribute.loading;
-    },
-    emptyPropsList() {
-      return this.attributesList.length === 0;
-    },
-    attributeTypeTitle() {
-      return this.$options.i18n[this.issuableAttribute];
-    },
-    i18n() {
-      return {
-        noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
-          issuableAttribute: this.issuableAttribute,
-        }),
-        assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), {
-          issuableAttribute: this.issuableAttribute,
-        }),
-        noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), {
-          issuableAttribute: this.issuableAttribute,
-        }),
-        updateError: sprintf(
-          s__(
-            'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.',
-          ),
-          { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
-        ),
-        listFetchError: sprintf(
-          s__(
-            'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.',
-          ),
-          { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
-        ),
-        currentFetchError: sprintf(
-          s__(
-            'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.',
-          ),
-          { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
-        ),
-      };
-    },
-  },
-  methods: {
-    updateAttribute(attributeId) {
-      if (this.currentAttribute === null && attributeId === null) return;
-      if (attributeId === this.currentAttribute?.id) return;
-
-      this.updating = true;
-
-      const selectedAttribute =
-        Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId);
-      this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none;
-
-      const { current } = this.issuableAttributeQuery;
-      const { mutation } = current[this.issuableType];
-
-      this.$apollo
-        .mutate({
-          mutation,
-          variables: {
-            fullPath: this.workspacePath,
-            attributeId,
-            iid: this.iid,
-          },
-        })
-        .then(({ data }) => {
-          if (data.issuableSetAttribute?.errors?.length) {
-            createFlash({
-              message: data.issuableSetAttribute.errors[0],
-              captureError: true,
-              error: data.issuableSetAttribute.errors[0],
-            });
-          } else {
-            this.$emit('attribute-updated', data);
-          }
-        })
-        .catch((error) => {
-          createFlash({ message: this.i18n.updateError, captureError: true, error });
-        })
-        .finally(() => {
-          this.updating = false;
-          this.searchTerm = '';
-          this.selectedTitle = null;
-        });
-    },
-    isAttributeChecked(attributeId = undefined) {
-      return (
-        attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
-      );
-    },
-    showDropdown() {
-      this.$refs.newDropdown.show();
-    },
-    handleOpen() {
-      this.editing = true;
-      this.showDropdown();
-    },
-    handleClose() {
-      this.editing = false;
-    },
-    setFocus() {
-      this.$refs.search.focusInput();
-    },
-  },
 };
 </script>
-
-<template>
-  <sidebar-editable-item
-    ref="editable"
-    :title="attributeTypeTitle"
-    :data-testid="`${issuableAttribute}-edit`"
-    :tracking="tracking"
-    :loading="updating || loading"
-    @open="handleOpen"
-    @close="handleClose"
-  >
-    <template #collapsed>
-      <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
-        <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" />
-        <span class="collapse-truncated-title">{{ attributeTitle }}</span>
-      </div>
-      <div
-        :data-testid="`select-${issuableAttribute}`"
-        :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
-      >
-        <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
-        <span v-else-if="!currentAttribute" class="gl-text-gray-500">
-          {{ $options.i18n.none }}
-        </span>
-        <gl-link v-else class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl">
-          {{ attributeTitle }}
-        </gl-link>
-      </div>
-    </template>
-    <template #default>
-      <gl-dropdown
-        ref="newDropdown"
-        lazy
-        :header-text="i18n.assignAttribute"
-        :text="dropdownText"
-        :loading="loading"
-        class="gl-w-full"
-        @shown="setFocus"
-      >
-        <gl-search-box-by-type ref="search" v-model="searchTerm" />
-        <gl-dropdown-item
-          :data-testid="`no-${issuableAttribute}-item`"
-          :is-check-item="true"
-          :is-checked="isAttributeChecked($options.noAttributeId)"
-          @click="updateAttribute($options.noAttributeId)"
-        >
-          {{ i18n.noAttribute }}
-        </gl-dropdown-item>
-        <gl-dropdown-divider />
-        <gl-loading-icon
-          v-if="$apollo.queries.attributesList.loading"
-          class="gl-py-4"
-          data-testid="loading-icon-dropdown"
-        />
-        <template v-else>
-          <gl-dropdown-text v-if="emptyPropsList">
-            {{ i18n.noAttributesFound }}
-          </gl-dropdown-text>
-          <gl-dropdown-item
-            v-for="attrItem in attributesList"
-            :key="attrItem.id"
-            :is-check-item="true"
-            :is-checked="isAttributeChecked(attrItem.id)"
-            :data-testid="`${issuableAttribute}-items`"
-            @click="updateAttribute(attrItem.id)"
-          >
-            {{ attrItem.title }}
-          </gl-dropdown-item>
-        </template>
-      </gl-dropdown>
-    </template>
-  </sidebar-editable-item>
-</template>
diff --git a/ee/app/assets/javascripts/sidebar/constants.js b/ee/app/assets/javascripts/sidebar/constants.js
index 023ae8d8b512f13815283a62e73e51df89f3d50b..10ecac7806f7a0255cc8e4afa4f0aced537df9b7 100644
--- a/ee/app/assets/javascripts/sidebar/constants.js
+++ b/ee/app/assets/javascripts/sidebar/constants.js
@@ -1,5 +1,10 @@
 import { IssuableType } from '~/issue_show/constants';
 import { s__, __ } from '~/locale';
+import {
+  IssuableAttributeType as IssuableAttributeTypeFoss,
+  IssuableAttributeState as IssuableAttributeStateFoss,
+  issuableAttributesQueries as issuableAttributesQueriesFoss,
+} from '~/sidebar/constants';
 import groupEpicsQuery from './queries/group_epics.query.graphql';
 import groupIterationsQuery from './queries/group_iterations.query.graphql';
 import projectIssueEpicMutation from './queries/project_issue_epic.mutation.graphql';
@@ -95,16 +100,19 @@ const epicsQueries = {
 };
 
 export const IssuableAttributeType = {
+  ...IssuableAttributeTypeFoss,
   Iteration: 'iteration',
   Epic: 'epic',
 };
 
 export const IssuableAttributeState = {
+  ...IssuableAttributeStateFoss,
   [IssuableAttributeType.Iteration]: 'opened',
   [IssuableAttributeType.Epic]: 'opened',
 };
 
 export const issuableAttributesQueries = {
+  ...issuableAttributesQueriesFoss,
   [IssuableAttributeType.Iteration]: {
     current: issuableIterationQueries,
     list: iterationsQueries,
diff --git a/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap b/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap
index 7611a41bfd4d39625d20e2b417bc182f11385453..4a34a8266eef040de1f7286719cec0c4a3462cf0 100644
--- a/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap
+++ b/ee/spec/frontend/boards/components/__snapshots__/board_content_sidebar_spec.js.snap
@@ -13,26 +13,33 @@ exports[`ee/BoardContentSidebar matches the snapshot 1`] = `
   />
    
   <sidebardropdownwidget-stub
-    attr-workspace-path="gitlab-org"
+    attrworkspacepath="gitlab-org"
     data-testid="sidebar-epic"
     iid="27"
-    issuable-attribute="epic"
-    issuable-type="issue"
-    workspace-path="gitlab-org/gitlab-test"
+    issuableattribute="epic"
+    issuabletype="issue"
+    workspacepath="gitlab-org/gitlab-test"
   />
    
   <div>
-    <boardsidebarmilestoneselect-stub />
+    <sidebardropdownwidget-stub
+      attrworkspacepath="gitlab-org/gitlab-test"
+      data-testid="sidebar-milestones"
+      iid="27"
+      issuableattribute="milestone"
+      issuabletype="issue"
+      workspacepath="gitlab-org/gitlab-test"
+    />
      
     <sidebardropdownwidget-stub
-      attr-workspace-path="gitlab-org"
+      attrworkspacepath="gitlab-org"
       class="gl-mt-5"
       data-qa-selector="iteration_container"
       data-testid="iteration-edit"
       iid="27"
-      issuable-attribute="iteration"
-      issuable-type="issue"
-      workspace-path="gitlab-org/gitlab-test"
+      issuableattribute="iteration"
+      issuabletype="issue"
+      workspacepath="gitlab-org/gitlab-test"
     />
   </div>
    
diff --git a/ee/spec/frontend/boards/components/board_content_sidebar_spec.js b/ee/spec/frontend/boards/components/board_content_sidebar_spec.js
index 79c32286b5dc6c757dbdbc7b3bfdee20738705ed..706e23e1ce63741f0c7017676a23bc9e1f8f1830 100644
--- a/ee/spec/frontend/boards/components/board_content_sidebar_spec.js
+++ b/ee/spec/frontend/boards/components/board_content_sidebar_spec.js
@@ -61,7 +61,6 @@ describe('ee/BoardContentSidebar', () => {
         SidebarConfidentialityWidget: true,
         BoardSidebarDueDate: true,
         SidebarSubscriptionsWidget: true,
-        BoardSidebarMilestoneSelect: true,
         BoardSidebarWeightInput: true,
         SidebarDropdownWidget: true,
       },
diff --git a/spec/features/boards/sidebar_milestones_spec.rb b/spec/features/boards/sidebar_milestones_spec.rb
index 54182781a308effe763191967edd472faf5e43e4..be7435263b16eaede54e4d308bba5812df1555d0 100644
--- a/spec/features/boards/sidebar_milestones_spec.rb
+++ b/spec/features/boards/sidebar_milestones_spec.rb
@@ -38,7 +38,7 @@
 
         wait_for_requests
 
-        page.within('.value') do
+        page.within('[data-testid="select-milestone"]') do
           expect(page).to have_content(milestone.title)
         end
       end
@@ -56,7 +56,7 @@
 
         wait_for_requests
 
-        page.within('.value') do
+        page.within('[data-testid="select-milestone"]') do
           expect(page).not_to have_content(milestone.title)
         end
       end
diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js
index 01c99a02db2c9fb3e247969016149ac797afa275..e97bdba5fea13ff9f7993b79b0f7768486330bef 100644
--- a/spec/frontend/boards/components/board_content_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_content_sidebar_spec.js
@@ -1,11 +1,11 @@
 import { GlDrawer } from '@gitlab/ui';
 import { shallowMount } from '@vue/test-utils';
 import Vuex from 'vuex';
+import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
 import { stubComponent } from 'helpers/stub_component';
 import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
 import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
 import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
-import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
 import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
 import { ISSUABLE } from '~/boards/constants';
 import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
@@ -68,6 +68,9 @@ describe('BoardContentSidebar', () => {
             iterations: {
               loading: false,
             },
+            attributesList: {
+              loading: false,
+            },
           },
         },
       },
@@ -84,38 +87,41 @@ describe('BoardContentSidebar', () => {
   });
 
   it('confirms we render GlDrawer', () => {
-    expect(wrapper.find(GlDrawer).exists()).toBe(true);
+    expect(wrapper.findComponent(GlDrawer).exists()).toBe(true);
   });
 
   it('does not render GlDrawer when isSidebarOpen is false', () => {
     createStore({ mockGetters: { isSidebarOpen: () => false } });
     createComponent();
 
-    expect(wrapper.find(GlDrawer).exists()).toBe(false);
+    expect(wrapper.findComponent(GlDrawer).exists()).toBe(false);
   });
 
   it('applies an open attribute', () => {
-    expect(wrapper.find(GlDrawer).props('open')).toBe(true);
+    expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true);
   });
 
   it('renders BoardSidebarLabelsSelect', () => {
-    expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
+    expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true);
   });
 
   it('renders BoardSidebarTitle', () => {
-    expect(wrapper.find(BoardSidebarTitle).exists()).toBe(true);
+    expect(wrapper.findComponent(BoardSidebarTitle).exists()).toBe(true);
   });
 
   it('renders BoardSidebarDueDate', () => {
-    expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true);
+    expect(wrapper.findComponent(BoardSidebarDueDate).exists()).toBe(true);
   });
 
   it('renders BoardSidebarSubscription', () => {
-    expect(wrapper.find(SidebarSubscriptionsWidget).exists()).toBe(true);
+    expect(wrapper.findComponent(SidebarSubscriptionsWidget).exists()).toBe(true);
   });
 
-  it('renders BoardSidebarMilestoneSelect', () => {
-    expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
+  it('renders SidebarDropdownWidget for milestones', () => {
+    expect(wrapper.findComponent(SidebarDropdownWidget).exists()).toBe(true);
+    expect(wrapper.findComponent(SidebarDropdownWidget).props('issuableAttribute')).toEqual(
+      'milestone',
+    );
   });
 
   describe('when we emit close', () => {
@@ -128,7 +134,7 @@ describe('BoardContentSidebar', () => {
     });
 
     it('calls toggleBoardItem with correct parameters', async () => {
-      wrapper.find(GlDrawer).vm.$emit('close');
+      wrapper.findComponent(GlDrawer).vm.$emit('close');
 
       expect(toggleBoardItem).toHaveBeenCalledTimes(1);
       expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8d58854b0130f04de8056c24e5ffd57e2395298a
--- /dev/null
+++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
@@ -0,0 +1,503 @@
+import {
+  GlDropdown,
+  GlDropdownItem,
+  GlDropdownText,
+  GlLink,
+  GlSearchBoxByType,
+  GlFormInput,
+  GlLoadingIcon,
+} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { IssuableType } from '~/issue_show/constants';
+import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { IssuableAttributeType } from '~/sidebar/constants';
+import projectIssueMilestoneMutation from '~/sidebar/queries/project_issue_milestone.mutation.graphql';
+import projectIssueMilestoneQuery from '~/sidebar/queries/project_issue_milestone.query.graphql';
+import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
+
+import {
+  mockIssue,
+  mockProjectMilestonesResponse,
+  noCurrentMilestoneResponse,
+  mockMilestoneMutationResponse,
+  mockMilestone2,
+  emptyProjectMilestonesResponse,
+} from '../mock_data';
+
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+
+describe('SidebarDropdownWidget', () => {
+  let wrapper;
+  let mockApollo;
+
+  const promiseData = { issuableSetAttribute: { issue: { attribute: { id: '123' } } } };
+  const firstErrorMsg = 'first error';
+  const promiseWithErrors = {
+    ...promiseData,
+    issuableSetAttribute: { ...promiseData.issuableSetAttribute, errors: [firstErrorMsg] },
+  };
+
+  const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData });
+  const mutationError = () =>
+    jest.fn().mockRejectedValue('Failed to set milestone on this issue. Please try again.');
+  const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors });
+
+  const findGlLink = () => wrapper.findComponent(GlLink);
+  const findDropdown = () => wrapper.findComponent(GlDropdown);
+  const findDropdownText = () => wrapper.findComponent(GlDropdownText);
+  const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+  const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+  const findDropdownItemWithText = (text) =>
+    findAllDropdownItems().wrappers.find((x) => x.text() === text);
+
+  const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem);
+  const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]');
+  const findEditableLoadingIcon = () => findSidebarEditableItem().findComponent(GlLoadingIcon);
+  const findAttributeItems = () => wrapper.findByTestId('milestone-items');
+  const findSelectedAttribute = () => wrapper.findByTestId('select-milestone');
+  const findNoAttributeItem = () => wrapper.findByTestId('no-milestone-item');
+  const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown');
+
+  const waitForDropdown = async () => {
+    // BDropdown first changes its `visible` property
+    // in a requestAnimationFrame callback.
+    // It then emits `shown` event in a watcher for `visible`
+    // Hence we need both of these:
+    await waitForPromises();
+    await wrapper.vm.$nextTick();
+  };
+
+  const waitForApollo = async () => {
+    jest.runOnlyPendingTimers();
+    await waitForPromises();
+  };
+
+  // Used with createComponentWithApollo which uses 'mount'
+  const clickEdit = async () => {
+    await findEditButton().trigger('click');
+
+    await waitForDropdown();
+
+    // We should wait for attributes list to be fetched.
+    await waitForApollo();
+  };
+
+  // Used with createComponent which shallow mounts components
+  const toggleDropdown = async () => {
+    wrapper.vm.$refs.editable.expand();
+
+    await waitForDropdown();
+  };
+
+  const createComponentWithApollo = async ({
+    requestHandlers = [],
+    projectMilestonesSpy = jest.fn().mockResolvedValue(mockProjectMilestonesResponse),
+    currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse),
+  } = {}) => {
+    localVue.use(VueApollo);
+    mockApollo = createMockApollo([
+      [projectMilestonesQuery, projectMilestonesSpy],
+      [projectIssueMilestoneQuery, currentMilestoneSpy],
+      ...requestHandlers,
+    ]);
+
+    wrapper = extendedWrapper(
+      mount(SidebarDropdownWidget, {
+        localVue,
+        provide: { canUpdate: true },
+        apolloProvider: mockApollo,
+        propsData: {
+          workspacePath: mockIssue.projectPath,
+          attrWorkspacePath: mockIssue.projectPath,
+          iid: mockIssue.iid,
+          issuableType: IssuableType.Issue,
+          issuableAttribute: IssuableAttributeType.Milestone,
+        },
+        attachTo: document.body,
+      }),
+    );
+
+    await waitForApollo();
+  };
+
+  const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => {
+    wrapper = extendedWrapper(
+      shallowMount(SidebarDropdownWidget, {
+        provide: { canUpdate: true },
+        data() {
+          return data;
+        },
+        propsData: {
+          workspacePath: '',
+          attrWorkspacePath: '',
+          iid: '',
+          issuableType: IssuableType.Issue,
+          issuableAttribute: IssuableAttributeType.Milestone,
+        },
+        mocks: {
+          $apollo: {
+            mutate: mutationPromise(),
+            queries: {
+              currentAttribute: { loading: false },
+              attributesList: { loading: false },
+              ...queries,
+            },
+          },
+        },
+        stubs: {
+          SidebarEditableItem,
+          GlSearchBoxByType,
+          GlDropdown,
+        },
+      }),
+    );
+
+    // We need to mock out `showDropdown` which
+    // invokes `show` method of BDropdown used inside GlDropdown.
+    jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
+  };
+
+  afterEach(() => {
+    wrapper.destroy();
+    wrapper = null;
+  });
+
+  describe('when not editing', () => {
+    beforeEach(() => {
+      createComponent({
+        data: {
+          currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' },
+        },
+        stubs: {
+          GlDropdown,
+          SidebarEditableItem,
+        },
+      });
+    });
+
+    it('shows the current attribute', () => {
+      expect(findSelectedAttribute().text()).toBe('title');
+    });
+
+    it('links to the current attribute', () => {
+      expect(findGlLink().attributes().href).toBe('webUrl');
+    });
+
+    it('does not show a loading spinner next to the heading', () => {
+      expect(findEditableLoadingIcon().exists()).toBe(false);
+    });
+
+    it('shows a loading spinner while fetching the current attribute', () => {
+      createComponent({
+        queries: {
+          currentAttribute: { loading: true },
+        },
+      });
+
+      expect(findEditableLoadingIcon().exists()).toBe(true);
+    });
+
+    it('shows the loading spinner and the title of the selected attribute while updating', () => {
+      createComponent({
+        data: {
+          updating: true,
+          selectedTitle: 'Some milestone title',
+        },
+        queries: {
+          currentAttribute: { loading: false },
+        },
+      });
+
+      expect(findEditableLoadingIcon().exists()).toBe(true);
+      expect(findSelectedAttribute().text()).toBe('Some milestone title');
+    });
+
+    describe('when current attribute does not exist', () => {
+      it('renders "None" as the selected attribute title', () => {
+        createComponent();
+
+        expect(findSelectedAttribute().text()).toBe('None');
+      });
+    });
+  });
+
+  describe('when a user can edit', () => {
+    describe('when user is editing', () => {
+      describe('when rendering the dropdown', () => {
+        it('shows a loading spinner while fetching a list of attributes', async () => {
+          createComponent({
+            queries: {
+              attributesList: { loading: true },
+            },
+          });
+
+          await toggleDropdown();
+
+          expect(findLoadingIconDropdown().exists()).toBe(true);
+        });
+
+        describe('GlDropdownItem with the right title and id', () => {
+          const id = 'id';
+          const title = 'title';
+
+          beforeEach(async () => {
+            createComponent({
+              data: { attributesList: [{ id, title }], currentAttribute: { id, title } },
+            });
+
+            await toggleDropdown();
+          });
+
+          it('does not show a loading spinner', () => {
+            expect(findLoadingIconDropdown().exists()).toBe(false);
+          });
+
+          it('renders title $title', () => {
+            expect(findDropdownItemWithText(title).exists()).toBe(true);
+          });
+
+          it('checks the correct dropdown item', () => {
+            expect(
+              findAllDropdownItems()
+                .filter((w) => w.props('isChecked') === true)
+                .at(0)
+                .text(),
+            ).toBe(title);
+          });
+        });
+
+        describe('when no data is assigned', () => {
+          beforeEach(async () => {
+            createComponent();
+
+            await toggleDropdown();
+          });
+
+          it('finds GlDropdownItem with "No milestone"', () => {
+            expect(findNoAttributeItem().text()).toBe('No milestone');
+          });
+
+          it('"No milestone" is checked', () => {
+            expect(findNoAttributeItem().props('isChecked')).toBe(true);
+          });
+
+          it('does not render any dropdown item', () => {
+            expect(findAttributeItems().exists()).toBe(false);
+          });
+        });
+
+        describe('when clicking on dropdown item', () => {
+          describe('when currentAttribute is equal to attribute id', () => {
+            it('does not call setIssueAttribute mutation', async () => {
+              createComponent({
+                data: {
+                  attributesList: [{ id: 'id', title: 'title' }],
+                  currentAttribute: { id: 'id', title: 'title' },
+                },
+              });
+
+              await toggleDropdown();
+
+              findDropdownItemWithText('title').vm.$emit('click');
+
+              expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0);
+            });
+          });
+
+          describe('when currentAttribute is not equal to attribute id', () => {
+            describe('when error', () => {
+              const bootstrapComponent = (mutationResp) => {
+                createComponent({
+                  data: {
+                    attributesList: [
+                      { id: '123', title: '123' },
+                      { id: 'id', title: 'title' },
+                    ],
+                    currentAttribute: '123',
+                  },
+                  mutationPromise: mutationResp,
+                });
+              };
+
+              describe.each`
+                description                 | mutationResp                 | expectedMsg
+                ${'top-level error'}        | ${mutationError}             | ${'Failed to set milestone on this issue. Please try again.'}
+                ${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg}
+              `(`$description`, ({ mutationResp, expectedMsg }) => {
+                beforeEach(async () => {
+                  bootstrapComponent(mutationResp);
+
+                  await toggleDropdown();
+
+                  findDropdownItemWithText('title').vm.$emit('click');
+                });
+
+                it(`calls createFlash with "${expectedMsg}"`, async () => {
+                  await wrapper.vm.$nextTick();
+                  expect(createFlash).toHaveBeenCalledWith({
+                    message: expectedMsg,
+                    captureError: true,
+                    error: expectedMsg,
+                  });
+                });
+              });
+            });
+          });
+        });
+      });
+
+      describe('when a user is searching', () => {
+        describe('when search result is not found', () => {
+          it('renders "No milestone found"', async () => {
+            createComponent();
+
+            await toggleDropdown();
+
+            findSearchBox().vm.$emit('input', 'non existing milestones');
+
+            await wrapper.vm.$nextTick();
+
+            expect(findDropdownText().text()).toBe('No milestone found');
+          });
+        });
+      });
+    });
+  });
+
+  describe('with mock apollo', () => {
+    let error;
+
+    beforeEach(() => {
+      jest.spyOn(Sentry, 'captureException');
+      error = new Error('mayday');
+    });
+
+    describe("when issuable type is 'issue'", () => {
+      describe('when dropdown is expanded and user can edit', () => {
+        let milestoneMutationSpy;
+        beforeEach(async () => {
+          milestoneMutationSpy = jest.fn().mockResolvedValue(mockMilestoneMutationResponse);
+
+          await createComponentWithApollo({
+            requestHandlers: [[projectIssueMilestoneMutation, milestoneMutationSpy]],
+          });
+
+          await clickEdit();
+        });
+
+        it('renders the dropdown on clicking edit', async () => {
+          expect(findDropdown().isVisible()).toBe(true);
+        });
+
+        it('focuses on the input when dropdown is shown', async () => {
+          expect(document.activeElement).toEqual(wrapper.findComponent(GlFormInput).element);
+        });
+
+        describe('when currentAttribute is not equal to attribute id', () => {
+          describe('when update is successful', () => {
+            beforeEach(() => {
+              findDropdownItemWithText(mockMilestone2.title).vm.$emit('click');
+            });
+
+            it('calls setIssueAttribute mutation', () => {
+              expect(milestoneMutationSpy).toHaveBeenCalledWith({
+                iid: mockIssue.iid,
+                attributeId: getIdFromGraphQLId(mockMilestone2.id),
+                fullPath: mockIssue.projectPath,
+              });
+            });
+
+            it('sets the value returned from the mutation to currentAttribute', async () => {
+              expect(findSelectedAttribute().text()).toBe(mockMilestone2.title);
+            });
+          });
+        });
+
+        describe('milestones', () => {
+          let projectMilestonesSpy;
+
+          it('should call createFlash if milestones query fails', async () => {
+            await createComponentWithApollo({
+              projectMilestonesSpy: jest.fn().mockRejectedValue(error),
+            });
+
+            await clickEdit();
+
+            expect(createFlash).toHaveBeenCalledWith({
+              message: wrapper.vm.i18n.listFetchError,
+              captureError: true,
+              error: expect.any(Error),
+            });
+          });
+
+          it('only fetches attributes when dropdown is opened', async () => {
+            projectMilestonesSpy = jest.fn().mockResolvedValueOnce(emptyProjectMilestonesResponse);
+            await createComponentWithApollo({ projectMilestonesSpy });
+
+            expect(projectMilestonesSpy).not.toHaveBeenCalled();
+
+            await clickEdit();
+
+            expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, {
+              fullPath: mockIssue.projectPath,
+              title: '',
+              state: 'active',
+            });
+          });
+
+          describe('when a user is searching', () => {
+            const mockSearchTerm = 'foobar';
+
+            beforeEach(async () => {
+              projectMilestonesSpy = jest
+                .fn()
+                .mockResolvedValueOnce(emptyProjectMilestonesResponse);
+              await createComponentWithApollo({ projectMilestonesSpy });
+
+              await clickEdit();
+            });
+
+            it('sends a projectMilestones query with the entered search term "foo"', async () => {
+              findSearchBox().vm.$emit('input', mockSearchTerm);
+              await wrapper.vm.$nextTick();
+
+              // Account for debouncing
+              jest.runAllTimers();
+
+              expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, {
+                fullPath: mockIssue.projectPath,
+                title: mockSearchTerm,
+                state: 'active',
+              });
+            });
+          });
+        });
+      });
+
+      describe('currentAttributes', () => {
+        it('should call createFlash if currentAttributes query fails', async () => {
+          await createComponentWithApollo({
+            currentMilestoneSpy: jest.fn().mockRejectedValue(error),
+          });
+
+          expect(createFlash).toHaveBeenCalledWith({
+            message: wrapper.vm.i18n.currentFetchError,
+            captureError: true,
+            error: expect.any(Error),
+          });
+        });
+      });
+    });
+  });
+});
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index b052038661a6dbabd4fb109335ad74084402b9ed..2c0a213df6da9645b7e4d94d71a6ac6b9576a2e2 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -513,4 +513,83 @@ export const participantsQueryResponse = {
   },
 };
 
+export const mockGroupPath = 'gitlab-org';
+export const mockProjectPath = `${mockGroupPath}/some-project`;
+
+export const mockIssue = {
+  projectPath: mockProjectPath,
+  iid: '1',
+  groupPath: mockGroupPath,
+};
+
+export const mockIssueId = 'gid://gitlab/Issue/1';
+
+export const mockMilestone1 = {
+  __typename: 'Milestone',
+  id: 'gid://gitlab/Milestone/1',
+  title: 'Foobar Milestone',
+  webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1',
+  state: 'active',
+};
+
+export const mockMilestone2 = {
+  __typename: 'Milestone',
+  id: 'gid://gitlab/Milestone/2',
+  title: 'Awesome Milestone',
+  webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2',
+  state: 'active',
+};
+
+export const mockProjectMilestonesResponse = {
+  data: {
+    workspace: {
+      attributes: {
+        nodes: [mockMilestone1, mockMilestone2],
+      },
+      __typename: 'MilestoneConnection',
+    },
+    __typename: 'Project',
+  },
+};
+
+export const noCurrentMilestoneResponse = {
+  data: {
+    workspace: {
+      issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' },
+      __typename: 'Project',
+    },
+  },
+};
+
+export const mockMilestoneMutationResponse = {
+  data: {
+    issuableSetAttribute: {
+      errors: [],
+      issuable: {
+        id: 'gid://gitlab/Issue/1',
+        attribute: {
+          id: 'gid://gitlab/Milestone/2',
+          title: 'Awesome Milestone',
+          state: 'active',
+          __typename: 'Milestone',
+        },
+        __typename: 'Issue',
+      },
+      __typename: 'UpdateIssuePayload',
+    },
+  },
+};
+
+export const emptyProjectMilestonesResponse = {
+  data: {
+    workspace: {
+      attributes: {
+        nodes: [],
+      },
+      __typename: 'MilestoneConnection',
+    },
+    __typename: 'Project',
+  },
+};
+
 export default mockData;