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 }) }}
-
-
-
-
-
- {{ data.item.label }}
-
-
-
-
{{ tooltipText(data.item.value) }}
-
-
-
-
-
-
setOption(option.value, value)"
- />
-
- {{ 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 @@
+ @close="() => $emit('close')"
+ >
+
+
+
+
-
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 @@
+
+
+
+
+
+ removeAll())"
+ >
+
+
+
+
+ {{ data.item.text }}
+
+
+
+
{{ tooltipText(data.item.value) }}
+
+
+
+
+
+
+
+
+ {{ $tr('noCategoryFoundText', { text: categoryText.trim() }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $tr('noCategoryFoundText') }}
+
+
+
+
+
+
+
+
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 }}
-
+
+
+
+
+
+ setOption(option.value, value)"
+ />
+
+
+
+ {{ 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;
+ }
+ },
},
};