diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CategoryOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CategoryOptions.vue new file mode 100644 index 0000000000..29eed00850 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CategoryOptions.vue @@ -0,0 +1,221 @@ + + + + removeAll())" + > + + + + + {{ data.item.text }} + + + + {{ tooltipHelper(data.item.value) }} + + + + + + + + + {{ $tr('noCategoryFoundText', { text: categoryText.trim() }) }} + + + + + + + + + checked ? add(item.value) : remove(item.value)" + /> + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue index 1cebc6c074..3fd74720d6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue @@ -61,18 +61,21 @@ > + + @@ -176,6 +181,7 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LevelsOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/LevelsOptions.vue index b163594b8f..bbf7de9a59 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LevelsOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/LevelsOptions.vue @@ -10,7 +10,7 @@ :label="translateMetadataString('level')" multiple deletableChips - attach="contentLevel" + attach="#levels" /> diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue index 6bb215f6d4..e6900d8d31 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue @@ -10,7 +10,7 @@ multiple deletableChips clearable - attach="resourcesNeeded" + attach="#resources_needed" /> diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js new file mode 100644 index 0000000000..c8a82a30e8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js @@ -0,0 +1,331 @@ +import Vue from 'vue'; +import Vuetify from 'vuetify'; +import { shallowMount } from '@vue/test-utils'; +import CategoryOptions from '../CategoryOptions.vue'; + +Vue.use(Vuetify); + +const testDropdown = [ + { + text: 'DAILY_LIFE', + value: 'PbGoe2MV', + }, + { + text: 'CURRENT_EVENTS', + value: 'PbGoe2MV.J7CU1IxN', + }, + { + text: 'DIVERSITY', + value: 'PbGoe2MV.EHcbjuKq', + }, + { + text: 'ENTREPRENEURSHIP', + value: 'PbGoe2MV.kyxTNsRS', + }, + { + text: 'ENVIRONMENT', + value: 'PbGoe2MV.tS7WKnZ7', + }, + { + text: 'FINANCIAL_LITERACY', + value: 'PbGoe2MV.HGIc9sZq', + }, + { + text: 'MEDIA_LITERACY', + value: 'PbGoe2MV.UOTL#KIV', + }, + { + text: 'MENTAL_HEALTH', + value: 'PbGoe2MV.d8&gCo2N', + }, + { + text: 'PUBLIC_HEALTH', + value: 'PbGoe2MV.kivAZaeX', + }, + { + text: 'FOR_TEACHERS', + value: 'ziJ6PCuU', + }, + { + text: 'GUIDES', + value: 'ziJ6PCuU.RLfhp37t', + }, + { + text: 'LESSON_PLANS', + value: 'ziJ6PCuU.lOBPr5ix', + }, + { + text: 'FOUNDATIONS', + value: 'BCG3&slG', + }, + { + text: 'DIGITAL_LITERACY', + value: 'BCG3&slG.wZ3EAedB', + }, + { + text: 'FOUNDATIONS_LOGIC_AND_CRITICAL_THINKING', + value: 'BCG3&slG.0&d0qTqS', + }, + { + text: 'LEARNING_SKILLS', + value: 'BCG3&slG.fP2j70bj', + }, + { + text: 'LITERACY', + value: 'BCG3&slG.HLo9TbNq', + }, + { + text: 'NUMERACY', + value: 'BCG3&slG.Tsyej9ta', + }, + { + text: 'SCHOOL', + value: 'd&WXdXWF', + }, + { + text: 'ARTS', + value: 'd&WXdXWF.5QAjgfv7', + }, + { + text: 'DANCE', + value: 'd&WXdXWF.5QAjgfv7.BUMJJBnS', + }, + { + text: 'DRAMA', + value: 'd&WXdXWF.5QAjgfv7.XsWznP4o', + }, + { + text: 'MUSIC', + value: 'd&WXdXWF.5QAjgfv7.u0aKjT4i', + }, + { + text: 'VISUAL_ART', + value: 'd&WXdXWF.5QAjgfv7.4LskOFXj', + }, + { + text: 'COMPUTER_SCIENCE', + value: 'd&WXdXWF.e#RTW9E#', + }, + { + text: 'MECHANICAL_ENGINEERING', + value: 'd&WXdXWF.e#RTW9E#.8ZoaPsVW', + }, + { + text: 'PROGRAMMING', + value: 'd&WXdXWF.e#RTW9E#.CfnlTDZ#', + }, + { + text: 'WEB_DESIGN', + value: 'd&WXdXWF.e#RTW9E#.P7s8FxQ8', + }, + { + text: 'HISTORY', + value: 'd&WXdXWF.zWtcJ&F2', + }, + { + text: 'LANGUAGE_LEARNING', + value: 'd&WXdXWF.JDUfJNXc', + }, + { + text: 'MATHEMATICS', + value: 'd&WXdXWF.qs0Xlaxq', + }, + { + text: 'ALGEBRA', + value: 'd&WXdXWF.qs0Xlaxq.0t5msbL5', + }, + { + text: 'ARITHMETIC', + value: 'd&WXdXWF.qs0Xlaxq.nG96nHDc', + }, + { + text: 'CALCULUS', + value: 'd&WXdXWF.qs0Xlaxq.8rJ57ht6', + }, + { + text: 'GEOMETRY', + value: 'd&WXdXWF.qs0Xlaxq.lb7ELcK5', + }, + { + text: 'STATISTICS', + value: 'd&WXdXWF.qs0Xlaxq.jNm15RLB', + }, + { + text: 'READING_AND_WRITING', + value: 'd&WXdXWF.kHKJ&PbV', + }, + { + text: 'LITERATURE', + value: 'd&WXdXWF.kHKJ&PbV.DJLBbaEk', + }, + { + text: 'LOGIC_AND_CRITICAL_THINKING', + value: 'd&WXdXWF.kHKJ&PbV.YMBXStib', + }, + { + text: 'READING_COMPREHENSION', + value: 'd&WXdXWF.kHKJ&PbV.r7RxB#9t', + }, + { + text: 'WRITING', + value: 'd&WXdXWF.kHKJ&PbV.KFJOCr&6', + }, + { + text: 'SCIENCES', + value: 'd&WXdXWF.i1IdaNwr', + }, + { + text: 'ASTRONOMY', + value: 'd&WXdXWF.i1IdaNwr.mjSF4QlF', + }, + { + text: 'BIOLOGY', + value: 'd&WXdXWF.i1IdaNwr.uErN4PdS', + }, + { + text: 'CHEMISTRY', + value: 'd&WXdXWF.i1IdaNwr.#r5ocgid', + }, + { + text: 'EARTH_SCIENCE', + value: 'd&WXdXWF.i1IdaNwr.zbDzxDE7', + }, + { + text: 'PHYSICS', + value: 'd&WXdXWF.i1IdaNwr.r#wbt#jF', + }, + { + text: 'SOCIAL_SCIENCES', + value: 'd&WXdXWF.K80UMYnW', + }, + { + text: 'ANTHROPOLOGY', + value: 'd&WXdXWF.K80UMYnW.ViBlbQR&', + }, + { + text: 'CIVIC_EDUCATION', + value: 'd&WXdXWF.K80UMYnW.F863vKiF', + }, + { + text: 'POLITICAL_SCIENCE', + value: 'd&WXdXWF.K80UMYnW.K72&pITr', + }, + { + text: 'SOCIOLOGY', + value: 'd&WXdXWF.K80UMYnW.75WBu1ZS', + }, + { + text: 'WORK', + value: 'l7DsPDlm', + }, + { + text: 'PROFESSIONAL_SKILLS', + value: 'l7DsPDlm.#N2VymZo', + }, + { + text: 'TECHNICAL_AND_VOCATIONAL_TRAINING', + value: 'l7DsPDlm.ISEXeZt&', + }, + { + text: 'INDUSTRY_AND_SECTOR_SPECIFIC', + value: 'l7DsPDlm.ISEXeZt&.pRvOzJTE', + }, + { + text: 'SKILLS_TRAINING', + value: 'l7DsPDlm.ISEXeZt&.&1WpYE&n', + }, + { + text: 'TOOLS_AND_SOFTWARE_TRAINING', + value: 'l7DsPDlm.ISEXeZt&.1JfIbP&N', + }, +]; + +describe('CategoryOptions', () => { + it('smoke test', () => { + const wrapper = shallowMount(CategoryOptions); + expect(wrapper.isVueInstance()).toBe(true); + }); + it('emits expected data', () => { + const wrapper = shallowMount(CategoryOptions); + const value = 'string'; + wrapper.vm.$emit('input', value); + + expect(wrapper.emitted().input).toBeTruthy(); + expect(wrapper.emitted().input.length).toBe(1); + expect(wrapper.emitted().input[0]).toEqual([value]); + }); + const expectedFamilyTree = [ + { text: 'SCHOOL', value: 'd&WXdXWF' }, + { text: 'ARTS', value: 'd&WXdXWF.5QAjgfv7' }, + { text: 'DANCE', value: 'd&WXdXWF.5QAjgfv7.BUMJJBnS' }, + ]; + + describe('display', () => { + it('has a tooltip that displays the tree for value of an item', () => { + const wrapper = shallowMount(CategoryOptions); + const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; // 'Dance' + const expectedToolTip = 'School - Arts - Dance'; + + expect(wrapper.vm.tooltipHelper(item)).toEqual(expectedToolTip); + }); + it(`dropdown has 'levels' key necessary to display the nested structure of categories`, () => { + const wrapper = shallowMount(CategoryOptions); + const dropdown = wrapper.vm.categoriesList; + const everyCategoryHasLevelsKey = dropdown.every(item => 'level' in item); + + expect(everyCategoryHasLevelsKey).toBeTruthy(); + }); + }); + + describe('nested family structure', () => { + it('can display all the ids of family tree of an item', () => { + const wrapper = shallowMount(CategoryOptions); + const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; //'Dance' + const expectedFamilyTreeIds = expectedFamilyTree.map(item => item.value); + + expect(wrapper.vm.findFamilyTreeIds(item).sort()).toEqual(expectedFamilyTreeIds.sort()); + }); + it('can display array of objects of family tree of an item', () => { + const wrapper = shallowMount(CategoryOptions); + const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; //'Dance' + + expect(wrapper.vm.displayFamilyTree(testDropdown, item)).toEqual(expectedFamilyTree); + }); + }); + + describe('interactions', () => { + it('when user checks an item, that is emitted to the parent component', () => { + const wrapper = shallowMount(CategoryOptions); + const item = 'abcd'; + wrapper.vm.$emit = jest.fn(); + wrapper.vm.add(item); + + expect(wrapper.vm.$emit.mock.calls[0][0]).toBe('input'); + expect(wrapper.vm.$emit.mock.calls[0][1]).toEqual([item]); + }); + it('when user unchecks an item, that is emitted to the parent component', () => { + const wrapper = shallowMount(CategoryOptions); + const item = 'defj'; + wrapper.vm.$emit = jest.fn(); + wrapper.vm.remove(item); + + expect(wrapper.vm.$emit.mock.calls[0][0]).toBe('input'); + expect(wrapper.vm.$emit.mock.calls[0][1]).toEqual([]); + }); + }); + + describe('close button on chip interactions', () => { + it('in the autocomplete bar, the chip is removed when user clicks on its close button', () => { + const wrapper = shallowMount(CategoryOptions, { + data() { + return { selected: ['remove me', 'abc', 'def', 'abc.'] }; + }, + }); + const originalChipsLength = wrapper.vm.selected.length; + wrapper.vm.remove('remove me'); + const chips = wrapper.vm.selected; + + expect(chips.length).toEqual(originalChipsLength - 1); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js index c608db3528..fc4b4e872c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js @@ -181,6 +181,7 @@ export function createContentNode(context, { parent, kind, ...payload }) { grade_levels: {}, learner_needs: {}, learning_activities: {}, + categories: {}, ...payload, }; @@ -217,6 +218,7 @@ function generateContentNodeData({ grade_levels = NOVALUE, learner_needs = NOVALUE, learning_activities = NOVALUE, + categories = NOVALUE, } = {}) { const contentNodeData = {}; if (title !== NOVALUE) { @@ -267,6 +269,9 @@ function generateContentNodeData({ if (learning_activities !== NOVALUE) { contentNodeData.learning_activities = learning_activities; } + if (categories !== NOVALUE) { + contentNodeData.categories = categories; + } if (extra_fields !== NOVALUE) { contentNodeData.extra_fields = contentNodeData.extra_fields || {}; diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index c7af961529..100c8a579d 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -1,3 +1,5 @@ +import invert from 'lodash/invert'; +import Subjects from 'kolibri-constants/labels/Subjects'; import featureFlagsSchema from 'static/feature_flags.json'; export { default as LearningActivities } from 'kolibri-constants/labels/LearningActivities'; @@ -8,6 +10,8 @@ export { default as AccessibilityCategories } from 'kolibri-constants/labels/Acc export { default as ContentLevels } from 'kolibri-constants/labels/Levels'; export { default as ResourcesNeededTypes } from 'kolibri-constants/labels/Needs'; +export const CategoriesLookup = invert(Subjects); + export const ContentDefaults = { author: 'author', provider: 'provider', diff --git a/contentcuration/contentcuration/frontend/shared/mixins.js b/contentcuration/contentcuration/frontend/shared/mixins.js index b7bedbaa60..8c64e09d66 100644 --- a/contentcuration/contentcuration/frontend/shared/mixins.js +++ b/contentcuration/contentcuration/frontend/shared/mixins.js @@ -674,6 +674,8 @@ const nonconformingKeys = { BASIC_SKILLS: 'allLevelsBasicSkills', FOUNDATIONS: 'basicSkills', foundationsLogicAndCriticalThinking: 'logicAndCriticalThinking', + toolsAndSoftwareTraining: 'softwareToolsAndTraining', + foundations: 'basicSkills', /* * TODO: the following are in ResourcesNeededTypes map from le-utils, but not in Kolibri, diff --git a/contentcuration/contentcuration/frontend/shared/views/LanguageDropdown.vue b/contentcuration/contentcuration/frontend/shared/views/LanguageDropdown.vue index d5ab775ce2..acad74d47f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/LanguageDropdown.vue +++ b/contentcuration/contentcuration/frontend/shared/views/LanguageDropdown.vue @@ -20,7 +20,7 @@ :menu-props="menuProps" :multiple="multiple" :chips="multiple" - attach="language" + attach="#language" @change="input = ''" @focus="$emit('focus')" > diff --git a/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue b/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue index d0d5b6d051..fe7da89f98 100644 --- a/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue +++ b/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue @@ -18,7 +18,7 @@ menu-props="offsetY" class="ma-0" box - attach="license" + attach="#license" @focus="$emit('focus')" > diff --git a/contentcuration/contentcuration/frontend/shared/views/VisibilityDropdown.vue b/contentcuration/contentcuration/frontend/shared/views/VisibilityDropdown.vue index 5b6da20684..10ae19a7e5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/VisibilityDropdown.vue +++ b/contentcuration/contentcuration/frontend/shared/views/VisibilityDropdown.vue @@ -14,7 +14,7 @@ :rules="rules" menu-props="offsetY" box - attach="role_visibility" + attach="#role_visibility" @focus="$emit('focus')" >