Skip to content
This repository has been archived by the owner on Jan 18, 2023. It is now read-only.

Feature/password validation #118

Closed
wants to merge 16 commits into from
Closed
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
31 changes: 8 additions & 23 deletions src/components/Objects/Input/Input.jsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,19 @@
import React from "react";

export default function Input({
className,
label,
onValueChange,
errorMessage,
type='text'
}) {

/************************************
* Helper Functions
************************************/

function handleChange(event) {
onValueChange(event.target.value);
}
import React from 'react';

export default function Input({ className, label, onValueChange, errorMessage, type = 'text', ...rest }) {
/************************************
* Render
************************************/

return (
<>
<label className={`${className}-input-container`}>
<div className='label-text-container'>
<p>{label}</p>
{errorMessage && <span>{errorMessage}</span>}
</div>
<input aria-label={`${label}-input`} type={type} onChange={handleChange}/>
<div className='label-text-container'>
<p>{label}</p>
{errorMessage && <span>{errorMessage}</span>}
</div>
<input {...rest} aria-label={`${label}-input`} type={type} onChange={onValueChange} />
</label>
</>
);
};
}
7 changes: 0 additions & 7 deletions src/constants/authentication-constants.js

This file was deleted.

29 changes: 29 additions & 0 deletions src/constants/validation-constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Schema } from '../validation';
will-nemo marked this conversation as resolved.
Show resolved Hide resolved

const EMAIL_PROPERTY = 'email';
const PASSWORD_PROPERTY = 'password';
const REQUIRED_PASSWORD_LENGTH = 8;
const CONFIRMED_PASSWORD_PROPERTY = 'confirmedPassword';

export const LOGIN_CONSTANTS = {
EMAIL_PROPERTY,
PASSWORD_PROPERTY,
EMAIL_SCHEMA: new Schema().isRequired(),
PASSWORD_SCHEMA: new Schema().isRequired()
};

export const REGISTER_CONSTANTS = {
EMAIL_PROPERTY,
PASSWORD_PROPERTY,
REQUIRED_PASSWORD_LENGTH,
CONFIRMED_PASSWORD_PROPERTY,
EMAIL_SCHEMA: new Schema().isEmail().isRequired(),
CONFIRMED_PASSWORD_SCHEMA: new Schema().isRequired().matches(PASSWORD_PROPERTY),
PASSWORD_SCHEMA: new Schema()
.hasDigit()
.hasSymbol()
.isRequired()
.hasLowercase()
.hasUppercase()
.min(REQUIRED_PASSWORD_LENGTH)
};
68 changes: 68 additions & 0 deletions src/hooks/__tests__/useForm.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { renderHook, act } from '@testing-library/react-hooks';

import useForm from '../useForm';
import { Schema } from '../../validation';

const LAST_NAME = 'last';
const FIRST_NAME = 'first';
const EVENT = {
target: { name: FIRST_NAME, value: 'def' },
preventDefault: jest.fn()
};

const VALID_FORM = { [FIRST_NAME]: 'abcd', [LAST_NAME]: 'abcd' };
const DEFAULT_STATE = { [FIRST_NAME]: '', [LAST_NAME]: '' };

const DEFAULT_SCHEMA = {
[FIRST_NAME]: new Schema().min(4).isRequired(),
last: new Schema()
};

describe('useForm hook', () => {
function setup(schema = DEFAULT_SCHEMA, state = DEFAULT_STATE) {
const { result } = renderHook(() => useForm(schema, state));

return result;
}

test('should set no errors by default and form data from state', () => {
const result = setup();

expect(result.current.errors).toStrictEqual({});
expect(result.current.form).toStrictEqual(DEFAULT_STATE);
});

test('should set proper element value', () => {
const result = setup();

act(() => {
result.current.handleInputChange(EVENT);
});

expect(result.current.form[FIRST_NAME]).toBe(EVENT.target.value);
});

test('should call user submit callback when form is valid', () => {
const result = setup(undefined, VALID_FORM);
const submitCallback = jest.fn();

act(() => {
result.current.handleSubmit(submitCallback, EVENT);
});

expect(submitCallback).toHaveBeenCalled();
expect(result.current.errors).toEqual({});
expect(result.current.submitError).toBe('');
});

test('should set submitErrorMessage when error occurs while submitting valid form', () => {
const result = setup(undefined, VALID_FORM);
const submitCallback = {}; // Will throw when called by handleSubmit

act(() => {
result.current.handleSubmit(submitCallback, EVENT);
});

expect(result.current.submitError).not.toBeNull();
});
});
218 changes: 218 additions & 0 deletions src/hooks/useForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useState } from 'react';

import { validate } from '../validation';
import { EMPTY_VALUE } from '../validation/constants';

/**
* The useForm hook return value.
* @typedef {object} UseFormReturnValue
* @property {Object.<string,string>} form - The values of the properties in the form.
* @property {Object.<string,string[]>} errors - The errors in the form.
* @property {string} submitError - The error message when the form fails to submit successfully.
* @property {Function} handleReset - Function to reset the form to its initial state.
* @property {Function} handleSubmit - Function to validate the form and call the user callback.
* @property {Function} handleInputChange - Function to validate changed input and set the state.
*/

/**
* The useForm state.
* @typedef {object} UseFormState
* @property {Object.<string,string>} form - The values of the properties in the form.
* @property {Object.<string,string[]>} errors - The errors in the form.
* @property {string} submitError - The error message when the form fails to submit successfully.
*/

/**
* Handle form functionality.
* @class
* @param {Function} schema - The schema of the form.
* @param {object} [initialFormState=null] - The default values for form elements.
* @returns {UseFormReturnValue} The form, errors and handlers.
*
* @example
* const {
* form,
* errors,
* submitError,
* handleReset,
* handleSubmit,
* handleInputChange
* } = useForm(formSchema);
*/
function useForm(schema, initialFormState = null) {
/************************************
* State
************************************/

const [state, setState] = useState(init(schema, initialFormState));
will-nemo marked this conversation as resolved.
Show resolved Hide resolved

/************************************
* Helper Functions
************************************/

/**
* Handles validating form and calling a user provided function when form is valid.
* @async
* @param {Function} submitForm - The user function to call on submit.
* @param {Event} [event=null] - The form submit event.
* @returns {Promise<void>} Nothing.
*/
async function handleSubmit(submitForm, event = null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this parameter be called submitCallback rather than submitForm. submitForm makes me think of an html element

if (event) event.preventDefault();
will-nemo marked this conversation as resolved.
Show resolved Hide resolved

// Validate and return errors
const { isValid, errors: validationErrors } = validate(state.form, schema);
if (!isValid) {
setState({
...state,
errors: {
...validationErrors
}
});
return;
}

try {
await submitForm();
} catch (error) {
// Note that the state will NOT be set if the error is
// caught inside the callback (submitForm) and not re-thrown
setState({
...state,
submitError: error.message
});
}
}

/**
* Reset the form to its initial value.
* @returns {void} Nothing.
will-nemo marked this conversation as resolved.
Show resolved Hide resolved
*/
function handleReset() {
setState(init(schema, initialFormState));
}

/**
* Set the new value onchange, and validate property or matching properties.
* @param {Event} event - The onChange event.
* @returns {void} Nothing.
*/
function handleInputChange(event) {
const { form } = state;
const { value, name } = event.target;
will-nemo marked this conversation as resolved.
Show resolved Hide resolved

// Ah, the good old days!
setState({
...state,
form: {
...form,
[name]: value
},
errors: {
...validateProperty(name, value)
}
});
}

/**
* Validate one or pair of corresponding properties.
* @param {string} name - The property name.
* @param {string} value - The property value.
* @returns {object} All the errors in the entire form.
*/
function validateProperty(name, value) {
const { errors, form } = state;
let allErrors = {
...errors
};
const matchingProperty = getMatchingProperty(name, schema);

// No matching property, just validate this one property
if (!matchingProperty) {
const { isValid, errors: propertyErrors } = validate(value, schema[name]);

isValid
? delete allErrors[name]
: (allErrors = {
...allErrors,
[name]: propertyErrors
});

return { ...allErrors };
}

// Matching properties present. ex: password & confirm password
const matchingValues = {
[name]: value,
[matchingProperty]: form[matchingProperty]
};

const matchingValuesSchema = {
[name]: schema[name],
[matchingProperty]: schema[matchingProperty]
};

// Clear previous errors on matching properties before
// potentially re-setting them
delete allErrors[name];
delete allErrors[matchingProperty];

const { errors: propertyErrors } = validate(matchingValues, matchingValuesSchema);

return {
...allErrors,
...propertyErrors
};
}

return {
...state,
handleReset,
handleSubmit,
handleInputChange
};
}

/**
* Derive state from the given schema.
* @param {object} schema - The given schema.
* @param {object} initialFormState - The initial values of the form properties.
* @returns {...UseFormState} The useForm initial state.
*/
function init(schema, initialFormState) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like init should be at the top since it's kinda a constructor and that's where i initially looked when i saw 'init'

Copy link
Contributor Author

@Fabricevladimir Fabricevladimir Apr 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. I can move that up. Actually, because init is not part of the useForm function, it can only be moved up to the top of the file. The component is going feel a bit buried, I think. What do you think?

let form = initialFormState;

if (!form) {
form = {};
for (const property in schema) {
if (schema.hasOwnProperty(property)) {
form[property] = EMPTY_VALUE;
}
}
}
return { form, errors: {}, submitError: EMPTY_VALUE };
}

/**
* Get the corresponding property that matches the
* current property being validated.
*
* @param {string} name - The property being validated.
* @param {object} schema - The schema of the entire form.
* @return {string} The name of the matching property.
*/
function getMatchingProperty(name, schema) {
const { matchingProperty } = schema[name];

if (matchingProperty) return matchingProperty;

for (const property in schema) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this for each confuses me, maybe i need to see it in action

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to rename schema to formSchema just to be clearer. It's matching up elements that have the matches option. For example:

const formSchemas = 
{
    pwd: new Schema(), 
    confPwd: new Schema().matches('pwd')
}; 

When confPwd fires an onchange event, validation is going to look at its schema and say ok confPwd matches up to pwd and it just plucks pwd's schema from the formSchemas. But when pwd fires an onchange event, its schema does NOT have a matches property on it.

The loop is going through all the formSchemas checking to see if any singular schema has a matches property whose value is pwd, and in this particular case confPwd's schema does contain exactly that.

By doing this, we're forcing validation for both elements each time one of them changes.

// Don't bother comparing if it's the current property's schema
if (property === name) continue;

// Find and return the matching property
if (schema.hasOwnProperty(property) && schema[property].matchingProperty === name) return property;
}
}

export default useForm;
Loading