From e2d5263ee9f18ef213f41bd386fe42afb9a389f7 Mon Sep 17 00:00:00 2001 From: Kassio Borges <kborges@gitlab.com> Date: Wed, 26 Feb 2025 18:48:33 +0000 Subject: [PATCH] Add GraphQL for WorkItems::UserPreference With the end goal of persisting user preferences on WorkItem lists, like sorting, we created WorkItems::UserPreference model. Now we're adding the basic read/write GraphQL actions for this model to be used by the frontend. Related to: https://gitlab.com/gitlab-org/gitlab/-/issues/501712 Changelog: added --- .../work_items/user_preference/update.rb | 55 ++++++++ .../work_items/user_preference_resolver.rb | 37 +++++ app/graphql/types/current_user_type.rb | 6 + app/graphql/types/mutation_type.rb | 1 + .../work_items/notes_filter_type_enum.rb | 2 +- .../types/work_items/user_preference.rb | 30 +++++ app/models/project.rb | 4 + app/models/work_items/sorting_keys.rb | 8 ++ app/models/work_items/type.rb | 2 +- .../work_items/types/user_preference.rb | 16 --- app/models/work_items/user_preference.rb | 56 ++++++++ .../work_items/user_preference_policy.rb | 7 + app/workers/all_queues.yml | 10 ++ .../user_preferences/destroy_worker.rb | 30 +++++ config/sidekiq_queues.yml | 2 + db/docs/work_item_type_user_preferences.yml | 2 +- doc/api/graphql/reference/_index.md | 56 ++++++++ ee/lib/ee/gitlab/event_store.rb | 3 +- lib/gitlab/event_store.rb | 87 ++++++------ locale/gitlab.pot | 6 + .../user_preference_resolver_spec.rb | 88 ++++++++++++ spec/models/project_spec.rb | 15 ++- spec/models/work_items/sorting_keys_spec.rb | 30 +++++ .../work_items/types/user_preference_spec.rb | 11 -- .../models/work_items/user_preference_spec.rb | 63 +++++++++ .../work_items/user_preference_policy_spec.rb | 63 +++++++++ .../work_items/user_preference/update_spec.rb | 126 ++++++++++++++++++ .../user_preferences/destroy_worker_spec.rb | 65 +++++++++ 28 files changed, 810 insertions(+), 71 deletions(-) create mode 100644 app/graphql/mutations/work_items/user_preference/update.rb create mode 100644 app/graphql/resolvers/work_items/user_preference_resolver.rb create mode 100644 app/graphql/types/work_items/user_preference.rb delete mode 100644 app/models/work_items/types/user_preference.rb create mode 100644 app/models/work_items/user_preference.rb create mode 100644 app/policies/work_items/user_preference_policy.rb create mode 100644 app/workers/work_items/user_preferences/destroy_worker.rb create mode 100644 spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb create mode 100644 spec/models/work_items/sorting_keys_spec.rb delete mode 100644 spec/models/work_items/types/user_preference_spec.rb create mode 100644 spec/models/work_items/user_preference_spec.rb create mode 100644 spec/policies/work_items/user_preference_policy_spec.rb create mode 100644 spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb create mode 100644 spec/workers/work_items/user_preferences/destroy_worker_spec.rb diff --git a/app/graphql/mutations/work_items/user_preference/update.rb b/app/graphql/mutations/work_items/user_preference/update.rb new file mode 100644 index 000000000000..b99fe16508ab --- /dev/null +++ b/app/graphql/mutations/work_items/user_preference/update.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + module UserPreference + class Update < BaseMutation + graphql_name 'WorkItemUserPreferenceUpdate' + description "Create or Update user preferences for a work item type and namespace." + + include Mutations::SpamProtection + include FindsNamespace + + authorize :read_namespace + + argument :namespace_path, + type: GraphQL::Types::ID, + required: true, + description: 'Full path of the namespace on which the preference is set.' + argument :work_item_type_id, + type: ::Types::GlobalIDType[::WorkItems::Type], + required: false, + description: 'Global ID of a work item type.' + + argument :sort, + type: ::Types::WorkItems::SortEnum, + description: 'Sort order for work item lists.', + required: false, + default_value: :created_asc + + field :user_preferences, + type: ::Types::WorkItems::UserPreference, + description: 'User preferences.' + + def resolve(namespace_path:, work_item_type_id: nil, **attributes) + namespace = find_object(namespace_path) + namespace = namespace.project_namespace if namespace.is_a?(Project) + authorize!(namespace) + + work_item_type_id = work_item_type_id&.model_id + + preferences = ::WorkItems::UserPreference.create_or_update( + namespace: namespace, + work_item_type_id: work_item_type_id, + user: current_user, + **attributes) + + { + user_preferences: preferences.valid? ? preferences : nil, + errors: errors_on_object(preferences) + } + end + end + end + end +end diff --git a/app/graphql/resolvers/work_items/user_preference_resolver.rb b/app/graphql/resolvers/work_items/user_preference_resolver.rb new file mode 100644 index 000000000000..e7babf179bfd --- /dev/null +++ b/app/graphql/resolvers/work_items/user_preference_resolver.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Resolvers + module WorkItems + class UserPreferenceResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type ::Types::WorkItems::UserPreference, null: true + + authorize :read_namespace + + argument :namespace_path, + GraphQL::Types::ID, + required: true, + description: 'Full path of the namespace the work item is created in.' + + argument :work_item_type_id, + ::Types::GlobalIDType[::WorkItems::Type], + required: false, + description: 'Global ID of a work item type.' + + def resolve(namespace_path:, work_item_type_id: nil) + namespace = ::Routable.find_by_full_path(namespace_path) + namespace = namespace.project_namespace if namespace.is_a?(Project) + authorize!(namespace) + + work_item_type_id = work_item_type_id&.model_id + + ::WorkItems::UserPreference.find_by_user_namespace_and_work_item_type_id( + current_user, + namespace, + work_item_type_id + ) + end + end + end +end diff --git a/app/graphql/types/current_user_type.rb b/app/graphql/types/current_user_type.rb index 3ade06cd02fc..21fd786d7c77 100644 --- a/app/graphql/types/current_user_type.rb +++ b/app/graphql/types/current_user_type.rb @@ -22,6 +22,12 @@ class CurrentUserType < ::Types::UserType resolver: Resolvers::WorkItems::UserWorkItemsResolver, description: 'Find work items visible to the current user.', experiment: { milestone: '17.10' } + + field :work_item_preferences, # rubocop:disable GraphQL/ExtractType -- fields with different contexts + resolver: ::Resolvers::WorkItems::UserPreferenceResolver, + null: true, + experiment: { milestone: '17.10' }, + description: 'User preferences for the given work item type and namespace.' end # rubocop:enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 43467248b8db..b341e811a2eb 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -235,6 +235,7 @@ class MutationType < BaseObject mount_mutation Mutations::WorkItems::AddClosingMergeRequest, experiment: { milestone: '17.1' } mount_mutation Mutations::WorkItems::Hierarchy::Reorder, experiment: { milestone: '17.3' } mount_mutation Mutations::WorkItems::BulkUpdate, experiment: { milestone: '17.4' } + mount_mutation Mutations::WorkItems::UserPreference::Update, experiment: { milestone: '17.10' } mount_mutation Mutations::Users::SavedReplies::Create mount_mutation Mutations::Users::SavedReplies::Update mount_mutation Mutations::Users::SavedReplies::Destroy diff --git a/app/graphql/types/work_items/notes_filter_type_enum.rb b/app/graphql/types/work_items/notes_filter_type_enum.rb index 93fb4689f0b2..f507dbbe1393 100644 --- a/app/graphql/types/work_items/notes_filter_type_enum.rb +++ b/app/graphql/types/work_items/notes_filter_type_enum.rb @@ -9,7 +9,7 @@ class NotesFilterTypeEnum < BaseEnum ::UserPreference::NOTES_FILTERS.each_pair do |key, value| value key.upcase, value: value, - description: UserPreference.notes_filters.invert[::UserPreference::NOTES_FILTERS[key]] + description: ::UserPreference.notes_filters.invert[::UserPreference::NOTES_FILTERS[key]] end def self.default_value diff --git a/app/graphql/types/work_items/user_preference.rb b/app/graphql/types/work_items/user_preference.rb new file mode 100644 index 000000000000..ec376fc66d0a --- /dev/null +++ b/app/graphql/types/work_items/user_preference.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class UserPreference < BaseObject + graphql_name 'WorkItemTypesUserPreference' + + authorize :read_namespace + + field :namespace, + type: ::Types::NamespaceType, + null: false, + description: 'Namespace for the user preference.' + + field :work_item_type, + type: ::Types::WorkItems::TypeType, + null: true, + description: 'Type assigned to the work item.' + + field :sort, + type: ::Types::WorkItems::SortEnum, + null: true, + description: 'Sort order for work item lists.' + + def sort + object.sort&.to_sym + end + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 9548a04dd7e0..592c2f9a956d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1179,6 +1179,10 @@ def by_pages_enabled_unique_domain(domain) pages_unique_domain: domain }) end + + def project_namespace_for(id:) + find_by(id: id)&.project_namespace + end end def initialize(attributes = nil) diff --git a/app/models/work_items/sorting_keys.rb b/app/models/work_items/sorting_keys.rb index 4f5ceb58f277..7c177f0d68c3 100644 --- a/app/models/work_items/sorting_keys.rb +++ b/app/models/work_items/sorting_keys.rb @@ -80,6 +80,14 @@ def widgets_sorting_keys .reduce({}, :merge) end strong_memoize_attr :widgets_sorting_keys + + def available?(key, widget_list: []) + key = key.to_sym + widget_list = Array.wrap(widget_list) + + DEFAULT_SORTING_KEYS.key?(key) || + widget_list.any? { |widget| widget.sorting_keys.key?(key) } + end end end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index 8a9aa3075965..8e642cb25013 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -76,7 +76,7 @@ class Type < ApplicationRecord through: :parent_restrictions, class_name: 'WorkItems::Type', foreign_key: :parent_type_id, source: :parent_type has_many :user_preferences, - class_name: 'WorkItems::Types::UserPreference', + class_name: 'WorkItems::UserPreference', inverse_of: :work_item_type before_validation :strip_whitespace diff --git a/app/models/work_items/types/user_preference.rb b/app/models/work_items/types/user_preference.rb deleted file mode 100644 index 3592e92cb7a5..000000000000 --- a/app/models/work_items/types/user_preference.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Types - class UserPreference < ApplicationRecord - self.table_name = 'work_item_type_user_preferences' - - belongs_to :user - belongs_to :namespace - belongs_to :work_item_type, - class_name: 'WorkItems::Type', - inverse_of: :user_preferences, - optional: true - end - end -end diff --git a/app/models/work_items/user_preference.rb b/app/models/work_items/user_preference.rb new file mode 100644 index 000000000000..6c158f8517ed --- /dev/null +++ b/app/models/work_items/user_preference.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module WorkItems + class UserPreference < ApplicationRecord + self.table_name = 'work_item_type_user_preferences' + + belongs_to :user + belongs_to :namespace + belongs_to :work_item_type, + class_name: 'WorkItems::Type', + inverse_of: :user_preferences, + optional: true + + validate :validate_sort_value + + def self.create_or_update(namespace:, work_item_type_id:, user:, **attributes) + record = find_or_initialize_by(namespace: namespace, work_item_type_id: work_item_type_id, user: user) + record.assign_attributes(**attributes) + record.save + record + end + + def self.find_by_user_namespace_and_work_item_type_id(user, namespace, work_item_type_id) + find_by( + user: user, + namespace: namespace, + work_item_type_id: work_item_type_id + ) + end + + private + + def validate_sort_value + return if sort.blank? + return if ::WorkItems::SortingKeys.available?(sort, widget_list: work_item_type&.widget_classes(namespace)) + + message = + if work_item_type.present? + format( + _('value "%{sort}" is not available on %{namespace} for work items type %{wit}'), + sort: sort, + namespace: namespace.full_path, + wit: work_item_type.name + ) + else + format( + _('value "%{sort}" is not available on %{namespace}'), + sort: sort, + namespace: namespace.full_path + ) + end + + errors.add(:sort, message) + end + end +end diff --git a/app/policies/work_items/user_preference_policy.rb b/app/policies/work_items/user_preference_policy.rb new file mode 100644 index 000000000000..778455b053c3 --- /dev/null +++ b/app/policies/work_items/user_preference_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module WorkItems + class UserPreferencePolicy < BasePolicy + delegate { @subject.namespace } + end +end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e5a124df37cc..f98e31efe9b2 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -4888,6 +4888,16 @@ :idempotent: true :tags: [] :queue_namespace: +- :name: work_items_user_preferences_destroy + :worker_name: WorkItems::UserPreferences::DestroyWorker + :feature_category: :seat_cost_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: - :name: x509_certificate_revoke :worker_name: X509CertificateRevokeWorker :feature_category: :source_code_management diff --git a/app/workers/work_items/user_preferences/destroy_worker.rb b/app/workers/work_items/user_preferences/destroy_worker.rb new file mode 100644 index 000000000000..3367d9c49720 --- /dev/null +++ b/app/workers/work_items/user_preferences/destroy_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module WorkItems + module UserPreferences + class DestroyWorker + include Gitlab::EventStore::Subscriber + + data_consistency :delayed + feature_category :seat_cost_management + urgency :low + idempotent! + deduplicate :until_executed + + def handle_event(event) + case event.data[:source_type] + when GroupMember::SOURCE_TYPE + ::WorkItems::UserPreference.delete_by( + user_id: event.data[:user_id], + namespace_id: event.data[:source_id] + ) + when ProjectMember::SOURCE_TYPE + ::WorkItems::UserPreference.delete_by( + user_id: event.data[:user_id], + namespace: Project.project_namespace_for(id: event.data[:source_id]) + ) + end + end + end + end +end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index b82a4268d806..582d299ffc2a 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -1013,6 +1013,8 @@ - 1 - - work_items_update_parent_objectives_progress - 1 +- - work_items_user_preferences_destroy + - 1 - - work_items_validate_epic_work_item_sync - 1 - - x509_certificate_revoke diff --git a/db/docs/work_item_type_user_preferences.yml b/db/docs/work_item_type_user_preferences.yml index e20ad5637ac7..8c506c71b51f 100644 --- a/db/docs/work_item_type_user_preferences.yml +++ b/db/docs/work_item_type_user_preferences.yml @@ -1,7 +1,7 @@ --- table_name: work_item_type_user_preferences classes: - - WorkItems::Types::UserPreference + - WorkItems::UserPreference feature_categories: - team_planning description: User preferences per work item type and namespace diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 0c7f12a06412..f6cbaeb1de32 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -12516,6 +12516,34 @@ Input type: `WorkItemUpdateInput` | <a id="mutationworkitemupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationworkitemupdateworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated work item. | +### `Mutation.workItemUserPreferenceUpdate` + +Create or Update user preferences for a work item type and namespace. + +{{< details >}} +**Introduced** in GitLab 17.10. +**Status**: Experiment. +{{< /details >}} + +Input type: `WorkItemUserPreferenceUpdateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationworkitemuserpreferenceupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationworkitemuserpreferenceupdatenamespacepath"></a>`namespacePath` | [`ID!`](#id) | Full path of the namespace on which the preference is set. | +| <a id="mutationworkitemuserpreferenceupdatesort"></a>`sort` | [`WorkItemSort`](#workitemsort) | Sort order for work item lists. | +| <a id="mutationworkitemuserpreferenceupdateworkitemtypeid"></a>`workItemTypeId` | [`WorkItemsTypeID`](#workitemstypeid) | Global ID of a work item type. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationworkitemuserpreferenceupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationworkitemuserpreferenceupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationworkitemuserpreferenceupdateuserpreferences"></a>`userPreferences` | [`WorkItemTypesUserPreference`](#workitemtypesuserpreference) | User preferences. | + ### `Mutation.workItemsHierarchyReorder` Reorder a work item in the hierarchy tree. @@ -23695,6 +23723,24 @@ four standard [pagination arguments](#pagination-arguments): | ---- | ---- | ----------- | | <a id="currentuseruserachievementsincludehidden"></a>`includeHidden` | [`Boolean`](#boolean) | Indicates whether or not achievements hidden from the profile should be included in the result. | +##### `CurrentUser.workItemPreferences` + +User preferences for the given work item type and namespace. + +{{< details >}} +**Introduced** in GitLab 17.10. +**Status**: Experiment. +{{< /details >}} + +Returns [`WorkItemTypesUserPreference`](#workitemtypesuserpreference). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="currentuserworkitempreferencesnamespacepath"></a>`namespacePath` | [`ID!`](#id) | Full path of the namespace the work item is created in. | +| <a id="currentuserworkitempreferencesworkitemtypeid"></a>`workItemTypeId` | [`WorkItemsTypeID`](#workitemstypeid) | Global ID of a work item type. | + ##### `CurrentUser.workItems` Find work items visible to the current user. @@ -39562,6 +39608,16 @@ Represents Depth limit reached for the allowed work item type. | <a id="workitemtypedepthlimitreachedbytypedepthlimitreached"></a>`depthLimitReached` | [`Boolean!`](#boolean) | Indicates if maximum allowed depth has been reached for the descendant type. | | <a id="workitemtypedepthlimitreachedbytypeworkitemtype"></a>`workItemType` | [`WorkItemType!`](#workitemtype) | Work item type. | +### `WorkItemTypesUserPreference` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="workitemtypesuserpreferencenamespace"></a>`namespace` | [`Namespace!`](#namespace) | Namespace for the user preference. | +| <a id="workitemtypesuserpreferencesort"></a>`sort` | [`WorkItemSort`](#workitemsort) | Sort order for work item lists. | +| <a id="workitemtypesuserpreferenceworkitemtype"></a>`workItemType` | [`WorkItemType`](#workitemtype) | Type assigned to the work item. | + ### `WorkItemWidgetAssignees` Represents an assignees widget. diff --git a/ee/lib/ee/gitlab/event_store.rb b/ee/lib/ee/gitlab/event_store.rb index 5a97c4333374..558468e771c8 100644 --- a/ee/lib/ee/gitlab/event_store.rb +++ b/ee/lib/ee/gitlab/event_store.rb @@ -83,11 +83,12 @@ def configure!(store) subscribe_to_zoekt_events(store) subscribe_to_members_added_event(store) subscribe_to_users_activity_events(store) - subscribe_to_member_destroyed_events(store) subscribe_to_merge_events(store) end + override :subscribe_to_member_destroyed_events def subscribe_to_member_destroyed_events(store) + super store.subscribe ::GitlabSubscriptions::Members::DestroyedWorker, to: ::Members::DestroyedEvent end diff --git a/lib/gitlab/event_store.rb b/lib/gitlab/event_store.rb index cf0d7ddfbf95..75070c56a065 100644 --- a/lib/gitlab/event_store.rb +++ b/lib/gitlab/event_store.rb @@ -13,49 +13,58 @@ module EventStore InvalidEvent = Class.new(Error) InvalidSubscriber = Class.new(Error) - def self.publish(event) - instance.publish(event) - end + class << self + def publish(event) + instance.publish(event) + end - def self.publish_group(events) - instance.publish_group(events) - end + def publish_group(events) + instance.publish_group(events) + end - def self.instance - @instance ||= Store.new { |store| configure!(store) } - end + def instance + @instance ||= Store.new { |store| configure!(store) } + end + + private + + # Define all event subscriptions using: + # + # store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent) + # + # It is possible to subscribe to a subset of events matching a condition: + # + # store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent), if: ->(event) { event.data == :some_value } + # + def configure!(store) + ### + # Add subscriptions here: + + store.subscribe ::MergeRequests::UpdateHeadPipelineWorker, to: ::Ci::PipelineCreatedEvent + store.subscribe ::Namespaces::UpdateRootStatisticsWorker, to: ::Projects::ProjectDeletedEvent + store.subscribe ::Ci::Runners::UpdateProjectRunnersOwnerWorker, to: ::Projects::ProjectDeletedEvent + + store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DraftStateChangeEvent + store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DiscussionsResolvedEvent + store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::MergeableEvent + store.subscribe ::MergeRequests::CreateApprovalEventWorker, to: ::MergeRequests::ApprovedEvent + store.subscribe ::MergeRequests::CreateApprovalNoteWorker, to: ::MergeRequests::ApprovedEvent + store.subscribe ::MergeRequests::ResolveTodosAfterApprovalWorker, to: ::MergeRequests::ApprovedEvent + store.subscribe ::MergeRequests::ExecuteApprovalHooksWorker, to: ::MergeRequests::ApprovedEvent + store.subscribe ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker, + to: ::Packages::PackageCreatedEvent, + if: ->(event) { ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker.handles_event?(event) } + store.subscribe ::Ci::InitializePipelinesIidSequenceWorker, to: ::Projects::ProjectCreatedEvent + store.subscribe ::Pages::DeletePagesDeploymentWorker, to: ::Projects::ProjectArchivedEvent + store.subscribe ::Pages::ResetPagesDefaultDomainRedirectWorker, to: ::Pages::Domains::PagesDomainDeletedEvent + + subscribe_to_member_destroyed_events(store) + end - # Define all event subscriptions using: - # - # store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent) - # - # It is possible to subscribe to a subset of events matching a condition: - # - # store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent), if: ->(event) { event.data == :some_value } - # - def self.configure!(store) - ### - # Add subscriptions here: - - store.subscribe ::MergeRequests::UpdateHeadPipelineWorker, to: ::Ci::PipelineCreatedEvent - store.subscribe ::Namespaces::UpdateRootStatisticsWorker, to: ::Projects::ProjectDeletedEvent - store.subscribe ::Ci::Runners::UpdateProjectRunnersOwnerWorker, to: ::Projects::ProjectDeletedEvent - - store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DraftStateChangeEvent - store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DiscussionsResolvedEvent - store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::MergeableEvent - store.subscribe ::MergeRequests::CreateApprovalEventWorker, to: ::MergeRequests::ApprovedEvent - store.subscribe ::MergeRequests::CreateApprovalNoteWorker, to: ::MergeRequests::ApprovedEvent - store.subscribe ::MergeRequests::ResolveTodosAfterApprovalWorker, to: ::MergeRequests::ApprovedEvent - store.subscribe ::MergeRequests::ExecuteApprovalHooksWorker, to: ::MergeRequests::ApprovedEvent - store.subscribe ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker, - to: ::Packages::PackageCreatedEvent, - if: ->(event) { ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker.handles_event?(event) } - store.subscribe ::Ci::InitializePipelinesIidSequenceWorker, to: ::Projects::ProjectCreatedEvent - store.subscribe ::Pages::DeletePagesDeploymentWorker, to: ::Projects::ProjectArchivedEvent - store.subscribe ::Pages::ResetPagesDefaultDomainRedirectWorker, to: ::Pages::Domains::PagesDomainDeletedEvent + def subscribe_to_member_destroyed_events(store) + store.subscribe ::WorkItems::UserPreferences::DestroyWorker, to: ::Members::DestroyedEvent + end end - private_class_method :configure! end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a488e6ed87ce..46b6dd575489 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -70313,6 +70313,12 @@ msgstr "" msgid "v%{version} published %{timeAgo}" msgstr "" +msgid "value \"%{sort}\" is not available on %{namespace}" +msgstr "" + +msgid "value \"%{sort}\" is not available on %{namespace} for work items type %{wit}" +msgstr "" + msgid "value at %{data_pointer} should use only one of: %{requirements}" msgstr "" diff --git a/spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb b/spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb new file mode 100644 index 000000000000..80b08dd25870 --- /dev/null +++ b/spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Resolvers::WorkItems::UserPreferenceResolver, feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:work_item_type) { WorkItems::Type.default_by_type(:issue) } + + let(:args) do + { + namespace_path: namespace.full_path, + work_item_type_id: work_item_type&.to_gid + } + end + + let(:result) do + resolve( + described_class, + obj: current_user, + args: args, + ctx: { + current_user: current_user + } + ) + end + + shared_examples 'resolve work items user preferences' do + context 'when user does not have access to the namespace' do + it 'does not update the user preference and return access error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + result + end + end + end + + context 'when user have access to the namespace' do + before_all do + namespace.add_guest(current_user) + end + + context 'when a user preference is not found' do + let_it_be(:work_item_type) { nil } + + it 'returns nil when the user preference is not found' do + expect(result).to be_blank + end + end + + context 'when a user preference is found' do + it 'returns the user preference when it is found' do + expect(result).to eq(user_preference) + end + end + end + end + + context 'when namespace is a group' do + let_it_be(:namespace) { create(:group, :private) } + + let_it_be(:user_preference) do + create( + :work_item_user_preference, + namespace: namespace, + work_item_type: work_item_type, + user: current_user + ) + end + + it_behaves_like 'resolve work items user preferences' + end + + context 'when namespace is a project' do + let_it_be(:namespace) { create(:project, :private) } + + let_it_be(:user_preference) do + create( + :work_item_user_preference, + namespace: namespace.project_namespace, + work_item_type: work_item_type, + user: current_user + ) + end + + it_behaves_like 'resolve work items user preferences' + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 06d4895246ea..52a43daf0a53 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -413,7 +413,7 @@ end end - context 'updating cd_cd_settings' do + context 'updating ci_cd_settings' do it 'does not raise an error' do project = create(:project) @@ -421,6 +421,19 @@ end end + describe '.project_namespace_for' do + it 'returns the project namespace for the given id' do + project = create(:project) + expect(described_class.project_namespace_for(id: project.id)).to eq(project.project_namespace) + end + + context 'when project is not found' do + it 'returns nil' do + expect(described_class.project_namespace_for(id: non_existing_record_id)).to be_nil + end + end + end + describe '#namespace_members' do let_it_be(:project) { create(:project, :public) } let_it_be(:requester) { create(:user) } diff --git a/spec/models/work_items/sorting_keys_spec.rb b/spec/models/work_items/sorting_keys_spec.rb new file mode 100644 index 000000000000..10776fd6b4dc --- /dev/null +++ b/spec/models/work_items/sorting_keys_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe WorkItems::SortingKeys, feature_category: :team_planning do + describe '#available?' do + context 'when no widget list is given' do + it 'returns true when passing a default sorting key' do + expect(described_class.available?('title_desc')).to be(true) + end + + it 'returns false when passing a default sorting key' do + expect(described_class.available?('unknown')).to be(false) + end + end + + context 'when widget list is given' do + let_it_be(:widget_list) { [WorkItems::Widgets::Milestone] } + + it 'returns true when passing a default sorting key' do + sorting_key = widget_list.sample.sorting_keys.keys.sample + expect(described_class.available?(sorting_key, widget_list: widget_list)).to be(true) + end + + it 'returns false when passing an unknown sorting key' do + expect(described_class.available?('unknown', widget_list: widget_list)).to be(false) + end + end + end +end diff --git a/spec/models/work_items/types/user_preference_spec.rb b/spec/models/work_items/types/user_preference_spec.rb deleted file mode 100644 index feb39fcff0d6..000000000000 --- a/spec/models/work_items/types/user_preference_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe WorkItems::Types::UserPreference, type: :model, feature_category: :team_planning do - describe 'associations' do - it { is_expected.to belong_to(:user) } - it { is_expected.to belong_to(:namespace) } - it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type').inverse_of(:user_preferences) } - end -end diff --git a/spec/models/work_items/user_preference_spec.rb b/spec/models/work_items/user_preference_spec.rb new file mode 100644 index 000000000000..e61c85293f52 --- /dev/null +++ b/spec/models/work_items/user_preference_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::UserPreference, type: :model, feature_category: :team_planning do + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:namespace) } + it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type').inverse_of(:user_preferences) } + end + + describe 'validations' do + let_it_be(:namespace) { build_stubbed(:group) } + + describe 'validate sort' do + let_it_be(:sorting_value) { 'due_date_asc' } + + it 'is valid when the sorting value is available' do + preferences = described_class.new(namespace: namespace, sort: sorting_value) + + expect(WorkItems::SortingKeys) + .to receive(:available?).with(sorting_value, widget_list: nil) + .and_return(true) + + expect(preferences).to be_valid + end + + it 'is not valid when the sorting value is not available' do + preferences = described_class.new(namespace: namespace, sort: sorting_value) + + expect(WorkItems::SortingKeys) + .to receive(:available?).with(sorting_value, widget_list: nil) + .and_return(false) + + expect(preferences).not_to be_valid + expect(preferences.errors.full_messages_for(:sort)).to include( + %(Sort value "#{sorting_value}" is not available on #{namespace.full_path}) + ) + end + + it 'is not valid when the sorting value is not available for an existign work item type' do + work_item_type = WorkItems::Type.default_by_type(:incident) + preferences = described_class.new( + namespace: namespace, + work_item_type: work_item_type, + sort: sorting_value + ) + + expect(WorkItems::SortingKeys) + .to receive(:available?).with( + sorting_value, + widget_list: work_item_type.widget_classes(namespace) + ).and_return(false) + + expect(preferences).not_to be_valid + expect(preferences.errors.full_messages_for(:sort)).to include(<<~MESSAGE.squish) + Sort value "#{sorting_value}" is not available + on #{namespace.full_path} for work items type #{work_item_type.name} + MESSAGE + end + end + end +end diff --git a/spec/policies/work_items/user_preference_policy_spec.rb b/spec/policies/work_items/user_preference_policy_spec.rb new file mode 100644 index 000000000000..9ebf50714a20 --- /dev/null +++ b/spec/policies/work_items/user_preference_policy_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::UserPreferencePolicy, feature_category: :team_planning do + let_it_be(:user) { create(:user) } + + subject(:policy) { described_class.new(user, user_preference) } + + context 'when namespace is public' do + context 'when namespace is a group' do + let_it_be(:namespace) { create(:group, :public) } + let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) } + + it { is_expected.to be_allowed(:read_namespace) } + end + + context 'when namespace is a project' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:namespace) { project.project_namespace } + let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) } + + it { is_expected.to be_allowed(:read_namespace) } + end + end + + context 'when namespace is private' do + context 'when namespace is a group' do + let_it_be(:namespace) { create(:group, :private) } + let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) } + + context 'when user is not member of the namespace' do + it { is_expected.to be_disallowed(:read_namespace) } + end + + context 'when user is member of the namespace' do + before_all do + namespace.add_guest(user) + end + + it { is_expected.to be_allowed(:read_namespace) } + end + end + + context 'when namespace is a project' do + let_it_be(:project) { create(:project, :private) } + let_it_be(:namespace) { project.project_namespace } + let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) } + + context 'when user is not member of the namespace' do + it { is_expected.to be_disallowed(:read_namespace) } + end + + context 'when user is member of the namespace' do + before_all do + project.add_guest(user) + end + + it { is_expected.to be_allowed(:read_namespace) } + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb new file mode 100644 index 000000000000..439ac704eda0 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe 'Update work items user preferences', feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:group, :private) } + let_it_be(:work_item_type) { WorkItems::Type.default_by_type(:incident) } + + let(:sorting_value) { 'CREATED_ASC' } + + let(:input) do + { + namespacePath: namespace.full_path, + workItemTypeId: work_item_type&.to_gid, + sort: sorting_value + } + end + + let(:fields) do + <<~FIELDS + errors + userPreferences { + namespace { + path + } + workItemType { + name + } + sort + } + FIELDS + end + + let(:mutation) { graphql_mutation(:WorkItemUserPreferenceUpdate, input, fields) } + let(:mutation_response) { graphql_mutation_response(:work_item_user_preference_update) } + + shared_examples 'updating work items user preferences' do + context 'when user does not have access to the namespace' do + it 'does not update the user preference and return access error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors.first['message']).to eq( + Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR + ) + end + end + + context 'when user has access to the namespace' do + before_all do + namespace.add_guest(user) + end + + it 'updates the user preferences successfully' do + post_graphql_mutation(mutation, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_blank + expect(mutation_response['errors']).to be_blank + expect(mutation_response['userPreferences']).to eq( + 'namespace' => { + 'path' => namespace.path + }, + 'workItemType' => { + 'name' => work_item_type.name + }, + 'sort' => sorting_value + ) + end + + context 'when work item type id is not provided' do + let(:input) do + { + namespacePath: namespace.full_path, + sort: sorting_value + } + end + + it 'updates the user preferences successfully' do + post_graphql_mutation(mutation, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_blank + expect(mutation_response['errors']).to be_blank + expect(mutation_response['userPreferences']).to eq( + 'namespace' => { + 'path' => namespace.path + }, + 'workItemType' => nil, + 'sort' => sorting_value + ) + end + end + + context 'when sort value is not available' do + let_it_be(:sorting_value) { 'DUE_DATE_ASC' } + + it 'updates the user preferences successfully' do + post_graphql_mutation(mutation, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_blank + expect(mutation_response['errors']).to include(<<~MESSAGE.squish) + Sort value "#{sorting_value.downcase}" is not available + on #{namespace.full_path} for work items type #{work_item_type.name} + MESSAGE + expect(mutation_response['userPreferences']).to be_nil + end + end + end + end + + context 'when namespace is a group' do + let_it_be(:namespace) { create(:group, :private) } + + it_behaves_like 'updating work items user preferences' + end + + context 'when namespace is a project' do + let_it_be(:namespace) { create(:project, :private) } + + it_behaves_like 'updating work items user preferences' + end +end diff --git a/spec/workers/work_items/user_preferences/destroy_worker_spec.rb b/spec/workers/work_items/user_preferences/destroy_worker_spec.rb new file mode 100644 index 000000000000..ed0d8432c2aa --- /dev/null +++ b/spec/workers/work_items/user_preferences/destroy_worker_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::UserPreferences::DestroyWorker, type: :worker, feature_category: :team_planning do + let_it_be(:root_namespace) { create(:group) } + let_it_be(:user) { create(:user) } + + let(:source) { root_namespace } + let(:source_type) { GroupMember::SOURCE_TYPE } + + let(:event) do + ::Members::DestroyedEvent.new( + data: { + root_namespace_id: root_namespace.id, + source_id: source.id, + source_type: source_type, + user_id: user.id + } + ) + end + + subject(:worker) { described_class.new } + + it_behaves_like 'subscribes to event' + + shared_examples 'delete user preferences' do + context 'when there user has no work item preference' do + it 'does nothing' do + expect { consume_event(subscriber: described_class, event: event) } + .not_to change { WorkItems::UserPreference.count } + end + end + + context 'when there user has work item preference' do + let_it_be(:user_preference) do + create(:work_item_user_preference, namespace: namespace, user: user) + end + + it 'destroy the existing user preference' do + expect { consume_event(subscriber: described_class, event: event) } + .to change { WorkItems::UserPreference.count }.by(-1) + + expect(WorkItems::UserPreference.exists?(id: user_preference.id)).to be(false) + end + end + end + + context 'when namespace is a group' do + let_it_be(:namespace) { create(:group, parent: root_namespace) } + let(:source) { namespace } + + it_behaves_like 'delete user preferences' + end + + context 'when namespace is a project' do + let_it_be(:project) { create(:project, group: root_namespace) } + let_it_be(:namespace) { project.project_namespace } + + let(:source) { project } + let(:source_type) { ProjectMember::SOURCE_TYPE } + + it_behaves_like 'delete user preferences' + end +end -- GitLab