diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 7795dac18bcbb52aa9802fba47cf3dd240ceec8f..cca4cf68f5e3c2d9890c16dd3f6c8e0f052d8822 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -721,3 +721,13 @@ export const getFirstPropertyValue = (data) => { return data[key]; }; + +export const isCurrentUser = (userId) => { + const currentUserId = window.gon?.current_user_id; + + if (!currentUserId) { + return false; + } + + return Number(userId) === currentUserId; +}; diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue index 7bab8a1c30dd79a93575dbcda6e95b8ed078d97a..2673ab6fbf423cb31247ca5ca7e88a1257b93d2e 100644 --- a/app/assets/javascripts/profile/components/follow.vue +++ b/app/assets/javascripts/profile/components/follow.vue @@ -1,7 +1,14 @@ <script> -import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { + GlAvatarLabeled, + GlAvatarLink, + GlLoadingIcon, + GlPagination, + GlEmptyState, +} from '@gitlab/ui'; import { DEFAULT_PER_PAGE } from '~/api'; import { NEXT, PREV } from '~/vue_shared/components/pagination/constants'; +import { isCurrentUser } from '~/lib/utils/common_utils'; export default { i18n: { @@ -13,7 +20,9 @@ export default { GlAvatarLink, GlLoadingIcon, GlPagination, + GlEmptyState, }, + inject: ['followEmptyState', 'userId'], props: { /** * Expected format: @@ -48,12 +57,34 @@ export default { required: false, default: DEFAULT_PER_PAGE, }, + currentUserEmptyStateTitle: { + type: String, + required: true, + }, + visitorEmptyStateTitle: { + type: String, + required: true, + }, + }, + computed: { + emptyStateTitle() { + return isCurrentUser(this.userId) + ? this.currentUserEmptyStateTitle + : this.visitorEmptyStateTitle; + }, }, }; </script> <template> <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> + <gl-empty-state + v-else-if="!users.length" + class="gl-mt-5" + :svg-path="followEmptyState" + :svg-height="144" + :title="emptyStateTitle" + /> <div v-else> <div class="gl-my-n3 gl-mx-n3 gl-display-flex gl-flex-wrap"> <div v-for="user in users" :key="user.id" class="gl-p-3 gl-w-full gl-md-w-half gl-lg-w-25p"> diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue index 1fa579bc6118c8ceb40c0ec457b38509efd28d90..927424d6c3feffdb5824942468e00c4d49ed3c7d 100644 --- a/app/assets/javascripts/profile/components/followers_tab.vue +++ b/app/assets/javascripts/profile/components/followers_tab.vue @@ -12,6 +12,8 @@ export default { errorMessage: s__( 'UserProfile|An error occurred loading the followers. Please refresh the page to try again.', ), + currentUserEmptyStateTitle: s__('UserProfile|You do not have any followers'), + visitorEmptyStateTitle: s__("UserProfile|This user doesn't have any followers"), }, components: { GlBadge, @@ -68,6 +70,8 @@ export default { :loading="loading" :page="page" :total-items="totalItems" + :current-user-empty-state-title="$options.i18n.currentUserEmptyStateTitle" + :visitor-empty-state-title="$options.i18n.visitorEmptyStateTitle" @pagination-input="onPaginationInput" /> </gl-tab> diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue index 27c16ee5b4652d8e27c9db8eb132b70e3daec292..66c7ee42a3f64a34627f4f7ca38daf13104cfb87 100644 --- a/app/assets/javascripts/profile/components/following_tab.vue +++ b/app/assets/javascripts/profile/components/following_tab.vue @@ -12,6 +12,8 @@ export default { errorMessage: s__( 'UserProfile|An error occurred loading the following. Please refresh the page to try again.', ), + currentUserEmptyStateTitle: s__('UserProfile|You are not following other users'), + visitorEmptyStateTitle: s__("UserProfile|This user isn't following other users"), }, components: { GlBadge, @@ -69,6 +71,8 @@ export default { :loading="loading" :page="page" :total-items="totalItems" + :current-user-empty-state-title="$options.i18n.currentUserEmptyStateTitle" + :visitor-empty-state-title="$options.i18n.visitorEmptyStateTitle" @pagination-input="onPaginationInput" /> </gl-tab> diff --git a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue index fce5e2f5e78bdec8d2dc12065ff33eba3b89353a..95649f9645ba8c51e1c3671848b4d7ff54482188 100644 --- a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue +++ b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue @@ -1,9 +1,11 @@ <script> import { GlTab, GlKeysetPagination, GlEmptyState } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants'; +import { isCurrentUser } from '~/lib/utils/common_utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; import getUserSnippets from '../graphql/get_user_snippets.query.graphql'; import SnippetRow from './snippet_row.vue'; @@ -11,7 +13,11 @@ export default { name: 'SnippetsTab', i18n: { title: s__('UserProfile|Snippets'), - noSnippets: s__('UserProfiles|No snippets found.'), + currentUserEmptyStateTitle: s__('UserProfile|Get started with snippets'), + visitorEmptyStateTitle: s__("UserProfile|This user doesn't have any snippets"), + emptyStateDescription: s__('UserProfile|Store, share, and embed bits of code and text.'), + newSnippet: __('New snippet'), + learnMore: __('Learn more'), }, components: { GlTab, @@ -19,7 +25,7 @@ export default { GlEmptyState, SnippetRow, }, - inject: ['userId', 'snippetsEmptyState'], + inject: ['userId', 'snippetsEmptyState', 'newSnippetPath'], data() { return { userInfo: {}, @@ -57,6 +63,14 @@ export default { hasSnippets() { return this.userSnippets?.length; }, + emptyStateTitle() { + return isCurrentUser(this.userId) + ? this.$options.i18n.currentUserEmptyStateTitle + : this.$options.i18n.visitorEmptyStateTitle; + }, + emptyStateDescription() { + return isCurrentUser(this.userId) ? this.$options.i18n.emptyStateDescription : null; + }, }, methods: { isLastSnippet(index) { @@ -76,6 +90,7 @@ export default { beforeToken: this.pageInfo.startCursor, }; }, + helpPagePath, }, }; </script> @@ -100,11 +115,17 @@ export default { </div> </template> <template v-if="!hasSnippets"> - <gl-empty-state class="gl-mt-5" :svg-height="75" :svg-path="snippetsEmptyState"> - <template #title> - <p class="gl-font-weight-bold gl-mt-n5">{{ $options.i18n.noSnippets }}</p> - </template> - </gl-empty-state> + <gl-empty-state + class="gl-mt-5" + :svg-path="snippetsEmptyState" + :svg-height="144" + :title="emptyStateTitle" + :description="emptyStateDescription" + :primary-button-link="newSnippetPath" + :primary-button-text="$options.i18n.newSnippet" + :secondary-button-text="$options.i18n.learnMore" + :secondary-button-link="helpPagePath('user/snippets')" + /> </template> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js index 198ffdb434bbd543c5e3bd9989fbb9c1f184198d..76430d7b34df2ccddef83c67150bdc187cd9a141 100644 --- a/app/assets/javascripts/profile/index.js +++ b/app/assets/javascripts/profile/index.js @@ -21,6 +21,8 @@ export const initProfileTabs = () => { utcOffset, userId, snippetsEmptyState, + newSnippetPath, + followEmptyState, } = el.dataset; const apolloProvider = new VueApollo({ @@ -39,6 +41,8 @@ export const initProfileTabs = () => { utcOffset, userId, snippetsEmptyState, + newSnippetPath, + followEmptyState, }, render(createElement) { return createElement(ProfileTabs); diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 383f482687c1964144de8c7fdde2c6e9356a71c4..29998a996e2d716a7a98c50c925623fb3df1a66a 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -190,7 +190,9 @@ def user_profile_tabs_app_data(user) user_activity_path: user_activity_path(user, :json), utc_offset: local_timezone_instance(user.timezone).now.utc_offset, user_id: user.id, - snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg') + snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg'), + new_snippet_path: (new_snippet_path if can?(current_user, :create_snippet)), + follow_empty_state: image_path('illustrations/empty-state/empty-friends-md.svg') } end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d7ab526bf1bb04473d0bc0bc9ed06b36eba3e4ff..a0d9ede9a6ca883e34a9de48c43bf3d6a9f944ea 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -49898,9 +49898,6 @@ msgstr "" msgid "UserList|created %{timeago}" msgstr "" -msgid "UserProfiles|No snippets found." -msgstr "" - msgid "UserProfile|%{count} %{file}" msgstr "" @@ -49955,6 +49952,9 @@ msgstr "" msgid "UserProfile|Following" msgstr "" +msgid "UserProfile|Get started with snippets" +msgstr "" + msgid "UserProfile|Groups" msgstr "" @@ -49994,15 +49994,24 @@ msgstr "" msgid "UserProfile|Starred projects" msgstr "" +msgid "UserProfile|Store, share, and embed bits of code and text." +msgstr "" + msgid "UserProfile|Subscribe" msgstr "" msgid "UserProfile|There are no projects available to be displayed here." msgstr "" +msgid "UserProfile|This user doesn't have any followers" +msgstr "" + msgid "UserProfile|This user doesn't have any followers." msgstr "" +msgid "UserProfile|This user doesn't have any snippets" +msgstr "" + msgid "UserProfile|This user has a private profile" msgstr "" @@ -50015,6 +50024,9 @@ msgstr "" msgid "UserProfile|This user is blocked" msgstr "" +msgid "UserProfile|This user isn't following other users" +msgstr "" + msgid "UserProfile|This user isn't following other users." msgstr "" @@ -50036,12 +50048,18 @@ msgstr "" msgid "UserProfile|View user in admin area" msgstr "" +msgid "UserProfile|You are not following other users" +msgstr "" + msgid "UserProfile|You are not following other users." msgstr "" msgid "UserProfile|You can create a group for several dependent projects." msgstr "" +msgid "UserProfile|You do not have any followers" +msgstr "" + msgid "UserProfile|You do not have any followers." msgstr "" diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index b4ec00ab76603024fe793a466d9742328c8fb026..444d4a96f9c10d9bd7b053726a8b3d2f5b0ae252 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -1140,4 +1140,38 @@ describe('common_utils', () => { expect(result).toEqual([{ hello: '' }, { helloWorld: '' }]); }); }); + + describe('isCurrentUser', () => { + describe('when user is not signed in', () => { + it('returns `false`', () => { + window.gon.current_user_id = null; + + expect(commonUtils.isCurrentUser(1)).toBe(false); + }); + }); + + describe('when current user id does not match the provided user id', () => { + it('returns `false`', () => { + window.gon.current_user_id = 2; + + expect(commonUtils.isCurrentUser(1)).toBe(false); + }); + }); + + describe('when current user id matches the provided user id', () => { + it('returns `true`', () => { + window.gon.current_user_id = 1; + + expect(commonUtils.isCurrentUser(1)).toBe(true); + }); + }); + + describe('when provided user id is a string and it matches current user id', () => { + it('returns `true`', () => { + window.gon.current_user_id = 1; + + expect(commonUtils.isCurrentUser('1')).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/profile/components/follow_spec.js b/spec/frontend/profile/components/follow_spec.js index 2555e41257fd8f4d1497787f8b1fa34139040930..a2e8d065a462bd5e18412614cc49586514cee4fe 100644 --- a/spec/frontend/profile/components/follow_spec.js +++ b/spec/frontend/profile/components/follow_spec.js @@ -1,11 +1,19 @@ -import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { + GlAvatarLabeled, + GlAvatarLink, + GlEmptyState, + GlLoadingIcon, + GlPagination, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import users from 'test_fixtures/api/users/followers/get.json'; import Follow from '~/profile/components/follow.vue'; import { DEFAULT_PER_PAGE } from '~/api'; +import { isCurrentUser } from '~/lib/utils/common_utils'; jest.mock('~/rest_api'); +jest.mock('~/lib/utils/common_utils'); describe('FollowersTab', () => { let wrapper; @@ -15,6 +23,13 @@ describe('FollowersTab', () => { loading: false, page: 1, totalItems: 50, + currentUserEmptyStateTitle: 'UserProfile|You do not have any followers.', + visitorEmptyStateTitle: "UserProfile|This user doesn't have any followers.", + }; + + const defaultProvide = { + followEmptyState: '/illustrations/empty-state/empty-friends-md.svg', + userId: '1', }; const createComponent = ({ propsData = {} } = {}) => { @@ -23,11 +38,13 @@ describe('FollowersTab', () => { ...defaultPropsData, ...propsData, }, + provide: defaultProvide, }); }; const findPagination = () => wrapper.findComponent(GlPagination); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); describe('when `loading` prop is `true`', () => { it('renders loading icon', () => { @@ -95,5 +112,35 @@ describe('FollowersTab', () => { expect(wrapper.emitted('pagination-input')).toEqual([[nextPage]]); }); }); + + describe('when the users prop is empty', () => { + describe('when user is the current user', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => true); + createComponent({ propsData: { users: [] } }); + }); + + it('displays empty state with correct message', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultProvide.followEmptyState, + title: defaultPropsData.currentUserEmptyStateTitle, + }); + }); + }); + + describe('when user is a visitor', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => false); + createComponent({ propsData: { users: [] } }); + }); + + it('displays empty state with correct message', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultProvide.followEmptyState, + title: defaultPropsData.visitorEmptyStateTitle, + }); + }); + }); + }); }); }); diff --git a/spec/frontend/profile/components/followers_tab_spec.js b/spec/frontend/profile/components/followers_tab_spec.js index 0370005d0a4e238cbe67f0ec9e924cc690268200..75586a2c9eaa4e984629240e809b64a4d2ff46f3 100644 --- a/spec/frontend/profile/components/followers_tab_spec.js +++ b/spec/frontend/profile/components/followers_tab_spec.js @@ -75,6 +75,8 @@ describe('FollowersTab', () => { loading: false, page: 1, totalItems: 6, + currentUserEmptyStateTitle: FollowersTab.i18n.currentUserEmptyStateTitle, + visitorEmptyStateTitle: FollowersTab.i18n.visitorEmptyStateTitle, }); }); diff --git a/spec/frontend/profile/components/following_tab_spec.js b/spec/frontend/profile/components/following_tab_spec.js index 1eadb2c738886a42fc98d2d54e5bc29140b8c9c8..48d84187739e48df250dd547d628985c2df0a126 100644 --- a/spec/frontend/profile/components/following_tab_spec.js +++ b/spec/frontend/profile/components/following_tab_spec.js @@ -68,6 +68,8 @@ describe('FollowingTab', () => { loading: false, page: MOCK_PAGE, totalItems: MOCK_TOTAL_FOLLOWING, + currentUserEmptyStateTitle: FollowingTab.i18n.currentUserEmptyStateTitle, + visitorEmptyStateTitle: FollowingTab.i18n.visitorEmptyStateTitle, }); }); diff --git a/spec/frontend/profile/components/snippets/snippets_tab_spec.js b/spec/frontend/profile/components/snippets/snippets_tab_spec.js index 47e2fbcf2c09ecb5e72ddf78d5abc7e9551d94d6..5992bb03e4d2c694baa313acf5a645f68157d5d7 100644 --- a/spec/frontend/profile/components/snippets/snippets_tab_spec.js +++ b/spec/frontend/profile/components/snippets/snippets_tab_spec.js @@ -7,6 +7,7 @@ import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants'; import SnippetsTab from '~/profile/components/snippets/snippets_tab.vue'; import SnippetRow from '~/profile/components/snippets/snippet_row.vue'; import getUserSnippets from '~/profile/components/graphql/get_user_snippets.query.graphql'; +import { isCurrentUser } from '~/lib/utils/common_utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { @@ -15,8 +16,14 @@ import { MOCK_USER_SNIPPETS_RES, MOCK_USER_SNIPPETS_PAGINATION_RES, MOCK_USER_SNIPPETS_EMPTY_RES, + MOCK_NEW_SNIPPET_PATH, } from 'jest/profile/mock_data'; +jest.mock('~/lib/utils/common_utils'); +jest.mock('~/helpers/help_page_helper', () => ({ + helpPagePath: jest.fn().mockImplementation(() => 'http://127.0.0.1:3000/help/user/snippets'), +})); + Vue.use(VueApollo); describe('UserProfileSnippetsTab', () => { @@ -32,6 +39,7 @@ describe('UserProfileSnippetsTab', () => { provide: { userId: MOCK_USER.id, snippetsEmptyState: MOCK_SNIPPETS_EMPTY_STATE, + newSnippetPath: MOCK_NEW_SNIPPET_PATH, }, }); }; @@ -52,9 +60,38 @@ describe('UserProfileSnippetsTab', () => { expect(findSnippetRows().exists()).toBe(false); }); - it('does render empty state with correct svg', () => { - expect(findGlEmptyState().exists()).toBe(true); - expect(findGlEmptyState().attributes('svgpath')).toBe(MOCK_SNIPPETS_EMPTY_STATE); + describe('when user is the current user', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => true); + createComponent(); + }); + + it('displays empty state with correct message', () => { + expect(findGlEmptyState().props()).toMatchObject({ + svgPath: MOCK_SNIPPETS_EMPTY_STATE, + title: SnippetsTab.i18n.currentUserEmptyStateTitle, + description: SnippetsTab.i18n.emptyStateDescription, + primaryButtonLink: MOCK_NEW_SNIPPET_PATH, + primaryButtonText: SnippetsTab.i18n.newSnippet, + secondaryButtonLink: 'http://127.0.0.1:3000/help/user/snippets', + secondaryButtonText: SnippetsTab.i18n.learnMore, + }); + }); + }); + + describe('when user is a visitor', () => { + beforeEach(() => { + isCurrentUser.mockImplementation(() => false); + createComponent(); + }); + + it('displays empty state with correct message', () => { + expect(findGlEmptyState().props()).toMatchObject({ + svgPath: MOCK_SNIPPETS_EMPTY_STATE, + title: SnippetsTab.i18n.visitorEmptyStateTitle, + description: null, + }); + }); }); }); diff --git a/spec/frontend/profile/mock_data.js b/spec/frontend/profile/mock_data.js index 856534aebd3750dc94c9bcb63943ded6d66c0b5b..6c4ff0a84f95a2f59e145eea685c84eb1102ce5b 100644 --- a/spec/frontend/profile/mock_data.js +++ b/spec/frontend/profile/mock_data.js @@ -22,6 +22,7 @@ export const userCalendarResponse = { }; export const MOCK_SNIPPETS_EMPTY_STATE = 'illustrations/empty-state/empty-snippets-md.svg'; +export const MOCK_NEW_SNIPPET_PATH = '/-/snippets/new'; export const MOCK_USER = { id: '1', diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb index 6ee208dfd15cc64c8a73a603344a48d051b1af59..c0d3c31a36d44f32d75a619abcece50c35654ea9 100644 --- a/spec/helpers/users_helper_spec.rb +++ b/spec/helpers/users_helper_spec.rb @@ -496,13 +496,17 @@ def stub_profile_permission_allowed(allowed, current_user = nil) describe '#user_profile_tabs_app_data' do before do + allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:user_calendar_path).with(user, :json).and_return('/users/root/calendar.json') allow(helper).to receive(:user_activity_path).with(user, :json).and_return('/users/root/activity.json') + allow(helper).to receive(:new_snippet_path).and_return('/-/snippets/new') allow(user).to receive_message_chain(:followers, :count).and_return(2) allow(user).to receive_message_chain(:followees, :count).and_return(3) end it 'returns expected hash' do + allow(helper).to receive(:can?).with(user, :create_snippet).and_return(true) + expect(helper.user_profile_tabs_app_data(user)).to match({ followees_count: 3, followers_count: 2, @@ -510,9 +514,21 @@ def stub_profile_permission_allowed(allowed, current_user = nil) user_activity_path: '/users/root/activity.json', utc_offset: 0, user_id: user.id, - snippets_empty_state: match_asset_path('illustrations/empty-state/empty-snippets-md.svg') + new_snippet_path: '/-/snippets/new', + snippets_empty_state: match_asset_path('illustrations/empty-state/empty-snippets-md.svg'), + follow_empty_state: match_asset_path('illustrations/empty-state/empty-friends-md.svg') }) end + + context 'when user does not have create_snippet permissions' do + before do + allow(helper).to receive(:can?).with(user, :create_snippet).and_return(false) + end + + it 'returns nil for new_snippet_path property' do + expect(helper.user_profile_tabs_app_data(user)[:new_snippet_path]).to be_nil + end + end end describe '#load_max_project_member_accesses' do