From e3110cfe9c0aaad403b670d3b327b4aa35e80b28 Mon Sep 17 00:00:00 2001
From: Jack Chapman <jachapman@gitlab.com>
Date: Fri, 25 Oct 2024 15:19:18 +0000
Subject: [PATCH] Allow vue router navigation for linked items

If possible, use Vue Router to navigate quickly
between adjacent work item routes when clicking
linked items.
---
 .../shared/work_item_link_child_contents.vue  | 44 ++++++++++++++++++-
 .../components/work_item_detail_modal.vue     |  4 ++
 .../work_item_links/work_item_tree.vue        |  4 ++
 .../javascripts/work_items/constants.js       |  2 +
 .../work_item_link_child_contents_spec.js     | 38 +++++++++++++++-
 spec/frontend/work_items/mock_data.js         |  5 ++-
 6 files changed, 92 insertions(+), 5 deletions(-)

diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
index 88639e6f0c923..a5459f98f0ab8 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
@@ -10,8 +10,10 @@ import {
   GlTooltip,
   GlTooltipDirective,
 } from '@gitlab/ui';
+import { escapeRegExp } from 'lodash';
 import { __, s__, sprintf } from '~/locale';
 import { isScopedLabel } from '~/lib/utils/common_utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
 import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue';
 import RichTimestampTooltip from '../rich_timestamp_tooltip.vue';
 import WorkItemTypeIcon from '../work_item_type_icon.vue';
@@ -22,6 +24,7 @@ import {
   WIDGET_TYPE_ASSIGNEES,
   WIDGET_TYPE_LABELS,
   LINKED_CATEGORIES_MAP,
+  INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION,
 } from '../../constants';
 import WorkItemRelationshipIcons from './work_item_relationship_icons.vue';
 
@@ -50,6 +53,14 @@ export default {
   directives: {
     GlTooltip: GlTooltipDirective,
   },
+  mixins: [glFeatureFlagMixin()],
+  inject: {
+    preventRouterNav: {
+      from: INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION,
+      default: false,
+    },
+    isGroup: {},
+  },
   props: {
     childItem: {
       type: Object,
@@ -137,11 +148,40 @@ export default {
         return item.linkType !== LINKED_CATEGORIES_MAP.RELATES_TO;
       });
     },
+    issueAsWorkItem() {
+      return (
+        !this.isGroup &&
+        this.glFeatures.workItemsViewPreference &&
+        gon.current_user_use_work_items_view
+      );
+    },
   },
   methods: {
     showScopedLabel(label) {
       return isScopedLabel(label) && this.allowsScopedLabels;
     },
+    handleTitleClick(e) {
+      const workItem = this.childItem;
+      if (e.metaKey || e.ctrlKey) {
+        return;
+      }
+      const escapedFullPath = escapeRegExp(this.workItemFullPath);
+      // eslint-disable-next-line no-useless-escape
+      const regex = new RegExp(`groups\/${escapedFullPath}\/-\/(work_items|epics)\/\\d+`);
+      const isWorkItemPath = regex.test(workItem.webUrl);
+
+      if (!(isWorkItemPath || this.issueAsWorkItem) || this.preventRouterNav) {
+        this.$emit('click', e);
+      } else {
+        e.preventDefault();
+        this.$router.push({
+          name: 'workItem',
+          params: {
+            iid: workItem.iid,
+          },
+        });
+      }
+    },
   },
 };
 </script>
@@ -171,10 +211,10 @@ export default {
             />
           </span>
           <gl-link
-            :href="childItem.webUrl"
+            :href="childItemWebUrl"
             :class="{ '!gl-text-secondary': !isChildItemOpen }"
             class="gl-hyphens-auto gl-break-words gl-font-semibold"
-            @click.exact="$emit('click', $event)"
+            @click.exact="handleTitleClick"
             @mouseover="$emit('mouseover')"
             @mouseout="$emit('mouseout')"
           >
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index 4cf34a5c6af18..1ad6c053a518f 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -3,6 +3,7 @@ import { GlAlert, GlModal } from '@gitlab/ui';
 import { s__ } from '~/locale';
 import { removeHierarchyChild } from '../graphql/cache_utils';
 import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
+import { INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION } from '../constants';
 
 export default {
   WORK_ITEM_DETAIL_MODAL_ID: 'work-item-detail-modal',
@@ -15,6 +16,9 @@ export default {
     GlModal,
     WorkItemDetail: () => import('./work_item_detail.vue'),
   },
+  provide: {
+    [INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION]: true,
+  },
   props: {
     parentId: {
       type: String,
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index fe3e798cb1a6a..3a08895259da4 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -16,6 +16,7 @@ import {
   WORKITEM_TREE_SHOWLABELS_LOCALSTORAGEKEY,
   WORK_ITEM_TYPE_VALUE_EPIC,
   WIDGET_TYPE_HIERARCHY,
+  INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION,
 } from '../../constants';
 import {
   findHierarchyWidgets,
@@ -48,6 +49,9 @@ export default {
     WorkItemRolledUpData,
   },
   inject: ['hasSubepicsFeature'],
+  provide: {
+    [INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION]: true,
+  },
   props: {
     fullPath: {
       type: String,
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index ae7a528df3a6e..631c6c571701f 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -372,3 +372,5 @@ export const WORK_ITEM_BASE_ROUTE_MAP = {
 export const WORKITEM_LINKS_SHOWLABELS_LOCALSTORAGEKEY = 'workItemLinks.showLabels';
 export const WORKITEM_TREE_SHOWLABELS_LOCALSTORAGEKEY = 'workItemTree.showLabels';
 export const WORKITEM_RELATIONSHIPS_SHOWLABELS_LOCALSTORAGEKEY = 'workItemRelationships.showLabels';
+
+export const INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION = 'injection:prevent-router-navigation';
diff --git a/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js b/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js
index d262db3c3384f..d63d446b03873 100644
--- a/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js
+++ b/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js
@@ -14,6 +14,7 @@ import WorkItemRelationshipIcons from '~/work_items/components/shared/work_item_
 
 import {
   workItemTask,
+  workItemEpic,
   workItemObjectiveWithChild,
   confidentialWorkItemTask,
   closedWorkItemTask,
@@ -32,6 +33,8 @@ describe('WorkItemLinkChildContents', () => {
   const mockAssignees = ASSIGNEES.assignees.nodes;
   const mockLabels = LABELS.labels.nodes;
 
+  const mockRouterPush = jest.fn();
+
   const findStatusBadgeComponent = () =>
     wrapper.findByTestId('item-status-icon').findComponent(WorkItemStateBadge);
   const findConfidentialIconComponent = () => wrapper.findByTestId('confidential-icon');
@@ -48,13 +51,23 @@ describe('WorkItemLinkChildContents', () => {
     canUpdate = true,
     childItem = workItemTask,
     showLabels = true,
+    workItemFullPath = 'test-project-path',
+    isGroup = false,
   } = {}) => {
     wrapper = shallowMountExtended(WorkItemLinkChildContents, {
       propsData: {
         canUpdate,
         childItem,
         showLabels,
-        workItemFullPath: 'test-project-path',
+        workItemFullPath,
+      },
+      provide: {
+        isGroup,
+      },
+      mocks: {
+        $router: {
+          push: mockRouterPush,
+        },
       },
     });
   };
@@ -129,6 +142,29 @@ describe('WorkItemLinkChildContents', () => {
 
       expect(wrapper.emitted('click')).toEqual([[eventObj]]);
     });
+
+    describe('when the linked item can be navigated to via Vue Router', () => {
+      const preventDefault = jest.fn();
+      beforeEach(() => {
+        createComponent({
+          childItem: workItemEpic,
+          isGroup: true,
+          workItemFullPath: 'gitlab-org/gitlab-test',
+        });
+
+        findTitleEl().vm.$emit('click', { preventDefault });
+      });
+
+      it('pushes a new router state', () => {
+        expect(mockRouterPush).toHaveBeenCalled();
+      });
+      it('prevents the default event behaviour', () => {
+        expect(preventDefault).toHaveBeenCalled();
+      });
+      it('does not emit a click event', () => {
+        expect(wrapper.emitted('click')).not.toBeDefined();
+      });
+    });
   });
 
   describe('item metadata', () => {
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 3b639ddb443d9..2479cbfcd60d5 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -1898,14 +1898,15 @@ export const workItemEpic = {
   namespace: {
     __typename: 'Project',
     id: '1',
-    fullPath: 'test-project-path',
+    fullPath: 'gitlab-org/gitlab-test',
     name: 'Project name',
   },
   createdAt: '2022-08-03T12:41:54Z',
   closedAt: null,
-  webUrl: '/gitlab-org/gitlab-test/-/work_items/4',
+  webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/gitlab-test/-/work_items/4',
   widgets: [
     workItemObjectiveMetadataWidgets.ASSIGNEES,
+    workItemObjectiveMetadataWidgets.LINKED_ITEMS,
     {
       type: 'HIERARCHY',
       hasChildren: false,
-- 
GitLab