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 f7693dd7102b8ef551b368d9e5de9dd2d1d0777d..9372618fb2a62c2b6509dced423a77dbd885db9b 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -6,8 +6,12 @@ import { GlDisclosureDropdownGroup, GlFilteredSearchToken, GlTooltipDirective, + GlDrawer, + GlLink, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; + +import produce from 'immer'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { isEmpty } from 'lodash'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; @@ -63,6 +67,9 @@ import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_ro import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { WORK_ITEM_TYPE_ENUM_OBJECTIVE } from '~/work_items/constants'; import { CREATED_DESC, defaultTypeTokenOptions, @@ -98,6 +105,7 @@ import { getSortKey, getSortOptions, isSortKey, + mapWorkItemWidgetsToIssueFields, } from '../utils'; import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin'; import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; @@ -131,12 +139,15 @@ export default { EmptyStateWithoutAnyIssues, GlButton, GlButtonGroup, + GlDrawer, IssuableByEmail, IssuableList, IssueCardStatistics, IssueCardTimeInfo, NewResourceDropdown, LocalStorageSync, + WorkItemDetail, + GlLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -218,6 +229,7 @@ export default { }, ], }, + activeIssuable: null, }; }, apollo: { @@ -230,6 +242,7 @@ export default { return data[this.namespace]?.issues.nodes ?? []; }, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + nextFetchPolicy: fetchPolicies.CACHE_FIRST, // We need this for handling loading state when using frontend cache // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details notifyOnNetworkStatusChange: true, @@ -536,6 +549,12 @@ export default { isGridView() { return this.viewType === ISSUES_GRID_VIEW_KEY; }, + isIssuableSelected() { + return !isEmpty(this.activeIssuable); + }, + issuesDrawerEnabled() { + return this.glFeatures?.issuesListDrawer; + }, }, watch: { $route(newValue, oldValue) { @@ -805,12 +824,96 @@ export default { // The default view is list view this.viewType = ISSUES_LIST_VIEW_KEY; }, + handleSelectIssuable(issuable) { + this.activeIssuable = issuable; + }, + updateIssuablesCache(workItem) { + const client = this.$apollo.provider.clients.defaultClient; + const issuesList = client.readQuery({ + query: getIssuesQuery, + variables: this.queryVariables, + }); + + const activeIssuable = issuesList.project.issues.nodes.find( + (issue) => issue.iid === workItem.iid, + ); + + // when we change issuable state, it's moved to a different tab + // to ensure that we show 20 items of the first page, we need to refetch issuables + if (!activeIssuable.state.includes(workItem.state.toLowerCase())) { + this.refetchIssuables(); + return; + } + + // handle all other widgets + const data = mapWorkItemWidgetsToIssueFields(issuesList, workItem); + + client.writeQuery({ query: getIssuesQuery, variables: this.queryVariables, data }); + }, + promoteToObjective(workItemIid) { + const { cache } = this.$apollo.provider.clients.defaultClient; + + cache.updateQuery({ query: getIssuesQuery, variables: this.queryVariables }, (issuesList) => + produce(issuesList, (draftData) => { + const activeItem = draftData.project.issues.nodes.find( + (issue) => issue.iid === workItemIid, + ); + + activeItem.type = WORK_ITEM_TYPE_ENUM_OBJECTIVE; + }), + ); + }, + refetchIssuables() { + this.$apollo.queries.issues.refetch(); + this.$apollo.queries.issuesCounts.refetch(); + }, + deleteIssuable({ workItemId }) { + this.$apollo + .mutate({ + mutation: deleteWorkItemMutation, + variables: { input: { id: workItemId } }, + }) + .then(({ data }) => { + if (data.workItemDelete.errors?.length) { + throw new Error(data.workItemDelete.errors[0]); + } + this.activeIssuable = null; + this.refetchIssuables(); + }) + .catch((error) => { + this.issuesError = this.$options.i18n.deleteError; + Sentry.captureException(error); + }); + }, }, }; </script> <template> <div> + <gl-drawer + v-if="issuesDrawerEnabled" + :open="isIssuableSelected" + header-height="calc(var(--top-bar-height) + var(--performance-bar-height))" + class="gl-w-40p gl-xs-w-full" + @close="activeIssuable = null" + > + <template #title> + <gl-link :href="activeIssuable.webUrl" class="gl-text-black-normal">{{ + __('Open full view') + }}</gl-link> + </template> + <template #default> + <work-item-detail + :key="activeIssuable.iid" + :work-item-iid="activeIssuable.iid" + @work-item-updated="updateIssuablesCache" + @addChild="refetchIssuables" + @deleteWorkItem="deleteIssuable" + @promotedToObjective="promoteToObjective" + /> + </template> + </gl-drawer> <issuable-list v-if="hasAnyIssues" :namespace="fullPath" @@ -840,7 +943,9 @@ export default { :has-previous-page="pageInfo.hasPreviousPage" :show-filtered-search-friendly-text="hasOrFeature" :is-grid-view="isGridView" + :active-issuable="activeIssuable" show-work-item-type-icon + :prevent-redirect="issuesDrawerEnabled" @click-tab="handleClickTab" @dismiss-alert="handleDismissAlert" @filter="handleFilter" @@ -850,6 +955,7 @@ export default { @sort="handleSort" @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" @page-size-change="handlePageSizeChange" + @select-issuable="handleSelectIssuable" > <template #nav-actions> <local-storage-sync diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 1a3d97277c787976f6e62f577f90c46eef6c1c12..a7933803ed4a795870ae0ce842aa3b26e7678424 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -115,6 +115,7 @@ export const i18n = { noSearchResultsTitle: __('Sorry, your filter produced no results'), relatedMergeRequests: __('Related merge requests'), reorderError: __('An error occurred while reordering issues.'), + deleteError: __('An error occurred while deleting an issuable.'), rssLabel: __('Subscribe to RSS feed'), searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js index e64870152bd1967a3d674c7ec3396d565c745509..6e9a566cb5c445dd90fef13a39567eca69592e84 100644 --- a/app/assets/javascripts/issues/list/graphql.js +++ b/app/assets/javascripts/issues/list/graphql.js @@ -1,6 +1,7 @@ import produce from 'immer'; import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import { config } from '~/graphql_shared/issuable_client'; let client; @@ -27,7 +28,7 @@ const resolvers = { export async function gqlClient() { if (client) return client; client = gon.features?.frontendCaching - ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' }) - : createDefaultClient(resolvers); + ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list', ...config }) + : createDefaultClient(resolvers, config); return client; } diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index d1b4529402608c975cc6beef2deabcdeb40c0171..511d6e6f4ce439fdf58a9c74055a49cfb26b7914 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -95,6 +95,8 @@ export async function mountIssuesListApp() { showNewIssueLink, signInPath, groupId = '', + reportAbusePath, + registerPath, } = el.dataset; return new Vue({ @@ -117,7 +119,10 @@ export async function mountIssuesListApp() { canReadCrmOrganization: parseBoolean(canReadCrmOrganization), emptyStateSvgPath, fullPath, + projectPath: fullPath, groupPath, + reportAbusePath, + registerPath, hasAnyIssues: parseBoolean(hasAnyIssues), hasAnyProjects: parseBoolean(hasAnyProjects), hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index d053400dd033f568deb7a362ccf48e9b6fa78137..8859021b16ca272db9c72c1ceb4e5bf2a06856bb 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -1,3 +1,4 @@ +import produce from 'immer'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -16,6 +17,15 @@ import { TOKEN_TYPE_LABEL, } from '~/vue_shared/components/filtered_search_bar/constants'; import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants'; +import { + WORK_ITEM_TO_ISSUE_MAP, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_AWARD_EMOJI, + EMOJI_THUMBSUP, + EMOJI_THUMBSDOWN, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, +} from '~/work_items/constants'; import { ALTERNATIVE_FILTER, API_PARAM, @@ -318,3 +328,59 @@ export const convertToSearchQuery = (filterTokens) => .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data) .map((token) => token.value.data) .join(' ') || undefined; + +function findWidget(type, workItem) { + return workItem?.widgets?.find((widget) => widget.type === type); +} + +export function mapWorkItemWidgetsToIssueFields(issuesList, workItem) { + return produce(issuesList, (draftData) => { + const activeItem = draftData.project.issues.nodes.find((issue) => issue.iid === workItem.iid); + + Object.keys(WORK_ITEM_TO_ISSUE_MAP).forEach((type) => { + const currentWidget = findWidget(type, workItem); + if (!currentWidget) { + return; + } + const property = WORK_ITEM_TO_ISSUE_MAP[type]; + + // handling the case for assignees and labels + if ( + property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_ASSIGNEES] || + property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_LABELS] + ) { + activeItem[property] = { + ...currentWidget[property], + nodes: currentWidget[property].nodes.map((node) => ({ + __persist: true, + ...node, + })), + }; + return; + } + + // handling the case for milestone + if (property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_MILESTONE] && currentWidget[property]) { + activeItem[property] = { __persist: true, ...currentWidget[property] }; + return; + } + + // handling emojis + if (property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_AWARD_EMOJI]) { + const upvotesCount = + currentWidget[property].nodes.filter((emoji) => emoji.name === EMOJI_THUMBSUP)?.length ?? + 0; + const downvotesCount = + currentWidget[property].nodes.filter((emoji) => emoji.name === EMOJI_THUMBSDOWN) + ?.length ?? 0; + activeItem.upvotes = upvotesCount; + activeItem.downvotes = downvotesCount; + return; + } + activeItem[property] = currentWidget[property]; + }); + + activeItem.title = workItem.title; + activeItem.confidential = workItem.confidential; + }); +} diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js index f8ae180107ce6a3bed5818380d48d809c6ef88ad..56f6606808e87169917c8a8b4c92389dd26663f5 100644 --- a/app/assets/javascripts/lib/apollo/persistence_mapper.js +++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js @@ -50,7 +50,7 @@ export const persistenceMapper = async (data) => { // we need this to prevent overcaching when we fetch the same entity (e.g. project) more than once // with different set of fields - if (Object.values(rootQuery).some((value) => value.__ref === key)) { + if (Object.values(rootQuery).some((value) => value?.__ref === key)) { const mappedEntity = {}; Object.entries(parsedEntity).forEach(([parsedKey, parsedValue]) => { if (!parsedValue || typeof parsedValue !== 'object' || parsedValue['__persist']) { 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 ce33d7a9b4b0a2033f9c084ddfae835b9db7384f..37aedc4ff0955f7cdf768f75c5883703bd8d033a 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 @@ -57,6 +57,16 @@ export default { required: false, default: false, }, + isActive: { + type: Boolean, + required: false, + default: false, + }, + preventRedirect: { + type: Boolean, + required: false, + default: false, + }, }, computed: { issuableId() { @@ -177,6 +187,13 @@ export default { } return ''; }, + handleIssuableItemClick(e) { + if (e.metaKey || e.ctrlKey || !this.preventRedirect) { + return; + } + e.preventDefault(); + this.$emit('select-issuable', { iid: this.issuableIid, webUrl: this.webUrl }); + }, }, }; </script> @@ -185,9 +202,10 @@ export default { <li :id="`issuable_${issuableId}`" class="issue gl-display-flex! gl-px-5!" - :class="{ closed: issuable.closedAt }" + :class="{ closed: issuable.closedAt, 'gl-bg-blue-50': isActive }" :data-labels="labelIdsString" :data-qa-issue-id="issuableId" + data-testid="issuable-item-wrapper" > <gl-form-checkbox v-if="showCheckbox" @@ -226,7 +244,9 @@ export default { dir="auto" :href="webUrl" data-qa-selector="issuable_title_link" + data-testid="issuable-title-link" v-bind="issuableTitleProps" + @click="handleIssuableItemClick" > {{ issuable.title }} <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> 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 4023337a1cb44b7ca18b83f2c3f460af7ef6a6a5..7a9404e06c7f2f8d620cd20d2a0be85562b0675e 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 @@ -203,6 +203,16 @@ export default { required: false, default: false, }, + activeIssuable: { + type: Object, + required: false, + default: null, + }, + preventRedirect: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -299,6 +309,9 @@ export default { handlePageSizeChange(newPageSize) { this.$emit('page-size-change', newPageSize); }, + isIssuableActive(issuable) { + return Boolean(issuable.iid === this.activeIssuable?.iid); + }, }, PAGE_SIZE_STORAGE_KEY, }; @@ -373,7 +386,10 @@ export default { :show-checkbox="showBulkEditSidebar" :checked="issuableChecked(issuable)" :show-work-item-type-icon="showWorkItemTypeIcon" + :prevent-redirect="preventRedirect" + :is-active="isIssuableActive(issuable)" @checked-input="handleIssuableCheckedInput(issuable, $event)" + @select-issuable="$emit('select-issuable', $event)" > <template #reference> <slot name="reference" :issuable="issuable"></slot> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 76a04bede611bf88d211f34825ef60669fcff98b..6ca487d5427fec3bf8b41d25bfe527aeb965e374 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -275,6 +275,7 @@ export default { } this.$toast.show(s__('WorkItem|Promoted to objective.')); this.track('promote_kr_to_objective'); + this.$emit('promotedToObjective'); } catch (error) { this.throwConvertError(); Sentry.captureException(error); 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 bbf1f39e30802643e1244bb7cc31bb412ebae83b..13c4aff09da959c725ad7c5d8ccbc651cda84a58 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -134,6 +134,7 @@ export default { if (!res.data) { return; } + this.$emit('work-item-updated', this.workItem); if (isEmpty(this.workItem)) { this.setEmptyState(); } @@ -467,6 +468,7 @@ export default { @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" /> <gl-button v-if="isModal" @@ -542,6 +544,7 @@ export default { " @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" /> </div> </div> @@ -584,6 +587,7 @@ export default { :can-update="canUpdate" :confidential="workItem.confidential" @show-modal="openInModal" + @addChild="$emit('addChild')" /> <work-item-notes v-if="workItemNotes" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index db649913602f33f191cb4f5a6072811675aaad9f..4960189fb488a748b859935b8b36568e12c3f9a9 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -306,6 +306,7 @@ export default { [this.error] = data.workItemCreate.errors; } else { this.unsetError(); + this.$emit('addChild'); } }) .catch(() => { 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 83f3c391769d30d8ba3dc882415731e2ef74b203..246eac82c784115bc78479284cc2be8f6683ac6a 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 @@ -145,6 +145,7 @@ export default { :children-ids="childrenIds" :parent-confidential="confidential" @cancel="hideAddForm" + @addChild="$emit('addChild')" /> <work-item-children-wrapper :children="children" diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index b8324d7d5522c453d5428836bb67c3f4736d34c2..7f03ba7f6d3a28a6c638ace0020f0c303b094c31 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -247,3 +247,13 @@ export const EMOJI_ACTION_ADD = 'ADD'; export const EMOJI_ACTION_REMOVE = 'REMOVE'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; + +export const WORK_ITEM_TO_ISSUE_MAP = { + [WIDGET_TYPE_ASSIGNEES]: 'assignees', + [WIDGET_TYPE_LABELS]: 'labels', + [WIDGET_TYPE_MILESTONE]: 'milestone', + [WIDGET_TYPE_WEIGHT]: 'weight', + [WIDGET_TYPE_START_AND_DUE_DATE]: 'dueDate', + [WIDGET_TYPE_HEALTH_STATUS]: 'healthStatus', + [WIDGET_TYPE_AWARD_EMOJI]: 'awardEmoji', +}; diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql index 5c93370aac9356f1d7666dab344e11ee8c3fd1c8..9828363990b525f4fe21cda6fd9585a396b7a991 100644 --- a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql @@ -5,4 +5,5 @@ fragment MilestoneFragment on Milestone { state startDate dueDate + webPath } diff --git a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql index 4c3be007d96643ba6191689e89981d1a66777bec..66ac9dcd8d1800c6ed2865c55c36d56cacb3cdd1 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql @@ -1,7 +1,7 @@ #import "./work_item.fragment.graphql" query workItemByIid($fullPath: ID!, $iid: String) { - workspace: project(fullPath: $fullPath) { + workspace: project(fullPath: $fullPath) @persist { id workItems(iid: $iid) { nodes { diff --git a/app/assets/stylesheets/page_bundles/issues_list.scss b/app/assets/stylesheets/page_bundles/issues_list.scss index f39dee12126739644a663bd2e7eeec9327f0d5ca..b5afb8cdf4dc00dc013656177f2258a1d1e06399 100644 --- a/app/assets/stylesheets/page_bundles/issues_list.scss +++ b/app/assets/stylesheets/page_bundles/issues_list.scss @@ -34,3 +34,13 @@ opacity: 0.3; pointer-events: none; } + +.work-item-labels { + .gl-token { + padding-left: $gl-spacing-scale-1; + } + + .gl-token-close { + display: none; + } +} diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 6a45595580f512c90d049ae4ea4edc1fdc87b9d7..045278e370b7646c8723150b44bd14344649bac4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -52,6 +52,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:saved_replies, current_user) push_frontend_feature_flag(:issues_grid_view) push_frontend_feature_flag(:service_desk_ticket) + push_frontend_feature_flag(:issues_list_drawer) end before_action only: [:index, :show] do diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index d9b9b27d16c5d58f49a0d0840024fef90a2940a2..bbd8d2dd0a9256c74dd3c64617029857158d30f6 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -220,7 +220,9 @@ def project_issues_list_data(project, current_user) quick_actions_help_path: help_page_path('user/project/quick_actions'), releases_path: project_releases_path(project, format: :json), reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), - show_new_issue_link: show_new_issue_link?(project).to_s + show_new_issue_link: show_new_issue_link?(project).to_s, + report_abuse_path: add_category_abuse_reports_path, + register_path: new_user_registration_path(redirect_to_referer: 'yes') ) end diff --git a/config/feature_flags/development/issues_list_drawer.yml b/config/feature_flags/development/issues_list_drawer.yml new file mode 100644 index 0000000000000000000000000000000000000000..dba17d109a34ce8ec9f2defbaad9d254ecafcd2b --- /dev/null +++ b/config/feature_flags/development/issues_list_drawer.yml @@ -0,0 +1,8 @@ +--- +name: issues_list_drawer +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123019 +rollout_issue_url: +milestone: '16.3' +type: development +group: group::product planning +default_enabled: false 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 26f099f888775e44f060e230e0107f8f13c9ab2a..c989558017771a1e638e744904d5387b57a66caf 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 @@ -10,6 +10,7 @@ exports[`ExternalIssuesListRoot error handling when request fails displays error exports[`ExternalIssuesListRoot when request succeeds renders issuable-list component with correct props 1`] = ` Object { + "activeIssuable": null, "currentPage": 1, "currentTab": "opened", "defaultPageSize": 2, @@ -120,6 +121,7 @@ Object { "labelFilterParam": "labels", "namespace": "gitlab-org/gitlab-test", "nextPage": 2, + "preventRedirect": false, "previousPage": 0, "recentSearchesStorageKey": "jira_issues", "searchInputPlaceholder": "Search Jira issues", diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7960d24b762e392f307f3976ba324fb97b5eb230..fc4b58c1b097f2e58729c848699f8a2281443577 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4901,6 +4901,9 @@ msgstr "" msgid "An error occurred while decoding the file." msgstr "" +msgid "An error occurred while deleting an issuable." +msgstr "" + msgid "An error occurred while deleting the approvers group" msgstr "" @@ -32242,6 +32245,9 @@ msgstr "" msgid "Open evidence JSON in new tab" msgstr "" +msgid "Open full view" +msgstr "" + msgid "Open in Gitpod" msgstr "" diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index 72bf48260568d8fa44e5a8b5b31dd3ea8a5a1fd2..31c6df953c6ce9b7ba0a526cedd0d4b6d7ec6ef8 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlDisclosureDropdown } from '@gitlab/ui'; +import { GlButton, GlDisclosureDropdown, GlDrawer } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -70,6 +70,14 @@ import { TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { + workItemResponseFactory, + mockAssignees, + mockLabels, + mockMilestone, +} from 'jest/work_items/mock_data'; import('~/issuable'); import('~/users_select'); @@ -130,6 +138,10 @@ describe('CE IssuesListApp component', () => { const mockIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse); const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse); + const deleteWorkItemMutationHandler = jest + .fn() + .mockResolvedValue({ data: { workItemDelete: { errors: [] } } }); + const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail); @@ -142,6 +154,8 @@ describe('CE IssuesListApp component', () => { const findNewResourceDropdown = () => wrapper.findComponent(NewResourceDropdown); const findCalendarButton = () => wrapper.findByTestId('subscribe-calendar'); const findRssButton = () => wrapper.findByTestId('subscribe-rss'); + const findIssuableDrawer = () => wrapper.findComponent(GlDrawer); + const findDrawerWorkItem = () => wrapper.findComponent(WorkItemDetail); const findLabelsToken = () => findIssuableList() @@ -156,11 +170,13 @@ describe('CE IssuesListApp component', () => { sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), stubs = {}, mountFn = shallowMount, + deleteMutationHandler = deleteWorkItemMutationHandler, } = {}) => { const requestHandlers = [ [getIssuesQuery, issuesQueryResponse], [getIssuesCountsQuery, issuesCountsQueryResponse], [setSortPreferenceMutation, sortPreferenceMutationResponse], + [deleteWorkItemMutation, deleteMutationHandler], ]; router = new VueRouter({ mode: 'history' }); @@ -1017,4 +1033,209 @@ describe('CE IssuesListApp component', () => { expect(findLabelsToken().fetchLatestLabels).toBe(null); }); }); + + describe('when issue drawer is enabled', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + glFeatures: { + issuesListDrawer: true, + }, + }, + stubs: { + GlDrawer, + }, + }); + }); + + it('renders issuable drawer component', () => { + expect(findIssuableDrawer().exists()).toBe(true); + }); + + it('renders issuable drawer closed by default', () => { + expect(findIssuableDrawer().props('open')).toBe(false); + }); + + describe('on selecting an issuable', () => { + beforeEach(() => { + findIssuableList().vm.$emit( + 'select-issuable', + getIssuesQueryResponse.data.project.issues.nodes[0], + ); + return nextTick(); + }); + + it('opens issuable drawer', () => { + expect(findIssuableDrawer().props('open')).toBe(true); + }); + + it('selects active issuable', () => { + expect(findIssuableList().props('activeIssuable')).toEqual( + getIssuesQueryResponse.data.project.issues.nodes[0], + ); + }); + + describe('when closing the drawer', () => { + it('closes the drawer on drawer `close` event', async () => { + findIssuableDrawer().vm.$emit('close'); + await nextTick(); + + expect(findIssuableDrawer().props('open')).toBe(false); + }); + + it('removes active issuable', async () => { + findIssuableDrawer().vm.$emit('close'); + await nextTick(); + + expect(findIssuableList().props('activeIssuable')).toBe(null); + }); + }); + + describe('when updating an issuable', () => { + it('refetches the list if the issuable changed state', async () => { + const { + data: { workItem }, + } = workItemResponseFactory({ iid: '789', state: 'CLOSED' }); + findDrawerWorkItem().vm.$emit('work-item-updated', workItem); + + await waitForPromises(); + + expect(mockIssuesQueryResponse).toHaveBeenCalledTimes(2); + expect(mockIssuesCountsQueryResponse).toHaveBeenCalledTimes(2); + }); + + it('updates the assignees field of active issuable', async () => { + const { + data: { workItem }, + } = workItemResponseFactory({ iid: '789' }); + findDrawerWorkItem().vm.$emit('work-item-updated', workItem); + + await waitForPromises(); + + expect(findIssuableList().props('issuables')[0].assignees.nodes).toEqual( + mockAssignees.map((assignee) => ({ + ...assignee, + __persist: true, + })), + ); + }); + + it('updates the labels field of active issuable', async () => { + const { + data: { workItem }, + } = workItemResponseFactory({ iid: '789' }); + findDrawerWorkItem().vm.$emit('work-item-updated', workItem); + + await waitForPromises(); + + expect(findIssuableList().props('issuables')[0].labels.nodes).toEqual( + mockLabels.map((label) => ({ + ...label, + __persist: true, + textColor: undefined, + })), + ); + }); + + it('updates the milestone field of active issuable', async () => { + const { + data: { workItem }, + } = workItemResponseFactory({ iid: '789' }); + findDrawerWorkItem().vm.$emit('work-item-updated', workItem); + + await waitForPromises(); + + expect(findIssuableList().props('issuables')[0].milestone).toEqual({ + ...mockMilestone, + __persist: true, + expired: undefined, + state: undefined, + }); + }); + + it('updates the title and confidential state of active issuable', async () => { + const { + data: { workItem }, + } = workItemResponseFactory({ iid: '789', confidential: true }); + findDrawerWorkItem().vm.$emit('work-item-updated', workItem); + + await waitForPromises(); + + expect(findIssuableList().props('issuables')[0].title).toBe('Updated title'); + expect(findIssuableList().props('issuables')[0].confidential).toBe(true); + }); + + it('refetches the list if new child was added to active issuable', async () => { + findDrawerWorkItem().vm.$emit('addChild'); + + await waitForPromises(); + + expect(mockIssuesQueryResponse).toHaveBeenCalledTimes(2); + expect(mockIssuesCountsQueryResponse).toHaveBeenCalledTimes(2); + }); + + it('updates issuable type to objective if promoted to objective', async () => { + findDrawerWorkItem().vm.$emit('promotedToObjective', '789'); + + await waitForPromises(); + // required for cache updates + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findIssuableList().props('issuables')[0].type).toBe('OBJECTIVE'); + }); + }); + + describe('when deleting an issuable from the drawer', () => { + beforeEach(async () => { + const { + data: { workItem }, + } = workItemResponseFactory({ iid: '789' }); + findDrawerWorkItem().vm.$emit('deleteWorkItem', workItem); + + await waitForPromises(); + }); + + it('should refetch issues and issues count', () => { + expect(mockIssuesQueryResponse).toHaveBeenCalledTimes(2); + expect(mockIssuesCountsQueryResponse).toHaveBeenCalledTimes(2); + }); + + it('should close the issue drawer', () => { + expect(findIssuableDrawer().props('open')).toBe(false); + }); + }); + }); + }); + + it('shows an error when deleting from the drawer fails', async () => { + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + const { + data: { workItem }, + } = workItemResponseFactory({ iid: '789' }); + + wrapper = mountComponent({ + provide: { + glFeatures: { + issuesListDrawer: true, + }, + }, + stubs: { + GlDrawer, + }, + deleteMutationHandler: errorHandler, + }); + + findIssuableList().vm.$emit( + 'select-issuable', + getIssuesQueryResponse.data.project.issues.nodes[0], + ); + await nextTick(); + + findDrawerWorkItem().vm.$emit('deleteWorkItem', workItem); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalled(); + expect(findIssuableList().props('error')).toBe('An error occurred while deleting an issuable.'); + }); }); 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 502fa609ebca32f59740e975613998fc0dc8e02a..77333a878d173343d12f3f7977c78aa2bfe095ae 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 @@ -15,6 +15,8 @@ const createComponent = ({ showCheckbox = true, slots = {}, showWorkItemTypeIcon = false, + isActive = false, + preventRedirect = false, } = {}) => shallowMount(IssuableItem, { propsData: { @@ -24,6 +26,8 @@ const createComponent = ({ showDiscussions: true, showCheckbox, showWorkItemTypeIcon, + isActive, + preventRedirect, }, slots, stubs: { @@ -43,6 +47,8 @@ describe('IssuableItem', () => { const findTimestampWrapper = () => wrapper.find('[data-testid="issuable-timestamp"]'); const findWorkItemTypeIcon = () => wrapper.findComponent(WorkItemTypeIcon); + const findIssuableTitleLink = () => wrapper.findComponentByTestId('issuable-title-link'); + const findIssuableItemWrapper = () => wrapper.findByTestId('issuable-item-wrapper'); beforeEach(() => { gon.gitlab_url = MOCK_GITLAB_URL; @@ -553,4 +559,35 @@ describe('IssuableItem', () => { }); }); }); + + describe('when preventing redirect on clicking the link', () => { + it('emits an event on item click', () => { + const { iid, webUrl } = mockIssuable; + + wrapper = createComponent({ + preventRedirect: true, + }); + + findIssuableTitleLink().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.emitted('select-issuable')).toEqual([[{ iid, webUrl }]]); + }); + + it('does not apply highlighted class when item is not active', () => { + wrapper = createComponent({ + preventRedirect: true, + }); + + expect(findIssuableItemWrapper().classes('gl-bg-blue-50')).toBe(false); + }); + + it('applies highlghted class when item is active', () => { + wrapper = createComponent({ + isActive: true, + preventRedirect: true, + }); + + expect(findIssuableItemWrapper().classes('gl-bg-blue-50')).toBe(true); + }); + }); }); 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 68904603f408a30e3c64bfdfd28f481c6bd6c6d3..51aae9b45123617c99330b37b284fbf37c85cc9b 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 @@ -530,4 +530,28 @@ describe('IssuableListRoot', () => { expect(findIssuableGrid().exists()).toBe(true); }); }); + + it('passes `isActive` prop as false if there is no active issuable', () => { + wrapper = createComponent({}); + + expect(findIssuableItem().props('isActive')).toBe(false); + }); + + it('passes `isActive` prop as true if active issuable matches issuable item', () => { + wrapper = createComponent({ + props: { + activeIssuable: mockIssuableListProps.issuables[0], + }, + }); + + expect(findIssuableItem().props('isActive')).toBe(true); + }); + + it('emits `select-issuable` event on emitting `select-issuable` from issuable item', () => { + const mockIssuable = mockIssuableListProps.issuables[0]; + wrapper = createComponent({}); + findIssuableItem().vm.$emit('select-issuable', mockIssuable); + + expect(wrapper.emitted('select-issuable')).toEqual([[mockIssuable]]); + }); }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index e03c6a7e28d9a95f9832a541ced706b8fc5d7b58..cbe026cd014f244c8062c37343e793bbb736191e 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -359,6 +359,7 @@ describe('WorkItemActions component', () => { expect(convertWorkItemMutationSuccessHandler).toHaveBeenCalled(); expect($toast.show).toHaveBeenCalledWith('Promoted to objective.'); + expect(wrapper.emitted('promotedToObjective')).toEqual([[]]); }); it('emits error when promote mutation fails', async () => { diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index 5f7f56d7063546e2c03dc1fb87b18ca9511635d1..8caacc2dc972c043c01204f0dd556abd0182ed32 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -94,7 +94,7 @@ describe('WorkItemLinksForm', () => { preventDefault: jest.fn(), }); await waitForPromises(); - expect(wrapper.vm.childWorkItemType).toEqual('gid://gitlab/WorkItems::Type/3'); + expect(createMutationResolver).toHaveBeenCalledWith({ input: { title: 'Create task test', @@ -106,6 +106,7 @@ describe('WorkItemLinksForm', () => { confidential: false, }, }); + expect(wrapper.emitted('addChild')).toEqual([[]]); }); it('creates child task in confidential parent', async () => { 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 e90775a52400cb95ae595bf08920ba3dd46b05bf..01fa4591cde5ee9fe7dc3efe2cfc22d8ca5b285a 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 @@ -118,4 +118,14 @@ describe('WorkItemTree', () => { expect(findWorkItemLinkChildrenWrapper().props('canUpdate')).toBe(false); }); }); + + it('emits `addChild` event when form emits `addChild` event', async () => { + createComponent(); + + findToggleFormSplitButton().vm.$emit('showCreateObjectiveForm'); + await nextTick(); + findForm().vm.$emit('addChild'); + + expect(wrapper.emitted('addChild')).toEqual([[]]); + }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index f88e69a7ffe3efc9063f08b85ac861e85c2fd3e6..d7e5c02ffbe52411412b38389c347fb1f667c868 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -44,6 +44,7 @@ export const mockMilestone = { expired: false, startDate: '2022-10-17', dueDate: '2022-10-24', + webPath: '123', }; export const mockAwardEmojiThumbsUp = { @@ -451,6 +452,7 @@ export const objectiveType = { }; export const workItemResponseFactory = ({ + iid = '1', canUpdate = false, canDelete = false, canCreateNote = false, @@ -482,14 +484,15 @@ export const workItemResponseFactory = ({ createdAt = '2022-08-03T12:41:54Z', updatedAt = '2022-08-08T12:32:54Z', awardEmoji = mockAwardsWidget, + state = 'OPEN', } = {}) => ({ data: { workItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', - iid: '1', + iid, title: 'Updated title', - state: 'OPEN', + state, description: 'description', confidential, createdAt,