mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GLDS: Create AutoSaveField component (#61316)
This commit is contained in:
parent
6309d3fae6
commit
344bbb251c
@ -0,0 +1,283 @@
|
||||
import { Props, Preview, ArgsTable } from '@storybook/addon-docs/blocks';
|
||||
import { AutoSaveField } from './AutoSaveField';
|
||||
import { Basic } from './AutoSaveField.story.tsx';
|
||||
|
||||
# AutoSaveField
|
||||
|
||||
Used for form inputs that should save its content automatically.
|
||||
|
||||
---
|
||||
|
||||
In this documentation you can find:
|
||||
|
||||
1. [Usage](#usage)
|
||||
1. [Variants](#variants)
|
||||
1. [Accessibility](#accessibility)
|
||||
1. [Content](#content)
|
||||
1. [Formatting](#formatting)
|
||||
1. [Behaviours](#behaviours)
|
||||
1. [Props table](#propstable)
|
||||
1. [Related](#related)
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
## <a name="usage"/> Usage
|
||||
|
||||
<br />
|
||||
|
||||
### **Do's**
|
||||
|
||||
- Use in **low-impact** areas within Grafana (examples: team name, display name)
|
||||
- Include helper text to tell users of the new behaviour for recently modified elements
|
||||
|
||||
<br />
|
||||
|
||||
### **Dont's**
|
||||
|
||||
- Do not use if high-impact situations (e.g. saving/changing passwords)
|
||||
<br />
|
||||
<br />
|
||||
|
||||
## <a name="variants"/>Variants
|
||||
|
||||
### 1. Text:
|
||||
|
||||
- The `Input` component allows saving the value of a string.
|
||||
- This is the main component thought to work as an `AutoSaveField` child.
|
||||
|
||||
```jsx
|
||||
<AutoSaveField
|
||||
onFinishChange={customRequest}
|
||||
//Complete field args if needed
|
||||
>
|
||||
{(onChange) => (
|
||||
<Input
|
||||
value={inputTextValue}
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.value);
|
||||
//Complete code if needed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
```
|
||||
|
||||
### 2. TextArea:
|
||||
|
||||
The `TextArea` component allows saving a multi-line text.
|
||||
|
||||
```jsx
|
||||
<AutoSaveField
|
||||
onFinishChange={customRequest}
|
||||
//Complete field args if needed
|
||||
>
|
||||
{(onChange) => (
|
||||
<TextArea
|
||||
value={textAreaValue}
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.value);
|
||||
//Complete code if needed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
```
|
||||
|
||||
### 3. Checkbox:
|
||||
|
||||
The `Checkbox` component allows saving a specific value by ticking or not the checkbox input.
|
||||
|
||||
```jsx
|
||||
<AutoSaveField
|
||||
onFinishChange={customRequest}
|
||||
//Complete field args if needed
|
||||
>
|
||||
{(onChange) => (
|
||||
<Checkbox
|
||||
value={checkBoxTest}
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.value);
|
||||
//Complete code if needed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
```
|
||||
|
||||
### 4. Switch:
|
||||
|
||||
The `Switch` component allows saving a specific value turning the switch input on or off.
|
||||
|
||||
```jsx
|
||||
<AutoSaveField
|
||||
onFinishChange={customRequest}
|
||||
//Complete field args if needed
|
||||
>
|
||||
{(onChange) => (
|
||||
<Switch
|
||||
value={switchTest}
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.value);
|
||||
//Complete code if needed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
```
|
||||
|
||||
### 5. RadioButtonGroup:
|
||||
|
||||
The `RadioButtonGroup` component allows saving a specific value selecting the corresponding `RadioButton`.
|
||||
|
||||
```jsx
|
||||
<AutoSaveField
|
||||
onFinishChange={customRequest}
|
||||
//Complete field args if needed
|
||||
>
|
||||
{(onChange) => (
|
||||
<RadioButtonGroup
|
||||
options={radioButtonOptions}
|
||||
value={currentOption}
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
//Complete code if needed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
```
|
||||
|
||||
### 6. Select:
|
||||
|
||||
The `Select` component allows saving a specific value selected from a list of options.
|
||||
|
||||
```jsx
|
||||
<AutoSaveField
|
||||
onFinishChange={customRequest}
|
||||
//Complete field args if needed
|
||||
>
|
||||
{(onChange) => (
|
||||
<Select
|
||||
loadOptions={optionsList}
|
||||
value={option}
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
//Complete code if needed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
```
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
## <a name="accessibility"/>Accessibility
|
||||
|
||||
`AutoSaveField` has inherited the accessibility properties of both the `Field` and the `Input` component it is comprised of.
|
||||
Apart from that, the `InlineToast`, used to show the success of the request, has an [aria-live](https://www.w3.org/TR/wai-aria/#aria-live) property set to polite, so assistive technologies will notify users of updates but generally do not interrupt the current task.
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
## <a name="content"/>Content
|
||||
|
||||
<br />
|
||||
|
||||
### Main elements
|
||||
|
||||
<br />
|
||||
|
||||
1. **Label:** the AutoSaveField must be accompanied with labels that should be clear and descriptive.
|
||||
2. **Input element:** this component must have an input as a child. There is a list of tested components in the ‘Variants’ section.
|
||||
3. **InlineToast:** when the input value is successfully saved, a toast notification with the text ‘Saved!’ will appear next to the proper field.
|
||||
4. **Error message:** this element will appear when the request fails. This message can be customized by the user or be the response of the request.
|
||||
<br />
|
||||
|
||||
### Optional elements:
|
||||
|
||||
- **Description:** used to inform the user the value of this input will be saved automatically. _This is a tentative requirement until users become familiar with this pattern_
|
||||
<br />
|
||||
<br />
|
||||
|
||||
## <a name="formatting"/>Formatting
|
||||
|
||||
<br />
|
||||
|
||||
### Anatomy
|
||||
|
||||
The `AutoSaveField`component is comprised of:
|
||||
|
||||
- A slot for form elements (e.g text input, checkbox, dropdown etc)
|
||||
- Inline toast notification: used to indicate temporal status near fields/components, such as a Saved! indicator next to a field, or a little Copied! indicator above a button
|
||||
- Inline validation
|
||||
- Helper text: used to inform the user the value of this input will be save automatically
|
||||
- Custom error message
|
||||
|
||||
{' '}
|
||||
|
||||
<Preview>
|
||||
<Basic />
|
||||
</Preview>
|
||||
<br />
|
||||
|
||||
### Placement
|
||||
|
||||
Placement for the `InlineToast` is dependent on the amount space around the field:
|
||||
|
||||
- When there is space, the inline toast notification displays directly to the right of the field it corresponds with.
|
||||
- If there is not enough space (e.g. on mobile) the inline toast notification is displayed beneath the field it corresponds to and overlaps the content below it. As the inline toast only displays for a few seconds, this will have no impact on the legibility of the content beneath it.
|
||||
- If the field errors and the inline validation is displayed, space will be created to persistently hold the inline validation error message until the issue has been addressed and the message goes away.
|
||||
<br />
|
||||
<br />
|
||||
|
||||
## <a name="behaviours"/>Behaviours
|
||||
|
||||
As the data is changed (for instance typing into a text input), the `AutoSaveField` attempts to save that new data, displaying first a spinner to indicate the loading state and then either an `InlineToast` with the text “Saved!” to indicate the data has been successfully saved or an error message to convey that the save has not been successful.
|
||||
|
||||
<br />
|
||||
|
||||
### Common behaviour
|
||||
|
||||
_**What triggers the auto-save action?**_\
|
||||
A change on the input, such as after typing a text or selecting a new option. The request will be triggered after waiting 600 milliseconds from the last change (debouncing).
|
||||
|
||||
_**What can be expected from the request?**_
|
||||
|
||||
1. The request is customized by the user working with the AutoSaveInput.
|
||||
1. This request can finish successfully or with an error:\
|
||||
2.1. **Success:** \
|
||||
2.1.1. An InlineToast with the text ‘Saved!’ will be shown. \
|
||||
2.1.2. It will automatically disappear after a short period of time. \
|
||||
2.1.3. It will place itself on the bottom of the input in case the screen is not wide enough to be placed on its right. \
|
||||
2.2. **Error:** \
|
||||
2.2.1. An error message will be shown. It can be a custom error message or the one from the request response.
|
||||
|
||||
<br />
|
||||
|
||||
### Specific behaviour
|
||||
|
||||
1. **Loading state:** shows a spinner while making the request: \
|
||||
This is specific of the `Input` component
|
||||
1. **Invalid prop:** shows a red border on the input: \
|
||||
This is specific of the `Input`, `TextArea` and `Select` components.
|
||||
<br />
|
||||
<br />
|
||||
|
||||
## <a name="propstable"/>Props table
|
||||
|
||||
<ArgsTable of={AutoSaveField} />
|
||||
|
||||
## <a name="related"/>Related
|
||||
|
||||
Links to related components:
|
||||
|
||||
- [Field](/docs/forms-field--horizontal-layout)
|
||||
- [InlineToast](/docs/inlinetoast--inline-toast)
|
||||
- [Input](/docs/forms-input--simple)
|
||||
- [TextArea](/docs/forms-textarea--basic)
|
||||
- [Checkbox](/docs/forms-checkbox--basic)
|
||||
- [RadioButtonGroup](/docs/forms-radiobuttongroup--radio-buttons)
|
||||
- [Switch](/docs/forms-switch--controlled)
|
||||
- [Select](/docs/forms-select--basic)
|
@ -0,0 +1,207 @@
|
||||
import { Story, Meta } from '@storybook/react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { RadioButtonGroup } from '../Forms/RadioButtonGroup/RadioButtonGroup';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Select } from '../Select/Select';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
import { TextArea } from '../TextArea/TextArea';
|
||||
|
||||
import { AutoSaveField } from './AutoSaveField';
|
||||
import mdx from './AutoSaveField.mdx';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Forms/AutoSaveField',
|
||||
component: AutoSaveField,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
options: {
|
||||
storySort: {
|
||||
order: ['Basic', 'AllComponentsSuccess', 'AllComponentsError'],
|
||||
},
|
||||
},
|
||||
controls: {
|
||||
exclude: [
|
||||
'className',
|
||||
'error',
|
||||
'loading',
|
||||
'htmlFor',
|
||||
'invalid',
|
||||
'horizontal',
|
||||
'onFinishChange',
|
||||
'validationMessageHorizontalOverflow',
|
||||
],
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
saveErrorMessage: { control: 'text' },
|
||||
label: { control: 'text' },
|
||||
required: {
|
||||
control: { type: 'boolean', options: [true, false] },
|
||||
},
|
||||
inputSuccessful: {
|
||||
control: { type: 'boolean', options: [true, false] },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
saveErrorMessage: 'This is a custom error message',
|
||||
required: false,
|
||||
description: 'This input has an auto-save behaviour',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const getSuccess = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
};
|
||||
const getError = () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
reject();
|
||||
});
|
||||
};
|
||||
const themeOptions = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'system', label: 'System' },
|
||||
];
|
||||
|
||||
export const Basic: Story = (args) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
return (
|
||||
<AutoSaveField onFinishChange={args.inputSuccessful ? getSuccess : getError} {...args}>
|
||||
{(onChange) => (
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
onChange(value);
|
||||
setInputValue(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
);
|
||||
};
|
||||
|
||||
Basic.args = {
|
||||
required: false,
|
||||
label: 'Input saving value automatically',
|
||||
inputSuccessful: false,
|
||||
};
|
||||
|
||||
export const AllComponents: Story = (args) => {
|
||||
const [selected, setSelected] = useState('');
|
||||
const [checkBoxTest, setCheckBoxTest] = useState(false);
|
||||
const [textAreaValue, setTextAreaValue] = useState('');
|
||||
const [inputTextValue, setInputTextValue] = useState('');
|
||||
const [switchTest, setSwitchTest] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AutoSaveField onFinishChange={args.inputSuccessful ? getSuccess : getError} label="Text as a child" {...args}>
|
||||
{(onChange) => (
|
||||
<Input
|
||||
value={inputTextValue}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
onChange(value);
|
||||
setInputTextValue(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
<AutoSaveField onFinishChange={args.inputSuccessful ? getSuccess : getError} label="Select as child" {...args}>
|
||||
{(onChange) => (
|
||||
<Select
|
||||
options={themeOptions}
|
||||
value={args.weekPickerValue}
|
||||
onChange={(v) => {
|
||||
onChange(v.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
<AutoSaveField
|
||||
onFinishChange={args.inputSuccessful ? getSuccess : getError}
|
||||
label="RadioButtonGroup as a child"
|
||||
{...args}
|
||||
>
|
||||
{(onChange) => (
|
||||
<RadioButtonGroup
|
||||
options={themeOptions}
|
||||
value={selected}
|
||||
onChange={(themeOption) => {
|
||||
setSelected(themeOption);
|
||||
onChange(themeOption);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
<AutoSaveField<Boolean>
|
||||
onFinishChange={args.inputSuccessful ? getSuccess : getError}
|
||||
label="Checkbox as a child"
|
||||
{...args}
|
||||
>
|
||||
{(onChange) => (
|
||||
<Checkbox
|
||||
label="Checkbox test"
|
||||
description="This is a checkbox input"
|
||||
name="checkbox-test"
|
||||
value={checkBoxTest}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.checked;
|
||||
onChange(value);
|
||||
setCheckBoxTest(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
<AutoSaveField
|
||||
onFinishChange={args.inputSuccessful ? getSuccess : getError}
|
||||
label="TextArea as a child"
|
||||
{...args}
|
||||
>
|
||||
{(onChange) => (
|
||||
<TextArea
|
||||
value={textAreaValue}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
onChange(value);
|
||||
setTextAreaValue(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
<AutoSaveField<Boolean>
|
||||
onFinishChange={args.inputSuccessful ? getSuccess : getError}
|
||||
label="Switch as a child"
|
||||
{...args}
|
||||
>
|
||||
{(onChange) => (
|
||||
<Switch
|
||||
label="Switch test"
|
||||
name="switch-test"
|
||||
value={switchTest}
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.checked);
|
||||
setSwitchTest(e.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
AllComponents.args = {
|
||||
required: false,
|
||||
inputSuccessful: true,
|
||||
};
|
@ -0,0 +1,518 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { createTheme, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { RadioButtonGroup } from '../Forms/RadioButtonGroup/RadioButtonGroup';
|
||||
import { Input } from '../Input/Input';
|
||||
import { SelectBase } from '../Select/SelectBase';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
import { TextArea } from '../TextArea/TextArea';
|
||||
|
||||
import { AutoSaveField, Props } from './AutoSaveField';
|
||||
|
||||
const mockOnFinishChange = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const mockOnFinishChangeError = jest.fn().mockImplementation(() => Promise.reject());
|
||||
const options: Array<SelectableValue<string>> = [
|
||||
{
|
||||
label: 'Light',
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
label: 'Dark',
|
||||
value: 'dark',
|
||||
},
|
||||
{
|
||||
label: 'Default',
|
||||
value: 'default',
|
||||
},
|
||||
];
|
||||
const setup = (propOverrides?: Partial<Props>) => {
|
||||
const props: Omit<Props, 'children'> = {
|
||||
label: 'Test',
|
||||
onFinishChange: mockOnFinishChange,
|
||||
htmlFor: 'input-test',
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<AutoSaveField {...props}>
|
||||
{(onChange) => <Input id="input-test" name="input-test" onChange={(e) => onChange(e.currentTarget.value)} />}
|
||||
</AutoSaveField>
|
||||
);
|
||||
};
|
||||
|
||||
const setupTextArea = (propOverrides?: Partial<Props>) => {
|
||||
const props: Omit<Props, 'children'> = {
|
||||
label: 'Test',
|
||||
onFinishChange: mockOnFinishChange,
|
||||
htmlFor: 'textarea-test',
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<AutoSaveField {...props}>
|
||||
{(onChange) => (
|
||||
<TextArea id="textarea-test" name="textarea-test" onChange={(e) => onChange(e.currentTarget.value)} />
|
||||
)}
|
||||
</AutoSaveField>
|
||||
);
|
||||
};
|
||||
|
||||
const setupCheckbox = (propOverrides?: Partial<Props>) => {
|
||||
const props: Omit<Props<Boolean>, 'children'> = {
|
||||
label: 'Test',
|
||||
onFinishChange: mockOnFinishChange,
|
||||
htmlFor: 'checkbox-test',
|
||||
defaultChecked: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<AutoSaveField<Boolean> {...props}>
|
||||
{(onChange) => (
|
||||
<Checkbox
|
||||
id="checkbox-test"
|
||||
name="checkbox-test"
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
);
|
||||
};
|
||||
|
||||
const setupSwitch = (propOverrides?: Partial<Props>) => {
|
||||
const props: Omit<Props<Boolean>, 'children'> = {
|
||||
label: 'Test',
|
||||
onFinishChange: mockOnFinishChange,
|
||||
htmlFor: 'switch-test',
|
||||
defaultChecked: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<AutoSaveField<Boolean> {...props}>
|
||||
{(onChange) => (
|
||||
<Switch
|
||||
id="switch-test"
|
||||
name="switch-test"
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
);
|
||||
};
|
||||
|
||||
const setupRadioButton = (propOverrides?: Partial<Props>) => {
|
||||
const props: Omit<Props, 'children'> = {
|
||||
label: 'Choose your theme',
|
||||
onFinishChange: mockOnFinishChange,
|
||||
htmlFor: 'radio-button-group-test',
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<AutoSaveField {...props}>
|
||||
{(onChange) => (
|
||||
<RadioButtonGroup
|
||||
id="radio-button-group-test"
|
||||
onChange={(option) => {
|
||||
onChange(option);
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
);
|
||||
};
|
||||
|
||||
const setupSelect = (propOverrides?: Partial<Props>) => {
|
||||
const props: Omit<Props, 'children'> = {
|
||||
label: 'Choose your theme',
|
||||
onFinishChange: mockOnFinishChange,
|
||||
htmlFor: 'select-test',
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<AutoSaveField {...props}>
|
||||
{(onChange) => (
|
||||
<SelectBase
|
||||
data-testid="select-test"
|
||||
onChange={(option) => {
|
||||
onChange(option.value ?? '');
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</AutoSaveField>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
Cases to cover:
|
||||
1.- General:
|
||||
a) It renders
|
||||
b) It has a children
|
||||
c) It has a onFinishChange function
|
||||
d) If success, the InlineToast renders on the right
|
||||
e) If success but not enough space, the InlineToas renders on the bottom
|
||||
2.- Per child:
|
||||
a) It renders
|
||||
b) When it is succesful, it show the InlineToast saying Saved!
|
||||
c) When there was an error, show the error message
|
||||
d) When there was an error and the child has an invalid prop, show the red border
|
||||
*/
|
||||
|
||||
describe('AutoSaveField ', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders with an Input as a children', () => {
|
||||
setup();
|
||||
expect(
|
||||
screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it('triggers the function on change by typing and shows the InlineToast', async () => {
|
||||
setup();
|
||||
const inputField = screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.type(inputField, 'This is a test text');
|
||||
expect(inputField).toHaveValue('This is a test text');
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChange).toHaveBeenCalledWith('This is a test text');
|
||||
expect(await screen.findByText('Saved!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input, as AutoSaveField child, ', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows an error message if there was any problem with the request', async () => {
|
||||
setup({ saveErrorMessage: 'There was an error', onFinishChange: mockOnFinishChangeError });
|
||||
const inputField = screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.type(inputField, 'This is a test text');
|
||||
expect(inputField).toHaveValue('This is a test text');
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).toHaveBeenCalledWith('This is a test text');
|
||||
expect(await screen.findByText('There was an error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a red border when invalid is true', async () => {
|
||||
setup({ invalid: true, onFinishChange: mockOnFinishChangeError });
|
||||
const theme = createTheme();
|
||||
const inputField = screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.type(inputField, 'This is a test text');
|
||||
expect(inputField).toHaveValue('This is a test text');
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).not.toHaveBeenCalled();
|
||||
expect(inputField).toHaveStyle(`border: 1px solid ${theme.colors.error.border};`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextArea, as AutoSaveField child, ', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders itself', () => {
|
||||
setupTextArea();
|
||||
expect(
|
||||
screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
//TextArea does not have a loading state so this test will fail till that is fixed
|
||||
it.skip('triggers the function on change by typing and shows the InlineToast', async () => {
|
||||
setupTextArea();
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.type(textArea, 'This is a test text');
|
||||
expect(textArea).toHaveValue('This is a test text');
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockOnFinishChange).toHaveBeenCalledWith('This is a test text');
|
||||
expect(await screen.findByText('Saved!')).toBeInTheDocument();
|
||||
});
|
||||
//TextArea does not have a loading state so this test will fail till that is fixed
|
||||
it.skip('shows an error message if there was any problem with the request', async () => {
|
||||
setupTextArea({ saveErrorMessage: 'There was an error', onFinishChange: mockOnFinishChangeError });
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.type(textArea, 'This is a test text');
|
||||
expect(textArea).toHaveValue('This is a test text');
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).toHaveBeenCalledWith('This is a test text');
|
||||
expect(await screen.findByText('There was an error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a red border when invalid is true', async () => {
|
||||
setupTextArea({ invalid: true, onFinishChange: mockOnFinishChangeError });
|
||||
const theme = createTheme();
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.type(textArea, 'This is a test text');
|
||||
expect(textArea).toHaveValue('This is a test text');
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).not.toHaveBeenCalled();
|
||||
expect(textArea).toHaveStyle(`border-color: ${theme.colors.error.border}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Checkbox, as AutoSaveField child, ', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders itself', () => {
|
||||
setupCheckbox();
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
name: 'Test',
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
//Checkbox does not have a loading state so this test will fail till that is fixed
|
||||
it.skip('triggers the function on change by click on it and shows the InlineToast', async () => {
|
||||
setupCheckbox();
|
||||
const checkbox = screen.getByRole('checkbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChange).toHaveBeenCalledWith(true);
|
||||
expect(await screen.findByText('Saved!')).toBeInTheDocument();
|
||||
});
|
||||
//Checkbox does not have a loading state so this test will fail till that is fixed
|
||||
it.skip('shows an error message if there was any problem with the request', async () => {
|
||||
setupCheckbox({ saveErrorMessage: 'There was an error', onFinishChange: mockOnFinishChangeError });
|
||||
const checkbox = screen.getByRole('checkbox', {
|
||||
name: 'Test',
|
||||
});
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).toHaveBeenCalledWith(true);
|
||||
expect(await screen.findByText('There was an error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Switch, as AutoSaveField child, ', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders itself', () => {
|
||||
setupSwitch();
|
||||
//Is there another way to find the switch element? Filtering by name doesn't work
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
checked: false,
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
//Switch does not have a loading state so this test will fail till that is fixed
|
||||
it.skip('triggers the function on change by toggle it and shows the InlineToast', async () => {
|
||||
setupSwitch();
|
||||
const switchInput = screen.getByRole('checkbox', {
|
||||
checked: false,
|
||||
});
|
||||
await user.click(switchInput);
|
||||
expect(switchInput).toBeChecked();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChange).toHaveBeenCalledWith(true);
|
||||
expect(await screen.findByText('Saved!')).toBeInTheDocument();
|
||||
});
|
||||
//Switch does not have a loading state so this test will fail till that is fixed
|
||||
it.skip('shows an error message if there was any problem with the request', async () => {
|
||||
setupSwitch({ saveErrorMessage: 'There was an error', onFinishChange: mockOnFinishChangeError });
|
||||
const switchInput = screen.getByRole('checkbox', {
|
||||
checked: false,
|
||||
});
|
||||
await user.click(switchInput);
|
||||
expect(switchInput).toBeChecked();
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).toHaveBeenCalledWith(true);
|
||||
expect(await screen.findByText('There was an error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RadioButtonGroup, as AutoSaveField child, ', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders itself', () => {
|
||||
setupRadioButton();
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(3);
|
||||
});
|
||||
it('triggers the function on change by click on an option and shows the InlineToast', async () => {
|
||||
setupRadioButton();
|
||||
const radioOption = screen.getByRole('radio', {
|
||||
name: /Light/,
|
||||
});
|
||||
await user.click(radioOption);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChange).toHaveBeenCalledWith('light');
|
||||
expect(await screen.findByText('Saved!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error message if there was any problem with the request', async () => {
|
||||
setupRadioButton({ saveErrorMessage: 'There was an error', onFinishChange: mockOnFinishChangeError });
|
||||
const radioOption = screen.getByRole('radio', {
|
||||
name: /Light/,
|
||||
});
|
||||
await user.click(radioOption);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).toHaveBeenCalledWith('light');
|
||||
expect(await screen.findByText('There was an error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select, as AutoSaveField child, ', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ delay: null });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders itself', async () => {
|
||||
setupSelect();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Choose'));
|
||||
const selectOptions = screen.getAllByLabelText('Select option');
|
||||
expect(selectOptions).toHaveLength(3);
|
||||
});
|
||||
it('triggers the function on change by selecting an option and shows the InlineToast', async () => {
|
||||
setupSelect();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Choose'));
|
||||
const selectOptions = screen.getAllByLabelText('Select option');
|
||||
await user.click(selectOptions[1]);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChange).toHaveBeenCalledWith('dark');
|
||||
expect(await screen.findByText('Saved!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error message if there was any problem with the request', async () => {
|
||||
setupSelect({ saveErrorMessage: 'There was an error', onFinishChange: mockOnFinishChangeError });
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Choose'));
|
||||
const selectOptions = screen.getAllByLabelText('Select option');
|
||||
await user.click(selectOptions[1]);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockOnFinishChangeError).toHaveBeenCalledWith('dark');
|
||||
expect(await screen.findByText('There was an error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,126 @@
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
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';
|
||||
*/
|
||||
|
||||
const SHOW_SUCCESS_DURATION = 2 * 1000;
|
||||
|
||||
export interface Props<T = string> extends Omit<FieldProps, 'children'> {
|
||||
/** Saving request that will be triggered 600ms after changing the value */
|
||||
onFinishChange: (inputValue: T) => Promise<void>;
|
||||
/** Custom error message to display on saving */
|
||||
saveErrorMessage?: string;
|
||||
/** Input that will save its value on change */
|
||||
children: (onChange: (newValue: T) => void) => React.ReactElement;
|
||||
}
|
||||
export function AutoSaveField<T = string>(props: Props<T>) {
|
||||
const {
|
||||
invalid,
|
||||
loading,
|
||||
onFinishChange,
|
||||
saveErrorMessage = 'Error saving this value',
|
||||
error,
|
||||
children,
|
||||
disabled,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const [fieldState, setFieldState] = React.useState({
|
||||
isLoading: false,
|
||||
showSuccess: false,
|
||||
showError: invalid,
|
||||
});
|
||||
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
if (fieldState.showSuccess) {
|
||||
const time = fieldState.showError ? 0 : SHOW_SUCCESS_DURATION;
|
||||
timeoutId = setTimeout(() => {
|
||||
setFieldState({ ...fieldState, showSuccess: false });
|
||||
}, time);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [fieldState]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(nextValue: T) => {
|
||||
if (invalid) {
|
||||
return;
|
||||
}
|
||||
setFieldState({ ...fieldState, isLoading: true, showSuccess: false });
|
||||
onFinishChange(nextValue)
|
||||
.then(() => {
|
||||
setFieldState({
|
||||
isLoading: false,
|
||||
showSuccess: true,
|
||||
showError: false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setFieldState({
|
||||
...fieldState,
|
||||
isLoading: false,
|
||||
showError: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
[invalid, fieldState, onFinishChange]
|
||||
);
|
||||
|
||||
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
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
{...restProps}
|
||||
loading={isLoading}
|
||||
invalid={isInvalid}
|
||||
disabled={disabled}
|
||||
error={error || (fieldState.showError && saveErrorMessage)}
|
||||
ref={fieldRef}
|
||||
>
|
||||
{React.cloneElement(
|
||||
children((newValue) => {
|
||||
lodashDebounce(newValue);
|
||||
})
|
||||
)}
|
||||
</Field>
|
||||
{fieldState.showSuccess && (
|
||||
<InlineToast
|
||||
suffixIcon={'check'}
|
||||
referenceElement={fieldRef.current}
|
||||
placement="right"
|
||||
alternativePlacement="bottom"
|
||||
>
|
||||
Saved!
|
||||
</InlineToast>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AutoSaveField.displayName = 'AutoSaveField';
|
@ -69,43 +69,58 @@ export const getFieldStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
};
|
||||
});
|
||||
|
||||
export const Field = ({
|
||||
label,
|
||||
description,
|
||||
horizontal,
|
||||
invalid,
|
||||
loading,
|
||||
disabled,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
className,
|
||||
validationMessageHorizontalOverflow,
|
||||
htmlFor,
|
||||
...otherProps
|
||||
}: FieldProps) => {
|
||||
const theme = useTheme2();
|
||||
const styles = getFieldStyles(theme);
|
||||
const inputId = htmlFor ?? getChildId(children);
|
||||
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
horizontal,
|
||||
invalid,
|
||||
loading,
|
||||
disabled,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
className,
|
||||
validationMessageHorizontalOverflow,
|
||||
htmlFor,
|
||||
...otherProps
|
||||
}: FieldProps,
|
||||
ref
|
||||
) => {
|
||||
const theme = useTheme2();
|
||||
const styles = getFieldStyles(theme);
|
||||
const inputId = htmlFor ?? getChildId(children);
|
||||
|
||||
const labelElement =
|
||||
typeof label === 'string' ? (
|
||||
<Label htmlFor={inputId} description={description}>
|
||||
{`${label}${required ? ' *' : ''}`}
|
||||
</Label>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
const labelElement =
|
||||
typeof label === 'string' ? (
|
||||
<Label htmlFor={inputId} description={description}>
|
||||
{`${label}${required ? ' *' : ''}`}
|
||||
</Label>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
|
||||
const childProps = deleteUndefinedProps({ invalid, disabled, loading });
|
||||
return (
|
||||
<div className={cx(styles.field, horizontal && styles.fieldHorizontal, className)} {...otherProps}>
|
||||
{labelElement}
|
||||
<div>
|
||||
{React.cloneElement(children, childProps)}
|
||||
{invalid && error && !horizontal && (
|
||||
const childProps = deleteUndefinedProps({ invalid, disabled, loading });
|
||||
return (
|
||||
<div className={cx(styles.field, horizontal && styles.fieldHorizontal, className)} {...otherProps}>
|
||||
{labelElement}
|
||||
<div>
|
||||
<div ref={ref}>{React.cloneElement(children, childProps)}</div>
|
||||
{invalid && error && !horizontal && (
|
||||
<div
|
||||
className={cx(styles.fieldValidationWrapper, {
|
||||
[styles.validationMessageHorizontalOverflow]: !!validationMessageHorizontalOverflow,
|
||||
})}
|
||||
>
|
||||
<FieldValidationMessage>{error}</FieldValidationMessage>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{invalid && error && horizontal && (
|
||||
<div
|
||||
className={cx(styles.fieldValidationWrapper, {
|
||||
className={cx(styles.fieldValidationWrapper, styles.fieldValidationWrapperHorizontal, {
|
||||
[styles.validationMessageHorizontalOverflow]: !!validationMessageHorizontalOverflow,
|
||||
})}
|
||||
>
|
||||
@ -113,19 +128,11 @@ export const Field = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
{invalid && error && horizontal && (
|
||||
<div
|
||||
className={cx(styles.fieldValidationWrapper, styles.fieldValidationWrapperHorizontal, {
|
||||
[styles.validationMessageHorizontalOverflow]: !!validationMessageHorizontalOverflow,
|
||||
})}
|
||||
>
|
||||
<FieldValidationMessage>{error}</FieldValidationMessage>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Field.displayName = 'Field';
|
||||
|
||||
function deleteUndefinedProps<T extends Object>(obj: T): Partial<T> {
|
||||
for (const key in obj) {
|
||||
|
@ -15,22 +15,38 @@ 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
|
||||
alternativePlacement?: BasePlacement;
|
||||
}
|
||||
|
||||
export function InlineToast({ referenceElement, children, suffixIcon, placement }: InlineToastProps) {
|
||||
export function InlineToast({
|
||||
referenceElement,
|
||||
children,
|
||||
suffixIcon,
|
||||
placement,
|
||||
alternativePlacement,
|
||||
}: InlineToastProps) {
|
||||
const [indicatorElement, setIndicatorElement] = useState<HTMLElement | null>(null);
|
||||
const popper = usePopper(referenceElement, indicatorElement, { placement });
|
||||
const [toastPlacement, setToastPlacement] = useState(placement);
|
||||
const popper = usePopper(referenceElement, indicatorElement, { placement: toastPlacement });
|
||||
const styles = useStyles2(getStyles);
|
||||
const placementStyles = useStyles2(getPlacementStyles);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (alternativePlacement && shouldUseAlt(placement, indicatorElement, referenceElement)) {
|
||||
setToastPlacement(alternativePlacement);
|
||||
}
|
||||
}, [alternativePlacement, placement, indicatorElement, referenceElement]);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
style={{ display: 'inline-block', ...popper.styles.popper }}
|
||||
{...popper.attributes.popper}
|
||||
ref={setIndicatorElement}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className={cx(styles.root, placementStyles[placement])}>
|
||||
<span className={cx(styles.root, placementStyles[toastPlacement])}>
|
||||
{children && <span>{children}</span>}
|
||||
{suffixIcon && <Icon name={suffixIcon} />}
|
||||
</span>
|
||||
@ -55,6 +71,31 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
};
|
||||
};
|
||||
|
||||
//To calculate if the InlineToast is displayed off-screen and should use the alternative placement
|
||||
const shouldUseAlt = (
|
||||
placement: BasePlacement,
|
||||
indicatorElement: HTMLElement | null,
|
||||
referenceElement: HTMLElement | null
|
||||
) => {
|
||||
const indicatorSizes = indicatorElement?.getBoundingClientRect();
|
||||
const referenceSizes = referenceElement?.getBoundingClientRect();
|
||||
if (!indicatorSizes || !referenceSizes) {
|
||||
return false;
|
||||
}
|
||||
switch (placement) {
|
||||
case 'right':
|
||||
return indicatorSizes.width + referenceSizes.right > window.innerWidth;
|
||||
case 'bottom':
|
||||
return indicatorSizes.height + referenceSizes.bottom > window.innerHeight;
|
||||
case 'left':
|
||||
return referenceSizes.left - indicatorSizes.width < 0;
|
||||
case 'top':
|
||||
return referenceSizes.top - indicatorSizes.height < 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createAnimation = (fromX: string | number, fromY: string | number) =>
|
||||
keyframes({
|
||||
from: {
|
||||
|
@ -46,6 +46,7 @@ export { DateTimePicker } from './DateTimePickers/DateTimePicker/DateTimePicker'
|
||||
export { List } from './List/List';
|
||||
export { InteractiveTable } from './InteractiveTable/InteractiveTable';
|
||||
export { TagsInput } from './TagsInput/TagsInput';
|
||||
export { AutoSaveField } from './AutoSaveField/AutoSaveField';
|
||||
export { Pagination } from './Pagination/Pagination';
|
||||
export { Tag, type OnTagClick } from './Tags/Tag';
|
||||
export { TagList } from './Tags/TagList';
|
||||
|
Loading…
Reference in New Issue
Block a user