Repository: mozilla/pontoon

Issue:
Ability to view adjacent strings to a search result
*This issue was created automatically by a [script](https://github.com/mathjazz/bugzilla2github/).*
## [Bug 1370173](https://bugzilla.mozilla.org/show_bug.cgi?id=1370173)
Bug Reporter: @StoyanDimitrov
CC:   @jordis, @julen, @mathjazz, @Mitch4sho, @MikkCZ
See also:     https://bugzilla.mozilla.org/show_bug.cgi?id=1370200

The idea is to have search result that shows few strings before and few stings after it.

Pootle has it. While not needed for every day use I find it very convenient at times and critical at others (basically no other way around it).

The idea is that related strings are close together in the source file. The classic example is the strings for pagination. You have "First", "Prev", "Next" and "Last" strings filed one after the other. I know that "Prev" and "Next" are together, but currently have no easy way to identify the correct "Prev"/"Next" pair I want to edit, let alone all four mentioned.

In a projects like AMO that lacks any kind of searchable meta-data attached to strings it is the only way to navigate to different sections of this 2 file, 2.8k strings project.


Discussion:

Resolving patch:
diff --git a/pontoon/base/models.py b/pontoon/base/models.py
--- a/pontoon/base/models.py
+++ b/pontoon/base/models.py
@@ -2911,7 +2911,12 @@ def for_project_locale(
 
     @classmethod
     def map_entities(
-        cls, locale, preferred_source_locale, entities, visible_entities=None
+        cls,
+        locale,
+        preferred_source_locale,
+        entities,
+        visible_entities=None,
+        is_sibling=False,
     ):
         entities_array = []
         visible_entities = visible_entities or []
@@ -2971,6 +2976,7 @@ def map_entities(
                         if entity.pk not in visible_entities or not visible_entities
                         else True
                     ),
+                    "is_sibling": is_sibling,
                 }
             )
 
diff --git a/pontoon/base/urls.py b/pontoon/base/urls.py
--- a/pontoon/base/urls.py
+++ b/pontoon/base/urls.py
@@ -94,4 +94,5 @@
     ),
     path("upload/", views.upload, name="pontoon.upload"),
     path("user-data/", views.user_data, name="pontoon.user_data"),
+    path("get-sibling-entities/", views.get_sibling_entities),
 ]
diff --git a/pontoon/base/views.py b/pontoon/base/views.py
--- a/pontoon/base/views.py
+++ b/pontoon/base/views.py
@@ -342,6 +342,51 @@ def get_translations_from_other_locales(request):
     return JsonResponse(payload, safe=False)
 
 
+@utils.require_AJAX
+def get_sibling_entities(request):
+    """Get entities preceding and succeeding the current entity"""
+    try:
+        entity = int(request.GET["entity"])
+        locale = request.GET["locale"]
+    except (MultiValueDictKeyError, ValueError) as e:
+        return JsonResponse(
+            {"status": False, "message": f"Bad Request: {e}"},
+            status=400,
+        )
+
+    entity = get_object_or_404(Entity, pk=entity)
+    locale = get_object_or_404(Locale, code=locale)
+    preferred_source_locale = ""
+    if request.user.is_authenticated:
+        preferred_source_locale = request.user.profile.preferred_source_locale
+
+    entities = Entity.objects.filter(resource=entity.resource, obsolete=False).order_by(
+        "order"
+    )
+    succeeding_entities = entities.filter(order__gt=entity.order)[:2]
+    preceding_entities = entities.filter(order__lt=entity.order).order_by("-order")[:2]
+
+    return JsonResponse(
+        {
+            "succeeding": Entity.map_entities(
+                locale,
+                preferred_source_locale,
+                succeeding_entities,
+                [],
+                True,
+            ),
+            "preceding": Entity.map_entities(
+                locale,
+                preferred_source_locale,
+                preceding_entities,
+                [],
+                True,
+            ),
+        },
+        safe=False,
+    )
+
+
 @utils.require_AJAX
 def get_translation_history(request):
     """Get history of translations of given entity to given locale."""


Test patch:
diff --git a/frontend/src/modules/entitieslist/components/Entity.test.js b/frontend/src/modules/entitieslist/components/Entity.test.js
--- a/frontend/src/modules/entitieslist/components/Entity.test.js
+++ b/frontend/src/modules/entitieslist/components/Entity.test.js
@@ -90,7 +90,11 @@ describe('<Entity>', () => {
 
     it('renders the source string and the first translation', () => {
         const wrapper = shallow(
-            <Entity entity={ENTITY_A} locale={DEFAULT_LOCALE} />,
+            <Entity
+                entity={ENTITY_A}
+                locale={DEFAULT_LOCALE}
+                parameters={{}}
+            />,
         );
 
         const contents = wrapper.find('TranslationProxy');
@@ -102,50 +106,72 @@ describe('<Entity>', () => {
 
     it('shows the correct status class', () => {
         let wrapper = shallow(
-            <Entity entity={ENTITY_A} locale={DEFAULT_LOCALE} />,
+            <Entity
+                entity={ENTITY_A}
+                locale={DEFAULT_LOCALE}
+                parameters={{}}
+            />,
         );
         expect(wrapper.instance().status).toEqual('approved');
 
-        wrapper = shallow(<Entity entity={ENTITY_B} locale={DEFAULT_LOCALE} />);
+        wrapper = shallow(
+            <Entity
+                entity={ENTITY_B}
+                locale={DEFAULT_LOCALE}
+                parameters={{}}
+            />,
+        );
         expect(wrapper.instance().status).toEqual('fuzzy');
 
-        wrapper = shallow(<Entity entity={ENTITY_C} locale={DEFAULT_LOCALE} />);
+        wrapper = shallow(
+            <Entity
+                entity={ENTITY_C}
+                locale={DEFAULT_LOCALE}
+                parameters={{}}
+            />,
+        );
         expect(wrapper.instance().status).toEqual('missing');
 
-        wrapper = shallow(<Entity entity={ENTITY_D} locale={DEFAULT_LOCALE} />);
+        wrapper = shallow(
+            <Entity
+                entity={ENTITY_D}
+                locale={DEFAULT_LOCALE}
+                parameters={{}}
+            />,
+        );
         expect(wrapper.instance().status).toEqual('errors');
 
-        wrapper = shallow(<Entity entity={ENTITY_E} locale={DEFAULT_LOCALE} />);
+        wrapper = shallow(
+            <Entity
+                entity={ENTITY_E}
+                locale={DEFAULT_LOCALE}
+                parameters={{}}
+            />,
+        );
         expect(wrapper.instance().status).toEqual('warnings');
 
-        wrapper = shallow(<Entity entity={ENTITY_F} locale={DEFAULT_LOCALE} />);
-        expect(wrapper.instance().status).toEqual('partial');
-    });
-
-    it('calls the selectEntity function on click on li', () => {
-        const selectEntityFn = sinon.spy();
-        const wrapper = mount(
+        wrapper = shallow(
             <Entity
-                entity={ENTITY_A}
-                selectEntity={selectEntityFn}
+                entity={ENTITY_F}
                 locale={DEFAULT_LOCALE}
+                parameters={{}}
             />,
         );
-        wrapper.find('li').simulate('click');
-        expect(selectEntityFn.calledOnce).toEqual(true);
+        expect(wrapper.instance().status).toEqual('partial');
     });
 
-    it('does not call the selectEntity function on click on .status', () => {
+    it('calls the selectEntity function on click on li', () => {
         const selectEntityFn = sinon.spy();
         const wrapper = mount(
             <Entity
                 entity={ENTITY_A}
                 selectEntity={selectEntityFn}
                 locale={DEFAULT_LOCALE}
+                parameters={{}}
             />,
         );
-        wrapper.find('.status').simulate('click');
-        expect(selectEntityFn.called).toEqual(false);
+        wrapper.find('li').simulate('click');
+        expect(selectEntityFn.calledOnce).toEqual(true);
     });
 
     it('calls the toggleForBatchEditing function on click on .status', () => {
@@ -157,6 +183,7 @@ describe('<Entity>', () => {
                 isReadOnlyEditor={false}
                 toggleForBatchEditing={toggleForBatchEditingFn}
                 locale={DEFAULT_LOCALE}
+                parameters={{}}
             />,
         );
         wrapper.find('.status').simulate('click');
@@ -165,13 +192,16 @@ describe('<Entity>', () => {
 
     it('does not call the toggleForBatchEditing function if user not translator', () => {
         const toggleForBatchEditingFn = sinon.spy();
+        const selectEntityFn = sinon.spy();
         const wrapper = mount(
             <Entity
                 entity={ENTITY_A}
                 isTranslator={false}
                 isReadOnlyEditor={false}
                 toggleForBatchEditing={toggleForBatchEditingFn}
+                selectEntity={selectEntityFn}
                 locale={DEFAULT_LOCALE}
+                parameters={{}}
             />,
         );
         wrapper.find('.status').simulate('click');
@@ -180,13 +210,16 @@ describe('<Entity>', () => {
 
     it('does not call the toggleForBatchEditing function if read-only editor', () => {
         const toggleForBatchEditingFn = sinon.spy();
+        const selectEntityFn = sinon.spy();
         const wrapper = mount(
             <Entity
                 entity={ENTITY_A}
                 isTranslator={false}
                 isReadOnlyEditor={true}
                 toggleForBatchEditing={toggleForBatchEditingFn}
+                selectEntity={selectEntityFn}
                 locale={DEFAULT_LOCALE}
+                parameters={{}}
             />,
         );
         wrapper.find('.status').simulate('click');
diff --git a/pontoon/base/tests/models/test_entity.py b/pontoon/base/tests/models/test_entity.py
--- a/pontoon/base/tests/models/test_entity.py
+++ b/pontoon/base/tests/models/test_entity.py
@@ -317,6 +317,7 @@ def test_entity_project_locale_no_paths(
         "machinery_original": str(entity_a.string),
         "readonly": False,
         "visible": False,
+        "is_sibling": False,
     }
     assert entities[0] == expected