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 b169d1306944579c1e17cec96e79c225a0ac7c16..71f43392858e5b820964e8df58d1099f5b31cb7a 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 e4ef4b11a713a806b337ec5e45b8d90721d5c963..74591823a45f398c5cf1a63580762e10af1adcff 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,17 @@ 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_ITEMS_NO_PARENT_LIST,
+} 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 +42,11 @@ export default {
required: false,
default: false,
},
+ selectedWorkItemTypesIds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -45,23 +56,31 @@ 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.selectedItemParentTypes.filter(
+ (type) => !WORK_ITEMS_NO_PARENT_LIST.includes(type),
+ ),
};
},
skip() {
- return !this.searchStarted;
+ return !this.searchStarted || !this.shouldLoadParents;
},
update(data) {
return data.workspace?.workItems?.nodes || [];
@@ -96,6 +115,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 +162,10 @@ export default {
return this.isSearchingByReference ? this.workItemsByReference : this.workspaceWorkItems;
},
listboxItems() {
+ if (!this.shouldLoadParents) {
+ return [];
+ }
+
if (!this.searchTerm.trim().length) {
return [
{
@@ -141,6 +196,54 @@ export default {
}
return s__('WorkItem|Select parent');
},
+ selectedItemsCanHaveParents() {
+ return this.selectedWorkItemTypesIds.some((id) =>
+ Object.keys(this.allowedParentTypesMap).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.selectedItemsCanHaveParents && this.areTypesCompatible;
+ },
+ selectedItemParentTypes() {
+ return [
+ ...new Set(
+ this.selectedWorkItemTypesIds?.flatMap(
+ (id) => this.allowedParentTypesMap?.[id]?.map((type) => type.name) || [],
+ ),
+ ),
+ ];
+ },
+ canHaveEpicParent() {
+ return this.selectedItemParentTypes?.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.canHaveEpicParent;
+ },
+ 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 +294,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 4bfd2691ab347aff9f838513fcf51bbf278963a9..1c6d2c64491535281d4f4a2a4749606827f5b095 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"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_parent.vue b/app/assets/javascripts/work_items/components/work_item_parent.vue
index 06c64c65d73248518a182e5513296b1aa9ee0d79..9afdf701d4ca3e07990cac7894fbf5bba746b7c5 100644
--- a/app/assets/javascripts/work_items/components/work_item_parent.vue
+++ b/app/assets/javascripts/work_items/components/work_item_parent.vue
@@ -156,7 +156,9 @@ export default {
apollo: {
workspaceWorkItems: {
query() {
- // TODO: Remove the this.isIssue check once issues are migrated to work items
+ // 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.isGroup || this.isIssue ? groupWorkItemsQuery : projectWorkItemsQuery;
},
variables() {
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 111e4b9d767919962f8c0d3615a5064283b11d8d..68823c271ea2f93386c7fc5f6e422cd506360f80 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -460,3 +460,9 @@ export const WORK_ITEM_LIST_PREFERENCES_METADATA_FIELDS = [
isPresentInGroup: true,
},
];
+
+export const WORK_ITEMS_NO_PARENT_LIST = [
+ WORK_ITEM_TYPE_ENUM_INCIDENT,
+ WORK_ITEM_TYPE_ENUM_TEST_CASE,
+ WORK_ITEM_TYPE_ENUM_TICKET,
+];
diff --git a/app/assets/javascripts/work_items/graphql/work_item_type.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_type.fragment.graphql
index 8970b07c4dade966963e28b784f95cc11088e17c..448b162492df0bb9a08f6aa3808a980108cd494e 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_type.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_type.fragment.graphql
@@ -15,6 +15,12 @@ fragment WorkItemTypeFragment on WorkItemType {
name
}
}
+ allowedParentTypes {
+ nodes {
+ id
+ name
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/work_items/pages/work_items_list_app.vue b/app/assets/javascripts/work_items/pages/work_items_list_app.vue
index 41e46021acca5f6ad2aea1177acddfcc32bc0397..56d33eae9756b153de2d1bad523c843a6439961d 100644
--- a/app/assets/javascripts/work_items/pages/work_items_list_app.vue
+++ b/app/assets/javascripts/work_items/pages/work_items_list_app.vue
@@ -275,7 +275,6 @@ export default {
workItemsSlim: [],
workItemStateCounts: {},
activeItem: null,
- isRefetching: false,
hasStateToken: false,
initialLoadWasFiltered: false,
showLocalBoard: false,
@@ -461,7 +460,7 @@ export default {
return Boolean(this.searchQuery);
},
isLoading() {
- return this.$apollo.queries.workItemsSlim.loading && !this.isRefetching;
+ return this.$apollo.queries.workItemsSlim.loading;
},
isOpenTab() {
return this.state === STATUS_OPEN;
diff --git a/ee/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb b/ee/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb
index c196ed000347551217337d4b819380232ecf0019..d66837ccc6dc0d1b9c9f8606f15d950b79de7014 100644
--- a/ee/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb
+++ b/ee/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb
@@ -23,13 +23,21 @@
labels: [frontend_label, wontfix_label, feature_label])
end
+ let_it_be(:issue_2) { create(:work_item, :issue, project: project, title: "Issue 2") }
+ let_it_be(:incident) { create(:incident, project: project, title: "Incident 1") }
+ let_it_be(:task) { create(:work_item, :task, project: project, title: "Task 1") }
+ let_it_be(:objective) { create(:work_item, :objective, project: project, title: "Objective 1") }
+ let_it_be(:shared_objective) { create(:work_item, :objective, project: project, title: "Objective 2") }
+ let_it_be(:key_result) { create(:work_item, :key_result, project: project, title: "Key result 1") }
+
before_all do
group.add_developer(user)
end
before do
sign_in user
- stub_licensed_features(epics: true, group_bulk_edit: true)
+ stub_licensed_features(epics: true, group_bulk_edit: true, okrs: true, subepics: true)
+ stub_feature_flags(okrs_mvc: true)
end
context 'when user is signed in' do
@@ -82,5 +90,142 @@
let(:work_item_with_label) { epic_with_label }
end
end
+
+ context 'when bulk editing parent on group issue list' do
+ before do
+ allow(Gitlab::QueryLimiting).to receive(:threshold).and_return(132)
+
+ visit issues_group_path(group)
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk assigns parent' do
+ let(:child_work_item) { issue }
+ let(:parent_work_item) { epic }
+ let(:child_work_item_2) { issue_2 }
+ end
+
+ context 'when unassigning a parent' do
+ before do
+ create(:parent_link, work_item_parent: epic, work_item: issue)
+ create(:parent_link, work_item_parent: epic, work_item: issue_2)
+ page.refresh
+
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk unassigns parent' do
+ let(:child_work_item) { issue }
+ let(:parent_work_item) { epic }
+ let(:child_work_item_2) { issue_2 }
+ end
+ end
+
+ it_behaves_like 'when parent bulk edit shows no available items' do
+ let(:incompatible_work_item) { incident }
+ let(:incompatible_work_item_1) { issue }
+ let(:incompatible_work_item_2) { task }
+ end
+
+ it_behaves_like 'when parent bulk edit fetches correct work items' do
+ let(:child_work_item) { task }
+ let(:parent_work_item) { issue }
+ let(:incident_work_item) { incident }
+ end
+
+ it_behaves_like 'when user selects multiple types' do
+ let(:compatible_work_item_type_1) { key_result }
+ let(:compatible_work_item_type_2) { objective }
+ let(:shared_parent_work_item) { shared_objective }
+ let(:incompatible_work_item_type_1) { issue }
+ let(:incompatible_work_item_type_2) { task }
+ end
+ end
+
+ context 'when bulk editing parent on project issue list' do
+ before do
+ allow(Gitlab::QueryLimiting).to receive(:threshold).and_return(132)
+ stub_feature_flags(work_item_view_for_issues: true)
+
+ visit project_issues_path(project)
+ # clear the type filter as we will also update task
+ click_button 'Clear'
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk assigns parent' do
+ let(:child_work_item) { issue }
+ let(:parent_work_item) { epic }
+ let(:child_work_item_2) { issue_2 }
+ end
+
+ context 'when unassigning a parent' do
+ before do
+ create(:parent_link, work_item_parent: epic, work_item: issue)
+ create(:parent_link, work_item_parent: epic, work_item: issue_2)
+ page.refresh
+
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk unassigns parent' do
+ let(:child_work_item) { issue }
+ let(:parent_work_item) { epic }
+ let(:child_work_item_2) { issue_2 }
+ end
+ end
+
+ it_behaves_like 'when parent bulk edit shows no available items' do
+ let(:incompatible_work_item) { incident }
+ let(:incompatible_work_item_1) { issue }
+ let(:incompatible_work_item_2) { task }
+ end
+
+ it_behaves_like 'when parent bulk edit fetches correct work items' do
+ let(:child_work_item) { task }
+ let(:parent_work_item) { issue }
+ let(:incident_work_item) { incident }
+ end
+
+ it_behaves_like 'when user selects multiple types' do
+ let(:compatible_work_item_type_1) { key_result }
+ let(:compatible_work_item_type_2) { objective }
+ let(:shared_parent_work_item) { shared_objective }
+ let(:incompatible_work_item_type_1) { issue }
+ let(:incompatible_work_item_type_2) { task }
+ end
+ end
+
+ context 'when bulk editing parent on epics list' do
+ let_it_be(:child_epic_1) { create(:work_item, :epic, namespace: group, title: "Child epic 1") }
+ let_it_be(:child_epic_2) { create(:work_item, :epic, namespace: group, title: "Child epic 2") }
+
+ before do
+ visit group_epics_path(group)
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk assigns parent' do
+ let(:child_work_item) { child_epic_1 }
+ let(:parent_work_item) { epic }
+ let(:child_work_item_2) { child_epic_2 }
+ end
+
+ context 'when unassigning a parent' do
+ before do
+ create(:parent_link, work_item_parent: epic, work_item: child_epic_1)
+ create(:parent_link, work_item_parent: epic, work_item: child_epic_2)
+ page.refresh
+
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk unassigns parent' do
+ let(:child_work_item) { child_epic_1 }
+ let(:parent_work_item) { epic }
+ let(:child_work_item_2) { child_epic_2 }
+ end
+ end
+ end
end
end
diff --git a/ee/spec/frontend/work_items/list/components/work_item_bulk_edit_sidebar_spec.js b/ee/spec/frontend/work_items/list/components/work_item_bulk_edit_sidebar_spec.js
index 579ced8d69fd6521c6366a2da855e0a24823222b..2787454c483f7a0cb7b0e54a6eb7ab1db1da2a7b 100644
--- a/ee/spec/frontend/work_items/list/components/work_item_bulk_edit_sidebar_spec.js
+++ b/ee/spec/frontend/work_items/list/components/work_item_bulk_edit_sidebar_spec.js
@@ -7,8 +7,12 @@ import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import workItemBulkUpdateMutation from '~/work_items/graphql/list/work_item_bulk_update.mutation.graphql';
import getAvailableBulkEditWidgets from '~/work_items/graphql/list/get_available_bulk_edit_widgets.query.graphql';
-import WorkItemBulkEditSidebar from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue';
+import WorkItemBulkEditAssignee from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_assignee.vue';
import WorkItemBulkEditLabels from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_labels.vue';
+import WorkItemBulkEditMilestone from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_milestone.vue';
+import WorkItemBulkEditParent from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent.vue';
+import WorkItemBulkEditSidebar from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue';
+import WorkItemBulkMove from '~/work_items/components/work_item_bulk_edit/work_item_bulk_move.vue';
import { createAlert } from '~/alert';
import WorkItemBulkEditStatus from 'ee_component/work_items/components/work_item_bulk_edit/work_item_bulk_edit_status.vue';
import WorkItemBulkEditIteration from 'ee_component/work_items/components/list/work_item_bulk_edit_iteration.vue';
@@ -95,6 +99,16 @@ describe('WorkItemBulkEditSidebar component EE', () => {
const findAddLabelsComponent = () => wrapper.findAllComponents(WorkItemBulkEditLabels).at(0);
const findRemoveLabelsComponent = () => wrapper.findAllComponents(WorkItemBulkEditLabels).at(1);
+ const findStateComponent = () => wrapper.findComponentByTestId('bulk-edit-state');
+ const findAssigneeComponent = () => wrapper.findComponent(WorkItemBulkEditAssignee);
+ const findHealthStatusComponent = () => wrapper.findComponentByTestId('bulk-edit-health-status');
+ const findSubscriptionComponent = () => wrapper.findComponentByTestId('bulk-edit-subscription');
+ const findConfidentialityComponent = () =>
+ wrapper.findComponentByTestId('bulk-edit-confidentiality');
+ const findMilestoneComponent = () => wrapper.findComponent(WorkItemBulkEditMilestone);
+ const findParentComponent = () => wrapper.findComponent(WorkItemBulkEditParent);
+ const findBulkMoveComponent = () => wrapper.findComponent(WorkItemBulkMove);
+
describe('when epics list', () => {
it('calls mutation to bulk edit', async () => {
const addLabelIds = ['gid://gitlab/Label/1'];
@@ -137,6 +151,46 @@ describe('WorkItemBulkEditSidebar component EE', () => {
message: 'Something went wrong while bulk editing.',
});
});
+
+ describe('widget visibility', () => {
+ it('shows the correct widgets', () => {
+ createComponent({
+ provide: { hasIssuableHealthStatusFeature: true },
+ });
+
+ // visible
+ expect(findStateComponent().exists()).toBe(true);
+ expect(findAssigneeComponent().exists()).toBe(true);
+ expect(findAddLabelsComponent().exists()).toBe(true);
+ expect(findRemoveLabelsComponent().exists()).toBe(true);
+ expect(findHealthStatusComponent().exists()).toBe(true);
+ expect(findSubscriptionComponent().exists()).toBe(true);
+ expect(findConfidentialityComponent().exists()).toBe(true);
+ expect(findMilestoneComponent().exists()).toBe(true);
+ expect(findParentComponent().exists()).toBe(true);
+ expect(findBulkMoveComponent().exists()).toBe(true);
+ });
+
+ it('shows the correct widgets on epics list', () => {
+ createComponent({
+ props: { isEpicsList: true },
+ provide: { hasIssuableHealthStatusFeature: true },
+ });
+
+ // visible
+ expect(findAssigneeComponent().exists()).toBe(true);
+ expect(findAddLabelsComponent().exists()).toBe(true);
+ expect(findRemoveLabelsComponent().exists()).toBe(true);
+ expect(findHealthStatusComponent().exists()).toBe(true);
+ expect(findSubscriptionComponent().exists()).toBe(true);
+ expect(findConfidentialityComponent().exists()).toBe(true);
+ expect(findMilestoneComponent().exists()).toBe(true);
+
+ // hidden
+ expect(findStateComponent().exists()).toBe(false);
+ expect(findBulkMoveComponent().exists()).toBe(false);
+ });
+ });
});
it('calls mutation to bulk edit ee attributes', async () => {
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e4aecec2b774801b661306ba2f2c58c66c694be2..375c3819d68c7371580fda184f5fc27815d44529 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -74512,6 +74512,9 @@ msgstr ""
msgid "WorkItem|No assignees"
msgstr ""
+msgid "WorkItem|No available parent for all selected items."
+msgstr ""
+
msgid "WorkItem|No available status for all selected items."
msgstr ""
diff --git a/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb b/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb
index a01e80809d31bb343e34d3a6e1c1b1d5ef903fe7..220813d7125ee561dd461dbd5531bd485776ba47 100644
--- a/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb
+++ b/spec/features/work_items/list/user_bulk_edits_work_items_spec.rb
@@ -14,6 +14,8 @@
let_it_be(:wontfix_label) { create(:label, project: project, title: 'wontfix') }
let_it_be(:issue) { create(:work_item, :issue, project: project, title: "Issue without label") }
let_it_be(:task) { create(:work_item, :task, project: project, title: "Task without label") }
+ let_it_be(:task_2) { create(:work_item, :task, project: project, title: "Task 2") }
+ let_it_be(:incident) { create(:incident, project: project, title: "Incident 1") }
let_it_be(:issue_with_label) do
create(:work_item, :issue, project: project, title: "Issue with label", labels: [frontend_label])
end
@@ -85,5 +87,51 @@
let(:work_item_with_label) { issue_with_label }
end
end
+
+ context 'when bulk editing parent on project issue list' do
+ before do
+ allow(Gitlab::QueryLimiting).to receive(:threshold).and_return(137)
+ stub_feature_flags(work_item_view_for_issues: true)
+
+ visit project_issues_path(project)
+ # clear the type filter as we will also update task
+ click_button 'Clear'
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk assigns parent' do
+ let(:child_work_item) { task }
+ let(:parent_work_item) { issue }
+ let(:child_work_item_2) { task_2 }
+ end
+
+ context 'when unassigning a parent' do
+ before do
+ create(:parent_link, work_item_parent: issue, work_item: task)
+ create(:parent_link, work_item_parent: issue, work_item: task_2)
+ page.refresh
+
+ click_bulk_edit
+ end
+
+ it_behaves_like 'when user bulk unassigns parent' do
+ let(:child_work_item) { task }
+ let(:parent_work_item) { issue }
+ let(:child_work_item_2) { task_2 }
+ end
+ end
+
+ it_behaves_like 'when parent bulk edit shows no available items' do
+ let(:incompatible_work_item) { incident }
+ let(:incompatible_work_item_1) { issue }
+ let(:incompatible_work_item_2) { task }
+ end
+
+ it_behaves_like 'when parent bulk edit fetches correct work items' do
+ let(:child_work_item) { task }
+ let(:parent_work_item) { issue }
+ let(:incident_work_item) { incident }
+ end
+ end
end
end
diff --git a/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent_spec.js b/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent_spec.js
index 7e9108d9c1eb263ea9cb0c63afd46df738961d9a..7e2bc514d9310c3a4c3c240271ee2f9b058ab69c 100644
--- a/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent_spec.js
+++ b/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent_spec.js
@@ -8,12 +8,14 @@ import { createAlert } from '~/alert';
import groupWorkItemsQuery from '~/work_items/graphql/group_work_items.query.graphql';
import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql';
import workItemsByReferencesQuery from '~/work_items/graphql/work_items_by_references.query.graphql';
+import namespaceWorkItemTypesQuery from '~/work_items/graphql/namespace_work_item_types.query.graphql';
import WorkItemBulkEditParent from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_parent.vue';
import { BULK_EDIT_NO_VALUE } from '~/work_items/constants';
import {
availableObjectivesResponse,
mockWorkItemReferenceQueryResponse,
groupEpicsWithMilestonesQueryResponse,
+ namespaceWorkItemTypesQueryResponse,
} from '../../mock_data';
jest.mock('~/alert');
@@ -34,6 +36,11 @@ const listResults = [
value: 'gid://gitlab/WorkItem/711',
},
];
+const objectiveTypeId = 'gid://gitlab/WorkItems::Type/6';
+const issueTypeId = 'gid://gitlab/WorkItems::Type/1';
+const incidentTypeId = 'gid://gitlab/WorkItems::Type/2';
+const taskTypeId = 'gid://gitlab/WorkItems::Type/5';
+const keyResultTypeId = 'gid://gitlab/WorkItems::Type/7';
describe('WorkItemBulkEditParent component', () => {
let wrapper;
@@ -43,22 +50,26 @@ describe('WorkItemBulkEditParent component', () => {
const workItemsByReferenceHandler = jest
.fn()
.mockResolvedValue(mockWorkItemReferenceQueryResponse);
+ const typesQuerySuccessHandler = jest.fn().mockResolvedValue(namespaceWorkItemTypesQueryResponse);
const createComponent = ({
props = {},
projectHandler = projectWorkItemsHandler,
groupHandler = groupWorkItemsHandler,
searchHandler = workItemsByReferenceHandler,
+ selectedWorkItemTypesIds = [objectiveTypeId],
} = {}) => {
wrapper = mount(WorkItemBulkEditParent, {
apolloProvider: createMockApollo([
[groupWorkItemsQuery, groupHandler],
[projectWorkItemsQuery, projectHandler],
[workItemsByReferencesQuery, searchHandler],
+ [namespaceWorkItemTypesQuery, typesQuerySuccessHandler],
]),
propsData: {
fullPath: 'group/project',
isGroup: false,
+ selectedWorkItemTypesIds,
...props,
},
stubs: {
@@ -113,13 +124,66 @@ describe('WorkItemBulkEditParent component', () => {
expect(projectWorkItemsHandler).not.toHaveBeenCalled();
});
- it('is called when dropdown is shown', async () => {
+ it('project work items query is called and not group work items query when dropdown is shown', async () => {
createComponent();
findListbox().vm.$emit('shown');
- await nextTick();
+ await waitForPromises();
expect(projectWorkItemsHandler).toHaveBeenCalled();
+ expect(groupWorkItemsHandler).not.toHaveBeenCalled();
+ });
+
+ it('excludes incident, test case and ticket when any work item is selected', async () => {
+ createComponent({
+ selectedWorkItemTypesIds: [objectiveTypeId],
+ });
+
+ findListbox().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(projectWorkItemsHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ types: expect.not.arrayContaining(['INCIDENT', 'TEST_CASE', 'TICKET']),
+ }),
+ );
+ });
+
+ it('does not call project work items query and calls group work items query when an issue is selected', async () => {
+ createComponent({
+ selectedWorkItemTypesIds: [issueTypeId],
+ });
+
+ findListbox().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(projectWorkItemsHandler).not.toHaveBeenCalled();
+ expect(groupWorkItemsHandler).toHaveBeenCalled();
+ });
+
+ describe('shows no available items', () => {
+ it.each`
+ description | selectedWorkItemTypesIds
+ ${'when multiple incompatible types are selected in a group '} | ${[objectiveTypeId, taskTypeId, issueTypeId, keyResultTypeId]}
+ ${'when objective and issue are selected in a group'} | ${[objectiveTypeId, issueTypeId]}
+ ${'when task and issue are selected in a group'} | ${[taskTypeId, issueTypeId]}
+ ${'when key result and issue are selected in a group'} | ${[keyResultTypeId, issueTypeId]}
+ ${'when objective and task are selected in a group'} | ${[objectiveTypeId, taskTypeId]}
+ ${'in case of incident in a project'} | ${[incidentTypeId]}
+ `('$description', async ({ selectedWorkItemTypesIds }) => {
+ createComponent({
+ props: { isGroup: false },
+ selectedWorkItemTypesIds,
+ });
+
+ findListbox().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(findListbox().props('items')).toEqual([]);
+ expect(findListbox().props('noResultsText')).toBe(
+ 'No available parent for all selected items.',
+ );
+ });
});
it('emits an error when there is an error in the call', async () => {
@@ -147,11 +211,65 @@ describe('WorkItemBulkEditParent component', () => {
createComponent({ props: { isGroup: true } });
findListbox().vm.$emit('shown');
- await nextTick();
+ await waitForPromises();
+
+ expect(groupWorkItemsHandler).toHaveBeenCalled();
+ });
+
+ it('excludes incident, test case and ticket when an objective is selected', async () => {
+ createComponent({
+ props: { isGroup: true },
+ selectedWorkItemTypesIds: [objectiveTypeId],
+ });
+
+ findListbox().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(groupWorkItemsHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ types: expect.not.arrayContaining(['INCIDENT', 'TEST_CASE', 'TICKET']),
+ }),
+ );
+ });
+ it('does not call project work items query when it is a group', async () => {
+ createComponent({
+ props: { isGroup: true },
+ selectedWorkItemTypesIds: [objectiveTypeId],
+ });
+
+ findListbox().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(projectWorkItemsHandler).not.toHaveBeenCalled();
expect(groupWorkItemsHandler).toHaveBeenCalled();
});
+ describe('shows no available items', () => {
+ it.each`
+ description | selectedWorkItemTypesIds
+ ${'when multiple incompatible types are selected in a group '} | ${[objectiveTypeId, taskTypeId, issueTypeId, keyResultTypeId]}
+ ${'when objective and issue are selected in a group'} | ${[objectiveTypeId, issueTypeId]}
+ ${'when task and issue are selected in a group'} | ${[taskTypeId, issueTypeId]}
+ ${'when key result and issue are selected in a group'} | ${[keyResultTypeId, issueTypeId]}
+ ${'when objective and task are selected in a group'} | ${[objectiveTypeId, taskTypeId]}
+ ${'in case of incident in a group'} | ${[incidentTypeId]}
+ `('$description', async ({ selectedWorkItemTypesIds }) => {
+ createComponent({
+ props: { isGroup: true },
+ selectedWorkItemTypesIds,
+ });
+
+ findListbox().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(findListbox().props('items')).toEqual([]);
+ expect(findListbox().props('noResultsText')).toBe(
+ 'No available parent for all selected items.',
+ );
+ });
+ });
+
it('emits an error when there is an error in the call', async () => {
createComponent({
props: { isGroup: true },
@@ -264,8 +382,8 @@ describe('WorkItemBulkEditParent component', () => {
});
});
- describe('with selected milestone', () => {
- it('renders the milestone title', async () => {
+ describe('with selected parent', () => {
+ it('renders the parent title', async () => {
createComponent();
await openListboxAndSelect('gid://gitlab/WorkItem/711');
diff --git a/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar_spec.js b/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar_spec.js
index cabf9049c670981ac4290cf74654841a81bd6b78..6bf8fb75c5b13174bda2445612c130e444415816 100644
--- a/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar_spec.js
+++ b/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar_spec.js
@@ -232,43 +232,19 @@ describe('WorkItemBulkEditSidebar component', () => {
describe('widget visibility', () => {
it('shows the correct widgets', () => {
- createComponent({
- provide: { hasIssuableHealthStatusFeature: true },
- });
+ createComponent();
// visible
expect(findStateComponent().exists()).toBe(true);
expect(findAssigneeComponent().exists()).toBe(true);
expect(findAddLabelsComponent().exists()).toBe(true);
expect(findRemoveLabelsComponent().exists()).toBe(true);
- expect(findHealthStatusComponent().exists()).toBe(true);
expect(findSubscriptionComponent().exists()).toBe(true);
expect(findConfidentialityComponent().exists()).toBe(true);
expect(findMilestoneComponent().exists()).toBe(true);
expect(findParentComponent().exists()).toBe(true);
expect(findBulkMoveComponent().exists()).toBe(true);
});
-
- it('shows the correct widgets on epics list', () => {
- createComponent({
- props: { isEpicsList: true },
- provide: { hasIssuableHealthStatusFeature: true },
- });
-
- // visible
- expect(findAssigneeComponent().exists()).toBe(true);
- expect(findAddLabelsComponent().exists()).toBe(true);
- expect(findRemoveLabelsComponent().exists()).toBe(true);
- expect(findHealthStatusComponent().exists()).toBe(true);
- expect(findSubscriptionComponent().exists()).toBe(true);
- expect(findConfidentialityComponent().exists()).toBe(true);
- expect(findMilestoneComponent().exists()).toBe(true);
-
- // hidden
- expect(findStateComponent().exists()).toBe(false);
- expect(findParentComponent().exists()).toBe(false);
- expect(findBulkMoveComponent().exists()).toBe(false);
- });
});
describe('getAvailableBulkEditWidgets query', () => {
@@ -557,14 +533,6 @@ describe('WorkItemBulkEditSidebar component', () => {
});
describe('"Parent" component', () => {
- it.each([true, false])('renders depending on isEpicsList prop', (isEpicsList) => {
- createComponent({
- props: { isEpicsList },
- });
-
- expect(findParentComponent().exists()).toBe(!isEpicsList);
- });
-
it('updates parent when "Parent" component emits "input" event', async () => {
createComponent({
props: { isEpicsList: false },
diff --git a/spec/support/helpers/work_items_helpers.rb b/spec/support/helpers/work_items_helpers.rb
index 56e0ba8477a2115a3a96ce0cb7d4cc5770b5d691..97fe616633d9bd280f3dbaa4e7719e5ebc48bb4f 100644
--- a/spec/support/helpers/work_items_helpers.rb
+++ b/spec/support/helpers/work_items_helpers.rb
@@ -16,6 +16,30 @@ def remove_labels_on_bulk_edit(items = [])
select_items_from_dropdown(items, 'Select labels', 'bulk-edit-remove-labels')
end
+ def select_parent_on_bulk_edit(parent_title)
+ select_items_from_dropdown([parent_title], 'Select parent', 'bulk-edit-parent')
+ end
+
+ def select_no_parent_on_bulk_edit
+ select_items_from_dropdown(['No parent'], 'Select parent', 'bulk-edit-parent')
+ end
+
+ def search_parent_on_bulk_edit(search_term)
+ within_testid('bulk-edit-parent') do
+ click_button 'Select parent'
+ wait_for_requests
+ fill_in 'Search', with: search_term
+ wait_for_requests
+ end
+ end
+
+ def click_parent_bulk_edit_dropdown
+ within_testid('bulk-edit-parent') do
+ click_button 'Select parent'
+ wait_for_requests
+ end
+ end
+
def select_items_from_dropdown(items, listbox_name, testid)
within_testid(testid) do
click_button listbox_name
diff --git a/spec/support/shared_examples/features/work_items/work_items_bulk_edit_shared_examples.rb b/spec/support/shared_examples/features/work_items/work_items_bulk_edit_shared_examples.rb
index ea96a6d9877c21a3d881e92f3cba144e6c55cb54..e9a8f83da633d362964d11200c19048c9847c527 100644
--- a/spec/support/shared_examples/features/work_items/work_items_bulk_edit_shared_examples.rb
+++ b/spec/support/shared_examples/features/work_items/work_items_bulk_edit_shared_examples.rb
@@ -105,3 +105,140 @@
end
end
end
+
+RSpec.shared_examples 'when user bulk assigns parent' do
+ it 'assigns parent to single work item' do
+ check_work_items([child_work_item.title])
+ select_parent_on_bulk_edit(parent_work_item.title)
+ click_update_selected
+
+ find_work_item_element(child_work_item.id).click
+ within_testid('work-item-parent') do
+ expect(page).to have_content parent_work_item.title
+ end
+ end
+
+ it 'assigns parent to multiple work items' do
+ check_work_items([child_work_item.title, child_work_item_2.title])
+ select_parent_on_bulk_edit(parent_work_item.title)
+ click_update_selected
+
+ find_work_item_element(child_work_item.id).click
+ within_testid('work-item-parent') do
+ expect(page).to have_content parent_work_item.title
+ end
+
+ close_drawer
+
+ find_work_item_element(child_work_item_2.id).click
+ within_testid('work-item-parent') do
+ expect(page).to have_content parent_work_item.title
+ end
+ end
+end
+
+RSpec.shared_examples 'when user bulk unassigns parent' do
+ it 'removes parent from single work item' do
+ check_work_items([child_work_item.title])
+ select_no_parent_on_bulk_edit
+ click_update_selected
+
+ find_work_item_element(child_work_item.id).click
+ within_testid('work-item-parent') do
+ expect(page).not_to have_content parent_work_item.title
+ end
+ end
+
+ it 'removes parent from multiple work items' do
+ check_work_items([child_work_item.title, child_work_item_2.title])
+ select_no_parent_on_bulk_edit
+ click_update_selected
+
+ find_work_item_element(child_work_item.id).click
+ within_testid('work-item-parent') do
+ expect(page).not_to have_content parent_work_item.title
+ end
+
+ close_drawer
+
+ find_work_item_element(child_work_item_2.id).click
+ within_testid('work-item-parent') do
+ expect(page).not_to have_content parent_work_item.title
+ end
+ end
+end
+
+RSpec.shared_examples 'when parent bulk edit shows no available items' do
+ it 'shows no available items message for incompatible work item types' do
+ check_work_items([incompatible_work_item.title])
+ click_parent_bulk_edit_dropdown
+
+ expect(page).to have_content 'No available parent for all selected items.'
+ end
+
+ it 'shows no available items message for mixed incompatible work item types' do
+ check_work_items([incompatible_work_item_1.title, incompatible_work_item_2.title])
+ click_parent_bulk_edit_dropdown
+
+ expect(page).to have_content 'No available parent for all selected items.'
+ end
+end
+
+RSpec.shared_examples 'when parent bulk edit fetches correct work items' do
+ it 'fetches and excludes incident, test case and ticket for task work items' do
+ check_work_items([child_work_item.title])
+ click_parent_bulk_edit_dropdown
+
+ within_testid('bulk-edit-parent') do
+ expect(page).to have_content parent_work_item.title
+ expect(page).not_to have_content incident_work_item.title
+ end
+ end
+
+ it 'searches across groups when issue is selected' do
+ check_work_items([child_work_item.title])
+ click_parent_bulk_edit_dropdown
+
+ within_testid('bulk-edit-parent') do
+ expect(page).to have_content parent_work_item.title
+ end
+ end
+
+ it 'searches parent by title' do
+ check_work_items([child_work_item.title])
+ search_parent_on_bulk_edit(parent_work_item.title)
+
+ within_testid('bulk-edit-parent') do
+ expect(page).to have_content parent_work_item.title
+ end
+ end
+
+ it 'searches parent by reference' do
+ check_work_items([child_work_item.title])
+ search_parent_on_bulk_edit("##{parent_work_item.iid}")
+
+ within_testid('bulk-edit-parent') do
+ expect(page).to have_content parent_work_item.title
+ end
+ end
+end
+
+RSpec.shared_examples 'when user selects multiple types' do
+ it 'shows intersection of available parents for mixed compatible types' do
+ check_work_items([compatible_work_item_type_1.title, compatible_work_item_type_2.title])
+ click_parent_bulk_edit_dropdown
+
+ within_testid('bulk-edit-parent') do
+ expect(page).to have_content shared_parent_work_item.title
+ end
+ end
+
+ it 'shows no available parents for mixed incompatible types' do
+ check_work_items([incompatible_work_item_type_1.title, incompatible_work_item_type_2.title])
+ click_parent_bulk_edit_dropdown
+
+ within_testid('bulk-edit-parent') do
+ expect(page).to have_content 'No available parent for all selected items.'
+ end
+ end
+end