Unverified Commit 1ee42299 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Add the LoadingButton component (#482)

The purpose of the component is to give visual feedback to the user
when a press on a button starts a long-running process
that can succeed or fail.
parent de1964a5
Pipeline #141488 passed with stages
in 10 minutes and 55 seconds
......@@ -23,7 +23,7 @@
.button {
padding: 7px 18px;
border-radius: 3px;
border-radius: 4px;
&:active,
&:focus {
outline: none;
......
/**
* 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, { ReactNode } from 'react';
import classNames from 'classnames';
import { PrimaryButton } from 'src/shared/components/button/Button';
import { CircleLoader } from 'src/shared/components/loader/Loader';
import { ReactComponent as Checkmark } from './checkmark.svg';
import { ReactComponent as Cross } from './cross.svg';
import { LoadingState } from 'src/shared/types/loading-state';
import styles from './LoadingButton.scss';
type Props = {
onClick: () => unknown;
status: LoadingState;
isDisabled?: boolean;
className?: string;
children: ReactNode;
};
const ControlledLoadingButton = (props: Props) => {
const { status: loadingState, className, ...otherProps } = props;
const buttonClass =
loadingState !== LoadingState.NOT_REQUESTED
? classNames(className, styles.invisible)
: className;
return (
<div className={styles.buttonWrapper}>
{loadingState === LoadingState.LOADING && <Loading />}
{loadingState === LoadingState.SUCCESS && <Success />}
{loadingState === LoadingState.ERROR && <ErrorIndicator />}
<PrimaryButton className={buttonClass} {...otherProps} />
</div>
);
};
const Loading = () => (
<div className={styles.loadingIndicator}>
<CircleLoader className={styles.spinner} />
</div>
);
const Success = () => (
<div className={styles.successIndicator}>
<Checkmark className={styles.checkmark} />
</div>
);
const ErrorIndicator = () => (
<div className={styles.errorIndicator}>
<Cross className={styles.cross} />
</div>
);
export default ControlledLoadingButton;
@import 'src/styles/common';
.buttonWrapper {
position: relative;
display: inline-block;
}
.invisible {
visibility: hidden;
}
.loadingIndicator,
.successIndicator,
.errorIndicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
border-radius: 4px;
border: 1px solid $grey;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.spinner {
width: 20px;
height: 20px;
border-width: 2px;
}
.checkmark {
fill: $green;
height: 80%;
animation: checkmark-pulse 0.5s ease-in;
}
.cross {
fill: $red;
height: 55%;
}
@keyframes checkmark-pulse {
0% {
transform: scale(0.6);
}
70% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
/**
* 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, useRef, ReactNode, useEffect } from 'react';
import { of, from, merge, timer, combineLatest, Subscription } from 'rxjs';
import { tap, mergeMap, delay, map, take, catchError } from 'rxjs/operators';
import ControlledLoadingButton from './ControlledLoadingButton';
import { LoadingState } from 'src/shared/types/loading-state';
type LoadingButtonProps = {
onClick: () => Promise<unknown>;
onSuccess?: (x?: unknown) => void;
onError?: (x?: unknown) => void;
isDisabled?: boolean;
className?: string;
children: ReactNode;
};
type RequestHandlerParams = {
loader: () => Promise<unknown>;
onSuccess?: (x?: unknown) => void;
onError?: (x?: unknown) => void;
};
const MINIMUM_SPINNER_TIME = 1000; // 1 second
const COMPLETION_INDICATOR_TIME = 2000; // 2 seconds
const getLoadingState$ = (params: RequestHandlerParams) => {
const loadingStart$ = of(LoadingState.LOADING);
const request$ = combineLatest([
timer(MINIMUM_SPINNER_TIME).pipe(take(1)),
from(params.loader()).pipe(
map((result) => ({
status: LoadingState.SUCCESS as const,
result
})),
catchError((error) =>
of({
status: LoadingState.ERROR as const,
error
})
)
)
]).pipe(
tap(([, response]) => {
if (response.status === LoadingState.SUCCESS) {
params?.onSuccess?.(response.result);
} else {
params?.onError?.(response.error);
}
}),
mergeMap(([, result]) => {
return merge(of(result.status), returnToInitial$);
})
);
const returnToInitial$ = of(LoadingState.NOT_REQUESTED).pipe(
delay(COMPLETION_INDICATOR_TIME)
);
return merge(loadingStart$, request$);
};
const LoadingButton = (props: LoadingButtonProps) => {
const [loadingState, setLoadingState] = useState<LoadingState>(
LoadingState.NOT_REQUESTED
);
const subscriptionRef = useRef<Subscription | null>(null);
const onClick = () => {
const loadingState$ = getLoadingState$({
loader: props.onClick,
onSuccess: props.onSuccess,
onError: props.onError
});
const subscription = loadingState$.subscribe(setLoadingState);
subscriptionRef.current = subscription;
};
useEffect(() => {
return () => subscriptionRef.current?.unsubscribe();
}, []);
return (
<ControlledLoadingButton
status={loadingState}
onClick={onClick}
className={props.className}
>
{props.children}
</ControlledLoadingButton>
);
};
export default LoadingButton;
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="tick" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
<path d="M13.3,26.7L30.6,9.4c0.5-0.5,0.5-1.6,0-2.1l-2.1-2.1c-0.5-0.5-1.6-0.5-2.1,0l-14,14l-7-6.7C5,12,4,12,3.5,12.5l-2.1,2.1
c-0.5,0.5-0.5,1.6,0,2.1l9.8,9.8C11.7,27.3,12.5,27.3,13.3,26.7L13.3,26.7z"/>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="cross" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
<path d="M30.2,5.4L19.8,16l10.6,10.6c0.8,0.8,0.8,1.5,0,2.3l-1.5,1.5c-0.8,0.8-1.5,0.8-2.3,0L16,19.8L5.4,30.4
c-0.8,0.8-1.5,0.8-2.3,0l-1.5-1.5c-0.8-0.8-0.8-1.5,0-2.3L12.2,16L1.8,5.4C1,4.6,1,3.9,1.8,3.1l1.5-1.5c0.8-0.8,1.5-0.8,2.3,0
L16,12.2L26.6,1.6c0.8-0.8,1.5-0.8,2.3,0l1.5,1.5C31,3.9,31,5.1,30.2,5.4L30.2,5.4z"/>
</svg>
/**
* 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.
*/
export { default } from './LoadingButton';
export { default as ControlledLoadingButton } from './ControlledLoadingButton';
@import 'src/styles/common';
.wrapper {
height: 95vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.backgroundComparisonGrid {
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: auto auto;
grid-column-gap: 1rem;
width: 500px;
height: 150px;
}
.columnHeading {
text-align: center;
height: 2rem;
display: flex;
align-items: flex-start;
justify-content: center;
}
.lightContainer,
.darkContainer {
display: flex;
justify-content: center;
align-items: center;
}
.darkContainer {
background: $soft-black;
}
.note {
font-style: italic;
}
.controls {
margin-top: 4em;
input {
margin-left: 1rem;
}
& > div {
display: flex;
align-items: center;
}
}
.responseDurationControl {
margin-bottom: 1rem;
input {
margin-right: 0.6rem;
}
}
.buttonDisableControlWrapper {
white-space: nowrap;
}
/**
* 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, ChangeEvent } from 'react';
import LoadingButton, {
ControlledLoadingButton
} from 'src/shared/components/loading-button';
import RadioGroup, {
OptionValue
} from 'src/shared/components/radio-group/RadioGroup';
import Checkbox from 'src/shared/components/checkbox/Checkbox';
import { LoadingState } from 'src/shared/types/loading-state';
import styles from './LoadingButton.stories.scss';
type DefaultArgs = {
onClick: (...args: any) => void;
onSuccess: (...args: any) => void;
onError: (...args: any) => void;
};
const longTask = (timeout: number, shouldError: boolean) =>
new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldError) {
reject('Error!');
} else {
resolve('task completed');
}
}, timeout);
});
export const LoadingButtonStory = (args: DefaultArgs) => {
const [responseDuration, setResponseDuration] = useState(200);
const [shouldError, setShouldError] = useState(false);
const updateResponseDuration = (e: ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
setResponseDuration(value);
};
const updateResponseError = () => {
setShouldError(!shouldError);
};
return (
<div className={styles.wrapper}>
<div className={styles.backgroundComparisonGrid}>
<div className={styles.columnHeading}>Light background</div>
<div className={styles.columnHeading}>Dark background</div>
<div className={styles.lightContainer}>
<LoadingButton
onClick={() => longTask(responseDuration, shouldError)}
onSuccess={(result: unknown) => args.onSuccess(result)}
onError={(err: unknown) => args.onError(err)}
>
Press me!
</LoadingButton>
</div>
<div className={styles.darkContainer}>
<LoadingButton
onClick={() => longTask(responseDuration, shouldError)}
onSuccess={(result: unknown) => args.onSuccess(result)}
onError={(err: unknown) => args.onError(err)}
>
Press me!
</LoadingButton>
</div>
</div>
<p className={styles.note}>
(note that regardless of the response duration, the spinner will be
shown for at least 1 second)
</p>
<div className={styles.controls}>
<div className={styles.responseDurationControl}>
Response duration:
<input
type="range"
min="50"
max="5000"
step="10"
value={responseDuration}
onChange={updateResponseDuration}
/>
{responseDuration} ms
</div>
<div>
Responds with an error:
<input
type="checkbox"
checked={shouldError}
onChange={updateResponseError}
/>
</div>
</div>
</div>
);
};
LoadingButtonStory.storyName = 'default';
export const ControlledLoadingButtonStory = (args: DefaultArgs) => {
const [buttonStatus, setButtonStatus] = useState<LoadingState>(
LoadingState.NOT_REQUESTED
);
const [isDisabled, setIsDisabled] = useState<boolean>(false);
const buttonStatuses = [
{ value: LoadingState.NOT_REQUESTED, label: 'Initial' },
{ value: LoadingState.LOADING, label: 'Loading' },
{ value: LoadingState.SUCCESS, label: 'Success' },
{ value: LoadingState.ERROR, label: 'Error' }
];
const onStatusChange = (newStatus: OptionValue) => {
setButtonStatus(newStatus as LoadingState);
};
const onDisabledChange = () => {
setIsDisabled(!isDisabled);
};
return (
<div className={styles.wrapper}>
<ControlledLoadingButton
status={buttonStatus}
onClick={args.onClick}
isDisabled={isDisabled}
>
I am button
</ControlledLoadingButton>
<p className={styles.note}>
(notice that this button is controlled entirely from the outside)
</p>
<div className={styles.controls}>
<RadioGroup
options={buttonStatuses}
selectedOption={buttonStatus}
onChange={onStatusChange}
/>
</div>
<div className={styles.buttonDisableControlWrapper}>
<Checkbox
checked={isDisabled}
onChange={onDisabledChange}
label="Disabled"
/>
</div>
</div>
);
};
ControlledLoadingButtonStory.storyName = 'controlled';
export default {
title: 'Components/Shared Components/LoadingButton',
argTypes: {
onClick: { action: 'click' },
onSuccess: { action: 'success' },
onError: { action: 'error' }
}
};
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment