diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue index cd0db25dcd..334bd3807d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -11,42 +11,6 @@

{{ $tr('resourcesSelected', { count: nodeIds.length }) }}

- - - -
- -

- {{ emptyText || $tr('emptyOptionsSearch') }} -

-
+ + {{ error }} @@ -86,12 +38,15 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue index 2dfa876768..2d2d474e1e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue @@ -1,68 +1,48 @@ - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue index 5586573aa5..30980840ea 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue @@ -65,6 +65,7 @@ ref="learning_activities" v-model="contentLearningActivities" :disabled="anyIsTopic" + :nodeIds="nodeIds" @focus="trackClick('Learning activities')" /> @@ -72,6 +73,7 @@ v-if="oneSelected" ref="contentLevel" v-model="contentLevel" + :nodeIds="nodeIds" @focus="trackClick('Levels dropdown')" /> @@ -79,6 +81,7 @@ v-if="oneSelected" ref="resourcesNeeded" v-model="resourcesNeeded" + :nodeIds="nodeIds" @focus="trackClick('What you will need')" /> @@ -111,7 +114,12 @@ - + @@ -376,11 +384,11 @@ import SubtitlesList from '../../views/files/supplementaryLists/SubtitlesList'; import { isImportedContent, isDisableSourceEdits, importedChannelLink } from '../../utils'; import AccessibilityOptions from './AccessibilityOptions.vue'; - import LevelsOptions from './LevelsOptions.vue'; - import ResourcesNeededOptions from './ResourcesNeededOptions.vue'; - import LearningActivityOptions from './LearningActivityOptions.vue'; - import CategoryOptions from './CategoryOptions.vue'; + import LevelsOptions from 'shared/views/contentNodeFields/LevelsOptions'; + import CategoryOptions from 'shared/views/contentNodeFields/CategoryOptions'; import CompletionOptions from 'shared/views/contentNodeFields/CompletionOptions'; + import LearningActivityOptions from 'shared/views/contentNodeFields/LearningActivityOptions'; + import ResourcesNeededOptions from 'shared/views/contentNodeFields/ResourcesNeededOptions'; import { getTitleValidators, getCopyrightHolderValidators, @@ -434,14 +442,10 @@ } /** - * This function is used to generate getter/setters for new metadata fields that are boolean maps: - * - `grade_levels` (sometimes referred to as `content_levels`) - * - `learner_needs` (resources needed) + * This function is used to generate getter/setters having its value as + * an array for metadata fields that are boolean maps: * - `accessibility_labels` (accessibility options) - * - `learning_activities` (learning activities) - * - `categories` (categories) */ - function generateNestedNodesGetterSetter(key) { return { get() { @@ -475,6 +479,42 @@ }; } + /** + * This function is used to generate getter/setters having its value as + * an object for metadata fields that are boolean maps: + * - `grade_levels` (sometimes referred to as `content_levels`) + * - `learner_needs` (resources needed) + * - `learning_activities` (learning activities) + * - `categories` (categories) + */ + function generateNestedNodesGetterSetterObject(key) { + return { + get() { + const value = {}; + for (const node of this.nodes) { + const diffTrackerNode = this.diffTracker[node.id] || {}; + const currentValue = diffTrackerNode[key] || node[key] || {}; + Object.entries(currentValue).forEach(([option, optionValue]) => { + if (optionValue) { + value[option] = value[option] || []; + value[option].push(node.id); + } + }); + } + return value; + }, + set(value) { + const newMap = {}; + for (const option in value) { + if (value[option].length === this.nodes.length) { + newMap[option] = true; + } + } + this.update({ [key]: newMap }); + }, + }; + } + export default { name: 'DetailsTabView', components: { @@ -584,24 +624,28 @@ role: generateGetterSetter('role_visibility'), language: generateGetterSetter('language'), accessibility: generateNestedNodesGetterSetter('accessibility_labels'), - contentLevel: generateNestedNodesGetterSetter('grade_levels'), - resourcesNeeded: generateNestedNodesGetterSetter('learner_needs'), + contentLevel: generateNestedNodesGetterSetterObject('grade_levels'), + resourcesNeeded: generateNestedNodesGetterSetterObject('learner_needs'), forBeginners: { get() { - return this.resourcesNeeded.includes(ResourcesNeededTypes.FOR_BEGINNERS); + const value = this.resourcesNeeded[ResourcesNeededTypes.FOR_BEGINNERS]; + return value && value.length === this.nodes.length; }, set(value) { if (value) { - this.resourcesNeeded = [...this.resourcesNeeded, ResourcesNeededTypes.FOR_BEGINNERS]; + this.resourcesNeeded = { + ...this.resourcesNeeded, + [ResourcesNeededTypes.FOR_BEGINNERS]: this.nodeIds, + }; } else { - this.resourcesNeeded = this.resourcesNeeded.filter( - r => r !== ResourcesNeededTypes.FOR_BEGINNERS - ); + const newMap = { ...this.resourcesNeeded }; + delete newMap[ResourcesNeededTypes.FOR_BEGINNERS]; + this.resourcesNeeded = newMap; } }, }, - contentLearningActivities: generateNestedNodesGetterSetter('learning_activities'), - categories: generateNestedNodesGetterSetter('categories'), + contentLearningActivities: generateNestedNodesGetterSetterObject('learning_activities'), + categories: generateNestedNodesGetterSetterObject('categories'), license() { return this.getValueFromNodes('license'); }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue deleted file mode 100644 index e10ddb3913..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js deleted file mode 100644 index c8a82a30e8..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js +++ /dev/null @@ -1,331 +0,0 @@ -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/components/edit/__tests__/resourcesNeededOptions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/resourcesNeededOptions.spec.js deleted file mode 100644 index 0fc09b1598..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/resourcesNeededOptions.spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import Vue from 'vue'; -import Vuetify from 'vuetify'; -import { shallowMount, mount } from '@vue/test-utils'; -import ResourcesNeededOptions from '../ResourcesNeededOptions.vue'; - -Vue.use(Vuetify); - -function makeWrapper(value) { - return mount(ResourcesNeededOptions, { - propsData: { - value, - }, - }); -} - -describe('ResourcesNeededOptions', () => { - it('smoke test', () => { - const wrapper = shallowMount(ResourcesNeededOptions); - - expect(wrapper.isVueInstance()).toBe(true); - }); - - describe('updating state', () => { - it('should update resources field with new values received from a parent', () => { - const resourcesNeeded = ['person', 'book']; - const wrapper = makeWrapper(resourcesNeeded); - const dropdown = wrapper.find({ name: 'v-select' }); - - expect(dropdown.props('value')).toEqual(resourcesNeeded); - - wrapper.setProps({ - value: ['cat'], - }); - expect(dropdown.props('value')).toEqual(['cat']); - }); - - it('should emit new input values', () => { - const resourcesNeeded = ['person', 'book', 'train']; - const wrapper = makeWrapper([]); - const dropdown = wrapper.find({ name: 'v-select' }); - dropdown.vm.$emit('input', resourcesNeeded); - - return Vue.nextTick().then(() => { - const emittedLevels = wrapper.emitted('input').pop()[0]; - expect(emittedLevels).toEqual(resourcesNeeded); - }); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CategoryOptions.vue b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CategoryOptions.vue new file mode 100644 index 0000000000..032a720873 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CategoryOptions.vue @@ -0,0 +1,320 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LearningActivityOptions.vue b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/LearningActivityOptions.vue similarity index 57% rename from contentcuration/contentcuration/frontend/channelEdit/components/edit/LearningActivityOptions.vue rename to contentcuration/contentcuration/frontend/shared/views/contentNodeFields/LearningActivityOptions.vue index 3ffd37968c..ce1f49b8b8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LearningActivityOptions.vue +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/LearningActivityOptions.vue @@ -1,23 +1,16 @@ @@ -25,23 +18,42 @@ import camelCase from 'lodash/camelCase'; import { LearningActivities } from 'shared/constants'; + import ExpandableSelect from 'shared/views/form/ExpandableSelect'; import { constantsTranslationMixin, metadataTranslationMixin } from 'shared/mixins'; import { getLearningActivityValidators, translateValidator } from 'shared/utils/validation'; - import DropdownWrapper from 'shared/views/form/DropdownWrapper'; export default { name: 'LearningActivityOptions', - components: { DropdownWrapper }, + components: { ExpandableSelect }, mixins: [constantsTranslationMixin, metadataTranslationMixin], props: { + /** + * This prop receives an object with the following structure: + * { + * [learningActivityId]: [nodeId1, nodeId2, ...] + * } + * Where nodeId is the id of the node that has the learning activity selected + */ value: { - type: Array, - default: () => [], + type: Object, + required: true, }, disabled: { type: Boolean, default: false, }, + expanded: { + type: Boolean, + default: false, + }, + hideLabel: { + type: Boolean, + default: false, + }, + nodeIds: { + type: Array, + required: true, + }, }, computed: { learningActivity: { @@ -49,7 +61,7 @@ if (!this.disabled) { return this.value; } - return null; + return {}; }, set(value) { if (!this.disabled) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LevelsOptions.vue b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/LevelsOptions.vue similarity index 65% rename from contentcuration/contentcuration/frontend/channelEdit/components/edit/LevelsOptions.vue rename to contentcuration/contentcuration/frontend/shared/views/contentNodeFields/LevelsOptions.vue index 9dea958af7..e300b281a0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LevelsOptions.vue +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/LevelsOptions.vue @@ -1,22 +1,14 @@ @@ -24,17 +16,36 @@ import camelCase from 'lodash/camelCase'; import { ContentLevels } from 'shared/constants'; + import ExpandableSelect from 'shared/views/form/ExpandableSelect'; import { constantsTranslationMixin, metadataTranslationMixin } from 'shared/mixins'; - import DropdownWrapper from 'shared/views/form/DropdownWrapper'; export default { name: 'LevelsOptions', - components: { DropdownWrapper }, + components: { ExpandableSelect }, mixins: [constantsTranslationMixin, metadataTranslationMixin], props: { + /** + * This prop receives an object with the following structure: + * { + * [levelId]: [nodeId1, nodeId2, ...] + * } + * Where nodeId is the id of the node that has the level selected + */ value: { + type: Object, + required: true, + }, + expanded: { + type: Boolean, + default: false, + }, + hideLabel: { + type: Boolean, + default: false, + }, + nodeIds: { type: Array, - default: () => [], + required: true, }, }, computed: { diff --git a/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/ResourcesNeededOptions.vue b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/ResourcesNeededOptions.vue new file mode 100644 index 0000000000..8585ed7911 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/ResourcesNeededOptions.vue @@ -0,0 +1,86 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/categoryOptions.spec.js b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/categoryOptions.spec.js new file mode 100644 index 0000000000..4cfa9baa2b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/categoryOptions.spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import Vuetify from 'vuetify'; +import { shallowMount } from '@vue/test-utils'; +import CategoryOptions from '../CategoryOptions.vue'; + +Vue.use(Vuetify); + +function makeWrapper({ value = {}, nodeIds = ['node1'] } = {}) { + return shallowMount(CategoryOptions, { + propsData: { + value, + nodeIds, + }, + }); +} + +describe('CategoryOptions', () => { + it('smoke test', () => { + const wrapper = makeWrapper(); + expect(wrapper.isVueInstance()).toBe(true); + }); + it('emits expected data', () => { + const wrapper = makeWrapper(); + 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]); + }); + + describe('display', () => { + it('has a tooltip that displays the tree for value of an item', () => { + const wrapper = makeWrapper(); + const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; // 'Dance' + const expectedToolTip = 'School - Arts - Dance'; + + expect(wrapper.vm.tooltipText(item)).toEqual(expectedToolTip); + }); + it(`dropdown has 'levels' key necessary to display the nested structure of categories`, () => { + const wrapper = makeWrapper(); + const dropdown = wrapper.vm.categoriesList; + const everyCategoryHasLevelsKey = dropdown.every(item => 'level' in item); + + expect(everyCategoryHasLevelsKey).toBeTruthy(); + }); + }); + + describe('interactions', () => { + it('when user checks an item, that is emitted to the parent component', () => { + const wrapper = makeWrapper(); + 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({ abcd: ['node1'] }); + }); + it('when user unchecks an item, that is emitted to the parent component', () => { + const wrapper = makeWrapper(); + 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', async () => { + const wrapper = makeWrapper({ + value: { + 'remove me': ['node1'], + 'keep me': ['node1'], + }, + }); + const originalChipsLength = Object.keys(wrapper.vm.selected).length; + wrapper.vm.remove('remove me'); + + expect(wrapper.emitted().input.length).toEqual(originalChipsLength - 1); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/learningActivityOptions.spec.js b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/learningActivityOptions.spec.js similarity index 72% rename from contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/learningActivityOptions.spec.js rename to contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/learningActivityOptions.spec.js index 1dffac7921..c3767d3c8a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/learningActivityOptions.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/learningActivityOptions.spec.js @@ -6,10 +6,11 @@ import { LearningActivities } from 'shared/constants'; Vue.use(Vuetify); -function makeWrapper(value) { +function makeWrapper({ value = {}, nodeIds = ['node1'] } = {}) { return mount(LearningActivityOptions, { propsData: { value, + nodeIds, }, }); } @@ -21,7 +22,7 @@ describe('LearningActivityOptions', () => { }); it('number of items in the dropdown should be equal to number of items available in ', async () => { - const wrapper = makeWrapper([]); + const wrapper = makeWrapper(); await wrapper.find('.v-input__slot').trigger('click'); const numberOfDropdownItems = Object.keys(LearningActivities).length; @@ -32,27 +33,35 @@ describe('LearningActivityOptions', () => { describe('updating state', () => { it('should update learning_activity field with new values received from a parent', () => { - const learningActivity = ['activity_1', 'activity_2']; - const wrapper = makeWrapper(learningActivity); + const learningActivity = { + activity_1: ['node1'], + activity_2: ['node1'], + }; + const wrapper = makeWrapper({ value: learningActivity, nodeIds: ['node1'] }); const dropdown = wrapper.find({ name: 'v-select' }); - expect(dropdown.props('value')).toEqual(learningActivity); + expect(dropdown.props('value')).toEqual(['activity_1', 'activity_2']); wrapper.setProps({ - value: ['activity_4'], + value: { + activity_4: ['node1'], + }, }); expect(dropdown.props('value')).toEqual(['activity_4']); }); it('should emit new input values', () => { - const learningActivity = ['activity_1', 'activity_2', 'activity_3']; - const wrapper = makeWrapper({}); + const learningActivity = ['activity_1', 'activity_2']; + const wrapper = makeWrapper(); const dropdown = wrapper.find({ name: 'v-select' }); dropdown.vm.$emit('input', learningActivity); return Vue.nextTick().then(() => { const emittedLevels = wrapper.emitted('input').pop()[0]; - expect(emittedLevels).toEqual(learningActivity); + expect(emittedLevels).toEqual({ + activity_1: ['node1'], + activity_2: ['node1'], + }); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/levelsOptions.spec.js b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/levelsOptions.spec.js similarity index 67% rename from contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/levelsOptions.spec.js rename to contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/levelsOptions.spec.js index 9fab21ed16..f306d1b50b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/levelsOptions.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/levelsOptions.spec.js @@ -6,10 +6,11 @@ import { ContentLevels } from 'shared/constants'; Vue.use(Vuetify); -function makeWrapper(value) { +function makeWrapper({ value = {}, nodeIds = ['node1'] } = {}) { return mount(LevelsOptions, { propsData: { value, + nodeIds, }, }); } @@ -22,7 +23,7 @@ describe('LevelsOptions', () => { }); it('number of items in the dropdown should be equal to number of items available in ContentLevels', async () => { - const wrapper = makeWrapper([]); + const wrapper = makeWrapper(); await wrapper.find('.v-input__slot').trigger('click'); const numberOfAvailableLevels = Object.keys(ContentLevels).length; @@ -33,27 +34,37 @@ describe('LevelsOptions', () => { describe('updating state', () => { it('should update levels field with new values received from a parent', () => { - const levels = ['abc', 'gefo']; - const wrapper = makeWrapper(levels); + const value = { + abc: ['node1'], + gefo: ['node1'], + }; + const wrapper = makeWrapper({ value, nodeIds: ['node1'] }); const dropdown = wrapper.find({ name: 'v-select' }); - expect(dropdown.props('value')).toEqual(levels); + expect(dropdown.props('value')).toEqual(['abc', 'gefo']); wrapper.setProps({ - value: ['def'], + value: { + def: ['node1'], + }, }); expect(dropdown.props('value')).toEqual(['def']); }); it('should emit new input values', () => { - const levels = ['abc', 'gefo', '8hw']; - const wrapper = makeWrapper({}); + const value = { + abc: ['node1'], + }; + const wrapper = makeWrapper({ value, nodeIds: ['node1'] }); const dropdown = wrapper.find({ name: 'v-select' }); - dropdown.vm.$emit('input', levels); + dropdown.vm.$emit('input', ['abc', 'gefo']); return Vue.nextTick().then(() => { const emittedLevels = wrapper.emitted('input').pop()[0]; - expect(emittedLevels).toEqual(levels); + expect(emittedLevels).toEqual({ + abc: ['node1'], + gefo: ['node1'], + }); }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/resourcesNeededOptions.spec.js b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/resourcesNeededOptions.spec.js new file mode 100644 index 0000000000..cc0757a09b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/__tests__/resourcesNeededOptions.spec.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import Vuetify from 'vuetify'; +import { mount } from '@vue/test-utils'; +import ResourcesNeededOptions from '../ResourcesNeededOptions.vue'; + +Vue.use(Vuetify); + +function makeWrapper({ value = {}, nodeIds = ['node1'] } = {}) { + return mount(ResourcesNeededOptions, { + propsData: { + value, + nodeIds, + }, + }); +} + +describe('ResourcesNeededOptions', () => { + it('smoke test', () => { + const wrapper = makeWrapper(); + + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('updating state', () => { + it('should update resources field with new values received from a parent', () => { + const value = { + person: ['node1'], + book: ['node1'], + }; + const wrapper = makeWrapper({ value, nodeIds: ['node1'] }); + const dropdown = wrapper.find({ name: 'v-select' }); + + expect(dropdown.props('value')).toEqual(['person', 'book']); + + wrapper.setProps({ + value: { + train: ['node1'], + }, + }); + expect(dropdown.props('value')).toEqual(['train']); + }); + + it('should emit new input values', async () => { + const resourcesNeeded = { + person: ['node1'], + }; + const wrapper = makeWrapper({ value: resourcesNeeded, nodeIds: ['node1'] }); + const dropdown = wrapper.find({ name: 'v-select' }); + dropdown.vm.$emit('input', ['person', 'book']); + + await wrapper.vm.$nextTick(); + + const emittedLevels = wrapper.emitted('input').pop()[0]; + expect(emittedLevels).toEqual({ + person: ['node1'], + book: ['node1'], + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/form/ExpandableSelect.vue b/contentcuration/contentcuration/frontend/shared/views/form/ExpandableSelect.vue index 266f25e7a8..127eeabd7a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/form/ExpandableSelect.vue +++ b/contentcuration/contentcuration/frontend/shared/views/form/ExpandableSelect.vue @@ -7,7 +7,7 @@ > @@ -25,16 +31,38 @@ v-else :class="disabled ? 'disabled' : ''" > -
+
{{ label }}
- +
+ + +
+

+ {{ hint }} +

+ @@ -48,15 +76,31 @@ name: 'ExpandableSelect', components: { DropdownWrapper }, props: { + /** + * It can receive a value as a string for single select or an object with + * the following structure for multiple select: + * { + * [optionId]: [itemId1, itemId2, ...] + * } + * where itemId is the id of the item that has the option selected + */ value: { - type: String, - required: false, - default: '', + type: [String, Object], + required: true, }, options: { type: Array, required: true, }, + /** + * If the select is multiple, this prop is required, and it + * represents the available items that can have the options selected + */ + availableItems: { + type: Array, + required: false, + default: () => [], + }, placeholder: { type: String, required: false, @@ -67,6 +111,11 @@ required: false, default: '', }, + hideLabel: { + type: Boolean, + required: false, + default: false, + }, rules: { type: Array, required: false, @@ -84,6 +133,14 @@ type: Boolean, default: false, }, + multiple: { + type: Boolean, + default: false, + }, + hint: { + type: String, + default: '', + }, }, computed: { valueModel: { @@ -94,6 +151,27 @@ this.$emit('input', value); }, }, + selectInputValueModel: { + get() { + if (this.multiple) { + return Object.keys(this.valueModel).filter( + key => this.valueModel[key].length === this.availableItems.length + ); + } + return this.valueModel; + }, + set(value) { + if (this.multiple) { + const newValueModel = {}; + value.forEach(optionId => { + newValueModel[optionId] = this.availableItems; + }); + this.valueModel = newValueModel; + } else { + this.valueModel = value; + } + }, + }, }, methods: { /** @@ -104,6 +182,30 @@ return getInvalidText(this.rules, this.valueModel); } }, + isSelected(value) { + if (!this.valueModel[value]) { + return false; + } + return this.valueModel[value].length === this.availableItems.length; + }, + isIndeterminate(value) { + if (!this.valueModel[value]) { + return false; + } + return this.valueModel[value].length < this.availableItems.length; + }, + setOption(optionId, value) { + if (value) { + this.valueModel = { + ...this.valueModel, + [optionId]: this.availableItems, + }; + } else { + const newValueModel = { ...this.valueModel }; + delete newValueModel[optionId]; + this.valueModel = newValueModel; + } + }, }, };