Introduce project manager status banner (#3422)

1. Add a banner for users defined as "Project Manager" within a project. To reduce confusion, the MNGR tooltip has been changed from from "Manager" to "Team Manager".

2. Consolidate roles between backend and frontend:
* If a user as a role within the locale (translator, manager), we use that for the banner
* If a user is set as PM, we use that even if the user is an Admin
* The isAdmin flag is true if the user is a superuser, not PM
* Introduce isPM flag and use it where isAdmin was used before
* Rename: managerForLocales -> canManageLocale, translatorForLocales -> canTranslateLocales
* Introduce managerForLocales and translatorForLocales and use them in UserStatus instead of canManageLocales and canTranslateLocales

3. Other changes:
* Add CSS variables for users, instead of reusing the ones for translation status
* Ignore system users for banners
* Use status identifier as a class name
* The term role is already taken, let's settle for status consistently

---------

Co-authored-by: Matjaž Horvat <matjaz.horvat@gmail.com>
This commit is contained in:
Francesco Lodolo 2024-11-05 15:37:07 +01:00 коммит произвёл GitHub
Родитель 5b4a3c111c
Коммит 4b23a5dfc9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
30 изменённых файлов: 220 добавлений и 94 удалений

Просмотреть файл

@ -461,7 +461,7 @@ textarea.strings-source {
color: var(--status-error);
display: inline-block;
font-size: 12px;
line-height: 14px; /* Strange, but needed to keep controls in line when error occures */
line-height: 14px; /* Strange, but needed to keep controls in line when error occurs */
list-style: none;
margin-left: 0;
margin-top: 2px;

Просмотреть файл

@ -26,12 +26,12 @@ class Comment(models.Model):
def __str__(self):
return self.content
def serialize(self):
def serialize(self, project_contact):
locale = self.locale or self.translation.locale
return {
"author": self.author.name_or_email,
"username": self.author.username,
"user_status": self.author.status(locale),
"user_status": self.author.status(locale, project_contact),
"user_gravatar_url_small": self.author.gravatar_url(88),
"created_at": self.timestamp.strftime("%b %d, %Y %H:%M"),
"date_iso": self.timestamp.isoformat(),

Просмотреть файл

@ -103,7 +103,7 @@ def user_manager_for_locales(self):
@property
def user_translated_locales(self):
def user_can_translate_locales(self):
"""A list of locale codes the user has permission to translate.
Includes all locales for superusers.
@ -114,7 +114,7 @@ def user_translated_locales(self):
@property
def user_managed_locales(self):
def user_can_manage_locales(self):
"""A list of locale codes the user has permission to manage.
Includes all locales for superusers.
@ -164,18 +164,18 @@ def user_role(self, managers=None, translators=None):
if self in managers:
return "Manager for " + ", ".join(managers[self])
else:
if self.managed_locales:
if self.can_manage_locales:
return "Manager for " + ", ".join(
self.managed_locales.values_list("code", flat=True)
self.can_manage_locales.values_list("code", flat=True)
)
if translators is not None:
if self in translators:
return "Translator for " + ", ".join(translators[self])
else:
if self.translated_locales:
if self.can_translate_locales:
return "Translator for " + ", ".join(
self.translated_locales.values_list("code", flat=True)
self.can_translate_locales.values_list("code", flat=True)
)
return "Contributor"
@ -194,13 +194,15 @@ def user_locale_role(self, locale):
return "Contributor"
def user_status(self, locale):
if self.username == "Imported":
def user_status(self, locale, project_contact):
if self.pk is None or self.profile.system_user:
return ("", "")
if self in locale.managers_group.user_set.all():
return ("MNGR", "Manager")
return ("MNGR", "Team Manager")
if self in locale.translators_group.user_set.all():
return ("TRNSL", "Translator")
if project_contact and self.pk == project_contact.pk:
return ("PM", "Project Manager")
if self.is_superuser:
return ("ADMIN", "Admin")
if self.date_joined >= timezone.now() - relativedelta(months=3):
@ -269,7 +271,7 @@ def can_translate(self, locale, project):
from pontoon.base.models.project_locale import ProjectLocale
# Locale managers can translate all projects
if locale in self.managed_locales:
if locale in self.can_manage_locales:
return True
project_locale = ProjectLocale.objects.get(project=project, locale=locale)
@ -465,8 +467,8 @@ User.add_to_class("display_name_and_email", user_display_name_and_email)
User.add_to_class("display_name_or_blank", user_display_name_or_blank)
User.add_to_class("translator_for_locales", user_translator_for_locales)
User.add_to_class("manager_for_locales", user_manager_for_locales)
User.add_to_class("translated_locales", user_translated_locales)
User.add_to_class("managed_locales", user_managed_locales)
User.add_to_class("can_translate_locales", user_can_translate_locales)
User.add_to_class("can_manage_locales", user_can_manage_locales)
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)

Просмотреть файл

@ -5,6 +5,13 @@
--main-border-1: #4d5967;
--moz-logo: url(../img/moz-logo-light.svg);
/* User banner and details */
--user-admin: #ff3366;
--user-pm: #ffa10f;
--user-manager: #4fc4f6;
--user-translator: #7bc876;
--user-new: #fed271;
/* Primary (darker) background */
--background-1: #272a2f;
--background-hover-1: #333941;

Просмотреть файл

@ -4,6 +4,13 @@
--main-border-1: #d8d8d8;
--moz-logo: url(../img/moz-logo.svg);
/* User banner and details */
--user-admin: #ff3366;
--user-pm: #ffa10f;
--user-manager: #4fc4f6;
--user-translator: #7bc876;
--user-new: #fed271;
/* Primary (darker) background */
--background-1: #f6f6f6;
--background-hover-1: #ffffff;

Просмотреть файл

@ -1,17 +1,22 @@
import pytest
from pontoon.test.factories import TeamCommentFactory, TranslationCommentFactory
from pontoon.test.factories import (
ProjectFactory,
TeamCommentFactory,
TranslationCommentFactory,
)
@pytest.mark.django_db
def test_serialize_comments():
tr = TranslationCommentFactory.create()
team = TeamCommentFactory.create()
project = ProjectFactory.create()
assert tr.serialize() == {
assert tr.serialize(project.contact) == {
"author": tr.author.name_or_email,
"username": tr.author.username,
"user_status": tr.author.status(tr.translation.locale),
"user_status": tr.author.status(tr.translation.locale, project.contact),
"user_gravatar_url_small": tr.author.gravatar_url(88),
"created_at": tr.timestamp.strftime("%b %d, %Y %H:%M"),
"date_iso": tr.timestamp.isoformat(),
@ -20,10 +25,10 @@ def test_serialize_comments():
"id": tr.id,
}
assert team.serialize() == {
assert team.serialize(project.contact) == {
"author": team.author.name_or_email,
"username": team.author.username,
"user_status": team.author.status(team.locale),
"user_status": team.author.status(team.locale, project.contact),
"user_gravatar_url_small": team.author.gravatar_url(88),
"created_at": team.timestamp.strftime("%b %d, %Y %H:%M"),
"date_iso": team.timestamp.isoformat(),

Просмотреть файл

@ -64,22 +64,31 @@ def test_user_locale_role(user_a, user_b, user_c, locale_a):
@pytest.mark.django_db
def test_user_status(user_a, user_b, user_c, locale_a):
def test_user_status(user_a, user_b, user_c, user_d, gt_user, locale_a, project_a):
project_contact = user_d
# New User
assert user_a.status(locale_a)[1] == "New User"
assert user_a.status(locale_a, project_contact)[1] == "New User"
# Fake user object
imported = User(username="Imported")
assert imported.status(locale_a)[1] == ""
assert imported.status(locale_a, project_contact)[1] == ""
# Admin
user_a.is_superuser = True
assert user_a.status(locale_a)[1] == "Admin"
assert user_a.status(locale_a, project_contact)[1] == "Admin"
# Manager
locale_a.managers_group.user_set.add(user_b)
assert user_b.status(locale_a)[1] == "Manager"
assert user_b.status(locale_a, project_contact)[1] == "Team Manager"
# Translator
locale_a.translators_group.user_set.add(user_c)
assert user_c.status(locale_a)[1] == "Translator"
assert user_c.status(locale_a, project_contact)[1] == "Translator"
# PM
assert user_d.status(locale_a, project_contact)[1] == "Project Manager"
# System user (Google Translate)
project_contact = gt_user
assert gt_user.status(locale_a, project_contact)[1] == ""

Просмотреть файл

@ -399,6 +399,7 @@ def get_translation_history(request):
entity = get_object_or_404(Entity, pk=entity)
locale = get_object_or_404(Locale, code=locale)
project_contact = entity.resource.project.contact
translations = Translation.objects.filter(
entity=entity,
@ -430,13 +431,13 @@ def get_translation_history(request):
"uid": u.id,
"username": u.username,
"user_gravatar_url_small": u.gravatar_url(88),
"user_status": u.status(locale),
"user_status": u.status(locale, project_contact),
"date": t.date,
"approved_user": User.display_name_or_blank(t.approved_user),
"approved_date": t.approved_date,
"rejected_user": User.display_name_or_blank(t.rejected_user),
"rejected_date": t.rejected_date,
"comments": [c.serialize() for c in t.comments.all()],
"comments": [c.serialize(project_contact) for c in t.comments.all()],
"machinery_sources": t.machinery_sources_values,
}
)
@ -459,13 +460,15 @@ def get_team_comments(request):
entity = get_object_or_404(Entity, pk=entity)
locale = get_object_or_404(Locale, code=locale)
project_contact = entity.resource.project.contact
comments = (
Comment.objects.filter(entity=entity)
.filter(Q(locale=locale) | Q(pinned=True))
.order_by("timestamp")
)
payload = [c.serialize() for c in comments]
payload = [c.serialize(project_contact) for c in comments]
return JsonResponse(payload, safe=False)
@ -873,7 +876,8 @@ def user_data(request):
return JsonResponse(
{
"is_authenticated": True,
"is_admin": user.has_perm("base.can_manage_project"),
"is_admin": user.is_superuser,
"is_pm": user.has_perm("base.can_manage_project"),
"id": user.id,
"email": user.email,
"display_name": user.display_name,
@ -883,12 +887,15 @@ def user_data(request):
"contributor_for_locales": list(
user.translation_set.values_list("locale__code", flat=True).distinct()
),
"manager_for_locales": list(
user.managed_locales.values_list("code", flat=True)
"can_manage_locales": list(
user.can_manage_locales.values_list("code", flat=True)
),
"translator_for_locales": list(
user.translated_locales.values_list("code", flat=True)
"can_translate_locales": list(
user.can_translate_locales.values_list("code", flat=True)
),
"manager_for_locales": [loc.code for loc in user.manager_for_locales],
"translator_for_locales": [loc.code for loc in user.translator_for_locales],
"pm_for_projects": list(user.contact_for.values_list("slug", flat=True)),
"translator_for_projects": user.translated_projects,
"settings": {
"quality_checks": user.profile.quality_checks,

Просмотреть файл

@ -153,11 +153,11 @@ h4 {
}
h4.superuser {
border: 1px solid var(--status-error);
border: 1px solid var(--user-admin);
border-radius: 16px;
line-height: 30px;
text-align: center;
color: var(--status-error);
color: var(--user-admin);
}
/* Approval Ratio */

Просмотреть файл

@ -25,7 +25,7 @@
{% set has_badges = badges.review_master_badge.level or badges.translation_champion_badge.level or badges.community_builder_badge.level %}
{% set translator_for_locales = contributor.translator_for_locales %}
{% set manager_for_locales = contributor.manager_for_locales %}
{% set user_is_translator = user.translated_locales %}
{% set user_is_translator = user.can_translate_locales %}
{% set profile_is_disabled = contributor.is_active == False %}
<a class="avatar" href="{% if is_my_profile %}https://gravatar.com/{% endif %}">
@ -227,7 +227,7 @@
<div class="right-column">
{% set approval_rate_visibile = profile.visibility_approval == "Public" or user_is_translator %}
{% set contributor_is_translator = contributor.translated_locales %}
{% set contributor_is_translator = contributor.can_translate_locales %}
{% set self_approval_rate_visibile = contributor_is_translator and (profile.visibility_self_approval == "Public" or user_is_translator) %}
<div class="clearfix">

Просмотреть файл

@ -129,7 +129,7 @@
<ul class="check-list">
{{ Checkbox.checkbox('Translate Toolkit checks', class='quality-checks', attribute='quality_checks', is_enabled=user.profile.quality_checks, title='Run Translate Toolkit checks before submitting translations') }}
{% if user.translated_locales %}
{% if user.can_translate_locales %}
{{ Checkbox.checkbox('Make suggestions', class='force-suggestions', attribute='force_suggestions', is_enabled=user.profile.force_suggestions, title='Save suggestions instead of translations') }}
{% endif %}
</ul>

Просмотреть файл

@ -95,7 +95,7 @@ def ajax_projects(request, locale):
pretranslation_request_enabled = (
request.user.is_authenticated
and locale in request.user.translated_locales
and locale in request.user.can_translate_locales
and locale.code in settings.GOOGLE_AUTOML_SUPPORTED_LOCALES
and pretranslated_projects.count() < enabled_projects.count()
)
@ -347,7 +347,7 @@ def request_pretranslation(request, locale):
locale = get_object_or_404(Locale, code=locale)
# Validate user
if locale not in user.translated_locales:
if locale not in user.can_translate_locales:
return HttpResponseBadRequest(
"Bad Request: Requester is not a translator or manager for the locale"
)

5
pontoon/test/fixtures/base.py поставляемый
Просмотреть файл

@ -52,6 +52,11 @@ def user_c():
return factories.UserFactory(username="user_c")
@pytest.fixture
def user_d():
return factories.UserFactory(username="user_d")
@pytest.fixture
def member(client, user_a):
"""Provides a `LoggedInMember` with the attributes `user` and `client`

Просмотреть файл

@ -45,7 +45,8 @@ The badge system consists of various badges awarded based on user activities, su
**Icons:**
- **New User Icon:** For users who joined less than 3 months ago.
- **Translator Icon:** For users contributing as translators.
- **Manager Icon:** For users serving as locale managers.
- **Team Manager Icon:** For users serving as locale managers.
- **Project Manager Icon:** For users serving as project managers.
- **Admin Icon:** For users holding administrative roles.
# Technical Specification
@ -78,4 +79,4 @@ The badge system will be integrated into the existing Pontoon interface, with ba
# Out of Scope
### Progress Bar
A visual progress bar for badges with different levels to indicate how close a user is to reaching the next level.
A visual progress bar for badges with different levels to indicate how close a user is to reaching the next level.

Просмотреть файл

@ -25,15 +25,20 @@ export type Notification = {
export type ApiUserData = {
is_authenticated?: boolean;
is_admin?: boolean;
is_pm?: boolean;
id?: string;
display_name?: string;
name_or_email?: string;
email?: string;
username?: string;
date_joined?: string;
can_manage_locales?: string[];
can_translate_locales?: string[];
manager_for_locales?: string[];
translator_for_locales?: string[];
contributor_for_locales?: string[];
translator_for_projects?: Record<string, boolean>;
pm_for_projects?: string[];
settings?: { quality_checks: boolean; force_suggestions: boolean };
tour_status?: number;
has_dismissed_addon_promotion?: boolean;

Просмотреть файл

@ -32,8 +32,8 @@ describe('useTranslator', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
managerForLocales: ['mylocale'],
translatorForLocales: [],
canManageLocales: ['mylocale'],
canTranslateLocales: [],
translatorForProjects: {},
}),
);
@ -44,8 +44,8 @@ describe('useTranslator', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
managerForLocales: [],
translatorForLocales: ['mylocale'],
canManageLocales: [],
canTranslateLocales: ['mylocale'],
translatorForProjects: {},
}),
);
@ -56,8 +56,8 @@ describe('useTranslator', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
managerForLocales: ['localeA'],
translatorForLocales: ['localeB'],
canManageLocales: ['localeA'],
canTranslateLocales: ['localeB'],
translatorForProjects: { 'mylocale-myproject': true },
}),
);

Просмотреть файл

@ -14,8 +14,8 @@ export function useTranslator(): boolean {
const { slug } = useProject();
const {
isAuthenticated,
managerForLocales,
translatorForLocales,
canManageLocales,
canTranslateLocales,
translatorForProjects,
} = useAppSelector((state) => state[USER]);
@ -23,7 +23,7 @@ export function useTranslator(): boolean {
return false;
}
if (managerForLocales.includes(code)) {
if (canManageLocales.includes(code)) {
return true;
}
@ -32,5 +32,5 @@ export function useTranslator(): boolean {
return translatorForProjects[localeProject];
}
return translatorForLocales.includes(code);
return canTranslateLocales.includes(code);
}

Просмотреть файл

@ -8,7 +8,9 @@ import { useUserStatus } from './useUserStatus';
beforeAll(() => {
sinon.stub(Hooks, 'useAppSelector');
sinon.stub(React, 'useContext').returns({ code: 'mylocale' });
sinon
.stub(React, 'useContext')
.returns({ code: 'mylocale', project: 'myproject' });
});
afterAll(() => {
Hooks.useAppSelector.restore();
@ -31,28 +33,81 @@ describe('useUserStatus', () => {
fakeSelector({
isAuthenticated: true,
isAdmin: true,
managerForLocales: ['mylocale'],
pmForProjects: [],
managerForLocales: [],
translatorForLocales: [],
}),
);
expect(useUserStatus()).toStrictEqual(['ADMIN', 'Admin']);
});
it('returns [PM, Project Manager] if user is a project manager for the project', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
pmForProjects: ['myproject'],
managerForLocales: [],
translatorForLocales: [],
}),
);
expect(useUserStatus()).toStrictEqual(['PM', 'Project Manager']);
});
it('returns [PM, Project Manager] if user is a project manager for the project, even if user is an Admin', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
isAdmin: true,
pmForProjects: ['myproject'],
managerForLocales: [],
translatorForLocales: [],
}),
);
expect(useUserStatus()).toStrictEqual(['PM', 'Project Manager']);
});
it('returns [MNGR, Manager] if user is a manager of the locale', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
pmForProjects: [],
managerForLocales: ['mylocale'],
translatorForLocales: [],
}),
);
expect(useUserStatus()).toStrictEqual(['MNGR', 'Manager']);
expect(useUserStatus()).toStrictEqual(['MNGR', 'Team Manager']);
});
it('returns [MNGR, Manager] if user is a manager of the locale, even if user is an Admin', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
isAdmin: true,
pmForProjects: [],
managerForLocales: ['mylocale'],
translatorForLocales: [],
}),
);
expect(useUserStatus()).toStrictEqual(['MNGR', 'Team Manager']);
});
it('returns [MNGR, Manager] if user is a manager of the locale, even if user is a Project Manager', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
pmForProjects: ['myproject'],
managerForLocales: ['mylocale'],
translatorForLocales: [],
}),
);
expect(useUserStatus()).toStrictEqual(['MNGR', 'Team Manager']);
});
it('returns [TRNSL, Translator] if user is a translator for the locale', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
pmForProjects: [],
managerForLocales: [],
translatorForLocales: ['mylocale'],
}),
@ -66,6 +121,7 @@ describe('useUserStatus', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
pmForProjects: [],
managerForLocales: [],
translatorForLocales: [],
dateJoined: dateJoined,
@ -78,6 +134,7 @@ describe('useUserStatus', () => {
Hooks.useAppSelector.callsFake(
fakeSelector({
isAuthenticated: true,
pmForProjects: [],
managerForLocales: [],
translatorForLocales: [],
dateJoined: dateJoined,

Просмотреть файл

@ -1,6 +1,7 @@
import { useContext } from 'react';
import { Locale } from '~/context/Locale';
import { Location } from '~/context/Location';
import { USER } from '~/modules/user';
import { useAppSelector } from '~/hooks';
@ -9,11 +10,13 @@ import { useAppSelector } from '~/hooks';
*/
export function useUserStatus(): Array<string> {
const { code } = useContext(Locale);
const { project } = useContext(Location);
const {
isAuthenticated,
isAdmin,
managerForLocales,
translatorForLocales,
pmForProjects,
dateJoined,
} = useAppSelector((state) => state[USER]);
@ -21,18 +24,23 @@ export function useUserStatus(): Array<string> {
return ['', ''];
}
if (isAdmin) {
return ['ADMIN', 'Admin'];
}
// Check status within the locale before checking for Project Manager or Admin
if (managerForLocales.includes(code)) {
return ['MNGR', 'Manager'];
return ['MNGR', 'Team Manager'];
}
if (translatorForLocales.includes(code)) {
return ['TRNSL', 'Translator'];
}
if (pmForProjects.includes(project)) {
return ['PM', 'Project Manager'];
}
if (isAdmin) {
return ['ADMIN', 'Admin'];
}
const dateJoinedObj = new Date(dateJoined);
let threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);

Просмотреть файл

@ -87,7 +87,7 @@ export function AddComment({
const [mentionIndex, setMentionIndex] = useState(0);
const [mentionSearch, setMentionSearch] = useState('');
const [requireUsers, setRequireUsers] = useState(false);
const role = useUserStatus();
const status = useUserStatus();
const { initMentions, mentionUsers } = useContext(MentionUsers);
const [slateKey, resetValue] = useReducer((key) => key + 1, 0);
@ -249,7 +249,7 @@ export function AddComment({
<UserAvatar
username={username}
imageUrl={gravatarURLSmall}
userStatus={role}
userStatus={status}
/>
<div className='container'>
<Slate

Просмотреть файл

@ -19,7 +19,7 @@ jest.mock('react-redux', () => ({
selector({
user: {
isAuthenticated: true,
managerForLocales: ['en'],
canManageLocales: ['en'],
},
}),
}));

Просмотреть файл

@ -67,7 +67,7 @@ describe('<FailedChecks>', () => {
it('renders suggest anyway button if user does not have sufficient permissions', () => {
const wrapper = mountFailedChecks(
{ errors: [], warnings: ['a warning'], source: 'submitted' },
{ manager_for_locales: [] },
{ can_manage_locales: [] },
);
expect(wrapper.find('.suggest.anyway')).toHaveLength(1);

Просмотреть файл

@ -141,7 +141,7 @@ describe('<EntitiesList>', () => {
});
// HACK to get isTranslator === true in Entity
createDefaultUser(store, { translator_for_locales: [''] });
createDefaultUser(store, { can_translate_locales: [''] });
const wrapper = mountComponentWithStore(EntitiesList, store);

Просмотреть файл

@ -47,7 +47,7 @@ export function TeamComments({
const renderComment = (comment: TranslationComment) => (
<Comment
comment={comment}
canPin={user.isAdmin}
canPin={user.isPM}
key={comment.id}
togglePinnedStatus={togglePinnedStatus}
/>

Просмотреть файл

@ -38,18 +38,22 @@
font-size: 7px;
}
.user-avatar .avatar-container .user-status-banner.admin {
color: var(--status-error);
.user-avatar .avatar-container .user-status-banner.ADMIN {
color: var(--user-admin);
}
.user-avatar .avatar-container .user-status-banner.manager {
color: var(--status-unreviewed);
.user-avatar .avatar-container .user-status-banner.PM {
color: var(--user-pm);
}
.user-avatar .avatar-container .user-status-banner.translator {
color: var(--status-translated);
.user-avatar .avatar-container .user-status-banner.MNGR {
color: var(--user-manager);
}
.user-avatar .avatar-container .user-status-banner.new-user {
color: var(--status-fuzzy);
.user-avatar .avatar-container .user-status-banner.TRNSL {
color: var(--user-translator);
}
.user-avatar .avatar-container .user-status-banner.NEW {
color: var(--user-new);
}

Просмотреть файл

@ -12,7 +12,7 @@ type Props = {
export function UserAvatar(props: Props): React.ReactElement<'div'> {
const { username, title, imageUrl, userStatus } = props;
const [role, tooltip] = userStatus;
const [status, tooltip] = userStatus;
return (
<div className='user-avatar'>
@ -27,12 +27,9 @@ export function UserAvatar(props: Props): React.ReactElement<'div'> {
<Localized id='user-UserAvatar--alt-text' attrs={{ alt: true }}>
<img src={imageUrl} alt='User Profile' height='44' width='44' />
</Localized>
{role && (
<span
className={`user-status-banner ${tooltip.toLowerCase().split(' ').join('-')}`}
title={tooltip}
>
{role}
{status && (
<span className={`user-status-banner ${status}`} title={tooltip}>
{status}
</span>
)}
</div>

Просмотреть файл

@ -33,7 +33,7 @@ describe('<UserMenuDialog>', () => {
};
function createUserMenu({
isAdmin = false,
isPM = false,
isReadOnly = false,
isTranslator = true,
isAuthenticated = true,
@ -46,7 +46,7 @@ describe('<UserMenuDialog>', () => {
<EntityView.Provider
value={{ entity: { pk: 42, readonly: isReadOnly } }}
>
<UserMenuDialog user={{ isAuthenticated, isAdmin }} />
<UserMenuDialog user={{ isAuthenticated, isPM }} />
</EntityView.Provider>
</MockLocalizationProvider>
</Location.Provider>,
@ -92,7 +92,7 @@ describe('<UserMenuDialog>', () => {
it('hides admin · current project menu item when translating all projects', () => {
const wrapper = createUserMenu({
location: { ...LOCATION, project: 'all-projects' },
isAdmin: true,
isPM: true,
});
expect(
@ -101,7 +101,7 @@ describe('<UserMenuDialog>', () => {
});
it('shows admin · current project menu item when translating a project', () => {
const wrapper = createUserMenu({ isAdmin: true });
const wrapper = createUserMenu({ isPM: true });
expect(wrapper.find('a[href="/admin/projects/proj/"]')).toHaveLength(1);
});
@ -130,7 +130,7 @@ describe('<UserMenuDialog>', () => {
});
it('shows the admin menu items when the user is an admin', () => {
const wrapper = createUserMenu({ isAdmin: true });
const wrapper = createUserMenu({ isPM: true });
expect(wrapper.find('a[href="/admin/"]')).toHaveLength(1);
expect(wrapper.find('a[href="/admin/projects/proj/"]')).toHaveLength(1);

Просмотреть файл

@ -230,7 +230,7 @@ export function UserMenuDialog({
{user.isAuthenticated && <li className='horizontal-separator'></li>}
{user.isAdmin && (
{user.isPM && (
<>
<li>
<Localized

Просмотреть файл

@ -67,16 +67,20 @@ export type Notifications = {
export type UserState = {
readonly isAuthenticated: boolean | null; // null while loading
readonly isAdmin: boolean;
readonly isPM: boolean;
readonly id: string;
readonly displayName: string;
readonly nameOrEmail: string;
readonly email: string;
readonly username: string;
readonly dateJoined: string;
readonly contributorForLocales: Array<string>;
readonly canManageLocales: Array<string>;
readonly canTranslateLocales: Array<string>;
readonly managerForLocales: Array<string>;
readonly translatorForLocales: Array<string>;
readonly contributorForLocales: Array<string>;
readonly translatorForProjects: Record<string, boolean>;
readonly pmForProjects: Array<string>;
readonly settings: SettingsState;
readonly tourStatus: number | null | undefined;
readonly hasDismissedAddonPromotion: boolean;
@ -91,16 +95,20 @@ export type UserState = {
const initial: UserState = {
isAuthenticated: null,
isAdmin: false,
isPM: false,
id: '',
displayName: '',
nameOrEmail: '',
email: '',
username: '',
dateJoined: '',
contributorForLocales: [],
canManageLocales: [],
canTranslateLocales: [],
managerForLocales: [],
translatorForLocales: [],
contributorForLocales: [],
translatorForProjects: {},
pmForProjects: [],
settings: initialSettings,
tourStatus: null,
hasDismissedAddonPromotion: false,
@ -122,16 +130,20 @@ export function reducer(state: UserState = initial, action: Action): UserState {
return {
isAuthenticated: action.data.is_authenticated ?? null,
isAdmin: action.data.is_admin ?? false,
isPM: action.data.is_pm ?? false,
id: action.data.id ?? '',
email: action.data.email ?? '',
displayName: action.data.display_name ?? '',
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 ?? [],
canManageLocales: action.data.can_manage_locales ?? [],
canTranslateLocales: action.data.can_translate_locales ?? [],
managerForLocales: action.data.manager_for_locales ?? [],
translatorForLocales: action.data.translator_for_locales ?? [],
contributorForLocales: action.data.contributor_for_locales ?? [],
translatorForProjects: action.data.translator_for_projects ?? {},
pmForProjects: action.data.pm_for_projects ?? [],
settings: settings(state.settings, action),
tourStatus: action.data.tour_status ?? null,
hasDismissedAddonPromotion:

Просмотреть файл

@ -53,8 +53,8 @@ export function createDefaultUser(store, initial = {}) {
settings: { force_suggestions: false },
username: 'Franck',
is_authenticated: true,
manager_for_locales: ['kg'],
translator_for_locales: [],
can_manage_locales: ['kg'],
can_translate_locales: [],
translator_for_projects: {},
...initial,
};