diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index 40b0fcbb8c6eec02b3a6a87d1ed06efbacc0b1e6..1b54ba766ff3f1877a99b10bb430c637df7a0c2a 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -10,7 +10,14 @@ import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
 import IssuableItem from './issuable_item.vue';
 import IssuableTabs from './issuable_tabs.vue';
 
+const VueDraggable = () => import('vuedraggable');
+
 export default {
+  vueDraggableAttributes: {
+    animation: 200,
+    ghostClass: 'gl-visibility-hidden',
+    tag: 'ul',
+  },
   components: {
     GlSkeletonLoading,
     IssuableTabs,
@@ -18,6 +25,7 @@ export default {
     IssuableItem,
     IssuableBulkEditSidebar,
     GlPagination,
+    VueDraggable,
   },
   props: {
     namespace: {
@@ -127,6 +135,11 @@ export default {
       required: false,
       default: null,
     },
+    isManualOrdering: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
   },
   data() {
     return {
@@ -159,6 +172,9 @@ export default {
         return acc;
       }, []);
     },
+    issuablesWrapper() {
+      return this.isManualOrdering ? VueDraggable : 'ul';
+    },
   },
   watch: {
     issuables(list) {
@@ -208,6 +224,9 @@ export default {
         this.checkedIssuables[issuableId].checked = value;
       });
     },
+    handleVueDraggableUpdate({ newIndex, oldIndex }) {
+      this.$emit('reorder', { newIndex, oldIndex });
+    },
   },
 };
 </script>
@@ -253,13 +272,18 @@ export default {
           <gl-skeleton-loading />
         </li>
       </ul>
-      <ul
+      <component
+        :is="issuablesWrapper"
         v-if="!issuablesLoading && issuables.length"
         class="content-list issuable-list issues-list"
+        :class="{ 'manual-ordering': isManualOrdering }"
+        v-bind="$options.vueDraggableAttributes"
+        @update="handleVueDraggableUpdate"
       >
         <issuable-item
           v-for="issuable in issuables"
           :key="issuableId(issuable)"
+          :class="{ 'gl-cursor-grab': isManualOrdering }"
           :issuable-symbol="issuableSymbol"
           :issuable="issuable"
           :enable-label-permalinks="enableLabelPermalinks"
@@ -284,7 +308,7 @@ export default {
             <slot name="statistics" :issuable="issuable"></slot>
           </template>
         </issuable-item>
-      </ul>
+      </component>
       <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
       <gl-pagination
         v-if="showPaginationControls"
diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
index 0b413ce0b06b9779f3a0cdbe5d994709f85fbd70..51cad662ebfd1517729cbe45ba2ccce93cceaab7 100644
--- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
@@ -30,6 +30,9 @@ import issueableEventHub from '../eventhub';
 import { emptyStateHelper } from '../service_desk_helper';
 import Issuable from './issuable.vue';
 
+/**
+ * @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead
+ */
 export default {
   LOADING_LIST_ITEMS_LENGTH,
   directives: {
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index c57fa5a82fa421a665598ec60c95c1463f6567ae..e4bb3ecabd01eacd5659ed24460ea62444e187c1 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -4,14 +4,26 @@ import { toNumber } from 'lodash';
 import createFlash from '~/flash';
 import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
 import { IssuableStatus } from '~/issue_show/constants';
-import { PAGE_SIZE } from '~/issues_list/constants';
+import {
+  CREATED_DESC,
+  PAGE_SIZE,
+  RELATIVE_POSITION_ASC,
+  sortOptions,
+  sortParams,
+} from '~/issues_list/constants';
 import axios from '~/lib/utils/axios_utils';
 import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
 import { __ } from '~/locale';
 import IssueCardTimeInfo from './issue_card_time_info.vue';
 
 export default {
+  CREATED_DESC,
   PAGE_SIZE,
+  sortOptions,
+  sortParams,
+  i18n: {
+    reorderError: __('An error occurred while reordering issues.'),
+  },
   components: {
     GlIcon,
     IssuableList,
@@ -28,12 +40,23 @@ export default {
     fullPath: {
       default: '',
     },
+    issuesPath: {
+      default: '',
+    },
   },
   data() {
+    const orderBy = getParameterByName('order_by');
+    const sort = getParameterByName('sort');
+    const sortKey = Object.keys(sortParams).find(
+      (key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort,
+    );
+
     return {
       currentPage: toNumber(getParameterByName('page')) || 1,
+      filters: sortParams[sortKey] || {},
       isLoading: false,
       issues: [],
+      sortKey: sortKey || CREATED_DESC,
       totalIssues: 0,
     };
   },
@@ -42,8 +65,12 @@ export default {
       return {
         page: this.currentPage,
         state: IssuableStatus.Open,
+        ...this.filters,
       };
     },
+    isManualOrdering() {
+      return this.sortKey === RELATIVE_POSITION_ASC;
+    },
   },
   mounted() {
     this.fetchIssues();
@@ -59,6 +86,7 @@ export default {
             per_page: this.$options.PAGE_SIZE,
             state: IssuableStatus.Open,
             with_labels_details: true,
+            ...this.filters,
           },
         })
         .then(({ data, headers }) => {
@@ -76,6 +104,44 @@ export default {
     handlePageChange(page) {
       this.fetchIssues(page);
     },
+    handleReorder({ newIndex, oldIndex }) {
+      const issueToMove = this.issues[oldIndex];
+      const isDragDropDownwards = newIndex > oldIndex;
+      const isMovingToBeginning = newIndex === 0;
+      const isMovingToEnd = newIndex === this.issues.length - 1;
+
+      let moveBeforeId;
+      let moveAfterId;
+
+      if (isDragDropDownwards) {
+        const afterIndex = isMovingToEnd ? newIndex : newIndex + 1;
+        moveBeforeId = this.issues[newIndex].id;
+        moveAfterId = this.issues[afterIndex].id;
+      } else {
+        const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1;
+        moveBeforeId = this.issues[beforeIndex].id;
+        moveAfterId = this.issues[newIndex].id;
+      }
+
+      return axios
+        .put(`${this.issuesPath}/${issueToMove.iid}/reorder`, {
+          move_before_id: isMovingToBeginning ? null : moveBeforeId,
+          move_after_id: isMovingToEnd ? null : moveAfterId,
+        })
+        .then(() => {
+          // Move issue to new position in list
+          this.issues.splice(oldIndex, 1);
+          this.issues.splice(newIndex, 0, issueToMove);
+        })
+        .catch(() => {
+          createFlash({ message: this.$options.i18n.reorderError });
+        });
+    },
+    handleSort(value) {
+      this.sortKey = value;
+      this.filters = sortParams[value];
+      this.fetchIssues();
+    },
   },
 };
 </script>
@@ -86,11 +152,13 @@ export default {
     recent-searches-storage-key="issues"
     :search-input-placeholder="__('Search or filter results…')"
     :search-tokens="[]"
-    :sort-options="[]"
+    :sort-options="$options.sortOptions"
+    :initial-sort-by="sortKey"
     :issuables="issues"
     :tabs="[]"
     current-tab=""
     :issuables-loading="isLoading"
+    :is-manual-ordering="isManualOrdering"
     :show-pagination-controls="true"
     :total-items="totalIssues"
     :current-page="currentPage"
@@ -98,6 +166,8 @@ export default {
     :next-page="currentPage + 1"
     :url-params="urlParams"
     @page-change="handlePageChange"
+    @reorder="handleReorder"
+    @sort="handleSort"
   >
     <template #timeframe="{ issuable = {} }">
       <issue-card-time-info :issue="issuable" />
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index f008ba1bf4adb396b06c41cebeb33f05cc882d2c..f6f23af80ba40ceb80baa7b5ccf0a49037b7b6ec 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -54,3 +54,191 @@ export const availableSortOptionsJira = [
 ];
 
 export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
+
+export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
+export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
+export const CREATED_ASC = 'CREATED_ASC';
+export const CREATED_DESC = 'CREATED_DESC';
+export const DUE_DATE_ASC = 'DUE_DATE_ASC';
+export const DUE_DATE_DESC = 'DUE_DATE_DESC';
+export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC';
+export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC';
+export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC';
+export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC';
+export const POPULARITY_ASC = 'POPULARITY_ASC';
+export const POPULARITY_DESC = 'POPULARITY_DESC';
+export const PRIORITY_ASC = 'PRIORITY_ASC';
+export const PRIORITY_DESC = 'PRIORITY_DESC';
+export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC';
+export const UPDATED_ASC = 'UPDATED_ASC';
+export const UPDATED_DESC = 'UPDATED_DESC';
+export const WEIGHT_ASC = 'WEIGHT_ASC';
+export const WEIGHT_DESC = 'WEIGHT_DESC';
+
+const SORT_ASC = 'asc';
+const SORT_DESC = 'desc';
+
+const BLOCKING_ISSUES = 'blocking_issues';
+
+export const sortParams = {
+  [PRIORITY_ASC]: {
+    order_by: PRIORITY,
+    sort: SORT_ASC,
+  },
+  [PRIORITY_DESC]: {
+    order_by: PRIORITY,
+    sort: SORT_DESC,
+  },
+  [CREATED_ASC]: {
+    order_by: CREATED_AT,
+    sort: SORT_ASC,
+  },
+  [CREATED_DESC]: {
+    order_by: CREATED_AT,
+    sort: SORT_DESC,
+  },
+  [UPDATED_ASC]: {
+    order_by: UPDATED_AT,
+    sort: SORT_ASC,
+  },
+  [UPDATED_DESC]: {
+    order_by: UPDATED_AT,
+    sort: SORT_DESC,
+  },
+  [MILESTONE_DUE_ASC]: {
+    order_by: MILESTONE_DUE,
+    sort: SORT_ASC,
+  },
+  [MILESTONE_DUE_DESC]: {
+    order_by: MILESTONE_DUE,
+    sort: SORT_DESC,
+  },
+  [DUE_DATE_ASC]: {
+    order_by: DUE_DATE,
+    sort: SORT_ASC,
+  },
+  [DUE_DATE_DESC]: {
+    order_by: DUE_DATE,
+    sort: SORT_DESC,
+  },
+  [POPULARITY_ASC]: {
+    order_by: POPULARITY,
+    sort: SORT_ASC,
+  },
+  [POPULARITY_DESC]: {
+    order_by: POPULARITY,
+    sort: SORT_DESC,
+  },
+  [LABEL_PRIORITY_ASC]: {
+    order_by: LABEL_PRIORITY,
+    sort: SORT_ASC,
+  },
+  [LABEL_PRIORITY_DESC]: {
+    order_by: LABEL_PRIORITY,
+    sort: SORT_DESC,
+  },
+  [RELATIVE_POSITION_ASC]: {
+    order_by: RELATIVE_POSITION,
+    per_page: 100,
+    sort: SORT_ASC,
+  },
+  [WEIGHT_ASC]: {
+    order_by: WEIGHT,
+    sort: SORT_ASC,
+  },
+  [WEIGHT_DESC]: {
+    order_by: WEIGHT,
+    sort: SORT_DESC,
+  },
+  [BLOCKING_ISSUES_ASC]: {
+    order_by: BLOCKING_ISSUES,
+    sort: SORT_ASC,
+  },
+  [BLOCKING_ISSUES_DESC]: {
+    order_by: BLOCKING_ISSUES,
+    sort: SORT_DESC,
+  },
+};
+
+export const sortOptions = [
+  {
+    id: 1,
+    title: __('Priority'),
+    sortDirection: {
+      ascending: PRIORITY_ASC,
+      descending: PRIORITY_DESC,
+    },
+  },
+  {
+    id: 2,
+    title: __('Created date'),
+    sortDirection: {
+      ascending: CREATED_ASC,
+      descending: CREATED_DESC,
+    },
+  },
+  {
+    id: 3,
+    title: __('Last updated'),
+    sortDirection: {
+      ascending: UPDATED_ASC,
+      descending: UPDATED_DESC,
+    },
+  },
+  {
+    id: 4,
+    title: __('Milestone due date'),
+    sortDirection: {
+      ascending: MILESTONE_DUE_ASC,
+      descending: MILESTONE_DUE_DESC,
+    },
+  },
+  {
+    id: 5,
+    title: __('Due date'),
+    sortDirection: {
+      ascending: DUE_DATE_ASC,
+      descending: DUE_DATE_DESC,
+    },
+  },
+  {
+    id: 6,
+    title: __('Popularity'),
+    sortDirection: {
+      ascending: POPULARITY_ASC,
+      descending: POPULARITY_DESC,
+    },
+  },
+  {
+    id: 7,
+    title: __('Label priority'),
+    sortDirection: {
+      ascending: LABEL_PRIORITY_ASC,
+      descending: LABEL_PRIORITY_DESC,
+    },
+  },
+  {
+    id: 8,
+    title: __('Manual'),
+    sortDirection: {
+      ascending: RELATIVE_POSITION_ASC,
+      descending: RELATIVE_POSITION_ASC,
+    },
+  },
+  {
+    id: 9,
+    title: __('Weight'),
+    sortDirection: {
+      ascending: WEIGHT_ASC,
+      descending: WEIGHT_DESC,
+    },
+  },
+  {
+    id: 10,
+    title: __('Blocking'),
+    sortDirection: {
+      ascending: BLOCKING_ISSUES_ASC,
+      descending: BLOCKING_ISSUES_DESC,
+    },
+  },
+];
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index a283cbdc86bd3f40adaf8c4372024854a5564477..b55ebf8dbdbb89af3e387550d20b3613869581e8 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -78,6 +78,7 @@ export function initIssuesListApp() {
     hasBlockedIssuesFeature,
     hasIssuableHealthStatusFeature,
     hasIssueWeightsFeature,
+    issuesPath,
   } = el.dataset;
 
   return new Vue({
@@ -91,6 +92,7 @@ export function initIssuesListApp() {
       hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
       hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
       hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
+      issuesPath,
     },
     render: (createComponent) => createComponent(IssuesListApp),
   });
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 97a8f681fafacc6759c533f70e83498d5f5f9ad1..6952c5def8ccef7da9422d7999981dca47fbb10d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -58,7 +58,7 @@ export default {
       type: String,
       required: false,
       default: '',
-      validator: (value) => value === '' || /(_desc)|(_asc)/g.test(value),
+      validator: (value) => value === '' || /(_desc)|(_asc)/gi.test(value),
     },
     showCheckbox: {
       type: Boolean,
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 1d300c42768213cb8fd53c1309d54588d8042356..9c54b117f4c0ccdc122da650b9de64e079f9904b 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -24,7 +24,8 @@
       full_path: @project.full_path,
       has_blocked_issues_feature: Gitlab.ee? && @project.feature_available?(:blocked_issues).to_s,
       has_issuable_health_status_feature: Gitlab.ee? && @project.feature_available?(:issuable_health_status).to_s,
-      has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s } }
+      has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s,
+      issues_path: project_issues_path(@project) } }
   - else
     = render 'shared/issuable/search_bar', type: :issues
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 22d167c205af57f4032f9e3b7b02fa531eb732eb..89511d3ea7c2fdf039795b4be0b519ecf70f5759 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -4856,6 +4856,9 @@ msgstr[1] ""
 msgid "Blocked issue"
 msgstr ""
 
+msgid "Blocking"
+msgstr ""
+
 msgid "Blocking issues"
 msgstr ""
 
@@ -17730,6 +17733,9 @@ msgstr ""
 msgid "Label lists show all issues with the selected label."
 msgstr ""
 
+msgid "Label priority"
+msgstr ""
+
 msgid "Label was created"
 msgstr ""
 
@@ -18619,6 +18625,9 @@ msgstr ""
 msgid "Manifest import"
 msgstr ""
 
+msgid "Manual"
+msgstr ""
+
 msgid "ManualOrdering|Couldn't save the order of the issues"
 msgstr ""
 
@@ -19685,6 +19694,9 @@ msgid_plural "Milestones"
 msgstr[0] ""
 msgstr[1] ""
 
+msgid "Milestone due date"
+msgstr ""
+
 msgid "Milestone lists not available with your current license"
 msgstr ""
 
@@ -22939,6 +22951,9 @@ msgstr ""
 msgid "Policy project doesn't exists"
 msgstr ""
 
+msgid "Popularity"
+msgstr ""
+
 msgid "Postman collection"
 msgstr ""
 
@@ -23128,6 +23143,9 @@ msgstr ""
 msgid "Prioritized label"
 msgstr ""
 
+msgid "Priority"
+msgstr ""
+
 msgid "Private"
 msgstr ""
 
diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
index 9c57233548c670b5a79a73634062ec57febd4945..53f5d7874e11e94f4236fc4f4ebd6b05c8b0041a 100644
--- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
@@ -1,5 +1,6 @@
 import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import VueDraggable from 'vuedraggable';
 
 import { TEST_HOST } from 'helpers/test_constants';
 
@@ -11,7 +12,7 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte
 import { mockIssuableListProps, mockIssuables } from '../mock_data';
 
 const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
-  mount(IssuableListRoot, {
+  shallowMount(IssuableListRoot, {
     propsData: props,
     data() {
       return data;
@@ -24,20 +25,28 @@ const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
       <p class="js-issuable-empty-state">Issuable empty state</p>
     `,
     },
+    stubs: {
+      IssuableTabs,
+    },
   });
 
 describe('IssuableListRoot', () => {
   let wrapper;
 
-  beforeEach(() => {
-    wrapper = createComponent();
-  });
+  const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
+  const findGlPagination = () => wrapper.findComponent(GlPagination);
+  const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
+  const findVueDraggable = () => wrapper.findComponent(VueDraggable);
 
   afterEach(() => {
     wrapper.destroy();
   });
 
   describe('computed', () => {
+    beforeEach(() => {
+      wrapper = createComponent();
+    });
+
     const mockCheckedIssuables = {
       [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
       [mockIssuables[1].iid]: { checked: true, issuable: mockIssuables[1] },
@@ -108,6 +117,10 @@ describe('IssuableListRoot', () => {
   });
 
   describe('watch', () => {
+    beforeEach(() => {
+      wrapper = createComponent();
+    });
+
     describe('issuables', () => {
       it('populates `checkedIssuables` prop with all issuables', async () => {
         wrapper.setProps({
@@ -147,6 +160,10 @@ describe('IssuableListRoot', () => {
   });
 
   describe('methods', () => {
+    beforeEach(() => {
+      wrapper = createComponent();
+    });
+
     describe('issuableId', () => {
       it('returns id value from provided issuable object', () => {
         expect(wrapper.vm.issuableId({ id: 1 })).toBe(1);
@@ -171,12 +188,16 @@ describe('IssuableListRoot', () => {
   });
 
   describe('template', () => {
+    beforeEach(() => {
+      wrapper = createComponent();
+    });
+
     it('renders component container element with class "issuable-list-container"', () => {
       expect(wrapper.classes()).toContain('issuable-list-container');
     });
 
     it('renders issuable-tabs component', () => {
-      const tabsEl = wrapper.find(IssuableTabs);
+      const tabsEl = findIssuableTabs();
 
       expect(tabsEl.exists()).toBe(true);
       expect(tabsEl.props()).toMatchObject({
@@ -187,14 +208,14 @@ describe('IssuableListRoot', () => {
     });
 
     it('renders contents for slot "nav-actions" within issuable-tab component', () => {
-      const buttonEl = wrapper.find(IssuableTabs).find('button.js-new-issuable');
+      const buttonEl = findIssuableTabs().find('button.js-new-issuable');
 
       expect(buttonEl.exists()).toBe(true);
       expect(buttonEl.text()).toBe('New issuable');
     });
 
     it('renders filtered-search-bar component', () => {
-      const searchEl = wrapper.find(FilteredSearchBar);
+      const searchEl = findFilteredSearchBar();
       const {
         namespace,
         recentSearchesStorageKey,
@@ -224,11 +245,13 @@ describe('IssuableListRoot', () => {
 
       await wrapper.vm.$nextTick();
 
-      expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(wrapper.vm.skeletonItemCount);
+      expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength(
+        wrapper.vm.skeletonItemCount,
+      );
     });
 
     it('renders issuable-item component for each item within `issuables` array', () => {
-      const itemsEl = wrapper.findAll(IssuableItem);
+      const itemsEl = wrapper.findAllComponents(IssuableItem);
       const mockIssuable = mockIssuableListProps.issuables[0];
 
       expect(itemsEl).toHaveLength(mockIssuableListProps.issuables.length);
@@ -257,7 +280,7 @@ describe('IssuableListRoot', () => {
 
       await wrapper.vm.$nextTick();
 
-      const paginationEl = wrapper.find(GlPagination);
+      const paginationEl = findGlPagination();
       expect(paginationEl.exists()).toBe(true);
       expect(paginationEl.props()).toMatchObject({
         perPage: 20,
@@ -271,10 +294,8 @@ describe('IssuableListRoot', () => {
   });
 
   describe('events', () => {
-    let wrapperChecked;
-
     beforeEach(() => {
-      wrapperChecked = createComponent({
+      wrapper = createComponent({
         data: {
           checkedIssuables: {
             [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
@@ -283,34 +304,30 @@ describe('IssuableListRoot', () => {
       });
     });
 
-    afterEach(() => {
-      wrapperChecked.destroy();
-    });
-
     it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
-      wrapper.find(IssuableTabs).vm.$emit('click');
+      findIssuableTabs().vm.$emit('click');
 
       expect(wrapper.emitted('click-tab')).toBeTruthy();
     });
 
     it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => {
-      const searchEl = wrapperChecked.find(FilteredSearchBar);
+      const searchEl = findFilteredSearchBar();
 
       searchEl.vm.$emit('checked-input', true);
 
-      await wrapperChecked.vm.$nextTick();
+      await wrapper.vm.$nextTick();
 
       expect(searchEl.emitted('checked-input')).toBeTruthy();
       expect(searchEl.emitted('checked-input').length).toBe(1);
 
-      expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
+      expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
         checked: true,
         issuable: mockIssuables[0],
       });
     });
 
     it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
-      const searchEl = wrapper.find(FilteredSearchBar);
+      const searchEl = findFilteredSearchBar();
 
       searchEl.vm.$emit('onFilter');
       expect(wrapper.emitted('filter')).toBeTruthy();
@@ -319,16 +336,16 @@ describe('IssuableListRoot', () => {
     });
 
     it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
-      const issuableItem = wrapperChecked.findAll(IssuableItem).at(0);
+      const issuableItem = wrapper.findAllComponents(IssuableItem).at(0);
 
       issuableItem.vm.$emit('checked-input', true);
 
-      await wrapperChecked.vm.$nextTick();
+      await wrapper.vm.$nextTick();
 
       expect(issuableItem.emitted('checked-input')).toBeTruthy();
       expect(issuableItem.emitted('checked-input').length).toBe(1);
 
-      expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
+      expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
         checked: true,
         issuable: mockIssuables[0],
       });
@@ -341,8 +358,48 @@ describe('IssuableListRoot', () => {
 
       await wrapper.vm.$nextTick();
 
-      wrapper.find(GlPagination).vm.$emit('input');
+      findGlPagination().vm.$emit('input');
       expect(wrapper.emitted('page-change')).toBeTruthy();
     });
   });
+
+  describe('manual sorting', () => {
+    describe('when enabled', () => {
+      beforeEach(() => {
+        wrapper = createComponent({
+          props: {
+            ...mockIssuableListProps,
+            isManualOrdering: true,
+          },
+        });
+      });
+
+      it('renders VueDraggable component', () => {
+        expect(findVueDraggable().exists()).toBe(true);
+      });
+
+      it('IssuableItem has grab cursor', () => {
+        expect(wrapper.findComponent(IssuableItem).classes()).toContain('gl-cursor-grab');
+      });
+
+      it('emits a "reorder" event when user updates the issue order', () => {
+        const oldIndex = 4;
+        const newIndex = 6;
+
+        findVueDraggable().vm.$emit('update', { oldIndex, newIndex });
+
+        expect(wrapper.emitted('reorder')).toEqual([[{ oldIndex, newIndex }]]);
+      });
+    });
+
+    describe('when disabled', () => {
+      beforeEach(() => {
+        wrapper = createComponent();
+      });
+
+      it('does not render VueDraggable component', () => {
+        expect(findVueDraggable().exists()).toBe(false);
+      });
+    });
+  });
 });
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index 1053e8934c97e59ed087a796a39ce2f0421266d5..0a445973c14a10e23475f322c21ff84d7a52bca1 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -1,16 +1,31 @@
 import { shallowMount } from '@vue/test-utils';
 import AxiosMockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'helpers/test_constants';
 import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
 import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
 import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
+import {
+  CREATED_DESC,
+  PAGE_SIZE,
+  PAGE_SIZE_MANUAL,
+  RELATIVE_POSITION_ASC,
+  sortOptions,
+  sortParams,
+} from '~/issues_list/constants';
 import axios from '~/lib/utils/axios_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+
+jest.mock('~/flash');
 
 describe('IssuesListApp component', () => {
+  const originalWindowLocation = window.location;
   let axiosMock;
   let wrapper;
 
   const fullPath = 'path/to/project';
   const endpoint = 'api/endpoint';
+  const issuesPath = `${fullPath}/-/issues`;
   const state = 'opened';
   const xPage = 1;
   const xTotal = 25;
@@ -29,37 +44,64 @@ describe('IssuesListApp component', () => {
       provide: {
         endpoint,
         fullPath,
+        issuesPath,
       },
     });
 
-  beforeEach(async () => {
+  beforeEach(() => {
     axiosMock = new AxiosMockAdapter(axios);
     axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
-    wrapper = mountComponent();
-    await waitForPromises();
   });
 
   afterEach(() => {
+    window.location = originalWindowLocation;
     axiosMock.reset();
     wrapper.destroy();
   });
 
-  it('renders IssuableList', () => {
-    expect(findIssuableList().props()).toMatchObject({
-      namespace: fullPath,
-      recentSearchesStorageKey: 'issues',
-      searchInputPlaceholder: 'Search or filter results…',
-      showPaginationControls: true,
-      issuables: [],
-      totalItems: xTotal,
-      currentPage: xPage,
-      previousPage: xPage - 1,
-      nextPage: xPage + 1,
-      urlParams: { page: xPage, state },
+  describe('IssuableList', () => {
+    beforeEach(async () => {
+      wrapper = mountComponent();
+      await waitForPromises();
+    });
+
+    it('renders', () => {
+      expect(findIssuableList().props()).toMatchObject({
+        namespace: fullPath,
+        recentSearchesStorageKey: 'issues',
+        searchInputPlaceholder: 'Search or filter results…',
+        sortOptions,
+        initialSortBy: CREATED_DESC,
+        showPaginationControls: true,
+        issuables: [],
+        totalItems: xTotal,
+        currentPage: xPage,
+        previousPage: xPage - 1,
+        nextPage: xPage + 1,
+        urlParams: { page: xPage, state },
+      });
     });
   });
 
-  describe('when "page-change" event is emitted', () => {
+  describe('initial sort', () => {
+    it.each(Object.keys(sortParams))('is set as %s when the url query matches', (sortKey) => {
+      Object.defineProperty(window, 'location', {
+        writable: true,
+        value: {
+          href: setUrlParams(sortParams[sortKey], TEST_HOST),
+        },
+      });
+
+      wrapper = mountComponent();
+
+      expect(findIssuableList().props()).toMatchObject({
+        initialSortBy: sortKey,
+        urlParams: sortParams[sortKey],
+      });
+    });
+  });
+
+  describe('when "page-change" event is emitted by IssuableList', () => {
     const data = [{ id: 10, title: 'title', state }];
     const page = 2;
     const totalItems = 21;
@@ -70,6 +112,8 @@ describe('IssuesListApp component', () => {
         'x-total': totalItems,
       });
 
+      wrapper = mountComponent();
+
       findIssuableList().vm.$emit('page-change', page);
 
       await waitForPromises();
@@ -78,7 +122,7 @@ describe('IssuesListApp component', () => {
     it('fetches issues with expected params', async () => {
       expect(axiosMock.history.get[1].params).toEqual({
         page,
-        per_page: 20,
+        per_page: PAGE_SIZE,
         state,
         with_labels_details: true,
       });
@@ -95,4 +139,75 @@ describe('IssuesListApp component', () => {
       });
     });
   });
+
+  describe('when "reorder" event is emitted by IssuableList', () => {
+    const issueOne = { id: 1, iid: 101, title: 'Issue one' };
+    const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
+    const issueThree = { id: 3, iid: 103, title: 'Issue three' };
+    const issueFour = { id: 4, iid: 104, title: 'Issue four' };
+    const issues = [issueOne, issueTwo, issueThree, issueFour];
+
+    beforeEach(async () => {
+      axiosMock.onGet(endpoint).reply(200, issues, fetchIssuesResponse.headers);
+      wrapper = mountComponent();
+      await waitForPromises();
+    });
+
+    describe('when successful', () => {
+      describe.each`
+        description                       | issueToMove   | oldIndex | newIndex | moveBeforeId    | moveAfterId
+        ${'to the beginning of the list'} | ${issueThree} | ${2}     | ${0}     | ${null}         | ${issueOne.id}
+        ${'down the list'}                | ${issueOne}   | ${0}     | ${1}     | ${issueTwo.id}  | ${issueThree.id}
+        ${'up the list'}                  | ${issueThree} | ${2}     | ${1}     | ${issueOne.id}  | ${issueTwo.id}
+        ${'to the end of the list'}       | ${issueTwo}   | ${1}     | ${3}     | ${issueFour.id} | ${null}
+      `(
+        'when moving issue $description',
+        ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
+          it('makes API call to reorder the issue', async () => {
+            findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
+
+            await waitForPromises();
+
+            expect(axiosMock.history.put[0]).toMatchObject({
+              url: `${issuesPath}/${issueToMove.iid}/reorder`,
+              data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
+            });
+          });
+        },
+      );
+    });
+
+    describe('when unsuccessful', () => {
+      it('displays an error message', async () => {
+        axiosMock.onPut(`${issuesPath}/${issueOne.iid}/reorder`).reply(500);
+
+        findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
+
+        await waitForPromises();
+
+        expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError });
+      });
+    });
+  });
+
+  describe('when "sort" event is emitted by IssuableList', () => {
+    it.each(Object.keys(sortParams))(
+      'fetches issues with correct params for "sort" payload %s',
+      async (sortKey) => {
+        wrapper = mountComponent();
+
+        findIssuableList().vm.$emit('sort', sortKey);
+
+        await waitForPromises();
+
+        expect(axiosMock.history.get[1].params).toEqual({
+          page: xPage,
+          per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+          state,
+          with_labels_details: true,
+          ...sortParams[sortKey],
+        });
+      },
+    );
+  });
 });