diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index e14a98b4bd2b63ec594919d45bbaf59c34181adf..12fcdfb434db5dce3bc2fc93f652192a937ccc4e 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -103,6 +103,7 @@ export default { :href="milestoneLink" :title="milestoneDate" class="gl-text-sm !gl-text-gray-500" + @click.stop > <gl-icon name="milestone" :size="12" /> {{ milestone.title }} 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 e70ce0e2e69f62b578e87891ae8a43671c77bdf6..ac70771ac61a4fcb8d70e4c8238a5481c05f63ea 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -905,6 +905,7 @@ export default { <issuable-list v-if="hasAnyIssues" :namespace="fullPath" + :full-path="fullPath" recent-searches-storage-key="issues" :search-tokens="searchTokens" :has-scoped-labels-feature="hasScopedLabelsFeature" diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 9ea3039173d39fecf63d55de451a4ecb8b2f6807..d5569f520548f95fe5daf26ebb7dd20f2799cad0 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -201,6 +201,7 @@ export async function mountIssuesListApp() { issuesListPath: wiIssuesListPath, labelsManagePath: wiLabelsManagePath, reportAbusePath: wiReportAbusePath, + hasSubepicsFeature: false, }, render: (createComponent) => createComponent(IssuesListApp), }); diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 0949071d4dc33c188a9ed36b836cf6e944f3bf6c..6f6a1efcf8c15e959094e35ecf5f09c59588feaa 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -115,6 +115,7 @@ export default { :data-user-id="userId" :data-username="popoverUsername" class="user-avatar-link js-user-link" + @click.stop > <user-avatar-image :class="imgCssWrapperClasses" diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 594eae37d3ca1e780d058df54a4df0f85cbcb6ec..771cae4f7bf6579c408f2fab5ad37a3c42f4e87f 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -8,10 +8,11 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +import { escapeRegExp } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { STATUS_OPEN, STATUS_CLOSED } from '~/issues/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; +import { isExternal, setUrlFragment, visitUrl } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -46,6 +47,11 @@ export default { type: String, required: true, }, + fullPath: { + type: String, + required: false, + default: null, + }, issuable: { type: Object, required: true, @@ -239,7 +245,7 @@ export default { return ''; }, handleIssuableItemClick(e) { - if (e.metaKey || e.ctrlKey || !this.preventRedirect) { + if (e.metaKey || e.ctrlKey || !this.preventRedirect || this.showCheckbox) { return; } e.preventDefault(); @@ -249,6 +255,26 @@ export default { fullPath: this.workItemFullPath, }); }, + navigateToIssuable() { + if (!this.fullPath) { + visitUrl(this.issuableLinkHref); + } + const escapedFullPath = escapeRegExp(this.fullPath); + // eslint-disable-next-line no-useless-escape + const regex = new RegExp(`groups\/${escapedFullPath}\/-\/(work_items|epics)\/\\d+`); + const isWorkItemPath = regex.test(this.issuableLinkHref); + + if (isWorkItemPath) { + this.$router.push({ + name: 'workItem', + params: { + iid: this.issuableIid, + }, + }); + } else { + visitUrl(this.issuableLinkHref); + } + }, }, }; </script> @@ -257,10 +283,16 @@ export default { <li :id="`issuable_${issuableId}`" class="issue !gl-flex !gl-px-5" - :class="{ closed: issuable.closedAt, 'gl-bg-blue-50': isActive }" + :class="{ + closed: issuable.closedAt, + 'gl-bg-blue-50': isActive, + 'gl-cursor-pointer': preventRedirect, + 'hover:gl-bg-subtle': preventRedirect && !isActive, + }" :data-labels="labelIdsString" :data-qa-issue-id="issuableId" data-testid="issuable-item-wrapper" + @click="handleIssuableItemClick" > <gl-form-checkbox v-if="showCheckbox" @@ -295,42 +327,40 @@ export default { :title="__('This issue is hidden because its author has been banned.')" :aria-label="__('Hidden')" /> - <template v-if="preventRedirect"> - <work-item-prefetch - :work-item-iid="issuableIid" - :work-item-full-path="workItemFullPath" - data-testid="issuable-prefetch-trigger" - > - <template #default="{ prefetchWorkItem, clearPrefetching }"> - <gl-link - class="issue-title-text gl-text-base" - dir="auto" - :href="issuableLinkHref" - data-testid="issuable-title-link" - v-bind="issuableTitleProps" - @click="handleIssuableItemClick" - @mouseover.native="prefetchWorkItem(issuableIid)" - @mouseout.native="clearPrefetching" - > - {{ issuable.title }} - <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> - </gl-link> - </template> - </work-item-prefetch> - </template> - <template v-else> - <gl-link - class="issue-title-text gl-text-base" - dir="auto" - :href="issuableLinkHref" - data-testid="issuable-title-link" - v-bind="issuableTitleProps" - @click="handleIssuableItemClick" - > - {{ issuable.title }} - <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> - </gl-link> - </template> + <work-item-prefetch + v-if="preventRedirect" + :work-item-iid="issuableIid" + :work-item-full-path="workItemFullPath" + data-testid="issuable-prefetch-trigger" + > + <template #default="{ prefetchWorkItem, clearPrefetching }"> + <gl-link + class="issue-title-text gl-text-base" + dir="auto" + :href="issuableLinkHref" + data-testid="issuable-title-link" + v-bind="issuableTitleProps" + @click.stop="handleIssuableItemClick" + @mouseover.native="prefetchWorkItem(issuableIid)" + @mouseout.native="clearPrefetching" + > + {{ issuable.title }} + <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> + </gl-link> + </template> + </work-item-prefetch> + <gl-link + v-else + class="issue-title-text gl-text-base" + dir="auto" + :href="issuableLinkHref" + data-testid="issuable-title-link" + v-bind="issuableTitleProps" + @click.stop="handleIssuableItemClick" + > + {{ issuable.title }} + <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> + </gl-link> <slot v-if="hasSlotContents('title-icons')" name="title-icons"></slot> <span v-if="taskStatus" @@ -372,6 +402,7 @@ export default { :href="author.webPath" data-testid="issuable-author" class="author-link js-user-link gl-text-sm !gl-text-gray-500" + @click.stop > <span class="author">{{ author.name }}</span> </gl-link> @@ -405,6 +436,7 @@ export default { :description="label.description" :scoped="scopedLabel(label)" :target="labelTarget(label)" + @click.stop /> </p> </div> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 1ec453e3857f22aca71f4efee021e12a9e4cd125..0f2060fa1b53d9853af2e5f4234439a0470a8341 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -47,6 +47,11 @@ export default { type: String, required: true, }, + fullPath: { + type: String, + required: false, + default: null, + }, recentSearchesStorageKey: { type: String, required: true, @@ -381,6 +386,7 @@ export default { :issuable-symbol="issuableSymbol" :issuable="issuable" :label-filter-param="labelFilterParam" + :full-path="fullPath" :show-checkbox="showBulkEditSidebar" :checked="isIssuableChecked(issuable)" :show-work-item-type-icon="showWorkItemTypeIcon" diff --git a/app/assets/javascripts/work_items/pages/work_items_list_app.vue b/app/assets/javascripts/work_items/pages/work_items_list_app.vue index c099941cc3771dfe0df70e4f77bf68cf496a68f1..056f2b262e8ea407c0994002e8486ca0e20815a3 100644 --- a/app/assets/javascripts/work_items/pages/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/pages/work_items_list_app.vue @@ -565,6 +565,7 @@ export default { @work-item-updated="handleStatusChange" /> <issuable-list + :active-issuable="activeItem" :current-tab="state" :default-page-size="pageSize" :error="error" @@ -576,6 +577,7 @@ export default { :issuables-loading="isLoading" :show-bulk-edit-sidebar="showBulkEditSidebar" namespace="work-items" + :full-path="fullPath" recent-searches-storage-key="issues" :search-tokens="searchTokens" show-filtered-search-friendly-text diff --git a/ee/spec/frontend/external_issues_list/components/__snapshots__/external_issues_list_root_spec.js.snap b/ee/spec/frontend/external_issues_list/components/__snapshots__/external_issues_list_root_spec.js.snap index 96c1acb95a2cbb89e66079d379d31b1f13bf7f5e..bdf3a16dc334277f58578df811da222837d82a12 100644 --- a/ee/spec/frontend/external_issues_list/components/__snapshots__/external_issues_list_root_spec.js.snap +++ b/ee/spec/frontend/external_issues_list/components/__snapshots__/external_issues_list_root_spec.js.snap @@ -36,6 +36,7 @@ exports[`ExternalIssuesListRoot when request succeeds renders issuable-list comp "currentTab": "opened", "defaultPageSize": 2, "error": "", + "fullPath": null, "hasNextPage": false, "hasPreviousPage": false, "hasScopedLabelsFeature": false, diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index f83e1386c132fc4e68996a3125454e4b0f0c2aab..5adfa61176f8bd77f6b0f35c4dd5c1099d63b721 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -49,7 +49,6 @@ describe('IssuableItem', () => { const findTimestampWrapper = () => wrapper.findByTestId('issuable-timestamp'); const findWorkItemTypeIcon = () => wrapper.findComponent(WorkItemTypeIcon); - const findIssuableTitleLink = () => wrapper.findComponentByTestId('issuable-title-link'); const findIssuableItemWrapper = () => wrapper.findByTestId('issuable-item-wrapper'); const findIssuablePrefetchTrigger = () => wrapper.findByTestId('issuable-prefetch-trigger'); const findStatusEl = () => wrapper.findByTestId('issuable-status'); @@ -630,28 +629,33 @@ describe('IssuableItem', () => { }); describe('when preventing redirect on clicking the link', () => { - it('emits an event on item click', () => { + beforeEach(() => { + window.open = jest.fn(); + }); + it('emits an event on row click', async () => { const { iid, webUrl } = mockIssuable; wrapper = createComponent({ preventRedirect: true, + showCheckbox: false, }); - findIssuableTitleLink().vm.$emit('click', new MouseEvent('click')); + await findIssuableItemWrapper().trigger('click'); expect(wrapper.emitted('select-issuable')).toEqual([[{ iid, webUrl }]]); }); - it('includes fullPath in emitted event for work items', () => { + it('includes fullPath in emitted event for work items', async () => { const { iid, webUrl } = mockIssuable; const fullPath = 'gitlab-org/gitlab'; wrapper = createComponent({ preventRedirect: true, + showCheckbox: false, issuable: { ...mockIssuable, namespace: { fullPath } }, }); - findIssuableTitleLink().vm.$emit('click', new MouseEvent('click')); + await findIssuableItemWrapper().trigger('click'); expect(wrapper.emitted('select-issuable')).toEqual([[{ iid, webUrl, fullPath }]]); }); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 8a4438302e33a9cf577be6f0a8eaeb51be629955..60f89ead9b3e3252cefd6525b416692db8a046d7 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -210,6 +210,7 @@ describe('IssuableListRoot component', () => { showWorkItemTypeIcon: false, preventRedirect: false, isActive: false, + fullPath: null, }); });