Commit c70eed96 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'mc/feature/add-user-callout-graphql' into 'master'

Add user callouts to GraphQL

See merge request gitlab-org/gitlab!55099
parents 2ea1f5d9 5ba52c44
......@@ -4,10 +4,11 @@ class UserCalloutsController < ApplicationController
feature_category :navigation
def create
callout = ensure_callout
callout = Users::DismissUserCalloutService.new(
container: nil, current_user: current_user, params: { feature_name: feature_name }
).execute
if callout.persisted?
callout.update(dismissed_at: Time.current)
respond_to do |format|
format.json { head :ok }
end
......@@ -20,12 +21,6 @@ class UserCalloutsController < ApplicationController
private
# rubocop: disable CodeReuse/ActiveRecord
def ensure_callout
current_user.callouts.find_or_create_by(feature_name: UserCallout.feature_names[feature_name])
end
# rubocop: enable CodeReuse/ActiveRecord
def feature_name
params.require(:feature_name)
end
......
# frozen_string_literal: true
module Mutations
module UserCallouts
class Create < ::Mutations::BaseMutation
graphql_name 'UserCalloutCreate'
argument :feature_name,
GraphQL::STRING_TYPE,
required: true,
description: "The feature name you want to dismiss the callout for."
field :user_callout, Types::UserCalloutType,
null: false,
description: 'The user callout dismissed.'
def resolve(feature_name:)
callout = Users::DismissUserCalloutService.new(
container: nil, current_user: current_user, params: { feature_name: feature_name }
).execute
errors = errors_on_object(callout)
{
user_callout: callout,
errors: errors
}
end
end
end
end
......@@ -97,6 +97,7 @@ module Types
mount_mutation Mutations::Ci::Pipeline::Retry
mount_mutation Mutations::Ci::CiCdSettingsUpdate
mount_mutation Mutations::Namespace::PackageSettings::Update
mount_mutation Mutations::UserCallouts::Create
end
end
......
# frozen_string_literal: true
module Types
class UserCalloutFeatureNameEnum < BaseEnum
graphql_name 'UserCalloutFeatureNameEnum'
description 'Name of the feature that the callout is for.'
::UserCallout.feature_names.keys.each do |feature_name|
value feature_name.upcase, value: feature_name, description: "Callout feature name for #{feature_name}."
end
end
end
# frozen_string_literal: true
module Types
class UserCalloutType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
graphql_name 'UserCallout'
field :feature_name, UserCalloutFeatureNameEnum, null: false,
description: 'Name of the feature that the callout is for.'
field :dismissed_at, Types::TimeType, null: true,
description: 'Date when the callout was dismissed.'
end
end
......@@ -67,5 +67,9 @@ module Types
null: true,
description: 'Snippets authored by the user.',
resolver: Resolvers::Users::SnippetsResolver
field :callouts,
Types::UserCalloutType.connection_type,
null: true,
description: 'User callouts that belong to the user.'
end
end
......@@ -1854,6 +1854,10 @@ class User < ApplicationRecord
created_at > Devise.confirm_within.ago
end
def find_or_initialize_callout(feature_name)
callouts.find_or_initialize_by(feature_name: ::UserCallout.feature_names[feature_name])
end
protected
# override, from Devise::Validatable
......
# frozen_string_literal: true
module Users
class DismissUserCalloutService < BaseContainerService
def execute
current_user.find_or_initialize_callout(params[:feature_name]).tap do |callout|
callout.update(dismissed_at: Time.current) if callout.valid?
end
end
end
end
---
title: Add user callouts to GraphQL.
merge_request: 55099
author:
type: added
......@@ -4503,6 +4503,7 @@ Represents a recorded measurement (object count) for the Admins.
| `authoredMergeRequests` | MergeRequestConnection | Merge Requests authored by the user. |
| `avatarUrl` | String | URL of the user's avatar. |
| `bot` | Boolean! | Indicates if the user is a bot. |
| `callouts` | UserCalloutConnection | User callouts that belong to the user. |
| `email` **{warning-solid}** | String | **Deprecated:** Use public_email. Deprecated in 13.7. |
| `groupCount` | Int | Group count for the user. Available only when feature flag `user_group_counts` is enabled. |
| `groupMemberships` | GroupMemberConnection | Group memberships of the user. |
......@@ -4522,6 +4523,23 @@ Represents a recorded measurement (object count) for the Admins.
| `webPath` | String! | Web path of the user. |
| `webUrl` | String! | Web URL of the user. |
### `UserCallout`
| Field | Type | Description |
| ----- | ---- | ----------- |
| `dismissedAt` | Time | Date when the callout was dismissed. |
| `featureName` | UserCalloutFeatureNameEnum! | Name of the feature that the callout is for. |
### `UserCalloutCreatePayload`
Autogenerated return type of UserCalloutCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `userCallout` | UserCallout! | The user callout dismissed. |
### `UserPermissions`
| Field | Type | Description |
......@@ -5918,6 +5936,39 @@ State of a test report.
| `personal` | |
| `project` | |
### `UserCalloutFeatureNameEnum`
Name of the feature that the callout is for.
| Value | Description |
| ----- | ----------- |
| `ACCOUNT_RECOVERY_REGULAR_CHECK` | Callout feature name for account_recovery_regular_check. |
| `ACTIVE_USER_COUNT_THRESHOLD` | Callout feature name for active_user_count_threshold. |
| `ADMIN_INTEGRATIONS_MOVED` | Callout feature name for admin_integrations_moved. |
| `BUY_PIPELINE_MINUTES_NOTIFICATION_DOT` | Callout feature name for buy_pipeline_minutes_notification_dot. |
| `CANARY_DEPLOYMENT` | Callout feature name for canary_deployment. |
| `CLUSTER_SECURITY_WARNING` | Callout feature name for cluster_security_warning. |
| `CUSTOMIZE_HOMEPAGE` | Callout feature name for customize_homepage. |
| `EOA_BRONZE_PLAN_BANNER` | Callout feature name for eoa_bronze_plan_banner. |
| `FEATURE_FLAGS_NEW_VERSION` | Callout feature name for feature_flags_new_version. |
| `GCP_SIGNUP_OFFER` | Callout feature name for gcp_signup_offer. |
| `GEO_ENABLE_HASHED_STORAGE` | Callout feature name for geo_enable_hashed_storage. |
| `GEO_MIGRATE_HASHED_STORAGE` | Callout feature name for geo_migrate_hashed_storage. |
| `GKE_CLUSTER_INTEGRATION` | Callout feature name for gke_cluster_integration. |
| `GOLD_TRIAL_BILLINGS` | Callout feature name for gold_trial_billings. |
| `NEW_USER_SIGNUPS_CAP_REACHED` | Callout feature name for new_user_signups_cap_reached. |
| `PERSONAL_ACCESS_TOKEN_EXPIRY` | Callout feature name for personal_access_token_expiry. |
| `REGISTRATION_ENABLED_CALLOUT` | Callout feature name for registration_enabled_callout. |
| `SERVICE_TEMPLATES_DEPRECATED` | Callout feature name for service_templates_deprecated. |
| `SUGGEST_PIPELINE` | Callout feature name for suggest_pipeline. |
| `SUGGEST_POPOVER_DISMISSED` | Callout feature name for suggest_popover_dismissed. |
| `TABS_POSITION_HIGHLIGHT` | Callout feature name for tabs_position_highlight. |
| `THREAT_MONITORING_INFO` | Callout feature name for threat_monitoring_info. |
| `ULTIMATE_TRIAL` | Callout feature name for ultimate_trial. |
| `UNFINISHED_TAG_CLEANUP_CALLOUT` | Callout feature name for unfinished_tag_cleanup_callout. |
| `WEBHOOKS_MOVED` | Callout feature name for webhooks_moved. |
| `WEB_IDE_ALERT_DISMISSED` | Callout feature name for web_ide_alert_dismissed. |
### `UserState`
Possible states of a user.
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::UserCallouts::Create do
let(:current_user) { create(:user) }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
describe '#resolve' do
subject(:resolve) { mutation.resolve(feature_name: feature_name) }
context 'when feature name is not supported' do
let(:feature_name) { 'not_supported' }
it 'does not create a user callout' do
expect { resolve }.not_to change(UserCallout, :count).from(0)
end
it 'returns error about feature name not being supported' do
expect(resolve[:errors]).to include("Feature name is not included in the list")
end
end
context 'when feature name is supported' do
let(:feature_name) { UserCallout.feature_names.each_key.first.to_s }
it 'creates a user callout' do
expect { resolve }.to change(UserCallout, :count).from(0).to(1)
end
it 'sets dismissed_at for the user callout' do
freeze_time do
expect(resolve[:user_callout].dismissed_at).to eq(Time.current)
end
end
it 'has no errors' do
expect(resolve[:errors]).to be_empty
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['UserCalloutFeatureNameEnum'] do
specify { expect(described_class.graphql_name).to eq('UserCalloutFeatureNameEnum') }
it 'exposes all the existing user callout feature names' do
expect(described_class.values.keys).to match_array(::UserCallout.feature_names.keys.map(&:upcase))
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['UserCallout'] do
specify { expect(described_class.graphql_name).to eq('UserCallout') }
it 'has expected fields' do
expect(described_class).to have_graphql_fields(:feature_name, :dismissed_at)
end
end
......@@ -31,6 +31,7 @@ RSpec.describe GitlabSchema.types['User'] do
groupCount
projectMemberships
starredProjects
callouts
]
expect(described_class).to have_graphql_fields(*expected_fields)
......@@ -44,4 +45,12 @@ RSpec.describe GitlabSchema.types['User'] do
is_expected.to have_graphql_resolver(Resolvers::Users::SnippetsResolver)
end
end
describe 'callouts field' do
subject { described_class.fields['callouts'] }
it 'returns user callouts' do
is_expected.to have_graphql_type(Types::UserCalloutType.connection_type)
end
end
end
......@@ -5482,4 +5482,43 @@ RSpec.describe User do
end
end
end
describe '#find_or_initialize_callout' do
subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) }
let(:user) { create(:user) }
let(:feature_name) { UserCallout.feature_names.each_key.first }
context 'when callout exists' do
let!(:callout) { create(:user_callout, user: user, feature_name: feature_name) }
it 'returns existing callout' do
expect(find_or_initialize_callout).to eq(callout)
end
end
context 'when callout does not exist' do
context 'when feature name is valid' do
it 'initializes a new callout' do
expect(find_or_initialize_callout).to be_a_new(UserCallout)
end
it 'is valid' do
expect(find_or_initialize_callout).to be_valid
end
end
context 'when feature name is not valid' do
let(:feature_name) { 'notvalid' }
it 'initializes a new callout' do
expect(find_or_initialize_callout).to be_a_new(UserCallout)
end
it 'is not valid' do
expect(find_or_initialize_callout).not_to be_valid
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create a user callout' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let(:feature_name) { ::UserCallout.feature_names.each_key.first }
let(:input) do
{
'featureName' => feature_name
}
end
let(:mutation) { graphql_mutation(:userCalloutCreate, input) }
let(:mutation_response) { graphql_mutation_response(:userCalloutCreate) }
it 'creates user callout' do
freeze_time do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['userCallout']['featureName']).to eq(feature_name.upcase)
expect(mutation_response['userCallout']['dismissedAt']).to eq(Time.current.iso8601)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Users::DismissUserCalloutService do
let(:user) { create(:user) }
let(:service) do
described_class.new(
container: nil, current_user: user, params: { feature_name: UserCallout.feature_names.each_key.first }
)
end
describe '#execute' do
subject(:execute) { service.execute }
it 'returns a user callout' do
expect(execute).to be_an_instance_of(UserCallout)
end
it 'sets the dismisse_at attribute to current time' do
freeze_time do
expect(execute).to have_attributes(dismissed_at: Time.current)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment