Unverified Commit 9538bd08 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Add submit slider and email validation (#549)

- Add the slider as a challenge for form submission
- Add email validation using browser's inbuilt functionality
parent 8af5b60e
Pipeline #185498 passed with stages
in 6 minutes and 41 seconds
......@@ -40,6 +40,12 @@ $columnGapWidth: 12px;
height: 150px;
}
.emailField {
&:invalid:not(:focus) {
color: $red;
}
}
.upload {
grid-column: right;
margin-top: 18px;
......@@ -51,12 +57,43 @@ $columnGapWidth: 12px;
}
.submit {
display: flex;
display: grid;
grid-template-areas:
'form-errors form-errors'
'submit-left submit-right';
grid-template-columns: 1fr auto;
column-gap: 1.5rem;
align-items: center;
gap: 1rem;
grid-column: right;
justify-self: right;
margin-top: 30px;
.formErrors {
grid-area: form-errors;
&:not(:empty) {
margin-bottom: 12px;
}
}
.sliderLabel {
grid-area: submit-left;
justify-self: end;
&Disabled {
color: $grey;
}
}
.submitSlider {
grid-area: submit-right;
}
}
.submitButton {
grid-column: submit-right;
justify-self: end;
height: 30px;
width: 90px;
}
.errorText {
......
......@@ -14,7 +14,14 @@
* limitations under the License.
*/
import React, { useState, useReducer, useCallback, useRef } from 'react';
import React, {
useState,
useEffect,
useReducer,
useCallback,
useRef
} from 'react';
import classNames from 'classnames';
import noop from 'lodash/noop';
import { submitForm } from '../submitForm';
......@@ -25,6 +32,7 @@ import ShadedTextarea from 'src/shared/components/textarea/ShadedTextarea';
import Upload from 'src/shared/components/upload/Upload';
import UploadedFile from 'src/shared/components/uploaded-file/UploadedFile';
import { PrimaryButton } from 'src/shared/components/button/Button';
import SubmitSlider from '../submit-slider/SubmitSlider';
import commonStyles from '../ContactUsForm.scss';
......@@ -105,16 +113,39 @@ const reducer = (state: State, action: Action): State => {
const ContactUsInitialForm = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const [isChallengeCompleted, setIsChallengeCompleted] = useState(false);
const [emailFieldValid, setEmailFieldValid] = useState(true);
const [emailFieldFocussed, setEmailFieldFocussed] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const emailFieldRef = useRef<HTMLInputElement | null>(null);
const stateRef = useRef<typeof state>();
stateRef.current = state;
useEffect(() => {
// TODO: this useEffect will be unnecessary when the Input is refactored to include forwardRef
const emailInput = formRef.current?.querySelector('#email');
if (emailInput) {
emailFieldRef.current = emailInput as HTMLInputElement;
}
}, [formRef.current]);
const onNameChange = useCallback((value: string) => {
dispatch({ type: 'update-name', payload: value });
}, []);
const onEmailChange = useCallback((value: string) => {
dispatch({ type: 'update-email', payload: value });
validateEmail();
}, []);
const onEmailFocus = useCallback(() => {
setEmailFieldFocussed(true);
}, []);
const onEmailBlur = useCallback(() => {
setEmailFieldFocussed(false);
}, []);
const onSubjectChange = useCallback((value: string) => {
......@@ -131,6 +162,12 @@ const ContactUsInitialForm = () => {
}
}, []);
const validateEmail = useCallback(() => {
if (emailFieldRef.current) {
setEmailFieldValid(emailFieldRef.current?.checkValidity());
}
}, [emailFieldRef.current]);
const deleteFile = (index: number) => {
dispatch({ type: 'remove-file', payload: index });
};
......@@ -141,7 +178,7 @@ const ContactUsInitialForm = () => {
setIsSubmitted(true);
}, []);
const isFormValid = validate(state);
const isFormValid = validate(state) && emailFieldValid;
if (isSubmitted) {
return <SubmissionSuccess />;
......@@ -158,6 +195,7 @@ const ContactUsInitialForm = () => {
</p>
</div>
<form
ref={formRef}
className={commonStyles.grid}
autoComplete="off"
onSubmit={handleSubmit}
......@@ -170,7 +208,15 @@ const ContactUsInitialForm = () => {
<label htmlFor="email" className={commonStyles.label}>
Your email
</label>
<ShadedInput id="email" value={state.email} onChange={onEmailChange} />
<ShadedInput
id="email"
type="email"
className={commonStyles.emailField}
value={state.email}
onChange={onEmailChange}
onFocus={onEmailFocus}
onBlur={onEmailBlur}
/>
<label htmlFor="subject" className={commonStyles.label}>
Subject
......@@ -209,15 +255,46 @@ const ContactUsInitialForm = () => {
</div>
<div className={commonStyles.submit}>
{exceedsAttachmentsSizeLimit(state) && (
<span className={commonStyles.errorText}>
Attachment(s) exceed 10 MB
</span>
{!isFormValid && (
<div className={commonStyles.formErrors}>
{!emailFieldValid && !emailFieldFocussed && (
<div className={commonStyles.errorText}>
Please check the email address
</div>
)}
{exceedsAttachmentsSizeLimit(state) && (
<div className={commonStyles.errorText}>
Attachment(s) exceed 10 MB
</div>
)}
</div>
)}
<PrimaryButton type="submit" isDisabled={!isFormValid} onClick={noop}>
Submit
</PrimaryButton>
{isChallengeCompleted ? (
<PrimaryButton
type="submit"
className={commonStyles.submitButton}
isDisabled={!isFormValid}
onClick={noop}
>
Send
</PrimaryButton>
) : (
<>
<span
className={classNames(commonStyles.sliderLabel, {
[commonStyles.sliderLabelDisabled]: !isFormValid
})}
>
Slide, then send
</span>
<SubmitSlider
className={commonStyles.submitSlider}
isDisabled={!isFormValid}
onSlideCompleted={() => setIsChallengeCompleted(true)}
/>
</>
)}
</div>
</form>
</div>
......
@import 'src/styles/common';
$trackWidth: 220px;
$sliderHeight: 30px;
.container {
width: $trackWidth;
position: relative;
}
.track {
position: relative;
width: $trackWidth;
height: $sliderHeight;
background-color: white;
border: 1px solid $grey;
border-radius: 4px;
z-index: 1;
}
.slider {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 0;
left: 0;
z-index: 2;
width: 90px;
height: 30px;
background-color: $blue;
border-radius: 4px;
cursor: grab;
&Dragged {
cursor: grabbing;
}
&Disabled {
background-color: $grey;
cursor: default;
}
}
.submitButton {
position: absolute;
right: 0;
height: $sliderHeight;
}
.chevron {
fill: white;
}
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useSpring, animated } from 'react-spring';
import classNames from 'classnames';
import clamp from 'lodash/clamp';
import Chevron from 'src/shared/components/chevron/Chevron';
import styles from './SubmitSlider.scss';
type Props = {
isDisabled: boolean;
className?: string;
onSlideCompleted: () => void;
};
const SubmitSlider = (props: Props) => {
const [isDragging, setIsDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const handlePress = () => {
setIsDragging(true);
};
const handleRelease = () => {
setIsDragging(false);
};
const springStyles = useDraggableSlider({
trackRef,
sliderRef,
isDisabled: props.isDisabled,
onRelease: handleRelease,
onSlideCompleted: props.onSlideCompleted
});
const containerClasses = classNames(styles.container, props.className);
const sliderClasses = classNames(styles.slider, {
[styles.sliderDisabled]: props.isDisabled,
[styles.sliderDragged]: isDragging
});
return (
<div className={containerClasses}>
<div className={styles.track} ref={trackRef}></div>
<animated.div
ref={sliderRef}
className={sliderClasses}
style={springStyles}
onMouseDown={handlePress}
onTouchStart={handlePress}
>
<Chevron direction="right" classNames={{ svg: styles.chevron }} />
</animated.div>
</div>
);
};
type UseDraggableSliderParams = {
trackRef: React.RefObject<HTMLDivElement>;
sliderRef: React.RefObject<HTMLDivElement>;
isDisabled: boolean;
onRelease: () => void;
onSlideCompleted: () => void;
};
const useDraggableSlider = (params: UseDraggableSliderParams) => {
const initialPointerX = useRef<number | null>(null);
const sliderRectsRef = useRef<{
trackRect: DOMRect;
sliderRect: DOMRect;
} | null>(null);
const pressRef = useRef(false);
const [springStyles, api] = useSpring(() => ({
config: { clamp: true },
to: {
transform: 'translateX(0)'
}
}));
useEffect(() => {
return () => {
if (pressRef.current) {
// shouldn't happen, but being cautious
removeMovementListeners();
}
};
}, []);
useEffect(() => {
if (params.isDisabled) {
return;
}
params.sliderRef.current?.addEventListener('mousedown', handlePress);
params.sliderRef.current?.addEventListener('touchstart', handlePress);
return () => {
params.sliderRef.current?.removeEventListener('mousedown', handlePress);
params.sliderRef.current?.removeEventListener('touchstart', handlePress);
};
}, [params.isDisabled, params.sliderRef.current]);
const addMovementListeners = useCallback(() => {
window.addEventListener('mousemove', handleDrag);
window.addEventListener('touchmove', handleDrag);
window.addEventListener('mouseup', handleRelease);
window.addEventListener('touchend', handleRelease);
}, []);
const removeMovementListeners = useCallback(() => {
window.removeEventListener('mousemove', handleDrag);
window.removeEventListener('touchmove', handleDrag);
window.removeEventListener('mouseup', handleRelease);
window.removeEventListener('touchend', handleRelease);
}, []);
const handlePress = useCallback((event: TouchEvent | MouseEvent) => {
pressRef.current = true;
recordInitialPointerX(event);
recordSliderRects();
setGrabbingCursor(true);
addMovementListeners();
}, []);
const handleRelease = useCallback(() => {
pressRef.current = false;
updateTranslateX(0);
setGrabbingCursor(false);
removeMovementListeners();
params.onRelease();
}, []);
const handleDrag = useCallback((event: TouchEvent | MouseEvent) => {
const distance = getDistance(event);
if (hasNotReachedTrackEnd(distance)) {
updateTranslateX(distance);
} else {
handleRelease();
params.onSlideCompleted();
}
}, []);
const setGrabbingCursor = (isGrabbing: boolean) => {
if (isGrabbing) {
document.body.style.cursor = 'grabbing';
} else {
document.body.style.cursor = '';
}
};
const recordSliderRects = () => {
const trackRect =
params.trackRef.current?.getBoundingClientRect() as DOMRect;
const sliderRect =
params.sliderRef.current?.getBoundingClientRect() as DOMRect;
sliderRectsRef.current = { trackRect, sliderRect };
};
const recordInitialPointerX = (event: TouchEvent | MouseEvent) => {
initialPointerX.current = getCurrentPointerX(event);
};
const getDistance = (event: TouchEvent | MouseEvent) => {
const currentPointerX = getCurrentPointerX(event);
const trackRect = sliderRectsRef.current?.trackRect as DOMRect;
const sliderRect = sliderRectsRef.current?.sliderRect as DOMRect;
const minValue = 0;
const maxValue = trackRect.width - sliderRect.width;
const currentValue = currentPointerX - (initialPointerX.current as number);
return clamp(minValue, currentValue, maxValue);
};
const hasNotReachedTrackEnd = (distance: number) => {
if (!sliderRectsRef.current) {
return;
}
const { trackRect, sliderRect } = sliderRectsRef.current;
const { right: trackRight } = trackRect;
const { right: initialSliderRight } = sliderRect;
return initialSliderRight + distance < trackRight;
};
const updateTranslateX = (distance: number) => {
api.start({
to: { transform: `translateX(${distance}px)` },
config: { mass: 1, tension: 380, friction: 1, velocity: 0.01 },
immediate: pressRef.current
});
};
const getCurrentPointerX = (
event: TouchEvent | React.TouchEvent | MouseEvent | React.MouseEvent
) => {
if ('touches' in event) {
return event.touches[0].clientX;
} else {
return event.clientX;
}
};
return springStyles;
};
export default SubmitSlider;
......@@ -128,7 +128,6 @@ const SidebarModeToggle = (props: SidebarModeToggleProps) => {
direction={
props.showAction === SidebarModeToggleAction.OPEN ? 'left' : 'right'
}
classNames={{ svg: styles.sidebarModeToggleChevron }}
onClick={props.onClick}
/>
</div>
......
......@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import get from 'lodash/get';
import classNames from 'classnames';
......@@ -81,6 +81,7 @@ export type UploadProps = {
const Upload = (props: UploadProps) => {
const [drag, setDrag] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
......@@ -114,10 +115,14 @@ const Upload = (props: UploadProps) => {
const files: FileList | null =
get(e, 'target.files') || get(e, 'dataTransfer.files') || null;
if (!files?.length) {
return;
if (files?.length) {
await processFiles(files);
}
clearInput();
};
const processFiles = async (files: FileList) => {
if (props.callbackWithFiles) {
// Just pass the first file to the callback if allowMultiple is true
if (!props.allowMultiple) {
......@@ -151,6 +156,13 @@ const Upload = (props: UploadProps) => {
props.onChange(results);
};
const clearInput = () => {
const inputElement = inputRef.current;
if (inputElement) {
inputElement.value = '';
}
};