diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeContextMenu.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeContextMenu.vue index e85d192285..ea742b88eb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeContextMenu.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeContextMenu.vue @@ -2,7 +2,7 @@ - + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue index f8920f83c0..9d24404885 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue @@ -45,6 +45,18 @@ @@ -105,7 +117,7 @@ import DraggableItem from 'shared/views/draggable/DraggableItem'; import { ContentNode } from 'shared/data/resources'; import { DragEffect, DropEffect, EffectAllowed } from 'shared/mixins/draggable/constants'; - import { DraggableRegions } from 'frontend/channelEdit/constants'; + import { QuickEditModals, DraggableRegions } from 'frontend/channelEdit/constants'; import { withChangeTracker } from 'shared/data/changes'; import { COPYING_STATUS, COPYING_STATUS_VALUES } from 'shared/data/constants'; @@ -235,7 +247,14 @@ 'updateContentNode', 'waitForCopyingStatus', 'deleteContentNode', + 'setQuickEditModal', ]), + editTitleDescription() { + this.setQuickEditModal({ + modal: QuickEditModals.TITLE_DESCRIPTION, + nodeIds: [this.nodeId], + }); + }, retryFailedCopy: withChangeTracker(function(changeTracker) { this.updateContentNode({ id: this.nodeId, @@ -280,6 +299,7 @@ creatingCopies: 'Copying...', copiedSnackbar: 'Copy operation complete', undo: 'Undo', + editTooltip: 'Edit Title & Description', }, }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue index 32579e9f68..ad50a809f8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue @@ -1,44 +1,50 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue new file mode 100644 index 0000000000..e5cbd8de28 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue @@ -0,0 +1,171 @@ + + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue new file mode 100644 index 0000000000..42f3db86db --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -0,0 +1,178 @@ + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue new file mode 100644 index 0000000000..2d2d474e1e --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue @@ -0,0 +1,52 @@ + + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue new file mode 100644 index 0000000000..dfb06a150f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue @@ -0,0 +1,136 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue new file mode 100644 index 0000000000..7a3ec6a646 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -0,0 +1,180 @@ + + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue new file mode 100644 index 0000000000..790de6c081 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue @@ -0,0 +1,60 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue new file mode 100644 index 0000000000..060465ef05 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue @@ -0,0 +1,53 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue new file mode 100644 index 0000000000..abc4fc4a62 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue @@ -0,0 +1,53 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue new file mode 100644 index 0000000000..2ac75bf99b --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue @@ -0,0 +1,330 @@ + + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue new file mode 100644 index 0000000000..79e978f21a --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue @@ -0,0 +1,103 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditAudienceModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditAudienceModal.spec.js new file mode 100644 index 0000000000..d209690647 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditAudienceModal.spec.js @@ -0,0 +1,257 @@ +import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import EditAudienceModal from '../EditAudienceModal'; +import { ResourcesNeededTypes } from 'shared/constants'; +import { RolesNames } from 'shared/leUtils/Roles'; + +let nodes; + +let store; +let contentNodeActions; +let generalActions; + +const getRolesValues = wrapper => { + const roles = {}; + const radioBtns = wrapper.findAll('[data-test="rol-radio-button"]'); + radioBtns.wrappers.forEach(checkbox => { + const { value, currentValue } = checkbox.vm.$props || {}; + roles[value] = currentValue === value; + }); + return roles; +}; + +const selectRole = (wrapper, rol) => { + const radioBtn = wrapper.find(`[data-test="rol-radio-button"] input[value="${rol}"]`); + radioBtn.setChecked(true); +}; + +const isForBeginnersChecked = wrapper => { + return wrapper.find('[data-test="for-beginners-checkbox"] input').element.checked; +}; + +const checkForBeginners = wrapper => { + wrapper.find('[data-test="for-beginners-checkbox"] input').setChecked(true); +}; + +const makeWrapper = nodeIds => { + return mount(EditAudienceModal, { + store, + propsData: { + nodeIds, + }, + }); +}; + +describe('EditAudienceModal', () => { + beforeEach(() => { + nodes = { + node1: { id: 'node1' }, + node2: { id: 'node2' }, + }; + contentNodeActions = { + updateContentNode: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => ids.map(id => nodes[id]), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper(['node1']); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected audience on first render', () => { + test('no rol should be selected if a single node does not have set rol', () => { + const wrapper = makeWrapper(['node1']); + + const rolesValues = getRolesValues(wrapper); + Object.values(rolesValues).forEach(value => { + expect(value).toBeFalsy(); + }); + }); + + test('no rol should be selected if just a single node among multiple nodes does not have rol set', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + + const wrapper = makeWrapper(['node1', 'node2']); + + const rolesValues = getRolesValues(wrapper); + Object.values(rolesValues).forEach(value => { + expect(value).toBeFalsy(); + }); + }); + + test('no rol should be selected if there are multiple roles set', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node2'].role_visibility = RolesNames.LEARNER; + + const wrapper = makeWrapper(['node1', 'node2']); + + const rolesValues = getRolesValues(wrapper); + Object.values(rolesValues).forEach(value => { + expect(value).toBeFalsy(); + }); + }); + + test('the common rol should be selected if all nodes have the same rol visibility', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node2'].role_visibility = RolesNames.COACH; + + const wrapper = makeWrapper(['node1', 'node2']); + + const rolesValues = getRolesValues(wrapper); + expect(rolesValues[RolesNames.COACH]).toBeTruthy(); + }); + + test('for beginners checkbox should be unselected if no node is set for beginners', () => { + const wrapper = makeWrapper(['node1']); + + expect(isForBeginnersChecked(wrapper)).toBeFalsy(); + }); + + test('for beginners checkbox should be unselected if just a single node among multiple nodes is not set for beginners', () => { + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(isForBeginnersChecked(wrapper)).toBeFalsy(); + }); + + test('for beginners checkbox should be selected if all nodes are set for beginners', () => { + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + nodes['node2'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(isForBeginnersChecked(wrapper)).toBeTruthy(); + }); + + test('should display information message about different roles visibilities if there are multiple roles set', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node2'].role_visibility = RolesNames.LEARNER; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="multiple-audience-message"]').exists()).toBeTruthy(); + }); + + test('should not display information message about different roles visibilities if just some nodes are set for beginners', () => { + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="multiple-audience-message"]').exists()).toBeTruthy(); + }); + + test('should not display information message about different roles visibilities if all nodes have the same rol visibility and are set for beginners', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + nodes['node2'].role_visibility = RolesNames.COACH; + nodes['node2'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="multiple-audience-message"]').exists()).toBeFalsy(); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper(['node1', 'node2']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should call updateContentNode with the right rol on success submit', () => { + const wrapper = makeWrapper(['node1']); + + selectRole(wrapper, RolesNames.COACH); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + role_visibility: RolesNames.COACH, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should call updateContentNode with the need for beginners set on success submit', () => { + const wrapper = makeWrapper(['node1']); + + checkForBeginners(wrapper); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + learner_needs: { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper(['node1']); + + selectRole(wrapper, RolesNames.COACH); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper(['node1']); + + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('cancel'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a confirmation snackbar on success submit', () => { + const wrapper = makeWrapper(['node1']); + + selectRole(wrapper, RolesNames.COACH); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); + cancelAnimationFrame(animationFrameId); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js new file mode 100644 index 0000000000..576b6639bb --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js @@ -0,0 +1,345 @@ +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; +import camelCase from 'lodash/camelCase'; +import EditBooleanMapModal from '../EditBooleanMapModal'; +import { metadataTranslationMixin } from 'shared/mixins'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; +import { Categories } from 'shared/constants'; +import CategoryOptions from 'shared/views/contentNodeFields/CategoryOptions'; + +let nodes; + +let store; +let contentNodeActions; +let generalActions; + +const CheckboxValue = { + UNCHECKED: 'UNCHECKED', + CHECKED: 'CHECKED', + INDETERMINATE: 'INDETERMINATE', +}; + +const { translateMetadataString } = metadataTranslationMixin.methods; + +const categoriesLookup = {}; +Object.entries(Categories).forEach(([key, value]) => { + const newKey = translateMetadataString(camelCase(key)); + categoriesLookup[newKey] = value; +}); + +const getOptionsValues = wrapper => { + const categories = {}; + const checkboxes = wrapper.findAll('[data-test="option-checkbox"]'); + checkboxes.wrappers.forEach(checkbox => { + const { label, checked, indeterminate } = checkbox.vm.$props || {}; + let value; + if (indeterminate) { + value = CheckboxValue.INDETERMINATE; + } else if (checked) { + value = CheckboxValue.CHECKED; + } else { + value = CheckboxValue.UNCHECKED; + } + categories[categoriesLookup[label]] = value; + }); + return categories; +}; + +const findOptionCheckbox = (wrapper, category) => { + const checkboxes = wrapper.findAll('[data-test="option-checkbox"]'); + return checkboxes.wrappers.find(checkbox => { + const { label } = checkbox.vm.$props || {}; + return categoriesLookup[label] === category; + }); +}; + +const options = Object.entries(Categories).map(([key, value]) => { + return { + label: key, + value, + }; +}); +const makeWrapper = ({ nodeIds, field = 'categories', ...restOptions }) => { + return mount(EditBooleanMapModal, { + store, + propsData: { + nodeIds, + options, + title: 'Edit Categories', + field, + autocompleteLabel: 'Select option', + confirmationMessage: 'edited', + ...restOptions, + }, + scopedSlots: { + input: function(props) { + return this.$createElement(CategoryOptions, { + props: { + ...props, + expanded: true, + hideLabel: true, + nodeIds, + }, + }); + }, + }, + }); +}; + +describe('EditBooleanMapModal', () => { + beforeEach(() => { + nodes = { + node1: { id: 'node1' }, + node2: { id: 'node2' }, + node3: { id: 'node3' }, + node4: { id: 'node4' }, + }; + contentNodeActions = { + updateContentNode: jest.fn(), + updateContentNodeDescendants: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => ids.map(id => nodes[id]), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected options on first render', () => { + describe('Options checkboxes', () => { + test('no option should be selected if a single node does not have options set', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + const optionsValues = getOptionsValues(wrapper); + expect( + Object.values(optionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + }); + + test('no option should be selected if multiple nodes dont have options set', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const optionsValues = getOptionsValues(wrapper); + expect( + Object.values(optionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + }); + + test('checkbox options should be selected depending on the options set for a single node - categories', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.FOUNDATIONS]: foundationsValue, + ...otheroptionsValues + } = optionsValues; + expect( + Object.values(otheroptionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + expect(foundationsValue).toBe(CheckboxValue.CHECKED); + }); + + test('checkbox option should be checked if all nodes have the same option set', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.FOUNDATIONS]: foundationsValue, + } = optionsValues; + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + expect(foundationsValue).toBe(CheckboxValue.CHECKED); + }); + + test('checkbox option should be indeterminate if not all nodes have the same options set', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.FOUNDATIONS]: foundationsValue, + } = optionsValues; + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + expect(foundationsValue).toBe(CheckboxValue.INDETERMINATE); + }); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should render the message of the number of resources selected - 2', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2', 'node3', 'node4'] }); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('4'); + }); + + describe('Submit', () => { + test('should call updateContentNode with the right options on success submit - categories', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const schoolCheckbox = findOptionCheckbox(wrapper, Categories.SCHOOL); + schoolCheckbox.element.click(); + const sociologyCheckbox = findOptionCheckbox(wrapper, Categories.SOCIOLOGY); + sociologyCheckbox.element.click(); + + const animationFrameId = requestAnimationFrame(() => { + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: { + [Categories.SCHOOL]: true, + [Categories.SOCIOLOGY]: true, + }, + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node2', + categories: { + [Categories.SCHOOL]: true, + [Categories.SOCIOLOGY]: true, + }, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a confirmation snackbar on success submit', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); + cancelAnimationFrame(animationFrameId); + }); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('cancel'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + describe('topic nodes present', () => { + test('should display the checkbox to apply change to descendants if a topic is present and is descendants updatable', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], isDescendantsUpdatable: true }); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeTruthy(); + }); + + test('should not display the checkbox to apply change to descendants if a topic is not present even though its descendants updatable', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], isDescendantsUpdatable: true }); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); + }); + + test('should not display the checkbox to apply change to descendants if is not descendants updatable', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], isDescendantsUpdatable: false }); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); + }); + + test('should call updateContentNode on success submit if the user does not check the update descendants checkbox', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1'], isDescendantsUpdatable: true }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: {}, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should call updateContentNodeDescendants on success submit if the user checks the descendants checkbox', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1'], isDescendantsUpdatable: true }); + + wrapper.find('[data-test="update-descendants-checkbox"] input').setChecked(true); + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNodeDescendants).toHaveBeenCalledWith( + expect.anything(), + { + id: 'node1', + categories: {}, + } + ); + cancelAnimationFrame(animationFrameId); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js new file mode 100644 index 0000000000..a18f7556aa --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js @@ -0,0 +1,257 @@ +import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import EditLanguageModal from '../EditLanguageModal'; +import { LanguagesList } from 'shared/leUtils/Languages'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; + +const nodes = [ + { id: 'test-en-res', language: 'en' }, + { id: 'test-es-res', language: 'es' }, + { id: 'test-nolang-res', language: '' }, + { id: 'test-en-topic', language: 'en', kind: ContentKindsNames.TOPIC }, +]; + +let store; +let contentNodeActions; +let generalActions; + +const makeWrapper = nodeIds => { + return mount(EditLanguageModal, { + store, + propsData: { + nodeIds, + }, + }); +}; + +describe('EditLanguageModal', () => { + beforeEach(() => { + contentNodeActions = { + updateContentNode: jest.fn(), + updateContentNodeDescendants: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => nodes.filter(node => ids.includes(node.id)), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper(['test-en-res']); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected language on first render', () => { + test('no language should be selected if a single node does not have a language', () => { + const wrapper = makeWrapper(['test-nolang-res']); + + const checkboxes = wrapper.findAll('input[type="radio"]'); + checkboxes.wrappers.forEach(checkbox => { + expect(checkbox.element.checked).toBeFalsy(); + }); + }); + + test('no language should be selected if just a single node among multiple nodes does not have language', () => { + const wrapper = makeWrapper(['test-en-res', 'test-nolang-res']); + + const checkboxes = wrapper.findAll('input[type="radio"]'); + checkboxes.wrappers.forEach(checkbox => { + expect(checkbox.element.checked).toBeFalsy(); + }); + }); + + test('no language should be selected if there are multiple languages set', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const checkboxes = wrapper.findAll('input[type="radio"]'); + checkboxes.wrappers.forEach(checkbox => { + expect(checkbox.element.checked).toBeFalsy(); + }); + }); + + test('the common language should be selected if all nodes have the same language', () => { + const wrapper = makeWrapper(['test-en-res', 'test-en-topic']); + + const checkbox = wrapper.find('input[value="en"]'); + expect(checkbox.element.checked).toBeTruthy(); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should render the message of the number of resources selected - 2', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res', 'test-en-topic', 'test-nolang-res']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('4'); + }); + + test('should filter languages options based on search query', () => { + const wrapper = makeWrapper(['test-en-topic']); + + wrapper.find('[data-test="search-input"]').vm.$emit('input', 'es'); + + const optionsList = wrapper.find('[data-test="language-options-list"]'); + const options = optionsList.findAll('input[type="radio"]'); + options.wrappers.forEach(option => { + const language = LanguagesList.find(lang => lang.id === option.element.value); + expect( + language.id.toLowerCase().includes('es') || + language.native_name.toLowerCase().includes('es') || + language.readable_name.toLowerCase().includes('es') + ).toBeTruthy(); + }); + }); + + test('should display information message about different languages if there are multiple languages set', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + expect(wrapper.find('[data-test="different-languages-message"]').exists()).toBeTruthy(); + }); + + test('shouldnt display information message about different languages if only one language is set', () => { + const wrapper = makeWrapper(['test-en-res', 'test-en-topic']); + + expect(wrapper.find('[data-test="different-languages-message"]').exists()).toBeFalsy(); + }); + + test('the submit button should be disabled if no language is selected', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const buttons = wrapper.findAll('button').wrappers; + const submitButton = buttons.find(button => button.text() === 'Save'); + + expect(submitButton.element.disabled).toBeTruthy(); + }); + + test('the submit button should be enabled if a language is selected', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const buttons = wrapper.findAll('button').wrappers; + const submitButton = buttons.find(button => button.text() === 'Save'); + + wrapper.find('input[value="en"]').setChecked(true); + + expect(submitButton.element.disabled).toBeFalsy(); + }); + + test('should call updateContentNode with the right language on success submit', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('input[value="en"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-es-res', + language: 'en', + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-en-res', + language: 'en', + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('input[value="en"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('cancel'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a confirmation snackbar on success submit', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('input[value="en"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); + cancelAnimationFrame(animationFrameId); + }); + }); + + describe('topic nodes present', () => { + test('should display the checkbox to apply change to descendants if a topic is present', () => { + const wrapper = makeWrapper(['test-en-topic', 'test-en-res']); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeTruthy(); + }); + + test('should not display the checkbox to apply change to descendants if a topic is not present', () => { + const wrapper = makeWrapper(['test-en-res']); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); + }); + + test('should call updateContentNode with the right language on success submit if the user does not check the checkbox', () => { + const wrapper = makeWrapper(['test-en-topic', 'test-en-res']); + + wrapper.find('input[value="es"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-en-topic', + language: 'es', + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should call updateContentNodeDescendants with the right language on success submit if the user checks the checkbox', () => { + const wrapper = makeWrapper(['test-en-topic', 'test-en-res']); + + wrapper.find('input[value="es"]').setChecked(true); + wrapper.find('[data-test="update-descendants-checkbox"] input').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNodeDescendants).toHaveBeenCalledWith( + expect.anything(), + { + id: 'test-en-topic', + language: 'es', + } + ); + cancelAnimationFrame(animationFrameId); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js new file mode 100644 index 0000000000..d2e86f40e5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js @@ -0,0 +1,310 @@ +import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import EditSourceModal from '../EditSourceModal'; +import { LicensesList } from 'shared/leUtils/Licenses'; +import { constantsTranslationMixin } from 'shared/mixins'; + +let nodes; + +let store; +let contentNodeActions; +let generalActions; +let generalGetters; + +const MIXED_VALUE = 'Mixed'; + +const getLicenseId = translatedLicense => { + if (translatedLicense === MIXED_VALUE) { + return MIXED_VALUE; + } + const translatedLicenses = LicensesList.reduce((acc, license) => { + const { translateConstant } = constantsTranslationMixin.methods; + acc[translateConstant(license.license_name)] = license.id; + return acc; + }, {}); + return translatedLicenses[translatedLicense]; +}; + +const getSourceValues = wrapper => { + return { + author: wrapper.find('[data-test="author-input"] input').element.value, + provider: wrapper.find('[data-test="provider-input"] input').element.value, + aggregator: wrapper.find('[data-test="aggregator-input"] input').element.value, + license: getLicenseId(wrapper.find('.v-select__selections').element.textContent), + license_description: wrapper.find('.license-description textarea')?.element?.value, + copyright_holder: wrapper.find('[data-test="copyright-holder-input"] input').element.value, + }; +}; + +const makeWrapper = nodeIds => { + return mount(EditSourceModal, { + store, + propsData: { + nodeIds, + }, + }); +}; + +describe('EditSourceModal', () => { + beforeEach(() => { + nodes = { + node1: { + id: 'node1', + copyright_holder: 'Test', + }, + node2: { + id: 'node2', + copyright_holder: 'Test', + }, + }; + contentNodeActions = { + updateContentNode: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + generalGetters = { + isAboutLicensesModalOpen: () => false, + }; + store = new Vuex.Store({ + actions: generalActions, + getters: generalGetters, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => ids.map(id => nodes[id]), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper(['node1']); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected source on first render', () => { + test('should display the correct source values when one node is selected', () => { + const testValues = { + author: 'Test author', + provider: 'Test provider', + aggregator: 'Test aggregator', + license: 9, + license_description: 'Test license description', + copyright_holder: 'Test copyright', + }; + Object.assign(nodes.node1, testValues); + const wrapper = makeWrapper(['node1']); + + expect(getSourceValues(wrapper)).toEqual(expect.anything(), testValues); + }); + + test('should display the common source values when multiple nodes are selected', () => { + const testValues = { + author: 'Test author', + provider: 'Test provider', + }; + Object.assign(nodes.node1, testValues); + Object.assign(nodes.node2, testValues); + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(getSourceValues(wrapper)).toEqual(expect.anything(), testValues); + }); + + test('should display the mixed value when the selected nodes have different values', () => { + nodes['node1'].author = 'Test author'; + nodes['node2'].author = 'Test author 2'; + + const wrapper = makeWrapper(['node1', 'node2']); + + const sourceValues = getSourceValues(wrapper); + expect(sourceValues.author).toEqual(MIXED_VALUE); + }); + + test('should disable inputs when all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + nodes['node2'].original_source_node_id = 'original_node2'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="author-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="provider-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="aggregator-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="copyright-holder-input"] input').element.disabled).toBe( + true + ); + }); + + test('should show message that source cannot be edited when all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + nodes['node2'].original_source_node_id = 'original_node2'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('.help').text()).toContain('Cannot edit'); + }); + + test('should disable inputs when node has freeze_authoring_data set to true', () => { + nodes['node1'].freeze_authoring_data = true; + + const wrapper = makeWrapper(['node1']); + + expect(wrapper.find('[data-test="author-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="provider-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="aggregator-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="copyright-holder-input"] input').element.disabled).toBe( + true + ); + }); + + test('should show message that source cannot be edited when node has freeze_authoring_data set to true', () => { + nodes['node1'].freeze_authoring_data = true; + + const wrapper = makeWrapper(['node1']); + + expect(wrapper.find('.help').text()).toContain('Cannot edit'); + }); + + test('should not disable inputs when not all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="author-input"] input').element.disabled).toBe(false); + expect(wrapper.find('[data-test="provider-input"] input').element.disabled).toBe(false); + expect(wrapper.find('[data-test="aggregator-input"] input').element.disabled).toBe(false); + expect(wrapper.find('[data-test="copyright-holder-input"] input').element.disabled).toBe( + false + ); + }); + + test('should show a message that edits will be reflected only for local resources if just some nodes are imported, but not all', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('.help').text()).toContain( + 'Edits will be reflected only for local resources' + ); + }); + }); + + describe('On submit', () => { + test('should not call updateContentNode on submit if copyright holder is missing', () => { + nodes['node1'].copyright_holder = ''; + const wrapper = makeWrapper(['node1']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).not.toHaveBeenCalled(); + }); + + test('should call updateContentNode on success submit for all editable nodes', () => { + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalled(); + }); + + test('should call updateContentNode with the correct parameters on success submit for all editable nodes', () => { + nodes['node1'].author = 'Test author'; + const newAuthor = 'new-author'; + + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="author-input"]').vm.$emit('input', newAuthor); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'node1', + author: newAuthor, + }) + ); + }); + + test('should call updateContentNode on submit just for the editable nodes', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledTimes(1); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'node2', + }) + ); + expect(contentNodeActions.updateContentNode).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'node1', + }) + ); + }); + + test('should not call updateContentNode on submit if all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + nodes['node2'].original_source_node_id = 'original_node2'; + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).not.toHaveBeenCalled(); + }); + + test('should show a snackbar with the correct number of edited nodes on success submit', () => { + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited attribution for 2 resources' + ); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a snack bar with the correct number of edited nodes on success submit if some nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited attribution for 1 resource' + ); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper(['node1', 'node2']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper(['node1', 'node2']); + + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('cancel'); + expect(wrapper.emitted('close')).toBeTruthy(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js new file mode 100644 index 0000000000..2c20379601 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js @@ -0,0 +1,190 @@ +import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import EditTitleDescriptionModal from '../EditTitleDescriptionModal.vue'; + +const nodeId = 'test-id'; + +const node = { + id: nodeId, + title: 'test-title', + description: 'test-description', +}; + +let store; +let contentNodeActions; +let generalActions; + +describe('EditTitleDescriptionModal', () => { + beforeEach(() => { + contentNodeActions = { + updateContentNode: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNode: () => () => node, + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + expect(wrapper.isVueInstance()).toBe(true); + }); + + test('should display the correct title and description on first render', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + expect(wrapper.find('[data-test="title-input"]').vm.$props.value).toBe(node.title); + expect(wrapper.find('[data-test="description-input"]').vm.$props.value).toBe(node.description); + }); + + test('should call updateContentNode on success submit', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + expect(contentNodeActions.updateContentNode).toHaveBeenCalled(); + }); + + test('should call updateContentNode with the correct parameters on success submit', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + const newTitle = 'new-title'; + const newDescription = 'new-description'; + wrapper.find('[data-test="title-input"]').vm.$emit('input', 'new-title'); + wrapper.find('[data-test="description-input"]').vm.$emit('input', 'new-description'); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: nodeId, + title: newTitle, + description: newDescription, + }); + }); + + test('should let update even if description is empty', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + const newTitle = 'new-title'; + wrapper.find('[data-test="title-input"]').vm.$emit('input', 'new-title'); + wrapper.find('[data-test="description-input"]').vm.$emit('input', ''); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: nodeId, + title: newTitle, + description: '', + }); + }); + + test('should validate title on blur', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="title-input"]').vm.$emit('input', ''); + wrapper.find('[data-test="title-input"]').vm.$emit('blur'); + + expect(wrapper.find('[data-test="title-input"]').vm.$props.invalidText).toBeTruthy(); + }); + + test('should validate title on submit', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="title-input"]').vm.$emit('input', ''); + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + expect(wrapper.find('[data-test="title-input"]').vm.$props.invalidText).toBeTruthy(); + }); + + test("should show 'Edited title and description' on a snackbar on success submit", () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited title and description' + ); + cancelAnimationFrame(animationFrameId); + }); + }); + + test("should emit 'close' event on success submit", () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted().close).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('cancel'); + expect(wrapper.emitted().close).toBeTruthy(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue new file mode 100644 index 0000000000..9042367bb9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -0,0 +1,138 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index 3a911dbfc1..3e4a6caecc 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -817,7 +817,7 @@ } }, category(options) { - const ids = Object.keys(options || []); + const ids = Object.keys(options || {}); const matches = Object.keys(Categories) .sort() .filter(k => ids.includes(Categories[k])); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue b/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue index f227eb99e1..a90a5f878c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue @@ -124,6 +124,7 @@ /> - + - - - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue index 192faf6fc1..1dbd45bf0c 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 @@ - + @@ -142,7 +150,7 @@ v-model="completionAndDuration" :kind="firstNode.kind" :fileDuration="fileDuration" - :required="!anyIsDocument || !allSameKind" + :required="!anyIsDocument" /> @@ -375,20 +383,19 @@ import ContentNodeThumbnail from '../../views/files/thumbnails/ContentNodeThumbnail'; import FileUpload from '../../views/files/FileUpload'; import SubtitlesList from '../../views/files/supplementaryLists/SubtitlesList'; - import { isImportedContent, importedChannelLink } from '../../utils'; + 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 CompletionOptions from './CompletionOptions.vue'; - import FormatPresetsMap, { FormatPresetsNames } from 'shared/leUtils/FormatPresets'; + 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, translateValidator, } from 'shared/utils/validation'; - import { findLicense, memoizeDebounce } from 'shared/utils/helpers'; + import { findLicense, memoizeDebounce, getFileDuration } from 'shared/utils/helpers'; import LanguageDropdown from 'shared/views/LanguageDropdown'; import HelpTooltip from 'shared/views/HelpTooltip'; import LicenseDropdown from 'shared/views/LicenseDropdown'; @@ -436,14 +443,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() { @@ -477,6 +480,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: { @@ -586,24 +625,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'); }, @@ -660,7 +703,7 @@ return this.nodes.some(node => node.freeze_authoring_data); }, disableSourceEdits() { - return this.disableAuthEdits || this.isImported; + return this.nodes.some(isDisableSourceEdits); }, detectedImportText() { const count = this.nodes.filter(node => node.freeze_authoring_data).length; @@ -694,20 +737,7 @@ return (this.firstNode && this.getContentNodeFiles(this.firstNode.id)) || []; }, fileDuration() { - if ( - this.firstNode.kind === ContentKindsNames.AUDIO || - this.firstNode.kind === ContentKindsNames.VIDEO - ) { - // filter for the correct file types, - // to exclude files such as subtitle or cc - const audioVideoFiles = this.nodeFiles.filter(file => this.allowedFileType(file)); - // return the last item in the array - const file = audioVideoFiles[audioVideoFiles.length - 1]; - if (file) { - return file.duration; - } - } - return null; + return getFileDuration(this.nodeFiles, this.firstNode.kind); }, videoSelected() { return this.oneSelected && this.firstNode.kind === ContentKindsNames.VIDEO; @@ -789,17 +819,6 @@ isUnique(value) { return value !== nonUniqueValue; }, - allowedFileType(file) { - let allowedFileTypes = []; - // add the relevant format presets for audio and video - // high res and low res are currently the same, so only one is included - allowedFileTypes.push( - FormatPresetsMap.get(FormatPresetsNames.HIGH_RES_VIDEO).allowed_formats - ); - allowedFileTypes.push(FormatPresetsMap.get(FormatPresetsNames.AUDIO).allowed_formats); - allowedFileTypes = allowedFileTypes.flat(); - return allowedFileTypes.includes(file.file_format); - }, getValueFromNodes(key) { const results = uniq( this.nodes.map(node => { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 0d1af1a8d4..00ea9f39e6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -121,6 +121,9 @@ + @@ -186,6 +189,7 @@ import { fileSizeMixin, routerMixin } from 'shared/mixins'; import FileStorage from 'shared/views/files/FileStorage'; import MessageDialog from 'shared/views/MessageDialog'; + import AboutLicensesModal from 'shared/views/AboutLicensesModal'; import ResizableNavigationDrawer from 'shared/views/ResizableNavigationDrawer'; import Uploader from 'shared/views/files/Uploader'; import LoadingText from 'shared/views/LoadingText'; @@ -215,6 +219,7 @@ SavingIndicator, ToolBar, BottomBar, + AboutLicensesModal, }, mixins: [fileSizeMixin, routerMixin], props: { @@ -247,6 +252,7 @@ }; }, computed: { + ...mapGetters(['isAboutLicensesModalOpen']), ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), 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 f3262cebed..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue +++ /dev/null @@ -1,81 +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/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index 6512e9e9b4..36f29a78fd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -80,3 +80,16 @@ export const DraggableRegions = { * @type {number} */ export const ImportSearchPageSize = 10; + +export const QuickEditModals = { + TITLE_DESCRIPTION: 'TITLE_DESCRIPTION', + TAGS: 'TAGS', + LANGUAGE: 'LANGUAGE', + CATEGORIES: 'CATEGORIES', + LEVELS: 'LEVELS', + LEARNING_ACTIVITIES: 'LEARNING_ACTIVITIES', + SOURCE: 'SOURCE', + AUDIENCE: 'AUDIENCE', + COMPLETION: 'COMPLETION', + WHAT_IS_NEEDED: 'WHAT_IS_NEEDED', +}; diff --git a/contentcuration/contentcuration/frontend/channelEdit/getters.js b/contentcuration/contentcuration/frontend/channelEdit/getters.js index d914622d62..455af2aece 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/getters.js @@ -10,6 +10,10 @@ export function isComfortableViewMode(state) { return viewMode === viewModes.COMFORTABLE; } +export function isAboutLicensesModalOpen(state) { + return state.aboutLicensesModalOpen; +} + // Convenience function to format strings like "Page Name - Channel Name" // for tab titles export function appendChannelName(state, getters) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/mutations.js index 2e67ded644..2bd2a99369 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/mutations.js @@ -5,3 +5,7 @@ export function SET_VIEW_MODE(state, viewMode) { export function SET_VIEW_MODE_OVERRIDES(state, overrides) { state.viewModeOverrides = overrides; } + +export function SET_SHOW_ABOUT_LICENSES(state, isOpen) { + state.aboutLicensesModalOpen = isOpen; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/store.js b/contentcuration/contentcuration/frontend/channelEdit/store.js index 068563d111..39a01b26c8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/store.js +++ b/contentcuration/contentcuration/frontend/channelEdit/store.js @@ -31,6 +31,8 @@ export const STORE_CONFIG = { * to override the current `viewMode`. */ viewModeOverrides: [], + + aboutLicensesModalOpen: false, }; }, actions, diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 5e7b868ca9..0e28ea4075 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -4,77 +4,14 @@ import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { metadataStrings, constantStrings } from 'shared/mixins'; import { + ContentModalities, AssessmentItemTypes, CompletionCriteriaModels, - ContentModalities, SHORT_LONG_ACTIVITY_MIDPOINT, - CompletionDropdownMap, + defaultCompletionCriteriaModels, + defaultCompletionCriteriaThresholds, } from 'shared/constants'; -// The constant mapping below is used to set -// default completion criteria and durations -// both as initial values in the edit modal, and -// to ensure backwards compatibility for contentnodes -// that were added before this was in place -export const defaultCompletionCriteriaModels = { - [ContentKindsNames.VIDEO]: CompletionCriteriaModels.TIME, - [ContentKindsNames.AUDIO]: CompletionCriteriaModels.TIME, - [ContentKindsNames.DOCUMENT]: CompletionCriteriaModels.PAGES, - [ContentKindsNames.H5P]: CompletionCriteriaModels.DETERMINED_BY_RESOURCE, - [ContentKindsNames.HTML5]: CompletionCriteriaModels.APPROX_TIME, - [ContentKindsNames.ZIM]: CompletionCriteriaModels.APPROX_TIME, - [ContentKindsNames.EXERCISE]: CompletionCriteriaModels.MASTERY, -}; - -export const defaultCompletionCriteriaThresholds = { - // Audio and Video threshold defaults are dynamic based - // on the duration of the file itself. - [ContentKindsNames.DOCUMENT]: '100%', - [ContentKindsNames.HTML5]: 300, - // We cannot set an automatic default threshold for exercises. -}; - -export const completionCriteriaToDropdownMap = { - [CompletionCriteriaModels.TIME]: CompletionDropdownMap.completeDuration, - [CompletionCriteriaModels.APPROX_TIME]: CompletionDropdownMap.completeDuration, - [CompletionCriteriaModels.PAGES]: CompletionDropdownMap.allContent, - [CompletionCriteriaModels.DETERMINED_BY_RESOURCE]: CompletionDropdownMap.determinedByResource, - [CompletionCriteriaModels.MASTERY]: CompletionDropdownMap.goal, - [CompletionCriteriaModels.REFERENCE]: CompletionDropdownMap.reference, -}; - -export const CompletionOptionsDropdownMap = { - [ContentKindsNames.DOCUMENT]: [ - CompletionDropdownMap.allContent, - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.EXERCISE]: [CompletionDropdownMap.goal, CompletionDropdownMap.practiceQuiz], - [ContentKindsNames.HTML5]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.determinedByResource, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.ZIM]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.determinedByResource, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.H5P]: [ - CompletionDropdownMap.determinedByResource, - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.VIDEO]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.AUDIO]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], -}; - /** * Get correct answer index/indices out of an array of answer objects. * @param {String} questionType single/multiple selection, true/false, input question @@ -203,6 +140,10 @@ export function isImportedContent(node) { ); } +export function isDisableSourceEdits(node) { + return node.freeze_authoring_data || isImportedContent(node); +} + export function importedChannelLink(node, router) { if (node && isImportedContent(node)) { const channelURI = window.Urls.channel(node.original_channel_id); diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue index d8c2d9985f..7cfec94ce6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue @@ -11,7 +11,7 @@ {{ getTitle(item) }} - + - + @@ -27,6 +27,30 @@ + + + + + + + + {{ $tr(mode) }} + + + @@ -40,50 +64,49 @@ style="font-size: 16px;" /> - -
- - - - - - -
-
+
+ {{ selectionText }} +
+
+ + + + + +
- - - - -
- {{ selectionText }} -
-
- + - - - - - - - - {{ $tr(mode) }} - - - - @@ -237,7 +231,13 @@ import MoveModal from '../components/move/MoveModal'; import ContentNodeOptions from '../components/ContentNodeOptions'; import ResourceDrawer from '../components/ResourceDrawer'; - import { RouteNames, viewModes, DraggableRegions, DraggableUniverses } from '../constants'; + import { + RouteNames, + viewModes, + DraggableRegions, + DraggableUniverses, + QuickEditModals, + } from '../constants'; import NodePanel from './NodePanel'; import IconButton from 'shared/views/IconButton'; import ToolBar from 'shared/views/ToolBar'; @@ -284,6 +284,8 @@ loadingAncestors: false, elevated: false, moveModalOpen: false, + breadcrumbsMenu: false, + resourceDrawerMenu: false, }; }, computed: { @@ -335,12 +337,139 @@ } }, }, + commandPaletteOptions() { + const groupedOptions = [ + [ + { + label: this.$tr('editSelectedButton'), + icon: 'edit', + onClick: () => this.editNodes(this.selected), + condition: this.canEdit, + dataTest: 'edit-selected-btn', + }, + { + label: this.$tr('moveSelectedButton'), + icon: 'move', + onClick: () => this.openMoveModal(), + condition: this.canEdit, + dataTest: 'move-selected-btn', + }, + { + label: this.$tr('copySelectedButton'), + icon: 'clipboard', + onClick: () => this.copyToClipboard(this.selected), + condition: true, + dataTest: 'copy-selected-to-clipboard-btn', + }, + { + label: this.$tr('duplicateSelectedButton'), + icon: 'copy', + onClick: () => this.duplicateNodes(this.selected), + condition: this.canEdit, + dataTest: 'duplicate-selected-btn', + }, + { + label: this.$tr('sortAlphabetically'), + icon: 'sort', + onClick: () => this.sortNodes(this.selected), + condition: this.canEdit, + dataTest: 'sort-selected-btn', + }, + { + label: this.$tr('deleteSelectedButton'), + icon: 'remove', + onClick: () => this.removeNodes(this.selected), + condition: this.canEdit, + dataTest: 'delete-selected-btn', + }, + ], + [ + { + label: this.$tr('editLanguageButton'), + icon: 'language', + onClick: this.quickEditModalFactory(QuickEditModals.LANGUAGE), + condition: this.canEdit, + dataTest: 'change-langugage-btn', + }, + ], + [ + { + label: this.$tr('editCategoriesButton'), + icon: 'categories', + onClick: this.quickEditModalFactory(QuickEditModals.CATEGORIES), + condition: this.canEdit, + dataTest: 'change-categories-btn', + }, + { + label: this.$tr('editLevelsButton'), + icon: 'levels', + onClick: this.quickEditModalFactory(QuickEditModals.LEVELS), + condition: this.canEdit, + dataTest: 'change-levels-btn', + }, + { + label: this.$tr('editLearningActivitiesButton'), + icon: 'activities', + onClick: this.quickEditModalFactory(QuickEditModals.LEARNING_ACTIVITIES), + condition: this.canEdit && this.isResourceSelected, + disabled: this.isTopicSelected, + dataTest: 'change-learning-activities-btn', + }, + ], + [ + { + label: this.$tr('editSourceButton'), + icon: 'attribution', + onClick: this.quickEditModalFactory(QuickEditModals.SOURCE), + condition: this.canEdit && this.isResourceSelected, + disabled: this.isTopicSelected, + dataTest: 'change-learning-activities-btn', + }, + { + label: this.$tr('editAudienceButton'), + icon: 'audience', + onClick: this.quickEditModalFactory(QuickEditModals.AUDIENCE), + condition: this.canEdit && this.isResourceSelected, + disabled: this.isTopicSelected, + dataTest: 'change-audience-btn', + }, + { + label: this.$tr('editWhatIsNeededButton'), + icon: 'lesson', + onClick: this.quickEditModalFactory(QuickEditModals.WHAT_IS_NEEDED), + condition: this.canEdit, + dataTest: 'change-resources-neded-btn', + }, + ], + ]; + + const filteredOptions = groupedOptions + .filter(group => group.some(option => option.condition)) + .map(group => group.filter(option => option.condition)); + + // Flatten the array with a divider between each group + return filteredOptions.reduce((acc, group, index) => { + if (index > 0) { + acc.push({ type: 'divider' }); + } + return acc.concat(group); + }, []); + }, height() { return this.hasStagingTree ? 'calc(100vh - 224px)' : 'calc(100vh - 160px)'; }, node() { return this.getContentNode(this.topicId); }, + selectedNodes() { + return this.getContentNodes(this.selected); + }, + isTopicSelected() { + return this.selectedNodes.some(node => node.kind === ContentKindsNames.TOPIC); + }, + isResourceSelected() { + return this.selectedNodes.some(node => node.kind !== ContentKindsNames.TOPIC); + }, ancestors() { return this.getContentNodeAncestors(this.topicId, true).map(ancestor => { return { @@ -386,6 +515,13 @@ ? DropEffect.COPY : DropEffect.MOVE; }, + dividerStyle() { + return { + height: '100%', + backgroundColor: this.$themeTokens.fineLine, + width: '1px', + }; + }, }, watch: { topicId: { @@ -433,6 +569,7 @@ 'moveContentNodes', 'copyContentNode', 'waitForCopyingStatus', + 'setQuickEditModal', ]), ...mapActions('clipboard', ['copyAll']), clearSelections() { @@ -521,6 +658,16 @@ }, }); }, + quickEditModalFactory(modal) { + return () => { + this.setQuickEditModal({ + modal, + nodeIds: this.selected, + }); + const trackActionLabel = modal.replace(/_/g, ' ').toLowerCase(); + this.trackClickEvent(`Edit ${trackActionLabel}`); + }; + }, treeLink(params) { return { name: RouteNames.TREE_VIEW, @@ -745,15 +892,27 @@ trackViewMode(mode) { this.$analytics.trackAction('general', mode); }, + showTitleDescriptionModal(nodeId) { + this.editTitleDescriptionModal = { + nodeId, + }; + }, }, $trs: { addTopic: 'New folder', - SortAlphabetically: 'Sort alphabetically', + sortAlphabetically: 'Sort alphabetically', addExercise: 'New exercise', uploadFiles: 'Upload files', importFromChannels: 'Import from channels', addButton: 'Add', editButton: 'Edit', + editSourceButton: 'Edit Source', + editLevelsButton: 'Edit Levels', + editLanguageButton: 'Edit Language', + editAudienceButton: 'Edit Audience', + editCategoriesButton: 'Edit Categories', + editWhatIsNeededButton: "Edit 'What is needed'", + editLearningActivitiesButton: 'Edit Learning Activity', optionsButton: 'Options', copyToClipboardButton: 'Copy to clipboard', [viewModes.DEFAULT]: 'Default view', @@ -791,4 +950,18 @@ .fade-transition-leave-active { transition-duration: 0.1s } + + .divider-wrapper { + padding: 8px 12px; + align-self: stretch; + } + .command-palette-wrapper { + min-width: 0; + flex-grow: 1; + padding-right: 4px; + } + .no-shrink { + flex-shrink: 0; + } + diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue index aca3ee7e5e..c3e5b5fd8b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue @@ -41,6 +41,7 @@ @infoClick="goToNodeDetail(child.id)" @topicChevronClick="goToTopic(child.id)" @dblclick.native="onNodeDoubleClick(child)" + @editTitleDescription="$emit('editTitleDescription', child.id)" /> diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue index c17dba1dcb..3a22c6b3de 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue @@ -221,6 +221,10 @@ :channel="currentChannel" @syncing="syncInProgress" /> + + !state.connection.online, }), + ...mapGetters(['isAboutLicensesModalOpen']), ...mapGetters('contentNode', ['getContentNode']), ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'canManage', 'rootId']), rootNode() { diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js index 8f0767beeb..eedfa8a763 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js @@ -4,6 +4,7 @@ import assessmentItem from '../../assessmentItem/index'; import file from 'shared/vuex/file'; import { ContentNode } from 'shared/data/resources'; import storeFactory from 'shared/vuex/baseStore'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { mockChannelScope, resetMockChannelScope } from 'shared/utils/testing'; jest.mock('../../currentChannel/index'); @@ -137,6 +138,85 @@ describe('contentNode actions', () => { }); }); }); + describe('updateContentNodeDescendants', () => { + const nodeIdToUpdate = '0000-1111-2222-3333'; + const topicContentNode = '0000-0000-1111-2222'; + const nonTopicContentNode = '0000-0000-1111-1111'; + beforeEach(async () => { + store.commit('contentNode/ADD_CONTENTNODES', [ + { + id: nodeIdToUpdate, + title: 'test', + language: 'en', + kind: ContentKindsNames.TOPIC, + }, + { + id: topicContentNode, + title: 'test nonTopic', + kind: ContentKindsNames.TOPIC, + parent: nodeIdToUpdate, + }, + { + id: nonTopicContentNode, + title: 'test nonTopic', + kind: ContentKindsNames.VIDEO, + parent: topicContentNode, + }, + ]); + }); + afterEach(() => { + store.commit('contentNode/REMOVE_CONTENTNODE', nodeIdToUpdate); + store.commit('contentNode/REMOVE_CONTENTNODE', topicContentNode); + store.commit('contentNode/REMOVE_CONTENTNODE', nonTopicContentNode); + }); + it('should throw an error if we try to update the descendants of a non-Topic node', () => { + expect(() => { + store.dispatch('contentNode/updateContentNodeDescendants', { + id: nonTopicContentNode, + language: 'es', + }); + }).toThrow(); + }); + it('should throw an error if we try to update the title of all descendants', () => { + expect(() => { + store.dispatch('contentNode/updateContentNodeDescendants', { + id: nodeIdToUpdate, + title: 'notatest', + }); + }).toThrow(); + }); + it('should call mutate the language of the descendants content nodes', async () => { + const newLang = 'es'; + await store.dispatch('contentNode/updateContentNodeDescendants', { + id: nodeIdToUpdate, + language: newLang, + }); + + const updatedContentNode = store.getters['contentNode/getContentNode'](nodeIdToUpdate); + expect(updatedContentNode.language).toEqual(newLang); + + const descendants = [topicContentNode, nonTopicContentNode]; + descendants.forEach(descendant => { + const updatedDescendant = store.getters['contentNode/getContentNode'](descendant); + expect(updatedDescendant.language).toEqual(newLang); + }); + }); + it('should call "ContentNode.updateDescendants" with the language update', async () => { + const updateDescendantsSpy = jest.spyOn(ContentNode, 'updateDescendants'); + const newLang = 'es'; + await store.dispatch('contentNode/updateContentNodeDescendants', { + id: nodeIdToUpdate, + language: newLang, + }); + + expect(updateDescendantsSpy).toHaveBeenCalledWith( + nodeIdToUpdate, + expect.objectContaining({ + language: newLang, + }) + ); + }); + }); describe('addTags action', () => { it('should call ContetnNode.update', () => { const updateSpy = jest.spyOn(ContentNode, 'update'); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js index da647bfecc..c9ec87bc1f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js @@ -1,6 +1,6 @@ import flatMap from 'lodash/flatMap'; import uniq from 'lodash/uniq'; -import { NEW_OBJECT, NOVALUE } from 'shared/constants'; +import { NEW_OBJECT, NOVALUE, DescendantsUpdatableFields } from 'shared/constants'; import client from 'shared/client'; import { RELATIVE_TREE_POSITIONS, @@ -382,6 +382,41 @@ export function updateContentNode(context, { id, ...payload } = {}) { return ContentNode.update(id, contentNodeData); } +/** + * Update a content node and all its descendants with the given payload. + * @param {*} context + * @param {string} param.id Id of the parent content to edit. It must be a topic. + */ +export function updateContentNodeDescendants(context, { id, ...payload } = {}) { + if (!id) { + throw ReferenceError('id must be defined to update a contentNode and its descendants'); + } + + const node = context.getters.getContentNode(id); + if (!node || node.kind !== ContentKindsNames.TOPIC) { + throw TypeError('Only topics can have descendants'); + } + + for (const field in payload) { + if (!DescendantsUpdatableFields.includes(field)) { + throw TypeError(`Cannot update field ${field} on all descendants`); + } + } + + const contentNodeData = generateContentNodeData(payload); + + const descendants = context.getters.getContentNodeDescendants(id); + const contentNodeIds = [id, ...descendants.map(node => node.id)]; + + const contentNodesData = contentNodeIds.map(contentNodeId => ({ + id: contentNodeId, + ...contentNodeData, + })); + + context.commit('ADD_CONTENTNODES', contentNodesData); + return ContentNode.updateDescendants(id, contentNodeData); +} + export function addTags(context, { ids, tags }) { return Promise.all( ids.map(id => { @@ -513,3 +548,7 @@ export async function checkSavingProgress( .first(); return Boolean(query); } + +export function setQuickEditModal(context, open) { + context.commit('SET_QUICK_EDIT_MODAL', open); +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js index ba594da164..b6567221f0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/getters.js @@ -351,3 +351,9 @@ export function nodeExpanded(state) { return Boolean(state.expandedNodes[id]); }; } + +export function getQuickEditModalOpen(state) { + return function() { + return state.quickEditModalOpen; + }; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/index.js index 5fa6d1faba..363020d731 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/index.js @@ -15,6 +15,18 @@ export default { contentNodesMap: {}, expandedNodes, + /** + * Object to store information about the current quick edit modal opened. + * Making this part of the vuex store as the quick edit modal can be opened + * from multiple places and deep in the tree. + * + * This object contains the following properties: + * - nodeIds: the ids of the nodes to be edited + * - modal: The name of the quick edit modal being + * opened (from the "QuickEditModals" object in constants.js) + */ + quickEditModalOpen: null, + /* Making this part of the vuex store as it seems like the cleanest solution for managing moving nodes. Alternative solutions are: diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/mutations.js index 18e6cb9272..e872f63a4d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/mutations.js @@ -2,9 +2,11 @@ import Vue from 'vue'; import isEmpty from 'lodash/isEmpty'; import { NEW_OBJECT } from 'shared/constants'; import { mergeMapItem } from 'shared/vuex/utils'; +import { cleanBooleanMaps } from 'shared/utils/helpers'; import { applyMods } from 'shared/data/applyRemoteChanges'; export function ADD_CONTENTNODE(state, contentNode) { + cleanBooleanMaps(contentNode); state.contentNodesMap = mergeMapItem(state.contentNodesMap, contentNode); } @@ -18,7 +20,9 @@ export function UPDATE_CONTENTNODE_FROM_INDEXEDDB(state, { id, ...updates }) { if (id && state.contentNodesMap[id]) { // Need to do object spread to return a new object for setting in the map // otherwise nested changes will not trigger reactive updates - Vue.set(state.contentNodesMap, id, { ...applyMods(state.contentNodesMap[id], updates) }); + const contentNode = { ...applyMods(state.contentNodesMap[id], updates) }; + cleanBooleanMaps(contentNode); + Vue.set(state.contentNodesMap, id, contentNode); } } @@ -74,6 +78,10 @@ export function SET_MOVE_NODES(state, ids) { state.moveNodes = ids; } +export function SET_QUICK_EDIT_MODAL(state, quickEditModalOpen) { + state.quickEditModalOpen = quickEditModalOpen; +} + /** * Remove an entry from the prerequisite mappings. */ diff --git a/contentcuration/contentcuration/frontend/shared/app.js b/contentcuration/contentcuration/frontend/shared/app.js index eef845a36f..d650a1d061 100644 --- a/contentcuration/contentcuration/frontend/shared/app.js +++ b/contentcuration/contentcuration/frontend/shared/app.js @@ -107,6 +107,7 @@ import { } from 'vuetify/lib/directives'; import VueIntl from 'vue-intl'; import Croppa from 'vue-croppa'; +import VueCompositionApi from '@vue/composition-api'; import { Workbox, messageSW } from 'workbox-window'; import KThemePlugin from 'kolibri-design-system/lib/KThemePlugin'; import trackInputModality from 'kolibri-design-system/lib/styles/trackInputModality'; @@ -123,6 +124,7 @@ import urls from 'shared/urls'; import ActionLink from 'shared/views/ActionLink'; import Icon from 'shared/views/Icon'; import Menu from 'shared/views/Menu'; +import Divider from 'shared/views/Divider'; import { initializeDB, resetDB } from 'shared/data'; import { Session, injectVuexStore } from 'shared/data/resources'; @@ -160,6 +162,7 @@ if (process.env.NODE_ENV !== 'production') { Vue.use(Croppa); Vue.use(VueIntl); Vue.use(VueRouter); +Vue.use(VueCompositionApi); Vue.use(Vuetify, { components: { // Explicitly register used Vuetify components globally @@ -260,6 +263,7 @@ Vue.use(AnalyticsPlugin, { dataLayer: window.dataLayer }); // Register global components Vue.component('ActionLink', ActionLink); Vue.component('Menu', Menu); +Vue.component('Divider', Divider); Vue.component('Icon', Icon); function initiateServiceWorker() { diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 29449880ec..f305949394 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -2,6 +2,7 @@ import invert from 'lodash/invert'; import Subjects from 'kolibri-constants/labels/Subjects'; import CompletionCriteria from 'kolibri-constants/CompletionCriteria'; import ContentLevels from 'kolibri-constants/labels/Levels'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import featureFlagsSchema from 'static/feature_flags.json'; export { default as LearningActivities } from 'kolibri-constants/labels/LearningActivities'; @@ -231,3 +232,87 @@ export const DurationDropdownMap = { // Define an object to act as the place holder for non unique values. export const nonUniqueValue = {}; nonUniqueValue.toString = () => ''; + +/** + * Not all fields are updatable on descendants, for many of them does not + * make sense to update to all the descendants, such as the title. + */ +export const DescendantsUpdatableFields = [ + 'language', + 'categories', + 'grade_levels', + 'learner_needs', +]; + +export const ResourcesNeededOptions = [ + 'PEERS', + 'TEACHER', + 'INTERNET', + 'SPECIAL_SOFTWARE', + 'PAPER_PENCIL', + 'OTHER_SUPPLIES', +]; + +// The constant mapping below is used to set +// default completion criteria and durations +// both as initial values in the edit modal, and +// to ensure backwards compatibility for contentnodes +// that were added before this was in place +export const defaultCompletionCriteriaModels = { + [ContentKindsNames.VIDEO]: CompletionCriteria.TIME, + [ContentKindsNames.AUDIO]: CompletionCriteria.TIME, + [ContentKindsNames.DOCUMENT]: CompletionCriteria.PAGES, + [ContentKindsNames.H5P]: CompletionCriteria.DETERMINED_BY_RESOURCE, + [ContentKindsNames.HTML5]: CompletionCriteria.APPROX_TIME, + [ContentKindsNames.ZIM]: CompletionCriteria.APPROX_TIME, + [ContentKindsNames.EXERCISE]: CompletionCriteria.MASTERY, +}; + +export const defaultCompletionCriteriaThresholds = { + // Audio and Video threshold defaults are dynamic based + // on the duration of the file itself. + [ContentKindsNames.DOCUMENT]: '100%', + [ContentKindsNames.HTML5]: 300, + // We cannot set an automatic default threshold for exercises. +}; + +export const CompletionOptionsDropdownMap = { + [ContentKindsNames.DOCUMENT]: [ + CompletionDropdownMap.allContent, + CompletionDropdownMap.completeDuration, + CompletionDropdownMap.reference, + ], + [ContentKindsNames.EXERCISE]: [CompletionDropdownMap.goal, CompletionDropdownMap.practiceQuiz], + [ContentKindsNames.HTML5]: [ + CompletionDropdownMap.completeDuration, + CompletionDropdownMap.determinedByResource, + CompletionDropdownMap.reference, + ], + [ContentKindsNames.ZIM]: [ + CompletionDropdownMap.completeDuration, + CompletionDropdownMap.determinedByResource, + CompletionDropdownMap.reference, + ], + [ContentKindsNames.H5P]: [ + CompletionDropdownMap.determinedByResource, + CompletionDropdownMap.completeDuration, + CompletionDropdownMap.reference, + ], + [ContentKindsNames.VIDEO]: [ + CompletionDropdownMap.completeDuration, + CompletionDropdownMap.reference, + ], + [ContentKindsNames.AUDIO]: [ + CompletionDropdownMap.completeDuration, + CompletionDropdownMap.reference, + ], +}; + +export const completionCriteriaToDropdownMap = { + [CompletionCriteria.TIME]: CompletionDropdownMap.completeDuration, + [CompletionCriteria.APPROX_TIME]: CompletionDropdownMap.completeDuration, + [CompletionCriteria.PAGES]: CompletionDropdownMap.allContent, + [CompletionCriteria.DETERMINED_BY_RESOURCE]: CompletionDropdownMap.determinedByResource, + [CompletionCriteria.MASTERY]: CompletionDropdownMap.goal, + [CompletionCriteria.REFERENCE]: CompletionDropdownMap.reference, +}; diff --git a/contentcuration/contentcuration/frontend/shared/data/__tests__/applyRemoteChanges.spec.js b/contentcuration/contentcuration/frontend/shared/data/__tests__/applyRemoteChanges.spec.js index fc9d6d3f6c..b4eb86eda6 100644 --- a/contentcuration/contentcuration/frontend/shared/data/__tests__/applyRemoteChanges.spec.js +++ b/contentcuration/contentcuration/frontend/shared/data/__tests__/applyRemoteChanges.spec.js @@ -177,6 +177,19 @@ describe('ChangeDispatcher', () => { expect(changeDispatcher.applyPublish).toHaveBeenCalledWith(change); expect(result).toBe(applyPublishResult); }); + + it('should call applyUpdateDescendants if change type is UPDATED_DESCENDANTS and applyUpdateDescendants is defined', async () => { + const change = { type: CHANGE_TYPES.UPDATED_DESCENDANTS }; + const applyUpdateDescendantsResult = 'update descendants result'; + changeDispatcher.applyUpdateDescendants = jest + .fn() + .mockResolvedValue(applyUpdateDescendantsResult); + + const result = await changeDispatcher.apply(change); + + expect(changeDispatcher.applyUpdateDescendants).toHaveBeenCalledWith(change); + expect(result).toBe(applyUpdateDescendantsResult); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/data/__tests__/changes.spec.js b/contentcuration/contentcuration/frontend/shared/data/__tests__/changes.spec.js index ca5e2181f9..ea1a795dec 100644 --- a/contentcuration/contentcuration/frontend/shared/data/__tests__/changes.spec.js +++ b/contentcuration/contentcuration/frontend/shared/data/__tests__/changes.spec.js @@ -9,6 +9,7 @@ import { PublishedChange, SyncedChange, DeployedChange, + UpdatedDescendantsChange, } from '../changes'; import { CHANGES_TABLE, @@ -289,6 +290,26 @@ describe('Change Types', () => { ...pick(change, ['type', 'key', 'table', 'source']), }); }); + + it('should persist only the specified fields in the UpdatedDescendantsChange', async () => { + const changes = { language: 'es' }; + const change = new UpdatedDescendantsChange({ + key: '1', + table: TABLE_NAMES.CONTENTNODE, + oldObj: { title: 'test', language: 'en' }, + changes, + source: CLIENTID, + }); + const rev = await change.saveChange(); + const persistedChange = await db[CHANGES_TABLE].get(rev); + + expect(persistedChange).toEqual({ + rev, + channel_id, + mods: changes, + ...pick(change, ['type', 'key', 'table', 'oldObj', 'source']), + }); + }); }); describe('Change Types Unhappy Paths', () => { @@ -649,4 +670,24 @@ describe('Change Types Unhappy Paths', () => { new ReferenceError('test is not a valid table value') ); }); + + // UpdatedDescendantsChange + it('should throw error when UpdatedDescendantsChange is instantiated without changes', () => { + expect( + () => + new UpdatedDescendantsChange({ key: '1', table: TABLE_NAMES.CONTENTNODE, source: CLIENTID }) + ).toThrow(new TypeError('changes should be an object, but undefined was passed instead')); + }); + + it('should throw error if UpdatedDescendantsChange is instantiated with a table different than CONTENTNODE', () => { + expect( + () => + new UpdatedDescendantsChange({ + key: '1', + table: TABLE_NAMES.CHANNEL, + changes: {}, + source: CLIENTID, + }) + ).toThrow(); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/data/__tests__/resources.spec.js b/contentcuration/contentcuration/frontend/shared/data/__tests__/resources.spec.js new file mode 100644 index 0000000000..34b285e48e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/data/__tests__/resources.spec.js @@ -0,0 +1,174 @@ +import { UpdatedDescendantsChange } from '../changes'; +import db from 'shared/data/db'; +import { CHANGE_TYPES, TABLE_NAMES } from 'shared/data/constants'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; +import { ContentNode } from 'shared/data/resources'; +import { mockChannelScope, resetMockChannelScope } from 'shared/utils/testing'; + +const CLIENTID = 'test-client-id'; + +const parentId = 'test-parent-id'; +const childId = 'test-child-id'; +const grandchildId = 'test-grandchild-id'; + +const contentNodes = [ + { + id: parentId, + title: 'test-title-1', + kind: ContentKindsNames.TOPIC, + }, + { + id: childId, + title: 'test-title-2', + parent: parentId, + kind: ContentKindsNames.TOPIC, + }, + { + id: grandchildId, + title: 'test-title-3', + parent: childId, + kind: ContentKindsNames.VIDEO, + }, +]; + +describe('Resources', () => { + const channel_id = 'test-123'; + beforeEach(async () => { + await db[TABLE_NAMES.CONTENTNODE].clear(); + await db[TABLE_NAMES.CHANGES_TABLE].clear(); + await mockChannelScope(channel_id); + + await Promise.all(contentNodes.map(node => db[TABLE_NAMES.CONTENTNODE].add(node))); + }); + + afterEach(async () => { + await resetMockChannelScope(); + }); + + describe('ContentNode resource', () => { + describe('Updated descendants changes', () => { + const saveChange = async (changes, key = parentId) => { + const change = new UpdatedDescendantsChange({ + key, + table: TABLE_NAMES.CONTENTNODE, + changes, + source: CLIENTID, + }); + + const rev = await change.saveChange(); + return rev; + }; + + it('should get empty array if no data is passed', async () => { + await saveChange({ lang: 'en' }); + + const inheritedChanges = await ContentNode.getInheritedChanges([]); + expect(inheritedChanges).toEqual([]); + }); + + it('should get empty array if no descendants changes have been made', async () => { + const inheritedChanges = await ContentNode.getInheritedChanges([contentNodes[2]]); + expect(inheritedChanges).toEqual([]); + }); + + it('should get empty array if no descendants changes have been made to the correct ascendants', async () => { + await saveChange({ lang: 'en' }, 'test-123'); + const inheritedChanges = await ContentNode.getInheritedChanges([contentNodes[2]]); + expect(inheritedChanges).toEqual([]); + }); + + it('should return the inherited changes for children', async () => { + const rev = await saveChange({ lang: 'en' }); + const inheritedChanges = await ContentNode.getInheritedChanges([contentNodes[1]]); + expect(inheritedChanges.length).toEqual(1); + expect(inheritedChanges[0].rev).toEqual(rev); + }); + + it('should return the inherited changes for descendants', async () => { + const rev = await saveChange({ lang: 'en' }); + const inheritedChanges = await ContentNode.getInheritedChanges([contentNodes[2]]); + expect(inheritedChanges.length).toEqual(1); + expect(inheritedChanges[0].rev).toEqual(rev); + }); + + it('descendants should inherit changes as "UPDATED" type of change for themsleves', async () => { + await saveChange({ lang: 'en' }); + const [change] = await ContentNode.getInheritedChanges([contentNodes[2]]); + expect(change.type).toEqual(CHANGE_TYPES.UPDATED); + expect(change.key).toEqual(contentNodes[2].id); + }); + + it('should return multiple inherited changes', async () => { + const revs = await Promise.all([saveChange({ lang: 'en' }), saveChange({ lang: 'es' })]); + const inheritedChanges = await ContentNode.getInheritedChanges([contentNodes[1]]); + expect(inheritedChanges.length).toEqual(revs.length); + expect(inheritedChanges.map(change => change.rev)).toEqual(revs); + }); + + it('should return the inherited changes for multiple descendants', async () => { + const rev = await saveChange({ lang: 'en' }); + const inheritedChanges = await ContentNode.getInheritedChanges([ + contentNodes[1], + contentNodes[2], + ]); + expect(inheritedChanges.length).toEqual(2); + + const [first, second] = inheritedChanges; + expect(first.rev).toEqual(rev); + expect(second.rev).toEqual(rev); + expect(first.key).toEqual(contentNodes[1].id); + expect(second.key).toEqual(contentNodes[2].id); + }); + + it('should return the inherited changes from multiple ascendants', async () => { + const revs = await Promise.all([ + saveChange({ lang: 'en' }, parentId), + saveChange({ lang: 'es' }, childId), + ]); + const inheritedChanges = await ContentNode.getInheritedChanges([contentNodes[2]]); + expect(inheritedChanges.length).toEqual(revs.length); + expect(inheritedChanges.map(change => change.rev)).toEqual(revs); + }); + }); + + describe('Update descendants', () => { + const changes = { lang: 'en' }; + it('should update the parent itself', async () => { + await ContentNode.updateDescendants(parentId, changes); + const node = await db[TABLE_NAMES.CONTENTNODE].get(parentId); + expect(node).toEqual(expect.objectContaining(changes)); + }); + + it('should update the children', async () => { + await ContentNode.updateDescendants(parentId, changes); + const node = await db[TABLE_NAMES.CONTENTNODE].get(childId); + expect(node).toEqual(expect.objectContaining(changes)); + }); + + it('should update the descendants', async () => { + await ContentNode.updateDescendants(parentId, changes); + const node = await db[TABLE_NAMES.CONTENTNODE].get(grandchildId); + expect(node).toEqual(expect.objectContaining(changes)); + }); + + it('shouldnt update other nodes', async () => { + await ContentNode.updateDescendants('test-123', changes); + const node = await db[TABLE_NAMES.CONTENTNODE].get(parentId); + expect(node.language).not.toEqual(changes.lang); + }); + + it('should save a new UPDATED DESCENDANTS change', async () => { + await ContentNode.updateDescendants(parentId, changes); + + const change = await db[TABLE_NAMES.CHANGES_TABLE] + .where({ + type: CHANGE_TYPES.UPDATED_DESCENDANTS, + }) + .first(); + expect(change).toBeDefined(); + expect(change.key).toEqual(parentId); + expect(change.mods).toEqual(changes); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js b/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js index 7e81231b98..eaa03cd6c0 100644 --- a/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js +++ b/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js @@ -7,7 +7,16 @@ import { INDEXEDDB_RESOURCES } from './registry'; import { RolesNames } from 'shared/leUtils/Roles'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; -const { CREATED, DELETED, UPDATED, MOVED, PUBLISHED, SYNCED, DEPLOYED } = CHANGE_TYPES; +const { + CREATED, + DELETED, + UPDATED, + MOVED, + PUBLISHED, + SYNCED, + DEPLOYED, + UPDATED_DESCENDANTS, +} = CHANGE_TYPES; export function applyMods(obj, mods) { for (const keyPath in mods) { @@ -32,6 +41,7 @@ export function collectChanges(changes) { [PUBLISHED]: [], [SYNCED]: [], [DEPLOYED]: [], + [UPDATED_DESCENDANTS]: [], }; } collectedChanges[change.table][change.type].push(change); @@ -76,6 +86,8 @@ export class ChangeDispatcher { result = await this.applyCopy(change); } else if (change.type === CHANGE_TYPES.PUBLISHED && this.applyPublish) { result = await this.applyPublish(change); + } else if (change.type === CHANGE_TYPES.UPDATED_DESCENDANTS && this.applyUpdateDescendants) { + result = await this.applyUpdateDescendants(change); } } catch (e) { logging.error(e, { @@ -200,6 +212,30 @@ class ReturnedChanges extends ChangeDispatcher { .modify({ changed: false, published: true }); }); } + + /** + * @param {UpdatedDescendantsChange} change + * @return {Promise} + */ + applyUpdateDescendants(change) { + if (change.table !== TABLE_NAMES.CONTENTNODE) { + return Promise.resolve(); + } + + const resource = INDEXEDDB_RESOURCES[TABLE_NAMES.CONTENTNODE]; + if (!resource || !resource.updateDescendants) { + return Promise.resolve(); + } + + return transaction(change, TABLE_NAMES.CONTENTNODE, async () => { + const ids = await resource.getLoadedDescendantsIds(change.key); + return db + .table(TABLE_NAMES.CONTENTNODE) + .where(':id') + .anyOf(ids) + .modify(obj => applyMods(obj, change.mods)); + }); + } } /** diff --git a/contentcuration/contentcuration/frontend/shared/data/changes.js b/contentcuration/contentcuration/frontend/shared/data/changes.js index 9c00afa7bd..eca58ee4de 100644 --- a/contentcuration/contentcuration/frontend/shared/data/changes.js +++ b/contentcuration/contentcuration/frontend/shared/data/changes.js @@ -21,6 +21,12 @@ import { COPYING_STATUS, TASK_ID, } from 'shared/data/constants'; +import { + Categories, + ContentLevels, + ResourcesNeededTypes, + ResourcesNeededOptions, +} from 'shared/constants'; import { INDEXEDDB_RESOURCES } from 'shared/data/registry'; /** @@ -466,3 +472,59 @@ export class DeployedChange extends Change { this.setChannelAndUserId({ id: this.key }); } } + +/** + * Change that represents an update to a content node and its descendants + * It can be used just with the content node table. + */ +export class UpdatedDescendantsChange extends Change { + constructor({ oldObj, changes, ...fields }) { + fields.type = CHANGE_TYPES.UPDATED_DESCENDANTS; + super(fields); + if (this.table !== TABLE_NAMES.CONTENTNODE) { + throw TypeError( + `${this.changeType} is only supported by ${TABLE_NAMES.CONTENTNODE} table but ${this.table} was passed instead` + ); + } + this.validateObj(changes, 'changes'); + changes = omitIgnoredSubFields(changes); + this.mods = changes; + this.setModsDeletedProperties(); + this.setChannelAndUserId(oldObj); + } + + get changed() { + return !isEmpty(this.mods); + } + + /** + * To ensure that the mods properties that are multi valued have set + * to true just the options that are present in the mods object. All + * other options are set to null. + */ + setModsDeletedProperties() { + if (!this.mods) return; + + const multiValueProperties = { + categories: Object.values(Categories), + learner_needs: ResourcesNeededOptions.map(option => ResourcesNeededTypes[option]), + grade_levels: Object.values(ContentLevels), + }; + Object.entries(multiValueProperties).forEach(([key, values]) => { + if (this.mods[key]) { + values.forEach(value => { + if (!this.mods[key][value]) { + this.mods[key][value] = null; + } + }); + } + }); + } + + saveChange() { + if (!this.changed) { + return Promise.resolve(null); + } + return super.saveChange(); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js index 6267f5d308..1785192a60 100644 --- a/contentcuration/contentcuration/frontend/shared/data/constants.js +++ b/contentcuration/contentcuration/frontend/shared/data/constants.js @@ -9,6 +9,7 @@ export const CHANGE_TYPES = { PUBLISHED: 6, SYNCED: 7, DEPLOYED: 8, + UPDATED_DESCENDANTS: 9, }; /** * An array of change types that directly result in the creation of nodes diff --git a/contentcuration/contentcuration/frontend/shared/data/index.js b/contentcuration/contentcuration/frontend/shared/data/index.js index 1baed3d8d5..705f84b619 100644 --- a/contentcuration/contentcuration/frontend/shared/data/index.js +++ b/contentcuration/contentcuration/frontend/shared/data/index.js @@ -14,14 +14,14 @@ export function setupSchema() { if (!Object.keys(resources).length) { console.warn('No resources defined!'); // eslint-disable-line no-console } - // Version incremented to 2 to add Bookmark table and new index on CHANGES_TABLE. - db.version(2).stores({ + // Version incremented to 3 to add new index on CHANGES_TABLE. + db.version(3).stores({ // A special table for logging unsynced changes // Dexie.js appears to have a table for this, // but it seems to squash and remove changes in ways // that I do not currently understand, so we engage // in somewhat duplicative behaviour instead. - [CHANGES_TABLE]: 'rev++,[table+key],server_rev', + [CHANGES_TABLE]: 'rev++,[table+key],server_rev,type', ...mapValues(INDEXEDDB_RESOURCES, value => value.schema), }); } diff --git a/contentcuration/contentcuration/frontend/shared/data/mergeChanges.js b/contentcuration/contentcuration/frontend/shared/data/mergeChanges.js index 93a9a90914..3c4129f099 100644 --- a/contentcuration/contentcuration/frontend/shared/data/mergeChanges.js +++ b/contentcuration/contentcuration/frontend/shared/data/mergeChanges.js @@ -132,7 +132,7 @@ export default function mergeAllChanges(changes, flatten = false, changesToSync if (!('rev' in change) || typeof change.rev === 'undefined') { console.error('This change has no `rev`:', change); throw new Error('Cannot determine the correct order for a change with no `rev`.'); - } else if (lastRev && change.rev <= lastRev) { + } else if (lastRev && change.rev < lastRev) { console.error("These changes aren't ordered by `rev`:", changes); throw new Error('Cannot merge changes unless they are ordered by `rev`.'); } diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index e55337be5f..15f6ce4b0e 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -40,11 +40,13 @@ import { PublishedChange, SyncedChange, DeployedChange, + UpdatedDescendantsChange, } from './changes'; import urls from 'shared/urls'; import { currentLanguage } from 'shared/i18n'; import client, { paramsSerializer } from 'shared/client'; import { DELAYED_VALIDATION, fileErrors, NEW_OBJECT } from 'shared/constants'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; // Number of seconds after which data is considered stale. const REFRESH_INTERVAL = 5; @@ -268,6 +270,68 @@ class IndexedDBResource { }); } + /** + * Search the "updated descendants" changes of the current resource and its + * parents to find any changes that should be applied to the current resource. + * And it transforms these "updated descendants" changes into "updated" changes + * to the current resource. + * @returns + */ + async getInheritedChanges(itemData = []) { + if (this.tableName !== TABLE_NAMES.CONTENTNODE || !itemData.length) { + return Promise.resolve([]); + } + + const updatedDescendantsChanges = await db[CHANGES_TABLE].where('type') + .equals(CHANGE_TYPES.UPDATED_DESCENDANTS) + .toArray(); + if (!updatedDescendantsChanges.length) { + return Promise.resolve([]); + } + + const inheritedChanges = []; + const parentIds = [...new Set(itemData.map(item => item.parent).filter(Boolean))]; + const ancestorsPromises = parentIds.map(parentId => this.getAncestors(parentId)); + const parentsAncestors = await Promise.all(ancestorsPromises); + + parentsAncestors.forEach(ancestors => { + const parent = ancestors[ancestors.length - 1]; + const ancestorsIds = ancestors.map(ancestor => ancestor.id); + const parentChanges = updatedDescendantsChanges.filter(change => + ancestorsIds.includes(change.key) + ); + if (!parentChanges.length) { + return; + } + + itemData + .filter(item => item.parent === parent.id) + .forEach(item => { + inheritedChanges.push( + ...parentChanges.map(change => ({ + ...change, + key: item.id, + type: CHANGE_TYPES.UPDATED, + })) + ); + }); + }); + + return inheritedChanges; + } + + mergeDescendantsChanges(changes, inheritedChanges) { + if (inheritedChanges.length) { + changes.push(...inheritedChanges); + changes = sortBy(changes, 'rev'); + } + changes + .filter(change => change.type === CHANGE_TYPES.UPDATED_DESCENDANTS) + .forEach(change => (change.type = CHANGE_TYPES.UPDATED)); + + return changes; + } + setData(itemData) { const now = Date.now(); // Directly write to the table, rather than using the add/update methods @@ -277,79 +341,84 @@ class IndexedDBResource { const changesPromise = db[CHANGES_TABLE].where('[table+key]') .anyOf(itemData.map(datum => [this.tableName, this.getIdValue(datum)])) .sortBy('rev'); + const inheritedChangesPromise = this.getInheritedChanges(itemData); const currentPromise = this.table .where(this.idField) .anyOf(itemData.map(datum => this.getIdValue(datum))) .toArray(); - return Promise.all([changesPromise, currentPromise]).then(([changes, currents]) => { - changes = mergeAllChanges(changes, true); - const collectedChanges = collectChanges(changes)[this.tableName] || {}; - for (const changeType of Object.keys(collectedChanges)) { - const map = {}; - for (const change of collectedChanges[changeType]) { - map[change.key] = change; - } - collectedChanges[changeType] = map; - } - const currentMap = {}; - for (const currentObj of currents) { - currentMap[this.getIdValue(currentObj)] = currentObj; - } - const data = itemData - .map(datum => { - const id = this.getIdValue(datum); - datum[LAST_FETCHED] = now; - // Persist TASK_ID and COPYING_STATUS attributes when directly fetching from the server - if (currentMap[id] && currentMap[id][TASK_ID]) { - datum[TASK_ID] = currentMap[id][TASK_ID]; - } - if (currentMap[id] && currentMap[id][COPYING_STATUS]) { - datum[COPYING_STATUS] = currentMap[id][COPYING_STATUS]; + return Promise.all([changesPromise, inheritedChangesPromise, currentPromise]).then( + ([changes, inheritedChanges, currents]) => { + changes = this.mergeDescendantsChanges(changes, inheritedChanges); + changes = mergeAllChanges(changes, true); + const collectedChanges = collectChanges(changes)[this.tableName] || {}; + for (const changeType of Object.keys(collectedChanges)) { + const map = {}; + for (const change of collectedChanges[changeType]) { + map[change.key] = change; } - // If we have an updated change, apply the modifications here - if ( - collectedChanges[CHANGE_TYPES.UPDATED] && - collectedChanges[CHANGE_TYPES.UPDATED][id] - ) { - applyMods(datum, collectedChanges[CHANGE_TYPES.UPDATED][id].mods); - } - return datum; - // If we have a deleted change, just filter out this object so we don't reput it - }) - .filter( - datum => - !collectedChanges[CHANGE_TYPES.DELETED] || - !collectedChanges[CHANGE_TYPES.DELETED][this.getIdValue(datum)] - ); - return this.table.bulkPut(data).then(() => { - // Move changes need to be reapplied on top of fetched data in case anything - // has happened on the backend. - return applyChanges(Object.values(collectedChanges[CHANGE_TYPES.MOVED] || {})).then( - results => { - if (!results || !results.length) { - return data; + collectedChanges[changeType] = map; + } + const currentMap = {}; + for (const currentObj of currents) { + currentMap[this.getIdValue(currentObj)] = currentObj; + } + const data = itemData + .map(datum => { + const id = this.getIdValue(datum); + datum[LAST_FETCHED] = now; + // Persist TASK_ID and COPYING_STATUS attributes when directly + // fetching from the server + if (currentMap[id] && currentMap[id][TASK_ID]) { + datum[TASK_ID] = currentMap[id][TASK_ID]; } - const resultsMap = {}; - for (const result of results) { - const id = this.getIdValue(result); - resultsMap[id] = result; + if (currentMap[id] && currentMap[id][COPYING_STATUS]) { + datum[COPYING_STATUS] = currentMap[id][COPYING_STATUS]; } - return data - .map(datum => { - const id = this.getIdValue(datum); - if (resultsMap[id]) { - applyMods(datum, resultsMap[id]); - } - return datum; - // Concatenate any unsynced created objects onto - // the end of the returned objects - }) - .concat(Object.values(collectedChanges[CHANGE_TYPES.CREATED]).map(c => c.obj)); - } - ); - }); - }); + // If we have an updated change, apply the modifications here + if ( + collectedChanges[CHANGE_TYPES.UPDATED] && + collectedChanges[CHANGE_TYPES.UPDATED][id] + ) { + applyMods(datum, collectedChanges[CHANGE_TYPES.UPDATED][id].mods); + } + return datum; + // If we have a deleted change, just filter out this object so we don't reput it + }) + .filter( + datum => + !collectedChanges[CHANGE_TYPES.DELETED] || + !collectedChanges[CHANGE_TYPES.DELETED][this.getIdValue(datum)] + ); + return this.table.bulkPut(data).then(() => { + // Move changes need to be reapplied on top of fetched data in case anything + // has happened on the backend. + return applyChanges(Object.values(collectedChanges[CHANGE_TYPES.MOVED] || {})).then( + results => { + if (!results || !results.length) { + return data; + } + const resultsMap = {}; + for (const result of results) { + const id = this.getIdValue(result); + resultsMap[id] = result; + } + return data + .map(datum => { + const id = this.getIdValue(datum); + if (resultsMap[id]) { + applyMods(datum, resultsMap[id]); + } + return datum; + // Concatenate any unsynced created objects onto + // the end of the returned objects + }) + .concat(Object.values(collectedChanges[CHANGE_TYPES.CREATED]).map(c => c.obj)); + } + ); + }); + } + ); }); } @@ -1712,6 +1781,63 @@ export const ContentNode = new TreeResource({ }); }); }, + async _updateDescendantsChange(id, changes) { + const oldObj = await this.table.get(id); + if (!oldObj) { + return Promise.resolve(); + } + + const change = new UpdatedDescendantsChange({ + key: id, + table: this.tableName, + oldObj, + changes, + source: CLIENTID, + }); + return this._saveAndQueueChange(change); + }, + /** + * Load descendants of a content node that are already in IndexedDB. + * It also returns the node itself. + * @param {string} id + * @returns {Promise} + * + */ + async getLoadedDescendantsIds(id) { + const children = await this.table.where({ parent: id }).toArray(); + if (!children.length) { + return [id]; + } + const descendants = await Promise.all( + children.map(child => { + if (child.kind === ContentKindsNames.TOPIC) { + return this.getLoadedDescendantsIds(child.id); + } + return child.id; + }) + ); + return [id].concat(flatMap(descendants, d => d)); + }, + /** + * Update a node and all its descendants that are already loaded in IndexedDB + * @param {*} id parent node to update + * @param {*} changes actual changes to made + * @returns {Promise} + */ + updateDescendants(id, changes) { + return this.transaction({ mode: 'rw' }, CHANGES_TABLE, async () => { + changes = this._cleanNew(changes); + + // Update node descendants that are already loaded + const ids = await this.getLoadedDescendantsIds(id); + await this.table + .where('id') + .anyOf(...ids) + .modify(changes); + + return this._updateDescendantsChange(id, changes); + }); + }, }); export const ChannelSet = new CreateModelResource({ diff --git a/contentcuration/contentcuration/frontend/shared/data/serverSync.js b/contentcuration/contentcuration/frontend/shared/data/serverSync.js index 6cffeae584..ead673ce19 100644 --- a/contentcuration/contentcuration/frontend/shared/data/serverSync.js +++ b/contentcuration/contentcuration/frontend/shared/data/serverSync.js @@ -43,6 +43,7 @@ const ChangeTypeMapFields = { 'assessment_items', ]), [CHANGE_TYPES.DEPLOYED]: commonFields, + [CHANGE_TYPES.UPDATED_DESCENDANTS]: commonFields.concat(['mods']), }; /** diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.js index 28b056cc3e..2e60701048 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.js @@ -3,7 +3,10 @@ import debounce from 'lodash/debounce'; import memoize from 'lodash/memoize'; import merge from 'lodash/merge'; +import { Categories, CategoriesLookup } from 'shared/constants'; import { LicensesList } from 'shared/leUtils/Licenses'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; +import FormatPresetsMap, { FormatPresetsNames } from 'shared/leUtils/FormatPresets'; function safeParseInt(str) { const parsed = parseInt(str); @@ -424,3 +427,151 @@ export function memoizeDebounce(func, wait = 0, options = {}) { mem.apply(this, arguments).apply(this, arguments); }; } + +/** + * Remove falsy properties in contentNode boolean maps. + * This is necessary as in many places we rely on Object.keys() to + * get the values of a field, but if the value is false, it should not + * be considered a valid value. + */ +export function cleanBooleanMaps(contentNode) { + const booleanMapFields = [ + 'grade_levels', + 'learner_needs', + 'accessibility_labels', + 'learning_activities', + 'categories', + ]; + booleanMapFields.forEach(field => { + if (contentNode[field]) { + Object.keys(contentNode[field]).forEach(key => { + if (!contentNode[field][key]) { + delete contentNode[field][key]; + } + }); + } + }); +} + +/** + * Copied implementation from Kolibri to have the same categories order. + * From: https://github.com/learningequality/kolibri/blob/c372cd05ddd105a7688db9e3698dc21b842ac3e5/kolibri/plugins/learn/assets/src/composables/useSearch.js#L77 + */ +function getCategoriesTree() { + const libraryCategories = {}; + + const availablePaths = {}; + + (Object.values(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 function getSortedCategories() { + const categoriesTree = getCategoriesTree(); + const categoriesSorted = {}; + const sortCategories = function(categories) { + Object.entries(categories).forEach(([name, category]) => { + categoriesSorted[category.value] = name; + sortCategories(category.nested); + }); + }; + sortCategories(categoriesTree); + return categoriesSorted; +} + +export function isAudioVideoFile(file) { + if (!file || !file.file_format) { + return false; + } + + const videoAllowedFormats = FormatPresetsMap.get(FormatPresetsNames.HIGH_RES_VIDEO) + .allowed_formats; + const audioAllowedFormats = FormatPresetsMap.get(FormatPresetsNames.AUDIO).allowed_formats; + return ( + videoAllowedFormats.includes(file.file_format) || audioAllowedFormats.includes(file.file_format) + ); +} + +export function getFileDuration(nodeFiles, kind) { + if ( + !nodeFiles || + !nodeFiles.length || + ![ContentKindsNames.AUDIO, ContentKindsNames.VIDEO].includes(kind) + ) { + return null; + } + + // filter for the correct file types, + // to exclude files such as subtitle or cc + const audioVideoFiles = nodeFiles.filter(file => isAudioVideoFile(file)); + // return the last item in the array + const file = audioVideoFiles[audioVideoFiles.length - 1]; + if (!file || !file.duration) { + return null; + } + return file.duration; +} + +export function hasMultipleFieldValues(array, field) { + let value; + for (const item of array) { + if (!item[field]) { + continue; + } + if (value === undefined) { + value = item[field]; + } else if (value !== item[field]) { + return true; + } + } + return false; +} diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.js b/contentcuration/contentcuration/frontend/shared/utils/validation.js index 3c6676ece5..62ba3b1e45 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.js @@ -159,7 +159,7 @@ function _getErrorMsg(error) { [ValidationErrors.ACTIVITY_DURATION_TOO_LONG]: translator.$tr('activityDurationTooLongWarning'), }; - return messages[error]; + return messages[error] || error; } // Helpers @@ -223,7 +223,7 @@ export function getMasteryModelNValidators() { export function getShortActivityDurationValidators() { return [ v => v !== '' || ValidationErrors.ACTIVITY_DURATION_REQUIRED, - v => v >= 1 || ValidationErrors.ACTIVITY_DURATION_MIN_FOR_SHORT_ACTIVITY, + v => v >= 5 || ValidationErrors.ACTIVITY_DURATION_MIN_FOR_SHORT_ACTIVITY, v => v <= 30 || ValidationErrors.ACTIVITY_DURATION_MAX_FOR_SHORT_ACTIVITY, ]; } @@ -231,7 +231,7 @@ export function getShortActivityDurationValidators() { export function getLongActivityDurationValidators() { return [ v => v !== '' || ValidationErrors.ACTIVITY_DURATION_REQUIRED, - v => v > 30 || ValidationErrors.ACTIVITY_DURATION_MIN_FOR_LONG_ACTIVITY, + v => v > 40 || ValidationErrors.ACTIVITY_DURATION_MIN_FOR_LONG_ACTIVITY, v => v <= 120 || ValidationErrors.ACTIVITY_DURATION_MAX_FOR_LONG_ACTIVITY, ]; } @@ -244,6 +244,21 @@ export function getActivityDurationValidators() { ]; } +/** + * Get invalid text for a given value using a list of validators. + * @param {Array} validators + * @param {*} value Value to validate. + * @returns {String} Translated error message of the first validator that returns an error. + Empty string if value is valid. + */ +export function getInvalidText(validators, value) { + return ( + validators + .map(validator => translateValidator(validator)(value)) + .find(validation => validation !== true) || '' + ); +} + // Node validation // These functions return an array of error codes export function getNodeTitleErrors(node) { diff --git a/contentcuration/contentcuration/frontend/shared/views/AboutLicensesModal.vue b/contentcuration/contentcuration/frontend/shared/views/AboutLicensesModal.vue new file mode 100644 index 0000000000..2c11725383 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/AboutLicensesModal.vue @@ -0,0 +1,62 @@ + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/Divider.vue b/contentcuration/contentcuration/frontend/shared/views/Divider.vue new file mode 100644 index 0000000000..dc0fe1296e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/Divider.vue @@ -0,0 +1,30 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue b/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue index 9004167be9..64b9ac92d8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue +++ b/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue @@ -6,6 +6,7 @@ @@ -65,22 +59,24 @@ - 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/ActivityDuration.vue b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CompletionOptions/ActivityDuration.vue similarity index 66% rename from contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue rename to contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CompletionOptions/ActivityDuration.vue index 2afa529798..a3606057b5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CompletionOptions/ActivityDuration.vue @@ -1,15 +1,15 @@