From be757ff7ba12ae440777bbbb3c09977f9a014161 Mon Sep 17 00:00:00 2001
From: Jack Chapman <jachapman@gitlab.com>
Date: Fri, 19 Apr 2024 19:19:58 +0000
Subject: [PATCH] Add actions dropdown to work item links

Adds disclosure dropdown to header area
of work item tree widget which contains
a link to a pre-filtered roadmap page.

Changelog: added
---
 .../work_item_links/work_item_tree.vue        | 18 ++++-
 .../work_item_tree_actions.vue                | 71 +++++++++++++++++++
 app/assets/javascripts/work_items/utils.js    | 12 ++++
 locale/gitlab.pot                             |  6 ++
 .../work_item_tree_actions_spec.js            | 54 ++++++++++++++
 .../work_item_links/work_item_tree_spec.js    | 21 ++++++
 spec/frontend/work_items/utils_spec.js        |  8 +++
 7 files changed, 189 insertions(+), 1 deletion(-)
 create mode 100644 app/assets/javascripts/work_items/components/work_item_links/work_item_tree_actions.vue
 create mode 100644 spec/frontend/work_items/components/work_item_links/work_item_tree_actions_spec.js

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 48d50d88df956..e34923cca9c80 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
@@ -9,6 +9,7 @@ import {
   WORK_ITEMS_TYPE_MAP,
   WORK_ITEM_TYPE_ENUM_OBJECTIVE,
   WORK_ITEM_TYPE_ENUM_KEY_RESULT,
+  WORK_ITEM_TYPE_ENUM_EPIC,
   I18N_WORK_ITEM_SHOW_LABELS,
   CHILD_ITEMS_ANCHOR,
 } from '../../constants';
@@ -18,6 +19,7 @@ import WidgetWrapper from '../widget_wrapper.vue';
 import WorkItemActionsSplitButton from './work_item_actions_split_button.vue';
 import WorkItemLinksForm from './work_item_links_form.vue';
 import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
+import WorkItemTreeActions from './work_item_tree_actions.vue';
 
 export default {
   FORM_TYPES,
@@ -29,6 +31,7 @@ export default {
     WidgetWrapper,
     WorkItemLinksForm,
     WorkItemChildrenWrapper,
+    WorkItemTreeActions,
     GlToggle,
   },
   props: {
@@ -121,6 +124,14 @@ export default {
         };
       });
     },
+    /**
+     * Based on the type of work item, should the actions menu be rendered?
+     *
+     * Currently only renders for `epic` work items
+     */
+    canShowActionsMenu() {
+      return this.workItemType.toUpperCase() === WORK_ITEM_TYPE_ENUM_EPIC && this.workItemIid;
+    },
   },
   methods: {
     genericActionItems(workItem) {
@@ -179,7 +190,12 @@ export default {
         label-id="relationship-toggle-labels"
         @change="showLabels = $event"
       />
-      <work-item-actions-split-button v-if="canUpdate" :actions="addItemsActions" />
+      <work-item-actions-split-button v-if="canUpdate" :actions="addItemsActions" class="gl-mr-3" />
+      <work-item-tree-actions
+        v-if="canShowActionsMenu"
+        :work-item-iid="workItemIid"
+        :full-path="fullPath"
+      />
     </template>
     <template #body>
       <div class="gl-new-card-content gl-px-0">
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_actions.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_actions.vue
new file mode 100644
index 0000000000000..2a975cdb6804c
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_actions.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { workItemRoadmapPath } from '../../utils';
+
+export default {
+  i18n: {
+    moreActions: s__('WorkItem|More actions'),
+  },
+  components: {
+    GlDisclosureDropdown,
+  },
+  directives: {
+    GlTooltip: GlTooltipDirective,
+  },
+  props: {
+    workItemIid: {
+      type: String,
+      required: true,
+    },
+    fullPath: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      isDropdownVisible: false,
+    };
+  },
+  computed: {
+    tooltipText() {
+      return !this.isDropdownVisible ? this.$options.i18n.moreActions : '';
+    },
+    actionDropdownItems() {
+      return [
+        {
+          text: s__('WorkItem|View on a roadmap'),
+          href: workItemRoadmapPath(this.fullPath, this.workItemIid),
+        },
+      ];
+    },
+  },
+  methods: {
+    showDropdown() {
+      this.isDropdownVisible = true;
+    },
+    hideDropdown() {
+      this.isDropdownVisible = false;
+    },
+  },
+};
+</script>
+<template>
+  <div>
+    <gl-disclosure-dropdown
+      ref="workItemsMoreActions"
+      v-gl-tooltip="tooltipText"
+      icon="ellipsis_v"
+      text-sr-only
+      :toggle-text="$options.i18n.moreActions"
+      :items="actionDropdownItems"
+      size="small"
+      category="tertiary"
+      no-caret
+      placement="bottom-end"
+      @shown="showDropdown"
+      @hidden="hideDropdown"
+    />
+  </div>
+</template>
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index 534ac1ccdf24c..3274855617e8f 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -82,3 +82,15 @@ export const isReference = (input) => {
 export const sortNameAlphabetically = (a, b) => {
   return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
 };
+
+/**
+ * Builds path to Roadmap page pre-filtered by
+ * work item iid
+ *
+ * @param {string} fullPath the path to the group
+ * @param {string} iid the iid of the work item
+ */
+export const workItemRoadmapPath = (fullPath, iid) => {
+  const domain = gon.relative_url_root || '';
+  return `${domain}/groups/${fullPath}/-/roadmap?epic_iid=${iid}`;
+};
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ad39104531418..b772bf9ce6f08 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -58330,6 +58330,9 @@ msgstr ""
 msgid "WorkItem|Mint green"
 msgstr ""
 
+msgid "WorkItem|More actions"
+msgstr ""
+
 msgid "WorkItem|Must be a valid hex code"
 msgstr ""
 
@@ -58573,6 +58576,9 @@ msgstr ""
 msgid "WorkItem|View current version"
 msgstr ""
 
+msgid "WorkItem|View on a roadmap"
+msgstr ""
+
 msgid "WorkItem|Work item"
 msgstr ""
 
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_actions_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_actions_spec.js
new file mode 100644
index 0000000000000..d2cc46ee45931
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_actions_spec.js
@@ -0,0 +1,54 @@
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+import WorkItemTreeActions from '~/work_items/components/work_item_links/work_item_tree_actions.vue';
+
+describe('WorkItemTreeActions', () => {
+  /**
+   * @type {import('@vue/test-utils').Wrapper}
+   */
+  let wrapper;
+
+  const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+  const findTooltip = () => getBinding(findDropdown().element, 'gl-tooltip');
+  const findDropdownButton = () => findDropdown().find('button');
+  const findLink = () => findDropdown().find('a');
+
+  const createComponent = () => {
+    wrapper = mount(WorkItemTreeActions, {
+      propsData: {
+        workItemIid: '2',
+        fullPath: 'project/group',
+      },
+      directives: {
+        GlTooltip: createMockDirective('gl-tooltip'),
+      },
+    });
+  };
+
+  beforeEach(() => {
+    createComponent();
+  });
+
+  it('contains the correct tooltip text', () => {
+    expect(findTooltip().value).toBe('More actions');
+  });
+
+  it('does not render the tooltip when the dropdown is shown', async () => {
+    await findDropdownButton().trigger('click');
+
+    await nextTick();
+
+    expect(findTooltip().value).toBe('');
+  });
+
+  it('contains a link to the roadmap page', () => {
+    const link = findLink();
+
+    expect(link.text()).toBe('View on a roadmap');
+
+    expect(link.attributes('href')).toBe('/groups/project/group/-/roadmap?epic_iid=2');
+  });
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
index 49a674e73c8b5..237dfb6ee2c22 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
@@ -9,11 +9,14 @@ import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree
 import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
 import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue';
 import WorkItemActionsSplitButton from '~/work_items/components/work_item_links/work_item_actions_split_button.vue';
+import WorkItemTreeActions from '~/work_items/components/work_item_links/work_item_tree_actions.vue';
 import getAllowedWorkItemChildTypes from '~/work_items//graphql/work_item_allowed_children.query.graphql';
 import {
   FORM_TYPES,
   WORK_ITEM_TYPE_ENUM_OBJECTIVE,
   WORK_ITEM_TYPE_ENUM_KEY_RESULT,
+  WORK_ITEM_TYPE_VALUE_EPIC,
+  WORK_ITEM_TYPE_VALUE_OBJECTIVE,
 } from '~/work_items/constants';
 import { childrenWorkItems, allowedChildrenTypesResponse } from '../../mock_data';
 
@@ -28,11 +31,13 @@ describe('WorkItemTree', () => {
   const findWidgetWrapper = () => wrapper.findComponent(WidgetWrapper);
   const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper);
   const findShowLabelsToggle = () => wrapper.findComponent(GlToggle);
+  const findTreeActions = () => wrapper.findComponent(WorkItemTreeActions);
 
   const allowedChildrenTypesHandler = jest.fn().mockResolvedValue(allowedChildrenTypesResponse);
 
   const createComponent = ({
     workItemType = 'Objective',
+    workItemIid = '2',
     parentWorkItemType = 'Objective',
     confidential = false,
     children = childrenWorkItems,
@@ -45,6 +50,7 @@ describe('WorkItemTree', () => {
       propsData: {
         fullPath: 'test/project',
         workItemType,
+        workItemIid,
         parentWorkItemType,
         workItemId: 'gid://gitlab/WorkItem/515',
         confidential,
@@ -163,4 +169,19 @@ describe('WorkItemTree', () => {
       expect(findWorkItemLinkChildrenWrapper().props('showLabels')).toBe(toggleValue);
     },
   );
+
+  describe('action menu', () => {
+    it.each`
+      visible  | workItemType
+      ${true}  | ${WORK_ITEM_TYPE_VALUE_EPIC}
+      ${false} | ${WORK_ITEM_TYPE_VALUE_OBJECTIVE}
+    `(
+      'When displaying a $workItemType, it is $visible that the action menu is rendered',
+      ({ workItemType, visible }) => {
+        createComponent({ workItemType });
+
+        expect(findTreeActions().exists()).toBe(visible);
+      },
+    );
+  });
 });
diff --git a/spec/frontend/work_items/utils_spec.js b/spec/frontend/work_items/utils_spec.js
index c3d6d55da802c..73d19598c4ddf 100644
--- a/spec/frontend/work_items/utils_spec.js
+++ b/spec/frontend/work_items/utils_spec.js
@@ -3,6 +3,7 @@ import {
   markdownPreviewPath,
   isReference,
   getWorkItemIcon,
+  workItemRoadmapPath,
 } from '~/work_items/utils';
 
 describe('autocompleteDataSources', () => {
@@ -72,3 +73,10 @@ describe('isReference', () => {
     expect(isReference(referenceId)).toBe(result);
   });
 });
+
+describe('workItemRoadmapPath', () => {
+  it('constructs a path to the roadmap page', () => {
+    const path = workItemRoadmapPath('project/group', '2');
+    expect(path).toBe('/groups/project/group/-/roadmap?epic_iid=2');
+  });
+});
-- 
GitLab