From e25e1539e3a8095fad290e6508ac526172ff6d17 Mon Sep 17 00:00:00 2001 From: Surya Shukla Date: Fri, 8 Dec 2023 12:48:51 +0530 Subject: [PATCH 001/170] password validation implemented in frontend --- .../contentcuration/frontend/accounts/pages/Create.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index b211cd5634..c3da3bb36e 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -51,6 +51,7 @@ (this.form.password1 === value ? true : this.$tr('passwordMatchMessage'))]; }, + passwordValidationRules() { + return [value =>(this.form.password1.length >= 8 ? true : this.$tr('passwordValidationMessage'))]; + }, tosAndPolicyRules() { return [value => (value ? true : this.$tr('ToSRequiredMessage'))]; }, @@ -465,6 +469,7 @@ passwordLabel: 'Password', confirmPasswordLabel: 'Confirm password', passwordMatchMessage: "Passwords don't match", + passwordValidationMessage: "Password should be atleast 8 characters long", // Usage question usageLabel: 'How do you plan on using Kolibri Studio (check all that apply)', From 1319e1f3fad15d3be16b8d0fe543b2a28cb297fa Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 8 Dec 2023 10:57:41 -0500 Subject: [PATCH 002/170] Add edit title and description modal --- .../components/ContentNodeEditListItem.vue | 166 +++++++++++------- .../quickEdit/EditTitleDescriptionModal.vue | 94 ++++++++++ 2 files changed, 192 insertions(+), 68 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue index 3e57f48587..310a2af09c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue @@ -1,80 +1,99 @@ @@ -89,6 +108,7 @@ import Checkbox from 'shared/views/form/Checkbox'; import IconButton from 'shared/views/IconButton'; import DraggableItem from 'shared/views/draggable/DraggableItem'; + import EditTitleDescriptionModal from './quickEdit/EditTitleDescriptionModal.vue'; import { COPYING_FLAG } from 'shared/data/constants'; import { DragEffect, DropEffect, EffectAllowed } from 'shared/mixins/draggable/constants'; import { DraggableRegions } from 'frontend/channelEdit/constants'; @@ -102,6 +122,7 @@ ContentNodeContextMenu, Checkbox, IconButton, + EditTitleDescriptionModal, }, props: { nodeId: { @@ -137,6 +158,9 @@ data() { return { activated: false, + showQuickEdit: { + titleDescription: false, + } }; }, computed: { @@ -200,6 +224,11 @@ }); }, }, + methods: { + showTitleDescriptionModal() { + this.showQuickEdit.titleDescription = true; + }, + }, beforeDestroy() { // Unselect before removing if (this.selected) { @@ -217,6 +246,7 @@ creatingCopies: 'Copying...', copiedSnackbar: 'Copy operation complete', undo: 'Undo', + editTooltip: 'Edit Title & Description', /* eslint-enable kolibri/vue-no-unused-translations */ }, }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue new file mode 100644 index 0000000000..19024a8504 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue @@ -0,0 +1,94 @@ + + + + + + + \ No newline at end of file From 780174c6f4ba44554a2c3e628aa5357d2ea2b5b9 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 8 Dec 2023 15:59:02 -0500 Subject: [PATCH 003/170] Refactor and maxlength --- .../quickEdit/EditTitleDescriptionModal.vue | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue index 19024a8504..202622e7e1 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue @@ -11,22 +11,22 @@ @cancel="close" > @@ -35,7 +35,7 @@ - - - \ No newline at end of file From d34108e4b1823ea7e18f78efea72d375629518c4 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 8 Dec 2023 16:18:01 -0500 Subject: [PATCH 004/170] Show snackbar on edit --- .../components/quickEdit/EditTitleDescriptionModal.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue index 202622e7e1..8d759d1059 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue @@ -77,6 +77,7 @@ description: description.trim(), }); + this.$store.dispatch('showSnackbarSimple', this.$tr('editedTitleDescription')); this.close(); }, }, @@ -87,6 +88,7 @@ saveAction: 'Save', cancelAction: 'Cancel', fieldRequired: 'Field is required', + editedTitleDescription: 'Edited title and description', }, } From 82ce6bc858585832e96f4a44c53f59d1e5ab3844 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 8 Dec 2023 16:21:33 -0500 Subject: [PATCH 005/170] Linting files --- .../components/ContentNodeEditListItem.vue | 14 +++++++------- .../quickEdit/EditTitleDescriptionModal.vue | 14 ++++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue index 310a2af09c..b44fd3cb67 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue @@ -105,10 +105,10 @@ import ContentNodeListItem from './ContentNodeListItem'; import ContentNodeOptions from './ContentNodeOptions'; import ContentNodeContextMenu from './ContentNodeContextMenu'; + import EditTitleDescriptionModal from './quickEdit/EditTitleDescriptionModal.vue'; import Checkbox from 'shared/views/form/Checkbox'; import IconButton from 'shared/views/IconButton'; import DraggableItem from 'shared/views/draggable/DraggableItem'; - import EditTitleDescriptionModal from './quickEdit/EditTitleDescriptionModal.vue'; import { COPYING_FLAG } from 'shared/data/constants'; import { DragEffect, DropEffect, EffectAllowed } from 'shared/mixins/draggable/constants'; import { DraggableRegions } from 'frontend/channelEdit/constants'; @@ -160,7 +160,7 @@ activated: false, showQuickEdit: { titleDescription: false, - } + }, }; }, computed: { @@ -224,17 +224,17 @@ }); }, }, - methods: { - showTitleDescriptionModal() { - this.showQuickEdit.titleDescription = true; - }, - }, beforeDestroy() { // Unselect before removing if (this.selected) { this.selected = false; } }, + methods: { + showTitleDescriptionModal() { + this.showQuickEdit.titleDescription = true; + }, + }, $trs: { optionsTooltip: 'Options', /* eslint-disable kolibri/vue-no-unused-translations */ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue index 8d759d1059..2540807576 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue @@ -1,4 +1,5 @@ @@ -38,6 +40,7 @@ import { mapActions } from 'vuex'; export default { + name: 'EditTitleDescriptionModal', props: { node: { type: Object, @@ -52,9 +55,7 @@ }; }, methods: { - ...mapActions('contentNode', [ - 'updateContentNode', - ]), + ...mapActions('contentNode', ['updateContentNode']), validateTitle() { if (this.title.trim().length === 0) { this.titleError = this.$tr('fieldRequired'); @@ -90,5 +91,6 @@ fieldRequired: 'Field is required', editedTitleDescription: 'Edited title and description', }, - } + }; + From dfb8c44ea22c75606fb1a87b8202c9db3a9eef04 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 11 Dec 2023 05:43:37 -0500 Subject: [PATCH 006/170] Add tests to EditTitleDescriptionModal component --- .../quickEdit/EditTitleDescriptionModal.vue | 3 + .../EditTitleDescriptionModal.spec.js | 175 ++++++++++++++++++ package.json | 6 +- 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/__tests__/EditTitleDescriptionModal.spec.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue index 2540807576..18972d6b22 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue @@ -8,11 +8,13 @@ :title="$tr('editTitleDescription')" :submitText="$tr('saveAction')" :cancelText="$tr('cancelAction')" + data-test="edit-title-description-modal" @submit="handleSave" @cancel="close" > { + beforeEach(() => { + contentNodeActions = { + updateContentNode: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = mount(EditTitleDescriptionModal, { + propsData: { + node, + }, + }); + expect(wrapper.isVueInstance()).toBe(true); + }); + + test('should display the correct title and description on first render', () => { + const wrapper = mount(EditTitleDescriptionModal, { + propsData: { + node, + }, + }); + + 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: { + node, + }, + }); + + 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: { + node, + }, + }); + + 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: node.id, + title: newTitle, + description: newDescription, + }); + }); + + test('should let update even if description is empty', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + node, + }, + }); + + 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: node.id, + title: newTitle, + description: '', + }); + }); + + test('should validate title on blur', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + node, + }, + }); + + 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: { + node, + }, + }); + + 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: { + node, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited title and description' + ); + }); + + test("should emit 'close' event on success submit", () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + node, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + expect(wrapper.emitted().close).toBeTruthy(); + }); + + test('should emit close event on cancel', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + node, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('cancel'); + expect(wrapper.emitted().close).toBeTruthy(); + }); +}); diff --git a/package.json b/package.json index 2c7ebd30dc..44d777db25 100644 --- a/package.json +++ b/package.json @@ -129,5 +129,9 @@ "browserslist": [ "> 1%", "Firefox ESR" - ] + ], + "volta": { + "node": "16.20.2", + "yarn": "1.22.21" + } } From d2a7c315d05b6c516a41fe770bbfed23e435c0c4 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 11 Dec 2023 05:47:00 -0500 Subject: [PATCH 007/170] Restore package.json --- package.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/package.json b/package.json index 44d777db25..2c7ebd30dc 100644 --- a/package.json +++ b/package.json @@ -129,9 +129,5 @@ "browserslist": [ "> 1%", "Firefox ESR" - ], - "volta": { - "node": "16.20.2", - "yarn": "1.22.21" - } + ] } From 88d3527756ef6605f0d483104b2e713b7092a739 Mon Sep 17 00:00:00 2001 From: Surya Shukla Date: Wed, 13 Dec 2023 22:30:41 +0530 Subject: [PATCH 008/170] Update contentcuration/contentcuration/frontend/accounts/pages/Create.vue Co-authored-by: Blaine Jester --- .../contentcuration/frontend/accounts/pages/Create.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index c3da3bb36e..28bfc67444 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -469,7 +469,7 @@ passwordLabel: 'Password', confirmPasswordLabel: 'Confirm password', passwordMatchMessage: "Passwords don't match", - passwordValidationMessage: "Password should be atleast 8 characters long", + passwordValidationMessage: "Password must be at least 8 characters long", // Usage question usageLabel: 'How do you plan on using Kolibri Studio (check all that apply)', From 3c2670aa4ce2ff13b4771324a0b9505367c4042c Mon Sep 17 00:00:00 2001 From: Surya Shukla Date: Thu, 14 Dec 2023 09:53:42 +0530 Subject: [PATCH 009/170] changes suggested by maintainers --- .../contentcuration/frontend/accounts/pages/Create.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index c3da3bb36e..0d8176a995 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -243,7 +243,7 @@ return [value => (this.form.password1 === value ? true : this.$tr('passwordMatchMessage'))]; }, passwordValidationRules() { - return [value =>(this.form.password1.length >= 8 ? true : this.$tr('passwordValidationMessage'))]; + return [value =>(value.length >= 8 ? true : this.$tr('passwordValidationMessage'))]; }, tosAndPolicyRules() { return [value => (value ? true : this.$tr('ToSRequiredMessage'))]; From e76340ea8f4f96807178b50b253e3d3937814c2b Mon Sep 17 00:00:00 2001 From: Surya Shukla Date: Thu, 14 Dec 2023 09:55:03 +0530 Subject: [PATCH 010/170] changes suggested by maintainers --- .../contentcuration/frontend/accounts/pages/Create.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index 0d8176a995..5ac32a1f56 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -469,7 +469,7 @@ passwordLabel: 'Password', confirmPasswordLabel: 'Confirm password', passwordMatchMessage: "Passwords don't match", - passwordValidationMessage: "Password should be atleast 8 characters long", + passwordValidationMessage: "Password should be at least 8 characters long", // Usage question usageLabel: 'How do you plan on using Kolibri Studio (check all that apply)', From 87ad48f1d3acd551e8cd483e883bc054e883a1e8 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Thu, 14 Dec 2023 07:29:45 -0500 Subject: [PATCH 011/170] Refactor modal validations --- .../quickEdit/EditTitleDescriptionModal.vue | 11 ++++------- .../frontend/shared/utils/validation.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue index 18972d6b22..5a67c7a883 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue @@ -41,6 +41,7 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue similarity index 100% rename from contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/EditTitleDescriptionModal.vue rename to contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/__tests__/EditTitleDescriptionModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js similarity index 100% rename from contentcuration/contentcuration/frontend/channelEdit/components/quickEdit/__tests__/EditTitleDescriptionModal.spec.js rename to contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js 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..ebbfc63ff4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -0,0 +1,60 @@ + + + + + + + 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/views/CurrentTopicView.vue b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue index 36dfd4860f..a53b26e85c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue @@ -223,12 +223,6 @@ - - @@ -241,7 +235,6 @@ import ContentNodeOptions from '../components/ContentNodeOptions'; import ResourceDrawer from '../components/ResourceDrawer'; import { RouteNames, viewModes, DraggableRegions, DraggableUniverses } from '../constants'; - import EditTitleDescriptionModal from '../components/quickEdit/EditTitleDescriptionModal'; import NodePanel from './NodePanel'; import IconButton from 'shared/views/IconButton'; import ToolBar from 'shared/views/ToolBar'; @@ -271,7 +264,6 @@ Checkbox, MoveModal, DraggableRegion, - EditTitleDescriptionModal, }, mixins: [titleMixin, routerMixin], props: { @@ -290,7 +282,6 @@ loadingAncestors: false, elevated: false, moveModalOpen: false, - editTitleDescriptionModal: null, }; }, computed: { diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue index a5e1fe843e..47ba379354 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue @@ -223,6 +223,7 @@ :channel="currentChannel" @syncing="syncInProgress" /> + Date: Tue, 19 Dec 2023 08:44:34 -0500 Subject: [PATCH 018/170] Lint files --- .../frontend/channelEdit/components/ContentNodeOptions.vue | 2 -- 1 file changed, 2 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue index 73654cacb6..65ac14fb7e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue @@ -348,8 +348,6 @@ $trs: { newSubtopic: 'New folder', editTitleDescription: 'Edit title and description', - editTopicDetails: 'Edit folder details', - editDetails: 'Edit details', editAllDetails: 'Edit all details', editTags: 'Edit tags', editLanguage: 'Edit language', From 752e572b8bb3f0011b6a39daced09c593163d007 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 2 Jan 2024 12:49:17 -0500 Subject: [PATCH 019/170] Refactor content node options --- .../components/ContentNodeOptions.vue | 248 ++++++++++-------- 1 file changed, 144 insertions(+), 104 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue index 65ac14fb7e..25d9bf880f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue @@ -4,92 +4,35 @@ style="max-height: 80vh" > - - {{ $tr('viewDetails') }} - - - {{ $tr('editTitleDescription') }} - - - - - - - - - {{ $tr('editAllDetails') }} - - - - {{ $tr('move') }} - + + + {{ option.label }} + + + - - - {{ $tr('copyToClipboard') }} - - - {{ $tr('makeACopy') }} - - - {{ $tr('newSubtopic') }} - - - {{ $tr('remove') }} - - - - - - - - {{ $tr('editTags') }} - - - {{ $tr('editLanguage') }} - - - - - - - - {{ $tr('editCategories') }} - - - {{ $tr('editLevels') }} - - - {{ $tr('editLearningActivities') }} - - - - - - - - {{ $tr('editSource') }} - - - {{ $tr('editAudience') }} - - - - - - - - {{ $tr('editCompletion') }} - - - {{ $tr('editWhatIsNeeded') }} - + + + @@ -136,6 +79,121 @@ isTopic() { return this.node.kind === 'topic'; }, + /** + * Returns a list of options to display in the menu + * @returns {Array>} List of lists, where each inner list is a group of options + * already filtered by the render condition + */ + groupedOptions() { + const options = [ + [ + { + label: this.$tr('viewDetails'), + to: this.viewLink, + onClick: () => this.trackAction('View'), + condition: !this.hideDetailsLink, + }, + { + label: this.$tr('editTitleDescription'), + onClick: this.editTitleDescription, + condition: this.canEdit, + }, + ], + [ + { + label: this.$tr('editAllDetails'), + to: this.editLink, + onClick: () => this.trackAction('Edit'), + condition: this.canEdit && !this.hideEditLink, + }, + { + label: this.$tr('move'), + onClick: $event => { + $event.stopPropagation(); + this.moveModalOpen = true; + }, + condition: this.canEdit, + }, + { + label: this.$tr('copyToClipboard'), + onClick: this.copyToClipboard, + condition: true, + }, + { + label: this.$tr('makeACopy'), + onClick: this.duplicateNode, + condition: this.canEdit, + }, + { + label: this.$tr('newSubtopic'), + onClick: this.newTopicNode, + condition: this.canEdit && this.isTopic, + }, + { + label: this.$tr('remove'), + onClick: this.removeNode, + condition: this.canEdit, + }, + ], + [ + { + label: this.$tr('editTags'), + onClick: this.editTags, + condition: this.canEdit, + }, + { + label: this.$tr('editLanguage'), + onClick: this.editLanguage, + condition: this.canEdit, + }, + ], + [ + { + label: this.$tr('editCategories'), + onClick: this.editCategories, + condition: this.canEdit, + }, + { + label: this.$tr('editLevels'), + onClick: this.editLevels, + condition: this.canEdit, + }, + { + label: this.$tr('editLearningActivities'), + onClick: this.editLearningActivities, + condition: this.canEdit, + }, + ], + [ + { + label: this.$tr('editSource'), + onClick: this.editSource, + condition: this.canEdit, + }, + { + label: this.$tr('editAudience'), + onClick: this.editAudience, + condition: this.canEdit, + }, + ], + [ + { + label: this.$tr('editCompletion'), + onClick: this.editCompletion, + condition: this.canEdit, + }, + { + label: this.$tr('editWhatIsNeeded'), + onClick: this.editWhatIsNeeded, + condition: this.canEdit, + }, + ], + ]; + + return options + .filter(group => group.some(option => option.condition)) + .map(group => group.filter(option => option.condition)); + }, editLink() { return { name: RouteNames.CONTENTNODE_DETAILS, @@ -154,11 +212,6 @@ }, }; }, - dividerStyle() { - return { - borderTop: `solid 1px ${this.$themeTokens.fineLine}`, - }; - }, }, watch: { moveModalOpen(open) { @@ -374,20 +427,7 @@ From f6f45ab9048c4a3d15cb696318103a020461bb14 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 2 Jan 2024 14:55:10 -0500 Subject: [PATCH 020/170] Add editLanguage quick editmodal --- .../QuickEditModal/EditLanguageModal.vue | 80 +++++++++++++++++++ .../components/QuickEditModal/index.vue | 12 ++- .../channelEdit/views/CurrentTopicView.vue | 23 +++++- 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue 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..d5d097a7ed --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -0,0 +1,80 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue index ebbfc63ff4..9fbd28c7d2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -6,6 +6,11 @@ :nodeId="nodeIds[0]" @close="close" /> + @@ -15,11 +20,13 @@ import { mapGetters, mapMutations } from 'vuex'; import { QuickEditModals } from '../../constants'; - import EditTitleDescriptionModal from './EditTitleDescriptionModal.vue'; + import EditLanguageModal from './EditLanguageModal'; + import EditTitleDescriptionModal from './EditTitleDescriptionModal'; export default { name: 'QuickEditModal', components: { + EditLanguageModal, EditTitleDescriptionModal, }, computed: { @@ -41,6 +48,9 @@ isTitleDescriptionOpen() { return this.openedModal === QuickEditModals.TITLE_DESCRIPTION; }, + isLanguageOpen() { + return this.openedModal === QuickEditModals.LANGUAGE; + }, }, methods: { ...mapMutations('contentNode', { diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue index a53b26e85c..d7973fc11c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue @@ -75,6 +75,13 @@ data-test="delete-selected-btn" @click="removeNodes(selected)" /> + @@ -229,12 +236,12 @@ + + \ 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 index f261583d9f..79e978f21a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue @@ -73,14 +73,14 @@ close() { this.$emit('close'); }, - handleSave() { + async handleSave() { this.validateTitle(); if (this.titleError) { return; } const { nodeId, title, description } = this; - this.updateContentNode({ + await this.updateContentNode({ id: nodeId, title: title.trim(), description: description.trim(), From ccc58a7dce1f54bb1757473c7b9b2ba5b001dfa8 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 5 Jan 2024 09:17:04 -0500 Subject: [PATCH 032/170] Add information messages for topics --- .../QuickEditModal/EditLanguageModal.vue | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue index 20e7266554..bd1751ac90 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -4,14 +4,17 @@ :title="$tr('editLanguage')" :submitText="$tr('saveAction')" :cancelText="$tr('cancelAction')" - data-test="edit-langugage" + data-test="edit-language-modal" :submitDisabled="!selectedLanguage" @submit="handleSave" @cancel="close" > -

+

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

+

+ {{ $tr('differentLanguages') }} +

-
+ +
node.kind === ContentKindsNames.TOPIC); }, - isMultipleNodesLanguages() { + isMultipleNodeLanguages() { const languages = new Set( this.nodes .map(node => node.language) @@ -82,6 +99,13 @@ )) )); }, + dividerStyle() { + return { + border: 0, + borderBottom: `1px solid ${this.$themeTokens.fineLine}`, + margin: '1em 0', + }; + }, }, created() { const languages = [...new Set( @@ -150,6 +174,8 @@ 'Edited language for {count, number, integer} {count, plural, one {resource} other {resources}}', selectLanguage: 'Select / Type Language', resourcesSelected: '{count, number, integer} {count, plural, one {resource} other {resources}} selected', + differentLanguages: 'The selected resources have different languages set. Choosing an option below will apply the language to all the selected resources', + updateDescendantsCheckbox: 'Apply to all resources and folders nested within the selected folders', }, }; From 6225491831a242fe36abceaeea1430b938c3a950 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 5 Jan 2024 09:23:01 -0500 Subject: [PATCH 033/170] Adding tests and linting files --- .../QuickEditModal/EditLanguageModal.vue | 37 ++- .../__tests__/EditLanguageModal.spec.js | 253 ++++++++++++++++++ .../EditTitleDescriptionModal.spec.js | 16 +- 3 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue index bd1751ac90..2eae0859dc 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -16,8 +16,8 @@ {{ $tr('differentLanguages') }}

node.kind === ContentKindsNames.TOPIC); }, isMultipleNodeLanguages() { - const languages = new Set( - this.nodes - .map(node => node.language) - .filter(Boolean) - ); + const languages = new Set(this.nodes.map(node => node.language).filter(Boolean)); return languages.size > 1; }, languageOptions() { @@ -93,11 +89,9 @@ return LanguagesList; } const criteria = ['id', 'native_name', 'readable_name']; - return LanguagesList.filter(lang => ( - criteria.some(key => ( - lang[key]?.toLowerCase().includes(searchQuery) - )) - )); + return LanguagesList.filter(lang => + criteria.some(key => lang[key]?.toLowerCase().includes(searchQuery)) + ); }, dividerStyle() { return { @@ -108,9 +102,7 @@ }, }, created() { - const languages = [...new Set( - this.nodes.map(node => node.language) - )]; + const languages = [...new Set(this.nodes.map(node => node.language))]; if (languages.length === 1) { this.selectedLanguage = languages[0] || ''; } @@ -124,7 +116,7 @@ selectedRadio?.scrollIntoView?.(); } }, - methods: { + methods: { ...mapActions('contentNode', ['updateContentNode']), languageText(langObject) { return this.$tr('languageItemText', { @@ -142,10 +134,8 @@ await Promise.all( this.nodes.map(node => { - if ( - this.updateDescendants && - node.kind === ContentKindsNames.TOPIC - ) { // will update with the new function to update all descendants + if (this.updateDescendants && node.kind === ContentKindsNames.TOPIC) { + // will update with the new function to update all descendants return this.updateContentNode({ id: node.id, language: this.selectedLanguage, @@ -173,9 +163,12 @@ editedLanguage: 'Edited language for {count, number, integer} {count, plural, one {resource} other {resources}}', selectLanguage: 'Select / Type Language', - resourcesSelected: '{count, number, integer} {count, plural, one {resource} other {resources}} selected', - differentLanguages: 'The selected resources have different languages set. Choosing an option below will apply the language to all the selected resources', - updateDescendantsCheckbox: 'Apply to all resources and folders nested within the selected folders', + resourcesSelected: + '{count, number, integer} {count, plural, one {resource} other {resources}} selected', + differentLanguages: + 'The selected resources have different languages set. Choosing an option below will apply the language to all the selected resources', + updateDescendantsCheckbox: + 'Apply to all resources and folders nested within the selected folders', }, }; 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..b6347b470c --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js @@ -0,0 +1,253 @@ +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('EditTitleDescriptionModal', () => { + beforeEach(() => { + contentNodeActions = { + updateContentNode: 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', async () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('input[value="en"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + setTimeout(() => { + // wait async + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-es-res', + language: 'en', + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-en-res', + language: 'en', + }); + }, 0); + }); + + 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'); + + setTimeout(() => { + // wait async + expect(wrapper.emitted('close')).toBeTruthy(); + }, 0); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('cancel'); + + setTimeout(() => { + // wait async + expect(wrapper.emitted('close')).toBeTruthy(); + }, 0); + }); + + 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'); + + setTimeout(() => { + // wait async + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); + }, 0); + }); + + 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'); + + setTimeout(() => { + // wait async + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-en-topic', + language: 'es', + }); + }); + }); + + test('should call updateContentNode 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'); + + setTimeout(() => { + // wait async + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-en-topic', + language: 'es', + }); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js index 083a020091..cb7764020c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js @@ -150,10 +150,13 @@ describe('EditTitleDescriptionModal', () => { }); wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); - expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( - expect.anything(), - 'Edited title and description' - ); + + setTimeout(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited title and description' + ); + }, 0); }); test("should emit 'close' event on success submit", () => { @@ -165,7 +168,10 @@ describe('EditTitleDescriptionModal', () => { }); wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); - expect(wrapper.emitted().close).toBeTruthy(); + + setTimeout(() => { + expect(wrapper.emitted().close).toBeTruthy(); + }, 0); }); test('should emit close event on cancel', () => { From fe468fa121e5a4c28a8a6d8103b1059a2be1321c Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 10 Jan 2024 08:53:31 -0500 Subject: [PATCH 034/170] Remove setTimeout and chaining operator --- .../QuickEditModal/EditLanguageModal.vue | 6 ++-- .../__tests__/EditLanguageModal.spec.js | 34 +++++++++---------- .../EditTitleDescriptionModal.spec.js | 10 +++--- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue index 2eae0859dc..f0bf9b858f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -90,7 +90,7 @@ } const criteria = ['id', 'native_name', 'readable_name']; return LanguagesList.filter(lang => - criteria.some(key => lang[key]?.toLowerCase().includes(searchQuery)) + criteria.some(key => lang[key] && lang[key].toLowerCase().includes(searchQuery)) ); }, dividerStyle() { @@ -113,7 +113,9 @@ const selectedRadio = this.$refs.languages.querySelector( `input[value="${this.selectedLanguage}"]` ); - selectedRadio?.scrollIntoView?.(); + if (selectedRadio && selectedRadio.scrollIntoView) { + selectedRadio.scrollIntoView(); + } } }, methods: { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js index b6347b470c..95d7143f2b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js @@ -152,14 +152,13 @@ describe('EditTitleDescriptionModal', () => { expect(submitButton.element.disabled).toBeFalsy(); }); - test('should call updateContentNode with the right language on success submit', async () => { + 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'); - setTimeout(() => { - // wait async + const animationFrameId = requestAnimationFrame(() => { expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { id: 'test-es-res', language: 'en', @@ -168,7 +167,8 @@ describe('EditTitleDescriptionModal', () => { id: 'test-en-res', language: 'en', }); - }, 0); + cancelAnimationFrame(animationFrameId); + }); }); test('should emit close event on success submit', () => { @@ -177,10 +177,10 @@ describe('EditTitleDescriptionModal', () => { wrapper.find('input[value="en"]').setChecked(true); wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); - setTimeout(() => { - // wait async + const animationFrameId = requestAnimationFrame(() => { expect(wrapper.emitted('close')).toBeTruthy(); - }, 0); + cancelAnimationFrame(animationFrameId); + }); }); test('should emit close event on cancel', () => { @@ -188,10 +188,10 @@ describe('EditTitleDescriptionModal', () => { wrapper.find('[data-test="edit-language-modal"]').vm.$emit('cancel'); - setTimeout(() => { - // wait async + const animationFrameId = requestAnimationFrame(() => { expect(wrapper.emitted('close')).toBeTruthy(); - }, 0); + cancelAnimationFrame(animationFrameId); + }); }); test('should show a confirmation snackbar on success submit', () => { @@ -200,10 +200,10 @@ describe('EditTitleDescriptionModal', () => { wrapper.find('input[value="en"]').setChecked(true); wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); - setTimeout(() => { - // wait async + const animationFrameId = requestAnimationFrame(() => { expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); - }, 0); + cancelAnimationFrame(animationFrameId); + }); }); describe('topic nodes present', () => { @@ -225,12 +225,12 @@ describe('EditTitleDescriptionModal', () => { wrapper.find('input[value="es"]').setChecked(true); wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); - setTimeout(() => { - // wait async + const animationFrameId = requestAnimationFrame(() => { expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { id: 'test-en-topic', language: 'es', }); + cancelAnimationFrame(animationFrameId); }); }); @@ -241,12 +241,12 @@ describe('EditTitleDescriptionModal', () => { wrapper.find('[data-test="update-descendants-checkbox"] input').setChecked(true); wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); - setTimeout(() => { - // wait async + const animationFrameId = requestAnimationFrame(() => { expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { id: 'test-en-topic', language: 'es', }); + cancelAnimationFrame(animationFrameId); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js index cb7764020c..2c20379601 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js @@ -151,12 +151,13 @@ describe('EditTitleDescriptionModal', () => { wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); - setTimeout(() => { + const animationFrameId = requestAnimationFrame(() => { expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( expect.anything(), 'Edited title and description' ); - }, 0); + cancelAnimationFrame(animationFrameId); + }); }); test("should emit 'close' event on success submit", () => { @@ -169,9 +170,10 @@ describe('EditTitleDescriptionModal', () => { wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); - setTimeout(() => { + const animationFrameId = requestAnimationFrame(() => { expect(wrapper.emitted().close).toBeTruthy(); - }, 0); + cancelAnimationFrame(animationFrameId); + }); }); test('should emit close event on cancel', () => { From eca5ec7b4efc553406fe7874abfc3259b8286d86 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Thu, 11 Jan 2024 11:56:57 -0500 Subject: [PATCH 035/170] Add backend tests --- .../contentcuration/tests/viewsets/base.py | 5 ++++ .../tests/viewsets/test_contentnode.py | 30 +++++++++++++++++++ .../contentcuration/viewsets/base.py | 1 + .../contentcuration/viewsets/contentnode.py | 4 +++ .../contentcuration/viewsets/sync/utils.py | 6 ++++ 5 files changed, 46 insertions(+) diff --git a/contentcuration/contentcuration/tests/viewsets/base.py b/contentcuration/contentcuration/tests/viewsets/base.py index f02ff3c4d1..5b17999d2b 100644 --- a/contentcuration/contentcuration/tests/viewsets/base.py +++ b/contentcuration/contentcuration/tests/viewsets/base.py @@ -13,6 +13,7 @@ from contentcuration.viewsets.sync.utils import generate_delete_event as base_generate_delete_event from contentcuration.viewsets.sync.utils import generate_deploy_event as base_generate_deploy_event from contentcuration.viewsets.sync.utils import generate_update_event as base_generate_update_event +from contentcuration.viewsets.sync.utils import generate_update_descendants_event as base_generate_update_descendants_event def generate_copy_event(*args, **kwargs): @@ -54,6 +55,10 @@ def generate_deploy_channel_event(channel_id, user_id): event["rev"] = random.randint(1, 10000000) return event +def generate_update_descendants_event(*args, **kwargs): + event = base_generate_update_descendants_event(*args, **kwargs) + event["rev"] = random.randint(1, 10000000) + return event class SyncTestMixin(object): celery_task_always_eager = None diff --git a/contentcuration/contentcuration/tests/viewsets/test_contentnode.py b/contentcuration/contentcuration/tests/viewsets/test_contentnode.py index 882b5644a9..2cf8459b45 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_contentnode.py +++ b/contentcuration/contentcuration/tests/viewsets/test_contentnode.py @@ -28,6 +28,7 @@ from contentcuration.tests.viewsets.base import generate_create_event from contentcuration.tests.viewsets.base import generate_delete_event from contentcuration.tests.viewsets.base import generate_update_event +from contentcuration.tests.viewsets.base import generate_update_descendants_event from contentcuration.tests.viewsets.base import SyncTestMixin from contentcuration.utils.db_tools import TreeBuilder from contentcuration.viewsets.contentnode import ContentNodeFilter @@ -573,6 +574,35 @@ def test_cannot_update_no_permissions(self): models.ContentNode.objects.get(id=contentnode.id).title, new_title ) + def test_update_descendants_contentnode(self): + root_node = testdata.tree(parent=self.channel.main_tree) + new_language = "es" + + response = self.sync_changes( + [generate_update_descendants_event(root_node.id, {"language": new_language}, channel_id=self.channel.id)], + ) + self.assertEqual(response.status_code, 200, response.content) + + descendants = root_node.get_descendants(include_self=True) + for descendant in descendants: + language = models.ContentNode.objects.get(id=descendant.id).language + language = str(language) + self.assertEqual(language, new_language) + + def test_cannot_update_descendants_when_updating_non_topic_node(self): + root_node = testdata.tree() + video_node = root_node.get_descendants().filter(kind_id=content_kinds.VIDEO).first() + new_language = "pt" + + response = self.sync_changes( + [generate_update_descendants_event(video_node.id, {"language": new_language}, channel_id=self.channel.id)], + ) + + self.assertEqual(len(response.data["errors"]), 1) + self.assertNotEqual( + models.ContentNode.objects.get(id=video_node.id).language, new_language + ) + def test_update_contentnode_exercise_mastery_model(self): metadata = self.contentnode_db_metadata metadata["kind_id"] = content_kinds.EXERCISE diff --git a/contentcuration/contentcuration/viewsets/base.py b/contentcuration/contentcuration/viewsets/base.py index bbc83c4d92..c59d3ca60c 100644 --- a/contentcuration/contentcuration/viewsets/base.py +++ b/contentcuration/contentcuration/viewsets/base.py @@ -892,6 +892,7 @@ def update_from_changes(self, changes): error[self.id_attr()], ) ): + print("NO SE PROP QUE ESTA AQUIIII DIOOOOOOSSSSS", error, datum) error = ValidationError("Not found").detail datum.update({"errors": error}) errors.append(datum) diff --git a/contentcuration/contentcuration/viewsets/contentnode.py b/contentcuration/contentcuration/viewsets/contentnode.py index 51feb0925c..635877c507 100644 --- a/contentcuration/contentcuration/viewsets/contentnode.py +++ b/contentcuration/contentcuration/viewsets/contentnode.py @@ -984,6 +984,10 @@ def perform_create(self, serializer, change=None): def update_descendants(self, pk, mods): root = ContentNode.objects.get(id=pk) + + if root.kind_id != content_kinds.TOPIC: + raise ValidationError("Only topics can have descendants to update") + descendants = root.get_descendants(include_self=True).values("id") changes = [{ "key": descendant["id"], "mods": mods } for descendant in descendants] diff --git a/contentcuration/contentcuration/viewsets/sync/utils.py b/contentcuration/contentcuration/viewsets/sync/utils.py index f86b9685d8..e47552735f 100644 --- a/contentcuration/contentcuration/viewsets/sync/utils.py +++ b/contentcuration/contentcuration/viewsets/sync/utils.py @@ -5,6 +5,7 @@ from contentcuration.utils.sentry import report_exception from contentcuration.viewsets.sync.constants import ALL_TABLES from contentcuration.viewsets.sync.constants import CHANNEL +from contentcuration.viewsets.sync.constants import CONTENTNODE from contentcuration.viewsets.sync.constants import COPIED from contentcuration.viewsets.sync.constants import CREATED from contentcuration.viewsets.sync.constants import DELETED @@ -12,6 +13,7 @@ from contentcuration.viewsets.sync.constants import MOVED from contentcuration.viewsets.sync.constants import PUBLISHED from contentcuration.viewsets.sync.constants import UPDATED +from contentcuration.viewsets.sync.constants import UPDATED_DESCENDANTS def validate_table(table): @@ -81,6 +83,10 @@ def generate_deploy_event(key, user_id): event = _generate_event(key, CHANNEL, DEPLOYED, channel_id=key, user_id=user_id) return event +def generate_update_descendants_event(key, mods, channel_id=None, user_id=None): + event = _generate_event(key, CONTENTNODE, UPDATED_DESCENDANTS, channel_id, user_id) + event["mods"] = mods + return event def log_sync_exception(e, user=None, change=None, changes=None): # Capture exception and report, but allow sync From ba6c2ebc68b7979eb6aad82d1c110c24b7ac5bf5 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Thu, 11 Jan 2024 12:24:26 -0500 Subject: [PATCH 036/170] Add functions documentations --- .../QuickEditModal/EditTitleDescriptionModal.vue | 5 +---- .../__tests__/EditTitleDescriptionModal.spec.js | 4 ++-- .../channelEdit/vuex/contentNode/actions.js | 5 +++++ .../contentcuration/frontend/shared/constants.js | 2 +- .../frontend/shared/data/changes.js | 4 ++++ .../frontend/shared/data/resources.js | 16 +++++++++++++++- contentcuration/contentcuration/viewsets/base.py | 1 - .../contentcuration/viewsets/contentnode.py | 1 + 8 files changed, 29 insertions(+), 9 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue index 2f3c2f0f8f..f261583d9f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue @@ -66,7 +66,7 @@ this.description = this.contentNode.description || ''; }, methods: { - ...mapActions('contentNode', ['updateContentNode', 'updateContentNodeDescendants']), + ...mapActions('contentNode', ['updateContentNode']), validateTitle() { this.titleError = getInvalidText(getTitleValidators(), this.title); }, @@ -83,9 +83,6 @@ this.updateContentNode({ id: nodeId, title: title.trim(), - }); - this.updateContentNodeDescendants({ - id: nodeId, description: description.trim(), }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js index d85d670454..083a020091 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js @@ -70,7 +70,7 @@ describe('EditTitleDescriptionModal', () => { expect(contentNodeActions.updateContentNode).toHaveBeenCalled(); }); - test.skip('should call updateContentNode with the correct parameters on success submit', () => { + test('should call updateContentNode with the correct parameters on success submit', () => { const wrapper = mount(EditTitleDescriptionModal, { store, propsData: { @@ -92,7 +92,7 @@ describe('EditTitleDescriptionModal', () => { }); }); - test.skip('should let update even if description is empty', () => { + test('should let update even if description is empty', () => { const wrapper = mount(EditTitleDescriptionModal, { store, propsData: { diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js index d2df4ca479..8f4aafab87 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js @@ -369,6 +369,11 @@ 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'); diff --git a/contentcuration/contentcuration/frontend/shared/constants.js b/contentcuration/contentcuration/frontend/shared/constants.js index 50a005d19e..424bf338c1 100644 --- a/contentcuration/contentcuration/frontend/shared/constants.js +++ b/contentcuration/contentcuration/frontend/shared/constants.js @@ -237,8 +237,8 @@ nonUniqueValue.toString = () => ''; * make sense to update to all the descendants, such as the title. */ export const DescendantsUpdatableFields = [ - 'description', // Just for testing pourposes, should be removed in the future 'language', 'categories', 'grade_levels', + 'learner_needs', ]; diff --git a/contentcuration/contentcuration/frontend/shared/data/changes.js b/contentcuration/contentcuration/frontend/shared/data/changes.js index f7b845b086..5ef3b16b99 100644 --- a/contentcuration/contentcuration/frontend/shared/data/changes.js +++ b/contentcuration/contentcuration/frontend/shared/data/changes.js @@ -459,6 +459,10 @@ export class DeployedChange extends Change { } } +/** + * 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; diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 20449c211e..77d41c83ac 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -269,6 +269,13 @@ 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([]); @@ -1709,7 +1716,8 @@ export const ContentNode = new TreeResource({ return this._saveAndQueueChange(change); }, /** - * Load descendants of a content node that are already in IndexedDB + * Load descendants of a content node that are already in IndexedDB. + * It also returns the node itself. * @param {string} id * @returns {Promise} * @@ -1729,6 +1737,12 @@ export const ContentNode = new TreeResource({ ); 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); diff --git a/contentcuration/contentcuration/viewsets/base.py b/contentcuration/contentcuration/viewsets/base.py index c59d3ca60c..bbc83c4d92 100644 --- a/contentcuration/contentcuration/viewsets/base.py +++ b/contentcuration/contentcuration/viewsets/base.py @@ -892,7 +892,6 @@ def update_from_changes(self, changes): error[self.id_attr()], ) ): - print("NO SE PROP QUE ESTA AQUIIII DIOOOOOOSSSSS", error, datum) error = ValidationError("Not found").detail datum.update({"errors": error}) errors.append(datum) diff --git a/contentcuration/contentcuration/viewsets/contentnode.py b/contentcuration/contentcuration/viewsets/contentnode.py index 635877c507..201a071252 100644 --- a/contentcuration/contentcuration/viewsets/contentnode.py +++ b/contentcuration/contentcuration/viewsets/contentnode.py @@ -983,6 +983,7 @@ def perform_create(self, serializer, change=None): ) def update_descendants(self, pk, mods): + """ Update a node and all of its descendants with the given mods """ root = ContentNode.objects.get(id=pk) if root.kind_id != content_kinds.TOPIC: From d32b48895f29909359a2e4cfe3c539f4226c8905 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Thu, 11 Jan 2024 14:38:23 -0500 Subject: [PATCH 037/170] Update method to updateDescendants in editLanguageModal --- .../components/QuickEditModal/EditLanguageModal.vue | 8 ++++---- .../QuickEditModal/__tests__/EditLanguageModal.spec.js | 5 +++-- .../contentcuration/frontend/shared/data/mergeChanges.js | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue index f0bf9b858f..c86f03bf51 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -24,9 +24,10 @@ /> @@ -21,12 +26,14 @@ import { mapGetters, mapMutations } from 'vuex'; import { QuickEditModals } from '../../constants'; import EditLanguageModal from './EditLanguageModal'; + import EditResourcesNeededModal from './EditResourcesNeededModal'; import EditTitleDescriptionModal from './EditTitleDescriptionModal'; export default { name: 'QuickEditModal', components: { EditLanguageModal, + EditResourcesNeededModal, EditTitleDescriptionModal, }, computed: { @@ -51,6 +58,9 @@ isLanguageOpen() { return this.openedModal === QuickEditModals.LANGUAGE; }, + isResourcesNeededOpen() { + return this.openedModal === QuickEditModals.WHAT_IS_NEEDED; + } }, methods: { ...mapMutations('contentNode', { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue index f3262cebed..e10ddb3913 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue @@ -24,19 +24,10 @@ + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/index.vue new file mode 100644 index 0000000000..374793efdf --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/index.vue @@ -0,0 +1,165 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue index 54891608f6..38b282ae0a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -16,6 +16,11 @@ :nodeIds="nodeIds" @close="close" /> +
@@ -26,6 +31,7 @@ import { mapGetters, mapMutations } from 'vuex'; import { QuickEditModals } from '../../constants'; import EditLanguageModal from './EditLanguageModal'; + import EditCategoriesModal from './EditCategoriesModal'; import EditResourcesNeededModal from './EditResourcesNeededModal'; import EditTitleDescriptionModal from './EditTitleDescriptionModal'; @@ -34,6 +40,7 @@ components: { EditLanguageModal, EditResourcesNeededModal, + EditCategoriesModal, EditTitleDescriptionModal, }, computed: { @@ -61,6 +68,9 @@ isResourcesNeededOpen() { return this.openedModal === QuickEditModals.WHAT_IS_NEEDED; }, + isCategoriesOpen() { + return this.openedModal === QuickEditModals.CATEGORIES; + }, }, methods: { ...mapMutations('contentNode', { diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/LanguageFilter.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/LanguageFilter.vue index f62e872887..9260e859ae 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/LanguageFilter.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/LanguageFilter.vue @@ -78,9 +78,59 @@ }, }, data() { + console.log("publicLanguages", publicLanguages); return { languageInput: '', - availableLanguages: publicLanguages, + availableLanguages: [ + { + "id": "en", + "name": "English", + "count": 1, + "related_names": [ + "Argh! Pirates!", + "en-PT", + "English, Pirate", + "British English", + "en-GB", + "English, Britain", + "English", + "en", + "English" + ] + }, + { + "id": "es", + "name": "Spanish", + "count": 1, + "related_names": [ + "Spanish", + ] + }, + { + "id": "es2", + "name": "Spanish2", + "count": 1, + "related_names": [ + "Spanish2", + ] + }, + { + "id": "es3", + "name": "Spanish3", + "count": 1, + "related_names": [ + "Spanish3", + ] + }, + { + "id": "es4", + "name": "Spanish4", + "count": 1, + "related_names": [ + "Spanish4", + ] + } + ], }; }, computed: { diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.js index edac6d2eb9..95ba057252 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.js @@ -3,6 +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'; function safeParseInt(str) { @@ -449,3 +453,78 @@ export function cleanBooleanMaps(contentNode) { } }); } + +/** + * 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; +} From 427ac2aa65a97ca207a9dacb7d83c619efe40a71 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Sun, 7 Jan 2024 00:58:42 -0500 Subject: [PATCH 049/170] Load nodes initial categories --- .../EditCategoriesModal/CategoriesFilter.vue | 88 ---------- .../EditCategoriesModal/index.vue | 158 +++++++++++++----- .../Channel/components/LanguageFilter.vue | 74 ++++---- .../frontend/shared/utils/helpers.js | 9 +- 4 files changed, 150 insertions(+), 179 deletions(-) delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/CategoriesFilter.vue diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/CategoriesFilter.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/CategoriesFilter.vue deleted file mode 100644 index d2d73f9903..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/CategoriesFilter.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/index.vue index 374793efdf..551b371348 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal/index.vue @@ -5,20 +5,26 @@ :submitText="$tr('saveAction')" :cancelText="$tr('cancelAction')" data-test="edit-language-modal" - :submitDisabled="!selectedLanguage" @submit="handleSave" @cancel="close" >

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

- - -
- +
@@ -48,27 +54,30 @@ + \ No newline at end of file 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..5b2a43f8ea --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js @@ -0,0 +1,656 @@ +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; +import camelCase from 'lodash/camelCase'; +import EditBooleanMapModal from '../EditBooleanMapModal'; +import { Categories } from 'shared/constants'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; + +let nodes; + +let store; +let contentNodeActions; +let generalActions; + +const CheckboxValue = { + UNCHECKED: 'UNCHECKED', + CHECKED: 'CHECKED', + INDETERMINATE: 'INDETERMINATE', +}; + +const categoriesLookup = {}; +Object.entries(Categories).forEach(([key, value]) => { + categoriesLookup[key] = 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 getOptionsChips = wrapper => { + const chips = wrapper.findAll('[data-test="option-chip"]'); + return chips.wrappers.map(chip => { + const [{ text } = {}] = chip.vm.$slots.default || []; + return categoriesLookup[text.trim()] || text.trim(); + }); +}; + +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, + }, + }); +}; + +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 options should be selected depending on the options set for a single node - learner_needs', () => { + nodes['node1'].learner_needs = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], field: 'learner_needs' }); + + 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); + }); + + describe('Showing hierarchy', () => { + test('just root categories should be selected depending on the categories set for a single node', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, // root categories + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: true }); + + 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('parent categories should be selected depending on the categories set for a single node when showing hierarchy', () => { + nodes['node1'].categories = { + [Categories.DIVERSITY]: true, // Daily Life -> Diversity + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: true }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.DIVERSITY]: diversityValue, + ...otheroptionsValues + } = optionsValues; + + expect( + Object.values(otheroptionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + expect(diversityValue).toBe(CheckboxValue.CHECKED); + }); + + test('parent categories should not be selected when not showing hierarchy', () => { + nodes['node1'].categories = { + [Categories.DIVERSITY]: true, // Daily Life -> Diversity + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: false }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DIVERSITY]: diversityValue, + ...otheroptionsValues + } = optionsValues; + + expect( + Object.values(otheroptionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + expect(diversityValue).toBe(CheckboxValue.CHECKED); + }); + + test('parent checkbox category should be indeterminate if not all nodes have the same parent categories set', () => { + nodes['node1'].categories = { + [Categories.DIVERSITY]: true, // Daily Life -> Diversity + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], showHierarchy: true }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.DIVERSITY]: diversityValue, + } = optionsValues; + expect(dailyLifeValue).toBe(CheckboxValue.INDETERMINATE); + expect(diversityValue).toBe(CheckboxValue.INDETERMINATE); + }); + + test('multiple parent checkbox categories should be indeterminate if not all nodes have the same parent categories set', () => { + nodes['node1'].categories = { + [Categories.DIVERSITY]: true, // Daily Life -> Diversity + }; + nodes['node2'].categories = { + [Categories.GUIDES]: true, // For teachers -> Guides + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], showHierarchy: true }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.FOR_TEACHERS]: forTeachersValue, + } = optionsValues; + expect(dailyLifeValue).toBe(CheckboxValue.INDETERMINATE); + expect(forTeachersValue).toBe(CheckboxValue.INDETERMINATE); + }); + + test('parent checkbox category should be checked if all nodes have the same parent categories set', () => { + nodes['node1'].categories = { + [Categories.DIVERSITY]: true, // Daily Life -> Diversity + }; + nodes['node2'].categories = { + [Categories.CURRENT_EVENTS]: true, // Daily Life -> Current Events + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], showHierarchy: true }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.DIVERSITY]: diversityValue, + [Categories.CURRENT_EVENTS]: currentEventsValue, + } = optionsValues; + expect(diversityValue).toBe(CheckboxValue.INDETERMINATE); + expect(currentEventsValue).toBe(CheckboxValue.INDETERMINATE); + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + }); + }); + }); + + describe('Showing autocomplete', () => { + test('no chip should be displayed if nodes does not have options set', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true }); + + const categoriesChips = getOptionsChips(wrapper); + expect(categoriesChips.length).toBe(0); + }); + + test('should render selected options chips if showing autocomplete - categories', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true }); + + const categoriesChips = getOptionsChips(wrapper); + expect(categoriesChips.length).toBe(2); + expect(categoriesChips).toContain(Categories.DAILY_LIFE); + expect(categoriesChips).toContain(Categories.FOUNDATIONS); + }); + + test('should render selected options chips if showing autocomplete - learner_needs', () => { + nodes['node1'].learner_needs = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], field: 'learner_needs', showAutocomplete: true }); + + const categoriesChips = getOptionsChips(wrapper); + expect(categoriesChips.length).toBe(2); + expect(categoriesChips).toContain(Categories.DAILY_LIFE); + expect(categoriesChips).toContain(Categories.FOUNDATIONS); + }); + + test('should not render selected options chips if not showing autocomplete', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: false }); + + const categoriesChips = getOptionsChips(wrapper); + expect(categoriesChips.length).toBe(0); + }); + + test('should not render parent category chips even though showing hierarchy', () => { + nodes['node1'].categories = { + [Categories.DIVERSITY]: true, // Daily Life -> Diversity + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true, showHierarchy: true }); + + const categoriesChips = getOptionsChips(wrapper); + expect(categoriesChips.length).toBe(1); + expect(categoriesChips).toContain(Categories.DIVERSITY); + }); + + test('should render "Mixed" chip if there are mixed options set', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], showAutocomplete: true }); + + const categoriesChips = getOptionsChips(wrapper); + expect(categoriesChips.length).toBe(1); + expect(categoriesChips).toContain('Mixed'); + }); + + test('should filter options based on autocomplete search query', () => { + const searchQuery = 'drama'; + + const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true }); + const animationFrameId = requestAnimationFrame(() => { + wrapper.find('[data-test="options-autocomplete"]').setValue(searchQuery); + + const categoriesOptions = wrapper.findAll('[data-test="option-checkbox"]'); + categoriesOptions.wrappers.forEach(checkbox => { + const { label } = checkbox.vm.$props || {}; + expect(label.toLowerCase()).toContain(searchQuery); + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should flatten options if autocomplete search query is not empty', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true }); + const animationFrameId = requestAnimationFrame(() => { + wrapper.find('[data-test="options-autocomplete"]').setValue('a'); + + const categoriesOptions = wrapper.findAll('[data-test="option-checkbox"]'); + categoriesOptions.wrappers.forEach(checkbox => { + expect(checkbox.element.style.paddingLeft).toBeFalsy(); + }); + cancelAnimationFrame(animationFrameId); + }); + }); + }); + }); + + 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'); + }); + + test('should display hierarchy of options using more padding on each child level if showing hierarchy', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: true }); + + const categoriesOptions = wrapper.findAll('[data-test="option-checkbox"]'); + let schoolPadding; + let socialSciencesPadding; // school -> social sciences + let anthropologyPadding; // school -> social sciences -> anthropology + categoriesOptions.wrappers.forEach(checkbox => { + const { label } = checkbox.vm.$props || {}; + const padding = checkbox.element.style.paddingLeft; + const paddingNumber = parseInt(padding.replace('px', '')); + if (label === 'SCHOOL') { + schoolPadding = paddingNumber; + } else if (label === 'SOCIAL_SCIENCES') { + socialSciencesPadding = paddingNumber; + } else if (label === 'ANTHROPOLOGY') { + anthropologyPadding = paddingNumber; + } + }); + + expect(schoolPadding).toBeLessThan(socialSciencesPadding); + expect(socialSciencesPadding).toBeLessThan(anthropologyPadding); + }); + + test('should not display hierarchy of options if not showing hierarchy', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: false }); + + const categoriesOptions = wrapper.findAll('[data-test="option-checkbox"]'); + let schoolPadding; + let socialSciencesPadding; // school -> social sciences + let anthropologyPadding; // school -> social sciences -> anthropology + categoriesOptions.wrappers.forEach(checkbox => { + const { label } = checkbox.vm.$props || {}; + const padding = checkbox.element.style.paddingLeft; + const paddingNumber = parseInt(padding.replace('px', '')); + if (label === 'school') { + schoolPadding = paddingNumber; + } else if (label === 'socialSciences') { + socialSciencesPadding = paddingNumber; + } else if (label === 'anthropology') { + anthropologyPadding = paddingNumber; + } + }); + + expect(schoolPadding).toBeFalsy(); + expect(socialSciencesPadding).toBeFalsy(); + expect(anthropologyPadding).toBeFalsy(); + }); + + 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 call updateContentNode with the right options on success submit - learner_needs', () => { + 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', + learner_needs: { + [Categories.SCHOOL]: true, + [Categories.SOCIOLOGY]: true, + }, + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node2', + learner_needs: { + [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__/EditCategoriesModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditCategoriesModal.spec.js deleted file mode 100644 index 20f830bb9c..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditCategoriesModal.spec.js +++ /dev/null @@ -1,497 +0,0 @@ -import Vuex from 'vuex'; -import { mount } from '@vue/test-utils'; -import camelCase from 'lodash/camelCase'; -import EditCategoriesModal from '../EditCategoriesModal'; -import { Categories } from 'shared/constants'; -import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; - -let nodes; - -let store; -let contentNodeActions; -let generalActions; - -const makeWrapper = nodeIds => { - return mount(EditCategoriesModal, { - store, - propsData: { - nodeIds, - }, - methods: { - translateMetadataString: value => { - return value; - }, - }, - }); -}; - -const CheckboxValue = { - UNCHECKED: 'UNCHECKED', - CHECKED: 'CHECKED', - INDETERMINATE: 'INDETERMINATE', -}; - -const categoriesLookup = {}; -Object.entries(Categories).forEach(([key, value]) => { - categoriesLookup[camelCase(key)] = value; -}); - -const getCategoriesValues = wrapper => { - const categories = {}; - const checkboxes = wrapper.findAll('[data-test="category-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 getCategoriesChips = wrapper => { - const chips = wrapper.findAll('[data-test="category-chip"]'); - return chips.wrappers.map(chip => { - const [{ text } = {}] = chip.vm.$slots.default || []; - return categoriesLookup[text.trim()] || text.trim(); - }); -}; - -const findCategoryCheckbox = (wrapper, category) => { - const checkboxes = wrapper.findAll('[data-test="category-checkbox"]'); - return checkboxes.wrappers.find(checkbox => { - const { label } = checkbox.vm.$props || {}; - return categoriesLookup[label] === category; - }); -}; - -describe('EditCategoriesModal', () => { - 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(['node1']); - expect(wrapper.isVueInstance()).toBe(true); - }); - - describe('Selected categories on first render', () => { - describe('Category checkboxes', () => { - test('no category should be selected if a single node does not have categories set', () => { - const wrapper = makeWrapper(['node1']); - - const categoriesValues = getCategoriesValues(wrapper); - expect( - Object.values(categoriesValues).every(value => value === CheckboxValue.UNCHECKED) - ).toBeTruthy(); - }); - - test('no category should be selected if multiple nodes dont have categories set', () => { - const wrapper = makeWrapper(['node1', 'node2']); - - const categoriesValues = getCategoriesValues(wrapper); - expect( - Object.values(categoriesValues).every(value => value === CheckboxValue.UNCHECKED) - ).toBeTruthy(); - }); - - test('just root categories should be selected depending on the categories set for a single node', () => { - nodes['node1'].categories = { - [Categories.DAILY_LIFE]: true, // root categories - [Categories.FOUNDATIONS]: true, - }; - - const wrapper = makeWrapper(['node1']); - - const categoriesValues = getCategoriesValues(wrapper); - const { - [Categories.DAILY_LIFE]: dailyLifeValue, - [Categories.FOUNDATIONS]: foundationsValue, - ...otherCategoriesValues - } = categoriesValues; - expect( - Object.values(otherCategoriesValues).every(value => value === CheckboxValue.UNCHECKED) - ).toBeTruthy(); - expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); - expect(foundationsValue).toBe(CheckboxValue.CHECKED); - }); - - test('parent categories should be selected depending on the categories set for a single node', () => { - nodes['node1'].categories = { - [Categories.DIVERSITY]: true, // Daily Life -> Diversity - }; - - const wrapper = makeWrapper(['node1']); - - const categoriesValues = getCategoriesValues(wrapper); - const { - [Categories.DAILY_LIFE]: dailyLifeValue, - [Categories.DIVERSITY]: diversityValue, - ...otherCategoriesValues - } = categoriesValues; - - expect( - Object.values(otherCategoriesValues).every(value => value === CheckboxValue.UNCHECKED) - ).toBeTruthy(); - expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); - expect(diversityValue).toBe(CheckboxValue.CHECKED); - }); - - test('checkbox category should be checked if all nodes have the same categories set', () => { - nodes['node1'].categories = { - [Categories.DAILY_LIFE]: true, - [Categories.FOUNDATIONS]: true, - }; - nodes['node2'].categories = { - [Categories.DAILY_LIFE]: true, - [Categories.FOUNDATIONS]: true, - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const categoriesValues = getCategoriesValues(wrapper); - const { - [Categories.DAILY_LIFE]: dailyLifeValue, - [Categories.FOUNDATIONS]: foundationsValue, - } = categoriesValues; - expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); - expect(foundationsValue).toBe(CheckboxValue.CHECKED); - }); - - test('checkbox category should be indeterminate if not all nodes have the same categories set', () => { - nodes['node1'].categories = { - [Categories.DAILY_LIFE]: true, - [Categories.FOUNDATIONS]: true, - }; - nodes['node2'].categories = { - [Categories.DAILY_LIFE]: true, - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const categoriesValues = getCategoriesValues(wrapper); - const { - [Categories.DAILY_LIFE]: dailyLifeValue, - [Categories.FOUNDATIONS]: foundationsValue, - } = categoriesValues; - expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); - expect(foundationsValue).toBe(CheckboxValue.INDETERMINATE); - }); - - test('parent checkbox category should be indeterminate if not all nodes have the same parent categories set', () => { - nodes['node1'].categories = { - [Categories.DIVERSITY]: true, // Daily Life -> Diversity - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const categoriesValues = getCategoriesValues(wrapper); - const { - [Categories.DAILY_LIFE]: dailyLifeValue, - [Categories.DIVERSITY]: diversityValue, - } = categoriesValues; - expect(dailyLifeValue).toBe(CheckboxValue.INDETERMINATE); - expect(diversityValue).toBe(CheckboxValue.INDETERMINATE); - }); - - test('multiple parent checkbox categories should be indeterminate if not all nodes have the same parent categories set', () => { - nodes['node1'].categories = { - [Categories.DIVERSITY]: true, // Daily Life -> Diversity - }; - nodes['node2'].categories = { - [Categories.GUIDES]: true, // For teachers -> Guides - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const categoriesValues = getCategoriesValues(wrapper); - const { - [Categories.DAILY_LIFE]: dailyLifeValue, - [Categories.FOR_TEACHERS]: forTeachersValue, - } = categoriesValues; - expect(dailyLifeValue).toBe(CheckboxValue.INDETERMINATE); - expect(forTeachersValue).toBe(CheckboxValue.INDETERMINATE); - }); - - test('parent checkbox category should be checked if all nodes have the same parent categories set', () => { - nodes['node1'].categories = { - [Categories.DIVERSITY]: true, // Daily Life -> Diversity - }; - nodes['node2'].categories = { - [Categories.CURRENT_EVENTS]: true, // Daily Life -> Current Events - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const categoriesValues = getCategoriesValues(wrapper); - const { - [Categories.DAILY_LIFE]: dailyLifeValue, - [Categories.DIVERSITY]: diversityValue, - [Categories.CURRENT_EVENTS]: currentEventsValue, - } = categoriesValues; - expect(diversityValue).toBe(CheckboxValue.INDETERMINATE); - expect(currentEventsValue).toBe(CheckboxValue.INDETERMINATE); - expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); - }); - }); - - describe('Autocomplete category chips', () => { - test('no chip should be displayed if nodes does not have categories set', () => { - const wrapper = makeWrapper(['node1']); - - const categoriesChips = getCategoriesChips(wrapper); - expect(categoriesChips.length).toBe(0); - }); - - test('should render selected category chips', () => { - nodes['node1'].categories = { - [Categories.DAILY_LIFE]: true, // root categories - [Categories.FOUNDATIONS]: true, - }; - - const wrapper = makeWrapper(['node1']); - - const categoriesChips = getCategoriesChips(wrapper); - expect(categoriesChips.length).toBe(2); - expect(categoriesChips).toContain(Categories.DAILY_LIFE); - expect(categoriesChips).toContain(Categories.FOUNDATIONS); - }); - - test('should not render parent category chips', () => { - nodes['node1'].categories = { - [Categories.DIVERSITY]: true, // Daily Life -> Diversity - }; - - const wrapper = makeWrapper(['node1']); - - const categoriesChips = getCategoriesChips(wrapper); - expect(categoriesChips.length).toBe(1); - expect(categoriesChips).toContain(Categories.DIVERSITY); - }); - - test('should render "Mixed" chip if there are mixed categories set', () => { - nodes['node1'].categories = { - [Categories.DAILY_LIFE]: true, - [Categories.FOUNDATIONS]: true, - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const categoriesChips = getCategoriesChips(wrapper); - expect(categoriesChips.length).toBe(1); - expect(categoriesChips).toContain('Mixed'); - }); - }); - }); - - 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 render the message of the number of resources selected - 2', () => { - const wrapper = makeWrapper(['node1', 'node2', 'node3', 'node4']); - - const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); - expect(resourcesCounter.exists()).toBeTruthy(); - expect(resourcesCounter.text()).toContain('4'); - }); - - test('should display hierarchy of categories using more padding on each child level', () => { - const wrapper = makeWrapper(['node1']); - - const categoriesOptions = wrapper.findAll('[data-test="category-checkbox"]'); - let schoolPadding; - let socialSciencesPadding; // school -> social sciences - let anthropologyPadding; // school -> social sciences -> anthropology - categoriesOptions.wrappers.forEach(checkbox => { - const { label } = checkbox.vm.$props || {}; - const padding = checkbox.element.style.paddingLeft; - const paddingNumber = parseInt(padding.replace('px', '')); - if (label === 'school') { - schoolPadding = paddingNumber; - } else if (label === 'socialSciences') { - socialSciencesPadding = paddingNumber; - } else if (label === 'anthropology') { - anthropologyPadding = paddingNumber; - } - }); - - expect(schoolPadding).toBeLessThan(socialSciencesPadding); - expect(socialSciencesPadding).toBeLessThan(anthropologyPadding); - }); - - test('should filter categories options based on autocomplete search query', () => { - const searchQuery = 'drama'; - - const wrapper = makeWrapper(['node1']); - const animationFrameId = requestAnimationFrame(() => { - wrapper.find('[data-test="category-autocomplete"]').setValue(searchQuery); - - const categoriesOptions = wrapper.findAll('[data-test="category-checkbox"]'); - categoriesOptions.wrappers.forEach(checkbox => { - const { label } = checkbox.vm.$props || {}; - expect(label.toLowerCase()).toContain(searchQuery); - }); - cancelAnimationFrame(animationFrameId); - }); - }); - - test('should flatten categories options if autocomplete search query is not empty', () => { - const wrapper = makeWrapper(['node1']); - const animationFrameId = requestAnimationFrame(() => { - wrapper.find('[data-test="category-autocomplete"]').setValue('a'); - - const categoriesOptions = wrapper.findAll('[data-test="category-checkbox"]'); - categoriesOptions.wrappers.forEach(checkbox => { - expect(checkbox.element.style.paddingLeft).toBeFalsy(); - }); - cancelAnimationFrame(animationFrameId); - }); - }); - - test('should call updateContentNode with the right categories on success submit', () => { - const wrapper = makeWrapper(['node1', 'node2']); - - const schoolCheckbox = findCategoryCheckbox(wrapper, Categories.SCHOOL); - schoolCheckbox.element.click(); - const sociologyCheckbox = findCategoryCheckbox(wrapper, Categories.SOCIOLOGY); - sociologyCheckbox.element.click(); - - const animationFrameId = requestAnimationFrame(() => { - wrapper.find('[data-test="edit-categories-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(['node1']); - - wrapper.find('[data-test="edit-categories-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-categories-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']); - - wrapper.find('[data-test="edit-categories-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', () => { - nodes['node1'].kind = ContentKindsNames.TOPIC; - - const wrapper = makeWrapper(['node1', 'node2']); - - 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(['node1', 'node2']); - - expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); - }); - - test('should call updateContentNode on success submit if the user does not check the checkbox', () => { - nodes['node1'].kind = ContentKindsNames.TOPIC; - - const wrapper = makeWrapper(['node1']); - - wrapper.find('[data-test="edit-categories-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 checkbox', () => { - nodes['node1'].kind = ContentKindsNames.TOPIC; - - const wrapper = makeWrapper(['node1']); - - wrapper.find('[data-test="update-descendants-checkbox"] input').setChecked(true); - wrapper.find('[data-test="edit-categories-modal"]').vm.$emit('submit'); - - const animationFrameId = requestAnimationFrame(() => { - expect(contentNodeActions.updateContentNodeDescendants).toHaveBeenCalledWith( - expect.anything(), - { - id: 'node1', - categories: {}, - } - ); - cancelAnimationFrame(animationFrameId); - }); - }); - }); -}); From 8082687cc7d356b27e3aeb9c9be2852aad2ef66d Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 12 Jan 2024 12:33:55 -0500 Subject: [PATCH 059/170] Refactor EditCategoriesModal --- .../QuickEditModal/EditBooleanMapModal.vue | 16 +- .../QuickEditModal/EditCategoriesModal.vue | 302 ++---------------- .../__tests__/EditBooleanMapModal.spec.js | 92 +++--- 3 files changed, 76 insertions(+), 334 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue index 6a51ee130c..879ff969fb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -142,7 +142,7 @@ confirmationMessage: { type: String, required: true, - } + }, }, data() { return { @@ -173,9 +173,7 @@ if (!this.searchQuery) { return this.options; } - return this.options.filter(option => - option.label.toLowerCase().includes(searchQuery) - ); + return this.options.filter(option => option.label.toLowerCase().includes(searchQuery)); }, flatList() { if (!this.showHierarchy) { @@ -300,10 +298,7 @@ }); }) ); - this.$store.dispatch( - 'showSnackbarSimple', - this.confirmationMessage || '' - ); + this.$store.dispatch('showSnackbarSimple', this.confirmationMessage || ''); this.close(); }, setOption(optionId, value) { @@ -314,7 +309,7 @@ }; } else { const newSelectedValues = { ...this.selectedValues }; - + if (this.showHierarchy) { // Remove all children values Object.keys(this.selectedValues).forEach(selectedValue => { @@ -360,7 +355,8 @@ const level = this.getLevel(optionId); for (let i = level - 1; i > 0; i--) { const parentOption = this.options.find( - option => this.isSubLevel(currentOption, option.value) && this.getLevel(option.value) === i + option => + this.isSubLevel(currentOption, option.value) && this.getLevel(option.value) === i ); if (parentOption) { text = `${parentOption.label} - ${text}`; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue index a0224b6cb7..c405869ecc 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue @@ -1,81 +1,22 @@ @@ -83,16 +24,15 @@ \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js index 5b2a43f8ea..aaeb12708b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js @@ -1,6 +1,5 @@ import Vuex from 'vuex'; import { mount } from '@vue/test-utils'; -import camelCase from 'lodash/camelCase'; import EditBooleanMapModal from '../EditBooleanMapModal'; import { Categories } from 'shared/constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -62,7 +61,7 @@ const options = Object.entries(Categories).map(([key, value]) => { value, }; }); -const makeWrapper = ({ nodeIds, field='categories', ...restOptions }) => { +const makeWrapper = ({ nodeIds, field = 'categories', ...restOptions }) => { return mount(EditBooleanMapModal, { store, propsData: { @@ -107,14 +106,14 @@ describe('EditBooleanMapModal', () => { }); test('smoke test', () => { - const wrapper = makeWrapper({ nodeIds: ['node1']}); + 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 wrapper = makeWrapper({ nodeIds: ['node1'] }); const optionsValues = getOptionsValues(wrapper); expect( @@ -137,7 +136,7 @@ describe('EditBooleanMapModal', () => { [Categories.FOUNDATIONS]: true, }; - const wrapper = makeWrapper({ nodeIds: ['node1']}); + const wrapper = makeWrapper({ nodeIds: ['node1'] }); const optionsValues = getOptionsValues(wrapper); const { @@ -220,9 +219,9 @@ describe('EditBooleanMapModal', () => { [Categories.DAILY_LIFE]: true, // root categories [Categories.FOUNDATIONS]: true, }; - + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: true }); - + const optionsValues = getOptionsValues(wrapper); const { [Categories.DAILY_LIFE]: dailyLifeValue, @@ -235,21 +234,21 @@ describe('EditBooleanMapModal', () => { expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); expect(foundationsValue).toBe(CheckboxValue.CHECKED); }); - + test('parent categories should be selected depending on the categories set for a single node when showing hierarchy', () => { nodes['node1'].categories = { [Categories.DIVERSITY]: true, // Daily Life -> Diversity }; - + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: true }); - + const optionsValues = getOptionsValues(wrapper); const { [Categories.DAILY_LIFE]: dailyLifeValue, [Categories.DIVERSITY]: diversityValue, ...otheroptionsValues } = optionsValues; - + expect( Object.values(otheroptionsValues).every(value => value === CheckboxValue.UNCHECKED) ).toBeTruthy(); @@ -261,15 +260,12 @@ describe('EditBooleanMapModal', () => { nodes['node1'].categories = { [Categories.DIVERSITY]: true, // Daily Life -> Diversity }; - + const wrapper = makeWrapper({ nodeIds: ['node1'], showHierarchy: false }); - + const optionsValues = getOptionsValues(wrapper); - const { - [Categories.DIVERSITY]: diversityValue, - ...otheroptionsValues - } = optionsValues; - + const { [Categories.DIVERSITY]: diversityValue, ...otheroptionsValues } = optionsValues; + expect( Object.values(otheroptionsValues).every(value => value === CheckboxValue.UNCHECKED) ).toBeTruthy(); @@ -280,9 +276,9 @@ describe('EditBooleanMapModal', () => { nodes['node1'].categories = { [Categories.DIVERSITY]: true, // Daily Life -> Diversity }; - + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], showHierarchy: true }); - + const optionsValues = getOptionsValues(wrapper); const { [Categories.DAILY_LIFE]: dailyLifeValue, @@ -291,7 +287,7 @@ describe('EditBooleanMapModal', () => { expect(dailyLifeValue).toBe(CheckboxValue.INDETERMINATE); expect(diversityValue).toBe(CheckboxValue.INDETERMINATE); }); - + test('multiple parent checkbox categories should be indeterminate if not all nodes have the same parent categories set', () => { nodes['node1'].categories = { [Categories.DIVERSITY]: true, // Daily Life -> Diversity @@ -299,9 +295,9 @@ describe('EditBooleanMapModal', () => { nodes['node2'].categories = { [Categories.GUIDES]: true, // For teachers -> Guides }; - + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], showHierarchy: true }); - + const optionsValues = getOptionsValues(wrapper); const { [Categories.DAILY_LIFE]: dailyLifeValue, @@ -310,7 +306,7 @@ describe('EditBooleanMapModal', () => { expect(dailyLifeValue).toBe(CheckboxValue.INDETERMINATE); expect(forTeachersValue).toBe(CheckboxValue.INDETERMINATE); }); - + test('parent checkbox category should be checked if all nodes have the same parent categories set', () => { nodes['node1'].categories = { [Categories.DIVERSITY]: true, // Daily Life -> Diversity @@ -318,9 +314,9 @@ describe('EditBooleanMapModal', () => { nodes['node2'].categories = { [Categories.CURRENT_EVENTS]: true, // Daily Life -> Current Events }; - + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], showHierarchy: true }); - + const optionsValues = getOptionsValues(wrapper); const { [Categories.DAILY_LIFE]: dailyLifeValue, @@ -362,7 +358,11 @@ describe('EditBooleanMapModal', () => { [Categories.FOUNDATIONS]: true, }; - const wrapper = makeWrapper({ nodeIds: ['node1'], field: 'learner_needs', showAutocomplete: true }); + const wrapper = makeWrapper({ + nodeIds: ['node1'], + field: 'learner_needs', + showAutocomplete: true, + }); const categoriesChips = getOptionsChips(wrapper); expect(categoriesChips.length).toBe(2); @@ -387,7 +387,11 @@ describe('EditBooleanMapModal', () => { [Categories.DIVERSITY]: true, // Daily Life -> Diversity }; - const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true, showHierarchy: true }); + const wrapper = makeWrapper({ + nodeIds: ['node1'], + showAutocomplete: true, + showHierarchy: true, + }); const categoriesChips = getOptionsChips(wrapper); expect(categoriesChips.length).toBe(1); @@ -409,11 +413,11 @@ describe('EditBooleanMapModal', () => { test('should filter options based on autocomplete search query', () => { const searchQuery = 'drama'; - + const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true }); const animationFrameId = requestAnimationFrame(() => { wrapper.find('[data-test="options-autocomplete"]').setValue(searchQuery); - + const categoriesOptions = wrapper.findAll('[data-test="option-checkbox"]'); categoriesOptions.wrappers.forEach(checkbox => { const { label } = checkbox.vm.$props || {}; @@ -422,12 +426,12 @@ describe('EditBooleanMapModal', () => { cancelAnimationFrame(animationFrameId); }); }); - + test('should flatten options if autocomplete search query is not empty', () => { const wrapper = makeWrapper({ nodeIds: ['node1'], showAutocomplete: true }); const animationFrameId = requestAnimationFrame(() => { wrapper.find('[data-test="options-autocomplete"]').setValue('a'); - + const categoriesOptions = wrapper.findAll('[data-test="option-checkbox"]'); categoriesOptions.wrappers.forEach(checkbox => { expect(checkbox.element.style.paddingLeft).toBeFalsy(); @@ -447,7 +451,7 @@ describe('EditBooleanMapModal', () => { }); test('should render the message of the number of resources selected - 2', () => { - const wrapper = makeWrapper({ nodeIds: ['node1', 'node2', 'node3', 'node4']}); + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2', 'node3', 'node4'] }); const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); expect(resourcesCounter.exists()).toBeTruthy(); @@ -506,12 +510,12 @@ describe('EditBooleanMapModal', () => { 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(), { @@ -534,12 +538,12 @@ describe('EditBooleanMapModal', () => { test('should call updateContentNode with the right options on success submit - learner_needs', () => { 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(), { @@ -561,10 +565,10 @@ describe('EditBooleanMapModal', () => { }); test('should emit close event on success submit', () => { - const wrapper = makeWrapper({ nodeIds: ['node1']}); - + 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); @@ -572,10 +576,10 @@ describe('EditBooleanMapModal', () => { }); test('should show a confirmation snackbar on success submit', () => { - const wrapper = makeWrapper({ nodeIds: ['node1']}); - + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); - + const animationFrameId = requestAnimationFrame(() => { expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); cancelAnimationFrame(animationFrameId); @@ -584,7 +588,7 @@ describe('EditBooleanMapModal', () => { }); test('should emit close event on cancel', () => { - const wrapper = makeWrapper({ nodeIds: ['node1']}); + const wrapper = makeWrapper({ nodeIds: ['node1'] }); wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('cancel'); From 02a96c4d8c42b7c47e8fc29c404c92f1329cc869 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 12 Jan 2024 12:50:39 -0500 Subject: [PATCH 060/170] Refactor Resources Needed Modal --- .../EditResourcesNeededModal.vue | 156 +--------- .../EditResourcesNeededModal.spec.js | 281 ------------------ 2 files changed, 14 insertions(+), 423 deletions(-) delete mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditResourcesNeededModal.spec.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue index 8658b66976..c281e7a111 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue @@ -1,54 +1,29 @@ + \ No newline at end of file + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditResourcesNeededModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditResourcesNeededModal.spec.js deleted file mode 100644 index 33d2a74d02..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditResourcesNeededModal.spec.js +++ /dev/null @@ -1,281 +0,0 @@ -import Vuex from 'vuex'; -import { mount } from '@vue/test-utils'; -import EditResourcesNeededModal from '../EditResourcesNeededModal'; -import { ResourcesNeededTypes } from 'shared/constants'; -import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; - -let nodes; - -let store; -let contentNodeActions; -let generalActions; - -const makeWrapper = nodeIds => { - return mount(EditResourcesNeededModal, { - store, - propsData: { - nodeIds, - }, - methods: { - translateMetadataString: value => { - return value; - }, - }, - }); -}; - -const CheckboxValue = { - UNCHECKED: 'UNCHECKED', - CHECKED: 'CHECKED', - INDETERMINATE: 'INDETERMINATE', -}; - -const resourcesLookup = {}; -Object.entries(ResourcesNeededTypes).forEach(([key, value]) => { - resourcesLookup[key] = value; -}); - -const getResourcesValues = wrapper => { - const resources = {}; - const checkboxes = wrapper.findAll('[data-test="resource-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; - } - resources[resourcesLookup[label]] = value; - }); - return resources; -}; - -const findResourceCheckbox = (wrapper, resource) => { - const checkboxes = wrapper.findAll('[data-test="resource-checkbox"]'); - return checkboxes.wrappers.find(checkbox => { - const { label } = checkbox.vm.$props || {}; - return resourcesLookup[label] === resource; - }); -}; - -describe('EditResourcesNeededModal', () => { - beforeEach(() => { - nodes = { - node1: { id: 'node1' }, - node2: { id: 'node2' }, - }; - 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(['node1']); - expect(wrapper.isVueInstance()).toBe(true); - }); - - describe('Selected resources on first render', () => { - test('no resource should be selected if a single node does not have needed resources set', () => { - const wrapper = makeWrapper(['node1']); - - const resourcesValues = getResourcesValues(wrapper); - expect( - Object.values(resourcesValues).every(value => value === CheckboxValue.UNCHECKED) - ).toBeTruthy(); - }); - - test('specific resources should be selected depending on the learner needs set for a single node', () => { - nodes['node1'].learner_needs = { - [ResourcesNeededTypes.INTERNET]: true, - [ResourcesNeededTypes.PEERS]: true, - }; - - const wrapper = makeWrapper(['node1']); - - const resourcesValues = getResourcesValues(wrapper); - const { - [ResourcesNeededTypes.INTERNET]: internetValue, - [ResourcesNeededTypes.PEERS]: peersValue, - ...otherResourcesValues - } = resourcesValues; - expect( - Object.values(otherResourcesValues).every(value => value === CheckboxValue.UNCHECKED) - ).toBeTruthy(); - expect(internetValue).toBe(CheckboxValue.CHECKED); - expect(peersValue).toBe(CheckboxValue.CHECKED); - }); - - test('checkbox resource should be checked if all nodes have the same resources needed set', () => { - nodes['node1'].learner_needs = { - [ResourcesNeededTypes.INTERNET]: true, - [ResourcesNeededTypes.PEERS]: true, - }; - nodes['node2'].learner_needs = { - [ResourcesNeededTypes.INTERNET]: true, - [ResourcesNeededTypes.PEERS]: true, - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const resourcesValues = getResourcesValues(wrapper); - const { - [ResourcesNeededTypes.INTERNET]: internetValue, - [ResourcesNeededTypes.PEERS]: peersValue, - } = resourcesValues; - expect(internetValue).toBe(CheckboxValue.CHECKED); - expect(peersValue).toBe(CheckboxValue.CHECKED); - }); - - test('checkbox resource should be indeterminate if not all nodes have the same learner needs set', () => { - nodes['node1'].learner_needs = { - [ResourcesNeededTypes.INTERNET]: true, - }; - - const wrapper = makeWrapper(['node1', 'node2']); - - const resourcesValues = getResourcesValues(wrapper); - const { [ResourcesNeededTypes.INTERNET]: internetValue } = resourcesValues; - expect(internetValue).toBe(CheckboxValue.INDETERMINATE); - }); - }); - - 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 resources on submit', () => { - const wrapper = makeWrapper(['node1', 'node2']); - - const peersCheckbox = findResourceCheckbox(wrapper, ResourcesNeededTypes.PEERS); - peersCheckbox.element.click(); - const internetCheckbox = findResourceCheckbox(wrapper, ResourcesNeededTypes.INTERNET); - internetCheckbox.element.click(); - - const animationFrameId = requestAnimationFrame(() => { - wrapper.find('[data-test="edit-resources-needed-modal"]').vm.$emit('submit'); - expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { - id: 'node1', - learner_needs: { - [ResourcesNeededTypes.PEERS]: true, - [ResourcesNeededTypes.INTERNET]: true, - }, - }); - expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { - id: 'node2', - learner_needs: { - [ResourcesNeededTypes.PEERS]: true, - [ResourcesNeededTypes.INTERNET]: true, - }, - }); - cancelAnimationFrame(animationFrameId); - }); - }); - - test('should emit close event on success submit', () => { - const wrapper = makeWrapper(['node1']); - - wrapper.find('[data-test="edit-resources-needed-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-resources-needed-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']); - - wrapper.find('[data-test="edit-resources-needed-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', () => { - nodes['node1'].kind = ContentKindsNames.TOPIC; - - const wrapper = makeWrapper(['node1', 'node2']); - - 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(['node1', 'node2']); - - expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); - }); - - test('should call updateContentNode on success submit if the user does not check the checkbox', () => { - nodes['node1'].kind = ContentKindsNames.TOPIC; - - const wrapper = makeWrapper(['node1']); - - wrapper.find('[data-test="edit-resources-needed-modal"]').vm.$emit('submit'); - - const animationFrameId = requestAnimationFrame(() => { - expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { - id: 'node1', - learner_needs: {}, - }); - cancelAnimationFrame(animationFrameId); - }); - }); - - test('should call updateContentNodeDescendants on success submit if the user checks the checkbox', () => { - nodes['node1'].kind = ContentKindsNames.TOPIC; - - const wrapper = makeWrapper(['node1']); - - wrapper.find('[data-test="update-descendants-checkbox"] input').setChecked(true); - wrapper.find('[data-test="edit-resources-needed-modal"]').vm.$emit('submit'); - - const animationFrameId = requestAnimationFrame(() => { - expect(contentNodeActions.updateContentNodeDescendants).toHaveBeenCalledWith( - expect.anything(), - { - id: 'node1', - learner_needs: {}, - } - ); - cancelAnimationFrame(animationFrameId); - }); - }); - }); -}); From 844f41668a158d0e796e75df13dff77d799f7b31 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 12 Jan 2024 13:24:37 -0500 Subject: [PATCH 061/170] Add edit levels modal --- .../QuickEditModal/EditLevelsModal.vue | 64 +++++++++++++++++++ .../components/QuickEditModal/index.vue | 12 +++- .../channelEdit/views/CurrentTopicView.vue | 15 +++++ .../frontend/shared/data/changes.js | 3 +- 4 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue 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..2be21f321b --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue @@ -0,0 +1,64 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue index 38b282ae0a..894270c3b4 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -21,6 +21,11 @@ :nodeIds="nodeIds" @close="close" /> +
@@ -30,6 +35,7 @@ import { mapGetters, mapMutations } from 'vuex'; import { QuickEditModals } from '../../constants'; + import EditLevelsModal from './EditLevelsModal'; import EditLanguageModal from './EditLanguageModal'; import EditCategoriesModal from './EditCategoriesModal'; import EditResourcesNeededModal from './EditResourcesNeededModal'; @@ -38,9 +44,10 @@ export default { name: 'QuickEditModal', components: { + EditLevelsModal, EditLanguageModal, - EditResourcesNeededModal, EditCategoriesModal, + EditResourcesNeededModal, EditTitleDescriptionModal, }, computed: { @@ -71,6 +78,9 @@ isCategoriesOpen() { return this.openedModal === QuickEditModals.CATEGORIES; }, + isLevelsOpen() { + return this.openedModal === QuickEditModals.LEVELS; + }, }, methods: { ...mapMutations('contentNode', { diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue index 43ec5bd0a9..40856c3ec8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue @@ -96,6 +96,13 @@ data-test="change-resources-neded-btn" @click="editResourcesNeeded(selected)" /> + @@ -532,6 +539,13 @@ nodeIds, }); }, + editLevels(nodeIds) { + this.trackClickEvent('Edit levels'); + this.openQuickEditModal({ + modal: QuickEditModals.LEVELS, + nodeIds, + }); + }, treeLink(params) { return { name: RouteNames.TREE_VIEW, @@ -751,6 +765,7 @@ importFromChannels: 'Import from channels', addButton: 'Add', editButton: 'Edit', + editLevelsButton: 'Edit levels', editLanguageButton: 'Edit language', editCategoriesButton: 'Edit categories', editWhatIsNeededButton: "Edit 'what is needed'", diff --git a/contentcuration/contentcuration/frontend/shared/data/changes.js b/contentcuration/contentcuration/frontend/shared/data/changes.js index c41d80f577..b734985bb0 100644 --- a/contentcuration/contentcuration/frontend/shared/data/changes.js +++ b/contentcuration/contentcuration/frontend/shared/data/changes.js @@ -21,7 +21,7 @@ import { COPYING_FLAG, TASK_ID, } from 'shared/data/constants'; -import { Categories, ResourcesNeededTypes, ResourcesNeededOptions } from 'shared/constants'; +import { Categories, ResourcesNeededTypes, ResourcesNeededOptions, ContentLevels } from 'shared/constants'; import { INDEXEDDB_RESOURCES } from 'shared/data/registry'; /** @@ -495,6 +495,7 @@ export class UpdatedDescendantsChange extends Change { 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]) { From 54a743a17cd0f58471bbbdb61527f2ffdedf0c01 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 12 Jan 2024 13:34:34 -0500 Subject: [PATCH 062/170] Add comments --- .../QuickEditModal/EditBooleanMapModal.vue | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue index 879ff969fb..fe27ce0985 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -119,10 +119,17 @@ type: Boolean, default: false, }, + /** + * If the options are hierarchical, this function should return true if the first value + * is a sublevel of the second value + */ isSubLevel: { type: Function, default: (value1, value2) => value1.startsWith(value2), }, + /** + * If the options are hierarchical, this function should return the level of the option + */ getLevel: { type: Function, default: value => value.split('.').length, @@ -234,29 +241,37 @@ this.$emit('close'); }, isCheckboxSelected(optionId) { + // If the value is truthy (true or an array of nodeIds) then + // it is selected just if it is true (not an array) if (this.selectedValues[optionId]) { return this.selectedValues[optionId] === true; } + // If we dont want to show a hierarchy, then we dont need to + // look further if (!this.showHierarchy) { return false; } + // If we are showing a hierarchy, then we need to check if any + // of the children options are selected or indeterminate const mapValues = Object.keys(this.selectedValues) .filter(selectedValue => this.isSubLevel(selectedValue, optionId)) .map(selectedValue => this.selectedValues[selectedValue]); if (mapValues.length === 0) { - return false; + return false; // No childen options } else if (mapValues.length === 1) { + // just one child option, the value is deterrmined by if it is selected return mapValues[0] === true; } - // Children values are selected + + // Here multiple children are selected or indeterminate if (mapValues.some(value => value === true)) { - // if some child value is selected for all nodes, then it is selected + // if some child value is selected for all nodes, then the parent option is selected return true; } - // Here all child values are mixed, we need to check if together - // they are all selected for the parent value + // Here all children options are mixed, we need to check if together + // the parent option is common for all nodes const nodeIds = new Set(); mapValues.forEach(valueNodeIds => { valueNodeIds.forEach(nodeId => nodeIds.add(nodeId)); From 3fe8cd95493aca76a154073aff40ae37431ba002 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 12 Jan 2024 13:35:32 -0500 Subject: [PATCH 063/170] Lint files --- .../components/ContentNodeListItem/index.vue | 2 +- .../QuickEditModal/EditBooleanMapModal.vue | 12 ++++++------ .../components/QuickEditModal/EditLevelsModal.vue | 6 +++--- .../contentcuration/frontend/shared/data/changes.js | 7 ++++++- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue index 545f67c2bf..ba6d555b85 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue @@ -321,7 +321,7 @@ return ids.map(i => this.translateMetadataString(camelCase(i))).join(', '); }, category(options) { - const ids = Object.keys(options).filter(k => options[k]); + 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/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue index fe27ce0985..5b931da714 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -254,18 +254,18 @@ // If we are showing a hierarchy, then we need to check if any // of the children options are selected or indeterminate - const mapValues = Object.keys(this.selectedValues) + const childrenOptionsValues = Object.keys(this.selectedValues) .filter(selectedValue => this.isSubLevel(selectedValue, optionId)) .map(selectedValue => this.selectedValues[selectedValue]); - if (mapValues.length === 0) { + if (childrenOptionsValues.length === 0) { return false; // No childen options - } else if (mapValues.length === 1) { + } else if (childrenOptionsValues.length === 1) { // just one child option, the value is deterrmined by if it is selected - return mapValues[0] === true; + return childrenOptionsValues[0] === true; } // Here multiple children are selected or indeterminate - if (mapValues.some(value => value === true)) { + if (childrenOptionsValues.some(value => value === true)) { // if some child value is selected for all nodes, then the parent option is selected return true; } @@ -273,7 +273,7 @@ // Here all children options are mixed, we need to check if together // the parent option is common for all nodes const nodeIds = new Set(); - mapValues.forEach(valueNodeIds => { + childrenOptionsValues.forEach(valueNodeIds => { valueNodeIds.forEach(nodeId => nodeIds.add(nodeId)); }); return nodeIds.size === this.nodeIds.length; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue index 2be21f321b..092b854a93 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue @@ -16,8 +16,8 @@ + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue index 894270c3b4..986614e94c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -26,6 +26,11 @@ :nodeIds="nodeIds" @close="close" /> + @@ -40,6 +45,7 @@ import EditCategoriesModal from './EditCategoriesModal'; import EditResourcesNeededModal from './EditResourcesNeededModal'; import EditTitleDescriptionModal from './EditTitleDescriptionModal'; + import EditLearningActivitiesModal from './EditLearningActivitiesModal.vue'; export default { name: 'QuickEditModal', @@ -49,6 +55,7 @@ EditCategoriesModal, EditResourcesNeededModal, EditTitleDescriptionModal, + EditLearningActivitiesModal, }, computed: { ...mapGetters('contentNode', ['getQuickEditModalOpen']), @@ -81,6 +88,9 @@ isLevelsOpen() { return this.openedModal === QuickEditModals.LEVELS; }, + isLearningActivitiesOpen() { + return this.openedModal === QuickEditModals.LEARNING_ACTIVITIES; + }, }, methods: { ...mapMutations('contentNode', { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index ddbe24f71f..8c3b4b90a4 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -830,7 +830,7 @@ } }, category(options) { - const ids = Object.keys(options || {}).filter(k => options[k]); + const ids = Object.keys(options || {}); const matches = Object.keys(Categories) .sort() .filter(k => ids.includes(Categories[k])); diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue index 40856c3ec8..842a94a36c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue @@ -103,6 +103,13 @@ data-test="change-levels-btn" @click="editLevels(selected)" /> + @@ -374,6 +381,12 @@ node() { return this.getContentNode(this.topicId); }, + selectedNodes() { + return this.getContentNodes(this.selected); + }, + isTopicSelected() { + return this.selectedNodes.some(node => node.kind === ContentKindsNames.TOPIC); + }, ancestors() { return this.getContentNodeAncestors(this.topicId, true).map(ancestor => { return { @@ -546,6 +559,13 @@ nodeIds, }); }, + editLearningActivities(nodeIds) { + this.trackClickEvent('Edit learning activities'); + this.openQuickEditModal({ + modal: QuickEditModals.LEARNING_ACTIVITIES, + nodeIds, + }); + }, treeLink(params) { return { name: RouteNames.TREE_VIEW, @@ -769,6 +789,7 @@ editLanguageButton: 'Edit language', editCategoriesButton: 'Edit categories', editWhatIsNeededButton: "Edit 'what is needed'", + editLearningActivitiesButton: 'Edit learning activities', optionsButton: 'Options', copyToClipboardButton: 'Copy to clipboard', [viewModes.DEFAULT]: 'Default view', diff --git a/contentcuration/contentcuration/frontend/shared/data/changes.js b/contentcuration/contentcuration/frontend/shared/data/changes.js index 79467814a8..7d5639b1d4 100644 --- a/contentcuration/contentcuration/frontend/shared/data/changes.js +++ b/contentcuration/contentcuration/frontend/shared/data/changes.js @@ -23,9 +23,9 @@ import { } from 'shared/data/constants'; import { Categories, + ContentLevels, ResourcesNeededTypes, ResourcesNeededOptions, - ContentLevels, } from 'shared/constants'; import { INDEXEDDB_RESOURCES } from 'shared/data/registry'; From ee6238b6a44717351ecf9f7da534255f40ad0ebf Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 24 Jan 2024 15:28:31 -0500 Subject: [PATCH 065/170] Refactor isSelected method --- .../QuickEditModal/EditBooleanMapModal.vue | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue index 8be639d8dd..cd0db25dcd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -261,20 +261,30 @@ return false; } - // If we are showing a hierarchy, then we need to check if any - // of the children options are selected or indeterminate - const childrenOptionsValues = Object.keys(this.selectedValues) + // If we are showing a hierarchy, then we need to check its children + return this.isCheckboxSelectedByChildren(optionId); + }, + /** + * Returns true if the given option should be selected thanks to its children. + * An option will be selected thanks to your children if: + * * One of the children is selected + * * It has several indeterminate children, but by joining all the contentNodes of the + * child options, together they constitute the same array of selected contentNodes. + */ + isCheckboxSelectedByChildren(optionId) { + const childrenOptions = Object.keys(this.selectedValues) .filter(selectedValue => this.isSubLevel(selectedValue, optionId)) .map(selectedValue => this.selectedValues[selectedValue]); - if (childrenOptionsValues.length === 0) { + + if (childrenOptions.length === 0) { return false; // No childen options - } else if (childrenOptionsValues.length === 1) { + } else if (childrenOptions.length === 1) { // just one child option, the value is deterrmined by if it is selected - return childrenOptionsValues[0] === true; + return childrenOptions[0] === true; } // Here multiple children are selected or indeterminate - if (childrenOptionsValues.some(value => value === true)) { + if (childrenOptions.some(value => value === true)) { // if some child value is selected for all nodes, then the parent option is selected return true; } @@ -282,7 +292,7 @@ // Here all children options are mixed, we need to check if together // the parent option is common for all nodes const nodeIds = new Set(); - childrenOptionsValues.forEach(valueNodeIds => { + childrenOptions.forEach(valueNodeIds => { valueNodeIds.forEach(nodeId => nodeIds.add(nodeId)); }); return nodeIds.size === this.nodeIds.length; From 6d6a32144644b982ce1b7720cac6b23451640734 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 14 Feb 2024 05:02:50 -0500 Subject: [PATCH 066/170] Update kds version --- .../channelEdit/components/QuickEditModal/EditLanguageModal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue index 891112eff2..514af77d5f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -42,7 +42,7 @@ v-for="language in languageOptions" :key="language.id" v-model="selectedLanguage" - :value="language.id" + :buttonValue="language.id" :label="languageText(language)" />

Date: Thu, 18 Jan 2024 08:27:11 -0500 Subject: [PATCH 067/170] Add quick edit modal to edit audience --- .../QuickEditModal/EditAudienceModal.vue | 167 ++++++++++++++++++ .../components/QuickEditModal/index.vue | 18 ++ 2 files changed, 185 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue 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..2ca4cd0fac --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue @@ -0,0 +1,167 @@ + + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue index 986614e94c..ad378f1834 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -31,6 +31,11 @@ :nodeIds="nodeIds" @close="close" /> + @@ -42,7 +47,11 @@ import { QuickEditModals } from '../../constants'; import EditLevelsModal from './EditLevelsModal'; import EditLanguageModal from './EditLanguageModal'; +<<<<<<< HEAD import EditCategoriesModal from './EditCategoriesModal'; +======= + import EditAudienceModal from './EditAudienceModal'; +>>>>>>> 5fa394fed (Add quick edit modal to edit audience) import EditResourcesNeededModal from './EditResourcesNeededModal'; import EditTitleDescriptionModal from './EditTitleDescriptionModal'; import EditLearningActivitiesModal from './EditLearningActivitiesModal.vue'; @@ -52,7 +61,11 @@ components: { EditLevelsModal, EditLanguageModal, +<<<<<<< HEAD EditCategoriesModal, +======= + EditAudienceModal, +>>>>>>> 5fa394fed (Add quick edit modal to edit audience) EditResourcesNeededModal, EditTitleDescriptionModal, EditLearningActivitiesModal, @@ -82,6 +95,7 @@ isResourcesNeededOpen() { return this.openedModal === QuickEditModals.WHAT_IS_NEEDED; }, +<<<<<<< HEAD isCategoriesOpen() { return this.openedModal === QuickEditModals.CATEGORIES; }, @@ -90,6 +104,10 @@ }, isLearningActivitiesOpen() { return this.openedModal === QuickEditModals.LEARNING_ACTIVITIES; +======= + isAudienceOpen() { + return this.openedModal === QuickEditModals.AUDIENCE; +>>>>>>> 5fa394fed (Add quick edit modal to edit audience) }, }, methods: { From 529faf63a5df2489ef506a4ac18c39edddcbbe14 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Thu, 18 Jan 2024 08:29:00 -0500 Subject: [PATCH 068/170] Add isTopic condition --- .../channelEdit/components/ContentNodeOptions.vue | 2 +- .../components/QuickEditModal/index.vue | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue index 22080147ef..f1c55a2176 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue @@ -175,7 +175,7 @@ { label: this.$tr('editAudience'), onClick: this.quickEditModalFactory(QuickEditModals.AUDIENCE), - condition: this.canEdit, + condition: this.canEdit && !this.isTopic, }, ], [ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue index ad378f1834..0276341cbd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -47,11 +47,8 @@ import { QuickEditModals } from '../../constants'; import EditLevelsModal from './EditLevelsModal'; import EditLanguageModal from './EditLanguageModal'; -<<<<<<< HEAD - import EditCategoriesModal from './EditCategoriesModal'; -======= import EditAudienceModal from './EditAudienceModal'; ->>>>>>> 5fa394fed (Add quick edit modal to edit audience) + import EditCategoriesModal from './EditCategoriesModal'; import EditResourcesNeededModal from './EditResourcesNeededModal'; import EditTitleDescriptionModal from './EditTitleDescriptionModal'; import EditLearningActivitiesModal from './EditLearningActivitiesModal.vue'; @@ -61,11 +58,8 @@ components: { EditLevelsModal, EditLanguageModal, -<<<<<<< HEAD - EditCategoriesModal, -======= EditAudienceModal, ->>>>>>> 5fa394fed (Add quick edit modal to edit audience) + EditCategoriesModal, EditResourcesNeededModal, EditTitleDescriptionModal, EditLearningActivitiesModal, @@ -95,7 +89,6 @@ isResourcesNeededOpen() { return this.openedModal === QuickEditModals.WHAT_IS_NEEDED; }, -<<<<<<< HEAD isCategoriesOpen() { return this.openedModal === QuickEditModals.CATEGORIES; }, @@ -104,10 +97,9 @@ }, isLearningActivitiesOpen() { return this.openedModal === QuickEditModals.LEARNING_ACTIVITIES; -======= + }, isAudienceOpen() { return this.openedModal === QuickEditModals.AUDIENCE; ->>>>>>> 5fa394fed (Add quick edit modal to edit audience) }, }, methods: { From 9dd3fabfaa3aa1dfb1262cf5f397a6c9e847c45d Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Thu, 18 Jan 2024 08:30:24 -0500 Subject: [PATCH 069/170] Lint files --- .../QuickEditModal/EditAudienceModal.vue | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue index 2ca4cd0fac..b1ad812c96 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue @@ -28,9 +28,9 @@ @@ -100,10 +100,12 @@ const languages = new Set(this.nodes.map(node => node.role_visibility).filter(Boolean)); if (languages.size > 1) { return true; - }; - const forBeginners = new Set(this.nodes.map(node => - Boolean(node.learner_needs && node.learner_needs[ResourcesNeededTypes.FOR_BEGINNERS]) - )); + } + const forBeginners = new Set( + this.nodes.map(node => + Boolean(node.learner_needs && node.learner_needs[ResourcesNeededTypes.FOR_BEGINNERS]) + ) + ); return forBeginners.size > 1; }, }, @@ -147,14 +149,16 @@ saveAction: 'Save', cancelAction: 'Cancel', editedAudience: - "Edited audience for {count, number, integer} {count, plural, one {resource} other {resources}}", + 'Edited audience for {count, number, integer} {count, plural, one {resource} other {resources}}', resourcesSelected: '{count, number, integer} {count, plural, one {resource} other {resources}} selected', forBeginnersCheckbox: 'For beginners', visibleTo: 'Visible to:', visibleToAnyone: 'Resources are visible to anyone', - visibleToCoaches: 'Resources are visible only to coaches (teachers, facilitators, administrators)', - multipleAudience: 'The selected resources have a mixed audience visbility. Choosing from the options below will apply the changes to all the selected resources' + visibleToCoaches: + 'Resources are visible only to coaches (teachers, facilitators, administrators)', + multipleAudience: + 'The selected resources have a mixed audience visbility. Choosing from the options below will apply the changes to all the selected resources', }, }; From 19e10807a946a2a528bcec37d5713507c5de3699 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 29 Jan 2024 06:12:56 -0500 Subject: [PATCH 070/170] Bulk edit audience --- .../channelEdit/views/CurrentTopicView.vue | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue index eb359a061a..05e8427e6f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue @@ -110,6 +110,13 @@ data-test="change-learning-activities-btn" @click="editLearningActivities(selected)" /> + @@ -569,6 +576,13 @@ nodeIds, }); }, + editAudience(nodeIds) { + this.trackClickEvent('Edit audience'); + this.openQuickEditModal({ + modal: QuickEditModals.AUDIENCE, + nodeIds, + }); + }, treeLink(params) { return { name: RouteNames.TREE_VIEW, @@ -806,11 +820,12 @@ importFromChannels: 'Import from channels', addButton: 'Add', editButton: 'Edit', - editLevelsButton: 'Edit levels', - editLanguageButton: 'Edit language', - editCategoriesButton: 'Edit categories', - editWhatIsNeededButton: "Edit 'what is needed'", - editLearningActivitiesButton: 'Edit learning activities', + 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', From 5a3d8a02be20a0eb8e196b6a0dc29ebc07d6b64d Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 29 Jan 2024 06:22:29 -0500 Subject: [PATCH 071/170] Add unit tests --- .../QuickEditModal/EditAudienceModal.vue | 6 +- .../__tests__/EditAudienceModal.spec.js | 257 ++++++++++++++++++ 2 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditAudienceModal.spec.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue index b1ad812c96..2a8ee3bb6c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue @@ -4,14 +4,14 @@ :title="$tr('editAudienceTitle')" :submitText="$tr('saveAction')" :cancelText="$tr('cancelAction')" - data-test="edit-resources-needed-modal" + data-test="edit-audience-modal" @submit="handleSave" @cancel="close" >

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

- diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue index 4ca0ca5f1f..b1cd0a67f7 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue @@ -128,6 +128,13 @@ /> + @@ -192,6 +199,8 @@ }, loading: true, listElevated: false, + more: null, + moreLoading: false, }; }, computed: { @@ -285,25 +294,30 @@ }, }, created() { - let childrenPromise; - // If viewing the root-level node, don't request anything, since the NodePanel.created - // hook will make a redundant request - if (this.nodeId === this.rootId) { - childrenPromise = Promise.resolve(); - } else { - childrenPromise = this.loadContentNodes({ parent: this.rootId }); - } - Promise.all([childrenPromise, this.loadAncestors({ id: this.nodeId })]).then(() => { - this.loading = false; - this.jumpToLocation(); - }); + const childrenPromise = this.loadChildren({ parent: this.rootId }); + Promise.all([childrenPromise, this.loadAncestors({ id: this.nodeId })]).then( + ([childrenResponse]) => { + this.loading = false; + this.more = childrenResponse.more || null; + this.jumpToLocation(); + } + ); }, methods: { ...mapMutations('contentNode', { collapseAll: 'COLLAPSE_ALL_EXPANDED', setExpanded: 'SET_EXPANSION', }), - ...mapActions('contentNode', ['loadAncestors', 'loadContentNodes']), + ...mapActions('contentNode', ['loadAncestors', 'loadChildren', 'loadContentNodes']), + loadMore() { + if (this.more && !this.moreLoading) { + this.moreLoading = true; + this.loadContentNodes(this.more).then(response => { + this.more = response.more || null; + this.moreLoading = false; + }); + } + }, verifyContentNodeId(id) { this.nodeNotFound = false; return this.$store.dispatch('contentNode/headContentNode', id).catch(() => { @@ -400,6 +414,7 @@ openCurrentLocationButton: 'Expand to current folder location', updatedResourcesReadyForReview: 'Updated resources are ready for review', closeDrawer: 'Close', + showMore: 'Show more', }, }; @@ -447,4 +462,10 @@ } } + .pagination-container { + display: flex; + justify-content: flex-start; + margin: 4px; + } + diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js index c9ec87bc1f..6c42af8974 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js @@ -19,9 +19,10 @@ import * as publicApi from 'shared/data/public'; import db from 'shared/data/db'; export function loadContentNodes(context, params = {}) { - return ContentNode.where(params).then(contentNodes => { + return ContentNode.where(params).then(response => { + const contentNodes = response.results ? response.results : response; context.commit('ADD_CONTENTNODES', contentNodes); - return contentNodes; + return response; }); } @@ -70,7 +71,7 @@ export function loadContentNodeByNodeId(context, nodeId) { } export function loadChildren(context, { parent, published = null, complete = null }) { - const params = { parent }; + const params = { parent, max_results: 25 }; if (published !== null) { params.published = published; } From cf8d7b22ef1ed608d7e8fd108b6fbde2bfb8a99a Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 23 Jul 2024 17:25:29 -0700 Subject: [PATCH 147/170] Fix tests. --- .../frontend/channelEdit/views/TreeView/index.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.spec.js b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.spec.js index 470e67a89d..4ec3d01d61 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.spec.js @@ -22,7 +22,7 @@ const GETTERS = { canManage: jest.fn(() => true), }, contentNode: { - getContentNodeChildren: () => jest.fn(() => []), + getContentNodeChildren: () => jest.fn(() => ({ results: [], more: null })), getContentNodeAncestors: () => jest.fn(() => []), getContentNode: () => jest.fn(() => ({})), getTopicAndResourceCounts: () => jest.fn(() => ({ topicCount: 0, resourceCount: 0 })), @@ -35,7 +35,7 @@ const ACTIONS = { loadContentNode: jest.fn(), headContentNode: () => jest.fn(), loadContentNodes: jest.fn(), - loadChildren: jest.fn(), + loadChildren: jest.fn(() => ({ results: [], more: null })), }, }; From a5dfad69750c22f5b32d01dd988936a39ae009e4 Mon Sep 17 00:00:00 2001 From: Liana Harris <46411498+LianaHarris360@users.noreply.github.com> Date: Tue, 20 Aug 2024 11:52:41 -0500 Subject: [PATCH 148/170] Correct clipboard resource and topic folder counts --- .../components/Clipboard/ContentNode.vue | 9 ++++++-- .../Clipboard/ContentNodeOptions.vue | 18 +++++++++++++-- .../components/Clipboard/index.vue | 22 ++++++++++++++----- .../channelEdit/components/move/MoveModal.vue | 9 +++++++- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/Clipboard/ContentNode.vue b/contentcuration/contentcuration/frontend/channelEdit/components/Clipboard/ContentNode.vue index dd72e4d37e..2c8139a390 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/Clipboard/ContentNode.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/Clipboard/ContentNode.vue @@ -55,7 +55,7 @@
- {{ contentNode.resource_count }} + {{ contentNodeResourceCount }}
@@ -117,7 +117,7 @@ \ 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 index f4a9a48b0a..c1f920d2d9 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue @@ -26,6 +26,7 @@ import { mapGetters, mapActions } from 'vuex'; import { getFileDuration } from 'shared/utils/helpers'; import CompletionOptions from 'shared/views/contentNodeFields/CompletionOptions'; + import commonStrings from 'shared/translator'; export default { name: 'EditCompletionModal', @@ -120,8 +121,8 @@ }; this.updateContentNode({ id: this.nodeId, ...payload }); - - this.$store.dispatch('showSnackbarSimple', this.$tr('editedCompletion', { count: 1 })); + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.$store.dispatch('showSnackbarSimple', commonStrings.$tr('changesSaved')); this.close(); }, close() { @@ -132,8 +133,6 @@ editCompletion: 'Edit Completion', saveAction: 'Save', cancelAction: 'Cancel', - editedCompletion: - 'Edited completion for {count, number, integer} {count, plural, one {resource} other {resources}}', }, }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue index 7a3ec6a646..026f9db5be 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -60,6 +60,7 @@ import { mapGetters, mapActions } from 'vuex'; import { LanguagesList } from 'shared/leUtils/Languages'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; + import commonStrings from 'shared/translator'; export default { name: 'EditLanguageModal', @@ -144,11 +145,8 @@ }); }) ); - - this.$store.dispatch( - 'showSnackbarSimple', - this.$tr('editedLanguage', { count: this.nodes.length }) - ); + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.$store.dispatch('showSnackbarSimple', commonStrings.$tr('changesSaved')); this.close(); }, }, @@ -157,8 +155,6 @@ languageItemText: '{language} ({code})', saveAction: 'Save', cancelAction: 'Cancel', - editedLanguage: - 'Edited language for {count, number, integer} {count, plural, one {resource} other {resources}}', selectLanguage: 'Select / Type Language', resourcesSelected: '{count, number, integer} {count, plural, one {resource} other {resources}} selected', @@ -177,4 +173,4 @@ height: 250px; overflow-y: auto; } - \ 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 index 790de6c081..1cd835f055 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue @@ -6,7 +6,7 @@ :title="$tr('editLearningActivitiesTitle')" :nodeIds="nodeIds" :validators="learningActivityValidators" - :confirmationMessage="$tr('editedLearningActivities', { count: nodeIds.length })" + :confirmationMessage="changesSaved" @close="() => $emit('close')" >