From d179355dea43a21f6fed78402b1e52fb2ea76bb6 Mon Sep 17 00:00:00 2001 From: Dennis Meister Date: Sun, 12 Oct 2025 12:56:30 +0200 Subject: [PATCH 1/8] Add unique users for achievements --- .../types/achievements/achievement_type.rb | 4 +-- app/models/achievements/achievement.rb | 2 +- spec/models/achievements/achievement_spec.rb | 29 +++++++++++++++---- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb index e6b27129f72ba2..798d4f362d2331 100644 --- a/app/graphql/types/achievements/achievement_type.rb +++ b/app/graphql/types/achievements/achievement_type.rb @@ -53,8 +53,8 @@ class AchievementType < BaseObject field :unique_users, Types::UserType.connection_type, null: false, - experiment: { milestone: '18.6' }, - description: "Unique users who have received the achievement." + experiment: { milestone: '18.5' }, + description: "Count of unique users who have received the achievement." def avatar_url object.avatar_url(only_path: false) diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb index 97bd13293678cf..6092132a77c63f 100644 --- a/app/models/achievements/achievement.rb +++ b/app/models/achievements/achievement.rb @@ -19,7 +19,7 @@ class Achievement < ApplicationRecord validates :description, length: { maximum: 1024 } def unique_users - users.distinct + users.uniq end def uploads_sharding_key diff --git a/spec/models/achievements/achievement_spec.rb b/spec/models/achievements/achievement_spec.rb index bd25b6d21ad966..3fdce249b65790 100644 --- a/spec/models/achievements/achievement_spec.rb +++ b/spec/models/achievements/achievement_spec.rb @@ -43,23 +43,40 @@ end describe '#unique_users' do - let_it_be(:achievement) { create(:achievement) } - - subject(:unique_users) { achievement.unique_users } - it 'returns unique users even when a user has multiple awards' do + achievement = create(:achievement) user1 = create(:user) user2 = create(:user) + user3 = create(:user) create(:user_achievement, achievement: achievement, user: user1) create(:user_achievement, achievement: achievement, user: user1) + create(:user_achievement, achievement: achievement, user: user2) - expect(unique_users).to contain_exactly(user1, user2) + unique_users = achievement.unique_users + + expect(unique_users.count).to eq(2) + expect(unique_users).to match_array([user1, user2]) + expect(unique_users).not_to include(user3) end it 'returns empty when no users have been awarded' do - expect(unique_users).to be_empty + achievement = create(:achievement) + + expect(achievement.unique_users).to be_empty + end + + it 'returns single user when only one user has been awarded multiple times' do + achievement = create(:achievement) + user = create(:user) + + create(:user_achievement, achievement: achievement, user: user) + create(:user_achievement, achievement: achievement, user: user) + create(:user_achievement, achievement: achievement, user: user) + + expect(achievement.unique_users.count).to eq(1) + expect(achievement.unique_users).to eq([user]) end end end -- GitLab From 66c644c505c91c93ed81e671f4e45c853ca2ec79 Mon Sep 17 00:00:00 2001 From: Dennis Meister Date: Mon, 13 Oct 2025 10:42:08 +0200 Subject: [PATCH 2/8] Fix review comments and pipeline --- app/graphql/types/achievements/achievement_type.rb | 2 +- app/models/achievements/achievement.rb | 2 +- doc/api/graphql/reference/_index.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb index 798d4f362d2331..2e1a1ff4511884 100644 --- a/app/graphql/types/achievements/achievement_type.rb +++ b/app/graphql/types/achievements/achievement_type.rb @@ -54,7 +54,7 @@ class AchievementType < BaseObject field :unique_users, Types::UserType.connection_type, null: false, experiment: { milestone: '18.5' }, - description: "Count of unique users who have received the achievement." + description: "Unique users who have received the achievement." def avatar_url object.avatar_url(only_path: false) diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb index 6092132a77c63f..97bd13293678cf 100644 --- a/app/models/achievements/achievement.rb +++ b/app/models/achievements/achievement.rb @@ -19,7 +19,7 @@ class Achievement < ApplicationRecord validates :description, length: { maximum: 1024 } def unique_users - users.uniq + users.distinct end def uploads_sharding_key diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 4abf36ac582f61..2c3bf37171b387 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -23136,7 +23136,7 @@ Representation of a GitLab user. | `id` | [`AchievementsAchievementID!`](#achievementsachievementid) | ID of the achievement. | | `name` | [`String!`](#string) | Name of the achievement. | | `namespace` | [`Namespace`](#namespace) | Namespace of the achievement. | -| `uniqueUsers` {{< icon name="warning-solid" >}} | [`UserCoreConnection!`](#usercoreconnection) | **Introduced** in GitLab 18.6. **Status**: Experiment. Unique users who have received the achievement. | +| `uniqueUsers` {{< icon name="warning-solid" >}} | [`UserCoreConnection!`](#usercoreconnection) | **Introduced** in GitLab 18.5. **Status**: Experiment. Unique users who have received the achievement. | | `updatedAt` | [`Time!`](#time) | Timestamp the achievement was last updated. | | `userAchievements` {{< icon name="warning-solid" >}} | [`UserAchievementConnection`](#userachievementconnection) | **Introduced** in GitLab 15.10. **Status**: Experiment. Recipients for the achievement. | -- GitLab From 5331034835d4dabec44bb179e44590f29cb6fabc Mon Sep 17 00:00:00 2001 From: Dennis Meister Date: Wed, 15 Oct 2025 16:43:26 +0200 Subject: [PATCH 3/8] Apply 3 suggestion(s) to 3 file(s) Co-authored-by: Lee Tickett --- .../types/achievements/achievement_type.rb | 2 +- doc/api/graphql/reference/_index.md | 2 +- spec/models/achievements/achievement_spec.rb | 28 ++++--------------- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb index 2e1a1ff4511884..e6b27129f72ba2 100644 --- a/app/graphql/types/achievements/achievement_type.rb +++ b/app/graphql/types/achievements/achievement_type.rb @@ -53,7 +53,7 @@ class AchievementType < BaseObject field :unique_users, Types::UserType.connection_type, null: false, - experiment: { milestone: '18.5' }, + experiment: { milestone: '18.6' }, description: "Unique users who have received the achievement." def avatar_url diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 2c3bf37171b387..4abf36ac582f61 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -23136,7 +23136,7 @@ Representation of a GitLab user. | `id` | [`AchievementsAchievementID!`](#achievementsachievementid) | ID of the achievement. | | `name` | [`String!`](#string) | Name of the achievement. | | `namespace` | [`Namespace`](#namespace) | Namespace of the achievement. | -| `uniqueUsers` {{< icon name="warning-solid" >}} | [`UserCoreConnection!`](#usercoreconnection) | **Introduced** in GitLab 18.5. **Status**: Experiment. Unique users who have received the achievement. | +| `uniqueUsers` {{< icon name="warning-solid" >}} | [`UserCoreConnection!`](#usercoreconnection) | **Introduced** in GitLab 18.6. **Status**: Experiment. Unique users who have received the achievement. | | `updatedAt` | [`Time!`](#time) | Timestamp the achievement was last updated. | | `userAchievements` {{< icon name="warning-solid" >}} | [`UserAchievementConnection`](#userachievementconnection) | **Introduced** in GitLab 15.10. **Status**: Experiment. Recipients for the achievement. | diff --git a/spec/models/achievements/achievement_spec.rb b/spec/models/achievements/achievement_spec.rb index 3fdce249b65790..eb228284bfb62e 100644 --- a/spec/models/achievements/achievement_spec.rb +++ b/spec/models/achievements/achievement_spec.rb @@ -43,40 +43,24 @@ end describe '#unique_users' do + let_it_be(:achievement) { create(:achievement) } + + subject(:unique_users) { achievement.unique_users } + it 'returns unique users even when a user has multiple awards' do - achievement = create(:achievement) user1 = create(:user) user2 = create(:user) user3 = create(:user) create(:user_achievement, achievement: achievement, user: user1) create(:user_achievement, achievement: achievement, user: user1) - create(:user_achievement, achievement: achievement, user: user2) - unique_users = achievement.unique_users - - expect(unique_users.count).to eq(2) - expect(unique_users).to match_array([user1, user2]) - expect(unique_users).not_to include(user3) + expect(unique_users).to contain_exactly(user1, user2) end it 'returns empty when no users have been awarded' do - achievement = create(:achievement) - - expect(achievement.unique_users).to be_empty - end - - it 'returns single user when only one user has been awarded multiple times' do - achievement = create(:achievement) - user = create(:user) - - create(:user_achievement, achievement: achievement, user: user) - create(:user_achievement, achievement: achievement, user: user) - create(:user_achievement, achievement: achievement, user: user) - - expect(achievement.unique_users.count).to eq(1) - expect(achievement.unique_users).to eq([user]) + expect(unique_users).to be_empty end end end -- GitLab From 9268ba55a25aafe08e37147e79a750ba026aa309 Mon Sep 17 00:00:00 2001 From: Dennis Meister Date: Wed, 15 Oct 2025 16:52:08 +0200 Subject: [PATCH 4/8] Remove unneeded user3 from spec --- spec/models/achievements/achievement_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/models/achievements/achievement_spec.rb b/spec/models/achievements/achievement_spec.rb index eb228284bfb62e..bd25b6d21ad966 100644 --- a/spec/models/achievements/achievement_spec.rb +++ b/spec/models/achievements/achievement_spec.rb @@ -50,7 +50,6 @@ it 'returns unique users even when a user has multiple awards' do user1 = create(:user) user2 = create(:user) - user3 = create(:user) create(:user_achievement, achievement: achievement, user: user1) create(:user_achievement, achievement: achievement, user: user1) -- GitLab From 01be7f9b152e37817fcf61d984bdb409e68c073a Mon Sep 17 00:00:00 2001 From: Dennis Meister Date: Sun, 12 Oct 2025 13:01:23 +0200 Subject: [PATCH 5/8] Handle unique users for achievements --- .../components/achievements_app.vue | 58 +++++++++++++++++-- .../achievement_fields.fragment.graphql | 12 ++-- .../get_more_unique_users.query.graphql | 19 ++++++ .../unique_users_fields.fragment.graphql | 12 ++++ .../graphql/user_fields.fragment.graphql | 7 +++ .../user_avatar/user_avatar_list.vue | 34 +++++++++-- 6 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 app/assets/javascripts/achievements/components/graphql/get_more_unique_users.query.graphql create mode 100644 app/assets/javascripts/achievements/components/graphql/unique_users_fields.fragment.graphql create mode 100644 app/assets/javascripts/achievements/components/graphql/user_fields.fragment.graphql diff --git a/app/assets/javascripts/achievements/components/achievements_app.vue b/app/assets/javascripts/achievements/components/achievements_app.vue index aa0c67521ef85f..5d6712cc72e746 100644 --- a/app/assets/javascripts/achievements/components/achievements_app.vue +++ b/app/assets/javascripts/achievements/components/achievements_app.vue @@ -6,13 +6,13 @@ import { GlKeysetPagination, GlLoadingIcon, } from '@gitlab/ui'; -import { uniqBy } from 'lodash'; import { s__ } from '~/locale'; import PageHeading from '~/vue_shared/components/page_heading.vue'; import CrudComponent from '~/vue_shared/components/crud_component.vue'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; import { NEW_ROUTE_NAME } from '../constants'; import getGroupAchievements from './graphql/get_group_achievements.query.graphql'; +import getMoreUniqueUsers from './graphql/get_more_unique_users.query.graphql'; import AwardButton from './award_button.vue'; const ENTRIES_PER_PAGE = 20; @@ -57,6 +57,7 @@ export default { before: null, }, pageInfo: {}, + loadingUsers: {}, }; }, apollo: { @@ -110,8 +111,48 @@ export default { before: item, }; }, - uniqueRecipients(userAchievements) { - return uniqBy(userAchievements, 'user.id').map(({ user }) => user); + async loadMoreUsers(achievementId, endCursor) { + this.setLoadingState(achievementId, true); + try { + const fetchedData = await this.fetchMoreUniqueUsers(achievementId, endCursor); + if (fetchedData) { + this.mergeUniqueUsers(achievementId, fetchedData); + } + } finally { + this.setLoadingState(achievementId, false); + } + }, + async fetchMoreUniqueUsers(achievementId, endCursor) { + const { data } = await this.$apollo.query({ + query: getMoreUniqueUsers, + variables: { + groupFullPath: this.groupFullPath, + achievementId, + after: endCursor, + }, + }); + return data?.group?.achievements?.nodes?.[0]?.uniqueUsers; + }, + mergeUniqueUsers(achievementId, fetchedData) { + this.achievements = this.achievements.map((achievement) => { + if (achievement.id === achievementId) { + return { + ...achievement, + uniqueUsers: { + nodes: [...achievement.uniqueUsers.nodes, ...fetchedData.nodes], + pageInfo: fetchedData.pageInfo, + count: fetchedData.count, + }, + }; + } + return achievement; + }); + }, + setLoadingState(achievementId, isLoading) { + this.loadingUsers = { + ...this.loadingUsers, + [achievementId]: isLoading, + }; }, }, i18n: { @@ -119,6 +160,7 @@ export default { emptyStateTitle: s__('Achievements|There are currently no achievements.'), newAchievement: s__('Achievements|New achievement'), notYetAwarded: s__('Achievements|Not yet awarded.'), + users: s__('Achievements|awarded users'), }, NEW_ROUTE_NAME, }; @@ -167,10 +209,16 @@ export default { +
+ {{ achievement.uniqueUsers.count }} {{ $options.i18n.users }} +
{{ $options.i18n.notYetAwarded }} diff --git a/app/assets/javascripts/achievements/components/graphql/achievement_fields.fragment.graphql b/app/assets/javascripts/achievements/components/graphql/achievement_fields.fragment.graphql index df356d7fc798fc..21349dd955568e 100644 --- a/app/assets/javascripts/achievements/components/graphql/achievement_fields.fragment.graphql +++ b/app/assets/javascripts/achievements/components/graphql/achievement_fields.fragment.graphql @@ -1,3 +1,6 @@ +#import "./unique_users_fields.fragment.graphql" +#import "./user_fields.fragment.graphql" + fragment AchievementFragment on Achievement { id name @@ -7,12 +10,11 @@ fragment AchievementFragment on Achievement { nodes { id user { - id - username - name - avatarUrl - webUrl + ...UserFragment } } } + uniqueUsers(first: 100) { + ...UniqueUsersFragment + } } diff --git a/app/assets/javascripts/achievements/components/graphql/get_more_unique_users.query.graphql b/app/assets/javascripts/achievements/components/graphql/get_more_unique_users.query.graphql new file mode 100644 index 00000000000000..52be4f32f0fc31 --- /dev/null +++ b/app/assets/javascripts/achievements/components/graphql/get_more_unique_users.query.graphql @@ -0,0 +1,19 @@ +#import "./unique_users_fields.fragment.graphql" + +query getMoreUniqueUsers( + $groupFullPath: ID! + $achievementId: AchievementsAchievementID! + $after: String +) { + group(fullPath: $groupFullPath) { + id + achievements(ids: [$achievementId]) { + nodes { + id + uniqueUsers(first: 100, after: $after) { + ...UniqueUsersFragment + } + } + } + } +} diff --git a/app/assets/javascripts/achievements/components/graphql/unique_users_fields.fragment.graphql b/app/assets/javascripts/achievements/components/graphql/unique_users_fields.fragment.graphql new file mode 100644 index 00000000000000..af9dfa121a3717 --- /dev/null +++ b/app/assets/javascripts/achievements/components/graphql/unique_users_fields.fragment.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "./user_fields.fragment.graphql" + +fragment UniqueUsersFragment on UserCoreConnection { + nodes { + ...UserFragment + } + pageInfo { + ...PageInfo + } + count +} diff --git a/app/assets/javascripts/achievements/components/graphql/user_fields.fragment.graphql b/app/assets/javascripts/achievements/components/graphql/user_fields.fragment.graphql new file mode 100644 index 00000000000000..6647c24e2004ae --- /dev/null +++ b/app/assets/javascripts/achievements/components/graphql/user_fields.fragment.graphql @@ -0,0 +1,7 @@ +fragment UserFragment on UserCore { + id + username + name + avatarUrl + webUrl +} diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index 258e8b1a6c55dc..3d578d0fe51447 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -27,6 +27,16 @@ export default { required: false, default: __('None'), }, + hasMore: { + type: Boolean, + required: false, + default: false, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -48,19 +58,26 @@ export default { return this.breakpoint > 0 && this.items.length > this.breakpoint; }, expandText() { - if (!this.hasHiddenItems) { + if (!this.hasHiddenItems && !this.hasMore) { return ''; } - const count = this.items.length - this.breakpoint; + if (this.hasMore) { + return __('Load more'); + } + const count = this.items.length - this.breakpoint; return sprintf(__('%{count} more'), { count }); }, }, methods: { expand() { - this.isExpanded = true; - this.$emit('expanded'); + if (this.hasMore && !this.hasHiddenItems) { + this.$emit('load-more'); + } else { + this.isExpanded = true; + this.$emit('expanded'); + } }, collapse() { this.isExpanded = false; @@ -85,8 +102,13 @@ export default { :popover-username="item.username" img-css-classes="gl-mr-3" /> -