From f54ab19dc9959a283feae43be749f881583605cb Mon Sep 17 00:00:00 2001 From: Rajan Mistry Date: Fri, 5 Sep 2025 21:17:20 +0530 Subject: [PATCH 1/3] Add Parent bulk editing support for group level items --- .../tokens/work_item_parent_token.vue | 3 + .../work_item_bulk_edit_parent.vue | 122 ++++++++++++++- .../work_item_bulk_edit_sidebar.vue | 3 +- .../components/work_item_parent.vue | 4 +- .../graphql/work_item_type.fragment.graphql | 6 + .../work_items/pages/work_items_list_app.vue | 3 +- .../list/user_bulk_edits_work_items_spec.rb | 147 +++++++++++++++++- .../work_item_bulk_edit_sidebar_spec.js | 56 ++++++- locale/gitlab.pot | 3 + .../list/user_bulk_edits_work_items_spec.rb | 48 ++++++ .../work_item_bulk_edit_parent_spec.js | 128 ++++++++++++++- .../work_item_bulk_edit_sidebar_spec.js | 34 +--- spec/support/helpers/work_items_helpers.rb | 24 +++ .../work_items_bulk_edit_shared_examples.rb | 137 ++++++++++++++++ 14 files changed, 668 insertions(+), 50 deletions(-) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/work_item_parent_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/work_item_parent_token.vue index b169d130694457..71f43392858e5b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/work_item_parent_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/work_item_parent_token.vue @@ -62,6 +62,9 @@ export default { this.loading = true; try { + // The logic to fetch the Parent seems to be different than other pages + // Below issue targets to have a common logic across work items app + // https://gitlab.com/gitlab-org/gitlab/-/issues/571302 const { data } = await this.$apollo.query({ query: searchWorkItemParentQuery, variables: { diff --git a/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent.vue b/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent.vue index e4ef4b11a713a8..43033319f0d765 100644 --- a/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent.vue +++ b/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent.vue @@ -5,11 +5,19 @@ import { createAlert } from '~/alert'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__ } from '~/locale'; import { isValidURL } from '~/lib/utils/url_utility'; -import { BULK_EDIT_NO_VALUE } from '../../constants'; +import { + BULK_EDIT_NO_VALUE, + NAME_TO_ENUM_MAP, + WORK_ITEM_TYPE_ENUM_EPIC, + WORK_ITEM_TYPE_ENUM_TICKET, + WORK_ITEM_TYPE_ENUM_TEST_CASE, + WORK_ITEM_TYPE_ENUM_INCIDENT, +} from '../../constants'; import groupWorkItemsQuery from '../../graphql/group_work_items.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import workItemsByReferencesQuery from '../../graphql/work_items_by_references.query.graphql'; -import { isReference } from '../../utils'; +import namespaceWorkItemTypesQuery from '../../graphql/namespace_work_item_types.query.graphql'; +import { isReference, findHierarchyWidgetDefinition } from '../../utils'; export default { components: { @@ -36,6 +44,11 @@ export default { required: false, default: false, }, + selectedWorkItemTypesIds: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -45,23 +58,36 @@ export default { workspaceWorkItems: [], workItemsCache: [], workItemsByReference: [], + allowedParentTypesMap: {}, }; }, apollo: { workspaceWorkItems: { query() { - return this.isGroup ? groupWorkItemsQuery : projectWorkItemsQuery; + // The logic to fetch the Parent seems to be different than other pages + // Below issue targets to have a common logic across work items app + // https://gitlab.com/gitlab-org/gitlab/-/issues/571302 + return this.shouldSearchAcrossGroups ? groupWorkItemsQuery : projectWorkItemsQuery; }, variables() { return { - fullPath: this.fullPath, + fullPath: !this.isGroup && this.shouldSearchAcrossGroups ? this.groupPath : this.fullPath, searchTerm: this.searchTerm, in: this.searchTerm ? 'TITLE' : undefined, includeAncestors: true, + includeDescendants: this.shouldSearchAcrossGroups, + types: this.uniqueAllowedParentTypes.filter( + (type) => + ![ + WORK_ITEM_TYPE_ENUM_INCIDENT, + WORK_ITEM_TYPE_ENUM_TEST_CASE, + WORK_ITEM_TYPE_ENUM_TICKET, + ].includes(type), + ), }; }, skip() { - return !this.searchStarted; + return !this.searchStarted || !this.shouldLoadParents; }, update(data) { return data.workspace?.workItems?.nodes || []; @@ -96,6 +122,38 @@ export default { }); }, }, + allowedParentTypesMap: { + query: namespaceWorkItemTypesQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + const typesParentsMap = {}; + const types = data.workspace.workItemTypes.nodes || []; + + // Used `for` loop for better readability and performance + for (const type of types) { + // Get the hierarchy widgets + const hierarchyWidget = findHierarchyWidgetDefinition({ workItemType: type }); + + // If there are allowed parent types map the ids and names + if (hierarchyWidget?.allowedParentTypes?.nodes?.length > 0) { + const parentNames = hierarchyWidget.allowedParentTypes?.nodes.map((parent) => { + // Used enums because the workspaceWorkItems does not support gids in the types fields + return { id: parent.id, name: NAME_TO_ENUM_MAP[parent.name] }; + }); + typesParentsMap[type.id] = parentNames; + } + } + + return typesParentsMap; + }, + skip() { + return !this.fullPath; + }, + }, }, computed: { isSearchingByReference() { @@ -111,6 +169,10 @@ export default { return this.isSearchingByReference ? this.workItemsByReference : this.workspaceWorkItems; }, listboxItems() { + if (!this.shouldLoadParents) { + return []; + } + if (!this.searchTerm.trim().length) { return [ { @@ -141,6 +203,54 @@ export default { } return s__('WorkItem|Select parent'); }, + allowedParentTypeIds() { + return Object.keys(this.allowedParentTypesMap); + }, + hasParent() { + return this.selectedWorkItemTypesIds.some((id) => this.allowedParentTypeIds.includes(id)); + }, + areTypesCompatible() { + return ( + this.selectedWorkItemTypesIds + .map((id) => new Set((this.allowedParentTypesMap[id] || []).map((type) => type.id))) + .reduce((intersection, parentIds) => { + // If there are no parents + if (parentIds.size === 0) return new Set(); + // If parents are unique + if (!intersection) return parentIds; + // Verify if the parents are incompatible + return new Set([...parentIds].filter((id) => intersection.has(id))); + }, null)?.size > 0 ?? false + ); + }, + shouldLoadParents() { + return this.hasParent && this.areTypesCompatible; + }, + allowedParentTypes() { + return this.selectedWorkItemTypesIds?.flatMap( + (id) => this.allowedParentTypesMap?.[id]?.map((type) => type.name) || [], + ); + }, + uniqueAllowedParentTypes() { + return [...new Set(this.allowedParentTypes)]; + }, + isParentEpic() { + return this.uniqueAllowedParentTypes?.includes(WORK_ITEM_TYPE_ENUM_EPIC); + }, + shouldSearchAcrossGroups() { + // Determines if we need to search across groups. + // Cross-group search applies only when the parent is + // a group-level work item, an epic. + return this.isGroup || this.isParentEpic; + }, + groupPath() { + return this.fullPath.substring(0, this.fullPath.lastIndexOf('/')); + }, + noResultText() { + return !this.shouldLoadParents + ? s__('WorkItem|No available parent for all selected items.') + : s__('WorkItem|No matching results'); + }, }, watch: { workspaceWorkItems(workspaceWorkItems) { @@ -191,7 +301,7 @@ export default { :header-text="s__('WorkItem|Select parent')" is-check-centered :items="listboxItems" - :no-results-text="s__('WorkItem|No matching results')" + :no-results-text="noResultText" :reset-button-label="__('Reset')" searchable :searching="isLoading" diff --git a/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue b/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue index 4bfd2691ab347a..1c6d2c64491535 100644 --- a/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue +++ b/app/assets/javascripts/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue @@ -322,11 +322,12 @@ export default { :disabled="!hasItemsSelected || !canEditMilestone" />