From 851621865643a5ed5b1e578f15644b3bd4e5f641 Mon Sep 17 00:00:00 2001
From: Heinrich Lee Yu <heinrich@gitlab.com>
Date: Tue, 11 Mar 2025 14:39:18 +0800
Subject: [PATCH] Add GraphQL arguments to filter by custom fields

Add custom field arguments for work items and issue boards
---
 doc/api/graphql/reference/_index.md           |   5 +
 .../ee/resolvers/work_items_resolver.rb       |   5 +
 .../ee/types/boards/board_issue_input_type.rb |   6 +
 .../api/graphql/project/issues_spec.rb        |  43 ----
 .../work_items/custom_field_filters_spec.rb   | 213 ++++++++++++++++++
 5 files changed, 229 insertions(+), 43 deletions(-)
 create mode 100644 ee/spec/requests/api/graphql/work_items/custom_field_filters_spec.rb

diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index 8a010ba1e969..063575680005 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -28030,6 +28030,7 @@ Returns [`WorkItemStateCountsType`](#workitemstatecountstype).
 | <a id="groupworkitemstatecountsconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter for confidential work items. If `false`, excludes confidential work items. If `true`, returns only confidential work items. |
 | <a id="groupworkitemstatecountscreatedafter"></a>`createdAfter` | [`Time`](#time) | Work items created after the timestamp. |
 | <a id="groupworkitemstatecountscreatedbefore"></a>`createdBefore` | [`Time`](#time) | Work items created before the timestamp. |
+| <a id="groupworkitemstatecountscustomfield"></a>`customField` {{< icon name="warning-solid" >}} | [`[WorkItemWidgetCustomFieldFilterInputType!]`](#workitemwidgetcustomfieldfilterinputtype) | **Introduced** in GitLab 17.10. **Status**: Experiment. Filter by custom fields. |
 | <a id="groupworkitemstatecountsdueafter"></a>`dueAfter` | [`Time`](#time) | Work items due after the timestamp. |
 | <a id="groupworkitemstatecountsduebefore"></a>`dueBefore` | [`Time`](#time) | Work items due before the timestamp. |
 | <a id="groupworkitemstatecountsexcludeprojects"></a>`excludeProjects` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 17.5. **Status**: Experiment. Exclude work items from projects within the group. |
@@ -28099,6 +28100,7 @@ four standard [pagination arguments](#pagination-arguments):
 | <a id="groupworkitemsconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter for confidential work items. If `false`, excludes confidential work items. If `true`, returns only confidential work items. |
 | <a id="groupworkitemscreatedafter"></a>`createdAfter` | [`Time`](#time) | Work items created after the timestamp. |
 | <a id="groupworkitemscreatedbefore"></a>`createdBefore` | [`Time`](#time) | Work items created before the timestamp. |
+| <a id="groupworkitemscustomfield"></a>`customField` {{< icon name="warning-solid" >}} | [`[WorkItemWidgetCustomFieldFilterInputType!]`](#workitemwidgetcustomfieldfilterinputtype) | **Introduced** in GitLab 17.10. **Status**: Experiment. Filter by custom fields. |
 | <a id="groupworkitemsdueafter"></a>`dueAfter` | [`Time`](#time) | Work items due after the timestamp. |
 | <a id="groupworkitemsduebefore"></a>`dueBefore` | [`Time`](#time) | Work items due before the timestamp. |
 | <a id="groupworkitemsexcludeprojects"></a>`excludeProjects` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 17.5. **Status**: Experiment. Exclude work items from projects within the group. |
@@ -35745,6 +35747,7 @@ Returns [`WorkItemStateCountsType`](#workitemstatecountstype).
 | <a id="projectworkitemstatecountsconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter for confidential work items. If `false`, excludes confidential work items. If `true`, returns only confidential work items. |
 | <a id="projectworkitemstatecountscreatedafter"></a>`createdAfter` | [`Time`](#time) | Work items created after the timestamp. |
 | <a id="projectworkitemstatecountscreatedbefore"></a>`createdBefore` | [`Time`](#time) | Work items created before the timestamp. |
+| <a id="projectworkitemstatecountscustomfield"></a>`customField` {{< icon name="warning-solid" >}} | [`[WorkItemWidgetCustomFieldFilterInputType!]`](#workitemwidgetcustomfieldfilterinputtype) | **Introduced** in GitLab 17.10. **Status**: Experiment. Filter by custom fields. |
 | <a id="projectworkitemstatecountsdueafter"></a>`dueAfter` | [`Time`](#time) | Work items due after the timestamp. |
 | <a id="projectworkitemstatecountsduebefore"></a>`dueBefore` | [`Time`](#time) | Work items due before the timestamp. |
 | <a id="projectworkitemstatecountshealthstatus"></a>`healthStatus` | [`HealthStatusFilter`](#healthstatusfilter) | Health status of the work item, "none" and "any" values are supported. |
@@ -35811,6 +35814,7 @@ four standard [pagination arguments](#pagination-arguments):
 | <a id="projectworkitemsconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter for confidential work items. If `false`, excludes confidential work items. If `true`, returns only confidential work items. |
 | <a id="projectworkitemscreatedafter"></a>`createdAfter` | [`Time`](#time) | Work items created after the timestamp. |
 | <a id="projectworkitemscreatedbefore"></a>`createdBefore` | [`Time`](#time) | Work items created before the timestamp. |
+| <a id="projectworkitemscustomfield"></a>`customField` {{< icon name="warning-solid" >}} | [`[WorkItemWidgetCustomFieldFilterInputType!]`](#workitemwidgetcustomfieldfilterinputtype) | **Introduced** in GitLab 17.10. **Status**: Experiment. Filter by custom fields. |
 | <a id="projectworkitemsdueafter"></a>`dueAfter` | [`Time`](#time) | Work items due after the timestamp. |
 | <a id="projectworkitemsduebefore"></a>`dueBefore` | [`Time`](#time) | Work items due before the timestamp. |
 | <a id="projectworkitemshealthstatus"></a>`healthStatus` | [`HealthStatusFilter`](#healthstatusfilter) | Health status of the work item, "none" and "any" values are supported. |
@@ -46886,6 +46890,7 @@ Field that are available while modifying the custom mapping attributes for an HT
 | <a id="boardissueinputassigneewildcardid"></a>`assigneeWildcardId` | [`AssigneeWildcardId`](#assigneewildcardid) | Filter by assignee wildcard. Incompatible with assigneeUsername and assigneeUsernames. |
 | <a id="boardissueinputauthorusername"></a>`authorUsername` | [`String`](#string) | Filter by author username. |
 | <a id="boardissueinputconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter by confidentiality. |
+| <a id="boardissueinputcustomfield"></a>`customField` {{< icon name="warning-solid" >}} | [`[WorkItemWidgetCustomFieldFilterInputType!]`](#workitemwidgetcustomfieldfilterinputtype) | **Deprecated:** **Status**: Experiment. Introduced in GitLab 17.10. |
 | <a id="boardissueinputepicid"></a>`epicId` {{< icon name="warning-solid" >}} | [`EpicID`](#epicid) | **Deprecated:** This will be replaced by WorkItem hierarchyWidget. Deprecated in GitLab 17.5. |
 | <a id="boardissueinputepicwildcardid"></a>`epicWildcardId` | [`EpicWildcardId`](#epicwildcardid) | Filter by epic ID wildcard. Incompatible with epicId. |
 | <a id="boardissueinputhealthstatusfilter"></a>`healthStatusFilter` | [`HealthStatusFilter`](#healthstatusfilter) | Health status of the issue, "none" and "any" values are supported. |
diff --git a/ee/app/graphql/ee/resolvers/work_items_resolver.rb b/ee/app/graphql/ee/resolvers/work_items_resolver.rb
index ba026b493296..8a4dd38a8b50 100644
--- a/ee/app/graphql/ee/resolvers/work_items_resolver.rb
+++ b/ee/app/graphql/ee/resolvers/work_items_resolver.rb
@@ -20,6 +20,11 @@ module WorkItemsResolver
         argument :weight, GraphQL::Types::String,
           required: false,
           description: 'Weight applied to the work item, "none" and "any" values are supported.'
+        argument :custom_field, [::Types::WorkItems::Widgets::CustomFieldFilterInputType],
+          required: false,
+          experiment: { milestone: '17.10' },
+          description: 'Filter by custom fields.',
+          prepare: ->(custom_fields, _ctx) { Array(custom_fields).inject({}, :merge) }
       end
 
       override :resolve_with_lookahead
diff --git a/ee/app/graphql/ee/types/boards/board_issue_input_type.rb b/ee/app/graphql/ee/types/boards/board_issue_input_type.rb
index cab9b55650eb..97f2aebacfa3 100644
--- a/ee/app/graphql/ee/types/boards/board_issue_input_type.rb
+++ b/ee/app/graphql/ee/types/boards/board_issue_input_type.rb
@@ -28,6 +28,12 @@ module BoardIssueInputType
             required: false,
             as: :health_status,
             description: 'Health status of the issue, "none" and "any" values are supported.'
+
+          argument :custom_field, [::Types::WorkItems::Widgets::CustomFieldFilterInputType],
+            required: false,
+            experiment: { milestone: '17.10' },
+            description: 'Filter by custom fields.',
+            prepare: ->(custom_fields, _ctx) { Array(custom_fields).inject({}, :merge) }
         end
       end
     end
diff --git a/ee/spec/requests/api/graphql/project/issues_spec.rb b/ee/spec/requests/api/graphql/project/issues_spec.rb
index 807209d0969f..452fa5bd724b 100644
--- a/ee/spec/requests/api/graphql/project/issues_spec.rb
+++ b/ee/spec/requests/api/graphql/project/issues_spec.rb
@@ -74,48 +74,5 @@ def query(params = issue_filter_params)
         expect(issues.first["id"]).to eq(issue_needs_attention.to_global_id.to_s)
       end
     end
-
-    context "for custom field" do
-      include_context 'with group configured with custom fields'
-
-      before_all do
-        create(:work_item_select_field_value, work_item_id: issue_a.id, custom_field: select_field,
-          custom_field_select_option: select_option_1)
-        create(:work_item_select_field_value, work_item_id: issue_b.id, custom_field: select_field,
-          custom_field_select_option: select_option_2)
-        create(:work_item_select_field_value, work_item_id: issue_c.id, custom_field: select_field,
-          custom_field_select_option: select_option_2)
-      end
-
-      before do
-        stub_licensed_features(custom_fields: true)
-      end
-
-      let(:query) do
-        graphql_query_for(:project, { full_path: project.full_path },
-          query_nodes(:issues, :id, args: params)
-        )
-      end
-
-      context "when filtering on a select field" do
-        let(:params) do
-          {
-            custom_field: [{
-              custom_field_id: select_field.to_global_id.to_s,
-              selected_option_ids: [select_option_2.to_global_id.to_s]
-            }]
-          }
-        end
-
-        it 'returns issues that match the custom field filter' do
-          post_graphql(query, current_user: current_user)
-
-          issues = graphql_data.dig('project', 'issues', 'nodes')
-
-          expect(issues.size).to eq(2)
-          expect(issues.pluck("id")).to contain_exactly(issue_b.to_global_id.to_s, issue_c.to_global_id.to_s)
-        end
-      end
-    end
   end
 end
diff --git a/ee/spec/requests/api/graphql/work_items/custom_field_filters_spec.rb b/ee/spec/requests/api/graphql/work_items/custom_field_filters_spec.rb
new file mode 100644
index 000000000000..c68f64809054
--- /dev/null
+++ b/ee/spec/requests/api/graphql/work_items/custom_field_filters_spec.rb
@@ -0,0 +1,213 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Custom field filters', feature_category: :team_planning do
+  include GraphqlHelpers
+
+  include_context 'with group configured with custom fields'
+
+  let_it_be(:group_label) { create(:group_label, group: group) }
+
+  let_it_be(:project) { create(:project, group: group) }
+
+  let_it_be(:work_item_a) { create(:work_item, project: project, labels: [group_label]) }
+  let_it_be(:work_item_b) { create(:work_item, project: project, labels: [group_label]) }
+  let_it_be(:work_item_c) { create(:work_item, project: project, labels: [group_label]) }
+
+  let(:current_user) { create(:user, guest_of: group) }
+  let(:params) do
+    {
+      custom_field: [
+        {
+          custom_field_id: select_field.to_global_id.to_s,
+          selected_option_ids: [
+            select_option_2.to_global_id.to_s
+          ]
+        }
+      ]
+    }
+  end
+
+  before_all do
+    create(:work_item_select_field_value, work_item_id: work_item_a.id, custom_field: select_field,
+      custom_field_select_option: select_option_1)
+    create(:work_item_select_field_value, work_item_id: work_item_b.id, custom_field: select_field,
+      custom_field_select_option: select_option_2)
+    create(:work_item_select_field_value, work_item_id: work_item_c.id, custom_field: select_field,
+      custom_field_select_option: select_option_2)
+  end
+
+  before do
+    stub_licensed_features(custom_fields: true)
+  end
+
+  shared_examples 'returns filtered counts' do
+    it 'returns counts matching the custom field filter' do
+      post_graphql(query, current_user: current_user)
+
+      expect(count).to eq(2)
+    end
+  end
+
+  shared_examples 'returns filtered items' do
+    it 'returns items matching the custom field filter' do
+      post_graphql(query, current_user: current_user)
+
+      model_ids = items.map { |item| GlobalID.parse(item['id']).model_id.to_i }
+
+      expect(model_ids.size).to eq(2)
+      expect(model_ids).to contain_exactly(work_item_b.id, work_item_c.id)
+    end
+  end
+
+  context 'when querying project.issueStatusCounts' do
+    let(:query) do
+      graphql_query_for(:project, { full_path: project.full_path },
+        query_graphql_field(:issueStatusCounts, params, :opened)
+      )
+    end
+
+    let(:count) { graphql_data.dig('project', 'issueStatusCounts', 'opened') }
+
+    it_behaves_like 'returns filtered counts'
+  end
+
+  context 'when querying project.issues' do
+    let(:query) do
+      graphql_query_for(:project, { full_path: project.full_path },
+        query_nodes(:issues, :id, args: params)
+      )
+    end
+
+    let(:items) { graphql_data.dig('project', 'issues', 'nodes') }
+
+    it_behaves_like 'returns filtered items'
+  end
+
+  context 'when querying group.issues' do
+    let(:query) do
+      graphql_query_for(:group, { full_path: group.full_path },
+        query_nodes(:issues, :id, args: params)
+      )
+    end
+
+    let(:items) { graphql_data.dig('group', 'issues', 'nodes') }
+
+    it_behaves_like 'returns filtered items'
+  end
+
+  context 'when querying project.workItemStateCounts' do
+    let(:query) do
+      graphql_query_for(:project, { full_path: project.full_path },
+        query_graphql_field(:workItemStateCounts, params, :opened)
+      )
+    end
+
+    let(:count) { graphql_data.dig('project', 'workItemStateCounts', 'opened') }
+
+    it_behaves_like 'returns filtered counts'
+  end
+
+  context 'when querying group.workItemStateCounts' do
+    let(:query) do
+      graphql_query_for(:group, { full_path: group.full_path },
+        query_graphql_field(:workItemStateCounts, params.merge(include_descendants: true), :opened)
+      )
+    end
+
+    let(:count) { graphql_data.dig('group', 'workItemStateCounts', 'opened') }
+
+    it_behaves_like 'returns filtered counts'
+  end
+
+  context 'when querying project.workItems' do
+    let(:query) do
+      graphql_query_for(:project, { full_path: project.full_path },
+        query_nodes(:work_items, :id, args: params)
+      )
+    end
+
+    let(:items) { graphql_data.dig('project', 'workItems', 'nodes') }
+
+    it_behaves_like 'returns filtered items'
+  end
+
+  context 'when querying group.workItems' do
+    let(:query) do
+      graphql_query_for(:group, { full_path: group.full_path },
+        query_nodes(:work_items, :id, args: params.merge(include_descendants: true))
+      )
+    end
+
+    let(:items) { graphql_data.dig('group', 'workItems', 'nodes') }
+
+    it_behaves_like 'returns filtered items'
+  end
+
+  context 'when querying project.board.lists.issues' do
+    let_it_be(:board) { create(:board, resource_parent: project) }
+    let_it_be(:label_list) { create(:list, board: board, label: group_label) }
+
+    let(:query) do
+      graphql_query_for(:project, { full_path: project.full_path },
+        <<~BOARDS
+          boards(first: 1) {
+            nodes {
+              lists(id: "#{label_list.to_global_id}") {
+                nodes {
+                  issues(#{attributes_to_graphql(filters: params)}) {
+                    nodes {
+                      id
+                    }
+                  }
+                }
+              }
+            }
+          }
+        BOARDS
+      )
+    end
+
+    let(:items) do
+      graphql_data.dig('project', 'boards', 'nodes')[0]
+        .dig('lists', 'nodes')[0]
+        .dig('issues', 'nodes')
+    end
+
+    it_behaves_like 'returns filtered items'
+  end
+
+  context 'when querying group.board.lists.issues' do
+    let_it_be(:board) { create(:board, resource_parent: group) }
+    let_it_be(:label_list) { create(:list, board: board, label: group_label) }
+
+    let(:query) do
+      graphql_query_for(:group, { full_path: group.full_path },
+        <<~BOARDS
+          boards(first: 1) {
+            nodes {
+              lists(id: "#{label_list.to_global_id}") {
+                nodes {
+                  issues(#{attributes_to_graphql(filters: params)}) {
+                    nodes {
+                      id
+                    }
+                  }
+                }
+              }
+            }
+          }
+        BOARDS
+      )
+    end
+
+    let(:items) do
+      graphql_data.dig('group', 'boards', 'nodes')[0]
+        .dig('lists', 'nodes')[0]
+        .dig('issues', 'nodes')
+    end
+
+    it_behaves_like 'returns filtered items'
+  end
+end
-- 
GitLab