From 16f2b6d96a47ed215afcb901efefc52826ed3781 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Fri, 28 Feb 2025 17:05:29 -0500 Subject: [PATCH] Show only linked items in unlink quick action suggestions Changelog: added --- app/assets/javascripts/gfm_auto_complete.js | 54 ++++++++ .../graphql_shared/issuable_client.js | 23 ++++ .../work_item_relationships.vue | 2 + spec/frontend/gfm_auto_complete_spec.js | 121 ++++++++++++++++-- 4 files changed, 190 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 6b4ed25178729d..f71357206108a6 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -9,6 +9,7 @@ import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { s__, __, sprintf } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import SidebarMediator from '~/sidebar/sidebar_mediator'; +import { linkedItems } from '~/graphql_shared/issuable_client'; import { state } from '~/sidebar/components/reviewers/sidebar_reviewers.vue'; import { ISSUABLE_EPIC, @@ -507,6 +508,13 @@ class GfmAutoComplete { } setupIssues($input) { + const instance = this; + const fetchData = this.fetchData.bind(this); + const MEMBER_COMMAND = { + UNLINK: '/unlink', + }; + let command = ''; + $input.atwho({ at: '#', alias: ISSUES_ALIAS, @@ -539,6 +547,52 @@ class GfmAutoComplete { }; }); }, + matcher(flag, subtext) { + const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext); + + // Check if # is followed by '/unlink' command. + command = subtextNodes.find((node) => { + if (Object.values(MEMBER_COMMAND).includes(node)) { + return node; + } + return null; + }); + + const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); + return match && match.length ? match[1] : null; + }, + filter(query, data) { + // Limit enhanced /unlink to only Work Items for now. + const hasWorkItemIssuesEnabled = + gon.current_user_use_work_items_view || gon.features.workItemViewForIssues; + if (hasWorkItemIssuesEnabled && command === MEMBER_COMMAND.UNLINK) { + const { workItemFullPath, workItemIid } = this.$inputor + .get(0) + .closest('section') + .querySelector('#linkeditems').dataset; + + // Only include items which are linked to the Issuable currently + // if `#` is followed by `/unlink` command. + const items = linkedItems()[`${workItemFullPath}:${workItemIid}`] || []; + return items.map((item) => ({ + id: Number(item.iid), + title: item.title, + reference: item.reference, + search: `${item.iid} ${item.title}`, + iconName: item.workItemType.iconName, + })); + } + + if (GfmAutoComplete.isLoading(data) || instance.previousQuery !== query) { + instance.previousQuery = query; + + fetchData(this.$inputor, this.at, query); + return data; + } + + // Return default data + return data; + }, }, }); showAndHideHelper($input, ISSUES_ALIAS); diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 493ef4b18f5c39..93d0e821a0003a 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -1,6 +1,7 @@ import produce from 'immer'; import VueApollo from 'vue-apollo'; import { concatPagination } from '@apollo/client/utilities'; +import { makeVar } from '@apollo/client/core'; import errorQuery from '~/boards/graphql/client/error.query.graphql'; import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql'; import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; @@ -20,6 +21,8 @@ import activeDiscussionQuery from '~/work_items/components/design_management/gra import { updateNewWorkItemCache, workItemBulkEdit } from '~/work_items/graphql/resolvers'; import { preserveDetailsState } from '~/work_items/utils'; +export const linkedItems = makeVar({}); + export const config = { typeDefs, cacheConfig: { @@ -130,6 +133,10 @@ export const config = { }, WorkItem: { fields: { + // Prevent `reference` from being transformed into `reference({"fullPath":true})` + reference: { + keyArgs: false, + }, // widgets policy because otherwise the subscriptions invalidate the cache widgets: { merge(existing = [], incoming, context) { @@ -202,6 +209,22 @@ export const config = { return { ...existingNode, ...incomingNode }; }); + // we only set up linked items when the widget is present and has `workItem` property + if (context.variables.iid) { + const items = resultNodes + .filter((node) => node.workItem) + // normally we would only get a `__ref` for nested properties but we need to extract the full work item + // eslint-disable-next-line no-underscore-dangle + .map((node) => context.cache.extract()[node.workItem.__ref]); + + // Ensure that any existing linked items are retained + const existingLinkedItems = linkedItems(); + linkedItems({ + ...existingLinkedItems, + [`${context.variables.fullPath}:${context.variables.iid}`]: items, + }); + } + return { ...incomingWidget, linkedItems: { diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue index 729f90e76e127d..c421e7c6e768a1 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue @@ -328,6 +328,8 @@ export default { :anchor-id="widgetName" :title="$options.i18n.title" :is-loading="isLoading" + :data-work-item-full-path="workItemFullPath" + :data-work-item-iid="workItemIid" is-collapsible persist-collapsed-state data-testid="work-item-relationships" diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index af48c175bbd85c..1ba16732b452c1 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -18,6 +18,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import AjaxCache from '~/lib/utils/ajax_cache'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { linkedItems } from '~/graphql_shared/issuable_client'; import { eventlistenersMockDefaultMap, crmContactsMock, @@ -25,6 +26,10 @@ import { const mockSpriteIcons = '/icons.svg'; +jest.mock('~/graphql_shared/issuable_client', () => ({ + linkedItems: jest.fn(), +})); + describe('escape', () => { it.each` xssPayload | escapedPayload @@ -57,6 +62,12 @@ describe('GfmAutoComplete', () => { jest.runOnlyPendingTimers(); }; + const getAutocompleteDropdownItems = (listSelector = '') => { + const dropdown = document.getElementById(listSelector); + const items = dropdown.getElementsByTagName('li'); + return [].map.call(items, (item) => item.textContent.trim()); + }; + beforeEach(() => { window.gon = { sprite_icons: mockSpriteIcons }; }); @@ -864,11 +875,7 @@ describe('GfmAutoComplete', () => { resetHTMLFixture(); }); - const getDropdownItems = () => { - const dropdown = document.getElementById('at-view-labels'); - const items = dropdown.getElementsByTagName('li'); - return [].map.call(items, (item) => item.textContent.trim()); - }; + const getDropdownItems = () => getAutocompleteDropdownItems('at-view-labels'); const expectLabels = ({ input, output }) => { triggerDropdown($textarea, input); @@ -1064,11 +1071,7 @@ describe('GfmAutoComplete', () => { resetHTMLFixture(); }); - const getDropdownItems = () => { - const dropdown = document.getElementById('at-view-contacts'); - const items = dropdown.getElementsByTagName('li'); - return [].map.call(items, (item) => item.textContent.trim()); - }; + const getDropdownItems = () => getAutocompleteDropdownItems('at-view-contacts'); const expectContacts = ({ input, output }) => { triggerDropdown($textarea, input); @@ -1128,6 +1131,104 @@ describe('GfmAutoComplete', () => { }); }); + describe('unlink', () => { + let autocomplete; + let $textarea; + const mockWorkItemFullPath = 'gitlab-test'; + const mockWorkItemIid = '1'; + const originalGon = window.gon; + const dataSources = { + issues: `${TEST_HOST}/autocomplete_sources/issues`, + }; + const mockIssues = [ + { + title: 'Issue 1', + iid: 1, + reference: 'group/project#1', + workItemType: { iconName: 'issues' }, + }, + { + title: 'Issue 2', + iid: 2, + reference: 'group/project#2', + workItemType: { iconName: 'issues' }, + }, + ]; + + const getDropdownItems = () => getAutocompleteDropdownItems('at-view-issues'); + + beforeEach(() => { + window.gon = { + current_user_use_work_items_view: true, + }; + setHTMLFixture(` +
+
+ +
+ `); + $textarea = $('textarea'); + linkedItems.mockImplementation(() => ({ + [`${mockWorkItemFullPath}:${mockWorkItemIid}`]: [], + })); + + autocomplete = new GfmAutoComplete(dataSources); + autocomplete.setup($textarea, { issues: true }); + autocomplete.cachedData['#'] = { + // This looks odd but that's how GFMAutoComplete + // caches issues data internally. + '': [...mockIssues], + }; + }); + + afterEach(() => { + autocomplete.destroy(); + resetHTMLFixture(); + window.gon = originalGon; + }); + + describe('without any linked issues present', () => { + it('using "#" shows all the issues', () => { + triggerDropdown($textarea, '#'); + + expect(getDropdownItems()).toHaveLength(mockIssues.length); + expect(getDropdownItems()).toEqual(mockIssues.map((i) => `${i.reference} ${i.title}`)); + }); + + it('using "/unlink #" shows no issues', () => { + triggerDropdown($textarea, '/unlink #'); + + expect(getDropdownItems()).toHaveLength(0); + expect(linkedItems).toHaveBeenCalled(); + }); + }); + + describe('with linked issue present', () => { + beforeEach(() => { + linkedItems.mockImplementation(() => ({ + [`${mockWorkItemFullPath}:${mockWorkItemIid}`]: [mockIssues[1]], + })); + }); + + it('using "#" shows all the issues', () => { + triggerDropdown($textarea, '#'); + + expect(getDropdownItems()).toHaveLength(mockIssues.length); + expect(getDropdownItems()).toEqual(mockIssues.map((i) => `${i.reference} ${i.title}`)); + }); + + it('using "/unlink #" shows only linked issues', () => { + triggerDropdown($textarea, '/unlink #'); + + expect(getDropdownItems()).toHaveLength(1); + expect(getDropdownItems()).toEqual([mockIssues[1]].map((i) => `${i.reference} ${i.title}`)); + expect(linkedItems).toHaveBeenCalled(); + }); + }); + }); + describe('autocomplete show eventlisteners', () => { let $textarea; -- GitLab