Skip to content

Commit a1a1d01

Browse files
authored
Sort @-mention results by locale and project activity (mozilla#3997)
Sort get users results by locale and project Adds locale and project parameters to the endpoint and use them to annotate each user with their translation activity. Results are then ordered by that activity. The frontend is updated to pass the locale and project when calling the endpoint Fixes mozilla#2548
1 parent e170ca5 commit a1a1d01

6 files changed

Lines changed: 187 additions & 16 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import json
2+
3+
import pytest
4+
5+
from django.contrib.auth.models import User
6+
7+
from pontoon.base.views import get_users
8+
from pontoon.test.factories import (
9+
EntityFactory,
10+
LocaleFactory,
11+
ProjectFactory,
12+
ResourceFactory,
13+
TranslationFactory,
14+
UserFactory,
15+
)
16+
17+
18+
@pytest.mark.django_db
19+
def test_get_users_excludes_system_users(rf, admin):
20+
request = rf.get("/get-users/", HTTP_X_REQUESTED_WITH="XMLHttpRequest")
21+
request.user = admin
22+
response = get_users(request)
23+
data = json.loads(response.content)
24+
usernames = [u["username"] for u in data]
25+
26+
system_users = User.objects.filter(profile__system_user=True)
27+
for u in system_users:
28+
assert u.username not in usernames
29+
30+
31+
@pytest.mark.django_db
32+
def test_get_users_sorted_by_locale_activity(rf, admin):
33+
locale = LocaleFactory()
34+
project = ProjectFactory(locales=[locale])
35+
resource = ResourceFactory(project=project)
36+
entity = EntityFactory(resource=resource)
37+
38+
other_locale = LocaleFactory()
39+
40+
# create a user that is from a different locale, not the current one
41+
other_locale_user = UserFactory(first_name="Michael", last_name="OtherLocale")
42+
TranslationFactory(user=other_locale_user, locale=other_locale, entity=entity)
43+
44+
# create a user active in the current locale
45+
locale_user = UserFactory(first_name="Alice", last_name="ActiveLocale")
46+
TranslationFactory(user=locale_user, locale=locale, entity=entity)
47+
48+
request = rf.get(
49+
"/get-users/",
50+
{"locale": locale.code, "project": project.pk},
51+
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
52+
)
53+
54+
request.user = admin
55+
response = get_users(request)
56+
data = json.loads(response.content)
57+
58+
names = [u["name"] for u in data]
59+
assert names.index(locale_user.name_or_email) < names.index(
60+
other_locale_user.name_or_email
61+
)
62+
63+
64+
# project & locale activity should be prioritized over alphabetic order
65+
# for users that are both active in the project or locale
66+
67+
68+
@pytest.mark.django_db
69+
def test_get_users_sorted_by_project_and_locale_activity(rf, admin):
70+
locale = LocaleFactory()
71+
project = ProjectFactory(locales=[locale])
72+
resource = ResourceFactory(project=project)
73+
entity = EntityFactory(resource=resource)
74+
75+
# create a user that is active in the current locale and project
76+
active_user = UserFactory(first_name="Zara", last_name="ActiveUser")
77+
TranslationFactory(user=active_user, locale=locale, entity=entity)
78+
79+
# create a user that is active in the current locale but not the project
80+
other_project_user = UserFactory(first_name="Bob", last_name="OtherProject")
81+
TranslationFactory(user=other_project_user, locale=locale)
82+
83+
# create a user that is active in the current project but not the locale
84+
other_locale_user = UserFactory(first_name="Charlie", last_name="OtherLocale")
85+
TranslationFactory(user=other_locale_user, entity=entity)
86+
87+
request = rf.get(
88+
"/get-users/",
89+
{"locale": locale.code, "project": project.pk},
90+
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
91+
)
92+
93+
request.user = admin
94+
response = get_users(request)
95+
data = json.loads(response.content)
96+
97+
names = [u["name"] for u in data]
98+
assert (
99+
names.index(active_user.name_or_email)
100+
< names.index(other_project_user.name_or_email)
101+
< names.index(other_locale_user.name_or_email)
102+
)

pontoon/base/views.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from django.contrib.auth.models import User
1515
from django.core.paginator import EmptyPage, Paginator
1616
from django.db import transaction
17-
from django.db.models import F, Prefetch, Q
17+
from django.db.models import Count, F, Prefetch, Q
1818
from django.http import (
1919
Http404,
2020
HttpResponse,
@@ -741,7 +741,12 @@ def unpin_comment(request):
741741
@utils.require_AJAX
742742
@login_required(redirect_field_name="", login_url="/403")
743743
def get_users(request):
744-
"""Get all users."""
744+
"""Get all users sorted by locale and project."""
745+
locale_code = request.GET.get("locale")
746+
project_id = request.GET.get("project")
747+
if project_id:
748+
project_id = int(project_id)
749+
745750
users = (
746751
User.objects
747752
# Exclude system users
@@ -750,6 +755,16 @@ def get_users(request):
750755
.exclude(email__regex=r"^deleted-user-(\w+)@example.com$")
751756
# Prefetch profile for retrieving username
752757
.prefetch_related("profile")
758+
.annotate(
759+
in_locale=Count(
760+
"translation", filter=Q(translation__locale__code=locale_code)
761+
),
762+
in_project=Count(
763+
"translation",
764+
filter=Q(translation__entity__resource__project__id=project_id),
765+
),
766+
)
767+
.order_by("-in_locale", "-in_project", "first_name", "last_name")
753768
)
754769
payload = []
755770

translate/src/api/user.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,17 @@ export const dismissAddonPromotion = (): Promise<void> =>
6969
/** Return data about the current user from the server. */
7070
export const fetchUserData = (): Promise<ApiUserData> => GET('/user-data/');
7171

72-
/** Get all users from server. */
73-
export const fetchUsersList = (): Promise<MentionUser[]> => GET('/get-users/');
72+
/** Get all users from server, sorted by activity in the current locale and project. */
73+
export const fetchUsersList = (
74+
locale: string,
75+
projectId: number,
76+
): Promise<MentionUser[]> => {
77+
const params = new URLSearchParams();
78+
if (locale) params.append('locale', locale);
79+
if (projectId) params.append('project', String(projectId));
80+
81+
return GET(`/get-users/?${params}`);
82+
};
7483

7584
/** Mark all notifications of the current user as read. */
7685
export const markAllNotificationsAsRead = (): Promise<void> =>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React, { useContext, useEffect } from 'react';
2+
import { render } from '@testing-library/react';
3+
import { vi } from 'vitest';
4+
5+
import { MentionUsers, MentionUsersProvider } from './MentionUsers';
6+
import { fetchUsersList } from '~/api/user';
7+
8+
vi.mock('~/api/user', () => ({
9+
fetchUsersList: vi.fn(() => Promise.resolve([])),
10+
}));
11+
12+
describe('MentionUsersProvider', () => {
13+
it('fetches users with locale and project from Location context', () => {
14+
const Trigger = () => {
15+
const { initMentions } = useContext(MentionUsers);
16+
useEffect(() => {
17+
initMentions('sl', 42);
18+
}, []);
19+
return null;
20+
};
21+
22+
render(
23+
<MentionUsersProvider>
24+
<Trigger />
25+
</MentionUsersProvider>,
26+
);
27+
28+
expect(fetchUsersList).toHaveBeenCalledWith('sl', 42);
29+
});
30+
});

translate/src/context/MentionUsers.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ type MentionUsers = {
77
*
88
* May be called multiple times, but data will only be fetched once.
99
*/
10-
initMentions(): void;
10+
initMentions(locale: string, projectId: number): void;
1111
mentionUsers: MentionUser[];
1212
};
1313

@@ -21,18 +21,28 @@ export function MentionUsersProvider({
2121
}: {
2222
children: React.ReactElement;
2323
}) {
24-
const [mentionUsers, setUsers] = useState<MentionUser[]>([]);
25-
const [initMentions, setInit] = useState(() => () => {
26-
setInit(() => () => {});
27-
fetchUsersList().then((list) => {
28-
if (Array.isArray(list)) {
29-
setUsers(list);
24+
const [state, setState] = useState<{
25+
projectId: number;
26+
mentionUsers: MentionUser[];
27+
} | null>(null);
28+
29+
const initMentions = useMemo(
30+
() => (locale: string, projectId: number) => {
31+
if (state?.projectId === projectId) {
32+
return;
3033
}
31-
});
32-
});
34+
fetchUsersList(locale, projectId).then((list) => {
35+
if (Array.isArray(list)) {
36+
setState({ projectId, mentionUsers: list });
37+
}
38+
});
39+
},
40+
[],
41+
);
42+
3343
const value = useMemo(
34-
() => ({ initMentions, mentionUsers }),
35-
[initMentions, mentionUsers],
44+
() => ({ initMentions, mentionUsers: state?.mentionUsers ?? [] }),
45+
[state],
3646
);
3747
return (
3848
<MentionUsers.Provider value={value}>{children}</MentionUsers.Provider>

translate/src/modules/comments/components/AddComment.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
withReact,
2727
} from 'slate-react';
2828

29+
import { Location } from '~/context/Location';
30+
import { EntityView } from '~/context/EntityView';
2931
import type { MentionUser } from '~/api/user';
3032
import { MentionUsers } from '~/context/MentionUsers';
3133
import type { UserState } from '~/modules/user';
@@ -116,7 +118,10 @@ export function AddComment({
116118
[editor],
117119
);
118120

119-
useEffect(initMentions, []);
121+
const { entity } = useContext(EntityView);
122+
const { locale } = useContext(Location);
123+
124+
useEffect(() => initMentions(locale, entity.project.pk), []);
120125

121126
// Insert project manager as mention when 'Request context / Report issue' button used
122127
// and then clear the value from state

0 commit comments

Comments
 (0)