diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 6ce8f75f198b227c5cbc72e9882f5f5fb8ff9a44..7e1c3c5c78ea99367bbd17175f966c19af7f4819 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -191,6 +191,7 @@ "WorkItemWidgetColor", "WorkItemWidgetCurrentUserTodos", "WorkItemWidgetDescription", + "WorkItemWidgetDesigns", "WorkItemWidgetHealthStatus", "WorkItemWidgetHierarchy", "WorkItemWidgetIteration", diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb index fda22db77795d8a4e9a0d1eff154779b5dde2e5d..d53f074b27726bfc988afdf2354d8c77cdaa47ba 100644 --- a/app/graphql/types/work_items/widget_interface.rb +++ b/app/graphql/types/work_items/widget_interface.rb @@ -24,7 +24,8 @@ module WidgetInterface ::Types::WorkItems::Widgets::AwardEmojiType, ::Types::WorkItems::Widgets::LinkedItemsType, ::Types::WorkItems::Widgets::ParticipantsType, - ::Types::WorkItems::Widgets::TimeTrackingType + ::Types::WorkItems::Widgets::TimeTrackingType, + ::Types::WorkItems::Widgets::DesignsType ].freeze def self.ce_orphan_types @@ -64,6 +65,8 @@ def self.resolve_type(object, context) ::Types::WorkItems::Widgets::ParticipantsType when ::WorkItems::Widgets::TimeTracking ::Types::WorkItems::Widgets::TimeTrackingType + when ::WorkItems::Widgets::Designs + ::Types::WorkItems::Widgets::DesignsType else raise "Unknown GraphQL type for widget #{object}" end diff --git a/app/graphql/types/work_items/widgets/designs_type.rb b/app/graphql/types/work_items/widgets/designs_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..c4cf37c9cb7cef3bdadd4ee9a45874e63bca202a --- /dev/null +++ b/app/graphql/types/work_items/widgets/designs_type.rb @@ -0,0 +1,21 @@ +# 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 -- reason above + class DesignsType < BaseObject + graphql_name 'WorkItemWidgetDesigns' + description 'Represents designs widget' + + implements Types::WorkItems::WidgetInterface + + field :design_collection, Types::DesignManagement::DesignCollectionType, null: true, + description: 'Collection of design images associated with the issue.' + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb index a8f35bec02966279222ec3767bed434ee4635642..6f2f16d9846173adfe671064d32dd8ccb6f62fd6 100644 --- a/app/models/work_items/widget_definition.rb +++ b/app/models/work_items/widget_definition.rb @@ -36,7 +36,8 @@ class WidgetDefinition < ApplicationRecord color: 18, # EE-only rolledup_dates: 19, # EE-only participants: 20, - time_tracking: 21 + time_tracking: 21, + designs: 22 } def self.available_widgets diff --git a/app/models/work_items/widgets/designs.rb b/app/models/work_items/widgets/designs.rb new file mode 100644 index 0000000000000000000000000000000000000000..cfb11c999239b67c951647e0f1303732d94df319 --- /dev/null +++ b/app/models/work_items/widgets/designs.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Designs < Base + delegate :designs, :design_versions, :design_collection, to: :work_item + end + end +end diff --git a/db/post_migrate/20240130070854_add_designs_widget_to_work_item_definitions.rb b/db/post_migrate/20240130070854_add_designs_widget_to_work_item_definitions.rb new file mode 100644 index 0000000000000000000000000000000000000000..1cd8dbffc620eda7e4f9165e379a4c7527c77e3d --- /dev/null +++ b/db/post_migrate/20240130070854_add_designs_widget_to_work_item_definitions.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class AddDesignsWidgetToWorkItemDefinitions < Gitlab::Database::Migration[2.2] + milestone '16.9' + + class WorkItemType < MigrationRecord + self.table_name = 'work_item_types' + end + + class WidgetDefinition < MigrationRecord + self.table_name = 'work_item_widget_definitions' + end + + restrict_gitlab_migration gitlab_schema: :gitlab_main + disable_ddl_transaction! + + WIDGET_NAME = 'Designs' + WIDGET_ENUM_VALUE = 22 + WORK_ITEM_TYPE = 'Issue' + + def up + type = WorkItemType.find_by_name_and_namespace_id(WORK_ITEM_TYPE, nil) + + unless type + Gitlab::AppLogger.warn("type #{WORK_ITEM_TYPE} is missing, not adding widget") + return + end + + widget = { + work_item_type_id: type.id, + name: WIDGET_NAME, + widget_type: WIDGET_ENUM_VALUE + } + + WidgetDefinition.upsert_all( + [widget], + unique_by: :index_work_item_widget_definitions_on_default_witype_and_name + ) + end + + def down + WidgetDefinition.where(name: WIDGET_NAME).delete_all + end +end diff --git a/db/schema_migrations/20240130070854 b/db/schema_migrations/20240130070854 new file mode 100644 index 0000000000000000000000000000000000000000..511e6479813948961b084acf7da36af5b85ee75e --- /dev/null +++ b/db/schema_migrations/20240130070854 @@ -0,0 +1 @@ +2ae6840adaf8ce18391c82bf27fdefba53b0b994fe356549430fe90878a27fb0 \ No newline at end of file diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 7a5cc74479a4772a8a96fa57866f957c024fe900..b70f6116c0513827fd98e5d4dc8eb125b92e4dde 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -29445,6 +29445,17 @@ Represents a description widget. | <a id="workitemwidgetdescriptionlasteditedby"></a>`lastEditedBy` | [`UserCore`](#usercore) | User that made the last edit to the work item's description. | | <a id="workitemwidgetdescriptiontype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. | +### `WorkItemWidgetDesigns` + +Represents designs widget. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="workitemwidgetdesignsdesigncollection"></a>`designCollection` | [`DesignCollection`](#designcollection) | Collection of design images associated with the issue. | +| <a id="workitemwidgetdesignstype"></a>`type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. | + ### `WorkItemWidgetHealthStatus` Represents a health status widget. @@ -32616,6 +32627,7 @@ Type of a work item widget. | <a id="workitemwidgettypecolor"></a>`COLOR` | Color widget. | | <a id="workitemwidgettypecurrent_user_todos"></a>`CURRENT_USER_TODOS` | Current User Todos widget. | | <a id="workitemwidgettypedescription"></a>`DESCRIPTION` | Description widget. | +| <a id="workitemwidgettypedesigns"></a>`DESIGNS` | Designs widget. | | <a id="workitemwidgettypehealth_status"></a>`HEALTH_STATUS` | Health Status widget. | | <a id="workitemwidgettypehierarchy"></a>`HIERARCHY` | Hierarchy widget. | | <a id="workitemwidgettypeiteration"></a>`ITERATION` | Iteration widget. | @@ -34312,6 +34324,7 @@ Implementations: - [`WorkItemWidgetColor`](#workitemwidgetcolor) - [`WorkItemWidgetCurrentUserTodos`](#workitemwidgetcurrentusertodos) - [`WorkItemWidgetDescription`](#workitemwidgetdescription) +- [`WorkItemWidgetDesigns`](#workitemwidgetdesigns) - [`WorkItemWidgetHealthStatus`](#workitemwidgethealthstatus) - [`WorkItemWidgetHierarchy`](#workitemwidgethierarchy) - [`WorkItemWidgetIteration`](#workitemwidgetiteration) diff --git a/ee/spec/models/ee/work_items/widget_definition_spec.rb b/ee/spec/models/ee/work_items/widget_definition_spec.rb index 48bbf059f021edebc912b5cae46f1bc074088b2c..992ce97222b25eeb320a2c6412087519aaa995a7 100644 --- a/ee/spec/models/ee/work_items/widget_definition_spec.rb +++ b/ee/spec/models/ee/work_items/widget_definition_spec.rb @@ -29,7 +29,8 @@ ::WorkItems::Widgets::Color, ::WorkItems::Widgets::RolledupDates, ::WorkItems::Widgets::Participants, - ::WorkItems::Widgets::TimeTracking + ::WorkItems::Widgets::TimeTracking, + ::WorkItems::Widgets::Designs ) end end diff --git a/lib/gitlab/database_importers/work_items/base_type_importer.rb b/lib/gitlab/database_importers/work_items/base_type_importer.rb index 3cf5072eb6c513ff01bf741ecf716eca20e3ab3c..0dba5d013d028fec70c3dd2dca6dcef81c2274c3 100644 --- a/lib/gitlab/database_importers/work_items/base_type_importer.rb +++ b/lib/gitlab/database_importers/work_items/base_type_importer.rb @@ -26,7 +26,8 @@ module BaseTypeImporter color: 'Color', rolledup_dates: 'Rolledup dates', participants: 'Participants', - time_tracking: 'Time tracking' + time_tracking: 'Time tracking', + designs: 'Designs' }.freeze WIDGETS_FOR_TYPE = { @@ -46,7 +47,8 @@ module BaseTypeImporter :award_emoji, :linked_items, :participants, - :time_tracking + :time_tracking, + :designs ], incident: [ :assignees, diff --git a/spec/graphql/types/work_items/widget_interface_spec.rb b/spec/graphql/types/work_items/widget_interface_spec.rb index df871dbe9aa969776136c32b9d7642558135f9f3..6e66f335d89ff8e4eeef15f7565c7df918782983 100644 --- a/spec/graphql/types/work_items/widget_interface_spec.rb +++ b/spec/graphql/types/work_items/widget_interface_spec.rb @@ -29,6 +29,7 @@ WorkItems::Widgets::Milestone | Types::WorkItems::Widgets::MilestoneType WorkItems::Widgets::Participants | Types::WorkItems::Widgets::ParticipantsType WorkItems::Widgets::TimeTracking | Types::WorkItems::Widgets::TimeTrackingType + WorkItems::Widgets::Designs | Types::WorkItems::Widgets::DesignsType end with_them do diff --git a/spec/graphql/types/work_items/widgets/designs_type_spec.rb b/spec/graphql/types/work_items/widgets/designs_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..26ae35f3577a1f366c9feb688a9cde4025ddfbcf --- /dev/null +++ b/spec/graphql/types/work_items/widgets/designs_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::WorkItems::Widgets::DesignsType, feature_category: :team_planning do + it 'exposes the expected fields' do + expected_fields = %i[type design_collection] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/models/work_items/widget_definition_spec.rb b/spec/models/work_items/widget_definition_spec.rb index 6751d331423b599fcfad425d6230f00dff5c2765..d25b5cb727951416ada88aa8aeddbc5fe99cdb48 100644 --- a/spec/models/work_items/widget_definition_spec.rb +++ b/spec/models/work_items/widget_definition_spec.rb @@ -17,7 +17,8 @@ ::WorkItems::Widgets::AwardEmoji, ::WorkItems::Widgets::LinkedItems, ::WorkItems::Widgets::Participants, - ::WorkItems::Widgets::TimeTracking + ::WorkItems::Widgets::TimeTracking, + ::WorkItems::Widgets::Designs ] if Gitlab.ee? diff --git a/spec/models/work_items/widgets/designs_spec.rb b/spec/models/work_items/widgets/designs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..003f9f1d5173220e692e499e93a85981440591f6 --- /dev/null +++ b/spec/models/work_items/widgets/designs_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::Designs, feature_category: :team_planning do + let_it_be(:work_item) { create(:work_item) } + + describe '.type' do + specify { expect(described_class.type).to eq(:designs) } + end + + describe '#type' do + specify { expect(described_class.new(work_item).type).to eq(:designs) } + end + + describe '#designs' do + it 'returns all designs' do + create_list(:design, 3, :with_file, issue: work_item) + design_a = create(:design, :with_file, issue: work_item) + + expect(described_class.new(work_item).designs.count).to eq(4) + + expect(described_class.new(work_item).designs).to include(design_a) + end + end + + describe '#design_versions' do + it 'returns all design versions' do + create_list(:design_version, 2, issue: work_item) + last_version = create(:design_version, issue: work_item) + + expect(described_class.new(work_item).design_versions.count).to eq(3) + expect(described_class.new(work_item).design_versions).to include(last_version) + end + end + + describe '#design_collection' do + it 'returns a design collection' do + collection = described_class.new(work_item).design_collection + + expect(collection).to be_a(DesignManagement::DesignCollection) + expect(collection.issue).to eq(work_item) + end + end +end diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb index 1879eb60261ddb2807a2b787b01064dc7bcdccd5..45f79de8e5afbdf5823dfccd1c37e18124cbee17 100644 --- a/spec/requests/api/graphql/work_item_spec.rb +++ b/spec/requests/api/graphql/work_item_spec.rb @@ -830,6 +830,267 @@ def pagination_results_data(nodes) end end + describe 'designs widget' do + include DesignManagementTestHelpers + + let(:work_item_fields) do + query_graphql_field( + :widgets, {}, query_graphql_field( + 'type ... on WorkItemWidgetDesigns', {}, query_graphql_field( + :design_collection, nil, design_collection_fields + ) + ) + ) + end + + let(:design_collection_fields) { nil } + + let(:post_query) { post_graphql(query, current_user: current_user) } + + let(:design_collection_data) { work_item_data['widgets'].find { |w| w['type'] == 'DESIGNS' }['designCollection'] } + + before do + project.add_developer(developer) + enable_design_management + end + + def id_hash(object) + a_graphql_entity_for(object) + end + + shared_examples 'fetch a design-like object by ID' do + let(:design) { design_a } + + let(:design_fields) do + [ + :filename, + query_graphql_field(:project, :id) + ] + end + + let(:design_collection_fields) do + query_graphql_field(object_field_name, object_params, object_fields) + end + + let(:object_fields) { design_fields } + + context 'when the ID is passed' do + let(:object_params) { { id: global_id_of(object) } } + let(:result_fields) { {} } + + let(:expected_fields) do + result_fields.merge({ 'filename' => design.filename, 'project' => id_hash(project) }) + end + + it 'retrieves the object' do + post_query + data = design_collection_data[GraphqlHelpers.fieldnamerize(object_field_name)] + + expect(data).to match(a_hash_including(expected_fields)) + end + + context 'when the user is unauthorized' do + let(:current_user) { create(:user) } + + it_behaves_like 'a failure to find anything' + end + + context 'without parameters' do + let(:object_params) { nil } + + it 'raises an error' do + post_query + + expect(graphql_errors).to include(no_argument_error) + end + end + end + + context 'when attempting to retrieve an object from a different issue' do + let(:object_params) { { id: global_id_of(object_on_other_issue) } } + + it_behaves_like 'a failure to find anything' + end + end + + context 'when work item is an issue' do + let_it_be(:issue_work_item) { create(:work_item, :issue, project: project) } + let_it_be(:issue_work_item1) { create(:work_item, :issue, project: project) } + let_it_be(:design_a) { create(:design, issue: issue_work_item) } + let_it_be(:version_a) { create(:design_version, issue: issue_work_item, created_designs: [design_a]) } + let_it_be(:global_id) { issue_work_item.to_gid.to_s } + + describe '.designs' do + let(:design_collection_fields) do + query_graphql_field('designs', {}, "nodes { id event filename }") + end + + it 'returns design data' do + post_query + + expect(design_collection_data).to include( + 'designs' => include( + 'nodes' => include( + hash_including( + 'id' => design_a.to_gid.to_s, + 'event' => 'CREATION', + 'filename' => design_a.filename + ) + ) + ) + ) + end + end + + describe 'copy_state' do + let(:design_collection_fields) do + 'copyState' + end + + it 'returns copyState of designCollection' do + post_query + + expect(design_collection_data).to include( + 'copyState' => 'READY' + ) + end + end + + describe '.versions' do + let(:design_collection_fields) do + query_graphql_field('versions', {}, "nodes { id sha createdAt }") + end + + it 'returns versions data' do + post_query + + expect(design_collection_data).to include( + 'versions' => include( + 'nodes' => include( + hash_including( + 'id' => version_a.to_gid.to_s, + 'sha' => version_a.sha, + 'createdAt' => version_a.created_at.iso8601 + ) + ) + ) + ) + end + end + + describe '.version' do + let(:version) { version_a } + + let(:design_collection_fields) do + query_graphql_field(:version, version_params, 'id sha') + end + + context 'with no parameters' do + let(:version_params) { nil } + + it 'raises an error' do + post_query + + expect(graphql_errors).to include(a_hash_including("message" => "one of id or sha is required")) + end + end + + shared_examples 'a successful query for a version' do + it 'finds the version' do + post_query + + data = design_collection_data['version'] + + expect(data).to match a_graphql_entity_for(version, :sha) + end + end + + context 'with (sha: STRING_TYPE)' do + let(:version_params) { { sha: version.sha } } + + it_behaves_like 'a successful query for a version' + end + + context 'with (id: ID_TYPE)' do + let(:version_params) { { id: global_id_of(version) } } + + it_behaves_like 'a successful query for a version' + end + end + + describe '.design' do + it_behaves_like 'fetch a design-like object by ID' do + let(:object) { design } + let(:object_field_name) { :design } + + let(:no_argument_error) do + a_hash_including("message" => "one of id or filename must be passed") + end + + let_it_be(:object_on_other_issue) { create(:design, issue: issue_work_item1) } + end + end + + describe '.designAtVersion' do + it_behaves_like 'fetch a design-like object by ID' do + let(:object) { build(:design_at_version, design: design, version: version) } + let(:object_field_name) { :design_at_version } + + let(:version) { version_a } + + let(:result_fields) { { 'version' => id_hash(version) } } + let(:object_fields) do + design_fields + [query_graphql_field(:version, :id)] + end + + let(:no_argument_error) do + a_hash_including("message" => "Field 'designAtVersion' is missing required arguments: id") + end + + let(:object_on_other_issue) { build(:design_at_version, issue: issue_work_item1) } + end + end + + describe 'N+1 query check' do + let(:design_collection_fields) do + query_graphql_field('designs', {}, "nodes { id event filename}") + end + + it 'avoids N+1 queries', :use_sql_query_cache do + post_query # warmup + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_query + end + + create_list(:work_item, 3, namespace: group) do |item| + create(:design, :with_file, issue: item) + end + + expect do + post_query + end.to issue_same_number_of_queries_as(control_count) + expect_graphql_errors_to_be_empty + end + end + end + + context 'when work item base type is non issue' do + let_it_be(:epic) { create(:work_item, :epic, namespace: group) } + let_it_be(:global_id) { epic.to_gid.to_s } + + it 'returns without design' do + post_query + + expect(epic&.work_item_type&.base_type).not_to match('issue') + expect(work_item_data['widgets']).not_to include( + hash_including( + 'type' => 'DESIGNS' + ) + ) + end + end + end + context 'when an Issue Global ID is provided' do let(:global_id) { Issue.find(work_item.id).to_gid.to_s }