diff --git a/pontoon/base/models/comment.py b/pontoon/base/models/comment.py index b31bb0eb45..7ecb88f29c 100644 --- a/pontoon/base/models/comment.py +++ b/pontoon/base/models/comment.py @@ -30,6 +30,7 @@ def serialize(self): return { "author": self.author.name_or_email, "username": self.author.username, + "user_status": self.author.status(self.locale), "user_gravatar_url_small": self.author.gravatar_url(88), "created_at": self.timestamp.strftime("%b %d, %Y %H:%M"), "date_iso": self.timestamp.isoformat(), diff --git a/pontoon/base/models/user.py b/pontoon/base/models/user.py index 05c33723f6..e9e6a1a81c 100644 --- a/pontoon/base/models/user.py +++ b/pontoon/base/models/user.py @@ -1,6 +1,7 @@ from hashlib import md5 from urllib.parse import quote, urlencode +from dateutil.relativedelta import relativedelta from guardian.shortcuts import get_objects_for_user from django.conf import settings @@ -8,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.urls import reverse +from django.utils import timezone from pontoon.actionlog.models import ActionLog @@ -192,6 +194,20 @@ def user_locale_role(self, locale): return "Contributor" +def user_status(self, locale): + if self.username == "Imported": + return ("", "") + if self.is_superuser: + return ("ADMIN", "Admin") + if self in locale.managers_group.user_set.all(): + return ("MNGR", "Manager") + if self in locale.translators_group.user_set.all(): + return ("TRNSL", "Translator") + if self.date_joined >= timezone.now() - relativedelta(months=3): + return ("NEW", "New User") + return ("", "") + + @property def contributed_translations(self): """Filtered contributions provided by user.""" @@ -423,6 +439,7 @@ def latest_action(self): User.add_to_class("translated_projects", user_translated_projects) User.add_to_class("role", user_role) User.add_to_class("locale_role", user_locale_role) +User.add_to_class("status", user_status) User.add_to_class("contributed_translations", contributed_translations) User.add_to_class("top_contributed_locale", top_contributed_locale) User.add_to_class("can_translate", can_translate) diff --git a/pontoon/base/tests/models/test_user.py b/pontoon/base/tests/models/test_user.py index 7f71039bf8..e4d4ac5520 100644 --- a/pontoon/base/tests/models/test_user.py +++ b/pontoon/base/tests/models/test_user.py @@ -61,3 +61,25 @@ def test_user_locale_role(user_a, user_b, user_c, locale_a): # Admin and Manager locale_a.managers_group.user_set.add(user_a) assert user_a.locale_role(locale_a) == "Manager" + + +@pytest.mark.django_db +def test_user_status(user_a, user_b, user_c, locale_a): + # New User + assert user_a.status(locale_a)[1] == "New User" + + # Fake user object + imported = User(username="Imported") + assert imported.status(locale_a)[1] == "" + + # Admin + user_a.is_superuser = True + assert user_a.status(locale_a)[1] == "Admin" + + # Manager + locale_a.managers_group.user_set.add(user_b) + assert user_b.status(locale_a)[1] == "Manager" + + # Translator + locale_a.translators_group.user_set.add(user_c) + assert user_c.status(locale_a)[1] == "Translator" diff --git a/pontoon/base/views.py b/pontoon/base/views.py index 03d43035e5..313ad2b707 100755 --- a/pontoon/base/views.py +++ b/pontoon/base/views.py @@ -420,6 +420,7 @@ def get_translation_history(request): "uid": u.id, "username": u.username, "user_gravatar_url_small": u.gravatar_url(88), + "user_status": u.status(locale), "date": t.date, "approved_user": User.display_name_or_blank(t.approved_user), "approved_date": t.approved_date, @@ -868,6 +869,7 @@ def user_data(request): "display_name": user.display_name, "name_or_email": user.name_or_email, "username": user.username, + "date_joined": user.date_joined, "contributor_for_locales": list( user.translation_set.values_list("locale__code", flat=True).distinct() ), diff --git a/translate/src/api/comment.ts b/translate/src/api/comment.ts index a7eaf708d4..8d6f8c006b 100644 --- a/translate/src/api/comment.ts +++ b/translate/src/api/comment.ts @@ -9,6 +9,7 @@ import { keysToCamelCase } from './utils/keysToCamelCase'; export type TranslationComment = { readonly author: string; readonly username: string; + readonly userStatus: string[]; readonly userGravatarUrlSmall: string; readonly createdAt: string; readonly dateIso: string; diff --git a/translate/src/api/translation.ts b/translate/src/api/translation.ts index 41b8dc5e94..9dc56de83c 100644 --- a/translate/src/api/translation.ts +++ b/translate/src/api/translation.ts @@ -44,6 +44,7 @@ export type HistoryTranslation = { readonly user: string; readonly username: string; readonly userGravatarUrlSmall: string; + readonly userStatus: string[]; readonly comments: Array; }; diff --git a/translate/src/api/user.ts b/translate/src/api/user.ts index 15198f0427..98a25f1b0e 100644 --- a/translate/src/api/user.ts +++ b/translate/src/api/user.ts @@ -30,6 +30,7 @@ export type ApiUserData = { name_or_email?: string; email?: string; username?: string; + date_joined?: string; manager_for_locales?: string[]; translator_for_locales?: string[]; translator_for_projects?: Record; diff --git a/translate/src/hooks/useUserStatus.test.js b/translate/src/hooks/useUserStatus.test.js new file mode 100644 index 0000000000..6381b1b3a4 --- /dev/null +++ b/translate/src/hooks/useUserStatus.test.js @@ -0,0 +1,88 @@ +import React from 'react'; +import sinon from 'sinon'; + +import { USER } from '~/modules/user'; +import * as Hooks from '~/hooks'; + +import { useUserStatus } from './useUserStatus'; + +beforeAll(() => { + sinon.stub(Hooks, 'useAppSelector'); + sinon.stub(React, 'useContext').returns({ code: 'mylocale' }); +}); +afterAll(() => { + Hooks.useAppSelector.restore(); + React.useContext.restore(); +}); + +const fakeSelector = (user) => (sel) => + sel({ + [USER]: user, + }); + +describe('useUserStatus', () => { + it('returns empty parameters for non-authenticated users', () => { + Hooks.useAppSelector.callsFake(fakeSelector({ isAuthenticated: false })), + expect(useUserStatus()).toStrictEqual(['', '']); + }); + + it('returns [ADMIN, Admin] if user has admin permissions', () => { + Hooks.useAppSelector.callsFake( + fakeSelector({ + isAuthenticated: true, + isAdmin: true, + managerForLocales: ['mylocale'], + translatorForLocales: [], + }), + ); + expect(useUserStatus()).toStrictEqual(['ADMIN', 'Admin']); + }); + + it('returns [MNGR, Manager] if user is a manager of the locale', () => { + Hooks.useAppSelector.callsFake( + fakeSelector({ + isAuthenticated: true, + managerForLocales: ['mylocale'], + translatorForLocales: [], + }), + ); + expect(useUserStatus()).toStrictEqual(['MNGR', 'Manager']); + }); + + it('returns [TRNSL, Translator] if user is a translator for the locale', () => { + Hooks.useAppSelector.callsFake( + fakeSelector({ + isAuthenticated: true, + managerForLocales: [], + translatorForLocales: ['mylocale'], + }), + ); + expect(useUserStatus()).toStrictEqual(['TRNSL', 'Translator']); + }); + + it('returns [NEW, New User] if user created their account within the last 3 months', () => { + const dateJoined = new Date(); + dateJoined.setMonth(dateJoined.getMonth() - 2); + Hooks.useAppSelector.callsFake( + fakeSelector({ + isAuthenticated: true, + managerForLocales: [], + translatorForLocales: [], + dateJoined: dateJoined, + }), + ); + expect(useUserStatus()).toStrictEqual(['NEW', 'New User']); + + // Set join date to be 6 months ago (no longer a new user) + dateJoined.setMonth(dateJoined.getMonth() - 6); + Hooks.useAppSelector.callsFake( + fakeSelector({ + isAuthenticated: true, + managerForLocales: [], + translatorForLocales: [], + dateJoined: dateJoined, + }), + ); + expect(useUserStatus()).toStrictEqual(['', '']); + }); +}); diff --git a/translate/src/hooks/useUserStatus.ts b/translate/src/hooks/useUserStatus.ts new file mode 100644 index 0000000000..05589cd795 --- /dev/null +++ b/translate/src/hooks/useUserStatus.ts @@ -0,0 +1,44 @@ +import { useContext } from 'react'; + +import { Locale } from '~/context/Locale'; +import { USER } from '~/modules/user'; +import { useAppSelector } from '~/hooks'; + +/** + * Return the user's status within the given locale, to display on the user banner + */ +export function useUserStatus(): Array { + const { code } = useContext(Locale); + const { + isAuthenticated, + isAdmin, + managerForLocales, + translatorForLocales, + dateJoined, + } = useAppSelector((state) => state[USER]); + + if (!isAuthenticated) { + return ['', '']; + } + + if (isAdmin) { + return ['ADMIN', 'Admin']; + } + + if (managerForLocales.includes(code)) { + return ['MNGR', 'Manager']; + } + + if (translatorForLocales.includes(code)) { + return ['TRNSL', 'Translator']; + } + + const dateJoinedObj = new Date(dateJoined); + let threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + if (dateJoinedObj > threeMonthsAgo) { + return ['NEW', 'New User']; + } + + return ['', '']; +} diff --git a/translate/src/modules/comments/components/AddComment.test.js b/translate/src/modules/comments/components/AddComment.test.js index c4035d3941..30885d798f 100644 --- a/translate/src/modules/comments/components/AddComment.test.js +++ b/translate/src/modules/comments/components/AddComment.test.js @@ -1,4 +1,3 @@ -import { shallow } from 'enzyme'; import React from 'react'; import sinon from 'sinon'; @@ -15,16 +14,22 @@ const USER = { describe('', () => { it('calls submitComment function', () => { + const store = createReduxStore(); const submitCommentFn = sinon.spy(); - const wrapper = shallow( - , + const Wrapper = () => ( + + + ); + const wrapper = mountComponentWithStore(Wrapper, store); const event = { preventDefault: sinon.spy(), }; - wrapper.find('button').simulate('onClick', event); + wrapper.find('button').simulate('click', event); expect(submitCommentFn.calledOnce).toBeTruthy; }); diff --git a/translate/src/modules/comments/components/AddComment.tsx b/translate/src/modules/comments/components/AddComment.tsx index 42b72b4862..d2f353e250 100644 --- a/translate/src/modules/comments/components/AddComment.tsx +++ b/translate/src/modules/comments/components/AddComment.tsx @@ -30,6 +30,7 @@ import type { MentionUser } from '~/api/user'; import { MentionUsers } from '~/context/MentionUsers'; import type { UserState } from '~/modules/user'; import { UserAvatar } from '~/modules/user'; +import { useUserStatus } from '~/hooks/useUserStatus'; import './AddComment.css'; import { MentionList } from './MentionList'; @@ -86,6 +87,7 @@ export function AddComment({ const [mentionIndex, setMentionIndex] = useState(0); const [mentionSearch, setMentionSearch] = useState(''); const [requireUsers, setRequireUsers] = useState(false); + const role = useUserStatus(); const { initMentions, mentionUsers } = useContext(MentionUsers); const [slateKey, resetValue] = useReducer((key) => key + 1, 0); @@ -244,7 +246,11 @@ export function AddComment({ return (
- +
{
diff --git a/translate/src/modules/comments/components/CommentsList.test.js b/translate/src/modules/comments/components/CommentsList.test.js index 5e1d55768b..248bbbc1d9 100644 --- a/translate/src/modules/comments/components/CommentsList.test.js +++ b/translate/src/modules/comments/components/CommentsList.test.js @@ -19,9 +19,9 @@ describe('', () => { translation: { ...DEFAULT_TRANSLATION, comments: [ - { id: 1, content: '11' }, - { id: 2, content: '22' }, - { id: 3, content: '33' }, + { id: 1, content: '11', userStatus: '' }, + { id: 2, content: '22', userStatus: '' }, + { id: 3, content: '33', userStatus: '' }, ], }, user: DEFAULT_USER, diff --git a/translate/src/modules/history/components/History.test.js b/translate/src/modules/history/components/History.test.js index e8789d3762..8796905b0c 100644 --- a/translate/src/modules/history/components/History.test.js +++ b/translate/src/modules/history/components/History.test.js @@ -24,7 +24,11 @@ function mountHistory(fetching, translations) { describe('', () => { it('shows the correct number of translations', () => { - const wrapper = mountHistory(false, [{ pk: 1 }, { pk: 2 }, { pk: 3 }]); + const wrapper = mountHistory(false, [ + { pk: 1, userStatus: '' }, + { pk: 2, userStatus: '' }, + { pk: 3, userStatus: '' }, + ]); expect(wrapper.find('ul > *')).toHaveLength(3); }); diff --git a/translate/src/modules/history/components/HistoryTranslation.css b/translate/src/modules/history/components/HistoryTranslation.css index 510251d9f6..5ffbd0af55 100644 --- a/translate/src/modules/history/components/HistoryTranslation.css +++ b/translate/src/modules/history/components/HistoryTranslation.css @@ -16,21 +16,6 @@ background: var(--dark-grey-1); } -.history .translation .user-avatar { - padding-right: 8px; - position: relative; - top: 3px; -} - -.history .translation .user-avatar img { - border-radius: 6px; - border: 2px solid var(--icon-border-1); -} - -.history .translation:hover .user-avatar img { - border-color: var(--translation-border); -} - .history .translation .content { flex-grow: 1; } diff --git a/translate/src/modules/history/components/HistoryTranslation.tsx b/translate/src/modules/history/components/HistoryTranslation.tsx index fd2ab7c9b6..8655bf7891 100644 --- a/translate/src/modules/history/components/HistoryTranslation.tsx +++ b/translate/src/modules/history/components/HistoryTranslation.tsx @@ -270,6 +270,7 @@ export function HistoryTranslationBase({ username={translation.username} title='' imageUrl={translation.userGravatarUrlSmall} + userStatus={translation.userStatus} /> {translation.machinerySources ? ( diff --git a/translate/src/modules/teamcomments/components/TeamComments.test.js b/translate/src/modules/teamcomments/components/TeamComments.test.js index fccdd8a124..0c006187aa 100644 --- a/translate/src/modules/teamcomments/components/TeamComments.test.js +++ b/translate/src/modules/teamcomments/components/TeamComments.test.js @@ -23,9 +23,9 @@ describe('', () => { teamComments: { entity: 267, comments: [ - { id: 1, content: '11' }, - { id: 2, content: '22' }, - { id: 3, content: '33' }, + { id: 1, content: '11', userStatus: '' }, + { id: 2, content: '22', userStatus: '' }, + { id: 3, content: '33', userStatus: '' }, ], }, user: DEFAULT_USER, diff --git a/translate/src/modules/user/components/UserAvatar.css b/translate/src/modules/user/components/UserAvatar.css new file mode 100644 index 0000000000..e7b1066edf --- /dev/null +++ b/translate/src/modules/user/components/UserAvatar.css @@ -0,0 +1,55 @@ +.history .translation .user-avatar { + padding-right: 8px; + position: relative; + top: 3px; +} + +.history .translation .user-avatar img { + border-radius: 6px; + border: 2px solid var(--icon-border-1); +} + +.history .translation:hover .user-avatar img { + border-color: var(--translation-border); +} + +.user-avatar .avatar-container { + position: relative; + display: inline-block; +} + +.user-avatar .avatar-container .user-status-banner { + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, 20%); + background-color: var(--background-1); + opacity: 0.8; + color: var(--tooltip-color); + font-size: 9px; + line-height: 2; + font-weight: bold; + padding: 0 4px; + border-radius: 4px; + white-space: nowrap; +} + +.comment .user-avatar .avatar-container .user-status-banner { + font-size: 7px; +} + +.user-avatar .avatar-container .user-status-banner.admin { + color: var(--status-error); +} + +.user-avatar .avatar-container .user-status-banner.manager { + color: var(--status-unreviewed); +} + +.user-avatar .avatar-container .user-status-banner.translator { + color: var(--status-translated); +} + +.user-avatar .avatar-container .user-status-banner.new-user { + color: var(--status-fuzzy); +} diff --git a/translate/src/modules/user/components/UserAvatar.tsx b/translate/src/modules/user/components/UserAvatar.tsx index 3fa435485c..9f2682f388 100644 --- a/translate/src/modules/user/components/UserAvatar.tsx +++ b/translate/src/modules/user/components/UserAvatar.tsx @@ -1,14 +1,18 @@ import React from 'react'; import { Localized } from '@fluent/react'; +import './UserAvatar.css'; + type Props = { username: string; title?: string; imageUrl: string; + userStatus: string[]; }; export function UserAvatar(props: Props): React.ReactElement<'div'> { - const { username, title, imageUrl } = props; + const { username, title, imageUrl, userStatus } = props; + const [role, tooltip] = userStatus; return (
@@ -19,9 +23,19 @@ export function UserAvatar(props: Props): React.ReactElement<'div'> { rel='noopener noreferrer' onClick={(e: React.MouseEvent) => e.stopPropagation()} > - - User Profile - +
+ + User Profile + + {role && ( + + {role} + + )} +
); diff --git a/translate/src/modules/user/reducer.ts b/translate/src/modules/user/reducer.ts index 5d9e94f228..ad41813e0c 100644 --- a/translate/src/modules/user/reducer.ts +++ b/translate/src/modules/user/reducer.ts @@ -72,6 +72,7 @@ export type UserState = { readonly nameOrEmail: string; readonly email: string; readonly username: string; + readonly dateJoined: string; readonly contributorForLocales: Array; readonly managerForLocales: Array; readonly translatorForLocales: Array; @@ -95,6 +96,7 @@ const initial: UserState = { nameOrEmail: '', email: '', username: '', + dateJoined: '', contributorForLocales: [], managerForLocales: [], translatorForLocales: [], @@ -125,6 +127,7 @@ export function reducer(state: UserState = initial, action: Action): UserState { nameOrEmail: action.data.name_or_email ?? '', email: action.data.email ?? '', username: action.data.username ?? '', + dateJoined: action.data.date_joined ?? '', contributorForLocales: action.data.contributor_for_locales ?? [], managerForLocales: action.data.manager_for_locales ?? [], translatorForLocales: action.data.translator_for_locales ?? [],