diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 376d71c7b31c1e3990fb899665af1fa79d5d30f8..e0ebc714dbb41498a1800918c16a0ac3b28d3230 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,5 +1,6 @@ <script> import { isEmpty } from 'lodash'; +import { produce } from 'immer'; import { GlAlert, GlSkeletonLoader, @@ -11,6 +12,7 @@ import { GlEmptyState, } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; +import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; @@ -269,6 +271,12 @@ export default { id: this.workItemId, }; }, + children() { + const widgetHierarchy = this.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + return widgetHierarchy.children.nodes; + }, }, methods: { isWidgetPresent(type) { @@ -326,6 +334,74 @@ export default { this.error = this.$options.i18n.fetchError; document.title = s__('404|Not found'); }, + addChild(child) { + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(child, child.id, client); + }, + toggleChildFromCache(workItem, childId, store) { + const sourceData = store.readQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.unshift(workItem); + } + }); + + store.writeQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + data: newData, + }); + }, + async updateWorkItem(workItem, childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + update: (store) => this.toggleChildFromCache(workItem, childId, store), + }); + }, + async undoChildRemoval(workItem, childId) { + try { + const { data } = await this.updateWorkItem(workItem, childId, this.workItem.id); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while undoing child removal.'); + Sentry.captureException(error); + } finally { + this.activeToast?.hide(); + } + }, + async removeChild(childId) { + try { + const { data } = await this.updateWorkItem(null, childId, null); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), + }, + }); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while removing child.'); + Sentry.captureException(error); + } + }, }, WORK_ITEM_TYPE_VALUE_OBJECTIVE, }; @@ -507,6 +583,11 @@ export default { v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" :work-item-type="workItemType" :work-item-id="workItem.id" + :children="children" + :can-update="canUpdate" + :project-path="fullPath" + @addWorkItemChild="addChild" + @removeChild="removeChild" /> <gl-empty-state v-if="error" diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue index a91133ce1accdcf0ed5f62439044415a7761964b..cbe67aa622cabcf379739a0065c3b02e3cdc62fe 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownDivider, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; @@ -30,7 +30,6 @@ export default { objectiveActionItems, components: { GlDropdown, - GlDropdownDivider, GlDropdownSectionHeader, GlDropdownItem, }, @@ -53,6 +52,10 @@ export default { {{ item.title }} </gl-dropdown-item> + <!-- TODO: Uncomment once following two issues addressed --> + <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/381833 --> + <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/385084 --> + <!-- <gl-dropdown-divider /> <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header> <gl-dropdown-item @@ -62,5 +65,6 @@ export default { > {{ item.title }} </gl-dropdown-item> + --> </gl-dropdown> </template> 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 index 7141d9c6f89367eba53c7c3d14aa01747c48ecbc..b04da53bf89385f57ace82b7a14465917773967b 100644 --- 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 @@ -1,12 +1,21 @@ <script> import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; -import { STATE_OPEN } from '../../constants'; +import { + STATE_OPEN, + TASK_TYPE_NAME, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_HIERARCHY, + WORK_ITEM_NAME_TO_ICON_MAP, +} from '../../constants'; +import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; import WorkItemLinksMenu from './work_item_links_menu.vue'; +import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { @@ -14,6 +23,7 @@ export default { GlIcon, RichTimestampTooltip, WorkItemLinksMenu, + WorkItemTreeChildren, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,16 +45,45 @@ export default { type: Object, required: true, }, + hasIndirectChildren: { + type: Boolean, + required: false, + default: true, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isExpanded: false, + children: [], + isLoadingChildren: false, + }; }, computed: { + canHaveChildren() { + return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; + }, isItemOpen() { return this.childItem.state === STATE_OPEN; }, - iconClass() { - return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + childItemType() { + return this.childItem.workItemType.name; }, iconName() { - return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + } + return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType]; + }, + iconClass() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + } + return ''; }, stateTimestamp() { return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt; @@ -55,55 +94,132 @@ export default { childPath() { return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`; }, + hasChildren() { + return this.getWidgetHierarchyForChild(this.childItem)?.hasChildren; + }, + chevronType() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + chevronTooltip() { + return this.isExpanded ? __('Collapse') : __('Expand'); + }, + }, + methods: { + toggleItem() { + this.isExpanded = !this.isExpanded; + if (this.children.length === 0 && this.hasChildren) { + this.fetchChildren(); + } + }, + getWidgetHierarchyForChild(workItem) { + const widgetHierarchy = workItem?.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + return widgetHierarchy || {}; + }, + async fetchChildren() { + this.isLoadingChildren = true; + try { + const { data } = await this.$apollo.query({ + query: getWorkItemTreeQuery, + variables: { + id: this.childItem.id, + }, + }); + this.children = this.getWidgetHierarchyForChild(data?.workItem).children.nodes; + } catch (error) { + this.isExpanded = !this.isExpanded; + createAlert({ + message: s__('Hierarchy|Something went wrong while fetching children.'), + captureError: true, + error, + }); + } finally { + this.isLoadingChildren = false; + } + }, }, }; </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', $event)" - @mouseover="$emit('mouseover')" - @mouseout="$emit('mouseout')" - > - {{ childItem.title }} - </gl-button> - </div> + <div> <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + class="gl-display-flex gl-align-items-center gl-mb-3" + :class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }" > - <work-item-links-menu - :work-item-id="childItem.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="$emit('remove', childItem.id)" + <gl-button + v-if="hasChildren" + v-gl-tooltip.viewport + :title="chevronTooltip" + :aria-label="chevronTooltip" + :icon="chevronType" + category="tertiary" + :loading="isLoadingChildren" + class="gl-px-0! gl-py-4! gl-mr-3" + data-testid="expand-child" + @click="toggleItem" /> + <div + class="gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-bg-white 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 + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :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', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ 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('removeChild', childItem.id)" + /> + </div> + </div> </div> + <work-item-tree-children + v-if="isExpanded" + :project-path="projectPath" + :can-update="canUpdate" + :work-item-id="issuableGid" + :work-item-type="workItemType" + :children="children" + @removeChild="fetchChildren" + /> </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 e96b56f13a9d0fce5faad2ae86bef740bf5159fd..faadb5fa6faab44adef2f833b36103af884a9679 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 @@ -412,7 +412,7 @@ export default { @click="openChild(child, $event)" @mouseover="prefetchWorkItem(child)" @mouseout="clearPrefetching" - @remove="removeChild" + @removeChild="removeChild" /> <work-item-detail-modal ref="modal" 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 9c09ee3a66a69ad0bf867975b3c9d96d83307fed..b4bb8a99452907436530d727b5a4d8c635cea34d 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 @@ -1,15 +1,26 @@ <script> import { GlButton } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { isEmpty } from 'lodash'; +import { __ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { FORM_TYPES, + WIDGET_TYPE_HIERARCHY, WORK_ITEMS_TREE_TEXT_MAP, WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, } from '../../constants'; +import workItemQuery from '../../graphql/work_item.query.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import OkrActionsSplitButton from './okr_actions_split_button.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; +import WorkItemLinkChild from './work_item_link_child.vue'; export default { FORM_TYPES, @@ -20,7 +31,9 @@ export default { GlButton, OkrActionsSplitButton, WorkItemLinksForm, + WorkItemLinkChild, }, + mixins: [glFeatureFlagMixin()], props: { workItemType: { type: String, @@ -30,6 +43,20 @@ export default { type: String, required: true, }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, }, data() { return { @@ -38,6 +65,7 @@ export default { error: null, formType: null, childType: null, + prefetchedWorkItem: null, }; }, computed: { @@ -45,8 +73,41 @@ export default { return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; }, toggleLabel() { - return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks'); + return this.isOpen ? __('Collapse') : __('Expand'); + }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + }, + childrenIds() { + return this.children.map((c) => c.id); + }, + hasIndirectChildren() { + return this.children + .map( + (child) => child.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) || {}, + ) + .some((hierarchy) => hierarchy.hasChildren); }, + childUrlParams() { + const params = {}; + if (this.fetchByIid) { + const iid = getParameterByName('work_item_iid'); + if (iid) { + params.iid = iid; + } + } else { + const workItemId = getParameterByName('work_item_id'); + if (workItemId) { + params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + } + } + return params; + }, + }, + mounted() { + if (!isEmpty(this.childUrlParams)) { + this.addWorkItemQuery(this.childUrlParams); + } }, methods: { toggle() { @@ -64,6 +125,37 @@ export default { hideAddForm() { this.isShownAddForm = false; }, + addWorkItemQuery({ id, iid }) { + const variables = this.fetchByIid + ? { + fullPath: this.projectPath, + iid, + } + : { + id, + }; + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query() { + return this.fetchByIid ? workItemByIidQuery : workItemQuery; + }, + variables, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + context: { + isSingleRequest: true, + }, + }); + }, + prefetchWorkItem({ id, iid }) { + this.prefetch = setTimeout( + () => this.addWorkItemQuery({ id, iid }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); + }, + clearPrefetching() { + clearTimeout(this.prefetch); + }, }, }; </script> @@ -113,7 +205,7 @@ export default { :class="{ 'gl-p-5 gl-pb-3': !error }" data-testid="tree-body" > - <div v-if="!isShownAddForm && !error" data-testid="tree-empty"> + <div v-if="!isShownAddForm && !error && children.length === 0" data-testid="tree-empty"> <p class="gl-mb-3"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> @@ -125,8 +217,23 @@ export default { :issuable-gid="workItemId" :form-type="formType" :children-type="childType" + :children-ids="childrenIds" + @addWorkItemChild="$emit('addWorkItemChild', $event)" @cancel="hideAddForm" /> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + :has-indirect-children="hasIndirectChildren" + @mouseover="prefetchWorkItem(child)" + @mouseout="clearPrefetching" + @removeChild="$emit('removeChild', $event)" + /> </div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue new file mode 100644 index 0000000000000000000000000000000000000000..911cac4de88432711242dee2d25bbbbcff0a1347 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -0,0 +1,68 @@ +<script> +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; + +export default { + components: { + WorkItemLinkChild: () => import('./work_item_link_child.vue'), + }, + props: { + workItemType: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, + }, + methods: { + async updateWorkItem(childId) { + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId: null } } }, + }); + this.$emit('removeChild'); + } catch (error) { + createAlert({ + message: s__('Hierarchy|Something went wrong while removing a child item.'), + captureError: true, + error, + }); + } + }, + }, +}; +</script> + +<template> + <div class="gl-ml-6"> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + @removeChild="updateWorkItem" + /> + </div> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 939cc416b9eaa6f0c374a3c2ade977e9274b3daa..368bb6a85a478550d27faf95c6ec75650eaa4f3b 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -116,7 +116,7 @@ export const WORK_ITEMS_TYPE_MAP = { }, [WORK_ITEM_TYPE_ENUM_KEY_RESULT]: { icon: `issue-type-issue`, - name: s__('WorkItem|Key result'), + name: s__('WorkItem|Key Result'), }, }; @@ -127,6 +127,14 @@ export const WORK_ITEMS_TREE_TEXT_MAP = { }, }; +export const WORK_ITEM_NAME_TO_ICON_MAP = { + Issue: 'issue-type-issue', + Task: 'issue-type-task', + Objective: 'issue-type-objective', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'Key Result': 'issue-type-key-result', +}; + export const FORM_TYPES = { create: 'create', add: 'add', 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 a37e5b869f6da62c43a2ef5d9a2ad831439bbfb0..7fcf622cdb2c83150ac5d1cf6e1c9c2e51b26d8b 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 @@ -24,6 +24,8 @@ query workItemLinksQuery($id: WorkItemID!) { confidential workItemType { id + name + iconName } title state diff --git a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a850d002de8769c5a810b26983416fe0305d8781 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql @@ -0,0 +1,47 @@ +query workItemTreeQuery($id: WorkItemID!) { + workItem(id: $id) { + id + workItemType { + id + name + iconName + } + title + userPermissions { + deleteWorkItem + updateWorkItem + } + confidential + widgets { + type + ... on WorkItemWidgetHierarchy { + type + parent { + id + } + children { + nodes { + id + iid + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 025a9d3673b87b628890c997cab142c98a2b3ec6..9b802a8e8fc0754f273d040a81a3de9398e7c516 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -38,6 +38,7 @@ fragment WorkItemWidgets on WorkItemWidget { } ... on WorkItemWidgetHierarchy { type + hasChildren parent { id iid @@ -56,11 +57,19 @@ fragment WorkItemWidgets on WorkItemWidget { confidential workItemType { id + name + iconName } title state createdAt closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + } } } } diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 702ba9d7994b4dab95f6854dd52178f10352cf58..2debddf842b654ed5b9115b5325c9a760ff42446 100644 --- a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -43,6 +43,7 @@ fragment WorkItemWidgets on WorkItemWidget { } ... on WorkItemWidgetHierarchy { type + hasChildren parent { id iid @@ -61,11 +62,19 @@ fragment WorkItemWidgets on WorkItemWidget { confidential workItemType { id + name + iconName } title state createdAt closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + } } } } diff --git a/ee/spec/features/work_items/okr_hierarchy_spec.rb b/ee/spec/features/work_items/okr_hierarchy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cc7e6049f75608f34ac85ba15af76f7a630cc4de --- /dev/null +++ b/ee/spec/features/work_items/okr_hierarchy_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'OKR hierarchy', :js, feature_category: :product_planning do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:type_objective) { WorkItems::Type.default_by_type(:objective) } + let_it_be(:objective) { create(:work_item, work_item_type: type_objective, project: project) } + + context 'for signed in user' do + before do + group.add_developer(user) + + sign_in(user) + + stub_licensed_features(okrs: true) + + stub_feature_flags(work_items: true) + stub_feature_flags(okrs_mvc: true) + stub_feature_flags(hierarchy_db_restrictions: true) + + visit project_work_items_path(project, work_items_path: objective.id) + end + + it 'shows no children', :aggregate_failures do + page.within('[data-testid="work-item-tree"]') do + expect(page).to have_content('Child objectives and key results') + expect(page).to have_content('No objectives or key results are currently assigned.') + end + end + + it 'toggles widget body', :aggregate_failures do + page.within('[data-testid="work-item-tree"]') do + expect(page).to have_selector('[data-testid="tree-body"]') + + click_button 'Collapse' + + expect(page).not_to have_selector('[data-testid="tree-body"]') + + click_button 'Expand' + + expect(page).to have_selector('[data-testid="tree-body"]') + end + end + + it 'toggles forms', :aggregate_failures do + page.within('[data-testid="work-item-tree"]') do + expect(page).not_to have_selector('[data-testid="add-tree-form"]') + + click_button 'Add' + click_button 'New objective' + + expect(page).to have_selector('[data-testid="add-tree-form"]') + expect(find('[data-testid="add-tree-form"]')).to have_button('Create objective', disabled: true) + + click_button 'Add' + click_button 'Existing objective' + + expect(find('[data-testid="add-tree-form"]')).to have_button('Add objective', disabled: true) + + # TODO: Uncomment once following two issues addressed + # https://gitlab.com/gitlab-org/gitlab/-/issues/381833 + # https://gitlab.com/gitlab-org/gitlab/-/issues/385084 + # click_button 'Add' + # click_button 'New key result' + + # expect(find('[data-testid="add-tree-form"]')).to have_button('Create key result', disabled: true) + + # click_button 'Add' + # click_button 'Existing key result' + + # expect(find('[data-testid="add-tree-form"]')).to have_button('Add key result', disabled: true) + + click_button 'Cancel' + + expect(page).not_to have_selector('[data-testid="add-tree-form"]') + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a0cfd270916892d38805ef48d6875f2578d3265e..5fbd4651fcf1b7a8ace7d90c4cd1cdc2a69bcf0e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20302,6 +20302,12 @@ msgstr "" msgid "Hierarchy|Planning hierarchy" msgstr "" +msgid "Hierarchy|Something went wrong while fetching children." +msgstr "" + +msgid "Hierarchy|Something went wrong while removing a child item." +msgstr "" + msgid "Hierarchy|Take the work items survey" msgstr "" @@ -23903,9 +23909,6 @@ msgstr "" msgid "Key (PEM)" msgstr "" -msgid "Key result" -msgstr "" - msgid "Key:" msgstr "" @@ -46877,7 +46880,7 @@ msgstr "" msgid "WorkItem|Iteration" msgstr "" -msgid "WorkItem|Key result" +msgid "WorkItem|Key Result" msgstr "" msgid "WorkItem|Milestone" @@ -46964,6 +46967,12 @@ msgstr "" msgid "WorkItem|Something went wrong while fetching milestones. Please try again." msgstr "" +msgid "WorkItem|Something went wrong while removing child." +msgstr "" + +msgid "WorkItem|Something went wrong while undoing child removal." +msgstr "" + msgid "WorkItem|Something went wrong while updating the %{workItemType}. Please try again." msgstr "" diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 30475b3656120494fa4d92a78603179d8d4c8946..a2b34fe38a95f1bb789d8b1fec3a2d8992ba83f2 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -21,6 +21,7 @@ import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; +import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; @@ -38,6 +39,7 @@ import { workItemAssigneesSubscriptionResponse, workItemMilestoneSubscriptionResponse, projectWorkItemResponse, + objectiveType, } from '../mock_data'; describe('WorkItemDetail component', () => { @@ -78,6 +80,7 @@ describe('WorkItemDetail component', () => { const findParentButton = () => findParent().findComponent(GlButton); const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); + const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); const createComponent = ({ isModal = false, @@ -638,4 +641,24 @@ describe('WorkItemDetail component', () => { iid: '1', }); }); + + describe('hierarchy widget', () => { + it('does not render children tree by default', async () => { + createComponent(); + await waitForPromises(); + + expect(findHierarchyTree().exists()).toBe(false); + }); + + it('renders children tree when work item is an Objective', async () => { + const objectiveWorkItem = workItemResponseFactory({ + workItemType: objectiveType, + }); + const handler = jest.fn().mockResolvedValue(objectiveWorkItem); + createComponent({ handler }); + await waitForPromises(); + + expect(findHierarchyTree().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js index 5563ba12a45f8460b58e726dfc0bb3cb09b07379..48711ddf15d489ac69125bba6bc0f838b1e2c0e7 100644 --- a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js +++ b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js @@ -25,9 +25,13 @@ describe('RelatedItemsTree', () => { expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(0).text()).toContain( 'Objective', ); - expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(1).text()).toContain( - 'Key result', - ); + + // TODO: Uncomment once following two issues addressed + // https://gitlab.com/gitlab-org/gitlab/-/issues/381833 + // https://gitlab.com/gitlab-org/gitlab/-/issues/385084 + // expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(1).text()).toContain( + // 'Key result', + // ); }); }); }); 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 index e345e5fc7cd27690ea667fa6950eeab83d04543d..3a8e785bc8063143afec3280a392fc8e75d731b0 100644 --- 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 @@ -1,33 +1,68 @@ import { GlButton, GlIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; +import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql'; 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'; +import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue'; +import { + WIDGET_TYPE_HIERARCHY, + TASK_TYPE_NAME, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, +} from '~/work_items/constants'; + +import { + workItemTask, + workItemObjectiveWithChild, + confidentialWorkItemTask, + closedWorkItemTask, + workItemHierarchyTreeResponse, + workItemHierarchyTreeFailureResponse, +} from '../../mock_data'; + +jest.mock('~/flash'); describe('WorkItemLinkChild', () => { const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; let wrapper; + let getWorkItemTreeQueryHandler; + + Vue.use(VueApollo); const createComponent = ({ projectPath = 'gitlab-org/gitlab-test', canUpdate = true, issuableGid = WORK_ITEM_ID, childItem = workItemTask, + workItemType = TASK_TYPE_NAME, + apolloProvider = null, } = {}) => { + getWorkItemTreeQueryHandler = jest.fn().mockResolvedValue(workItemHierarchyTreeResponse); + wrapper = shallowMountExtended(WorkItemLinkChild, { + apolloProvider: + apolloProvider || createMockApollo([[getWorkItemTreeQuery, getWorkItemTreeQueryHandler]]), propsData: { projectPath, canUpdate, issuableGid, childItem, + workItemType, }, }); }; + beforeEach(() => { + createAlert.mockClear(); + }); + afterEach(() => { wrapper.destroy(); }); @@ -121,7 +156,78 @@ describe('WorkItemLinkChild', () => { it('removeChild event on menu triggers `click-remove-child` event', () => { itemMenuEl.vm.$emit('removeChild'); - expect(wrapper.emitted('remove')).toEqual([[workItemTask.id]]); + expect(wrapper.emitted('removeChild')).toEqual([[workItemTask.id]]); + }); + }); + + describe('nested children', () => { + const findExpandButton = () => wrapper.findByTestId('expand-child'); + const findTreeChildren = () => wrapper.findComponent(WorkItemTreeChildren); + + beforeEach(() => { + getWorkItemTreeQueryHandler.mockClear(); + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + }); + }); + + it('displays expand button when item has children, children are not displayed by default', () => { + expect(findExpandButton().exists()).toBe(true); + expect(findTreeChildren().exists()).toBe(false); + }); + + it('fetches and displays children of item when clicking on expand button', async () => { + await findExpandButton().vm.$emit('click'); + + expect(findExpandButton().props('loading')).toBe(true); + await waitForPromises(); + + expect(getWorkItemTreeQueryHandler).toHaveBeenCalled(); + expect(findTreeChildren().exists()).toBe(true); + + const widgetHierarchy = workItemHierarchyTreeResponse.data.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + expect(findTreeChildren().props('children')).toEqual(widgetHierarchy.children.nodes); + }); + + it('does not fetch children if already fetched once while clicking expand button', async () => { + findExpandButton().vm.$emit('click'); // Expand for the first time + await waitForPromises(); + + expect(findTreeChildren().exists()).toBe(true); + + await findExpandButton().vm.$emit('click'); // Collapse + findExpandButton().vm.$emit('click'); // Expand again + await waitForPromises(); + + expect(getWorkItemTreeQueryHandler).toHaveBeenCalledTimes(1); // ensure children were fetched only once. + expect(findTreeChildren().exists()).toBe(true); + }); + + it('calls createAlert when children fetch request fails on clicking expand button', async () => { + const getWorkItemTreeQueryFailureHandler = jest + .fn() + .mockRejectedValue(workItemHierarchyTreeFailureResponse); + const apolloProvider = createMockApollo([ + [getWorkItemTreeQuery, getWorkItemTreeQueryFailureHandler], + ]); + + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + apolloProvider, + }); + + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Object), + message: 'Something went wrong while fetching children.', + }); }); }); }); 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 fe95a9851773f7731d8daa81d4bbd9bf8321f5d8..a61de78c623a6170a64630a518feb45ff3ce4d3a 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 @@ -257,7 +257,7 @@ describe('WorkItemLinks', () => { }); it('calls correct mutation with correct variables', async () => { - firstChild.vm.$emit('remove', firstChild.vm.childItem.id); + firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id); await waitForPromises(); @@ -272,7 +272,7 @@ describe('WorkItemLinks', () => { }); it('shows toast when mutation succeeds', async () => { - firstChild.vm.$emit('remove', firstChild.vm.childItem.id); + firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id); await waitForPromises(); @@ -284,7 +284,7 @@ describe('WorkItemLinks', () => { it('renders correct number of children after removal', async () => { expect(findWorkItemLinkChildItems()).toHaveLength(4); - firstChild.vm.$emit('remove', firstChild.vm.childItem.id); + firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id); await waitForPromises(); expect(findWorkItemLinkChildItems()).toHaveLength(3); 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 9c1e9ccb6e86e670223594428df60224b64a103f..cc2e174dfdadeee5645bd6b32e7213b469f17ea1 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 @@ -2,12 +2,14 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; +import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; import { FORM_TYPES, WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, } from '~/work_items/constants'; +import { childrenWorkItems } from '../../mock_data'; describe('WorkItemTree', () => { let wrapper; @@ -17,10 +19,16 @@ describe('WorkItemTree', () => { const findEmptyState = () => wrapper.findByTestId('tree-empty'); const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton); const findForm = () => wrapper.findComponent(WorkItemLinksForm); + const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); - const createComponent = () => { + const createComponent = ({ children = childrenWorkItems } = {}) => { wrapper = shallowMountExtended(WorkItemTree, { - propsData: { workItemType: 'Objective', workItemId: 'gid://gitlab/WorkItem/515' }, + propsData: { + workItemType: 'Objective', + workItemId: 'gid://gitlab/WorkItem/515', + children, + projectPath: 'test/project', + }, }); }; @@ -47,9 +55,14 @@ describe('WorkItemTree', () => { }); it('displays empty state if there are no children', () => { + createComponent({ children: [] }); expect(findEmptyState().exists()).toBe(true); }); + it('renders all hierarchy widget children', () => { + expect(findWorkItemLinkChildItems()).toHaveLength(4); + }); + it('does not display form by default', () => { expect(findForm().exists()).toBe(false); }); @@ -71,4 +84,11 @@ describe('WorkItemTree', () => { expect(findForm().props('childrenType')).toBe(childType); }, ); + + it('remove event on child triggers `removeChild` event', () => { + const firstChild = findWorkItemLinkChildItems().at(0); + firstChild.vm.$emit('removeChild', 'gid://gitlab/WorkItem/2'); + + expect(wrapper.emitted('removeChild')).toEqual([['gid://gitlab/WorkItem/2']]); + }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index a4c16e014efa9712b3b526d58e319aca5a12edbb..7bade7345868b621a5dd0ec540b2e59c6a6470ce 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -85,6 +85,7 @@ export const workItemQueryResponse = { { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', + hasChildren: true, parent: { id: 'gid://gitlab/Issue/1', iid: '5', @@ -108,7 +109,15 @@ export const workItemQueryResponse = { state: 'OPEN', workItemType: { id: '1', + name: 'Task', + iconName: 'issue-type-task', }, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: false, + }, + ], }, ], }, @@ -150,6 +159,7 @@ export const updateWorkItemMutationResponse = { }, widgets: [ { + type: 'HIERARCHY', children: { nodes: [ { @@ -161,10 +171,13 @@ export const updateWorkItemMutationResponse = { state: 'OPEN', workItemType: { id: '1', + name: 'Task', + iconName: 'issue-type-task', }, }, ], }, + __typename: 'WorkItemConnection', }, { __typename: 'WorkItemWidgetAssignees', @@ -219,6 +232,20 @@ export const descriptionHtmlWithCheckboxes = ` </ul> `; +const taskType = { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', +}; + +export const objectiveType = { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', +}; + export const workItemResponseFactory = ({ canUpdate = false, canDelete = false, @@ -236,6 +263,7 @@ export const workItemResponseFactory = ({ lastEditedBy = null, withCheckboxes = false, parent = mockParent.parent, + workItemType = taskType, } = {}) => ({ data: { workItem: { @@ -253,12 +281,7 @@ export const workItemResponseFactory = ({ id: '1', fullPath: 'test-project-path', }, - workItemType: { - __typename: 'WorkItemType', - id: 'gid://gitlab/WorkItems::Type/5', - name: 'Task', - iconName: 'issue-type-task', - }, + workItemType, userPermissions: { deleteWorkItem: canDelete, updateWorkItem: canUpdate, @@ -338,6 +361,7 @@ export const workItemResponseFactory = ({ { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', + hasChildren: true, children: { nodes: [ { @@ -349,7 +373,15 @@ export const workItemResponseFactory = ({ state: 'OPEN', workItemType: { id: '1', + name: 'Task', + iconName: 'issue-type-task', }, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: false, + }, + ], }, ], }, @@ -669,6 +701,8 @@ export const workItemHierarchyEmptyResponse = { id: 'gid://gitlab/WorkItem/1', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', + name: 'Issue', + iconName: 'issue-type-issue', __typename: 'WorkItemType', }, title: 'New title', @@ -692,6 +726,7 @@ export const workItemHierarchyEmptyResponse = { { type: 'HIERARCHY', parent: null, + hasChildren: false, children: { nodes: [], __typename: 'WorkItemConnection', @@ -710,6 +745,8 @@ export const workItemHierarchyNoUpdatePermissionResponse = { id: 'gid://gitlab/WorkItem/1', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', + name: 'Issue', + iconName: 'issue-type-issue', __typename: 'WorkItemType', }, title: 'New title', @@ -731,6 +768,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { { type: 'HIERARCHY', parent: null, + hasChildren: true, children: { nodes: [ { @@ -738,6 +776,8 @@ export const workItemHierarchyNoUpdatePermissionResponse = { iid: '2', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'xyz', @@ -745,6 +785,12 @@ export const workItemHierarchyNoUpdatePermissionResponse = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: false, + }, + ], __typename: 'WorkItem', }, ], @@ -763,6 +809,8 @@ export const workItemTask = { iid: '4', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'bar', @@ -778,6 +826,8 @@ export const confidentialWorkItemTask = { iid: '2', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'xyz', @@ -793,6 +843,8 @@ export const closedWorkItemTask = { iid: '3', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'abc', @@ -803,6 +855,28 @@ export const closedWorkItemTask = { __typename: 'WorkItem', }; +export const childrenWorkItems = [ + confidentialWorkItemTask, + closedWorkItemTask, + workItemTask, + { + id: 'gid://gitlab/WorkItem/5', + iid: '5', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', + __typename: 'WorkItemType', + }, + title: 'foobar', + state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + __typename: 'WorkItem', + }, +]; + export const workItemHierarchyResponse = { data: { workItem: { @@ -810,6 +884,8 @@ export const workItemHierarchyResponse = { iid: '1', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', + name: 'Objective', + iconName: 'issue-type-objective', __typename: 'WorkItemType', }, title: 'New title', @@ -831,23 +907,97 @@ export const workItemHierarchyResponse = { { type: 'HIERARCHY', parent: null, + hasChildren: true, + children: { + nodes: childrenWorkItems, + __typename: 'WorkItemConnection', + }, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', + }, + }, +}; + +export const workItemObjectiveWithChild = { + id: 'gid://gitlab/WorkItem/12', + iid: '12', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', + __typename: 'WorkItemType', + }, + title: 'Objective', + state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: true, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', +}; + +export const workItemHierarchyTreeResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/2', + iid: '2', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', + __typename: 'WorkItemType', + }, + title: 'New title', + userPermissions: { + deleteWorkItem: true, + updateWorkItem: true, + }, + confidential: false, + project: { + __typename: 'Project', + id: '1', + fullPath: 'test-project-path', + }, + widgets: [ + { + type: 'DESCRIPTION', + __typename: 'WorkItemWidgetDescription', + }, + { + type: 'HIERARCHY', + parent: null, + hasChildren: true, children: { nodes: [ - confidentialWorkItemTask, - closedWorkItemTask, - workItemTask, { - id: 'gid://gitlab/WorkItem/5', - iid: '5', + id: 'gid://gitlab/WorkItem/13', + iid: '13', workItemType: { - id: 'gid://gitlab/WorkItems::Type/5', + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', __typename: 'WorkItemType', }, - title: 'foobar', + title: 'Objective 2', state: 'OPEN', confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: true, + __typename: 'WorkItemWidgetHierarchy', + }, + ], __typename: 'WorkItem', }, ], @@ -861,6 +1011,15 @@ export const workItemHierarchyResponse = { }, }; +export const workItemHierarchyTreeFailureResponse = { + data: {}, + errors: [ + { + message: 'Something went wrong', + }, + ], +}; + export const changeWorkItemParentMutationResponse = { data: { workItemUpdate: { @@ -894,6 +1053,7 @@ export const changeWorkItemParentMutationResponse = { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', parent: null, + hasChildren: false, children: { nodes: [], },