diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index dc32de8ca8..d05cb80191 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -58,7 +58,7 @@ jobs: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: ./docker file: ./docker/Dockerfile.postgres.dev @@ -98,7 +98,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: ./ file: ./k8s/images/nginx/Dockerfile diff --git a/.gitignore b/.gitignore index 8d869357f8..64e2dc5733 100644 --- a/.gitignore +++ b/.gitignore @@ -128,7 +128,7 @@ webpack-stats\.json storybook-static/ # i18n -/contentcuration/locale/CSV_FILES/* +/contentcuration/locale/**/LC_MESSAGES/*.csv # pyenv .python-version diff --git a/Makefile b/Makefile index 051053bab3..619fcee41e 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ migrate: # 4) Remove the management command from this `deploy-migrate` recipe # 5) Repeat! deploy-migrate: - echo "Nothing to do here!" + python contentcuration/manage.py rectify_incorrect_contentnode_source_fields contentnodegc: python contentcuration/manage.py garbage_collect diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue index 36477d257c..54e46f8799 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue @@ -258,6 +258,7 @@ retryFailedCopy: withChangeTracker(function(changeTracker) { this.updateContentNode({ id: this.nodeId, + checkComplete: true, [COPYING_STATUS]: COPYING_STATUS_VALUES.COPYING, }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js index 89c7f526fb..9c7a8f457d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js @@ -36,6 +36,11 @@ function mountComponent(opts = {}) { namespaced: true, getters: { isNodeInCopyingState: () => jest.fn(), + getContentNodesCount: () => + jest.fn().mockReturnValue({ + resource_count: TOPIC_NODE.resource_count, + assessment_item_count: EXERCISE_NODE.assessment_item_count, + }), }, }, }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue index f7fc43c8a9..55e0941d27 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue @@ -258,7 +258,11 @@ }; }, computed: { - ...mapGetters('contentNode', ['isNodeInCopyingState', 'hasNodeCopyingErrored']), + ...mapGetters('contentNode', [ + 'isNodeInCopyingState', + 'hasNodeCopyingErrored', + 'getContentNodesCount', + ]), isCompact() { return this.compact || !this.$vuetify.breakpoint.mdAndUp; }, @@ -273,14 +277,15 @@ return { title, kind, src, encoding }; }, subtitle() { + const count = this.getContentNodesCount(this.node.id); switch (this.node.kind) { case ContentKindsNames.TOPIC: return this.$tr('resources', { - value: this.node.resource_count || 0, + value: count?.resource_count || 0, }); case ContentKindsNames.EXERCISE: return this.$tr('questions', { - value: this.node.assessment_item_count || 0, + value: count?.assessment_item_count || 0, }); } diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue index 9048aebb78..f5bda050a3 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeOptions.vue @@ -124,7 +124,7 @@ }, { label: this.$tr('makeACopy'), - onClick: this.duplicateNode, + onClick: () => this.duplicateNode(this.nodeId), condition: this.canEdit, }, { @@ -282,9 +282,11 @@ }); }, moveNode(target) { - return this.moveContentNodes({ id__in: [this.nodeId], parent: target }).then( - this.$refs.moveModal.moveComplete - ); + return this.moveContentNodes({ + id__in: [this.nodeId], + parent: target, + inherit: this.node.parent !== target, + }).then(this.$refs.moveModal.moveComplete); }, getRemoveNodeRedirect() { // Returns a callback to do appropriate post-removal navigation @@ -322,7 +324,7 @@ removeNode: withChangeTracker(function(id__in, changeTracker) { this.trackAction('Delete'); const redirect = this.getRemoveNodeRedirect(); - return this.moveContentNodes({ id__in, parent: this.trashId }).then(() => { + return this.moveContentNodes({ id__in, parent: this.trashId, inherit: false }).then(() => { redirect(); this.showSnackbar({ text: this.$tr('removedItems'), @@ -345,7 +347,7 @@ } ); }), - duplicateNode: withChangeTracker(async function(changeTracker) { + duplicateNode: withChangeTracker(async function(nodeId, changeTracker) { this.trackAction('Copy'); this.showSnackbar({ duration: null, @@ -356,8 +358,8 @@ // actionCallback: () => changeTracker.revert(), }); const copiedContentNode = await this.copyContentNode({ - id: this.nodeId, - target: this.nodeId, + id: nodeId, + target: nodeId, position: RELATIVE_TREE_POSITIONS.RIGHT, }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue index e5d7aa09f2..5a34b7cf18 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -107,7 +107,9 @@ }, canSave() { if (this.hasMixedCategories) { - return Object.values(this.selectedValues).some(value => value.length > 0); + return Object.values(this.selectedValues).some( + value => value.length === this.nodes.length + ); } return !this.error; }, @@ -174,7 +176,7 @@ Object.assign(fieldValue, currentNode[this.field] || {}); } Object.entries(this.selectedValues) - .filter(([value]) => value.length === this.nodeIds.length) + .filter(entry => entry[1].length === this.nodeIds.length) .forEach(([key]) => { fieldValue[key] = true; }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue index 839f19a896..7014297a18 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue @@ -62,11 +62,9 @@ box :required="isEditable" :disabled="!isEditable" + :helpText="helpText" /> -

- {{ helpText }} -

@@ -293,7 +291,7 @@ aggregatorToolTip: 'Website or org hosting the content collection but not necessarily the creator or copyright holder', copyrightHolderLabel: 'Copyright holder', - cannotEditPublic: 'Cannot edit for public channel resources', + cannotEditPublic: 'Not editable for resources from public channels', editOnlyLocal: 'Edits will be reflected only for local resources', mixed: 'Mixed', saveAction: 'Save', 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 428d41cad1..2c49fe6b94 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js @@ -78,6 +78,11 @@ const makeWrapper = ({ nodeIds, field = 'categories', ...restOptions }) => { hideLabel: true, nodeIds, }, + on: { + input(value) { + props.inputHandler(value); + }, + }, }); }, }, @@ -107,6 +112,7 @@ describe('EditBooleanMapModal', () => { actions: contentNodeActions, getters: { getContentNodes: () => ids => ids.map(id => nodes[id]), + getContentNode: () => id => nodes[id], }, }, }, @@ -199,7 +205,7 @@ describe('EditBooleanMapModal', () => { }); describe('Submit', () => { - test('should call updateContentNode with the right options on success submit - categories', () => { + test('should call updateContentNode with the selected options on an empty boolean map when success submit', async () => { const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); const schoolCheckbox = findOptionCheckbox(wrapper, Categories.SCHOOL); @@ -207,58 +213,43 @@ describe('EditBooleanMapModal', () => { 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); + await wrapper.vm.handleSave(); + 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, + }, }); }); - test('should emit close event on success submit', () => { + test('should emit close event on success submit', async () => { 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); - }); + await wrapper.vm.handleSave(); + expect(wrapper.emitted('close')).toBeTruthy(); }); - test('should show a confirmation snackbar on success submit', () => { + test('should show a confirmation snackbar on success submit', async () => { const wrapper = makeWrapper({ nodeIds: ['node1'] }); - wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); - - const animationFrameId = requestAnimationFrame(() => { - expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); - cancelAnimationFrame(animationFrameId); - }); + await wrapper.vm.handleSave(); + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); }); }); 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); - }); + wrapper.vm.close(); + expect(wrapper.emitted('close')).toBeTruthy(); }); describe('topic nodes present', () => { @@ -284,39 +275,262 @@ describe('EditBooleanMapModal', () => { 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', () => { + test('should call updateContentNode on success submit if the user does not check the update descendants checkbox', async () => { nodes['node1'].kind = ContentKindsNames.TOPIC; const wrapper = makeWrapper({ nodeIds: ['node1'], isDescendantsUpdatable: true }); + await wrapper.vm.handleSave(); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: {}, + }); + }); - wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + test('should call updateContentNodeDescendants on success submit if the user checks the descendants checkbox', async () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; - const animationFrameId = requestAnimationFrame(() => { - expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + const wrapper = makeWrapper({ nodeIds: ['node1'], isDescendantsUpdatable: true }); + wrapper.find('[data-test="update-descendants-checkbox"]').element.click(); + await wrapper.vm.handleSave(); + + expect(contentNodeActions.updateContentNodeDescendants).toHaveBeenCalledWith( + expect.anything(), + { id: 'node1', categories: {}, + } + ); + }); + }); + + describe('mixed options that are not selected across all nodes', () => { + describe('render mixed options message', () => { + test('should not render mixed options message if there are not mixed options across selected nodes', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + expect(wrapper.find('[data-test="mixed-categories-message"]').exists()).toBeFalsy(); + }); + + test('should not render mixed options message if there are not mixed options across selected nodes - 2', () => { + 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'] }); + + expect(wrapper.find('[data-test="mixed-categories-message"]').exists()).toBeFalsy(); + }); + + test('should render mixed options message if there are mixed options across selected nodes', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + }; + nodes['node2'].categories = {}; + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + expect(wrapper.find('[data-test="mixed-categories-message"]').exists()).toBeTruthy(); + }); + + test('should render mixed options message if there are mixed options across selected nodes - 2', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + expect(wrapper.find('[data-test="mixed-categories-message"]').exists()).toBeTruthy(); + }); + }); + describe('on submit', () => { + test('should add new selected options on submit even if there are not common selected options', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + }; + nodes['node2'].categories = { + [Categories.FOUNDATIONS]: true, + }; + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const schoolCheckbox = findOptionCheckbox(wrapper, Categories.SCHOOL); + schoolCheckbox.element.click(); + + wrapper.vm.handleSave(); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: { + // already daily_life category selected plus new school category selected + [Categories.SCHOOL]: true, + [Categories.DAILY_LIFE]: true, + }, + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node2', + categories: { + // already foundations category selected plus new school category selected + [Categories.SCHOOL]: true, + [Categories.FOUNDATIONS]: true, + }, + }); + }); + + test('should add new selected options on submit even if there are common selected options', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + nodes['node2'].categories = { + [Categories.FOUNDATIONS]: true, + }; + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const schoolCheckbox = findOptionCheckbox(wrapper, Categories.SCHOOL); + schoolCheckbox.element.click(); + + wrapper.vm.handleSave(); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: { + // already daily_life and foundation category selected plus new school category selected + [Categories.SCHOOL]: true, + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }, + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node2', + categories: { + // already foundations category selected plus new school category selected + [Categories.SCHOOL]: true, + [Categories.FOUNDATIONS]: true, + }, + }); + }); + + test('should not remove common selected options even if they are unchecked', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + }; + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const dailyLifeCheckbox = findOptionCheckbox(wrapper, Categories.DAILY_LIFE); + dailyLifeCheckbox.element.click(); // uncheck daily lifye + + const schoolCheckbox = findOptionCheckbox(wrapper, Categories.SCHOOL); + schoolCheckbox.element.click(); // check school + + wrapper.vm.handleSave(); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: { + // already daily_life and foundation category selected plus new school category selected + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + [Categories.SCHOOL]: true, + }, + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node2', + categories: { + // already daily life category selected plus new school category selected + [Categories.DAILY_LIFE]: true, + [Categories.SCHOOL]: true, + }, + }); + }); + + test('should not remove common selected options even if they are unchecked and no new options are checked', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + [Categories.SCHOOL]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const dailyLifeCheckbox = findOptionCheckbox(wrapper, Categories.DAILY_LIFE); + dailyLifeCheckbox.element.click(); // uncheck daily lifye + + wrapper.vm.handleSave(); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: { + // already selected options + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + [Categories.SCHOOL]: true, + }, + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node2', + categories: { + // already selected options + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }, }); - cancelAnimationFrame(animationFrameId); }); }); + describe('can save method', () => { + test('should not can save if there are mixed categories and no options selected', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + }; + nodes['node2'].categories = { + [Categories.FOUNDATIONS]: true, + }; - test('should call updateContentNodeDescendants on success submit if the user checks the descendants checkbox', () => { - nodes['node1'].kind = ContentKindsNames.TOPIC; + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); - const wrapper = makeWrapper({ nodeIds: ['node1'], isDescendantsUpdatable: true }); + expect(wrapper.vm.canSave).toBeFalsy(); + }); + + test('should can save if there are mixed categories but new options are selected', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + }; + nodes['node2'].categories = { + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const schoolCheckbox = findOptionCheckbox(wrapper, Categories.SCHOOL); + schoolCheckbox.element.click(); + + expect(wrapper.vm.canSave).toBeTruthy(); + }); + + test('should can save if there are mixed categories but at least one common option across all nodes', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); - 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); + expect(wrapper.vm.canSave).toBeTruthy(); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js index 20858eecac..8be43746f9 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js @@ -141,7 +141,7 @@ describe('EditSourceModal', () => { const wrapper = makeWrapper(['node1', 'node2']); - expect(wrapper.find('.help').text()).toContain('Cannot edit'); + expect(wrapper.find('.help').text()).toContain(EditSourceModal.$trs.cannotEditPublic); }); test('should disable inputs when node has freeze_authoring_data set to true', () => { @@ -162,7 +162,7 @@ describe('EditSourceModal', () => { const wrapper = makeWrapper(['node1']); - expect(wrapper.find('.help').text()).toContain('Cannot edit'); + expect(wrapper.find('.help').text()).toContain(EditSourceModal.$trs.cannotEditPublic); }); test('should not disable inputs when not all nodes are imported', () => { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue index 7812086201..053a5fd70c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue @@ -270,6 +270,9 @@ +

+ {{ helpTextString.$tr('cannotEditPublic') }} +

@@ -344,6 +348,9 @@ @input="copyright_holder = $event" @focus="trackClick('Copyright holder')" /> +

+ {{ helpTextString.$tr('cannotEditPublic') }} +

@@ -384,6 +391,7 @@ import FileUpload from '../../views/files/FileUpload'; import SubtitlesList from '../../views/files/supplementaryLists/SubtitlesList'; import { isImportedContent, isDisableSourceEdits, importedChannelLink } from '../../utils'; + import EditSourceModal from '../QuickEditModal/EditSourceModal.vue'; import AccessibilityOptions from './AccessibilityOptions.vue'; import LevelsOptions from 'shared/views/contentNodeFields/LevelsOptions'; import CategoryOptions from 'shared/views/contentNodeFields/CategoryOptions'; @@ -409,6 +417,7 @@ nonUniqueValue, } from 'shared/constants'; import { constantsTranslationMixin, metadataTranslationMixin } from 'shared/mixins'; + import { crossComponentTranslator } from 'shared/i18n'; function getValueFromResults(results) { if (results.length === 0) { @@ -547,6 +556,7 @@ valid: true, diffTracker: {}, changed: false, + helpTextString: crossComponentTranslator(EditSourceModal), }; }, computed: { @@ -776,10 +786,12 @@ saveFromDiffTracker(id) { if (this.diffTracker[id]) { this.changed = true; - return this.updateContentNode({ id, ...this.diffTracker[id] }).then(() => { - delete this.diffTracker[id]; - return this.changed; - }); + return this.updateContentNode({ id, checkComplete: true, ...this.diffTracker[id] }).then( + () => { + delete this.diffTracker[id]; + return this.changed; + } + ); } return Promise.resolve(this.changed); }, @@ -991,4 +1003,14 @@ } } + // Positions help text underneath + p.help { + position: relative; + top: -20px; + left: 10px; + margin-bottom: 14px; + font-size: 12px; + color: var(--v-text-lighten4); + } + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 2dba69cc0f..3ab92e6cbb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -64,13 +64,18 @@ {{ $tr('addTopic') }} - + {{ $tr('uploadButton') }}
@@ -96,7 +101,7 @@ @@ -123,8 +128,10 @@ @@ -252,6 +259,9 @@ listElevated: false, storagePoll: null, openTime: null, + isInheritModalOpen: false, + newNodeIds: [], + creatingNodes: false, }; }, computed: { @@ -315,6 +325,17 @@ invalidNodes() { return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, + currentSelectedNodes: { + get() { + if (this.isInheritModalOpen && this.newNodeIds.length) { + return this.newNodeIds; + } + return this.selected; + }, + set(value) { + this.selected = value; + }, + }, }, beforeRouteEnter(to, from, next) { if ( @@ -378,7 +399,11 @@ if (completeCheck !== node.complete) { validationPromises.push( - vm.updateContentNode({ id: nodeId, complete: completeCheck }) + vm.updateContentNode({ + id: nodeId, + complete: completeCheck, + checkComplete: true, + }) ); } }); @@ -432,7 +457,10 @@ this.hideHTMLScroll(false); this.$router.push({ name: RouteNames.TREE_VIEW, - params: { nodeId: this.$route.params.nodeId }, + params: { + nodeId: this.$route.params.nodeId, + addedCount: this.nodeIds.length, + }, }); }, hideHTMLScroll(hidden) { @@ -506,28 +534,37 @@ return newNodeId; }); }, + resetInheritMetadataModal() { + this.$refs.inheritModal?.checkInheritance(); + }, createTopic() { this.createNode('topic', { title: '', }).then(newNodeId => { this.selected = [newNodeId]; + this.$nextTick(() => { + this.resetInheritMetadataModal(); + }); }); }, - createNodesFromUploads(fileUploads) { - fileUploads.forEach((file, index) => { - let title; - if (file.metadata.title) { - title = file.metadata.title; - } else { - title = file.original_filename - .split('.') - .slice(0, -1) - .join('.'); - } - this.createNode( - FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, - { title, ...file.metadata } - ).then(newNodeId => { + async createNodesFromUploads(fileUploads) { + this.creatingNodes = true; + const parentPropDefinedForInheritModal = Boolean(this.$refs.inheritModal?.parent); + this.newNodeIds = await Promise.all( + fileUploads.map(async (file, index) => { + let title; + if (file.metadata.title) { + title = file.metadata.title; + } else { + title = file.original_filename + .split('.') + .slice(0, -1) + .join('.'); + } + const newNodeId = await this.createNode( + FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, + { title, ...file.metadata } + ); if (index === 0) { this.selected = [newNodeId]; } @@ -535,8 +572,15 @@ ...file, contentnode: newNodeId, }); - }); - }); + return newNodeId; + }) + ); + this.creatingNodes = false; + if (parentPropDefinedForInheritModal) { + // Only call this if the parent prop was previously defined, otherwise, + // rely on the parent prop watcher to trigger the inherit event. + this.resetInheritMetadataModal(); + } }, updateTitleForPage() { this.updateTabTitle(this.$store.getters.appendChannelName(this.modalTitle)); @@ -547,8 +591,31 @@ }); }, inheritMetadata(metadata) { - for (const nodeId of this.nodeIds) { - this.updateContentNode({ id: nodeId, ...metadata, mergeMapFields: true }); + if (!this.createMode) { + // This shouldn't happen, but prevent this just in case. + return; + } + const setMetadata = () => { + const nodeIds = this.uploadMode ? this.newNodeIds : this.selected; + for (const nodeId of nodeIds) { + this.updateContentNode({ + id: nodeId, + ...metadata, + mergeMapFields: true, + checkComplete: true, + }); + } + this.newNodeIds = []; + }; + if (!this.creatingNodes) { + setMetadata(); + } else { + const unwatch = this.$watch('creatingNodes', creatingNodes => { + if (!creatingNodes) { + unwatch(); + setMetadata(); + } + }); } }, }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/InheritAncestorMetadataModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/InheritAncestorMetadataModal.vue index 6c0a42fda7..333d3f0eab 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/InheritAncestorMetadataModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/InheritAncestorMetadataModal.vue @@ -7,13 +7,13 @@ :submitText="$tr('continueAction')" :cancelText="$tr('cancelAction')" @submit="handleContinue" - @cancel="closed = true" + @cancel="handleCancel" >
-

- {{ $tr('inheritMetadataDescription') }} -

+

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

!isUndefined(this.parent.extra_fields.inherit_metadata[field]) + field => !isUndefined(this.parent.extra_fields.inherited_metadata[field]) ) ); }, active() { - return ( - this.parent !== null && - !this.allFieldsDesignatedByParent && - !this.closed && - this.parentHasInheritableMetadata - ); + return this.parent !== null && !this.closed; }, inheritableMetadataItems() { const returnValue = {}; @@ -141,6 +136,11 @@ fieldsToInherit() { return Object.keys(this.inheritableMetadataItems).filter(field => this.checks[field]); }, + parentHasNonLanguageMetadata() { + return ( + !isEmpty(this.categories) || !isEmpty(this.grade_levels) || !isEmpty(this.learner_needs) + ); + }, parentHasInheritableMetadata() { return !isEmpty(this.inheritableMetadataItems); }, @@ -178,12 +178,28 @@ this.resetData(); } }, + active(newValue) { + this.$emit('updateActive', newValue); + }, }, created() { this.resetData(); }, methods: { ...mapActions('contentNode', ['updateContentNode']), + /** + * @public + */ + checkInheritance() { + if (this.allFieldsDesignatedByParent || !this.parentHasInheritableMetadata) { + // If all fields have been designated by the parent, or there is nothing to inherit, + // automatically continue + this.handleContinue(); + } else { + // Wait for the data to be updated before showing the dialog + this.closed = false; + } + }, resetData() { if (this.parent) { this.dontShowAgain = false; @@ -193,12 +209,16 @@ } this.checks = checks; ContentNode.getAncestors(this.parent.id).then(ancestors => { + if (!this.parent) { + // If the parent has been removed before the data is fetched, return + return; + } for (const field of inheritableFields) { if ( - this.parent.extra_fields.inherit_metadata && - this.parent.extra_fields.inherit_metadata[field] + this.parent.extra_fields?.inherited_metadata && + !isUndefined(this.parent.extra_fields.inherited_metadata[field]) ) { - this.checks[field] = this.parent.extra_fields.inherit_metadata[field]; + this.checks[field] = this.parent.extra_fields.inherited_metadata[field]; } } this.categories = ancestors.reduce((acc, ancestor) => { @@ -230,14 +250,7 @@ }; }, {}); this.$nextTick(() => { - if (this.allFieldsDesignatedByParent || !this.parentHasInheritableMetadata) { - // If all fields have been designated by the parent, or there is nothing to inherit, - // automatically continue - this.handleContinue(); - } else { - // Wait for the data to be updated before showing the dialog - this.closed = false; - } + this.checkInheritance(); }); }); } @@ -250,19 +263,19 @@ // but just in case, return return; } - const inherit_metadata = { - ...(this.parent?.extra_fields.inherit_metadata || {}), + const inherited_metadata = { + ...(this.parent?.extra_fields.inherited_metadata || {}), }; for (const field of inheritableFields) { if (this.inheritableMetadataItems[field]) { // Only store preferences for fields that have been shown to the user as inheritable - inherit_metadata[field] = this.checks[field]; + inherited_metadata[field] = this.checks[field]; } } this.updateContentNode({ id: this.parent.id, extra_fields: { - inherit_metadata, + inherited_metadata, }, }); }, @@ -283,6 +296,10 @@ } this.closed = true; }, + handleCancel() { + this.closed = true; + this.$emit('inherit', {}); + }, }, $trs: { applyResourceDetailsTitle: "Apply details from the folder '{folder}'", diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue index d5c6164863..a68c50d51b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue @@ -135,7 +135,7 @@