mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Grafana UI: Improve AutoSaveField component (#66751)
This commit is contained in:
parent
cd8f6a59b9
commit
07373705d9
@ -1,20 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Field, FieldProps } from '../Forms/Field';
|
||||
import { InlineToast } from '../InlineToast/InlineToast';
|
||||
|
||||
/**
|
||||
1.- Use the Input component as a base
|
||||
2.- Just save if there is any change and when it loses focus
|
||||
3.- Set the loading to true while the backend is saving
|
||||
4.- Be aware of the backend response. If there is an error show a proper message and return the focus to the input.
|
||||
5.- Add aria-live="polite" and check how it works in a screen-reader.
|
||||
Debounce instead of working with onBlur?
|
||||
import debouncePromise from 'debounce-promise';
|
||||
or
|
||||
import { debounce} from 'lodash';
|
||||
*/
|
||||
import { EllipsisAnimated } from './EllipsisAnimated';
|
||||
|
||||
const SHOW_SUCCESS_DURATION = 2 * 1000;
|
||||
|
||||
@ -88,20 +80,22 @@ export function AutoSaveField<T = string>(props: Props<T>) {
|
||||
const lodashDebounce = useMemo(() => debounce(handleChange, 600, { leading: false }), [handleChange]);
|
||||
//We never want to pass false to field, because it won't be deleted with deleteUndefinedProps() being false
|
||||
const isInvalid = invalid || fieldState.showError || undefined;
|
||||
const isLoading = loading || fieldState.isLoading || undefined;
|
||||
/**
|
||||
* use Field around input to pass the error message
|
||||
* use InlineToast.tsx to show the save message
|
||||
*/
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
{...restProps}
|
||||
loading={isLoading}
|
||||
loading={loading || undefined}
|
||||
invalid={isInvalid}
|
||||
disabled={disabled}
|
||||
error={error || (fieldState.showError && saveErrorMessage)}
|
||||
ref={fieldRef}
|
||||
className={styles.widthFitContent}
|
||||
>
|
||||
{React.cloneElement(
|
||||
children((newValue) => {
|
||||
@ -109,6 +103,11 @@ export function AutoSaveField<T = string>(props: Props<T>) {
|
||||
})
|
||||
)}
|
||||
</Field>
|
||||
{fieldState.isLoading && (
|
||||
<InlineToast referenceElement={fieldRef.current} placement="right" alternativePlacement="bottom">
|
||||
Saving <EllipsisAnimated />
|
||||
</InlineToast>
|
||||
)}
|
||||
{fieldState.showSuccess && (
|
||||
<InlineToast
|
||||
suffixIcon={'check'}
|
||||
@ -124,3 +123,11 @@ export function AutoSaveField<T = string>(props: Props<T>) {
|
||||
}
|
||||
|
||||
AutoSaveField.displayName = 'AutoSaveField';
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
widthFitContent: css({
|
||||
width: 'fit-content',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -0,0 +1,91 @@
|
||||
import { css, keyframes } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
|
||||
export const EllipsisAnimated = React.memo(() => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={styles.ellipsis}>
|
||||
<span className={styles.firstDot}>.</span>
|
||||
<span className={styles.secondDot}>.</span>
|
||||
<span className={styles.thirdDot}>.</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EllipsisAnimated.displayName = 'EllipsisAnimated';
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
ellipsis: css({
|
||||
display: 'inline',
|
||||
}),
|
||||
firstDot: css({
|
||||
animation: `${firstDot} 2s linear infinite`,
|
||||
}),
|
||||
secondDot: css({
|
||||
animation: `${secondDot} 2s linear infinite`,
|
||||
}),
|
||||
thirdDot: css({
|
||||
animation: `${thirdDot} 2s linear infinite`,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const firstDot = keyframes`
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
65% {
|
||||
opacity: 1;
|
||||
}
|
||||
66% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const secondDot = keyframes`
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
21% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
22% {
|
||||
opacity: 1;
|
||||
}
|
||||
65% {
|
||||
opacity: 1;
|
||||
}
|
||||
66% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const thirdDot = keyframes`
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
43% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
44% {
|
||||
opacity: 1;
|
||||
}
|
||||
65% {
|
||||
opacity: 1;
|
||||
}
|
||||
66% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
@ -15,7 +15,7 @@ export interface InlineToastProps {
|
||||
suffixIcon?: IconName;
|
||||
referenceElement: HTMLElement | null;
|
||||
placement: BasePlacement;
|
||||
// Placement to use if there is not enough space to show the full toast with the original placement
|
||||
/** Placement to use if there is not enough space to show the full toast with the original placement*/
|
||||
alternativePlacement?: BasePlacement;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user