diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 7ca3f20ec1c709edbd79269c75088c70026e4ca4..3885be1dadcc64b3b1c4bf09ab32a45362e6d7d5 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -129,5 +129,8 @@ "VulnerabilityLocationGeneric", "VulnerabilityLocationSast", "VulnerabilityLocationSecretDetection" + ], + "WorkItemWidget": [ + "WorkItemWidgetDescription" ] } diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index cd784d54959fd0d3701488820351499eef694660..18b9bfd1c9aecda50d8ff46f27736a685fe0c838 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -18,6 +18,8 @@ class WorkItemType < BaseObject description: 'State of the work item.' field :title, GraphQL::Types::String, null: false, description: 'Title of the work item.' + field :widgets, [Types::WorkItems::WidgetInterface], null: true, + description: 'Collection of widgets that belong to the work item.' field :work_item_type, Types::WorkItems::TypeType, null: false, description: 'Type assigned to the work item.' diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb new file mode 100644 index 0000000000000000000000000000000000000000..67765c5b4325a73f37954e34ab7c31afc07c640b --- /dev/null +++ b/app/graphql/types/work_items/widget_interface.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module WidgetInterface + include Types::BaseInterface + + graphql_name 'WorkItemWidget' + + field :type, ::Types::WorkItems::WidgetTypeEnum, null: true, + description: 'Widget type.' + + def self.resolve_type(object, context) + case object + when ::WorkItems::Widgets::Description + ::Types::WorkItems::Widgets::DescriptionType + else + raise "Unknown GraphQL type for widget #{object}" + end + end + + orphan_types ::Types::WorkItems::Widgets::DescriptionType + end + end +end diff --git a/app/graphql/types/work_items/widget_type_enum.rb b/app/graphql/types/work_items/widget_type_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e5933bff869b7aab175a3102fa62d7894d7f83a --- /dev/null +++ b/app/graphql/types/work_items/widget_type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class WidgetTypeEnum < BaseEnum + graphql_name 'WorkItemWidgetType' + description 'Type of a work item widget' + + ::WorkItems::Type.available_widgets.each do |widget| + value widget.type.to_s.upcase, value: widget.type, description: "#{widget.type.to_s.titleize} widget." + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/description_type.rb b/app/graphql/types/work_items/widgets/description_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..79192d7c3d434b24a6cc0815635f5aa008dd0713 --- /dev/null +++ b/app/graphql/types/work_items/widgets/description_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + # Disabling widget level authorization as it might be too granular + # and we already authorize the parent work item + # rubocop:disable Graphql/AuthorizeTypes + class DescriptionType < BaseObject + graphql_name 'WorkItemWidgetDescription' + description 'Represents a description widget' + + implements Types::WorkItems::WidgetInterface + + field :description, GraphQL::Types::String, null: true, + description: 'Description of the work item.' + + markdown_field :description_html, null: true do |resolved_object| + resolved_object.work_item + end + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 557694da35ae5e6fdd444b06ca0faec8b1975114..d5cd1140ed25b7a0629699c59058214e744579e5 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -8,6 +8,12 @@ def noteable_target_type_name 'issue' end + def widgets + work_item_type.widgets.map do |widget_class| + widget_class.new(self) + end + end + private def record_create_action diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index 0d390fa131d47c38142b4945185d1bbbad333d63..f708a30f12866119f984703d8852351cb1539920 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -20,6 +20,14 @@ class Type < ApplicationRecord task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 } }.freeze + WIDGETS_FOR_TYPE = { + issue: [Widgets::Description], + incident: [Widgets::Description], + test_case: [Widgets::Description], + requirement: [Widgets::Description], + task: [Widgets::Description] + }.freeze + cache_markdown_field :description, pipeline: :single_line enum base_type: BASE_TYPES.transform_values { |value| value[:enum_value] } @@ -40,6 +48,10 @@ class Type < ApplicationRecord scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) } scope :by_type, ->(base_type) { where(base_type: base_type) } + def self.available_widgets + WIDGETS_FOR_TYPE.values.flatten.uniq + end + def self.default_by_type(type) found_type = find_by(namespace_id: nil, base_type: type) return found_type if found_type @@ -60,6 +72,10 @@ def default? namespace.blank? end + def widgets + WIDGETS_FOR_TYPE[base_type.to_sym] + end + private def strip_whitespace diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..9d1e48690e01e0a23decc45fa6aa29a46e3a7337 --- /dev/null +++ b/app/models/work_items/widgets/base.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Base + def self.type + name.demodulize.underscore.to_sym + end + + def type + self.class.type + end + + def initialize(work_item) + @work_item = work_item + end + + attr_reader :work_item + end + end +end diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e84d172befb69fec9a50c3fcae92940c5d1751b --- /dev/null +++ b/app/models/work_items/widgets/description.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Description < Base + delegate :description, to: :work_item + end + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 82b3ae84f622ad1d9dd83809570c99153f31879d..9a8718aef09aa2abf2060d073f1c47ce6171c59e 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -17840,6 +17840,7 @@ Represents vulnerability letter grades with associated projects. | <a id="workitemtitle"></a>`title` | [`String!`](#string) | Title of the work item. | | <a id="workitemtitlehtml"></a>`titleHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `title`. | | <a id="workitemuserpermissions"></a>`userPermissions` | [`WorkItemPermissions!`](#workitempermissions) | Permissions for the current user on the resource. | +| <a id="workitemwidgets"></a>`widgets` | [`[WorkItemWidget!]`](#workitemwidget) | Collection of widgets that belong to the work item. | | <a id="workitemworkitemtype"></a>`workItemType` | [`WorkItemType!`](#workitemtype) | Type assigned to the work item. | ### `WorkItemPermissions` @@ -17864,6 +17865,18 @@ Check permissions for the current user on a work item. | <a id="workitemtypeid"></a>`id` | [`WorkItemsTypeID!`](#workitemstypeid) | Global ID of the work item type. | | <a id="workitemtypename"></a>`name` | [`String!`](#string) | Name of the work item type. | +### `WorkItemWidgetDescription` + +Represents a description widget. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="workitemwidgetdescriptiondescription"></a>`description` | [`String`](#string) | Description of the work item. | +| <a id="workitemwidgetdescriptiondescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. | +| <a id="workitemwidgetdescriptiontype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. | + ## Enumeration types Also called _Enums_, enumeration types are a special kind of scalar that @@ -19608,6 +19621,14 @@ Values for work item state events. | <a id="workitemstateeventclose"></a>`CLOSE` | Closes the work item. | | <a id="workitemstateeventreopen"></a>`REOPEN` | Reopens the work item. | +### `WorkItemWidgetType` + +Type of a work item widget. + +| Value | Description | +| ----- | ----------- | +| <a id="workitemwidgettypedescription"></a>`DESCRIPTION` | Description widget. | + ## Scalar types Scalar values are atomic values, and do not have fields of their own. @@ -20803,6 +20824,18 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="usertodosstate"></a>`state` | [`[TodoStateEnum!]`](#todostateenum) | State of the todo. | | <a id="usertodostype"></a>`type` | [`[TodoTargetEnum!]`](#todotargetenum) | Type of the todo. | +#### `WorkItemWidget` + +Implementations: + +- [`WorkItemWidgetDescription`](#workitemwidgetdescription) + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="workitemwidgettype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. | + ## Input types Types that may be used as arguments (all scalar types may also diff --git a/lib/gitlab/graphql/markdown_field.rb b/lib/gitlab/graphql/markdown_field.rb index 6188d860aba35a46af25bd19b51464a91a94db18..43dddf4c4bc4ad522e63c58ec81ba3272126a2f9 100644 --- a/lib/gitlab/graphql/markdown_field.rb +++ b/lib/gitlab/graphql/markdown_field.rb @@ -22,8 +22,10 @@ def self.markdown_field(name, **kwargs) field name, GraphQL::Types::String, **kwargs define_method resolver_method do + markdown_object = block_given? ? yield(object) : object + # We need to `dup` the context so the MarkdownHelper doesn't modify it - ::MarkupHelper.markdown_field(object, method_name.to_sym, context.to_h.dup) + ::MarkupHelper.markdown_field(markdown_object, method_name.to_sym, context.to_h.dup) end end end diff --git a/spec/graphql/types/work_item_type_spec.rb b/spec/graphql/types/work_item_type_spec.rb index a048050615641cad1e13248ddf0f5b7329c09027..7ed58786b5be992e132c4baed4194691ff16da7a 100644 --- a/spec/graphql/types/work_item_type_spec.rb +++ b/spec/graphql/types/work_item_type_spec.rb @@ -10,7 +10,18 @@ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::WorkItem) } it 'has specific fields' do - fields = %i[description description_html id iid lock_version state title title_html userPermissions work_item_type] + fields = %i[ + description + description_html + id + iid + lock_version + state title + title_html + userPermissions + widgets + work_item_type + ] fields.each do |field_name| expect(described_class).to have_graphql_fields(*fields) diff --git a/spec/graphql/types/work_items/widget_interface_spec.rb b/spec/graphql/types/work_items/widget_interface_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..63d7782df28f9811208db6ce4534daf6cd60ec2d --- /dev/null +++ b/spec/graphql/types/work_items/widget_interface_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::WorkItems::WidgetInterface do + include GraphqlHelpers + + it 'exposes the expected fields' do + expected_fields = %i[type] + + expect(described_class).to have_graphql_fields(*expected_fields) + end + + describe ".resolve_type" do + it 'knows the correct type for objects' do + expect( + described_class.resolve_type(WorkItems::Widgets::Description.new(build(:work_item)), {}) + ).to eq(Types::WorkItems::Widgets::DescriptionType) + end + + it 'raises an error for an unknown type' do + project = build(:project) + + expect_graphql_error_to_be_created("Unknown GraphQL type for widget #{project}") do + described_class.resolve_type(project, {}) + end + end + end +end diff --git a/spec/graphql/types/work_items/widget_type_enum_spec.rb b/spec/graphql/types/work_items/widget_type_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e7ac9b9c317c6260bc9c1ad411889152f53a57ee --- /dev/null +++ b/spec/graphql/types/work_items/widget_type_enum_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['WorkItemWidgetType'] do + specify { expect(described_class.graphql_name).to eq('WorkItemWidgetType') } + + it 'exposes all the existing widget type values' do + expect(described_class.values.transform_values { |v| v.value }).to include( + 'DESCRIPTION' => :description + ) + end +end diff --git a/spec/graphql/types/work_items/widgets/description_type_spec.rb b/spec/graphql/types/work_items/widgets/description_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5ade1fe4aa2e6d60c3bc75709ee56ed487f8d45d --- /dev/null +++ b/spec/graphql/types/work_items/widgets/description_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::WorkItems::Widgets::DescriptionType do + it 'exposes the expected fields' do + expected_fields = %i[description description_html type] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/lib/gitlab/graphql/markdown_field_spec.rb b/spec/lib/gitlab/graphql/markdown_field_spec.rb index 84494d3dd68584c84fcbdc00e6f9e21d5bbf21b5..a73eba1e9db2c57fe73f188d4a52eeef9ce34327 100644 --- a/spec/lib/gitlab/graphql/markdown_field_spec.rb +++ b/spec/lib/gitlab/graphql/markdown_field_spec.rb @@ -55,6 +55,20 @@ end end + context 'when a block is passed for the resolved object' do + let(:type_class) do + class_with_markdown_field(:note_html, null: false) do |resolved_object| + resolved_object.object + end + end + + let(:type_instance) { type_class.authorized_new(class_wrapped_object(note), context) } + + it 'renders markdown from the same property as the field name without the `_html` suffix' do + expect(field.resolve(type_instance, {}, context)).to eq(expected_markdown) + end + end + describe 'basic verification that references work' do let_it_be(:project) { create(:project, :public) } @@ -83,12 +97,22 @@ end end - def class_with_markdown_field(name, **args) + def class_with_markdown_field(name, **args, &blk) Class.new(Types::BaseObject) do prepend Gitlab::Graphql::MarkdownField graphql_name 'MarkdownFieldTest' - markdown_field name, **args + markdown_field name, **args, &blk end end + + def class_wrapped_object(object) + Class.new do + def initialize(object) + @object = object + end + + attr_accessor :object + end.new(object) + end end diff --git a/spec/models/work_item_spec.rb b/spec/models/work_item_spec.rb index e92ae746911b90c3a721eb851b1b8f9d16c80c66..dfb8f9787e08944b27b5b4ab3834b1e4c5497d7b 100644 --- a/spec/models/work_item_spec.rb +++ b/spec/models/work_item_spec.rb @@ -11,6 +11,12 @@ end end + describe '#widgets' do + subject { build(:work_item).widgets } + + it { is_expected.to contain_exactly(instance_of(WorkItems::Widgets::Description)) } + end + describe 'callbacks' do describe 'record_create_action' do it 'records the creation action after saving' do diff --git a/spec/models/work_items/type_spec.rb b/spec/models/work_items/type_spec.rb index 6e9f3210e65ab7b711415318d1e04a238b613bbc..6936b8f558e97e6622df66be1111d13f8bc4cc2c 100644 --- a/spec/models/work_items/type_spec.rb +++ b/spec/models/work_items/type_spec.rb @@ -60,7 +60,13 @@ it { is_expected.not_to allow_value('s' * 256).for(:icon_name) } end - describe 'default?' do + describe '.available_widgets' do + subject { described_class.available_widgets } + + it { is_expected.to contain_exactly(::WorkItems::Widgets::Description) } + end + + describe '#default?' do subject { build(:work_item_type, namespace: namespace).default? } context 'when namespace is nil' do diff --git a/spec/models/work_items/widgets/base_spec.rb b/spec/models/work_items/widgets/base_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9b4b4d9e98fae3c43ae2ab8f0fb0ec30697b091c --- /dev/null +++ b/spec/models/work_items/widgets/base_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::Base do + let_it_be(:work_item) { create(:work_item, description: '# Title') } + + describe '.type' do + subject { described_class.type } + + it { is_expected.to eq(:base) } + end + + describe '#type' do + subject { described_class.new(work_item).type } + + it { is_expected.to eq(:base) } + end +end diff --git a/spec/models/work_items/widgets/description_spec.rb b/spec/models/work_items/widgets/description_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8359db31bff547b4ba1cf34777389175b49b45c2 --- /dev/null +++ b/spec/models/work_items/widgets/description_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::Description do + let_it_be(:work_item) { create(:work_item, description: '# Title') } + + describe '.type' do + subject { described_class.type } + + it { is_expected.to eq(:description) } + end + + describe '#type' do + subject { described_class.new(work_item).type } + + it { is_expected.to eq(:description) } + end + + describe '#description' do + subject { described_class.new(work_item).description } + + it { is_expected.to eq(work_item.description) } + end +end diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb index 5b34c21989a34fe867dcec4f61209370a32acec0..1c93ae453c0a4693626bac6c5f5576ce96743e06 100644 --- a/spec/requests/api/graphql/work_item_spec.rb +++ b/spec/requests/api/graphql/work_item_spec.rb @@ -7,7 +7,7 @@ let_it_be(:developer) { create(:user) } let_it_be(:project) { create(:project, :private).tap { |project| project.add_developer(developer) } } - let_it_be(:work_item) { create(:work_item, project: project) } + let_it_be(:work_item) { create(:work_item, project: project, description: '- List item') } let(:current_user) { developer } let(:work_item_data) { graphql_data['workItem'] } @@ -38,6 +38,34 @@ ) end + context 'when querying widgets' do + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetDescription { + description + descriptionHtml + } + } + GRAPHQL + end + + it 'returns widget information' do + expect(work_item_data).to include( + 'id' => work_item.to_gid.to_s, + 'widgets' => contain_exactly( + hash_including( + 'type' => 'DESCRIPTION', + 'description' => work_item.description, + 'descriptionHtml' => ::MarkupHelper.markdown_field(work_item, :description, {}) + ) + ) + ) + end + end + context 'when an Issue Global ID is provided' do let(:global_id) { Issue.find(work_item.id).to_gid.to_s }