diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue new file mode 100644 index 0000000000000000000000000000000000000000..34874908f9b3d225e4dcfa356eca146828c35f86 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -0,0 +1,109 @@ +<script> +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; + +import { STATE_OPEN } from '../../constants'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; + +export default { + components: { + GlButton, + GlIcon, + RichTimestampTooltip, + WorkItemLinksMenu, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + projectPath: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + issuableGid: { + type: String, + required: true, + }, + childItem: { + type: Object, + required: true, + }, + }, + computed: { + isItemOpen() { + return this.childItem.state === STATE_OPEN; + }, + iconClass() { + return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + }, + iconName() { + return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + }, + stateTimestamp() { + return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt; + }, + stateTimestampTypeText() { + return this.isItemOpen ? __('Created') : __('Closed'); + }, + childPath() { + return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`; + }, + }, +}; +</script> + +<template> + <div + class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + data-testid="links-child" + > + <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> + <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon"> + <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <gl-icon + v-if="childItem.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + <gl-button + :href="childPath" + category="tertiary" + variant="link" + class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" + @click="$emit('click', childItem.id, $event)" + @mouseover="$emit('mouseover', childItem.id, $event)" + @mouseout="$emit('mouseout', childItem.id, $event)" + > + {{ childItem.title }} + </gl-button> + </div> + <div + v-if="canUpdate" + class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + > + <work-item-links-menu + :work-item-id="childItem.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="$emit('remove', childItem.id)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index b13162a81d5f492112c31f25792594d215799e2b..99907ed7f57b5a14264090c90898147d393dad1b 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -9,18 +9,13 @@ import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.g import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; -import { - STATE_OPEN, - WIDGET_ICONS, - WORK_ITEM_STATUS_TEXT, - WIDGET_TYPE_HIERARCHY, -} from '../../constants'; +import { WIDGET_ICONS, WORK_ITEM_STATUS_TEXT, WIDGET_TYPE_HIERARCHY } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import workItemQuery from '../../graphql/work_item.query.graphql'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; +import WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; -import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { @@ -28,8 +23,8 @@ export default { GlIcon, GlAlert, GlLoadingIcon, + WorkItemLinkChild, WorkItemLinksForm, - WorkItemLinksMenu, WorkItemDetailModal, }, directives: { @@ -124,12 +119,6 @@ export default { }, }, methods: { - iconClass(state) { - return state === STATE_OPEN ? 'gl-text-green-500' : 'gl-text-blue-500'; - }, - iconName(state) { - return state === STATE_OPEN ? 'issue-open-m' : 'issue-close'; - }, toggle() { this.isOpen = !this.isOpen; }, @@ -171,9 +160,6 @@ export default { replace: true, }); }, - childPath(childItemId) { - return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`; - }, toggleChildFromCache(workItem, childId, store) { const sourceData = store.readQuery({ query: getWorkItemLinksQuery, @@ -322,48 +308,18 @@ export default { @cancel="hideAddForm" @addWorkItemChild="addChild" /> - <div + <work-item-link-child v-for="child in children" :key="child.id" - class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" - data-testid="links-child" - > - <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> - <gl-icon - :name="iconName(child.state)" - class="gl-mr-3" - :class="iconClass(child.state)" - /> - <gl-icon - v-if="child.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :title="__('Confidential')" - /> - <gl-button - :href="childPath(child.id)" - category="tertiary" - variant="link" - class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" - @click="openChild(child.id, $event)" - @mouseover="prefetchWorkItem(child.id)" - @mouseout="clearPrefetching" - > - {{ child.title }} - </gl-button> - </div> - <div class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"> - <work-item-links-menu - v-if="canUpdate" - :work-item-id="child.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="removeChild(child.id)" - /> - </div> - </div> + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="issuableGid" + :child-item="child" + @click="openChild" + @mouseover="prefetchWorkItem" + @mouseout="clearPrefetching" + @remove="removeChild" + /> <work-item-detail-modal ref="modal" :work-item-id="activeChildId" diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index 2ca4450f89258fef3567af1edfe227ebeee1399f..7b63d9c7ca3a7c56d35e8d76e8b2264da87c7808 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -26,6 +26,8 @@ query workItemLinksQuery($id: WorkItemID!) { } title state + createdAt + closedAt } } } diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1d5472a0473a3ddd849e30667194ee5d14fa5091 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -0,0 +1,122 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; + +import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; + +import { workItemTask, confidentialWorkItemTask, closedWorkItemTask } from '../../mock_data'; + +describe('WorkItemLinkChild', () => { + const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; + let wrapper; + + const createComponent = ({ + projectPath = 'gitlab-org/gitlab-test', + canUpdate = true, + issuableGid = WORK_ITEM_ID, + childItem = workItemTask, + } = {}) => { + wrapper = shallowMountExtended(WorkItemLinkChild, { + propsData: { + projectPath, + canUpdate, + issuableGid, + childItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + status | childItem | statusIconName | statusIconColorClass | rawTimestamp | tooltipContents + ${'open'} | ${workItemTask} | ${'issue-open-m'} | ${'gl-text-green-500'} | ${workItemTask.createdAt} | ${'Created'} + ${'closed'} | ${closedWorkItemTask} | ${'issue-close'} | ${'gl-text-blue-500'} | ${closedWorkItemTask.closedAt} | ${'Closed'} + `( + 'renders item status icon and tooltip when item status is `$status`', + ({ childItem, statusIconName, statusIconColorClass, rawTimestamp, tooltipContents }) => { + createComponent({ childItem }); + + const statusIcon = wrapper.findByTestId('item-status-icon').findComponent(GlIcon); + const statusTooltip = wrapper.findComponent(RichTimestampTooltip); + + expect(statusIcon.props('name')).toBe(statusIconName); + expect(statusIcon.classes()).toContain(statusIconColorClass); + expect(statusTooltip.props('rawTimestamp')).toBe(rawTimestamp); + expect(statusTooltip.props('timestampTypeText')).toContain(tooltipContents); + }, + ); + + it('renders confidential icon when item is confidential', () => { + createComponent({ childItem: confidentialWorkItemTask }); + + const confidentialIcon = wrapper.findByTestId('confidential-icon'); + + expect(confidentialIcon.props('name')).toBe('eye-slash'); + expect(confidentialIcon.attributes('title')).toBe('Confidential'); + }); + + describe('item title', () => { + let titleEl; + + beforeEach(() => { + createComponent(); + + titleEl = wrapper.findComponent(GlButton); + }); + + it('renders item title', () => { + expect(titleEl.attributes('href')).toBe('/gitlab-org/gitlab-test/-/work_items/4'); + expect(titleEl.text()).toBe(workItemTask.title); + }); + + it.each` + action | event | emittedEvent + ${'clicking'} | ${'click'} | ${'click'} + ${'doing mouseover on'} | ${'mouseover'} | ${'mouseover'} + ${'doing mouseout on'} | ${'mouseout'} | ${'mouseout'} + `('$action item title emit `$emittedEvent` event', ({ event, emittedEvent }) => { + const eventObj = { + preventDefault: jest.fn(), + }; + titleEl.vm.$emit(event, eventObj); + + expect(wrapper.emitted(emittedEvent)).toEqual([[workItemTask.id, eventObj]]); + }); + }); + + describe('item menu', () => { + let itemMenuEl; + + beforeEach(() => { + createComponent(); + + itemMenuEl = wrapper.findComponent(WorkItemLinksMenu); + }); + + it('renders work-item-links-menu', () => { + expect(itemMenuEl.exists()).toBe(true); + + expect(itemMenuEl.attributes()).toMatchObject({ + 'work-item-id': workItemTask.id, + 'parent-work-item-id': WORK_ITEM_ID, + }); + }); + + it('does not render work-item-links-menu when canUpdate is false', () => { + createComponent({ canUpdate: false }); + + expect(wrapper.findComponent(WorkItemLinksMenu).exists()).toBe(false); + }); + + it('removeChild event on menu triggers `click-remove-child` event', () => { + itemMenuEl.vm.$emit('removeChild'); + + expect(wrapper.emitted('remove')).toEqual([[workItemTask.id]]); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index a5deeb827f6a3fc3e42bd22552ef184b7d068baa..876aedff08b278f956615dadac2bf326884d6980 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -1,5 +1,5 @@ import Vue, { nextTick } from 'vue'; -import { GlButton, GlIcon, GlAlert } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -7,6 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; +import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; @@ -92,10 +93,10 @@ describe('WorkItemLinks', () => { const findLinksBody = () => wrapper.findByTestId('links-body'); const findEmptyState = () => wrapper.findByTestId('links-empty'); const findToggleAddFormButton = () => wrapper.findByTestId('toggle-add-form'); + const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); + const findFirstWorkItemLinkChild = () => findWorkItemLinkChildItems().at(0); const findAddLinksForm = () => wrapper.findByTestId('add-links-form'); - const findFirstLinksMenu = () => wrapper.findByTestId('links-menu'); const findChildrenCount = () => wrapper.findByTestId('children-count'); - const findChildren = () => wrapper.findAllByTestId('links-child'); beforeEach(async () => { await createComponent(); @@ -148,8 +149,7 @@ describe('WorkItemLinks', () => { it('renders all hierarchy widget children', () => { expect(findLinksBody().exists()).toBe(true); - expect(findChildren()).toHaveLength(4); - expect(findFirstLinksMenu().exists()).toBe(true); + expect(findWorkItemLinkChildItems()).toHaveLength(4); }); it('shows alert when list loading fails', async () => { @@ -164,19 +164,6 @@ describe('WorkItemLinks', () => { expect(findAlert().text()).toBe(errorMessage); }); - it('renders widget child icon and tooltip', () => { - expect(findChildren().at(0).findComponent(GlIcon).props('name')).toBe('issue-open-m'); - expect(findChildren().at(1).findComponent(GlIcon).props('name')).toBe('issue-close'); - }); - - it('renders confidentiality icon when child item is confidential', () => { - const children = wrapper.findAll('[data-testid="links-child"]'); - const confidentialIcon = children.at(0).find('[data-testid="confidential-icon"]'); - - expect(confidentialIcon.exists()).toBe(true); - expect(confidentialIcon.props('name')).toBe('eye-slash'); - }); - it('displays number if children', () => { expect(findChildrenCount().exists()).toBe(true); @@ -195,17 +182,21 @@ describe('WorkItemLinks', () => { }); it('does not display link menu on children', () => { - expect(findFirstLinksMenu().exists()).toBe(false); + expect(findWorkItemLinkChildItems().at(0).props('canUpdate')).toBe(false); }); }); describe('remove child', () => { + let firstChild; + beforeEach(async () => { await createComponent({ mutationHandler: mutationChangeParentHandler }); + + firstChild = findFirstWorkItemLinkChild(); }); it('calls correct mutation with correct variables', async () => { - findFirstLinksMenu().vm.$emit('removeChild'); + firstChild.vm.$emit('remove', firstChild.vm.childItem.id); await waitForPromises(); @@ -220,7 +211,7 @@ describe('WorkItemLinks', () => { }); it('shows toast when mutation succeeds', async () => { - findFirstLinksMenu().vm.$emit('removeChild'); + firstChild.vm.$emit('remove', firstChild.vm.childItem.id); await waitForPromises(); @@ -230,28 +221,30 @@ describe('WorkItemLinks', () => { }); it('renders correct number of children after removal', async () => { - expect(findChildren()).toHaveLength(4); + expect(findWorkItemLinkChildItems()).toHaveLength(4); - findFirstLinksMenu().vm.$emit('removeChild'); + firstChild.vm.$emit('remove', firstChild.vm.childItem.id); await waitForPromises(); - expect(findChildren()).toHaveLength(3); + expect(findWorkItemLinkChildItems()).toHaveLength(3); }); }); describe('prefetching child items', () => { + let firstChild; + beforeEach(async () => { await createComponent(); - }); - const findChildLink = () => findChildren().at(0).findComponent(GlButton); + firstChild = findFirstWorkItemLinkChild(); + }); it('does not fetch the child work item before hovering work item links', () => { expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); }); it('fetches the child work item if link is hovered for 250+ ms', async () => { - findChildLink().vm.$emit('mouseover'); + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); @@ -261,9 +254,9 @@ describe('WorkItemLinks', () => { }); it('does not fetch the child work item if link is hovered for less than 250 ms', async () => { - findChildLink().vm.$emit('mouseover'); + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); jest.advanceTimersByTime(200); - findChildLink().vm.$emit('mouseout'); + firstChild.vm.$emit('mouseout', firstChild.vm.childItem.id); await waitForPromises(); expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 6a5aa48b61006fb143c153a9a949623754db827c..216c0baa7cc972ac5b5061b1714f447a4116eea2 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -515,6 +515,48 @@ export const workItemHierarchyNoUpdatePermissionResponse = { }, }; +export const workItemTask = { + id: 'gid://gitlab/WorkItem/4', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'bar', + state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + __typename: 'WorkItem', +}; + +export const confidentialWorkItemTask = { + id: 'gid://gitlab/WorkItem/2', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'xyz', + state: 'OPEN', + confidential: true, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + __typename: 'WorkItem', +}; + +export const closedWorkItemTask = { + id: 'gid://gitlab/WorkItem/3', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'abc', + state: 'CLOSED', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: '2022-08-12T13:07:52Z', + __typename: 'WorkItem', +}; + export const workItemHierarchyResponse = { data: { workItem: { @@ -544,45 +586,9 @@ export const workItemHierarchyResponse = { parent: null, children: { nodes: [ - { - id: 'gid://gitlab/WorkItem/2', - workItemType: { - id: 'gid://gitlab/WorkItems::Type/5', - __typename: 'WorkItemType', - }, - title: 'xyz', - state: 'OPEN', - confidential: true, - createdAt: '2022-08-03T12:41:54Z', - closedAt: null, - __typename: 'WorkItem', - }, - { - id: 'gid://gitlab/WorkItem/3', - workItemType: { - id: 'gid://gitlab/WorkItems::Type/5', - __typename: 'WorkItemType', - }, - title: 'abc', - state: 'CLOSED', - confidential: false, - createdAt: '2022-08-03T12:41:54Z', - closedAt: '2022-08-12T13:07:52Z', - __typename: 'WorkItem', - }, - { - id: 'gid://gitlab/WorkItem/4', - workItemType: { - id: 'gid://gitlab/WorkItems::Type/5', - __typename: 'WorkItemType', - }, - title: 'bar', - state: 'OPEN', - confidential: false, - createdAt: '2022-08-03T12:41:54Z', - closedAt: null, - __typename: 'WorkItem', - }, + confidentialWorkItemTask, + closedWorkItemTask, + workItemTask, { id: 'gid://gitlab/WorkItem/5', workItemType: {