diff --git a/contentcuration/contentcuration/forms.py b/contentcuration/contentcuration/forms.py index d9dc781f61..8e9320d85f 100644 --- a/contentcuration/contentcuration/forms.py +++ b/contentcuration/contentcuration/forms.py @@ -1,5 +1,4 @@ import json -from builtins import object from django import forms from django.conf import settings @@ -7,6 +6,7 @@ from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UserCreationForm from django.core import signing +from django.core.exceptions import ValidationError from django.db.models import Q from django.template.loader import render_to_string @@ -16,23 +16,16 @@ REGISTRATION_SALT = getattr(settings, 'REGISTRATION_SALT', 'registration') -class ExtraFormMixin(object): - - def check_field(self, field, error): - if not self.cleaned_data.get(field): - self.errors[field] = self.error_class() - self.add_error(field, error) - return False - return self.cleaned_data.get(field) - - # LOGIN/REGISTRATION FORMS ################################################################# -class RegistrationForm(UserCreationForm, ExtraFormMixin): +class RegistrationForm(UserCreationForm): + CODE_ACCOUNT_ACTIVE = 'account_active' + CODE_ACCOUNT_INACTIVE = 'account_inactive' + first_name = forms.CharField(required=True) last_name = forms.CharField(required=True) - email = forms.CharField(required=True) - password1 = forms.CharField(required=True) + email = forms.EmailField(required=True) + password1 = forms.CharField(required=True, min_length=8) password2 = forms.CharField(required=True) uses = forms.CharField(required=True) other_use = forms.CharField(required=False) @@ -45,22 +38,18 @@ class RegistrationForm(UserCreationForm, ExtraFormMixin): locations = forms.CharField(required=True) def clean_email(self): - email = self.cleaned_data['email'].strip().lower() - if User.objects.filter(Q(is_active=True) | Q(deleted=True), email__iexact=email).exists(): - raise UserWarning + # ensure email is lower case + email = self.cleaned_data["email"].strip().lower() + user_qs = User.objects.filter(email__iexact=email) + if user_qs.exists(): + if user_qs.filter(Q(is_active=True) | Q(deleted=True)).exists(): + raise ValidationError("Account already active", code=self.CODE_ACCOUNT_ACTIVE) + else: + raise ValidationError("Already registered.", code=self.CODE_ACCOUNT_INACTIVE) return email - def clean(self): - super(RegistrationForm, self).clean() - - # Errors should be caught on the frontend - # or a warning should be thrown if the account exists - self.errors.clear() - return self.cleaned_data - def save(self, commit=True): - user = super(RegistrationForm, self).save(commit=commit) - user.set_password(self.cleaned_data["password1"]) + user = super(RegistrationForm, self).save(commit=False) user.first_name = self.cleaned_data["first_name"] user.last_name = self.cleaned_data["last_name"] user.information = { @@ -165,7 +154,7 @@ def save(self, user): return user -class StorageRequestForm(forms.Form, ExtraFormMixin): +class StorageRequestForm(forms.Form): # Nature of content storage = forms.CharField(required=True) kind = forms.CharField(required=True) @@ -194,7 +183,7 @@ class Meta: "audience", "import_count", "location", "uploading_for", "organization_type", "time_constraint", "message") -class IssueReportForm(forms.Form, ExtraFormMixin): +class IssueReportForm(forms.Form): operating_system = forms.CharField(required=True) browser = forms.CharField(required=True) channel = forms.CharField(required=False) @@ -204,7 +193,7 @@ class Meta: fields = ("operating_system", "browser", "channel", "description") -class DeleteAccountForm(forms.Form, ExtraFormMixin): +class DeleteAccountForm(forms.Form): email = forms.CharField(required=True) def __init__(self, user, *args, **kwargs): @@ -214,5 +203,5 @@ def __init__(self, user, *args, **kwargs): def clean_email(self): email = self.cleaned_data['email'].strip().lower() if self.user.is_admin or self.user.email.lower() != self.cleaned_data['email']: - raise UserWarning + raise ValidationError("Not allowed") return email diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index ba9f8bf338..e4e28d6d08 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -31,31 +31,40 @@ v-model="form.first_name" maxlength="100" counter - :label="$tr('firstNameLabel')" autofocus + :label="$tr('firstNameLabel')" + :error-messages="errors.first_name" + @input="resetErrors('first_name')" /> @@ -200,6 +209,7 @@ import Checkbox from 'shared/views/form/Checkbox'; import { policies } from 'shared/constants'; import DropdownWrapper from 'shared/views/form/DropdownWrapper'; + import commonStrings from 'shared/translator'; export default { name: 'Create', @@ -219,7 +229,6 @@ return { valid: true, registrationFailed: false, - emailErrors: [], form: { first_name: '', last_name: '', @@ -237,6 +246,13 @@ accepted_policy: false, accepted_tos: false, }, + errors: { + first_name: [], + last_name: [], + email: [], + password1: [], + password2: [], + }, }; }, computed: { @@ -247,6 +263,9 @@ passwordConfirmRules() { return [value => (this.form.password1 === value ? true : this.$tr('passwordMatchMessage'))]; }, + passwordValidationRules() { + return [value => (value.length >= 8 ? true : this.$tr('passwordValidationMessage'))]; + }, acceptedAgreement: { get() { return this.form.accepted_tos && this.form.accepted_policy; @@ -294,10 +313,12 @@ ]; }, usageRules() { - return [() => (this.form.uses.length ? true : this.$tr('fieldRequiredMessage'))]; + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + return [() => (this.form.uses.length ? true : commonStrings.$tr('fieldRequired'))]; }, locationRules() { - return [() => (this.form.locations.length ? true : this.$tr('fieldRequiredMessage'))]; + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + return [() => (this.form.locations.length ? true : commonStrings.$tr('fieldRequired'))]; }, sources() { return sources; @@ -359,7 +380,8 @@ ]; }, sourceRules() { - return [() => (this.form.source.length ? true : this.$tr('fieldRequiredMessage'))]; + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + return [() => (this.form.source.length ? true : commonStrings.$tr('fieldRequired'))]; }, clean() { return data => { @@ -413,12 +435,11 @@ }, methods: { ...mapActions('account', ['register']), - ...mapActions('policies', ['openPolicy']), showTermsOfService() { - this.openPolicy(policies.TERMS_OF_SERVICE); + this.$router.push({ query: { showPolicy: policies.TERMS_OF_SERVICE } }); }, showPrivacyPolicy() { - this.openPolicy(policies.PRIVACY); + this.$router.push({ query: { showPolicy: policies.PRIVACY } }); }, showStorageField(id) { return id === uses.STORING && this.form.uses.includes(id); @@ -438,8 +459,27 @@ .catch(error => { if (error.message === 'Network Error') { this.$refs.top.scrollIntoView({ behavior: 'smooth' }); + } else if (error.response.status === 400) { + for (const field of error.response.data) { + if (!Object.prototype.hasOwnProperty.call(this.errors, field)) { + continue; + } + let message = ''; + switch (field) { + case 'password1': + message = this.$tr('passwordValidationMessage'); + break; + default: + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + message = commonStrings.$tr('fieldHasError'); + break; + } + this.errors[field] = [message]; + } + this.registrationFailed = true; + this.valid = false; } else if (error.response.status === 403) { - this.emailErrors = [this.$tr('emailExistsMessage')]; + this.errors.email = [this.$tr('emailExistsMessage')]; } else if (error.response.status === 405) { this.$router.push({ name: 'AccountNotActivated' }); } else { @@ -452,12 +492,14 @@ } return Promise.resolve(); }, + resetErrors(field) { + this.errors[field] = []; + }, }, $trs: { backToLoginButton: 'Sign in', createAnAccountTitle: 'Create an account', - fieldRequiredMessage: 'Field is required', errorsMessage: 'Please fix the errors below', registrationFailed: 'There was an error registering your account. Please try again', registrationFailedOffline: @@ -470,6 +512,7 @@ passwordLabel: 'Password', confirmPasswordLabel: 'Confirm password', passwordMatchMessage: "Passwords don't match", + passwordValidationMessage: 'Password should be at least 8 characters long', // Usage question usageLabel: 'How do you plan on using Kolibri Studio (check all that apply)', diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Main.vue b/contentcuration/contentcuration/frontend/accounts/pages/Main.vue index c2ba1f5616..6f305885e3 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Main.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Main.vue @@ -153,12 +153,11 @@ }, methods: { ...mapActions(['login']), - ...mapActions('policies', ['openPolicy']), showTermsOfService() { - this.openPolicy(policies.TERMS_OF_SERVICE); + this.$router.push({ query: { showPolicy: policies.TERMS_OF_SERVICE } }); }, showPrivacyPolicy() { - this.openPolicy(policies.PRIVACY); + this.$router.push({ query: { showPolicy: policies.PRIVACY } }); }, submit() { if (this.$refs.form.validate()) { diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js index f2a201b560..143520329b 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js @@ -17,8 +17,8 @@ const defaultData = { first_name: 'Test', last_name: 'User', email: 'test@test.com', - password1: 'pass', - password2: 'pass', + password1: 'tester123', + password2: 'tester123', uses: ['tagging'], storage: '', other_use: '', @@ -126,6 +126,11 @@ describe('create', () => { expect(register).not.toHaveBeenCalled(); }); }); + it('should fail if password1 is too short', () => { + const wrapper = makeWrapper({ password1: '123' }); + wrapper.vm.submit(); + expect(register).not.toHaveBeenCalled(); + }); it('should fail if password1 and password2 do not match', () => { const wrapper = makeWrapper({ password1: 'some other password' }); wrapper.vm.submit(); @@ -155,7 +160,7 @@ describe('create', () => { it('should say account with email already exists if register returns a 403', async () => { wrapper.setMethods({ register: makeFailedPromise(403) }); await wrapper.vm.submit(); - expect(wrapper.vm.emailErrors).toHaveLength(1); + expect(wrapper.vm.errors.email).toHaveLength(1); }); it('should say account has not been activated if register returns 405', async () => { wrapper.setMethods({ register: makeFailedPromise(405) }); diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js index 7980086963..6d6e9a2451 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/resetPassword.spec.js @@ -39,8 +39,10 @@ describe('resetPassword', () => { }); it('should call setPassword on submit if password data is valid', () => { wrapper.setData({ new_password1: 'pass', new_password2: 'pass' }); - wrapper.find({ ref: 'form' }).trigger('submit'); - expect(setPassword).toHaveBeenCalled(); + wrapper.vm.$nextTick(() => { + wrapper.find({ ref: 'form' }).trigger('submit'); + expect(setPassword).toHaveBeenCalled(); + }); }); it('should retain data from query params so reset credentials are preserved', () => { router.replace({ @@ -50,7 +52,9 @@ describe('resetPassword', () => { }, }); wrapper.setData({ new_password1: 'pass', new_password2: 'pass' }); - wrapper.find({ ref: 'form' }).trigger('submit'); - expect(setPassword.mock.calls[0][0].test).toBe('testing'); + wrapper.vm.$nextTick(() => { + wrapper.find({ ref: 'form' }).trigger('submit'); + expect(setPassword.mock.calls[0][0].test).toBe('testing'); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue index 2fd3ceddd3..65e69270c9 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/resetPassword/ResetPassword.vue @@ -9,6 +9,7 @@ (this.new_password1 === value ? true : this.$tr('passwordMatchMessage'))]; }, + passwordValidationRules() { + return [value => (value.length >= 8 ? true : this.$tr('passwordValidationMessage'))]; + }, }, methods: { ...mapActions('account', ['setPassword']), @@ -80,6 +84,7 @@ resetPasswordPrompt: 'Enter and confirm your new password', passwordLabel: 'New password', passwordConfirmLabel: 'Confirm password', + passwordValidationMessage: 'Password should be at least 8 characters long', passwordMatchMessage: "Passwords don't match", submitButton: 'Submit', resetPasswordFailed: 'Failed to reset password. Please try again.', diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/Clipboard/ContentNode.vue b/contentcuration/contentcuration/frontend/channelEdit/components/Clipboard/ContentNode.vue index aa739233fa..359233cec9 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 @@ 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..87a7f368b5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditAudienceModal.vue @@ -0,0 +1,170 @@ + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue new file mode 100644 index 0000000000..8168d8a72d --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditBooleanMapModal.vue @@ -0,0 +1,180 @@ + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue new file mode 100644 index 0000000000..014709c5b2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCategoriesModal.vue @@ -0,0 +1,62 @@ + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue new file mode 100644 index 0000000000..c1f920d2d9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditCompletionModal.vue @@ -0,0 +1,142 @@ + + + + + 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..3a23ccd2a0 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLanguageModal.vue @@ -0,0 +1,178 @@ + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue new file mode 100644 index 0000000000..377717a70b --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLearningActivitiesModal.vue @@ -0,0 +1,68 @@ + + + + + + + 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..06b0137ea0 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditLevelsModal.vue @@ -0,0 +1,63 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue new file mode 100644 index 0000000000..f7ee9ace3c --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditResourcesNeededModal.vue @@ -0,0 +1,63 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue new file mode 100644 index 0000000000..8e5524bd27 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditSourceModal.vue @@ -0,0 +1,328 @@ + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue new file mode 100644 index 0000000000..37e59e64a7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/EditTitleDescriptionModal.vue @@ -0,0 +1,103 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditAudienceModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditAudienceModal.spec.js new file mode 100644 index 0000000000..2201530c35 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditAudienceModal.spec.js @@ -0,0 +1,258 @@ +import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import EditAudienceModal from '../EditAudienceModal'; +import { ResourcesNeededTypes } from 'shared/constants'; +import { RolesNames } from 'shared/leUtils/Roles'; + +let nodes; + +let store; +let contentNodeActions; +let generalActions; + +const getRolesValues = wrapper => { + const roles = {}; + const radioBtns = wrapper.findAll('[data-test="rol-radio-button"]'); + radioBtns.wrappers.forEach(checkbox => { + const { value, currentValue } = checkbox.vm.$props || {}; + roles[value] = currentValue === value; + }); + return roles; +}; + +const selectRole = (wrapper, rol) => { + const radioBtn = wrapper.find(`[data-test="rol-radio-button"] input[value="${rol}"]`); + radioBtn.setChecked(true); +}; + +const isForBeginnersChecked = wrapper => { + return wrapper.find('[data-test="for-beginners-checkbox"] input').element.checked; +}; + +const checkForBeginners = wrapper => { + wrapper.find('[data-test="for-beginners-checkbox"] input').setChecked(true); +}; + +const makeWrapper = nodeIds => { + return mount(EditAudienceModal, { + store, + propsData: { + nodeIds, + resourcesSelectedText: '2 resources', + }, + }); +}; + +describe('EditAudienceModal', () => { + beforeEach(() => { + nodes = { + node1: { id: 'node1' }, + node2: { id: 'node2' }, + }; + contentNodeActions = { + updateContentNode: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => ids.map(id => nodes[id]), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper(['node1']); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected audience on first render', () => { + test('no rol should be selected if a single node does not have set rol', () => { + const wrapper = makeWrapper(['node1']); + + const rolesValues = getRolesValues(wrapper); + Object.values(rolesValues).forEach(value => { + expect(value).toBeFalsy(); + }); + }); + + test('no rol should be selected if just a single node among multiple nodes does not have rol set', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + + const wrapper = makeWrapper(['node1', 'node2']); + + const rolesValues = getRolesValues(wrapper); + Object.values(rolesValues).forEach(value => { + expect(value).toBeFalsy(); + }); + }); + + test('no rol should be selected if there are multiple roles set', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node2'].role_visibility = RolesNames.LEARNER; + + const wrapper = makeWrapper(['node1', 'node2']); + + const rolesValues = getRolesValues(wrapper); + Object.values(rolesValues).forEach(value => { + expect(value).toBeFalsy(); + }); + }); + + test('the common rol should be selected if all nodes have the same rol visibility', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node2'].role_visibility = RolesNames.COACH; + + const wrapper = makeWrapper(['node1', 'node2']); + + const rolesValues = getRolesValues(wrapper); + expect(rolesValues[RolesNames.COACH]).toBeTruthy(); + }); + + test('for beginners checkbox should be unselected if no node is set for beginners', () => { + const wrapper = makeWrapper(['node1']); + + expect(isForBeginnersChecked(wrapper)).toBeFalsy(); + }); + + test('for beginners checkbox should be unselected if just a single node among multiple nodes is not set for beginners', () => { + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(isForBeginnersChecked(wrapper)).toBeFalsy(); + }); + + test('for beginners checkbox should be selected if all nodes are set for beginners', () => { + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + nodes['node2'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(isForBeginnersChecked(wrapper)).toBeTruthy(); + }); + + test('should display information message about different roles visibilities if there are multiple roles set', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node2'].role_visibility = RolesNames.LEARNER; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="multiple-audience-message"]').exists()).toBeTruthy(); + }); + + test('should not display information message about different roles visibilities if just some nodes are set for beginners', () => { + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="multiple-audience-message"]').exists()).toBeTruthy(); + }); + + test('should not display information message about different roles visibilities if all nodes have the same rol visibility and are set for beginners', () => { + nodes['node1'].role_visibility = RolesNames.COACH; + nodes['node1'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + nodes['node2'].role_visibility = RolesNames.COACH; + nodes['node2'].learner_needs = { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="multiple-audience-message"]').exists()).toBeFalsy(); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper(['node1', 'node2']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should call updateContentNode with the right rol on success submit', () => { + const wrapper = makeWrapper(['node1']); + + selectRole(wrapper, RolesNames.COACH); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + role_visibility: RolesNames.COACH, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should call updateContentNode with the need for beginners set on success submit', () => { + const wrapper = makeWrapper(['node1']); + + checkForBeginners(wrapper); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + learner_needs: { + [ResourcesNeededTypes.FOR_BEGINNERS]: true, + }, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper(['node1']); + + selectRole(wrapper, RolesNames.COACH); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper(['node1']); + + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('cancel'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a confirmation snackbar on success submit', () => { + const wrapper = makeWrapper(['node1']); + + selectRole(wrapper, RolesNames.COACH); + wrapper.find('[data-test="edit-audience-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); + cancelAnimationFrame(animationFrameId); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js new file mode 100644 index 0000000000..43e7237d41 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditBooleanMapModal.spec.js @@ -0,0 +1,346 @@ +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; +import camelCase from 'lodash/camelCase'; +import EditBooleanMapModal from '../EditBooleanMapModal'; +import { metadataTranslationMixin } from 'shared/mixins'; +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; +import { Categories } from 'shared/constants'; +import CategoryOptions from 'shared/views/contentNodeFields/CategoryOptions'; + +let nodes; + +let store; +let contentNodeActions; +let generalActions; + +const CheckboxValue = { + UNCHECKED: 'UNCHECKED', + CHECKED: 'CHECKED', + INDETERMINATE: 'INDETERMINATE', +}; + +const { translateMetadataString } = metadataTranslationMixin.methods; + +const categoriesLookup = {}; +Object.entries(Categories).forEach(([key, value]) => { + const newKey = translateMetadataString(camelCase(key)); + categoriesLookup[newKey] = value; +}); + +const getOptionsValues = wrapper => { + const categories = {}; + const checkboxes = wrapper.findAll('[data-test="option-checkbox"]'); + checkboxes.wrappers.forEach(checkbox => { + const { label, checked, indeterminate } = checkbox.vm.$props || {}; + let value; + if (indeterminate) { + value = CheckboxValue.INDETERMINATE; + } else if (checked) { + value = CheckboxValue.CHECKED; + } else { + value = CheckboxValue.UNCHECKED; + } + categories[categoriesLookup[label]] = value; + }); + return categories; +}; + +const findOptionCheckbox = (wrapper, category) => { + const checkboxes = wrapper.findAll('[data-test="option-checkbox"]'); + return checkboxes.wrappers.find(checkbox => { + const { label } = checkbox.vm.$props || {}; + return categoriesLookup[label] === category; + }); +}; + +const options = Object.entries(Categories).map(([key, value]) => { + return { + label: key, + value, + }; +}); +const makeWrapper = ({ nodeIds, field = 'categories', ...restOptions }) => { + return mount(EditBooleanMapModal, { + store, + propsData: { + nodeIds, + options, + title: 'Edit Categories', + field, + autocompleteLabel: 'Select option', + confirmationMessage: 'edited', + resourcesSelectedText: '2 resources', + ...restOptions, + }, + scopedSlots: { + input: function(props) { + return this.$createElement(CategoryOptions, { + props: { + ...props, + expanded: true, + hideLabel: true, + nodeIds, + }, + }); + }, + }, + }); +}; + +describe('EditBooleanMapModal', () => { + beforeEach(() => { + nodes = { + node1: { id: 'node1' }, + node2: { id: 'node2' }, + node3: { id: 'node3' }, + node4: { id: 'node4' }, + }; + contentNodeActions = { + updateContentNode: jest.fn(), + updateContentNodeDescendants: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => ids.map(id => nodes[id]), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected options on first render', () => { + describe('Options checkboxes', () => { + test('no option should be selected if a single node does not have options set', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + const optionsValues = getOptionsValues(wrapper); + expect( + Object.values(optionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + }); + + test('no option should be selected if multiple nodes dont have options set', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const optionsValues = getOptionsValues(wrapper); + expect( + Object.values(optionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + }); + + test('checkbox options should be selected depending on the options set for a single node - categories', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.FOUNDATIONS]: foundationsValue, + ...otheroptionsValues + } = optionsValues; + expect( + Object.values(otheroptionsValues).every(value => value === CheckboxValue.UNCHECKED) + ).toBeTruthy(); + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + expect(foundationsValue).toBe(CheckboxValue.CHECKED); + }); + + test('checkbox option should be checked if all nodes have the same option set', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.FOUNDATIONS]: foundationsValue, + } = optionsValues; + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + expect(foundationsValue).toBe(CheckboxValue.CHECKED); + }); + + test('checkbox option should be indeterminate if not all nodes have the same options set', () => { + nodes['node1'].categories = { + [Categories.DAILY_LIFE]: true, + [Categories.FOUNDATIONS]: true, + }; + nodes['node2'].categories = { + [Categories.DAILY_LIFE]: true, + }; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const optionsValues = getOptionsValues(wrapper); + const { + [Categories.DAILY_LIFE]: dailyLifeValue, + [Categories.FOUNDATIONS]: foundationsValue, + } = optionsValues; + expect(dailyLifeValue).toBe(CheckboxValue.CHECKED); + expect(foundationsValue).toBe(CheckboxValue.INDETERMINATE); + }); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should render the message of the number of resources selected - 2', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2', 'node3', 'node4'] }); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2 resources'); + }); + + describe('Submit', () => { + test('should call updateContentNode with the right options on success submit - categories', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'] }); + + const schoolCheckbox = findOptionCheckbox(wrapper, Categories.SCHOOL); + schoolCheckbox.element.click(); + const sociologyCheckbox = findOptionCheckbox(wrapper, Categories.SOCIOLOGY); + sociologyCheckbox.element.click(); + + const animationFrameId = requestAnimationFrame(() => { + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: { + [Categories.SCHOOL]: true, + [Categories.SOCIOLOGY]: true, + }, + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node2', + categories: { + [Categories.SCHOOL]: true, + [Categories.SOCIOLOGY]: true, + }, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a confirmation snackbar on success submit', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); + cancelAnimationFrame(animationFrameId); + }); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper({ nodeIds: ['node1'] }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('cancel'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + describe('topic nodes present', () => { + test('should display the checkbox to apply change to descendants if a topic is present and is descendants updatable', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], isDescendantsUpdatable: true }); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeTruthy(); + }); + + test('should not display the checkbox to apply change to descendants if a topic is not present even though its descendants updatable', () => { + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], isDescendantsUpdatable: true }); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); + }); + + test('should not display the checkbox to apply change to descendants if is not descendants updatable', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1', 'node2'], isDescendantsUpdatable: false }); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); + }); + + test('should call updateContentNode on success submit if the user does not check the update descendants checkbox', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1'], isDescendantsUpdatable: true }); + + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'node1', + categories: {}, + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should call updateContentNodeDescendants on success submit if the user checks the descendants checkbox', () => { + nodes['node1'].kind = ContentKindsNames.TOPIC; + + const wrapper = makeWrapper({ nodeIds: ['node1'], isDescendantsUpdatable: true }); + + wrapper.find('[data-test="update-descendants-checkbox"] input').setChecked(true); + wrapper.find('[data-test="edit-booleanMap-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNodeDescendants).toHaveBeenCalledWith( + expect.anything(), + { + id: 'node1', + categories: {}, + } + ); + cancelAnimationFrame(animationFrameId); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js new file mode 100644 index 0000000000..4a53039d92 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditLanguageModal.spec.js @@ -0,0 +1,258 @@ +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, + resourcesSelectedText: '2 resources', + }, + }); +}; + +describe('EditLanguageModal', () => { + beforeEach(() => { + contentNodeActions = { + updateContentNode: jest.fn(), + updateContentNodeDescendants: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => nodes.filter(node => ids.includes(node.id)), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper(['test-en-res']); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected language on first render', () => { + test('no language should be selected if a single node does not have a language', () => { + const wrapper = makeWrapper(['test-nolang-res']); + + const checkboxes = wrapper.findAll('input[type="radio"]'); + checkboxes.wrappers.forEach(checkbox => { + expect(checkbox.element.checked).toBeFalsy(); + }); + }); + + test('no language should be selected if just a single node among multiple nodes does not have language', () => { + const wrapper = makeWrapper(['test-en-res', 'test-nolang-res']); + + const checkboxes = wrapper.findAll('input[type="radio"]'); + checkboxes.wrappers.forEach(checkbox => { + expect(checkbox.element.checked).toBeFalsy(); + }); + }); + + test('no language should be selected if there are multiple languages set', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const checkboxes = wrapper.findAll('input[type="radio"]'); + checkboxes.wrappers.forEach(checkbox => { + expect(checkbox.element.checked).toBeFalsy(); + }); + }); + + test('the common language should be selected if all nodes have the same language', () => { + const wrapper = makeWrapper(['test-en-res', 'test-en-topic']); + + const checkbox = wrapper.find('input[value="en"]'); + expect(checkbox.element.checked).toBeTruthy(); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should render the message of the number of resources selected - 2', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res', 'test-en-topic', 'test-nolang-res']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2 resources'); + }); + + test('should filter languages options based on search query', () => { + const wrapper = makeWrapper(['test-en-topic']); + + wrapper.find('[data-test="search-input"]').vm.$emit('input', 'es'); + + const optionsList = wrapper.find('[data-test="language-options-list"]'); + const options = optionsList.findAll('input[type="radio"]'); + options.wrappers.forEach(option => { + const language = LanguagesList.find(lang => lang.id === option.element.value); + expect( + language.id.toLowerCase().includes('es') || + language.native_name.toLowerCase().includes('es') || + language.readable_name.toLowerCase().includes('es') + ).toBeTruthy(); + }); + }); + + test('should display information message about different languages if there are multiple languages set', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + expect(wrapper.find('[data-test="different-languages-message"]').exists()).toBeTruthy(); + }); + + test('shouldnt display information message about different languages if only one language is set', () => { + const wrapper = makeWrapper(['test-en-res', 'test-en-topic']); + + expect(wrapper.find('[data-test="different-languages-message"]').exists()).toBeFalsy(); + }); + + test('the submit button should be disabled if no language is selected', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const buttons = wrapper.findAll('button').wrappers; + const submitButton = buttons.find(button => button.text() === 'Save'); + + expect(submitButton.element.disabled).toBeTruthy(); + }); + + test('the submit button should be enabled if a language is selected', () => { + const wrapper = makeWrapper(['test-en-res', 'test-es-res']); + + const buttons = wrapper.findAll('button').wrappers; + const submitButton = buttons.find(button => button.text() === 'Save'); + + wrapper.find('input[value="en"]').setChecked(true); + + expect(submitButton.element.disabled).toBeFalsy(); + }); + + test('should call updateContentNode with the right language on success submit', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('input[value="en"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-es-res', + language: 'en', + }); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-en-res', + language: 'en', + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('input[value="en"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('cancel'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a confirmation snackbar on success submit', () => { + const wrapper = makeWrapper(['test-en-res']); + + wrapper.find('input[value="en"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalled(); + cancelAnimationFrame(animationFrameId); + }); + }); + + describe('topic nodes present', () => { + test('should display the checkbox to apply change to descendants if a topic is present', () => { + const wrapper = makeWrapper(['test-en-topic', 'test-en-res']); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeTruthy(); + }); + + test('should not display the checkbox to apply change to descendants if a topic is not present', () => { + const wrapper = makeWrapper(['test-en-res']); + + expect(wrapper.find('[data-test="update-descendants-checkbox"]').exists()).toBeFalsy(); + }); + + test('should call updateContentNode with the right language on success submit if the user does not check the checkbox', () => { + const wrapper = makeWrapper(['test-en-topic', 'test-en-res']); + + wrapper.find('input[value="es"]').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: 'test-en-topic', + language: 'es', + }); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should call updateContentNodeDescendants with the right language on success submit if the user checks the checkbox', () => { + const wrapper = makeWrapper(['test-en-topic', 'test-en-res']); + + wrapper.find('input[value="es"]').setChecked(true); + wrapper.find('[data-test="update-descendants-checkbox"] input').setChecked(true); + wrapper.find('[data-test="edit-language-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(contentNodeActions.updateContentNodeDescendants).toHaveBeenCalledWith( + expect.anything(), + { + id: 'test-en-topic', + language: 'es', + } + ); + cancelAnimationFrame(animationFrameId); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js new file mode 100644 index 0000000000..ae88d106ef --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditSourceModal.spec.js @@ -0,0 +1,311 @@ +import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import EditSourceModal from '../EditSourceModal'; +import { LicensesList } from 'shared/leUtils/Licenses'; +import { constantsTranslationMixin } from 'shared/mixins'; + +let nodes; + +let store; +let contentNodeActions; +let generalActions; +let generalGetters; + +const MIXED_VALUE = 'Mixed'; + +const getLicenseId = translatedLicense => { + if (translatedLicense === MIXED_VALUE) { + return MIXED_VALUE; + } + const translatedLicenses = LicensesList.reduce((acc, license) => { + const { translateConstant } = constantsTranslationMixin.methods; + acc[translateConstant(license.license_name)] = license.id; + return acc; + }, {}); + return translatedLicenses[translatedLicense]; +}; + +const getSourceValues = wrapper => { + return { + author: wrapper.find('[data-test="author-input"] input').element.value, + provider: wrapper.find('[data-test="provider-input"] input').element.value, + aggregator: wrapper.find('[data-test="aggregator-input"] input').element.value, + license: getLicenseId(wrapper.find('.v-select__selections').element.textContent), + license_description: wrapper.find('.license-description textarea')?.element?.value, + copyright_holder: wrapper.find('[data-test="copyright-holder-input"] input').element.value, + }; +}; + +const makeWrapper = nodeIds => { + return mount(EditSourceModal, { + store, + propsData: { + nodeIds, + resourcesSelectedText: '2 resources', + }, + }); +}; + +describe('EditSourceModal', () => { + beforeEach(() => { + nodes = { + node1: { + id: 'node1', + copyright_holder: 'Test', + }, + node2: { + id: 'node2', + copyright_holder: 'Test', + }, + }; + contentNodeActions = { + updateContentNode: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + generalGetters = { + isAboutLicensesModalOpen: () => false, + }; + store = new Vuex.Store({ + actions: generalActions, + getters: generalGetters, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNodes: () => ids => ids.map(id => nodes[id]), + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = makeWrapper(['node1']); + expect(wrapper.isVueInstance()).toBe(true); + }); + + describe('Selected source on first render', () => { + test('should display the correct source values when one node is selected', () => { + const testValues = { + author: 'Test author', + provider: 'Test provider', + aggregator: 'Test aggregator', + license: 9, + license_description: 'Test license description', + copyright_holder: 'Test copyright', + }; + Object.assign(nodes.node1, testValues); + const wrapper = makeWrapper(['node1']); + + expect(getSourceValues(wrapper)).toEqual(expect.anything(), testValues); + }); + + test('should display the common source values when multiple nodes are selected', () => { + const testValues = { + author: 'Test author', + provider: 'Test provider', + }; + Object.assign(nodes.node1, testValues); + Object.assign(nodes.node2, testValues); + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(getSourceValues(wrapper)).toEqual(expect.anything(), testValues); + }); + + test('should display the mixed value when the selected nodes have different values', () => { + nodes['node1'].author = 'Test author'; + nodes['node2'].author = 'Test author 2'; + + const wrapper = makeWrapper(['node1', 'node2']); + + const sourceValues = getSourceValues(wrapper); + expect(sourceValues.author).toEqual(MIXED_VALUE); + }); + + test('should disable inputs when all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + nodes['node2'].original_source_node_id = 'original_node2'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="author-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="provider-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="aggregator-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="copyright-holder-input"] input').element.disabled).toBe( + true + ); + }); + + test('should show message that source cannot be edited when all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + nodes['node2'].original_source_node_id = 'original_node2'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('.help').text()).toContain('Cannot edit'); + }); + + test('should disable inputs when node has freeze_authoring_data set to true', () => { + nodes['node1'].freeze_authoring_data = true; + + const wrapper = makeWrapper(['node1']); + + expect(wrapper.find('[data-test="author-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="provider-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="aggregator-input"] input').element.disabled).toBe(true); + expect(wrapper.find('[data-test="copyright-holder-input"] input').element.disabled).toBe( + true + ); + }); + + test('should show message that source cannot be edited when node has freeze_authoring_data set to true', () => { + nodes['node1'].freeze_authoring_data = true; + + const wrapper = makeWrapper(['node1']); + + expect(wrapper.find('.help').text()).toContain('Cannot edit'); + }); + + test('should not disable inputs when not all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('[data-test="author-input"] input').element.disabled).toBe(false); + expect(wrapper.find('[data-test="provider-input"] input').element.disabled).toBe(false); + expect(wrapper.find('[data-test="aggregator-input"] input').element.disabled).toBe(false); + expect(wrapper.find('[data-test="copyright-holder-input"] input').element.disabled).toBe( + false + ); + }); + + test('should show a message that edits will be reflected only for local resources if just some nodes are imported, but not all', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + + const wrapper = makeWrapper(['node1', 'node2']); + + expect(wrapper.find('.help').text()).toContain( + 'Edits will be reflected only for local resources' + ); + }); + }); + + describe('On submit', () => { + test('should not call updateContentNode on submit if copyright holder is missing', () => { + nodes['node1'].copyright_holder = ''; + const wrapper = makeWrapper(['node1']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).not.toHaveBeenCalled(); + }); + + test('should call updateContentNode on success submit for all editable nodes', () => { + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalled(); + }); + + test('should call updateContentNode with the correct parameters on success submit for all editable nodes', () => { + nodes['node1'].author = 'Test author'; + const newAuthor = 'new-author'; + + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="author-input"]').vm.$emit('input', newAuthor); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'node1', + author: newAuthor, + }) + ); + }); + + test('should call updateContentNode on submit just for the editable nodes', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledTimes(1); + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'node2', + }) + ); + expect(contentNodeActions.updateContentNode).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'node1', + }) + ); + }); + + test('should not call updateContentNode on submit if all nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + nodes['node2'].original_source_node_id = 'original_node2'; + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).not.toHaveBeenCalled(); + }); + + test('should show a snackbar with the correct number of edited nodes on success submit', () => { + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited attribution for 2 resources' + ); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should show a snack bar with the correct number of edited nodes on success submit if some nodes are imported', () => { + nodes['node1'].original_source_node_id = 'original_node1'; + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited attribution for 1 resource' + ); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on success submit', () => { + const wrapper = makeWrapper(['node1', 'node2']); + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted('close')).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + }); + + test('should render the message of the number of resources selected', () => { + const wrapper = makeWrapper(['node1', 'node2']); + + const resourcesCounter = wrapper.find('[data-test="resources-selected-message"]'); + expect(resourcesCounter.exists()).toBeTruthy(); + expect(resourcesCounter.text()).toContain('2'); + }); + + test('should emit close event on cancel', () => { + const wrapper = makeWrapper(['node1', 'node2']); + + wrapper.find('[data-test="edit-source-modal"]').vm.$emit('cancel'); + expect(wrapper.emitted('close')).toBeTruthy(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js new file mode 100644 index 0000000000..2c20379601 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/__tests__/EditTitleDescriptionModal.spec.js @@ -0,0 +1,190 @@ +import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import EditTitleDescriptionModal from '../EditTitleDescriptionModal.vue'; + +const nodeId = 'test-id'; + +const node = { + id: nodeId, + title: 'test-title', + description: 'test-description', +}; + +let store; +let contentNodeActions; +let generalActions; + +describe('EditTitleDescriptionModal', () => { + beforeEach(() => { + contentNodeActions = { + updateContentNode: jest.fn(), + }; + generalActions = { + showSnackbarSimple: jest.fn(), + }; + store = new Vuex.Store({ + actions: generalActions, + modules: { + contentNode: { + namespaced: true, + actions: contentNodeActions, + getters: { + getContentNode: () => () => node, + }, + }, + }, + }); + }); + + test('smoke test', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + expect(wrapper.isVueInstance()).toBe(true); + }); + + test('should display the correct title and description on first render', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + expect(wrapper.find('[data-test="title-input"]').vm.$props.value).toBe(node.title); + expect(wrapper.find('[data-test="description-input"]').vm.$props.value).toBe(node.description); + }); + + test('should call updateContentNode on success submit', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + expect(contentNodeActions.updateContentNode).toHaveBeenCalled(); + }); + + test('should call updateContentNode with the correct parameters on success submit', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + const newTitle = 'new-title'; + const newDescription = 'new-description'; + wrapper.find('[data-test="title-input"]').vm.$emit('input', 'new-title'); + wrapper.find('[data-test="description-input"]').vm.$emit('input', 'new-description'); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: nodeId, + title: newTitle, + description: newDescription, + }); + }); + + test('should let update even if description is empty', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + const newTitle = 'new-title'; + wrapper.find('[data-test="title-input"]').vm.$emit('input', 'new-title'); + wrapper.find('[data-test="description-input"]').vm.$emit('input', ''); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + expect(contentNodeActions.updateContentNode).toHaveBeenCalledWith(expect.anything(), { + id: nodeId, + title: newTitle, + description: '', + }); + }); + + test('should validate title on blur', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="title-input"]').vm.$emit('input', ''); + wrapper.find('[data-test="title-input"]').vm.$emit('blur'); + + expect(wrapper.find('[data-test="title-input"]').vm.$props.invalidText).toBeTruthy(); + }); + + test('should validate title on submit', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="title-input"]').vm.$emit('input', ''); + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + expect(wrapper.find('[data-test="title-input"]').vm.$props.invalidText).toBeTruthy(); + }); + + test("should show 'Edited title and description' on a snackbar on success submit", () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(generalActions.showSnackbarSimple).toHaveBeenCalledWith( + expect.anything(), + 'Edited title and description' + ); + cancelAnimationFrame(animationFrameId); + }); + }); + + test("should emit 'close' event on success submit", () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('submit'); + + const animationFrameId = requestAnimationFrame(() => { + expect(wrapper.emitted().close).toBeTruthy(); + cancelAnimationFrame(animationFrameId); + }); + }); + + test('should emit close event on cancel', () => { + const wrapper = mount(EditTitleDescriptionModal, { + store, + propsData: { + nodeId, + }, + }); + + wrapper.find('[data-test="edit-title-description-modal"]').vm.$emit('cancel'); + expect(wrapper.emitted().close).toBeTruthy(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue new file mode 100644 index 0000000000..7496cd8a4d --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/QuickEditModal/index.vue @@ -0,0 +1,162 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index 393872f8bb..b2ae91a2ea 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -817,7 +817,7 @@ } }, category(options) { - const ids = Object.keys(options || []); + const ids = Object.keys(options || {}); const matches = Object.keys(Categories) .sort() .filter(k => ids.includes(Categories[k])); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue b/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue index 4b7986a2bc..f72a3f6d0e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/StudioTree/StudioTree.vue @@ -130,6 +130,7 @@ /> - + - - - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue index 192faf6fc1..1dbd45bf0c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue @@ -65,6 +65,7 @@ ref="learning_activities" v-model="contentLearningActivities" :disabled="anyIsTopic" + :nodeIds="nodeIds" @focus="trackClick('Learning activities')" /> @@ -72,6 +73,7 @@ v-if="oneSelected" ref="contentLevel" v-model="contentLevel" + :nodeIds="nodeIds" @focus="trackClick('Levels dropdown')" /> @@ -79,6 +81,7 @@ v-if="oneSelected" ref="resourcesNeeded" v-model="resourcesNeeded" + :nodeIds="nodeIds" @focus="trackClick('What you will need')" /> @@ -111,7 +114,12 @@ - + @@ -142,7 +150,7 @@ v-model="completionAndDuration" :kind="firstNode.kind" :fileDuration="fileDuration" - :required="!anyIsDocument || !allSameKind" + :required="!anyIsDocument" /> @@ -375,20 +383,19 @@ import ContentNodeThumbnail from '../../views/files/thumbnails/ContentNodeThumbnail'; import FileUpload from '../../views/files/FileUpload'; import SubtitlesList from '../../views/files/supplementaryLists/SubtitlesList'; - import { isImportedContent, importedChannelLink } from '../../utils'; + import { isImportedContent, isDisableSourceEdits, importedChannelLink } from '../../utils'; import AccessibilityOptions from './AccessibilityOptions.vue'; - import LevelsOptions from './LevelsOptions.vue'; - import ResourcesNeededOptions from './ResourcesNeededOptions.vue'; - import LearningActivityOptions from './LearningActivityOptions.vue'; - import CategoryOptions from './CategoryOptions.vue'; - import CompletionOptions from './CompletionOptions.vue'; - import FormatPresetsMap, { FormatPresetsNames } from 'shared/leUtils/FormatPresets'; + import LevelsOptions from 'shared/views/contentNodeFields/LevelsOptions'; + import CategoryOptions from 'shared/views/contentNodeFields/CategoryOptions'; + import CompletionOptions from 'shared/views/contentNodeFields/CompletionOptions'; + import LearningActivityOptions from 'shared/views/contentNodeFields/LearningActivityOptions'; + import ResourcesNeededOptions from 'shared/views/contentNodeFields/ResourcesNeededOptions'; import { getTitleValidators, getCopyrightHolderValidators, translateValidator, } from 'shared/utils/validation'; - import { findLicense, memoizeDebounce } from 'shared/utils/helpers'; + import { findLicense, memoizeDebounce, getFileDuration } from 'shared/utils/helpers'; import LanguageDropdown from 'shared/views/LanguageDropdown'; import HelpTooltip from 'shared/views/HelpTooltip'; import LicenseDropdown from 'shared/views/LicenseDropdown'; @@ -436,14 +443,10 @@ } /** - * This function is used to generate getter/setters for new metadata fields that are boolean maps: - * - `grade_levels` (sometimes referred to as `content_levels`) - * - `learner_needs` (resources needed) + * This function is used to generate getter/setters having its value as + * an array for metadata fields that are boolean maps: * - `accessibility_labels` (accessibility options) - * - `learning_activities` (learning activities) - * - `categories` (categories) */ - function generateNestedNodesGetterSetter(key) { return { get() { @@ -477,6 +480,42 @@ }; } + /** + * This function is used to generate getter/setters having its value as + * an object for metadata fields that are boolean maps: + * - `grade_levels` (sometimes referred to as `content_levels`) + * - `learner_needs` (resources needed) + * - `learning_activities` (learning activities) + * - `categories` (categories) + */ + function generateNestedNodesGetterSetterObject(key) { + return { + get() { + const value = {}; + for (const node of this.nodes) { + const diffTrackerNode = this.diffTracker[node.id] || {}; + const currentValue = diffTrackerNode[key] || node[key] || {}; + Object.entries(currentValue).forEach(([option, optionValue]) => { + if (optionValue) { + value[option] = value[option] || []; + value[option].push(node.id); + } + }); + } + return value; + }, + set(value) { + const newMap = {}; + for (const option in value) { + if (value[option].length === this.nodes.length) { + newMap[option] = true; + } + } + this.update({ [key]: newMap }); + }, + }; + } + export default { name: 'DetailsTabView', components: { @@ -586,24 +625,28 @@ role: generateGetterSetter('role_visibility'), language: generateGetterSetter('language'), accessibility: generateNestedNodesGetterSetter('accessibility_labels'), - contentLevel: generateNestedNodesGetterSetter('grade_levels'), - resourcesNeeded: generateNestedNodesGetterSetter('learner_needs'), + contentLevel: generateNestedNodesGetterSetterObject('grade_levels'), + resourcesNeeded: generateNestedNodesGetterSetterObject('learner_needs'), forBeginners: { get() { - return this.resourcesNeeded.includes(ResourcesNeededTypes.FOR_BEGINNERS); + const value = this.resourcesNeeded[ResourcesNeededTypes.FOR_BEGINNERS]; + return value && value.length === this.nodes.length; }, set(value) { if (value) { - this.resourcesNeeded = [...this.resourcesNeeded, ResourcesNeededTypes.FOR_BEGINNERS]; + this.resourcesNeeded = { + ...this.resourcesNeeded, + [ResourcesNeededTypes.FOR_BEGINNERS]: this.nodeIds, + }; } else { - this.resourcesNeeded = this.resourcesNeeded.filter( - r => r !== ResourcesNeededTypes.FOR_BEGINNERS - ); + const newMap = { ...this.resourcesNeeded }; + delete newMap[ResourcesNeededTypes.FOR_BEGINNERS]; + this.resourcesNeeded = newMap; } }, }, - contentLearningActivities: generateNestedNodesGetterSetter('learning_activities'), - categories: generateNestedNodesGetterSetter('categories'), + contentLearningActivities: generateNestedNodesGetterSetterObject('learning_activities'), + categories: generateNestedNodesGetterSetterObject('categories'), license() { return this.getValueFromNodes('license'); }, @@ -660,7 +703,7 @@ return this.nodes.some(node => node.freeze_authoring_data); }, disableSourceEdits() { - return this.disableAuthEdits || this.isImported; + return this.nodes.some(isDisableSourceEdits); }, detectedImportText() { const count = this.nodes.filter(node => node.freeze_authoring_data).length; @@ -694,20 +737,7 @@ return (this.firstNode && this.getContentNodeFiles(this.firstNode.id)) || []; }, fileDuration() { - if ( - this.firstNode.kind === ContentKindsNames.AUDIO || - this.firstNode.kind === ContentKindsNames.VIDEO - ) { - // filter for the correct file types, - // to exclude files such as subtitle or cc - const audioVideoFiles = this.nodeFiles.filter(file => this.allowedFileType(file)); - // return the last item in the array - const file = audioVideoFiles[audioVideoFiles.length - 1]; - if (file) { - return file.duration; - } - } - return null; + return getFileDuration(this.nodeFiles, this.firstNode.kind); }, videoSelected() { return this.oneSelected && this.firstNode.kind === ContentKindsNames.VIDEO; @@ -789,17 +819,6 @@ isUnique(value) { return value !== nonUniqueValue; }, - allowedFileType(file) { - let allowedFileTypes = []; - // add the relevant format presets for audio and video - // high res and low res are currently the same, so only one is included - allowedFileTypes.push( - FormatPresetsMap.get(FormatPresetsNames.HIGH_RES_VIDEO).allowed_formats - ); - allowedFileTypes.push(FormatPresetsMap.get(FormatPresetsNames.AUDIO).allowed_formats); - allowedFileTypes = allowedFileTypes.flat(); - return allowedFileTypes.includes(file.file_format); - }, getValueFromNodes(key) { const results = uniq( this.nodes.map(node => { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 30947603d5..591afec53b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -121,6 +121,9 @@ + @@ -186,6 +189,7 @@ import { fileSizeMixin, routerMixin } from 'shared/mixins'; import FileStorage from 'shared/views/files/FileStorage'; import MessageDialog from 'shared/views/MessageDialog'; + import AboutLicensesModal from 'shared/views/AboutLicensesModal'; import ResizableNavigationDrawer from 'shared/views/ResizableNavigationDrawer'; import Uploader from 'shared/views/files/Uploader'; import LoadingText from 'shared/views/LoadingText'; @@ -215,6 +219,7 @@ SavingIndicator, ToolBar, BottomBar, + AboutLicensesModal, }, mixins: [fileSizeMixin, routerMixin], props: { @@ -247,6 +252,7 @@ }; }, computed: { + ...mapGetters(['isAboutLicensesModalOpen']), ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue deleted file mode 100644 index f3262cebed..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ResourcesNeededOptions.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js deleted file mode 100644 index c8a82a30e8..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/categoryOptions.spec.js +++ /dev/null @@ -1,331 +0,0 @@ -import Vue from 'vue'; -import Vuetify from 'vuetify'; -import { shallowMount } from '@vue/test-utils'; -import CategoryOptions from '../CategoryOptions.vue'; - -Vue.use(Vuetify); - -const testDropdown = [ - { - text: 'DAILY_LIFE', - value: 'PbGoe2MV', - }, - { - text: 'CURRENT_EVENTS', - value: 'PbGoe2MV.J7CU1IxN', - }, - { - text: 'DIVERSITY', - value: 'PbGoe2MV.EHcbjuKq', - }, - { - text: 'ENTREPRENEURSHIP', - value: 'PbGoe2MV.kyxTNsRS', - }, - { - text: 'ENVIRONMENT', - value: 'PbGoe2MV.tS7WKnZ7', - }, - { - text: 'FINANCIAL_LITERACY', - value: 'PbGoe2MV.HGIc9sZq', - }, - { - text: 'MEDIA_LITERACY', - value: 'PbGoe2MV.UOTL#KIV', - }, - { - text: 'MENTAL_HEALTH', - value: 'PbGoe2MV.d8&gCo2N', - }, - { - text: 'PUBLIC_HEALTH', - value: 'PbGoe2MV.kivAZaeX', - }, - { - text: 'FOR_TEACHERS', - value: 'ziJ6PCuU', - }, - { - text: 'GUIDES', - value: 'ziJ6PCuU.RLfhp37t', - }, - { - text: 'LESSON_PLANS', - value: 'ziJ6PCuU.lOBPr5ix', - }, - { - text: 'FOUNDATIONS', - value: 'BCG3&slG', - }, - { - text: 'DIGITAL_LITERACY', - value: 'BCG3&slG.wZ3EAedB', - }, - { - text: 'FOUNDATIONS_LOGIC_AND_CRITICAL_THINKING', - value: 'BCG3&slG.0&d0qTqS', - }, - { - text: 'LEARNING_SKILLS', - value: 'BCG3&slG.fP2j70bj', - }, - { - text: 'LITERACY', - value: 'BCG3&slG.HLo9TbNq', - }, - { - text: 'NUMERACY', - value: 'BCG3&slG.Tsyej9ta', - }, - { - text: 'SCHOOL', - value: 'd&WXdXWF', - }, - { - text: 'ARTS', - value: 'd&WXdXWF.5QAjgfv7', - }, - { - text: 'DANCE', - value: 'd&WXdXWF.5QAjgfv7.BUMJJBnS', - }, - { - text: 'DRAMA', - value: 'd&WXdXWF.5QAjgfv7.XsWznP4o', - }, - { - text: 'MUSIC', - value: 'd&WXdXWF.5QAjgfv7.u0aKjT4i', - }, - { - text: 'VISUAL_ART', - value: 'd&WXdXWF.5QAjgfv7.4LskOFXj', - }, - { - text: 'COMPUTER_SCIENCE', - value: 'd&WXdXWF.e#RTW9E#', - }, - { - text: 'MECHANICAL_ENGINEERING', - value: 'd&WXdXWF.e#RTW9E#.8ZoaPsVW', - }, - { - text: 'PROGRAMMING', - value: 'd&WXdXWF.e#RTW9E#.CfnlTDZ#', - }, - { - text: 'WEB_DESIGN', - value: 'd&WXdXWF.e#RTW9E#.P7s8FxQ8', - }, - { - text: 'HISTORY', - value: 'd&WXdXWF.zWtcJ&F2', - }, - { - text: 'LANGUAGE_LEARNING', - value: 'd&WXdXWF.JDUfJNXc', - }, - { - text: 'MATHEMATICS', - value: 'd&WXdXWF.qs0Xlaxq', - }, - { - text: 'ALGEBRA', - value: 'd&WXdXWF.qs0Xlaxq.0t5msbL5', - }, - { - text: 'ARITHMETIC', - value: 'd&WXdXWF.qs0Xlaxq.nG96nHDc', - }, - { - text: 'CALCULUS', - value: 'd&WXdXWF.qs0Xlaxq.8rJ57ht6', - }, - { - text: 'GEOMETRY', - value: 'd&WXdXWF.qs0Xlaxq.lb7ELcK5', - }, - { - text: 'STATISTICS', - value: 'd&WXdXWF.qs0Xlaxq.jNm15RLB', - }, - { - text: 'READING_AND_WRITING', - value: 'd&WXdXWF.kHKJ&PbV', - }, - { - text: 'LITERATURE', - value: 'd&WXdXWF.kHKJ&PbV.DJLBbaEk', - }, - { - text: 'LOGIC_AND_CRITICAL_THINKING', - value: 'd&WXdXWF.kHKJ&PbV.YMBXStib', - }, - { - text: 'READING_COMPREHENSION', - value: 'd&WXdXWF.kHKJ&PbV.r7RxB#9t', - }, - { - text: 'WRITING', - value: 'd&WXdXWF.kHKJ&PbV.KFJOCr&6', - }, - { - text: 'SCIENCES', - value: 'd&WXdXWF.i1IdaNwr', - }, - { - text: 'ASTRONOMY', - value: 'd&WXdXWF.i1IdaNwr.mjSF4QlF', - }, - { - text: 'BIOLOGY', - value: 'd&WXdXWF.i1IdaNwr.uErN4PdS', - }, - { - text: 'CHEMISTRY', - value: 'd&WXdXWF.i1IdaNwr.#r5ocgid', - }, - { - text: 'EARTH_SCIENCE', - value: 'd&WXdXWF.i1IdaNwr.zbDzxDE7', - }, - { - text: 'PHYSICS', - value: 'd&WXdXWF.i1IdaNwr.r#wbt#jF', - }, - { - text: 'SOCIAL_SCIENCES', - value: 'd&WXdXWF.K80UMYnW', - }, - { - text: 'ANTHROPOLOGY', - value: 'd&WXdXWF.K80UMYnW.ViBlbQR&', - }, - { - text: 'CIVIC_EDUCATION', - value: 'd&WXdXWF.K80UMYnW.F863vKiF', - }, - { - text: 'POLITICAL_SCIENCE', - value: 'd&WXdXWF.K80UMYnW.K72&pITr', - }, - { - text: 'SOCIOLOGY', - value: 'd&WXdXWF.K80UMYnW.75WBu1ZS', - }, - { - text: 'WORK', - value: 'l7DsPDlm', - }, - { - text: 'PROFESSIONAL_SKILLS', - value: 'l7DsPDlm.#N2VymZo', - }, - { - text: 'TECHNICAL_AND_VOCATIONAL_TRAINING', - value: 'l7DsPDlm.ISEXeZt&', - }, - { - text: 'INDUSTRY_AND_SECTOR_SPECIFIC', - value: 'l7DsPDlm.ISEXeZt&.pRvOzJTE', - }, - { - text: 'SKILLS_TRAINING', - value: 'l7DsPDlm.ISEXeZt&.&1WpYE&n', - }, - { - text: 'TOOLS_AND_SOFTWARE_TRAINING', - value: 'l7DsPDlm.ISEXeZt&.1JfIbP&N', - }, -]; - -describe('CategoryOptions', () => { - it('smoke test', () => { - const wrapper = shallowMount(CategoryOptions); - expect(wrapper.isVueInstance()).toBe(true); - }); - it('emits expected data', () => { - const wrapper = shallowMount(CategoryOptions); - const value = 'string'; - wrapper.vm.$emit('input', value); - - expect(wrapper.emitted().input).toBeTruthy(); - expect(wrapper.emitted().input.length).toBe(1); - expect(wrapper.emitted().input[0]).toEqual([value]); - }); - const expectedFamilyTree = [ - { text: 'SCHOOL', value: 'd&WXdXWF' }, - { text: 'ARTS', value: 'd&WXdXWF.5QAjgfv7' }, - { text: 'DANCE', value: 'd&WXdXWF.5QAjgfv7.BUMJJBnS' }, - ]; - - describe('display', () => { - it('has a tooltip that displays the tree for value of an item', () => { - const wrapper = shallowMount(CategoryOptions); - const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; // 'Dance' - const expectedToolTip = 'School - Arts - Dance'; - - expect(wrapper.vm.tooltipHelper(item)).toEqual(expectedToolTip); - }); - it(`dropdown has 'levels' key necessary to display the nested structure of categories`, () => { - const wrapper = shallowMount(CategoryOptions); - const dropdown = wrapper.vm.categoriesList; - const everyCategoryHasLevelsKey = dropdown.every(item => 'level' in item); - - expect(everyCategoryHasLevelsKey).toBeTruthy(); - }); - }); - - describe('nested family structure', () => { - it('can display all the ids of family tree of an item', () => { - const wrapper = shallowMount(CategoryOptions); - const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; //'Dance' - const expectedFamilyTreeIds = expectedFamilyTree.map(item => item.value); - - expect(wrapper.vm.findFamilyTreeIds(item).sort()).toEqual(expectedFamilyTreeIds.sort()); - }); - it('can display array of objects of family tree of an item', () => { - const wrapper = shallowMount(CategoryOptions); - const item = 'd&WXdXWF.5QAjgfv7.BUMJJBnS'; //'Dance' - - expect(wrapper.vm.displayFamilyTree(testDropdown, item)).toEqual(expectedFamilyTree); - }); - }); - - describe('interactions', () => { - it('when user checks an item, that is emitted to the parent component', () => { - const wrapper = shallowMount(CategoryOptions); - const item = 'abcd'; - wrapper.vm.$emit = jest.fn(); - wrapper.vm.add(item); - - expect(wrapper.vm.$emit.mock.calls[0][0]).toBe('input'); - expect(wrapper.vm.$emit.mock.calls[0][1]).toEqual([item]); - }); - it('when user unchecks an item, that is emitted to the parent component', () => { - const wrapper = shallowMount(CategoryOptions); - const item = 'defj'; - wrapper.vm.$emit = jest.fn(); - wrapper.vm.remove(item); - - expect(wrapper.vm.$emit.mock.calls[0][0]).toBe('input'); - expect(wrapper.vm.$emit.mock.calls[0][1]).toEqual([]); - }); - }); - - describe('close button on chip interactions', () => { - it('in the autocomplete bar, the chip is removed when user clicks on its close button', () => { - const wrapper = shallowMount(CategoryOptions, { - data() { - return { selected: ['remove me', 'abc', 'def', 'abc.'] }; - }, - }); - const originalChipsLength = wrapper.vm.selected.length; - wrapper.vm.remove('remove me'); - const chips = wrapper.vm.selected; - - expect(chips.length).toEqual(originalChipsLength - 1); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/resourcesNeededOptions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/resourcesNeededOptions.spec.js deleted file mode 100644 index 0fc09b1598..0000000000 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/__tests__/resourcesNeededOptions.spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import Vue from 'vue'; -import Vuetify from 'vuetify'; -import { shallowMount, mount } from '@vue/test-utils'; -import ResourcesNeededOptions from '../ResourcesNeededOptions.vue'; - -Vue.use(Vuetify); - -function makeWrapper(value) { - return mount(ResourcesNeededOptions, { - propsData: { - value, - }, - }); -} - -describe('ResourcesNeededOptions', () => { - it('smoke test', () => { - const wrapper = shallowMount(ResourcesNeededOptions); - - expect(wrapper.isVueInstance()).toBe(true); - }); - - describe('updating state', () => { - it('should update resources field with new values received from a parent', () => { - const resourcesNeeded = ['person', 'book']; - const wrapper = makeWrapper(resourcesNeeded); - const dropdown = wrapper.find({ name: 'v-select' }); - - expect(dropdown.props('value')).toEqual(resourcesNeeded); - - wrapper.setProps({ - value: ['cat'], - }); - expect(dropdown.props('value')).toEqual(['cat']); - }); - - it('should emit new input values', () => { - const resourcesNeeded = ['person', 'book', 'train']; - const wrapper = makeWrapper([]); - const dropdown = wrapper.find({ name: 'v-select' }); - dropdown.vm.$emit('input', resourcesNeeded); - - return Vue.nextTick().then(() => { - const emittedLevels = wrapper.emitted('input').pop()[0]; - expect(emittedLevels).toEqual(resourcesNeeded); - }); - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue index c4da1b55a4..2ccf76569e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/move/MoveModal.vue @@ -173,6 +173,10 @@ type: Boolean, default: false, }, + clipboardTopicResourceCount: { + type: Object, + default: () => ({}), + }, }, data() { return { @@ -200,7 +204,10 @@ }, }, moveHeader() { - return this.$tr('moveItems', this.getTopicAndResourceCounts(this.moveNodeIds)); + const resourceCounts = Object.keys(this.clipboardTopicResourceCount).length + ? this.clipboardTopicResourceCount + : this.getTopicAndResourceCounts(this.moveNodeIds); + return this.$tr('moveItems', resourceCounts); }, moveHereButtonDisabled() { if (this.moveNodesInProgress) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue index cdd2faccbc..670398ad47 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue @@ -49,7 +49,7 @@ :label="$tr('versionDescriptionLabel')" :invalid="!isDescriptionValid" :invalidText="$tr('descriptionRequiredMessage')" - :showInvalidText="showInvalidText" + :showInvalidText="showDescriptionInvalidText" autofocus textArea /> @@ -57,6 +57,20 @@ + + + + + + + @@ -71,6 +85,7 @@ import { mapActions, mapGetters } from 'vuex'; import HelpTooltip from 'shared/views/HelpTooltip'; import { forceServerSync } from 'shared/data/serverSync'; + import { LanguagesList } from 'shared/leUtils/Languages'; export default { name: 'PublishModal', @@ -88,9 +103,13 @@ step: 0, publishDescription: '', size: 0, - showInvalidText: false, // lazy validation + showDescriptionInvalidText: false, // lazy validation + showLanguageInvalidText: false, loading: false, loadingTaskId: null, + language: {}, + channelLanguages: [], + channelLanguageExists: true, }; }, computed: { @@ -112,9 +131,34 @@ isDescriptionValid() { return this.publishDescription && this.publishDescription.trim(); }, + isLanguageValid() { + return Object.keys(this.language).length > 0; + }, sizeCalculationTask() { return this.loadingTaskId ? this.getAsyncTask(this.loadingTaskId) : null; }, + isCheffedChannel() { + return Boolean(this.currentChannel.ricecooker_version); + }, + isPrivateChannel() { + return this.currentChannel.public; + }, + isFirstPublish() { + return this.currentChannel.version === 0; + }, + defaultLanguage() { + const channelLang = this.filterLanguages(l => l.id === this.currentChannel.language)[0]; + return this.languages.some(lang => lang.value === channelLang.value) ? channelLang : {}; + }, + showLanguageDropdown() { + return ( + ((this.isCheffedChannel || this.isPrivateChannel) && this.isFirstPublish) || + !this.channelLanguageExists + ); + }, + languages() { + return this.filterLanguages(l => this.channelLanguages.includes(l.id)); + }, }, watch: { sizeCalculationTask(task) { @@ -139,16 +183,35 @@ // this.loading = response.stale; // this.loadingTaskId = response.changes.length ? response.changes[0].key : null; // }); + this.channelLanguageExistsInResources().then(exists => { + this.channelLanguageExists = exists; + if (!exists) { + this.getLanguagesInChannelResources().then(languages => { + this.channelLanguages = languages.length ? languages : [this.currentChannel.language]; + this.language = this.defaultLanguage; + }); + } else { + this.channelLanguages = [this.currentChannel.language]; + this.language = this.defaultLanguage; + } + }); }, methods: { - ...mapActions('currentChannel', ['publishChannel']), + ...mapActions('channel', ['updateChannel']), + ...mapActions('currentChannel', [ + 'publishChannel', + 'channelLanguageExistsInResources', + 'getLanguagesInChannelResources', + ]), close() { this.publishDescription = ''; + this.language = this.defaultLanguage; this.dialog = false; }, validate() { - this.showInvalidText = true; - return this.isDescriptionValid; + this.showDescriptionInvalidText = !this.isDescriptionValid; + this.showLanguageInvalidText = !this.isLanguageValid; + return !this.showDescriptionInvalidText && !this.showLanguageInvalidText; }, async handlePublish() { if (this.validate()) { @@ -156,9 +219,20 @@ await forceServerSync(); } - this.publishChannel(this.publishDescription).then(this.close); + this.updateChannel({ + id: this.currentChannel.id, + language: this.language.value, + }).then(() => { + this.publishChannel(this.publishDescription).then(this.close); + }); } }, + filterLanguages(filterFn) { + return LanguagesList.filter(filterFn).map(l => ({ + value: l.id, + label: l.native_name, + })); + }, }, $trs: { // Incomplete channel window @@ -174,8 +248,11 @@ descriptionRequiredMessage: "Please describe what's new in this version before publishing", descriptionDescriptionTooltip: 'This description will be shown to Kolibri admins before they update channel versions', + languageDescriptionTooltip: 'The default language for a channel and its resources', cancelButton: 'Cancel', publishButton: 'Publish', + languageLabel: 'Language', + languageRequiredMessage: 'Please select a language for this channel', }, }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js index 3027fae7a3..1de950ceec 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js @@ -85,17 +85,21 @@ describe('publishModal', () => { }); }); describe('on publish step', () => { + const updateChannel = jest.fn(); const publishChannel = jest.fn(); + beforeEach(() => { wrapper.setData({ step: steps.PUBLISH }); wrapper.setMethods({ - publishChannel: () => { + updateChannel: () => { return new Promise(resolve => { + updateChannel(); publishChannel(); resolve(); }); }, }); + updateChannel.mockReset(); publishChannel.mockReset(); }); it('publish button should trigger form validation', () => { @@ -110,20 +114,26 @@ describe('publishModal', () => { "Please describe what's new in this version before publishing" ); }); - it('publishing should be blocked if no description is given', () => { + it('publishing should be blocked if no description & language are given', () => { wrapper .find('[data-test="confirm-publish-modal"]') .find('form') .trigger('submit'); + expect(updateChannel).not.toHaveBeenCalled(); expect(publishChannel).not.toHaveBeenCalled(); }); - it('publish button should call publishChannel if description is given', () => { + it('publish button should call updateChannel if description and language are given', () => { const description = 'Version notes'; - wrapper.setData({ publishDescription: description }); + const language = { + value: 'en', + label: 'English', + }; + wrapper.setData({ publishDescription: description, language }); wrapper .find('[data-test="confirm-publish-modal"]') .find('form') .trigger('submit'); + expect(updateChannel).toHaveBeenCalled(); expect(publishChannel).toHaveBeenCalled(); }); it('cancel button on publish step should also close modal', () => { diff --git a/contentcuration/contentcuration/frontend/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index 6512e9e9b4..36f29a78fd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -80,3 +80,16 @@ export const DraggableRegions = { * @type {number} */ export const ImportSearchPageSize = 10; + +export const QuickEditModals = { + TITLE_DESCRIPTION: 'TITLE_DESCRIPTION', + TAGS: 'TAGS', + LANGUAGE: 'LANGUAGE', + CATEGORIES: 'CATEGORIES', + LEVELS: 'LEVELS', + LEARNING_ACTIVITIES: 'LEARNING_ACTIVITIES', + SOURCE: 'SOURCE', + AUDIENCE: 'AUDIENCE', + COMPLETION: 'COMPLETION', + WHAT_IS_NEEDED: 'WHAT_IS_NEEDED', +}; diff --git a/contentcuration/contentcuration/frontend/channelEdit/getters.js b/contentcuration/contentcuration/frontend/channelEdit/getters.js index d914622d62..455af2aece 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/getters.js @@ -10,6 +10,10 @@ export function isComfortableViewMode(state) { return viewMode === viewModes.COMFORTABLE; } +export function isAboutLicensesModalOpen(state) { + return state.aboutLicensesModalOpen; +} + // Convenience function to format strings like "Page Name - Channel Name" // for tab titles export function appendChannelName(state, getters) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/mutations.js index 2e67ded644..2bd2a99369 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/mutations.js @@ -5,3 +5,7 @@ export function SET_VIEW_MODE(state, viewMode) { export function SET_VIEW_MODE_OVERRIDES(state, overrides) { state.viewModeOverrides = overrides; } + +export function SET_SHOW_ABOUT_LICENSES(state, isOpen) { + state.aboutLicensesModalOpen = isOpen; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/store.js b/contentcuration/contentcuration/frontend/channelEdit/store.js index 068563d111..39a01b26c8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/store.js +++ b/contentcuration/contentcuration/frontend/channelEdit/store.js @@ -31,6 +31,8 @@ export const STORE_CONFIG = { * to override the current `viewMode`. */ viewModeOverrides: [], + + aboutLicensesModalOpen: false, }; }, actions, diff --git a/contentcuration/contentcuration/frontend/channelEdit/translator.js b/contentcuration/contentcuration/frontend/channelEdit/translator.js index d58efa3f33..9479fbbcec 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/translator.js +++ b/contentcuration/contentcuration/frontend/channelEdit/translator.js @@ -14,6 +14,8 @@ const MESSAGES = { errorMissingAnswer: 'Choose a correct answer', errorChooseAtLeastOneCorrectAnswer: 'Choose at least one correct answer', errorProvideAtLeastOneCorrectAnswer: 'Provide at least one correct answer', + selectionCount: + '{topicCount, plural, =0 {} one {# folder, } other {# folders, }}{resourceCount, plural, one {# resource} other {# resources}}', }; export default createTranslator(NAMESPACE, MESSAGES); diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 5e7b868ca9..0e28ea4075 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -4,77 +4,14 @@ import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { metadataStrings, constantStrings } from 'shared/mixins'; import { + ContentModalities, AssessmentItemTypes, CompletionCriteriaModels, - ContentModalities, SHORT_LONG_ACTIVITY_MIDPOINT, - CompletionDropdownMap, + defaultCompletionCriteriaModels, + defaultCompletionCriteriaThresholds, } from 'shared/constants'; -// The constant mapping below is used to set -// default completion criteria and durations -// both as initial values in the edit modal, and -// to ensure backwards compatibility for contentnodes -// that were added before this was in place -export const defaultCompletionCriteriaModels = { - [ContentKindsNames.VIDEO]: CompletionCriteriaModels.TIME, - [ContentKindsNames.AUDIO]: CompletionCriteriaModels.TIME, - [ContentKindsNames.DOCUMENT]: CompletionCriteriaModels.PAGES, - [ContentKindsNames.H5P]: CompletionCriteriaModels.DETERMINED_BY_RESOURCE, - [ContentKindsNames.HTML5]: CompletionCriteriaModels.APPROX_TIME, - [ContentKindsNames.ZIM]: CompletionCriteriaModels.APPROX_TIME, - [ContentKindsNames.EXERCISE]: CompletionCriteriaModels.MASTERY, -}; - -export const defaultCompletionCriteriaThresholds = { - // Audio and Video threshold defaults are dynamic based - // on the duration of the file itself. - [ContentKindsNames.DOCUMENT]: '100%', - [ContentKindsNames.HTML5]: 300, - // We cannot set an automatic default threshold for exercises. -}; - -export const completionCriteriaToDropdownMap = { - [CompletionCriteriaModels.TIME]: CompletionDropdownMap.completeDuration, - [CompletionCriteriaModels.APPROX_TIME]: CompletionDropdownMap.completeDuration, - [CompletionCriteriaModels.PAGES]: CompletionDropdownMap.allContent, - [CompletionCriteriaModels.DETERMINED_BY_RESOURCE]: CompletionDropdownMap.determinedByResource, - [CompletionCriteriaModels.MASTERY]: CompletionDropdownMap.goal, - [CompletionCriteriaModels.REFERENCE]: CompletionDropdownMap.reference, -}; - -export const CompletionOptionsDropdownMap = { - [ContentKindsNames.DOCUMENT]: [ - CompletionDropdownMap.allContent, - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.EXERCISE]: [CompletionDropdownMap.goal, CompletionDropdownMap.practiceQuiz], - [ContentKindsNames.HTML5]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.determinedByResource, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.ZIM]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.determinedByResource, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.H5P]: [ - CompletionDropdownMap.determinedByResource, - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.VIDEO]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], - [ContentKindsNames.AUDIO]: [ - CompletionDropdownMap.completeDuration, - CompletionDropdownMap.reference, - ], -}; - /** * Get correct answer index/indices out of an array of answer objects. * @param {String} questionType single/multiple selection, true/false, input question @@ -203,6 +140,10 @@ export function isImportedContent(node) { ); } +export function isDisableSourceEdits(node) { + return node.freeze_authoring_data || isImportedContent(node); +} + export function importedChannelLink(node, router) { if (node && isImportedContent(node)) { const channelURI = window.Urls.channel(node.original_channel_id); diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue index b684e67682..d7d842ac9a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/CurrentTopicView.vue @@ -11,7 +11,7 @@ {{ getTitle(item) }} - + - + @@ -28,6 +28,31 @@ + + + + + + + + {{ $tr(mode) }} + + + @@ -37,54 +62,46 @@ v-if="node && node.total_count" v-model="selectAll" :indeterminate="selected.length > 0 && !selectAll" - :label="selected.length ? '' : $tr('selectAllLabel')" + :label="selected.length ? selectionText : $tr('selectAllLabel')" style="font-size: 16px;" /> - -
- - - - - - -
-
+
+ + + + + +
- - - - -
- {{ selectionText }} -
-
- + - + - - - - - - {{ $tr(mode) }} - - - - - - {{ $tr('addTopic') }} @@ -180,7 +169,12 @@ @select="selected = [...selected, $event]" @deselect="selected = selected.filter(id => id !== $event)" @scroll="scroll" - /> + @editTitleDescription="showTitleDescriptionModal" + > + + - + - @@ -242,7 +236,13 @@ import MoveModal from '../components/move/MoveModal'; import ContentNodeOptions from '../components/ContentNodeOptions'; import ResourceDrawer from '../components/ResourceDrawer'; - import { RouteNames, viewModes, DraggableRegions, DraggableUniverses } from '../constants'; + import { + RouteNames, + viewModes, + DraggableRegions, + DraggableUniverses, + QuickEditModals, + } from '../constants'; import NodePanel from './NodePanel'; import ToolBar from 'shared/views/ToolBar'; import Breadcrumbs from 'shared/views/Breadcrumbs'; @@ -287,6 +287,8 @@ loadingAncestors: false, elevated: false, moveModalOpen: false, + breadcrumbsMenu: false, + resourceDrawerMenu: false, }; }, computed: { @@ -305,7 +307,7 @@ 'getContentNode', 'getContentNodes', 'getContentNodeAncestors', - 'getTopicAndResourceCounts', + 'getSelectedTopicAndResourceCountText', 'getContentNodeChildren', 'isNodeInCopyingState', ]), @@ -338,12 +340,139 @@ } }, }, + commandPaletteOptions() { + const groupedOptions = [ + [ + { + label: this.$tr('editSelectedButton'), + icon: 'edit', + onClick: () => this.editNodes(this.selected), + condition: this.canEdit, + dataTest: 'edit-selected-btn', + }, + { + label: this.$tr('moveSelectedButton'), + icon: 'move', + onClick: () => this.openMoveModal(), + condition: this.canEdit, + dataTest: 'move-selected-btn', + }, + { + label: this.$tr('copySelectedButton'), + icon: 'clipboard', + onClick: () => this.copyToClipboard(this.selected), + condition: true, + dataTest: 'copy-selected-to-clipboard-btn', + }, + { + label: this.$tr('duplicateSelectedButton'), + icon: 'copy', + onClick: () => this.duplicateNodes(this.selected), + condition: this.canEdit, + dataTest: 'duplicate-selected-btn', + }, + { + label: this.$tr('sortAlphabetically'), + icon: 'sort', + onClick: () => this.sortNodes(this.selected), + condition: this.canEdit, + dataTest: 'sort-selected-btn', + }, + { + label: this.$tr('deleteSelectedButton'), + icon: 'remove', + onClick: () => this.removeNodes(this.selected), + condition: this.canEdit, + dataTest: 'delete-selected-btn', + }, + ], + [ + { + label: this.$tr('editLanguageButton'), + icon: 'language', + onClick: this.quickEditModalFactory(QuickEditModals.LANGUAGE), + condition: this.canEdit, + dataTest: 'change-langugage-btn', + }, + ], + [ + { + label: this.$tr('editCategoriesButton'), + icon: 'categories', + onClick: this.quickEditModalFactory(QuickEditModals.CATEGORIES), + condition: this.canEdit, + dataTest: 'change-categories-btn', + }, + { + label: this.$tr('editLevelsButton'), + icon: 'levels', + onClick: this.quickEditModalFactory(QuickEditModals.LEVELS), + condition: this.canEdit, + dataTest: 'change-levels-btn', + }, + { + label: this.$tr('editLearningActivitiesButton'), + icon: 'activities', + onClick: this.quickEditModalFactory(QuickEditModals.LEARNING_ACTIVITIES), + condition: this.canEdit && this.isResourceSelected, + disabled: this.isTopicSelected, + dataTest: 'change-learning-activities-btn', + }, + ], + [ + { + label: this.$tr('editSourceButton'), + icon: 'attribution', + onClick: this.quickEditModalFactory(QuickEditModals.SOURCE), + condition: this.canEdit && this.isResourceSelected, + disabled: this.isTopicSelected, + dataTest: 'change-learning-activities-btn', + }, + { + label: this.$tr('editAudienceButton'), + icon: 'audience', + onClick: this.quickEditModalFactory(QuickEditModals.AUDIENCE), + condition: this.canEdit && this.isResourceSelected, + disabled: this.isTopicSelected, + dataTest: 'change-audience-btn', + }, + { + label: this.$tr('editWhatIsNeededButton'), + icon: 'lesson', + onClick: this.quickEditModalFactory(QuickEditModals.WHAT_IS_NEEDED), + condition: this.canEdit, + dataTest: 'change-resources-neded-btn', + }, + ], + ]; + + const filteredOptions = groupedOptions + .filter(group => group.some(option => option.condition)) + .map(group => group.filter(option => option.condition)); + + // Flatten the array with a divider between each group + return filteredOptions.reduce((acc, group, index) => { + if (index > 0) { + acc.push({ type: 'divider' }); + } + return acc.concat(group); + }, []); + }, height() { return this.hasStagingTree ? 'calc(100vh - 224px)' : 'calc(100vh - 160px)'; }, node() { return this.getContentNode(this.topicId); }, + selectedNodes() { + return this.getContentNodes(this.selected); + }, + isTopicSelected() { + return this.selectedNodes.some(node => node.kind === ContentKindsNames.TOPIC); + }, + isResourceSelected() { + return this.selectedNodes.some(node => node.kind !== ContentKindsNames.TOPIC); + }, ancestors() { return this.getContentNodeAncestors(this.topicId, true).map(ancestor => { return { @@ -372,7 +501,7 @@ }; }, selectionText() { - return this.$tr('selectionCount', this.getTopicAndResourceCounts(this.selected)); + return this.getSelectedTopicAndResourceCountText(this.selected); }, draggableId() { return DraggableRegions.TOPIC_VIEW; @@ -389,6 +518,13 @@ ? DropEffect.COPY : DropEffect.MOVE; }, + dividerStyle() { + return { + height: '100%', + backgroundColor: this.$themeTokens.fineLine, + width: '1px', + }; + }, }, watch: { topicId: { @@ -436,6 +572,7 @@ 'moveContentNodes', 'copyContentNode', 'waitForCopyingStatus', + 'setQuickEditModal', ]), ...mapActions('clipboard', ['copyAll']), clearSelections() { @@ -524,6 +661,16 @@ }, }); }, + quickEditModalFactory(modal) { + return () => { + this.setQuickEditModal({ + modal, + nodeIds: this.selected, + }); + const trackActionLabel = modal.replace(/_/g, ' ').toLowerCase(); + this.trackClickEvent(`Edit ${trackActionLabel}`); + }; + }, treeLink(params) { return { name: RouteNames.TREE_VIEW, @@ -748,15 +895,27 @@ trackViewMode(mode) { this.$analytics.trackAction('general', mode); }, + showTitleDescriptionModal(nodeId) { + this.editTitleDescriptionModal = { + nodeId, + }; + }, }, $trs: { addTopic: 'New folder', - SortAlphabetically: 'Sort alphabetically', + sortAlphabetically: 'Sort alphabetically', addExercise: 'New exercise', uploadFiles: 'Upload files', importFromChannels: 'Import from channels', addButton: 'Add', editButton: 'Edit', + editSourceButton: 'Edit Source', + editLevelsButton: 'Edit Levels', + editLanguageButton: 'Edit Language', + editAudienceButton: 'Edit Audience', + editCategoriesButton: 'Edit Categories', + editWhatIsNeededButton: "Edit 'What is needed'", + editLearningActivitiesButton: 'Edit Learning Activity', optionsButton: 'Options', copyToClipboardButton: 'Copy to clipboard', [viewModes.DEFAULT]: 'Default view', @@ -767,8 +926,6 @@ moveSelectedButton: 'Move', duplicateSelectedButton: 'Make a copy', deleteSelectedButton: 'Delete', - selectionCount: - '{topicCount, plural,\n =1 {# folder}\n other {# folders}}, {resourceCount, plural,\n =1 {# resource}\n other {# resources}}', undo: 'Undo', creatingCopies: 'Copying...', copiedItems: 'Copy operation complete', @@ -794,4 +951,18 @@ .fade-transition-leave-active { transition-duration: 0.1s } + + .divider-wrapper { + padding: 8px 12px; + align-self: stretch; + } + .command-palette-wrapper { + min-width: 0; + flex-grow: 1; + padding-right: 4px; + } + .no-shrink { + flex-shrink: 0; + } + diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue index aca3ee7e5e..8572c8eb51 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/NodePanel.vue @@ -41,9 +41,11 @@ @infoClick="goToNodeDetail(child.id)" @topicChevronClick="goToTopic(child.id)" @dblclick.native="onNodeDoubleClick(child)" + @editTitleDescription="$emit('editTitleDescription', child.id)" /> + diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue index 61c0bb34ac..b2c3825799 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue @@ -221,6 +221,10 @@ :channel="currentChannel" @syncing="syncInProgress" /> + + !state.connection.online, }), + ...mapGetters(['isAboutLicensesModalOpen']), ...mapGetters('contentNode', ['getContentNode']), ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'canManage', 'rootId']), rootNode() { diff --git a/contentcuration/contentcuration/frontend/channelEdit/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 })), }, }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue index 09d63289b1..6c33b6aa9d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/index.vue @@ -136,6 +136,13 @@ /> + @@ -198,6 +205,8 @@ }, loading: true, listElevated: false, + more: null, + moreLoading: false, }; }, computed: { @@ -291,25 +300,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(() => { @@ -406,6 +420,7 @@ openCurrentLocationButton: 'Expand to current folder location', updatedResourcesReadyForReview: 'Updated resources are ready for review', closeDrawer: 'Close', + showMore: 'Show more', }, }; @@ -445,4 +460,10 @@ } } + .pagination-container { + display: flex; + justify-content: flex-start; + margin: 4px; + } + diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue b/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue index a00c9e2b55..c18a221f1e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/trash/TrashModal.vue @@ -78,7 +78,7 @@ @@ -112,6 +117,11 @@ }, methods: { ...mapActions('channel', ['loadChannelList']), + handleSelectChannel(channelId) { + this.selectedChannels = this.selectedChannels.includes(channelId) + ? this.selectedChannels.filter(id => id !== channelId) + : [...this.selectedChannels, channelId]; + }, }, $trs: { searchText: 'Search for a channel', diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js index efdcbf96f5..b35725f43a 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSelectionList.spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import Vuex from 'vuex'; import ChannelSelectionList from '../ChannelSelectionList'; import { ChannelListTypes } from 'shared/constants'; @@ -25,6 +26,25 @@ const publicChannel = { published: true, }; +const getters = { + channels: jest.fn(() => [editChannel, editChannel2, publicChannel]), + getChannel: jest.fn(() => () => editChannel), +}; + +const actions = { + loadChannelList: jest.fn(() => Promise.resolve()), +}; + +const store = new Vuex.Store({ + modules: { + channel: { + namespaced: true, + getters, + actions, + }, + }, +}); + function makeWrapper() { return mount(ChannelSelectionList, { sync: false, @@ -41,9 +61,7 @@ function makeWrapper() { return Promise.resolve(); }, }, - stubs: { - ChannelItem: true, - }, + store, }); } @@ -77,4 +95,17 @@ describe('channelSelectionList', () => { expect(wrapper.vm.listChannels.find(c => c.id === editChannel.id)).toBeTruthy(); expect(wrapper.vm.listChannels.find(c => c.id === editChannel2.id)).toBeFalsy(); }); + it('should select channels when the channel card has been clicked', () => { + wrapper.setData({ loading: false }); + wrapper.find(`[data-test="channel-item-${editChannel.id}"]`).trigger('click'); + expect(wrapper.emitted('input')[0][0]).toEqual([editChannel.id]); + }); + it('should deselect channels when the channel card has been clicked', () => { + wrapper.setData({ loading: false }); + wrapper.find(`[data-test="channel-item-${editChannel.id}"]`).element.click(); // Check the channel + wrapper.find(`[data-test="channel-item-${editChannel.id}"]`).element.click(); // Uncheck the channel + + expect(wrapper.emitted('input')[0].length).toEqual(1); // Only one event should be emitted (corresponding to the initial check) + expect(wrapper.emitted('input')[0][0]).toEqual([editChannel.id]); // The initial check event should be emitted + }); }); diff --git a/contentcuration/contentcuration/frontend/settings/pages/Account/ChangePasswordForm.vue b/contentcuration/contentcuration/frontend/settings/pages/Account/ChangePasswordForm.vue index 82ed1bcadb..8394fb4e2c 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/Account/ChangePasswordForm.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/Account/ChangePasswordForm.vue @@ -8,9 +8,11 @@ @submit="submitPassword" @cancel="dialog = false" > - + + (value.length >= 8 ? true : this.$tr('passwordValidationMessage'))]; + }, passwordConfirmRules() { return [value => (this.password === value ? true : this.$tr('formInvalidText'))]; }, @@ -86,6 +91,7 @@ newPasswordLabel: 'New password', confirmNewPasswordLabel: 'Confirm new password', formInvalidText: "Passwords don't match", + passwordValidationMessage: 'Password should be at least 8 characters long', cancelAction: 'Cancel', saveChangesAction: 'Save changes', paswordChangeSuccess: 'Password updated', diff --git a/contentcuration/contentcuration/frontend/settings/pages/UsingStudio/index.vue b/contentcuration/contentcuration/frontend/settings/pages/UsingStudio/index.vue index 2ede4e3834..e82a7086ef 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/UsingStudio/index.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/UsingStudio/index.vue @@ -87,21 +87,19 @@ \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/Breadcrumbs.vue b/contentcuration/contentcuration/frontend/shared/views/Breadcrumbs.vue index 16c09d3313..a1fd1959c6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/Breadcrumbs.vue +++ b/contentcuration/contentcuration/frontend/shared/views/Breadcrumbs.vue @@ -119,7 +119,7 @@ /* Truncate text if the last item is too long */ .breadcrumb:last-child { - max-width: calc(100% - 76px); + max-width: calc(100% - 86px); } /deep/ .v-breadcrumbs__item { diff --git a/contentcuration/contentcuration/frontend/shared/views/Divider.vue b/contentcuration/contentcuration/frontend/shared/views/Divider.vue new file mode 100644 index 0000000000..dc0fe1296e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/Divider.vue @@ -0,0 +1,30 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue b/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue index 9004167be9..64b9ac92d8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue +++ b/contentcuration/contentcuration/frontend/shared/views/LicenseDropdown.vue @@ -6,6 +6,7 @@ @@ -65,22 +59,24 @@ - diff --git a/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CategoryOptions.vue b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CategoryOptions.vue new file mode 100644 index 0000000000..032a720873 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CategoryOptions.vue @@ -0,0 +1,320 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CompletionOptions/ActivityDuration.vue similarity index 66% rename from contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue rename to contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CompletionOptions/ActivityDuration.vue index 2afa529798..a3606057b5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/ActivityDuration.vue +++ b/contentcuration/contentcuration/frontend/shared/views/contentNodeFields/CompletionOptions/ActivityDuration.vue @@ -1,15 +1,15 @@