diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js
deleted file mode 100644
index 3b0d5e1284e..00000000000
--- a/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js
+++ /dev/null
@@ -1,438 +0,0 @@
-import { get, set } from '@vueuse/core';
-import VueRouter from 'vue-router';
-import Vue, { nextTick, ref } from 'vue';
-import ContentNodeResource from 'kolibri-common/apiResources/ContentNodeResource';
-import { coreStoreFactory } from 'kolibri/store';
-import { AllCategories, NoCategories } from 'kolibri/constants';
-import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line
-import useSearch from '../useSearch';
-import coreModule from '../../../../../../core/assets/src/state/modules/core';
-
-Vue.use(VueRouter);
-
-jest.mock('kolibri/composables/useUser');
-
-const name = 'not important';
-
-function prep(query = {}, descendant = null) {
- const store = coreStoreFactory({
- state: () => ({
- route: {
- query,
- name,
- },
- }),
- mutations: {
- SET_QUERY(state, query) {
- state.route.query = query;
- },
- },
- });
- store.registerModule('core', coreModule);
- const router = new VueRouter();
- router.push = jest.fn().mockReturnValue(Promise.resolve());
- return {
- ...useSearch(descendant, store, router),
- router,
- store,
- };
-}
-
-describe(`useSearch`, () => {
- beforeEach(() => {
- ContentNodeResource.fetchCollection = jest.fn();
- ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
- useUser.mockImplementation(() => useUserMock());
- });
- 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: {},
- 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: {},
- 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',
- 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,
- },
- 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',
- '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.mockReturnValue(Promise.resolve({}));
- store.commit('SET_QUERY', { categories: 'test1,test2' });
- await nextTick();
- expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
- getParams: {
- categories: ['test1', 'test2'],
- max_results: 25,
- include_coach_content: false,
- },
- });
- });
- it('should not call ContentNodeResource.fetchCollection if there is no search', () => {
- const { search } = prep();
- ContentNodeResource.fetchCollection.mockClear();
- 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 } = prep({}, ref({ tree_id: 1, lft: 10, rght: 20 }));
- ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
- search();
- expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
- getParams: {
- tree_id: 1,
- lft__gt: 10,
- rght__lt: 20,
- max_results: 1,
- include_coach_content: false,
- },
- });
- });
- it('should set labels and clear more if there is no search but a descendant is set', async () => {
- const { labels, more, search } = prep({}, ref({ tree_id: 1, lft: 10, rght: 20 }));
- const labelsSet = {
- available: ['labels'],
- languages: [],
- };
- ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({ labels: labelsSet }));
- set(more, { test: 'test' });
- search();
- await 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.mockReturnValue(Promise.resolve({}));
- search();
- expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
- getParams: {
- categories: ['test1', 'test2'],
- max_results: 25,
- include_coach_content: false,
- },
- });
- });
- it('should ignore other categories when AllCategories is set and search for isnull false', () => {
- const { search } = prep({ categories: `test1,test2,${NoCategories},${AllCategories}` });
- ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
- search();
- expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
- getParams: { categories__isnull: false, max_results: 25, include_coach_content: false },
- });
- });
- it('should ignore other categories when NoCategories is set and search for isnull true', () => {
- const { search } = prep({ categories: `test1,test2,${NoCategories}` });
- ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
- search();
- expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
- getParams: { categories__isnull: true, max_results: 25, include_coach_content: false },
- });
- });
- it('should set keywords when defined', () => {
- const { search } = prep({ keywords: `this is just a test` });
- ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
- search();
- expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
- getParams: {
- keywords: `this is just a test`,
- max_results: 25,
- include_coach_content: false,
- },
- });
- });
- it('should set results, labels, and more with returned data', async () => {
- const { labels, more, results, search } = prep({ categories: 'test1,test2' });
- const expectedLabels = {
- available: ['labels'],
- languages: [],
- };
- const expectedMore = {
- cursor: 'adalskdjsadlkjsadlkjsalkd',
- };
- const expectedResults = [{ id: 'node-id1' }];
- ContentNodeResource.fetchCollection.mockReturnValue(
- Promise.resolve({
- labels: expectedLabels,
- results: expectedResults,
- more: expectedMore,
- }),
- );
- search();
- await nextTick();
- expect(get(labels)).toEqual(expectedLabels);
- expect(get(results)).toEqual(expectedResults);
- expect(get(more)).toEqual(expectedMore);
- });
- });
- describe('searchMore method', () => {
- it('should not call anything when not displaying search terms', () => {
- const { searchMore } = prep();
- ContentNodeResource.fetchCollection.mockClear();
- 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.mockClear();
- 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.mockClear();
- 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.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, search } = prep({
- categories: `test1,test2,${NoCategories}`,
- });
- const expectedLabels = {
- available: ['labels'],
- languages: [],
- };
- const expectedMore = {
- cursor: 'adalskdjsadlkjsadlkjsalkd',
- };
- const originalResults = [{ id: 'originalId', content_id: 'first' }];
- ContentNodeResource.fetchCollection.mockReturnValue(
- Promise.resolve({
- labels: expectedLabels,
- results: originalResults,
- more: expectedMore,
- }),
- );
- search();
- await nextTick();
- const expectedResults = [{ id: 'node-id1', content_id: 'second' }];
- ContentNodeResource.fetchCollection.mockReturnValue(
- Promise.resolve({
- labels: expectedLabels,
- results: expectedResults,
- more: expectedMore,
- }),
- );
- set(more, {});
- searchMore();
- await nextTick();
- expect(get(labels)).toEqual(expectedLabels);
- expect(get(results)).toEqual(originalResults.concat(expectedResults));
- 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',
- learning_activities: 'watch',
- });
- removeFilterTag({ value: 'test1', key: 'categories' });
- expect(router.push).toHaveBeenCalledWith({
- name,
- query: {
- categories: 'test2',
- learning_activities: 'watch',
- },
- });
- });
- });
- describe('clearSearch method', () => {
- it('should remove all filters from the searchTerms', () => {
- const { clearSearch, router } = prep({
- categories: 'test1,test2',
- learning_activities: 'watch',
- keywords: 'this',
- });
- clearSearch();
- expect(router.push).toHaveBeenCalledWith({
- name,
- query: {},
- });
- });
- });
-});
diff --git a/kolibri/plugins/learn/assets/src/composables/useContentLink.js b/kolibri/plugins/learn/assets/src/composables/useContentLink.js
index d5bce334591..00447fa62f6 100644
--- a/kolibri/plugins/learn/assets/src/composables/useContentLink.js
+++ b/kolibri/plugins/learn/assets/src/composables/useContentLink.js
@@ -2,6 +2,7 @@ import { get } from '@vueuse/core';
import isEmpty from 'lodash/isEmpty';
import pick from 'lodash/pick';
import { computed, getCurrentInstance } from 'vue';
+import { primaryLanguageKey } from 'kolibri-common/composables/useBaseSearch';
import { ExternalPagePaths, PageNames } from '../constants';
function _decodeBackLinkQuery(query) {
@@ -17,6 +18,10 @@ export default function useContentLink(store) {
function _makeNodeLink(id, isResource, query, deviceId) {
const params = get(route).params;
+ const oldQuery = get(route).query || {};
+ if (!isResource && oldQuery[primaryLanguageKey]) {
+ query[primaryLanguageKey] = oldQuery[primaryLanguageKey];
+ }
return {
name: isResource ? PageNames.TOPICS_CONTENT : PageNames.TOPICS_TOPIC,
params: pick({ id, deviceId: deviceId || params.deviceId }, ['id', 'deviceId']),
diff --git a/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue b/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue
index b0499ab0f87..4a9e997c1b8 100644
--- a/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue
+++ b/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue
@@ -49,9 +49,16 @@
v-else-if="!displayingSearchResults && !rootNodesLoading"
data-test="channels"
>
-
- {{ channelsLabel }}
-
+
+
+
+ {{ channelsLabel }}
+
+
+
+
+
+
@@ -122,6 +130,7 @@
v-model="searchTerms"
data-test="side-panel"
:width="`${sidePanelWidth}px`"
+ :showLanguages="displayingSearchResults"
/>
@@ -176,20 +185,22 @@
diff --git a/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue b/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue
index cf26123bff5..0a94c833d44 100644
--- a/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue
+++ b/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue
@@ -1,16 +1,9 @@
- handleChange('languages', val)"
/>
handleChange('grade_levels', val)"
+ @change="val => handleChange('grade_levels', val && val.value)"
/>
handleChange('accessibility_labels', val)"
+ @change="val => handleChange('accessibility_labels', val && val.value)"
/>
@@ -47,21 +40,20 @@
import { ContentLevels, AccessibilityCategories } from 'kolibri/constants';
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import { injectBaseSearch } from 'kolibri-common/composables/useBaseSearch';
+ import LanguageSelector from './LanguageSelector';
export default {
name: 'SelectGroup',
+ components: {
+ LanguageSelector,
+ },
mixins: [commonCoreStrings],
setup() {
- const {
- availableGradeLevels,
- availableAccessibilityOptions,
- availableLanguages,
- searchableLabels,
- } = injectBaseSearch();
+ const { availableGradeLevels, availableAccessibilityOptions, searchableLabels } =
+ injectBaseSearch();
return {
availableGradeLevels,
availableAccessibilityOptions,
- availableLanguages,
searchableLabels,
};
},
@@ -74,6 +66,10 @@
return inputKeys.every(k => Object.prototype.hasOwnProperty.call(value, k));
},
},
+ showLanguages: {
+ type: Boolean,
+ default: true,
+ },
},
computed: {
selectorStyle() {
@@ -83,19 +79,6 @@
borderRadius: '2px',
};
},
- languageOptionsList() {
- return this.availableLanguages.map(language => {
- return {
- value: language.id,
- disabled:
- this.searchableLabels && !this.searchableLabels.languages.includes(language.id),
- label: language.lang_name,
- };
- });
- },
- enabledLanguageOptions() {
- return this.languageOptionsList.filter(l => !l.disabled);
- },
accessibilityOptionsList() {
return this.availableAccessibilityOptions.map(key => {
const value = AccessibilityCategories[key];
@@ -133,15 +116,6 @@
enabledContentLevels() {
return this.contentLevelsList.filter(c => !c.disabled);
},
- langId() {
- return Object.keys(this.value.languages)[0];
- },
- selectedLanguage() {
- if (!this.langId && this.enabledLanguageOptions.length === 1) {
- return this.enabledLanguageOptions[0];
- }
- return this.languageOptionsList.find(o => o.value === this.langId) || {};
- },
accessId() {
return Object.keys(this.value.accessibility_labels)[0];
},
diff --git a/packages/kolibri-common/components/SearchFiltersPanel/index.vue b/packages/kolibri-common/components/SearchFiltersPanel/index.vue
index ba2eb32634f..d98e5244be1 100644
--- a/packages/kolibri-common/components/SearchFiltersPanel/index.vue
+++ b/packages/kolibri-common/components/SearchFiltersPanel/index.vue
@@ -85,7 +85,7 @@
Promise.resolve(true)),
+ createBaseSearchGetParams: jest.fn(() => ({})),
};
export function useBaseSearchMock(overrides = {}) {
@@ -82,6 +84,9 @@ export const injectBaseSearch = jest.fn(() => ({
availableChannels: [],
searchableLabels: [],
activeSearchTerms: [],
+ languageOptions: [],
+ enabledLanguageOptions: [],
+ removeSearchTerm: jest.fn(),
}));
export const searchKeys = [];
diff --git a/packages/kolibri-common/composables/__tests__/useBaseSearch.spec.js b/packages/kolibri-common/composables/__tests__/useBaseSearch.spec.js
index b4b46c14a82..ad4d77dc689 100644
--- a/packages/kolibri-common/composables/__tests__/useBaseSearch.spec.js
+++ b/packages/kolibri-common/composables/__tests__/useBaseSearch.spec.js
@@ -39,6 +39,12 @@ function prep(query = {}, descendant = null) {
}
describe(`useBaseSearch`, () => {
+ async function waitThreeTicks() {
+ // Because of async behaviour in search method, we need to wait for three ticks
+ await nextTick();
+ await nextTick();
+ await nextTick();
+ }
beforeEach(() => {
ContentNodeResource.fetchCollection = jest.fn();
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
@@ -196,9 +202,8 @@ describe(`useBaseSearch`, () => {
describe('search method', () => {
it('should call ContentNodeResource.fetchCollection when searchTerms changes', async () => {
const { store } = prep();
- ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
store.commit('SET_QUERY', { categories: 'test1,test2' });
- await nextTick();
+ await waitThreeTicks();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: {
categories: ['test1', 'test2'],
@@ -207,25 +212,28 @@ describe(`useBaseSearch`, () => {
},
});
});
- it('should not call ContentNodeResource.fetchCollection if there is no search', () => {
+ it('should not call ContentNodeResource.fetchCollection if there is no search', async () => {
const { search } = prep();
ContentNodeResource.fetchCollection.mockClear();
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
search();
+ await waitThreeTicks();
expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled();
});
- it('should clear labels and more if there is no search', () => {
+ it('should clear labels and more if there is no search', async () => {
const { search, labels, more } = prep();
set(labels, ['test']);
set(more, { test: 'test' });
search();
+ await waitThreeTicks();
expect(get(labels)).toBeNull();
expect(get(more)).toBeNull();
});
- it('should call ContentNodeResource.fetchCollection if there is no search but a descendant is set', () => {
+ it('should call ContentNodeResource.fetchCollection if there is no search but a descendant is set', async () => {
const { search } = prep({}, ref({ tree_id: 1, lft: 10, rght: 20 }));
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
search();
+ await waitThreeTicks();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: {
tree_id: 1,
@@ -245,14 +253,16 @@ describe(`useBaseSearch`, () => {
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({ labels: labelsSet }));
set(more, { test: 'test' });
search();
- await nextTick();
+ await waitThreeTicks();
expect(get(more)).toBeNull();
expect(get(labels)).toEqual(labelsSet);
});
- it('should call ContentNodeResource.fetchCollection when searchTerms exist', () => {
+ it('should call ContentNodeResource.fetchCollection when searchTerms exist', async () => {
const { search } = prep({ categories: 'test1,test2' });
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
+ await nextTick();
search();
+ await waitThreeTicks();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: {
categories: ['test1', 'test2'],
@@ -261,26 +271,29 @@ describe(`useBaseSearch`, () => {
},
});
});
- it('should ignore other categories when AllCategories is set and search for isnull false', () => {
+ it('should ignore other categories when AllCategories is set and search for isnull false', async () => {
const { search } = prep({ categories: `test1,test2,${NoCategories},${AllCategories}` });
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
search();
+ await waitThreeTicks();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: { categories__isnull: false, max_results: 25, include_coach_content: false },
});
});
- it('should ignore other categories when NoCategories is set and search for isnull true', () => {
+ it('should ignore other categories when NoCategories is set and search for isnull true', async () => {
const { search } = prep({ categories: `test1,test2,${NoCategories}` });
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
search();
+ await waitThreeTicks();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: { categories__isnull: true, max_results: 25, include_coach_content: false },
});
});
- it('should set keywords when defined', () => {
+ it('should set keywords when defined', async () => {
const { search } = prep({ keywords: `this is just a test` });
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
search();
+ await waitThreeTicks();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: {
keywords: `this is just a test`,
@@ -307,7 +320,7 @@ describe(`useBaseSearch`, () => {
}),
);
search();
- await nextTick();
+ await waitThreeTicks();
expect(get(labels)).toEqual(expectedLabels);
expect(get(results)).toEqual(expectedResults);
expect(get(more)).toEqual(expectedMore);
@@ -366,7 +379,7 @@ describe(`useBaseSearch`, () => {
}),
);
search();
- await nextTick();
+ await waitThreeTicks();
const expectedResults = [{ id: 'node-id1', content_id: 'second' }];
ContentNodeResource.fetchCollection.mockReturnValue(
Promise.resolve({
@@ -377,18 +390,18 @@ describe(`useBaseSearch`, () => {
);
set(more, {});
searchMore();
- await nextTick();
+ await waitThreeTicks();
expect(get(labels)).toEqual(expectedLabels);
expect(get(results)).toEqual(originalResults.concat(expectedResults));
expect(get(more)).toEqual(expectedMore);
});
});
- describe('removeFilterTag method', () => {
+ describe('removeSearchTerm method', () => {
it('should remove a filter from the searchTerms', () => {
- const { removeFilterTag, router } = prep({
+ const { removeSearchTerm, router } = prep({
categories: 'test1,test2',
});
- removeFilterTag({ value: 'test1', key: 'categories' });
+ removeSearchTerm({ value: 'test1', key: 'categories' });
expect(router.push).toHaveBeenCalledWith({
name,
query: {
@@ -397,21 +410,21 @@ describe(`useBaseSearch`, () => {
});
});
it('should remove keywords from the searchTerms', () => {
- const { removeFilterTag, router } = prep({
+ const { removeSearchTerm, router } = prep({
keywords: 'test',
});
- removeFilterTag({ value: 'test', key: 'keywords' });
+ removeSearchTerm({ value: 'test', key: 'keywords' });
expect(router.push).toHaveBeenCalledWith({
name,
query: {},
});
});
it('should not remove any other filters', () => {
- const { removeFilterTag, router } = prep({
+ const { removeSearchTerm, router } = prep({
categories: 'test1,test2',
learning_activities: 'watch',
});
- removeFilterTag({ value: 'test1', key: 'categories' });
+ removeSearchTerm({ value: 'test1', key: 'categories' });
expect(router.push).toHaveBeenCalledWith({
name,
query: {
diff --git a/packages/kolibri-common/composables/useBaseSearch.js b/packages/kolibri-common/composables/useBaseSearch.js
index dcee9eb8a25..80a425aab89 100644
--- a/packages/kolibri-common/composables/useBaseSearch.js
+++ b/packages/kolibri-common/composables/useBaseSearch.js
@@ -1,5 +1,6 @@
-import { get, set } from '@vueuse/core';
+import { get, set, until } from '@vueuse/core';
import invert from 'lodash/invert';
+import orderBy from 'lodash/orderBy';
import logger from 'kolibri-logging';
import { computed, getCurrentInstance, inject, provide, ref, watch } from 'vue';
import ContentNodeResource from 'kolibri-common/apiResources/ContentNodeResource';
@@ -13,6 +14,7 @@ import {
NoCategories,
ResourcesNeededTypes,
} from 'kolibri/constants';
+import { getContentLangActive } from 'kolibri/utils/i18n';
import useUser from 'kolibri/composables/useUser';
import { deduplicateResources } from '../utils/contentNode';
@@ -152,13 +154,17 @@ export const searchKeys = [
'grade_levels',
];
+export const primaryLanguageKey = 'primaryLanguage';
+
+const allLanguagesValue = 'allLangs';
+
export default function useBaseSearch({
descendant,
store,
router,
baseurl,
fetchContentNodeProgress,
-}) {
+} = {}) {
// Get store and router references from the curent instance
// but allow them to be passed in to allow for dependency
// injection, primarily for tests.
@@ -213,6 +219,24 @@ export default function useBaseSearch({
},
});
+ const primaryLanguage = computed({
+ get() {
+ if (get(route).query[primaryLanguageKey] === allLanguagesValue) {
+ return;
+ }
+ return get(route).query[primaryLanguageKey];
+ },
+ set(value) {
+ const query = { ...get(route).query };
+ if (value) {
+ query[primaryLanguageKey] = value;
+ } else {
+ query[primaryLanguageKey] = allLanguagesValue;
+ }
+ router.push({ ...get(route), query }).catch(() => {});
+ },
+ });
+
const displayingSearchResults = computed(() =>
// Happily this works even for keywords, because calling Object.keys
// on a string value will give an array of the indexes of a string
@@ -230,43 +254,65 @@ export default function useBaseSearch({
}
}
- function search() {
- const currentBaseUrl = get(baseurl);
+ function createBaseSearchGetParams(setDescendant = true) {
const getParams = {
include_coach_content: get(isAdmin) || get(isCoach) || get(isSuperuser),
- baseurl: currentBaseUrl,
+ baseurl: get(baseurl),
};
- const descValue = descendant ? get(descendant) : null;
- if (descValue) {
- getParams.tree_id = descValue.tree_id;
- getParams.lft__gt = descValue.lft;
- getParams.rght__lt = descValue.rght;
+ if (setDescendant && descendant) {
+ const descValue = get(descendant);
+ if (descValue) {
+ getParams.tree_id = descValue.tree_id;
+ getParams.lft__gt = descValue.lft;
+ getParams.rght__lt = descValue.rght;
+ }
}
- if (get(displayingSearchResults)) {
- getParams.max_results = 25;
- const terms = get(searchTerms);
- set(searchResultsLoading, true);
- for (const key of searchKeys) {
- if (key === 'categories') {
- if (terms[key][AllCategories]) {
- getParams['categories__isnull'] = false;
- continue;
- } else if (terms[key][NoCategories]) {
- getParams['categories__isnull'] = true;
- continue;
- }
- }
- if (key === 'channels' && descValue) {
+ const primaryLang = get(primaryLanguage);
+ if (primaryLang && primaryLang !== allLanguagesValue) {
+ const langCode = primaryLang.split('-')[0];
+ getParams.languages = get(languagesList)
+ .filter(lang => lang.id.startsWith(langCode))
+ .map(lang => lang.id);
+ }
+ return getParams;
+ }
+
+ function createSearchGetParams() {
+ const getParams = createBaseSearchGetParams();
+ const terms = get(searchTerms);
+ for (const key of searchKeys) {
+ if (key === 'categories') {
+ if (terms[key][AllCategories]) {
+ getParams['categories__isnull'] = false;
+ continue;
+ } else if (terms[key][NoCategories]) {
+ getParams['categories__isnull'] = true;
continue;
- }
- const keys = Object.keys(terms[key]);
- if (keys.length) {
- getParams[key] = keys;
}
}
- if (terms.keywords) {
- getParams.keywords = terms.keywords;
+ if (key === 'channels' && descendant ? get(descendant) : null) {
+ continue;
}
+ const keys = Object.keys(terms[key]);
+ if (keys.length) {
+ getParams[key] = keys;
+ }
+ }
+ if (terms.keywords) {
+ getParams.keywords = terms.keywords;
+ }
+ return getParams;
+ }
+
+ async function search() {
+ await until(globalLabelsLoading).toBe(false);
+ const desc = descendant ? get(descendant) : null;
+ if (get(displayingSearchResults)) {
+ // If we're actually displaying search results
+ // then we need to load all the search results to display
+ set(searchResultsLoading, true);
+ const getParams = createSearchGetParams();
+ getParams.max_results = 25;
if (get(isUserLoggedIn)) {
fetchContentNodeProgress?.(getParams);
}
@@ -276,7 +322,8 @@ export default function useBaseSearch({
_setAvailableLabels(data.labels);
set(searchResultsLoading, false);
});
- } else if (descValue) {
+ } else if (desc || get(primaryLanguage)) {
+ const getParams = createBaseSearchGetParams();
getParams.max_results = 1;
ContentNodeResource.fetchCollection({ getParams }).then(data => {
_setAvailableLabels(data.labels);
@@ -305,7 +352,7 @@ export default function useBaseSearch({
}
}
- function removeFilterTag({ value, key }) {
+ function removeSearchTerm({ value, key }) {
if (key === 'keywords') {
set(searchTerms, {
...get(searchTerms),
@@ -335,6 +382,8 @@ export default function useBaseSearch({
});
}
+ watch(primaryLanguage, search);
+
// Helper to get the route information in a setup() function
function currentRoute() {
return get(route);
@@ -409,9 +458,61 @@ export default function useBaseSearch({
return _getGlobalLabels('accessibilityOptionsList', []);
});
const languagesList = computed(() => {
- return _getGlobalLabels('languagesList', []);
+ return orderBy(
+ _getGlobalLabels('languagesList', []),
+ [getContentLangActive, 'id'],
+ ['desc', 'asc'],
+ );
});
+ const languageOptionsList = computed(() => {
+ const searchableLabels = get(labels);
+ return get(languagesList).map(language => {
+ return {
+ value: language.id,
+ disabled: searchableLabels && !searchableLabels.languages.includes(language.id),
+ label: language.lang_name,
+ };
+ });
+ });
+
+ const enabledLanguageOptions = computed(() => {
+ return get(languageOptionsList).filter(l => !l.disabled);
+ });
+
+ const currentLanguageId = computed({
+ get() {
+ return get(searchTerms).languages ? Object.keys(get(searchTerms).languages)[0] : null;
+ },
+ set(value) {
+ set(searchTerms, {
+ ...get(searchTerms),
+ languages: value ? { [value]: true } : {},
+ });
+ },
+ });
+
+ async function ensurePrimaryLanguage() {
+ await until(globalLabelsLoading).toBe(false);
+ if (!get(route).query[primaryLanguageKey]) {
+ // If we have no currently selected primary language key
+ // compute the closest match to the currently active interface language
+ const closestMatchChannelLanguage = get(languagesList)[0];
+ const language =
+ closestMatchChannelLanguage && getContentLangActive(closestMatchChannelLanguage) > 1
+ ? closestMatchChannelLanguage.id
+ : allLanguagesValue;
+ router.replace({
+ query: {
+ ...currentRoute().query,
+ [primaryLanguageKey]: language,
+ },
+ });
+ return false;
+ }
+ return true;
+ }
+
provide('availableLearningActivities', learningActivitiesShown);
provide('availableLibraryCategories', libraryCategories);
provide('availableResourcesNeeded', resourcesNeeded);
@@ -419,6 +520,11 @@ export default function useBaseSearch({
provide('availableAccessibilityOptions', accessibilityOptionsList);
provide('availableLanguages', languagesList);
+ provide('currentLanguageId', currentLanguageId);
+ provide('currentPrimaryLanguageId', primaryLanguage);
+ provide('languageOptions', languageOptionsList);
+ provide('enabledLanguageOptions', enabledLanguageOptions);
+
// Provide an object of searchable labels
// This is a manifest of all the labels that could still be selected and produce search results
// given the currently applied search filters.
@@ -427,9 +533,12 @@ export default function useBaseSearch({
// Currently selected search terms
provide('activeSearchTerms', searchTerms);
+ provide('removeSearchTerm', removeSearchTerm);
+
return {
currentRoute,
searchTerms,
+ createBaseSearchGetParams,
displayingSearchResults,
searchLoading,
moreLoading,
@@ -438,8 +547,10 @@ export default function useBaseSearch({
labels,
search,
searchMore,
- removeFilterTag,
clearSearch,
+ primaryLanguage,
+ ensurePrimaryLanguage,
+ removeSearchTerm,
};
}
@@ -456,6 +567,14 @@ export function injectBaseSearch() {
const availableLanguages = inject('availableLanguages');
const searchableLabels = inject('searchableLabels');
const activeSearchTerms = inject('activeSearchTerms');
+
+ const currentLanguageId = inject('currentLanguageId');
+ const currentPrimaryLanguageId = inject('currentPrimaryLanguageId');
+ const languageOptions = inject('languageOptions');
+ const enabledLanguageOptions = inject('enabledLanguageOptions');
+
+ const removeSearchTerm = inject('removeSearchTerm');
+
return {
availableLearningActivities,
availableLibraryCategories,
@@ -465,5 +584,10 @@ export function injectBaseSearch() {
availableLanguages,
searchableLabels,
activeSearchTerms,
+ currentLanguageId,
+ currentPrimaryLanguageId,
+ languageOptions,
+ enabledLanguageOptions,
+ removeSearchTerm,
};
}
diff --git a/packages/kolibri/utils/i18n.js b/packages/kolibri/utils/i18n.js
index 769cb5f368c..cc9ce41e07b 100644
--- a/packages/kolibri/utils/i18n.js
+++ b/packages/kolibri/utils/i18n.js
@@ -1,4 +1,5 @@
import has from 'lodash/has';
+import isString from 'lodash/isString';
import Vue from 'vue';
import logger from 'kolibri-logging';
import plugin_data from 'kolibri-plugin-data';
@@ -32,15 +33,30 @@ const contentLanguageCodes = {
yo: ['yor'],
};
-export const getContentLangActive = language => {
- const langCode = languageIdToCode(currentLanguage);
+export const getContentLangActive = (language, comparisonLanguage = currentLanguage) => {
+ if (isString(language)) {
+ // If the language is a string, assume it's a language code
+ // convert to object format
+ language = {
+ id: language,
+ lang_code: languageIdToCode(language),
+ };
+ }
+ const langCode = languageIdToCode(comparisonLanguage);
const additionalCodes = contentLanguageCodes[langCode] || [];
- if (language.id.toLowerCase() === currentLanguage.toLowerCase()) {
- // Best possible match, return a 2 to have it still be truthy, but distinguishable
- // from a 1 which is a lang_code match
+ if (language.id.toLowerCase() === comparisonLanguage.toLowerCase()) {
+ // Best possible match, return a 3 to sort first
+ // Exact match between content and the language the user is using
+ return 3;
+ }
+ if (language.id === langCode || additionalCodes.includes(language.id)) {
+ // Here the language for the content has no region code, and we have an exact
+ // match with the language we are using.
return 2;
}
if (language.lang_code === langCode || additionalCodes.includes(language.lang_code)) {
+ // Here the language for the content has a region code, but the language itself
+ // matches, even if the region is different.
return 1;
}
return 0;