Skip to content

Commit 9e10cdb

Browse files
authored
Add advanced search options to user settings (#4012)
1 parent 52cff6e commit 9e10cdb

File tree

18 files changed

+649
-148
lines changed

18 files changed

+649
-148
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 4.2.28 on 2026-03-04 07:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("base", "0106_create_project_managers_group"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="userprofile",
14+
name="search_exclude_source_strings",
15+
field=models.BooleanField(default=False),
16+
),
17+
migrations.AddField(
18+
model_name="userprofile",
19+
name="search_identifiers",
20+
field=models.BooleanField(default=False),
21+
),
22+
migrations.AddField(
23+
model_name="userprofile",
24+
name="search_match_case",
25+
field=models.BooleanField(default=False),
26+
),
27+
migrations.AddField(
28+
model_name="userprofile",
29+
name="search_match_whole_word",
30+
field=models.BooleanField(default=False),
31+
),
32+
migrations.AddField(
33+
model_name="userprofile",
34+
name="search_rejected_translations",
35+
field=models.BooleanField(default=False),
36+
),
37+
]

pontoon/base/models/user_profile.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ class EmailFrequencies(models.TextChoices):
111111
quality_checks = models.BooleanField(default=True)
112112
force_suggestions = models.BooleanField(default=False)
113113

114+
# Search settings
115+
search_exclude_source_strings = models.BooleanField(default=False)
116+
search_identifiers = models.BooleanField(default=False)
117+
search_match_case = models.BooleanField(default=False)
118+
search_match_whole_word = models.BooleanField(default=False)
119+
search_rejected_translations = models.BooleanField(default=False)
120+
114121
# Used to redirect a user to a custom team page.
115122
custom_homepage = models.CharField(max_length=20, blank=True, null=True)
116123

pontoon/base/views.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
TranslatedResource,
4545
Translation,
4646
TranslationMemoryEntry,
47+
UserProfile,
4748
)
4849
from pontoon.base.templatetags.helpers import provider_login_url
4950
from pontoon.checks.libraries import run_checks
@@ -290,6 +291,18 @@ def entities(request):
290291
k: form.cleaned_data[k] for k in restrict_to_keys if k in form.cleaned_data
291292
}
292293

294+
# If search option params are not specified in the URL, read the default
295+
# value from the model field (avoid specifying the default in two places).
296+
for name in (
297+
"search_identifiers",
298+
"search_exclude_source_strings",
299+
"search_rejected_translations",
300+
"search_match_case",
301+
"search_match_whole_word",
302+
):
303+
if name not in request.POST:
304+
form_data[name] = UserProfile._meta.get_field(name).get_default()
305+
293306
try:
294307
entities = Entity.for_project_locale(request.user, project, locale, **form_data)
295308
except ValueError as error:
@@ -979,6 +992,11 @@ def user_data(request):
979992
"settings": {
980993
"quality_checks": user.profile.quality_checks,
981994
"force_suggestions": user.profile.force_suggestions,
995+
"search_exclude_source_strings": user.profile.search_exclude_source_strings,
996+
"search_identifiers": user.profile.search_identifiers,
997+
"search_match_case": user.profile.search_match_case,
998+
"search_match_whole_word": user.profile.search_match_whole_word,
999+
"search_rejected_translations": user.profile.search_rejected_translations,
9821000
},
9831001
"tour_status": user.profile.tour_status,
9841002
"has_dismissed_addon_promotion": user.profile.has_dismissed_addon_promotion,

pontoon/contributors/templates/contributors/settings.html

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,56 @@ <h3>Editor</h3>
358358
</div>
359359
</section>
360360

361+
<section id="default-search-options">
362+
<h3>Default search options</h3>
363+
<div class="check-list">
364+
{{
365+
Checkbox.checkbox(
366+
'Match case',
367+
class='search-match-case',
368+
attribute='search_match_case',
369+
is_enabled=user.profile.search_match_case,
370+
title='Match case when searching')
371+
}}
372+
373+
{{
374+
Checkbox.checkbox(
375+
'Match whole word',
376+
class='search-match-whole-word',
377+
attribute='search_match_whole_word',
378+
is_enabled=user.profile.search_match_whole_word,
379+
title='Match whole word when searching')
380+
}}
381+
382+
{{
383+
Checkbox.checkbox(
384+
'Search in string identifiers',
385+
class='search-identifiers',
386+
attribute='search_identifiers',
387+
is_enabled=user.profile.search_identifiers,
388+
title='Include string identifiers when searching')
389+
}}
390+
391+
{{
392+
Checkbox.checkbox(
393+
'Include rejected translations',
394+
class='search-rejected-translations',
395+
attribute='search_rejected_translations',
396+
is_enabled=user.profile.search_rejected_translations,
397+
title='Include rejected translations when searching')
398+
}}
399+
400+
{{
401+
Checkbox.checkbox(
402+
'Exclude source strings',
403+
class='search-exclude-source-strings',
404+
attribute='search_exclude_source_strings',
405+
is_enabled=user.profile.search_exclude_source_strings,
406+
title='Exclude source strings when searching')
407+
}}
408+
</div>
409+
</section>
410+
361411
<section>
362412
<h3>Default locales</h3>
363413
<div id="locale-settings" class="clearfix">

pontoon/contributors/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,12 @@ def toggle_user_profile_attribute(request):
263263
# Editor settings
264264
"quality_checks",
265265
"force_suggestions",
266+
# Search settings
267+
"search_exclude_source_strings",
268+
"search_identifiers",
269+
"search_match_case",
270+
"search_match_whole_word",
271+
"search_rejected_translations",
266272
# In-app notifications
267273
"new_string_notifications",
268274
"project_deadline_notifications",

pontoon/search/views.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,20 @@ def create_api_url(
5454
return f"{SITE_URL}/api/v2/search/translations/?{urlencode(query_params)}"
5555

5656

57+
def get_search_option(request, name):
58+
"""Return a search option from the URL, falling back to the user's profile setting."""
59+
if name in request.GET:
60+
return parse_bool(request.GET.get(name))
61+
return request.user.is_authenticated and getattr(request.user.profile, name)
62+
63+
5764
def search(request):
5865
"""Render the search page with filters for searching entities."""
5966
locale_code = request.GET.get("locale")
6067
project_slug = request.GET.get("project")
61-
search_identifiers = parse_bool(request.GET.get("search_identifiers"))
62-
search_match_case = parse_bool(request.GET.get("search_match_case"))
63-
search_match_whole_word = parse_bool(request.GET.get("search_match_whole_word"))
68+
search_identifiers = get_search_option(request, "search_identifiers")
69+
search_match_case = get_search_option(request, "search_match_case")
70+
search_match_whole_word = get_search_option(request, "search_match_whole_word")
6471

6572
projects = list(
6673
Project.objects.visible()

translate/public/locale/en-US/translate.ftl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,9 @@ search-SearchPanel--heading = SEARCH OPTIONS
687687
search-SearchPanel--apply-search-options = APPLY SEARCH OPTIONS
688688
.title = Apply Selected Search Options
689689
690+
search-SearchPanel--restore-default-options = Restore default options
691+
search-SearchPanel--change-default-search-settings = Change default search settings
692+
690693
## Time Range Filter
691694
## Time Range filter title, input fields and chart.
692695

translate/src/api/entity.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Location } from '~/context/Location';
2+
import { SEARCH_OPTION_KEYS } from '~/modules/search/constants';
23

34
import { GET, POST } from './utils/base';
45
import { getCSRFToken } from './utils/csrfToken';
@@ -124,14 +125,17 @@ function buildFetchPayload(
124125
if (entity) {
125126
payload.append('entity', String(entity));
126127
}
128+
// Explicit values (true or false) are sent as-is; undefined (not provided in
129+
// the URL) are not sent so the backend can apply the default.
130+
for (const key of SEARCH_OPTION_KEYS) {
131+
const value = location[key];
132+
if (value !== undefined) {
133+
payload.append(key, String(value));
134+
}
135+
}
127136
for (const key of [
128137
'search',
129138
'status',
130-
'search_identifiers',
131-
'search_exclude_source_strings',
132-
'search_rejected_translations',
133-
'search_match_case',
134-
'search_match_whole_word',
135139
'extra',
136140
'tag',
137141
'author',

translate/src/api/user.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ export type ApiUserData = {
3939
contributor_for_locales?: string[];
4040
translator_for_projects?: Record<string, boolean>;
4141
pm_for_projects?: string[];
42-
settings?: { quality_checks: boolean; force_suggestions: boolean };
42+
settings?: {
43+
quality_checks: boolean;
44+
force_suggestions: boolean;
45+
search_exclude_source_strings: boolean;
46+
search_identifiers: boolean;
47+
search_match_case: boolean;
48+
search_match_whole_word: boolean;
49+
search_rejected_translations: boolean;
50+
};
4351
tour_status?: number;
4452
has_dismissed_addon_promotion?: boolean;
4553
login_url?: string;

translate/src/context/Location.tsx

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import React, {
66
useState,
77
} from 'react';
88

9+
import { SEARCH_OPTION_KEYS } from '~/modules/search/constants';
10+
911
export type Location = {
1012
push(location: string | Partial<Location>): void;
1113
replace(location: string | Partial<Location>): void;
@@ -20,11 +22,12 @@ export type Location = {
2022
search: string | null;
2123
status: string | null;
2224
extra: string | null;
23-
search_identifiers: boolean;
24-
search_exclude_source_strings: boolean;
25-
search_rejected_translations: boolean;
26-
search_match_case: boolean;
27-
search_match_whole_word: boolean;
25+
/** undefined = not provided in the URL, use default or profile setting instead */
26+
search_identifiers: boolean | undefined;
27+
search_exclude_source_strings: boolean | undefined;
28+
search_rejected_translations: boolean | undefined;
29+
search_match_case: boolean | undefined;
30+
search_match_whole_word: boolean | undefined;
2831
tag: string | null;
2932
author: string | null;
3033
time: string | null;
@@ -38,11 +41,11 @@ const emptyParams = {
3841
search: null,
3942
status: null,
4043
extra: null,
41-
search_identifiers: false,
42-
search_exclude_source_strings: false,
43-
search_rejected_translations: false,
44-
search_match_case: false,
45-
search_match_whole_word: false,
44+
search_identifiers: undefined,
45+
search_exclude_source_strings: undefined,
46+
search_rejected_translations: undefined,
47+
search_match_case: undefined,
48+
search_match_whole_word: undefined,
4649
tag: null,
4750
author: null,
4851
time: null,
@@ -78,6 +81,14 @@ export function LocationProvider({
7881
return <Location.Provider value={state}>{children}</Location.Provider>;
7982
}
8083

84+
function getSearchParam(
85+
params: URLSearchParams,
86+
key: string,
87+
): boolean | undefined {
88+
const val = params.get(key);
89+
return val !== null ? val !== 'false' : undefined;
90+
}
91+
8192
function parse(
8293
history: History.History,
8394
{ pathname, search }: History.Location,
@@ -107,15 +118,20 @@ function parse(
107118
search: params.get('search'),
108119
status: params.get('status'),
109120
extra: params.get('extra'),
110-
search_identifiers: params.has('search_identifiers'),
111-
search_exclude_source_strings: params.has(
121+
search_identifiers: getSearchParam(params, 'search_identifiers'),
122+
search_exclude_source_strings: getSearchParam(
123+
params,
112124
'search_exclude_source_strings',
113125
),
114-
search_rejected_translations: params.has(
126+
search_rejected_translations: getSearchParam(
127+
params,
115128
'search_rejected_translations',
116129
),
117-
search_match_case: params.has('search_match_case'),
118-
search_match_whole_word: params.has('search_match_whole_word'),
130+
search_match_case: getSearchParam(params, 'search_match_case'),
131+
search_match_whole_word: getSearchParam(
132+
params,
133+
'search_match_whole_word',
134+
),
119135
tag: params.get('tag'),
120136
author: params.get('author'),
121137
time: params.get('time'),
@@ -143,15 +159,17 @@ function stringify(prev: Location, next: string | Partial<Location>) {
143159
} else if (prev.list && !('list' in next)) {
144160
params.set('list', prev.list.join(','));
145161
}
162+
// Encode explicit search option values (omit undefined).
163+
for (const key of SEARCH_OPTION_KEYS) {
164+
const value = key in next ? next[key] : prev[key];
165+
if (value !== undefined) {
166+
params.set(key, String(value));
167+
}
168+
}
146169
for (const key of [
147170
'search',
148171
'status',
149172
'extra',
150-
'search_identifiers',
151-
'search_exclude_source_strings',
152-
'search_rejected_translations',
153-
'search_match_case',
154-
'search_match_whole_word',
155173
'tag',
156174
'author',
157175
'time',

0 commit comments

Comments
 (0)