Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
}

.message-slot-container {
width: 100%;
max-width: 400px;
margin: 24px auto 0;
text-align: center;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/vue';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import VueRouter from 'vue-router';
import { createLocalVue } from '@vue/test-utils';
import ResetPassword from '../resetPassword/ResetPassword';
import commonStrings from 'shared/translator';
import { createTranslator } from 'shared/i18n';

const { fieldRequired$ } = commonStrings;
const {
passwordLabel$,
passwordConfirmLabel$,
passwordValidationMessage$,
passwordMatchMessage$,
submitButton$,
resetPasswordFailed$,
} = createTranslator(ResetPassword.name, ResetPassword.$trs);

const localVue = createLocalVue();
localVue.use(VueRouter);
Expand All @@ -10,6 +23,7 @@ const setPasswordMock = jest.fn(() => Promise.resolve());

const renderComponent = (queryParams = {}) => {
const router = new VueRouter({
mode: 'abstract',
routes: [
{ path: '/', name: 'Main' },
{ path: '/reset-password', name: 'ResetPassword' },
Expand Down Expand Up @@ -80,4 +94,130 @@ describe('ResetPassword', () => {
expect(router.currentRoute.name).toBe('ResetPasswordSuccess');
});
});

describe('validation feedback', () => {
let user;

beforeEach(() => {
user = userEvent.setup();
});

const newPasswordField = () => screen.getByLabelText(passwordLabel$());
const confirmPasswordField = () => screen.getByLabelText(passwordConfirmLabel$());
// KTextbox doesn't link its error text to the input via ARIA, so scope
// queries to the textbox container that holds both.
const fieldContainer = field => field.closest('.ui-textbox');

it('shows the required error when an empty field loses focus', async () => {
renderComponent();

await user.click(newPasswordField());
await user.tab();

expect(within(fieldContainer(newPasswordField())).getByText(fieldRequired$())).toBeVisible();
expect(
within(fieldContainer(confirmPasswordField())).queryByText(fieldRequired$()),
).not.toBeInTheDocument();
});

it('hides errors while typing and shows them once the field loses focus', async () => {
renderComponent();

await user.type(newPasswordField(), 'short');
expect(screen.queryByText(passwordValidationMessage$())).not.toBeInTheDocument();

await user.tab();
expect(screen.getByText(passwordValidationMessage$())).toBeInTheDocument();
});

it('re-checks the confirmation when the new password changes', async () => {
renderComponent();

await user.type(newPasswordField(), 'abcdefgh');
await user.type(confirmPasswordField(), 'abcdefgh');
await user.tab();
expect(screen.queryByText(passwordMatchMessage$())).not.toBeInTheDocument();

await user.type(newPasswordField(), 'ZZ');
await user.tab();
expect(screen.getByText(passwordMatchMessage$())).toBeInTheDocument();
});

it('clears the mismatch error when the new password is changed to match', async () => {
renderComponent();

await user.type(newPasswordField(), 'abcdefgh');
await user.type(confirmPasswordField(), 'abcdefghZZ');
await user.tab();
expect(screen.getByText(passwordMatchMessage$())).toBeInTheDocument();

await user.type(newPasswordField(), 'ZZ');
expect(screen.queryByText(passwordMatchMessage$())).not.toBeInTheDocument();
});

it('shows the required error on both fields when submitting an empty form', async () => {
renderComponent();

await user.click(screen.getByRole('button', { name: submitButton$() }));

expect(screen.getAllByText(fieldRequired$())).toHaveLength(2);
expect(setPasswordMock).not.toHaveBeenCalled();
});

it('moves focus to the new password field when it is the first invalid field', async () => {
renderComponent();

await user.click(screen.getByRole('button', { name: submitButton$() }));

await waitFor(() => {
expect(newPasswordField()).toHaveFocus();
});
});

it('moves focus to the confirmation field when only it is invalid', async () => {
renderComponent();

await user.type(newPasswordField(), 'abcdefgh');
await user.type(confirmPasswordField(), 'mismatch');
await user.click(screen.getByRole('button', { name: submitButton$() }));

await waitFor(() => {
expect(confirmPasswordField()).toHaveFocus();
});
});
});

it('shows and announces an error banner when resetting the password fails', async () => {
const user = userEvent.setup();
setPasswordMock.mockRejectedValueOnce(new Error('Invalid token'));
renderComponent();

await user.type(screen.getByLabelText(passwordLabel$()), 'validPassword123');
await user.type(screen.getByLabelText(passwordConfirmLabel$()), 'validPassword123');
await user.click(screen.getByRole('button', { name: submitButton$() }));

await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(resetPasswordFailed$());
});
});

it('preserves leading and trailing whitespace in the submitted passwords', async () => {
const user = userEvent.setup();
const PASSWORD_WITH_SPACES = ' spaced password ';
renderComponent();

await user.type(screen.getByLabelText(passwordLabel$()), PASSWORD_WITH_SPACES);
await user.type(screen.getByLabelText(passwordConfirmLabel$()), PASSWORD_WITH_SPACES);
await user.click(screen.getByRole('button', { name: submitButton$() }));

await waitFor(() => {
expect(setPasswordMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
new_password1: PASSWORD_WITH_SPACES,
new_password2: PASSWORD_WITH_SPACES,
}),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,92 +1,153 @@
<template>

<MessageLayout
<StudioMessageLayout
:header="$tr('resetPasswordTitle')"
:text="$tr('resetPasswordPrompt')"
>
<VForm
ref="form"
lazy-validation
@submit.prevent="submit"
<form
class="reset-password-form"
novalidate
@submit.prevent="resetPassword"
>
<Banner
:text="$tr('resetPasswordFailed')"
:value="error"
<StudioBanner
v-if="error"
role="alert"
error
class="mb-4"
/>
<PasswordField
class="banner"
>
{{ $tr('resetPasswordFailed') }}
</StudioBanner>
<StudioPasswordField
ref="new_password1"
v-model="new_password1"
:label="$tr('passwordLabel')"
:additionalRules="passwordValidationRules"
autofocus
:label="$tr('passwordLabel')"
:errorMessages="new_password1Error ? [new_password1Error] : []"
@blur="touched.new_password1 = true"
Comment thread
rtibblesbot marked this conversation as resolved.
/>
<PasswordField
<StudioPasswordField
ref="new_password2"
v-model="new_password2"
:label="$tr('passwordConfirmLabel')"
:additionalRules="passwordConfirmRules"
:errorMessages="new_password2Error ? [new_password2Error] : []"
@blur="touched.new_password2 = true"
/>
<KButton
primary
class="w-100"
:text="$tr('submitButton')"
type="submit"
/>
</VForm>
</MessageLayout>
</form>
</StudioMessageLayout>

</template>


<script>

import { mapActions } from 'vuex';
import MessageLayout from '../../components/MessageLayout';
import PasswordField from 'shared/views/form/PasswordField';
import Banner from 'shared/views/Banner';
import StudioMessageLayout from '../../components/StudioMessageLayout';
import StudioPasswordField from '../../components/form/StudioPasswordField';
import StudioBanner from 'shared/views/StudioBanner';
import commonStrings from 'shared/translator';
import { generateFormMixin } from 'shared/mixins';

const validators = {
new_password1: v => Boolean(v) && v.length >= 8,
new_password2: (v, vm) => Boolean(v) && v === vm.form.new_password1,
};

const formMixin = generateFormMixin({
new_password1: {
required: true,
validator: validators.new_password1,
},
new_password2: {
required: true,
validator: validators.new_password2,
},
});

export default {
name: 'ResetPassword',
components: {
MessageLayout,
PasswordField,
Banner,
StudioMessageLayout,
StudioPasswordField,
StudioBanner,
},
mixins: [formMixin],
data() {
return {
new_password1: '',
new_password2: '',
error: false,
// Gates error display until blur, since formMixin's setters otherwise
Comment thread
rtibblesbot marked this conversation as resolved.
// mark errors on every keystroke.
touched: {
new_password1: false,
new_password2: false,
},
};
},
computed: {
passwordConfirmRules() {
return [value => (this.new_password1 === value ? true : this.$tr('passwordMatchMessage'))];
new_password1Error() {
if (!this.touched.new_password1 || validators.new_password1(this.form.new_password1)) {
return '';
}
if (!this.form.new_password1) {
/* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */
return commonStrings.$tr('fieldRequired');
}
return this.$tr('passwordValidationMessage');
},
passwordValidationRules() {
return [value => (value.length >= 8 ? true : this.$tr('passwordValidationMessage'))];
new_password2Error() {
if (
!this.touched.new_password2 ||
validators.new_password2(this.form.new_password2, this)
) {
return '';
}
if (!this.form.new_password2) {
/* eslint-disable-next-line kolibri/vue-no-undefined-string-uses */
return commonStrings.$tr('fieldRequired');
}
return this.$tr('passwordMatchMessage');
},
},
methods: {
...mapActions('account', ['setPassword']),
submit() {
resetPassword() {
Comment thread
rtibblesbot marked this conversation as resolved.
this.error = false;
if (this.$refs.form.validate()) {
const payload = {
...this.$route.query,
new_password1: this.new_password1,
new_password2: this.new_password2,
};
this.setPassword(payload)
.then(() => {
this.$router.push({
name: 'ResetPasswordSuccess',
});
})
.catch(() => {
this.error = true;
});
this.touched.new_password1 = true;
this.touched.new_password2 = true;

// Validate against this.form rather than formMixin's clean(), which
// trims every field. Passwords must keep the leading/trailing spaces
// the user typed, both here and in the payload below.
if (!this.validate(this.form)) {
Comment thread
rtibblesbot marked this conversation as resolved.
this.focusFirstInvalidField();
return;
}

const payload = {
...this.$route.query,
new_password1: this.form.new_password1,
new_password2: this.form.new_password2,
};
this.setPassword(payload)
.then(() => {
this.$router.push({
name: 'ResetPasswordSuccess',
});
})
.catch(() => {
this.error = true;
});
},
focusFirstInvalidField() {
const firstInvalidField = this.errors.new_password1 ? 'new_password1' : 'new_password2';
this.$nextTick(() => {
this.$refs[firstInvalidField].$el.querySelector('input').focus();
});
},
},
$trs: {
Expand All @@ -106,6 +167,17 @@

<style lang="scss" scoped>

.reset-password-form {
width: 400px;
Comment thread
rtibblesbot marked this conversation as resolved.
max-width: 100%;
text-align: left;
}

.banner {
width: 100%;
margin-bottom: 16px;
}

.w-100 {
width: 100%;
}
Expand Down
Loading