diff --git a/packages/kolibri-common/composables/__mocks__/useBaseSearch.js b/packages/kolibri-common/composables/__mocks__/useBaseSearch.js new file mode 100644 index 00000000000..ba904503ab8 --- /dev/null +++ b/packages/kolibri-common/composables/__mocks__/useBaseSearch.js @@ -0,0 +1,88 @@ +/** + * `useBaseSearch` composable function mock. + * + * If default values are sufficient for tests, + * you only need call `jest.mock('')` + * at the top of a test file. + * + * If you need to override some default values from some tests, + * you can import a helper function `useBaseSearch` that accepts + * an object with values to be overriden and use it together + * with `mockImplementation` as follows: + * + * ``` + * // eslint-disable-next-line import/named + * import useBaseSearch, { useBaseSearch } from ''; + * + * jest.mock('') + * + * it('test', () => { + * useBaseSearch.mockImplementation( + * () => useBaseSearch({ classes: [{ id: 'class-1' }] }) + * ); + * }) + * ``` + * + * You can reset your mock implementation back to default values + * for other tests by calling the following in `beforeEach`: + * + * ``` + * useBaseSearch.mockImplementation(() => useBaseSearch()) + * ``` + */ + +const MOCK_DEFAULTS = { + searchTerms: { + learning_activities: {}, + categories: {}, + learner_needs: {}, + channels: {}, + accessibility_labels: {}, + languages: {}, + grade_levels: {}, + }, + displayingSearchResults: false, + searchLoading: false, + moreLoading: false, + results: [], + more: null, + labels: null, + search: jest.fn(), + searchMore: jest.fn(), + removeFilterTag: jest.fn(), + clearSearch: jest.fn(), + currentRoute: jest.fn(() => { + // return a $route-flavored object to avoid undefined errors + return { + params: {}, + query: {}, + path: '', + fullPath: '', + name: '', + meta: {}, + }; + }), +}; + +export function useBaseSearch(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export default jest.fn(() => useBaseSearch()); + +export const injectSearch = jest.fn(() => ({ + availableLearningActivities: [], + availableLibraryCategories: [], + availableResourcesNeeded: [], + availableGradeLevels: [], + availableAccessibilityOptions: [], + availableLanguages: [], + availableChannels: [], + searchableLabels: [], + activeSearchTerms: [], +})); + +export const searchKeys = []; diff --git a/packages/kolibri-common/composables/__tests__/useBaseSearch.spec.js b/packages/kolibri-common/composables/__tests__/useBaseSearch.spec.js new file mode 100644 index 00000000000..e684d417b67 --- /dev/null +++ b/packages/kolibri-common/composables/__tests__/useBaseSearch.spec.js @@ -0,0 +1,468 @@ +import { get, set } from '@vueuse/core'; +import VueRouter from 'vue-router'; +import Vue from 'vue'; +import { ref } from 'kolibri.lib.vueCompositionApi'; +import { ContentNodeResource } from 'kolibri.resources'; +import { coreStoreFactory } from 'kolibri.coreVue.vuex.store'; +import { AllCategories, NoCategories } from 'kolibri.coreVue.vuex.constants'; +import useBaseSearch from '../useBaseSearch'; +import coreModule from '../../../../kolibri/core/assets/src/state/modules/core'; + +Vue.use(VueRouter); + +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; + }, + }, + }); + const router = new VueRouter(); + router.push = jest.fn().mockReturnValue(Promise.resolve()); + store.registerModule('core', coreModule); + return { + ...useBaseSearch(descendant, store, router), + router, + store, + }; +} + +describe(`useBaseSearch`, () => { + beforeEach(() => { + ContentNodeResource.fetchCollection = jest.fn(); + ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); + }); + 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.mockReturnValue(Promise.resolve({})); + store.commit('SET_QUERY', { categories: 'test1,test2' }); + await Vue.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'], + channels: [], + languages: [], + }; + 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.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 ignore channels when descendant is set', () => { + const { search } = prep( + { + categories: `test1,test2`, + channels: 'test1', + }, + ref({ tree_id: 1, lft: 10, rght: 20 }), + ); + 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, + 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'], + channels: [], + languages: [], + }; + const expectedMore = { + cursor: 'adalskdjsadlkjsadlkjsalkd', + }; + const expectedResults = [{ id: 'node-id1' }]; + 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); + 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'], + channels: [], + 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 Vue.nextTick(); + const expectedResults = [{ id: 'node-id1', content_id: 'second' }]; + ContentNodeResource.fetchCollection.mockReturnValue( + Promise.resolve({ + labels: expectedLabels, + results: expectedResults, + more: expectedMore, + }), + ); + set(more, {}); + searchMore(); + await Vue.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', + 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: {}, + }); + }); + }); +}); diff --git a/packages/kolibri-common/composables/useBaseSearch.js b/packages/kolibri-common/composables/useBaseSearch.js new file mode 100644 index 00000000000..628006e4319 --- /dev/null +++ b/packages/kolibri-common/composables/useBaseSearch.js @@ -0,0 +1,484 @@ +import { get, set } from '@vueuse/core'; +import invert from 'lodash/invert'; +import logger from 'kolibri.lib.logging'; +import { + computed, + getCurrentInstance, + inject, + provide, + ref, + watch, +} from 'kolibri.lib.vueCompositionApi'; +import { ContentNodeResource } from 'kolibri.resources'; +import { + AllCategories, + Categories, + CategoriesLookup, + ContentLevels, + AccessibilityCategories, + LearningActivities, + NoCategories, + ResourcesNeededTypes, +} from 'kolibri.coreVue.vuex.constants'; +import { setLanguages } from './useLanguages'; + +//TBD #12517 -- See diff in #12566 +const deduplicateResources = o => o; + +export const logging = logger.getLogger(__filename); + +const activitiesLookup = invert(LearningActivities); + +function _generateLearningActivitiesShown(learningActivities) { + const learningActivitiesShown = {}; + + (learningActivities || []).map(id => { + const key = activitiesLookup[id]; + learningActivitiesShown[key] = id; + }); + return learningActivitiesShown; +} + +const resourcesNeededShown = [ + 'FOR_BEGINNERS', + 'PEERS', + 'TEACHER', + 'SPECIAL_SOFTWARE', + 'PAPER_PENCIL', + 'INTERNET', + 'OTHER_SUPPLIES', +]; + +function _generateResourcesNeeded(learnerNeeds) { + const resourcesNeeded = {}; + resourcesNeededShown.map(key => { + const value = ResourcesNeededTypes[key]; + if (learnerNeeds && learnerNeeds.includes(value)) { + resourcesNeeded[key] = value; + } + }); + return resourcesNeeded; +} + +const gradeLevelsShown = [ + 'BASIC_SKILLS', + 'PRESCHOOL', + 'LOWER_PRIMARY', + 'UPPER_PRIMARY', + 'LOWER_SECONDARY', + 'UPPER_SECONDARY', + 'TERTIARY', + 'PROFESSIONAL', + 'WORK_SKILLS', +]; + +function _generateGradeLevelsList(gradeLevels) { + return gradeLevelsShown.filter(key => { + return gradeLevels && gradeLevels.includes(ContentLevels[key]); + }); +} + +const accessibilityLabelsShown = [ + 'SIGN_LANGUAGE', + 'AUDIO_DESCRIPTION', + 'TAGGED_PDF', + 'ALT_TEXT', + 'HIGH_CONTRAST', + 'CAPTIONS_SUBTITLES', +]; + +function _generateAccessibilityOptionsList(accessibilityLabels) { + return accessibilityLabelsShown.filter(key => { + return accessibilityLabels && accessibilityLabels.includes(AccessibilityCategories[key]); + }); +} + +function _generateLibraryCategoriesLookup(categories) { + const libraryCategories = {}; + + const availablePaths = {}; + + (categories || []).map(key => { + const paths = key.split('.'); + let path = ''; + for (const path_segment of paths) { + path = path === '' ? path_segment : path + '.' + path_segment; + availablePaths[path] = true; + } + }); + // Create a nested object representing the hierarchy of categories + for (const value of Object.values(Categories) + // Sort by the length of the key path to deal with + // shorter key paths first. + .sort((a, b) => a.length - b.length)) { + // Split the value into the paths so we can build the object + // down the path to create the nested representation + const ids = value.split('.'); + // Start with an empty path + let path = ''; + // Start with the global object + let nested = libraryCategories; + for (const fragment of ids) { + // Add the fragment to create the path we examine + path += fragment; + // Check to see if this path is one of the paths + // that is available on this device + if (availablePaths[path]) { + // Lookup the human readable key for this path + const nestedKey = CategoriesLookup[path]; + // Check if we have already represented this in the object + if (!nested[nestedKey]) { + // If not, add an object representing this category + nested[nestedKey] = { + // The value is the whole path to this point, so the value + // of the key. + value: path, + // Nested is an object that contains any subsidiary categories + nested: {}, + }; + } + // For the next stage of the loop the relevant object to edit is + // the nested object under this key. + nested = nested[nestedKey].nested; + // Add '.' to path so when we next append to the path, + // it is properly '.' separated. + path += '.'; + } else { + break; + } + } + } + return libraryCategories; +} + +export const searchKeys = [ + 'learning_activities', + 'categories', + 'learner_needs', + 'channels', + 'accessibility_labels', + 'languages', + 'grade_levels', +]; + +// TBD #12517 - Will be injected in subsequent work. +const fetchContentNodeProgress = Promise.resolve({}); + +export default function useBaseSearch(descendant, store, router) { + // Get store and router references from the curent instance + // but allow them to be passed in to allow for dependency + // injection, primarily for tests. + store = store || getCurrentInstance().proxy.$store; + router = router || getCurrentInstance().proxy.$router; + const route = computed(() => store.state.route); + + const searchResultsLoading = ref(false); + const moreLoading = ref(false); + const _results = ref([]); + const more = ref(null); + const labels = ref(null); + + // TODO: Kolibri #12517 - Will be injected in subsequent work + const baseurl = undefined; + + const searchTerms = computed({ + get() { + const searchTerms = {}; + const query = get(route).query; + for (const key of searchKeys) { + const obj = {}; + if (query[key]) { + for (const value of query[key].split(',')) { + obj[value] = true; + } + } + searchTerms[key] = obj; + } + searchTerms.keywords = query.keywords || ''; + return searchTerms; + }, + set(value) { + const query = { ...get(route).query }; + for (const key of searchKeys) { + const val = Object.keys(value[key] || {}) + .filter(Boolean) + .join(','); + if (val.length) { + query[key] = Object.keys(value[key]).join(','); + } else { + delete query[key]; + } + } + if (value.keywords && value.keywords.length) { + query.keywords = value.keywords; + } else { + delete query.keywords; + } + // Just catch an error from making a redundant navigation rather + // than try to precalculate this. + 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 + // for an empty string, this array will be empty, meaning that this + // check still works! + Object.values(get(searchTerms)).some(v => Object.keys(v).length), + ); + + function _setAvailableLabels(searchableLabels) { + if (searchableLabels) { + set(labels, { + ...searchableLabels, + channels: searchableLabels.channels ? searchableLabels.channels.map(c => c.id) : [], + languages: searchableLabels.languages ? searchableLabels.languages.map(l => l.id) : [], + }); + } + } + + function search() { + const currentBaseUrl = get(baseurl); + const getParams = { + include_coach_content: + store.getters.isAdmin || store.getters.isCoach || store.getters.isSuperuser, + baseurl: currentBaseUrl, + }; + 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 (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) { + continue; + } + const keys = Object.keys(terms[key]); + if (keys.length) { + getParams[key] = keys; + } + } + if (terms.keywords) { + getParams.keywords = terms.keywords; + } + if (store.getters.isUserLoggedIn) { + fetchContentNodeProgress(getParams); + } + ContentNodeResource.fetchCollection({ getParams }).then(data => { + set(_results, data.results || []); + set(more, data.more); + _setAvailableLabels(data.labels); + set(searchResultsLoading, false); + }); + } else if (descValue) { + getParams.max_results = 1; + ContentNodeResource.fetchCollection({ getParams }).then(data => { + _setAvailableLabels(data.labels); + set(more, null); + }); + } else { + // Clear labels if no search results displaying + // and we're not gathering labels from the descendant + set(more, null); + set(labels, null); + } + } + + function searchMore() { + if (get(displayingSearchResults) && get(more) && !get(moreLoading)) { + set(moreLoading, true); + if (store.getters.isUserLoggedIn) { + fetchContentNodeProgress(get(more)); + } + return ContentNodeResource.fetchCollection({ getParams: get(more) }).then(data => { + set(_results, [...get(_results), ...(data.results || [])]); + set(more, data.more); + _setAvailableLabels(data.labels); + set(moreLoading, false); + }); + } + } + + function removeFilterTag({ value, key }) { + if (key === 'keywords') { + set(searchTerms, { + ...get(searchTerms), + [key]: '', + }); + } else { + const keyObject = get(searchTerms)[key]; + delete keyObject[value]; + set(searchTerms, { + ...get(searchTerms), + [key]: keyObject, + }); + } + } + + function clearSearch() { + set(searchTerms, {}); + } + + watch(searchTerms, search); + + if (descendant) { + watch(descendant, newValue => { + if (newValue) { + search(); + } + }); + } + + // Helper to get the route information in a setup() function + function currentRoute() { + return get(route); + } + + const results = computed(() => { + return deduplicateResources(get(_results)); + }); + + // Globally available metadata labels + // These are the labels that are available globally for this search context + // These labels may be disabled for specific searches within a search context + // We use provide/inject here to allow a parent + // component to setup the available labels for child components + // to consume them. + + const globalLabels = ref(null); + + const globalLabelsLoading = ref(false); + + const searchLoading = computed(() => get(searchResultsLoading) || get(globalLabelsLoading)); + + function ensureGlobalLabels() { + set(globalLabelsLoading, true); + const currentBaseUrl = get(baseurl); + ContentNodeResource.fetchCollection({ + getParams: { max_results: 1, baseurl: currentBaseUrl }, + }) + .then(data => { + const labels = data.labels; + set(globalLabels, { + learningActivitiesShown: _generateLearningActivitiesShown(labels.learning_activities), + libraryCategories: _generateLibraryCategoriesLookup(labels.categories), + resourcesNeeded: _generateResourcesNeeded(labels.learner_needs), + gradeLevelsList: _generateGradeLevelsList(labels.grade_levels || []), + accessibilityOptionsList: _generateAccessibilityOptionsList(labels.accessibility_labels), + languagesList: labels.languages || [], + channelsList: labels.channels || [], + }); + setLanguages(labels.languages || []); + }) + .catch(err => logging.error('Failed to fetch search labels from remote', err)) + .then(() => { + set(globalLabelsLoading, false); + }); + } + + ensureGlobalLabels(); + watch(baseurl, ensureGlobalLabels); + + function _getGlobalLabels(name, defaultValue) { + const lookup = get(globalLabels); + if (lookup) { + return lookup[name]; + } + return defaultValue; + } + + const learningActivitiesShown = computed(() => { + return _getGlobalLabels('learningActivitiesShown', {}); + }); + const libraryCategories = computed(() => { + return _getGlobalLabels('libraryCategories', {}); + }); + const resourcesNeeded = computed(() => { + return _getGlobalLabels('resourcesNeeded', {}); + }); + const gradeLevelsList = computed(() => { + return _getGlobalLabels('gradeLevelsList', []); + }); + const accessibilityOptionsList = computed(() => { + return _getGlobalLabels('accessibilityOptionsList', []); + }); + const languagesList = computed(() => { + return _getGlobalLabels('languagesList', []); + }); + const channelsList = computed(() => { + return _getGlobalLabels('channelsList', []); + }); + + provide('availableLearningActivities', learningActivitiesShown); + provide('availableLibraryCategories', libraryCategories); + provide('availableResourcesNeeded', resourcesNeeded); + provide('availableGradeLevels', gradeLevelsList); + provide('availableAccessibilityOptions', accessibilityOptionsList); + provide('availableLanguages', languagesList); + provide('availableChannels', channelsList); + + // 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. + provide('searchableLabels', labels); + + // Currently selected search terms + provide('activeSearchTerms', searchTerms); + + return { + currentRoute, + searchTerms, + displayingSearchResults, + searchLoading, + moreLoading, + results, + more, + labels, + search, + searchMore, + removeFilterTag, + clearSearch, + }; +} + +/* + * Helper function to retrieve references for provided properties + * from an ancestor's use of useBaseSearch + */ +export function injectSearch() { + const availableLearningActivities = inject('availableLearningActivities'); + const availableLibraryCategories = inject('availableLibraryCategories'); + const availableResourcesNeeded = inject('availableResourcesNeeded'); + const availableGradeLevels = inject('availableGradeLevels'); + const availableAccessibilityOptions = inject('availableAccessibilityOptions'); + const availableLanguages = inject('availableLanguages'); + const availableChannels = inject('availableChannels'); + const searchableLabels = inject('searchableLabels'); + const activeSearchTerms = inject('activeSearchTerms'); + return { + availableLearningActivities, + availableLibraryCategories, + availableResourcesNeeded, + availableGradeLevels, + availableAccessibilityOptions, + availableLanguages, + availableChannels, + searchableLabels, + activeSearchTerms, + }; +} diff --git a/packages/kolibri-common/composables/useLanguages.js b/packages/kolibri-common/composables/useLanguages.js new file mode 100644 index 00000000000..2f05fff81c9 --- /dev/null +++ b/packages/kolibri-common/composables/useLanguages.js @@ -0,0 +1,30 @@ +/** + * A composable function containing logic related to languages + */ + +import sortBy from 'lodash/sortBy'; +import { computed, ref } from 'kolibri.lib.vueCompositionApi'; +import { get } from '@vueuse/core'; +import plugin_data from 'plugin_data'; + +const langArray = plugin_data.languages ? plugin_data.languages : []; +const langMap = {}; + +export function setLanguages(langs) { + for (const lang of langs) { + langMap[lang.id] = lang; + } +} + +setLanguages(langArray); + +// The refs are defined in the outer scope so they can be used as a shared store +const languagesMap = ref(langMap); + +export default function useLanguages() { + const languages = computed(() => sortBy(Object.values(get(languagesMap)), 'id')); + return { + languages, + languagesMap, + }; +}