Repository: learningequality/kolibri

Issue:
Turn search functionality used in LibraryPage and TopicsPage into a composition helper
https://github.com/learningequality/kolibri/pull/8548 duplicates the search code used in LibraryPage for TopicsPage to provide channel scoped search.

We can abstract this into a composition helper in order to ensure consistent behaviour (as the underlying code has some complexity).


Discussion:

Resolving patch:
diff --git a/kolibri/core/content/api.py b/kolibri/core/content/api.py
--- a/kolibri/core/content/api.py
+++ b/kolibri/core/content/api.py
@@ -24,6 +24,7 @@
 from django_filters.rest_framework import ChoiceFilter
 from django_filters.rest_framework import DjangoFilterBackend
 from django_filters.rest_framework import FilterSet
+from django_filters.rest_framework import NumberFilter
 from django_filters.rest_framework import UUIDFilter
 from le_utils.constants import content_kinds
 from le_utils.constants import languages
@@ -185,6 +186,8 @@ class ContentNodeFilter(IdFilter):
     channels = UUIDInFilter(name="channel_id")
     languages = CharInFilter(name="lang_id")
     categories__isnull = BooleanFilter(field_name="categories", lookup_expr="isnull")
+    lft__gt = NumberFilter(field_name="lft", lookup_expr="gt")
+    rght__lt = NumberFilter(field_name="rght", lookup_expr="lt")
 
     class Meta:
         model = models.ContentNode
@@ -211,6 +214,9 @@ class Meta:
             "keywords",
             "channels",
             "languages",
+            "tree_id",
+            "lft__gt",
+            "rght__lt",
         ]
 
     def filter_kind(self, queryset, name, value):
@@ -819,7 +825,7 @@ class ContentNodeTreeViewset(BaseContentNodeMixin, BaseValuesViewset):
 
     def validate_and_return_params(self, request):
         depth = request.query_params.get("depth", 2)
-        lft__gt = request.query_params.get("lft__gt")
+        next__gt = request.query_params.get("next__gt")
 
         try:
             depth = int(depth)
@@ -828,16 +834,16 @@ def validate_and_return_params(self, request):
         except ValueError:
             raise ValidationError("Depth query parameter must have the value 1 or 2")
 
-        if lft__gt is not None:
+        if next__gt is not None:
             try:
-                lft__gt = int(lft__gt)
-                if 1 > lft__gt:
+                next__gt = int(next__gt)
+                if 1 > next__gt:
                     raise ValueError
             except ValueError:
                 raise ValidationError(
-                    "lft__gt query parameter must be a positive integer if specified"
+                    "next__gt query parameter must be a positive integer if specified"
                 )
-        return depth, lft__gt
+        return depth, next__gt
 
     def get_grandchild_ids(self, child_ids, depth):
         if depth == 2:
@@ -868,13 +874,13 @@ def retrieve(self, request, **kwargs):
         GET parameters on request can be:
         depth - a value of either 1 or 2 indicating the depth to recurse the tree, either 1 or 2 levels
         if this parameter is missing it will default to 2.
-        lft__gt - a value to return child nodes with a lft value greater than this, if missing defaults to None
+        next__gt - a value to return child nodes with a lft value greater than this, if missing defaults to None
 
         The pagination object returned for "children" will have this form:
         results - a list of serialized children, that can also have their own nested children attribute.
         more - a dictionary or None, if a dictionary, will have an id key that is the id of the parent object
         for these children, and a params key that is a dictionary of the required query parameters to query more
-        children for this parent - at a minimum this will include lft__gt and depth, but may also include
+        children for this parent - at a minimum this will include next__gt and depth, but may also include
         other query parameters for filtering content nodes.
 
         The "more" property describes the "id" required to do URL reversal on this endpoint, and the params that should
@@ -890,12 +896,12 @@ def retrieve(self, request, **kwargs):
         # use for the `get_ancestors` MPTT method.
         parent_model = self.get_object()
 
-        depth, lft__gt = self.validate_and_return_params(request)
+        depth, next__gt = self.validate_and_return_params(request)
 
         # Get a list of child_ids of the parent node up to the pagination limit
         child_qs = self.get_queryset().filter(parent=parent_model)
-        if lft__gt is not None:
-            child_qs = child_qs.filter(lft__gt=lft__gt)
+        if next__gt is not None:
+            child_qs = child_qs.filter(lft__gt=next__gt)
         child_ids = child_qs.values_list("id", flat=True).order_by("lft")[
             0 : self.page_size
         ]
@@ -956,13 +962,13 @@ def retrieve(self, request, **kwargs):
             if len(desc_parent["children"]["results"]) == self.page_size:
                 # Any subsequent queries to get siblings of this node can restrict themselves
                 # to looking for nodes with lft greater than the rght value of this descendant
-                lft__gt = desc["rght"]
+                next__gt = desc["rght"]
                 # If the rght value of this descendant is exactly 1 less than the rght value of
                 # its parent, then there are no more children that can be queried.
                 # So only in this instance do we update the more URL
                 if desc["rght"] + 1 < desc_parent["rght"]:
                     params = request.query_params.copy()
-                    params["lft__gt"] = lft__gt
+                    params["next__gt"] = next__gt
                     params["depth"] = more_depth
                     desc_parent["children"]["more"] = {
                         "id": desc_parent["id"],
diff --git a/kolibri/core/webpack/hooks.py b/kolibri/core/webpack/hooks.py
--- a/kolibri/core/webpack/hooks.py
+++ b/kolibri/core/webpack/hooks.py
@@ -22,6 +22,7 @@
 from django.conf import settings
 from django.contrib.staticfiles.finders import find as find_staticfiles
 from django.contrib.staticfiles.storage import staticfiles_storage
+from django.core.serializers.json import DjangoJSONEncoder
 from django.utils.functional import cached_property
 from django.utils.safestring import mark_safe
 from django.utils.six.moves.urllib.request import url2pathname
@@ -276,7 +277,10 @@ def plugin_data_tag(self):
                     bundle=self.unique_id,
                     plugin_data=json.dumps(
                         json.dumps(
-                            self.plugin_data, separators=(",", ":"), ensure_ascii=False
+                            self.plugin_data,
+                            separators=(",", ":"),
+                            ensure_ascii=False,
+                            cls=DjangoJSONEncoder,
                         )
                     ),
                 )
diff --git a/kolibri/plugins/learn/kolibri_plugin.py b/kolibri/plugins/learn/kolibri_plugin.py
--- a/kolibri/plugins/learn/kolibri_plugin.py
+++ b/kolibri/plugins/learn/kolibri_plugin.py
@@ -2,6 +2,7 @@
 from __future__ import print_function
 from __future__ import unicode_literals
 
+from django.db.models import F
 from django.urls import reverse
 
 from kolibri.core.auth.constants.user_kinds import ANONYMOUS
@@ -51,6 +52,33 @@ class LearnAsset(webpack_hooks.WebpackBundleHook):
 
     @property
     def plugin_data(self):
+        from kolibri.core.content.models import ChannelMetadata
+
+        channels = list(
+            ChannelMetadata.objects.filter(root__available=True)
+            .annotate(
+                lang_code=F("root__lang__lang_code"),
+                lang_name=F("root__lang__lang_name"),
+                available=F("root__available"),
+                num_coach_contents=F("root__num_coach_contents"),
+            )
+            .values(
+                "author",
+                "description",
+                "tagline",
+                "id",
+                "last_updated",
+                "lang_code",
+                "lang_name",
+                "name",
+                "root",
+                "thumbnail",
+                "version",
+                "available",
+                "num_coach_contents",
+                "public",
+            )
+        )
         label_metadata = get_all_contentnode_label_metadata()
         return {
             "allowGuestAccess": get_device_setting("allow_guest_access"),
@@ -64,7 +92,7 @@ def plugin_data(self):
             "categories": label_metadata["categories"],
             "learningActivities": label_metadata["learning_activities"],
             "languages": label_metadata["languages"],
-            "channels": label_metadata["channels"],
+            "channels": channels,
             "gradeLevels": label_metadata["grade_levels"],
             "accessibilityLabels": label_metadata["accessibility_labels"],
             "learnerNeeds": label_metadata["learner_needs"],


Test patch:
diff --git a/kolibri/core/content/test/test_content_app.py b/kolibri/core/content/test/test_content_app.py
--- a/kolibri/core/content/test/test_content_app.py
+++ b/kolibri/core/content/test/test_content_app.py
@@ -398,7 +398,7 @@ def _recurse_and_assert(self, data, nodes, recursion_depth=0):
                     self.assertGreater(len(child_nodes), len(children["results"]))
                     self.assertEqual(children["more"]["id"], expected.id)
                     self.assertEqual(
-                        children["more"]["params"]["lft__gt"], child_nodes[24].rght
+                        children["more"]["params"]["next__gt"], child_nodes[24].rght
                     )
                     self.assertEqual(
                         children["more"]["params"]["depth"], 2 - recursion_depth
@@ -444,15 +444,15 @@ def test_contentnode_tree_depth_1(self):
         == "django.db.backends.postgresql",
         "Skipping postgres as not as vulnerable to large queries and large insertions are less performant",
     )
-    def test_contentnode_tree_lft__gt(self):
+    def test_contentnode_tree_next__gt(self):
         builder = ChannelBuilder(levels=2, num_children=30)
         builder.insert_into_default_db()
         content.ContentNode.objects.all().update(available=True)
         root = content.ContentNode.objects.get(id=builder.root_node["id"])
-        lft__gt = content.ContentNode.objects.filter(parent=root)[24].rght
+        next__gt = content.ContentNode.objects.filter(parent=root)[24].rght
         response = self.client.get(
             reverse("kolibri:core:contentnode_tree-detail", kwargs={"pk": root.id}),
-            data={"lft__gt": lft__gt},
+            data={"next__gt": next__gt},
         )
         self.assertEqual(len(response.data["children"]["results"]), 5)
         self.assertIsNone(response.data["children"]["more"])
diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js
new file mode 100644
--- /dev/null
+++ b/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js
@@ -0,0 +1,460 @@
+import { get, set } from '@vueuse/core';
+import VueRouter from 'vue-router';
+import Vue from 'vue';
+import { ContentNodeResource } from 'kolibri.resources';
+import { coreStoreFactory } from 'kolibri.coreVue.vuex.store';
+import { AllCategories, NoCategories } from 'kolibri.coreVue.vuex.constants';
+import useSearch from '../useSearch';
+import { normalizeContentNode } from '../../modules/coreLearn/utils';
+
+Vue.use(VueRouter);
+
+const name = 'not important';
+
+function prep(query = {}) {
+  const store = coreStoreFactory({
+    state: () => ({
+      route: {
+        query,
+        name,
+      },
+    }),
+    mutations: {
+      SET_QUERY(state, query) {
+        state.route.query = query;
+      },
+    },
+  });
+  const router = new VueRouter();
+  router.push = jest.fn().mockReturnValue(Promise.resolve());
+  return {
+    ...useSearch(store, router),
+    router,
+    store,
+  };
+}
+
+describe(`useSearch`, () => {
+  describe(`searchTerms computed ref`, () => {
+    it(`returns an object with all relevant keys when query params are empty`, () => {
+      const { searchTerms } = prep();
+      expect(get(searchTerms)).toEqual({
+        accessibility_labels: {},
+        categories: {},
+        channels: {},
+        grade_levels: {},
+        languages: {},
+        learner_needs: {},
+        learning_activities: {},
+        keywords: '',
+      });
+    });
+    it(`returns an object with all relevant keys when query params have other keys`, () => {
+      const { searchTerms } = prep({
+        search: {
+          this: true,
+        },
+        keyword: 'how about this?',
+      });
+      expect(get(searchTerms)).toEqual({
+        accessibility_labels: {},
+        categories: {},
+        channels: {},
+        grade_levels: {},
+        languages: {},
+        learner_needs: {},
+        learning_activities: {},
+        keywords: '',
+      });
+    });
+    it(`returns an object with all relevant keys when query params are specified`, () => {
+      const { searchTerms } = prep({
+        accessibility_labels: 'test1,test2',
+        keywords: 'I love paris in the springtime!',
+        categories: 'notatest,reallynotatest,absolutelynotatest',
+        channels: 'channelid1,channelid2,channelid3',
+        grade_levels: 'lowerprimary,uppersecondary,adult',
+        languages: 'ar-jk,en-pr,en-gb',
+        learner_needs: 'internet,pencil,rolodex',
+        learning_activities: 'watch',
+      });
+      expect(get(searchTerms)).toEqual({
+        accessibility_labels: {
+          test1: true,
+          test2: true,
+        },
+        categories: {
+          notatest: true,
+          reallynotatest: true,
+          absolutelynotatest: true,
+        },
+        channels: {
+          channelid1: true,
+          channelid2: true,
+          channelid3: true,
+        },
+        grade_levels: {
+          lowerprimary: true,
+          uppersecondary: true,
+          adult: true,
+        },
+        languages: {
+          'ar-jk': true,
+          'en-pr': true,
+          'en-gb': true,
+        },
+        learner_needs: {
+          internet: true,
+          pencil: true,
+          rolodex: true,
+        },
+        learning_activities: {
+          watch: true,
+        },
+        keywords: 'I love paris in the springtime!',
+      });
+    });
+    it(`setting relevant keys will result in a router push`, () => {
+      const { searchTerms, router } = prep();
+      set(searchTerms, {
+        keywords: 'test',
+        categories: {
+          cat1: true,
+          cat2: true,
+        },
+      });
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {
+          keywords: 'test',
+          categories: 'cat1,cat2',
+        },
+      });
+    });
+    it(`removing keys will be propagated to the router`, () => {
+      const { searchTerms, router } = prep({
+        keywords: 'test',
+        categories: 'cat1,cat2',
+        grade_levels: 'level1',
+      });
+      set(searchTerms, {
+        keywords: '',
+        categories: {
+          cat2: true,
+        },
+      });
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {
+          categories: 'cat2',
+        },
+      });
+    });
+    it(`setting keywords to null will be propagated to the router`, () => {
+      const { searchTerms, router } = prep({
+        keywords: 'test',
+        categories: 'cat1,cat2',
+        grade_levels: 'level1',
+      });
+      set(searchTerms, {
+        keywords: null,
+        categories: {
+          cat2: true,
+        },
+      });
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {
+          categories: 'cat2',
+        },
+      });
+    });
+  });
+  describe('displayingSearchResults computed property', () => {
+    const searchKeys = [
+      'learning_activities',
+      'categories',
+      'learner_needs',
+      'channels',
+      'accessibility_labels',
+      'languages',
+      'grade_levels',
+    ];
+    it.each(searchKeys)('should be true when there are any values for %s', key => {
+      const { displayingSearchResults } = prep({
+        [key]: 'test1,test2',
+      });
+      expect(get(displayingSearchResults)).toBe(true);
+    });
+    it('should be true when there is a value for keywords', () => {
+      const { displayingSearchResults } = prep({
+        keywords: 'testing testing one two three',
+      });
+      expect(get(displayingSearchResults)).toBe(true);
+    });
+  });
+  describe('search method', () => {
+    it('should call ContentNodeResource.fetchCollection when searchTerms changes', async () => {
+      const { store } = prep();
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      store.commit('SET_QUERY', { categories: 'test1,test2' });
+      await Vue.nextTick();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
+        getParams: { categories: ['test1', 'test2'], max_results: 25 },
+      });
+    });
+    it('should not call ContentNodeResource.fetchCollection if there is no search', () => {
+      const { search } = prep();
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      search();
+      expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled();
+    });
+    it('should clear labels and more if there is no search', () => {
+      const { search, labels, more } = prep();
+      set(labels, ['test']);
+      set(more, { test: 'test' });
+      search();
+      expect(get(labels)).toBeNull();
+      expect(get(more)).toBeNull();
+    });
+    it('should call ContentNodeResource.fetchCollection if there is no search but a descendant is set', () => {
+      const { search, setSearchWithinDescendant } = prep();
+      setSearchWithinDescendant({ tree_id: 1, lft: 10, rght: 20 });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      search();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
+        getParams: { tree_id: 1, lft__gt: 10, rght__lt: 20, max_results: 1 },
+      });
+    });
+    it('should set labels and clear more if there is no search but a descendant is set', async () => {
+      const { labels, more, search, setSearchWithinDescendant } = prep();
+      setSearchWithinDescendant({ tree_id: 1, lft: 10, rght: 20 });
+      ContentNodeResource.fetchCollection = jest.fn();
+      const labelsSet = {
+        available: ['labels'],
+      };
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({ labels: labelsSet }));
+      set(more, { test: 'test' });
+      search();
+      await Vue.nextTick();
+      expect(get(more)).toBeNull();
+      expect(get(labels)).toEqual(labelsSet);
+    });
+    it('should call ContentNodeResource.fetchCollection when searchTerms exist', () => {
+      const { search } = prep({ categories: 'test1,test2' });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      search();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
+        getParams: { categories: ['test1', 'test2'], max_results: 25 },
+      });
+    });
+    it('should ignore other categories when AllCategories is set and search for isnull false', () => {
+      const { search } = prep({ categories: `test1,test2,${NoCategories},${AllCategories}` });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      search();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
+        getParams: { categories__isnull: false, max_results: 25 },
+      });
+    });
+    it('should ignore other categories when NoCategories is set and search for isnull true', () => {
+      const { search } = prep({ categories: `test1,test2,${NoCategories}` });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      search();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
+        getParams: { categories__isnull: true, max_results: 25 },
+      });
+    });
+    it('should ignore channels when descendant is set', () => {
+      const { search, setSearchWithinDescendant } = prep({
+        categories: `test1,test2`,
+        channels: 'test1',
+      });
+      setSearchWithinDescendant({ tree_id: 1, lft: 10, rght: 20 });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      search();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
+        getParams: {
+          categories: ['test1', 'test2'],
+          max_results: 25,
+          tree_id: 1,
+          lft__gt: 10,
+          rght__lt: 20,
+        },
+      });
+    });
+    it('should set keywords when defined', () => {
+      const { search } = prep({ keywords: `this is just a test` });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      search();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
+        getParams: { keywords: `this is just a test`, max_results: 25 },
+      });
+    });
+    it('should set results, labels, and more with returned data', async () => {
+      const { labels, more, results, search } = prep({ categories: 'test1,test2' });
+      const expectedLabels = {
+        available: ['labels'],
+      };
+      const expectedMore = {
+        cursor: 'adalskdjsadlkjsadlkjsalkd',
+      };
+      const expectedResults = [{ id: 'node-id1' }];
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(
+        Promise.resolve({
+          labels: expectedLabels,
+          results: expectedResults,
+          more: expectedMore,
+        })
+      );
+      search();
+      await Vue.nextTick();
+      expect(get(labels)).toEqual(expectedLabels);
+      expect(get(results)).toEqual(expectedResults.map(normalizeContentNode));
+      expect(get(more)).toEqual(expectedMore);
+    });
+  });
+  describe('searchMore method', () => {
+    it('should not call anything when not displaying search terms', () => {
+      const { searchMore } = prep();
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      searchMore();
+      expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled();
+    });
+    it('should not call anything when more is null', () => {
+      const { more, searchMore } = prep({ categories: 'test1' });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      set(more, null);
+      searchMore();
+      expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled();
+    });
+    it('should not call anything when moreLoading is true', () => {
+      const { more, moreLoading, searchMore } = prep({ categories: 'test1' });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      set(more, {});
+      set(moreLoading, true);
+      searchMore();
+      expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled();
+    });
+    it('should pass the more object directly to getParams', () => {
+      const { more, searchMore } = prep({ categories: `test1,test2,${NoCategories}` });
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+      const moreExpected = { test: 'this', not: 'that' };
+      set(more, moreExpected);
+      searchMore();
+      expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({ getParams: moreExpected });
+    });
+    it('should set results, more and labels', async () => {
+      const { labels, more, results, searchMore } = prep({
+        categories: `test1,test2,${NoCategories}`,
+      });
+      const expectedLabels = {
+        available: ['labels'],
+      };
+      const expectedMore = {
+        cursor: 'adalskdjsadlkjsadlkjsalkd',
+      };
+      const expectedResults = [{ id: 'node-id1' }];
+      ContentNodeResource.fetchCollection = jest.fn();
+      ContentNodeResource.fetchCollection.mockReturnValue(
+        Promise.resolve({
+          labels: expectedLabels,
+          results: expectedResults,
+          more: expectedMore,
+        })
+      );
+      set(more, {});
+      const originalResults = [{ id: 'originalId' }];
+      set(results, originalResults);
+      searchMore();
+      await Vue.nextTick();
+      expect(get(labels)).toEqual(expectedLabels);
+      expect(get(results)).toEqual(
+        originalResults.concat(expectedResults.map(normalizeContentNode))
+      );
+      expect(get(more)).toEqual(expectedMore);
+    });
+  });
+  describe('removeFilterTag method', () => {
+    it('should remove a filter from the searchTerms', () => {
+      const { removeFilterTag, router } = prep({
+        categories: 'test1,test2',
+      });
+      removeFilterTag({ value: 'test1', key: 'categories' });
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {
+          categories: 'test2',
+        },
+      });
+    });
+    it('should remove keywords from the searchTerms', () => {
+      const { removeFilterTag, router } = prep({
+        keywords: 'test',
+      });
+      removeFilterTag({ value: 'test', key: 'keywords' });
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {},
+      });
+    });
+    it('should not remove any other filters', () => {
+      const { removeFilterTag, router } = prep({
+        categories: 'test1,test2',
+        channels: 'channel1',
+      });
+      removeFilterTag({ value: 'test1', key: 'categories' });
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {
+          categories: 'test2',
+          channels: 'channel1',
+        },
+      });
+    });
+  });
+  describe('clearSearch method', () => {
+    it('should remove all filters from the searchTerms', () => {
+      const { clearSearch, router } = prep({
+        categories: 'test1,test2',
+        channels: 'channel1',
+        keywords: 'this',
+      });
+      clearSearch();
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {},
+      });
+    });
+  });
+  describe('setCategory method', () => {
+    it('should remove all other category filters and add the new one', () => {
+      const { setCategory, router } = prep({
+        categories: 'test1,test2',
+        channels: 'channel1',
+        keywords: 'this',
+      });
+      setCategory('newcategory');
+      expect(router.push).toHaveBeenCalledWith({
+        name,
+        query: {
+          categories: 'newcategory',
+          channels: 'channel1',
+          keywords: 'this',
+        },
+      });
+    });
+  });
+});
diff --git a/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/__tests__/ExploreChannels.spec.js b/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/__tests__/ExploreChannels.spec.js
--- a/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/__tests__/ExploreChannels.spec.js
+++ b/kolibri/plugins/learn/assets/src/views/HomePage/ExploreChannels/__tests__/ExploreChannels.spec.js
@@ -11,18 +11,22 @@ const TEST_CHANNELS = [
   {
     id: 'channel-1',
     name: 'Channel 1',
+    root: 'root-1',
   },
   {
     id: 'channel-2',
     name: 'Channel 2',
+    root: 'root-2',
   },
   {
     id: 'channel-3',
     name: 'Channel 3',
+    root: 'root-3',
   },
   {
     id: 'channel-4',
     name: 'Channel 4',
+    root: 'root-4',
   },
 ];
 
@@ -42,8 +46,8 @@ function makeWrapper(propsData) {
         path: '/library',
       },
       {
-        name: PageNames.TOPICS_CHANNEL,
-        path: '/channel',
+        name: PageNames.TOPICS_TOPIC,
+        path: '/topics/t',
       },
     ],
   });
@@ -116,7 +120,7 @@ describe(`ExploreChannels`, () => {
     getChannelsLinks(wrapper)
       .at(0)
       .trigger('click');
-    expect(wrapper.vm.$route.name).toBe(PageNames.TOPICS_CHANNEL);
-    expect(wrapper.vm.$route.params).toEqual({ channel_id: 'channel-1' });
+    expect(wrapper.vm.$route.name).toBe(PageNames.TOPICS_TOPIC);
+    expect(wrapper.vm.$route.params).toEqual({ id: 'root-1' });
   });
 });
diff --git a/kolibri/plugins/learn/assets/test/views/breadcrumbs.spec.js b/kolibri/plugins/learn/assets/test/views/breadcrumbs.spec.js
--- a/kolibri/plugins/learn/assets/test/views/breadcrumbs.spec.js
+++ b/kolibri/plugins/learn/assets/test/views/breadcrumbs.spec.js
@@ -4,6 +4,10 @@ import { mount, createLocalVue } from '@vue/test-utils';
 import Breadcrumbs from '../../src/views/Breadcrumbs';
 import makeStore from '../makeStore';
 import { PageNames } from '../../src/constants';
+// eslint-disable-next-line import/named
+import useChannels, { useChannelsMock } from '../../src/composables/useChannels';
+
+jest.mock('../../src/composables/useChannels');
 
 const localVue = createLocalVue();
 localVue.use(VueRouter);
@@ -11,7 +15,7 @@ localVue.use(VueRouter);
 const router = new VueRouter({
   routes: [
     { path: '/recommended', name: PageNames.RECOMMENDED },
-    { path: '/channels', name: PageNames.TOPICS_ROOT },
+    { path: '/library', name: PageNames.LIBRARY },
     {
       path: '/topics/c/:id',
       name: PageNames.TOPICS_CONTENT,
@@ -20,10 +24,6 @@ const router = new VueRouter({
       path: '/topics/t/:id',
       name: PageNames.TOPICS_TOPIC,
     },
-    {
-      path: '/topics/:channel_id',
-      name: PageNames.TOPICS_CHANNEL,
-    },
   ],
 });
 
@@ -41,84 +41,66 @@ function getElements(wrapper) {
 describe('learn page breadcrumbs', () => {
   describe('when in Topic Browsing mode', () => {
     it('shows no breadcrumbs on topics root (i.e. Channels)', () => {
-      const store = makeStore({ pageName: PageNames.TOPICS_ROOT });
+      const store = makeStore({ pageName: PageNames.LIBRARY });
       const wrapper = makeWrapper({ store });
       const { breadcrumbs } = getElements(wrapper);
       expect(breadcrumbs().exists()).toEqual(false);
     });
 
     it('shows correct breadcrumbs at a Channel', () => {
-      const store = makeStore({ pageName: PageNames.TOPICS_CHANNEL });
-      store.state.topicsTree.channel = {
-        title: 'Recommended Channel',
+      const store = makeStore({ pageName: PageNames.TOPICS_TOPIC });
+      useChannels.mockImplementation(() =>
+        useChannelsMock({
+          channelsMap: {
+            'channel-1': { id: 'channel-1', root: 'root-1', name: 'Recommended Channel' },
+          },
+        })
+      );
+      store.state.topicsTree.topic = {
+        title: 'Recommended Channel Root Node',
+        ancestors: [],
+        channel_id: 'channel-1',
       };
       const wrapper = makeWrapper({ store });
       const { breadcrumbItems } = getElements(wrapper);
       const bcs = breadcrumbItems();
-      expect(bcs.length).toEqual(2);
-      expect(bcs[0].link.name).toEqual(PageNames.TOPICS_ROOT);
-      expect(bcs[1].link).toEqual(undefined);
-      expect(bcs[1].text).toEqual('Recommended Channel');
+      expect(bcs.length).toEqual(1);
+      expect(bcs[0].link).toEqual(undefined);
+      expect(bcs[0].text).toEqual('Recommended Channel');
     });
 
     it('shows correct breadcrumbs at a non-Channel Topic', () => {
       const store = makeStore({ pageName: PageNames.TOPICS_TOPIC });
-      store.state.topicsTree.channel = {
-        title: 'Another Recommended Channel',
-        root_id: 'another_channel',
-      };
+      useChannels.mockImplementation(() =>
+        useChannelsMock({
+          channelsMap: {
+            'channel-1': { id: 'channel-1', root: 'root-1', name: 'Another Recommended Channel' },
+          },
+        })
+      );
       store.state.topicsTree.topic = {
         title: 'Recommended Topic',
-        breadcrumbs: [{ id: 'previous_topic', title: 'Previous Topic' }],
+        channel_id: 'channel-1',
+        ancestors: [
+          { id: 'root-1', title: 'a root node!' },
+          { id: 'previous_topic', title: 'Previous Topic' },
+        ],
       };
       const wrapper = makeWrapper({ store });
       const { breadcrumbItems } = getElements(wrapper);
       const bcs = breadcrumbItems();
-      expect(bcs.length).toEqual(4);
-      // All Channels Link
-      expect(bcs[0].link.name).toEqual(PageNames.TOPICS_ROOT);
-      expect(bcs[0].text).toEqual('Channels');
+      expect(bcs.length).toEqual(3);
       // Parent Channel Link
-      expect(bcs[1].link.name).toEqual(PageNames.TOPICS_CHANNEL);
-      expect(bcs[1].link.params.channel_id).toEqual('another_channel');
-      expect(bcs[1].text).toEqual('Another Recommended Channel');
+      expect(bcs[0].link.name).toEqual(PageNames.TOPICS_TOPIC);
+      expect(bcs[0].link.params.id).toEqual('root-1');
+      expect(bcs[0].text).toEqual('Another Recommended Channel');
       // Previous Topic Link
-      expect(bcs[2].link.name).toEqual(PageNames.TOPICS_TOPIC);
-      expect(bcs[2].link.params.id).toEqual('previous_topic');
-      expect(bcs[2].text).toEqual('Previous Topic');
+      expect(bcs[1].link.name).toEqual(PageNames.TOPICS_TOPIC);
+      expect(bcs[1].link.params.id).toEqual('previous_topic');
+      expect(bcs[1].text).toEqual('Previous Topic');
       // Topic
-      expect(bcs[3].link).toEqual(undefined);
-      expect(bcs[3].text).toEqual('Recommended Topic');
-    });
-
-    it('shows correct breadcrumbs at a Content Item', () => {
-      const store = makeStore({ pageName: PageNames.TOPICS_CONTENT });
-      store.state.topicsTree.channel = {
-        title: 'Another Recommended Channel',
-        root_id: 'another_channel',
-      };
-      store.state.topicsTree.content = {
-        title: 'Recommended Item',
-        breadcrumbs: [{ id: 'previous_topic', title: 'Previous Topic' }],
-      };
-      const wrapper = makeWrapper({ store });
-      const { breadcrumbItems } = getElements(wrapper);
-      const bcs = breadcrumbItems();
-      expect(bcs.length).toEqual(4);
-      // All Channels Link
-      expect(bcs[0].link.name).toEqual(PageNames.TOPICS_ROOT);
-      expect(bcs[0].text).toEqual('Channels');
-      // Channel Link
-      expect(bcs[1].link.name).toEqual(PageNames.TOPICS_CHANNEL);
-      expect(bcs[1].link.params.channel_id).toEqual('another_channel');
-      expect(bcs[1].text).toEqual('Another Recommended Channel');
-      // Previous Topic link
-      expect(bcs[2].link.name).toEqual(PageNames.TOPICS_TOPIC);
-      expect(bcs[2].link.params.id).toEqual('previous_topic');
-      expect(bcs[2].text).toEqual('Previous Topic');
-      // Content Item
-      expect(bcs[3].link).toEqual(undefined);
-      expect(bcs[3].text).toEqual('Recommended Item');
+      expect(bcs[2].link).toEqual(undefined);
+      expect(bcs[2].text).toEqual('Recommended Topic');
     });
   });