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 { debounce } from 'lodash';
|
||||||
import React, { useCallback, useMemo, useRef } from 'react';
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useStyles2 } from '../../themes';
|
||||||
import { Field, FieldProps } from '../Forms/Field';
|
import { Field, FieldProps } from '../Forms/Field';
|
||||||
import { InlineToast } from '../InlineToast/InlineToast';
|
import { InlineToast } from '../InlineToast/InlineToast';
|
||||||
|
|
||||||
/**
|
import { EllipsisAnimated } from './EllipsisAnimated';
|
||||||
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';
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SHOW_SUCCESS_DURATION = 2 * 1000;
|
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]);
|
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
|
//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 isInvalid = invalid || fieldState.showError || undefined;
|
||||||
const isLoading = loading || fieldState.isLoading || undefined;
|
|
||||||
/**
|
/**
|
||||||
* use Field around input to pass the error message
|
* use Field around input to pass the error message
|
||||||
* use InlineToast.tsx to show the save message
|
* use InlineToast.tsx to show the save message
|
||||||
*/
|
*/
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Field
|
<Field
|
||||||
{...restProps}
|
{...restProps}
|
||||||
loading={isLoading}
|
loading={loading || undefined}
|
||||||
invalid={isInvalid}
|
invalid={isInvalid}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
error={error || (fieldState.showError && saveErrorMessage)}
|
error={error || (fieldState.showError && saveErrorMessage)}
|
||||||
ref={fieldRef}
|
ref={fieldRef}
|
||||||
|
className={styles.widthFitContent}
|
||||||
>
|
>
|
||||||
{React.cloneElement(
|
{React.cloneElement(
|
||||||
children((newValue) => {
|
children((newValue) => {
|
||||||
@ -109,6 +103,11 @@ export function AutoSaveField<T = string>(props: Props<T>) {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
{fieldState.isLoading && (
|
||||||
|
<InlineToast referenceElement={fieldRef.current} placement="right" alternativePlacement="bottom">
|
||||||
|
Saving <EllipsisAnimated />
|
||||||
|
</InlineToast>
|
||||||
|
)}
|
||||||
{fieldState.showSuccess && (
|
{fieldState.showSuccess && (
|
||||||
<InlineToast
|
<InlineToast
|
||||||
suffixIcon={'check'}
|
suffixIcon={'check'}
|
||||||
@ -124,3 +123,11 @@ export function AutoSaveField<T = string>(props: Props<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AutoSaveField.displayName = 'AutoSaveField';
|
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;
|
suffixIcon?: IconName;
|
||||||
referenceElement: HTMLElement | null;
|
referenceElement: HTMLElement | null;
|
||||||
placement: BasePlacement;
|
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;
|
alternativePlacement?: BasePlacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user