diff --git a/contentcuration/contentcuration/frontend/accounts/components/form/StudioEmailField.vue b/contentcuration/contentcuration/frontend/accounts/components/form/StudioEmailField.vue new file mode 100644 index 0000000000..10b2c82127 --- /dev/null +++ b/contentcuration/contentcuration/frontend/accounts/components/form/StudioEmailField.vue @@ -0,0 +1,64 @@ + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/accounts/components/form/StudioPasswordField.vue b/contentcuration/contentcuration/frontend/accounts/components/form/StudioPasswordField.vue new file mode 100644 index 0000000000..873b84ca11 --- /dev/null +++ b/contentcuration/contentcuration/frontend/accounts/components/form/StudioPasswordField.vue @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/accounts/components/form/__tests__/StudioEmailField.spec.js b/contentcuration/contentcuration/frontend/accounts/components/form/__tests__/StudioEmailField.spec.js new file mode 100644 index 0000000000..5a770a55e1 --- /dev/null +++ b/contentcuration/contentcuration/frontend/accounts/components/form/__tests__/StudioEmailField.spec.js @@ -0,0 +1,66 @@ +import VueRouter from 'vue-router'; +import { render, screen, fireEvent } from '@testing-library/vue'; +import StudioEmailField from '../StudioEmailField.vue'; + +const renderComponent = (props = {}) => + render(StudioEmailField, { + router: new VueRouter(), + props: { + value: '', + ...props, + }, + }); + +describe('StudioEmailField', () => { + describe('rendering', () => { + it('renders with the default "Email address" label', () => { + renderComponent(); + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); + }); + + it('renders with a custom label when provided', () => { + renderComponent({ label: 'Work email' }); + expect(screen.getByLabelText(/work email/i)).toBeInTheDocument(); + }); + + it('is disabled when the disabled prop is true', () => { + renderComponent({ disabled: true }); + expect(screen.getByLabelText(/email address/i)).toBeDisabled(); + }); + }); + + describe('input handling', () => { + it('emits trimmed value — strips leading and trailing whitespace', async () => { + const { emitted } = renderComponent(); + const input = screen.getByLabelText(/email address/i); + await fireEvent.update(input, ' test@example.com '); + expect(emitted().input).toBeTruthy(); + expect(emitted().input[0][0]).toBe('test@example.com'); + }); + + it('emits blur event when the field loses focus', async () => { + const { emitted } = renderComponent(); + const input = screen.getByLabelText(/email address/i); + await fireEvent.blur(input); + expect(emitted().blur).toBeTruthy(); + }); + }); + + describe('error display', () => { + it('shows the first error message when errorMessages is non-empty', () => { + renderComponent({ errorMessages: ['Please enter a valid email address'] }); + expect(screen.getByText('Please enter a valid email address')).toBeVisible(); + }); + + it('shows no error text when errorMessages is empty', () => { + renderComponent({ errorMessages: [] }); + expect(screen.queryByText('Please enter a valid email address')).not.toBeInTheDocument(); + }); + + it('shows only the first error when multiple messages are provided', () => { + renderComponent({ errorMessages: ['First error', 'Second error'] }); + expect(screen.getByText('First error')).toBeVisible(); + expect(screen.queryByText('Second error')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/accounts/components/form/__tests__/StudioPasswordField.spec.js b/contentcuration/contentcuration/frontend/accounts/components/form/__tests__/StudioPasswordField.spec.js new file mode 100644 index 0000000000..fabea6229a --- /dev/null +++ b/contentcuration/contentcuration/frontend/accounts/components/form/__tests__/StudioPasswordField.spec.js @@ -0,0 +1,63 @@ +import VueRouter from 'vue-router'; +import { render, screen, fireEvent } from '@testing-library/vue'; +import StudioPasswordField from '../StudioPasswordField.vue'; + +const renderComponent = (props = {}) => + render(StudioPasswordField, { + router: new VueRouter(), + props: { + value: '', + ...props, + }, + }); + +describe('StudioPasswordField', () => { + describe('rendering', () => { + it('renders with the default "Password" label', () => { + renderComponent(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + }); + + it('renders with a custom label when provided', () => { + renderComponent({ label: 'Confirm password' }); + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); + }); + }); + + describe('input handling', () => { + it('emits raw value without trimming whitespace', async () => { + const { emitted } = renderComponent(); + const input = screen.getByLabelText(/^password$/i); + await fireEvent.update(input, ' mypassword '); + expect(emitted().input).toBeTruthy(); + expect(emitted().input[0][0]).toBe(' mypassword '); + }); + + it('emits blur event when the field loses focus', async () => { + const { emitted } = renderComponent(); + const input = screen.getByLabelText(/^password$/i); + await fireEvent.blur(input); + expect(emitted().blur).toBeTruthy(); + }); + }); + + describe('error display', () => { + it('shows the first error message when errorMessages is non-empty', () => { + renderComponent({ errorMessages: ['Password should be at least 8 characters long'] }); + expect(screen.getByText('Password should be at least 8 characters long')).toBeVisible(); + }); + + it('shows no error text when errorMessages is empty', () => { + renderComponent({ errorMessages: [] }); + expect( + screen.queryByText('Password should be at least 8 characters long'), + ).not.toBeInTheDocument(); + }); + + it('shows only the first error when multiple messages are provided', () => { + renderComponent({ errorMessages: ['First error', 'Second error'] }); + expect(screen.getByText('First error')).toBeVisible(); + expect(screen.queryByText('Second error')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue index 12074ef1ea..442c7d0382 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/Create.vue +++ b/contentcuration/contentcuration/frontend/accounts/pages/Create.vue @@ -1,11 +1,13 @@ - - + + {{ $tr('createAnAccountTitle') }} + + {{ $tr('createAnAccountTitle') }} - - + - {{ registrationFailed ? $tr('registrationFailed') : $tr('errorsMessage') }} - - + {{ $tr('registrationFailedOffline') }} - + - + {{ $tr('basicInformationHeader') }} - - - - - - - {{ $tr('usageLabel') }}* + {{ $tr('usageLabel') }}* - - - + - + + - + + + + {{ fieldRequiredText }} - - {{ $tr('locationLabel') }}* + {{ $tr('locationLabel') }}* + + {{ fieldRequiredText }} + - {{ $tr('sourceLabel') }}* + - {{ $tr('sourceLabel') }}* - - - - - - - + - - - + + + {{ fieldRequiredText }} + - - + - - {{ $tr('ToSRequiredMessage') }} - + {{ $tr('ToSRequiredMessage') }} - + - | - - - - {{ $tr('contactMessage') }} - + + {{ $tr('contactMessage') }} - - + + - + @@ -230,32 +244,24 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { uses, sources } from '../constants'; - import TextField from 'shared/views/form/TextField'; - import EmailField from 'shared/views/form/EmailField'; - import PasswordField from 'shared/views/form/PasswordField'; - import TextArea from 'shared/views/form/TextArea'; + import StudioEmailField from '../components/form/StudioEmailField'; + import StudioPasswordField from '../components/form/StudioPasswordField'; import CountryField from 'shared/views/form/CountryField'; import PolicyModals from 'shared/views/policies/PolicyModals'; - import ImmersiveModalLayout from 'shared/layouts/ImmersiveModalLayout'; - import Banner from 'shared/views/Banner'; - import Checkbox from 'shared/views/form/Checkbox'; + import StudioImmersiveModal from 'shared/views/StudioImmersiveModal'; + import StudioBanner from 'shared/views/StudioBanner'; import { policies } from 'shared/constants'; - import DropdownWrapper from 'shared/views/form/DropdownWrapper'; import commonStrings from 'shared/translator'; export default { name: 'Create', components: { - DropdownWrapper, - ImmersiveModalLayout, - TextField, - EmailField, - PasswordField, - TextArea, + StudioImmersiveModal, + StudioEmailField, + StudioPasswordField, CountryField, PolicyModals, - Banner, - Checkbox, + StudioBanner, }, data() { return { @@ -272,7 +278,7 @@ storage: '', other_use: '', locations: [], - source: '', + source: { value: '', label: '' }, organization: '', conference: '', other_source: '', @@ -284,6 +290,7 @@ last_name: [], email: [], password1: [], + other_use: [], password2: [], }, }; @@ -293,12 +300,6 @@ offline: state => !state.connection.online, }), ...mapGetters('policies', ['getPolicyAcceptedData']), - 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; @@ -345,91 +346,79 @@ }, ]; }, - usageRules() { - /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ - return [() => (this.form.uses.length ? true : commonStrings.$tr('fieldRequired'))]; - }, - locationRules() { - /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ - return [() => (this.form.locations.length ? true : commonStrings.$tr('fieldRequired'))]; - }, sources() { return sources; }, + policies() { + return policies; + }, + fieldRequiredText() { + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + return commonStrings.$tr('fieldRequired'); + }, sourceOptions() { return [ { - id: sources.ORGANIZATION, + value: sources.ORGANIZATION, label: this.$tr('organizationSourceOption'), - additional: { - model: this.form.organization, - label: this.$tr('organizationSourcePlaceholder'), - }, }, { - id: sources.WEBSITE, + value: sources.WEBSITE, label: this.$tr('websiteSourceOption'), }, { - id: sources.NEWSLETTER, + value: sources.NEWSLETTER, label: this.$tr('newsletterSourceOption'), }, { - id: sources.FORUM, + value: sources.FORUM, label: this.$tr('forumSourceOption'), }, { - id: sources.GITHUB, + value: sources.GITHUB, label: this.$tr('githubSourceOption'), }, { - id: sources.SOCIAL_MEDIA, + value: sources.SOCIAL_MEDIA, label: this.$tr('socialMediaSourceOption'), }, { - id: sources.CONFERENCE, + value: sources.CONFERENCE, label: this.$tr('conferenceSourceOption'), - additional: { - model: this.form.conference, - label: this.$tr('conferenceSourcePlaceholder'), - }, }, { - id: sources.CONVERSATION, + value: sources.CONVERSATION, label: this.$tr('conversationSourceOption'), }, { - id: sources.DEMO, + value: sources.DEMO, label: this.$tr('personalDemoSourceOption'), }, { - id: sources.OTHER, + value: sources.OTHER, label: this.$tr('otherSourceOption'), - additional: { - model: this.form.other_source, - label: this.$tr('otherSourcePlaceholder'), - }, }, ]; }, - sourceRules() { - /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ - return [() => (this.form.source.length ? true : commonStrings.$tr('fieldRequired'))]; - }, clean() { return data => { const cleanedData = { ...data, policies: {} }; Object.keys(cleanedData).forEach(key => { - // Trim text fields if (key === 'source') { - if (cleanedData[key] === sources.ORGANIZATION) { + const sourceValue = + cleanedData[key] && cleanedData[key].value != null + ? cleanedData[key].value + : typeof cleanedData[key] === 'string' + ? cleanedData[key] + : ''; + if (sourceValue === sources.ORGANIZATION) { cleanedData[key] = `${cleanedData.organization} (organization)`; - } else if (cleanedData[key] === sources.CONFERENCE) { + } else if (sourceValue === sources.CONFERENCE) { cleanedData[key] = `${cleanedData.conference} (conference)`; - } else if (cleanedData[key] === sources.OTHER) { + } else if (sourceValue === sources.OTHER) { cleanedData[key] = `${cleanedData.other_source} (other)`; } else { - cleanedData[key] = cleanedData[key].trim(); + cleanedData[key] = sourceValue.trim(); } } else if (typeof cleanedData[key] === 'string') { cleanedData[key] = cleanedData[key].trim(); @@ -468,11 +457,16 @@ }, methods: { ...mapActions('account', ['register']), - showTermsOfService() { - this.$router.push({ query: { showPolicy: policies.TERMS_OF_SERVICE } }); + goBack() { + this.$router.push({ name: 'Main' }); }, - showPrivacyPolicy() { - this.$router.push({ query: { showPolicy: policies.PRIVACY } }); + toggleUsage(id) { + const index = this.form.uses.indexOf(id); + if (index > -1) { + this.form.uses.splice(index, 1); + } else { + this.form.uses.push(id); + } }, showStorageField(id) { return id === uses.STORING && this.form.uses.includes(id); @@ -480,10 +474,99 @@ showOtherField(id) { return id === uses.OTHER && this.form.uses.includes(id); }, + // Custom validation is used (not generateFormMixin) because KTextbox requires + // field-specific error message strings via :invalidText, which generateFormMixin + // does not support (it stores only boolean errors per field). + validateField(field) { + switch (field) { + case 'first_name': + if (!this.form.first_name || this.form.first_name.trim() === '') { + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.errors.first_name = [commonStrings.$tr('fieldRequired')]; + } else { + this.errors.first_name = []; + } + break; + case 'last_name': + if (!this.form.last_name || this.form.last_name.trim() === '') { + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.errors.last_name = [commonStrings.$tr('fieldRequired')]; + } else { + this.errors.last_name = []; + } + break; + case 'email': + if (!this.form.email || this.form.email.trim() === '') { + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.errors.email = [commonStrings.$tr('fieldRequired')]; + } else if (!/\S+@\S+\.\S+/.test(this.form.email)) { + this.errors.email = [this.$tr('emailValidationMessage')]; + } else { + this.errors.email = []; + } + break; + case 'password1': + if (!this.form.password1) { + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.errors.password1 = [commonStrings.$tr('fieldRequired')]; + } else if (this.form.password1.length < 8) { + this.errors.password1 = [this.$tr('passwordValidationMessage')]; + } else { + this.errors.password1 = []; + } + break; + case 'password2': + if (!this.form.password2) { + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.errors.password2 = [commonStrings.$tr('fieldRequired')]; + } else if (this.form.password1 !== this.form.password2) { + this.errors.password2 = [this.$tr('passwordMatchMessage')]; + } else { + this.errors.password2 = []; + } + break; + case 'other_use': + if ( + this.form.uses.includes(uses.OTHER) && + (!this.form.other_use || this.form.other_use.trim() === '') + ) { + /* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */ + this.errors.other_use = [commonStrings.$tr('fieldRequired')]; + } else { + this.errors.other_use = []; + } + break; + } + }, + validateForm() { + let isValid = true; + + ['first_name', 'last_name', 'email', 'password1', 'password2', 'other_use'].forEach( + field => { + this.validateField(field); + if (this.errors[field].length > 0) { + isValid = false; + } + }, + ); + + if (!this.form.uses || this.form.uses.length === 0) { + isValid = false; + } + if (!this.form.locations || this.form.locations.length === 0) { + isValid = false; + } + if (!this.form.source || !this.form.source.value) { + isValid = false; + } + + this.valid = isValid; + return isValid; + }, submit() { - // We need to check the "acceptedAgreement" here explicitly because it is not a - // Vuetify form field and does not trigger the form validation. - if (this.$refs.form.validate() && this.acceptedAgreement) { + // acceptedAgreement must be checked explicitly + // here as it is not included in validateForm(). + if (this.validateForm() && this.acceptedAgreement) { // Prevent double submission if (this.submitting) { return Promise.resolve(); @@ -540,7 +623,6 @@ }, $trs: { - backToLoginButton: 'Sign in', createAnAccountTitle: 'Create an account', errorsMessage: 'Please fix the errors below', registrationFailed: 'There was an error registering your account. Please try again', @@ -551,6 +633,7 @@ firstNameLabel: 'First name', lastNameLabel: 'Last name', emailExistsMessage: 'An account with this email already exists', + emailValidationMessage: 'Please enter a valid email address', passwordLabel: 'Password', confirmPasswordLabel: 'Confirm password', passwordMatchMessage: "Passwords don't match", @@ -606,30 +689,71 @@ diff --git a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js index 591c26fb3c..2463700e7e 100644 --- a/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/pages/__tests__/create.spec.js @@ -105,18 +105,16 @@ describe('Create account page', () => { // NOTE: // Full form submission tests are intentionally skipped here. // - // This page still relies on Vuetify components (v-select / v-autocomplete) - // for required fields such as "locations" and "source". - // These components do not reliably update their v-model state when interacted - // with via Vue Testing Library’s userEvent APIs, which prevents a fully - // user-centric submission flow from being exercised. + // The "locations" field uses CountryField, which internally uses VAutocomplete + // (a Vuetify component). VAutocomplete does not reliably update its v-model + // state when interacted with via Vue Testing Library's userEvent APIs, which + // prevents a fully user-centric submission flow from being exercised. // - // The previous Vue Test Utils tests worked around this by directly mutating - // component data (setData), which is intentionally avoided when using - // Testing Library. + // The "source" field has been migrated to KSelect (KDS) and no longer has + // this limitation, but "locations" remains the blocker. // - // These tests will be re-enabled once this page is migrated to the - // Kolibri Design System as part of the Vuetify removal effort . + // These tests will be re-enabled once CountryField is migrated away from + // VAutocomplete as part of the ongoing Vuetify removal effort. it.skip('creates an account when the user submits valid information', async () => { await renderComponent(); @@ -143,7 +141,7 @@ describe('Create account page', () => { }); }); - // Skipped for the same reason as above + // Skipped for the same reason as above — CountryField (VAutocomplete) blocker it.skip('shows an offline error when the user is offline', async () => { await renderComponent({ offline: true });