mirror of https://github.com/grafana/grafana
UI: ConfirmButton component (#20993)
* UI: ConfirmButton component
* UI: add link button variant
* UI: add ConfirmButton story with delete option
* Chore: use ConfirmButton instead of DeleteButton
* UI: remove DeleteButton
* UI: rename confirmButtonVariant to confirmVariant
* UI: use Form.Button in ConfirmButton
* Chore: use sm ConfirmButton size after changing defaults
* Revert "UI: add link button variant"
This reverts commit 4372350daa
.
* Chore: add 'link' variant type to the Button
* UI: DeleteButton component on top of ConfirmButton
* Chore: use DeleteButton instead of ConfirmButton
* Chore: DeleteButton, use md size by default
* Chore: update test snapshots
pull/21090/head
parent
4dba02dd20
commit
fd2131c1e3
@ -0,0 +1,76 @@ |
||||
import React from 'react'; |
||||
import { storiesOf } from '@storybook/react'; |
||||
import { text, boolean, select } from '@storybook/addon-knobs'; |
||||
import { ConfirmButton } from './ConfirmButton'; |
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
import { action } from '@storybook/addon-actions'; |
||||
import { Button } from '../Button/Button'; |
||||
|
||||
const getKnobs = () => { |
||||
return { |
||||
buttonText: text('Button text', 'Edit'), |
||||
confirmText: text('Confirm text', 'Save'), |
||||
size: select('Size', ['sm', 'md', 'lg'], 'md'), |
||||
confirmVariant: select( |
||||
'Confirm variant', |
||||
{ |
||||
primary: 'primary', |
||||
secondary: 'secondary', |
||||
danger: 'danger', |
||||
inverse: 'inverse', |
||||
transparent: 'transparent', |
||||
}, |
||||
'primary' |
||||
), |
||||
disabled: boolean('Disabled', false), |
||||
}; |
||||
}; |
||||
|
||||
storiesOf('UI/ConfirmButton', module) |
||||
.addDecorator(withCenteredStory) |
||||
.add('default', () => { |
||||
const { size, buttonText, confirmText, confirmVariant, disabled } = getKnobs(); |
||||
return ( |
||||
<> |
||||
<div className="gf-form-group"> |
||||
<div className="gf-form"> |
||||
<ConfirmButton |
||||
size={size} |
||||
confirmText={confirmText} |
||||
disabled={disabled} |
||||
confirmVariant={confirmVariant} |
||||
onConfirm={() => { |
||||
action('Saved')('save!'); |
||||
}} |
||||
> |
||||
{buttonText} |
||||
</ConfirmButton> |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
}) |
||||
.add('with custom button', () => { |
||||
const { buttonText, confirmText, confirmVariant, disabled, size } = getKnobs(); |
||||
return ( |
||||
<> |
||||
<div className="gf-form-group"> |
||||
<div className="gf-form"> |
||||
<ConfirmButton |
||||
size={size} |
||||
confirmText={confirmText} |
||||
disabled={disabled} |
||||
confirmVariant={confirmVariant} |
||||
onConfirm={() => { |
||||
action('Saved')('save!'); |
||||
}} |
||||
> |
||||
<Button size={size} variant="secondary" icon="fa fa-pencil"> |
||||
{buttonText} |
||||
</Button> |
||||
</ConfirmButton> |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
}); |
@ -0,0 +1,34 @@ |
||||
import React from 'react'; |
||||
import { ConfirmButton } from './ConfirmButton'; |
||||
import { mount, ShallowWrapper } from 'enzyme'; |
||||
import { Button } from '../Button/Button'; |
||||
|
||||
describe('ConfirmButton', () => { |
||||
let wrapper: any; |
||||
let deleted: any; |
||||
|
||||
beforeAll(() => { |
||||
deleted = false; |
||||
|
||||
function deleteItem() { |
||||
deleted = true; |
||||
} |
||||
|
||||
wrapper = mount( |
||||
<ConfirmButton confirmText="Confirm delete" onConfirm={() => deleteItem()}> |
||||
Delete |
||||
</ConfirmButton> |
||||
); |
||||
}); |
||||
|
||||
it('should show confirm delete when clicked', () => { |
||||
expect(deleted).toBe(false); |
||||
wrapper |
||||
.find(Button) |
||||
.findWhere((n: ShallowWrapper) => { |
||||
return n.text() === 'Confirm delete' && n.type() === Button; |
||||
}) |
||||
.simulate('click'); |
||||
expect(deleted).toBe(true); |
||||
}); |
||||
}); |
@ -0,0 +1,160 @@ |
||||
import React, { PureComponent, SyntheticEvent } from 'react'; |
||||
import { cx, css } from 'emotion'; |
||||
import { stylesFactory, withTheme } from '../../themes'; |
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
import { Themeable } from '../../types'; |
||||
import { Button } from '../Button/Button'; |
||||
import Forms from '../Forms'; |
||||
import { ButtonVariant, ButtonSize } from '../Button/types'; |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => { |
||||
return { |
||||
buttonContainer: css` |
||||
direction: rtl; |
||||
display: flex; |
||||
align-items: center; |
||||
`,
|
||||
buttonDisabled: css` |
||||
text-decoration: none; |
||||
color: ${theme.colors.text}; |
||||
opacity: 0.65; |
||||
cursor: not-allowed; |
||||
pointer-events: none; |
||||
`,
|
||||
buttonShow: css` |
||||
opacity: 1; |
||||
transition: opacity 0.1s ease; |
||||
z-index: 2; |
||||
`,
|
||||
buttonHide: css` |
||||
opacity: 0; |
||||
transition: opacity 0.1s ease; |
||||
z-index: 0; |
||||
`,
|
||||
confirmButtonContainer: css` |
||||
overflow: hidden; |
||||
position: absolute; |
||||
z-index: 1; |
||||
`,
|
||||
confirmButton: css` |
||||
display: flex; |
||||
align-items: flex-start; |
||||
`,
|
||||
confirmButtonShow: css` |
||||
opacity: 1; |
||||
transition: opacity 0.08s ease-out, transform 0.1s ease-out; |
||||
transform: translateX(0); |
||||
`,
|
||||
confirmButtonHide: css` |
||||
opacity: 0; |
||||
transition: opacity 0.12s ease-in, transform 0.14s ease-in; |
||||
transform: translateX(100px); |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
interface Props extends Themeable { |
||||
className?: string; |
||||
size?: ButtonSize; |
||||
confirmText?: string; |
||||
disabled?: boolean; |
||||
confirmVariant?: ButtonVariant; |
||||
|
||||
onConfirm(): void; |
||||
onClick?(): void; |
||||
onCancel?(): void; |
||||
} |
||||
|
||||
interface State { |
||||
showConfirm: boolean; |
||||
} |
||||
|
||||
class UnThemedConfirmButton extends PureComponent<Props, State> { |
||||
static defaultProps: Partial<Props> = { |
||||
size: 'md', |
||||
confirmText: 'Save', |
||||
disabled: false, |
||||
confirmVariant: 'primary', |
||||
}; |
||||
|
||||
state: State = { |
||||
showConfirm: false, |
||||
}; |
||||
|
||||
onClickButton = (event: SyntheticEvent) => { |
||||
if (event) { |
||||
event.preventDefault(); |
||||
} |
||||
|
||||
this.setState({ |
||||
showConfirm: true, |
||||
}); |
||||
|
||||
if (this.props.onClick) { |
||||
this.props.onClick(); |
||||
} |
||||
}; |
||||
|
||||
onClickCancel = (event: SyntheticEvent) => { |
||||
if (event) { |
||||
event.preventDefault(); |
||||
} |
||||
this.setState({ |
||||
showConfirm: false, |
||||
}); |
||||
if (this.props.onCancel) { |
||||
this.props.onCancel(); |
||||
} |
||||
}; |
||||
|
||||
render() { |
||||
const { |
||||
theme, |
||||
className, |
||||
size, |
||||
disabled, |
||||
confirmText, |
||||
confirmVariant: confirmButtonVariant, |
||||
onConfirm, |
||||
children, |
||||
} = this.props; |
||||
const styles = getStyles(theme); |
||||
const buttonClass = cx( |
||||
className, |
||||
this.state.showConfirm ? styles.buttonHide : styles.buttonShow, |
||||
disabled && styles.buttonDisabled |
||||
); |
||||
const confirmButtonClass = cx( |
||||
styles.confirmButton, |
||||
this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide |
||||
); |
||||
const onClick = disabled ? () => {} : this.onClickButton; |
||||
|
||||
return ( |
||||
<span className={styles.buttonContainer}> |
||||
{typeof children === 'string' ? ( |
||||
<Forms.Button className={buttonClass} size={size} variant="link" onClick={onClick}> |
||||
{children} |
||||
</Forms.Button> |
||||
) : ( |
||||
<span className={buttonClass} onClick={onClick}> |
||||
{children} |
||||
</span> |
||||
)} |
||||
<span className={styles.confirmButtonContainer}> |
||||
<span className={confirmButtonClass}> |
||||
<Button size={size} variant="transparent" onClick={this.onClickCancel}> |
||||
Cancel |
||||
</Button> |
||||
<Button size={size} variant={confirmButtonVariant} onClick={onConfirm}> |
||||
{confirmText} |
||||
</Button> |
||||
</span> |
||||
</span> |
||||
</span> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export const ConfirmButton = withTheme(UnThemedConfirmButton); |
||||
ConfirmButton.displayName = 'ConfirmButton'; |
@ -0,0 +1,34 @@ |
||||
import React from 'react'; |
||||
import { storiesOf } from '@storybook/react'; |
||||
import { boolean, select } from '@storybook/addon-knobs'; |
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
import { action } from '@storybook/addon-actions'; |
||||
import { DeleteButton } from './DeleteButton'; |
||||
|
||||
const getKnobs = () => { |
||||
return { |
||||
size: select('Size', ['sm', 'md', 'lg'], 'md'), |
||||
disabled: boolean('Disabled', false), |
||||
}; |
||||
}; |
||||
|
||||
storiesOf('UI/ConfirmButton', module) |
||||
.addDecorator(withCenteredStory) |
||||
.add('delete button', () => { |
||||
const { disabled, size } = getKnobs(); |
||||
return ( |
||||
<> |
||||
<div className="gf-form-group"> |
||||
<div className="gf-form"> |
||||
<DeleteButton |
||||
size={size} |
||||
disabled={disabled} |
||||
onConfirm={() => { |
||||
action('Deleted')('delete!'); |
||||
}} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
}); |
@ -0,0 +1,24 @@ |
||||
import React, { FC } from 'react'; |
||||
import { ConfirmButton } from './ConfirmButton'; |
||||
import { Button } from '../Button/Button'; |
||||
import { ButtonSize } from '../Button/types'; |
||||
|
||||
interface Props { |
||||
size?: ButtonSize; |
||||
disabled?: boolean; |
||||
onConfirm(): void; |
||||
} |
||||
|
||||
export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm }) => { |
||||
return ( |
||||
<ConfirmButton |
||||
confirmText="Delete" |
||||
confirmVariant="danger" |
||||
size={size || 'md'} |
||||
disabled={disabled} |
||||
onConfirm={onConfirm} |
||||
> |
||||
<Button variant="danger" icon="fa fa-remove" size={size || 'sm'} /> |
||||
</ConfirmButton> |
||||
); |
||||
}; |
@ -1,17 +0,0 @@ |
||||
import React from 'react'; |
||||
import { storiesOf } from '@storybook/react'; |
||||
import { DeleteButton } from './DeleteButton'; |
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
import { action } from '@storybook/addon-actions'; |
||||
|
||||
storiesOf('UI/DeleteButton', module) |
||||
.addDecorator(withCenteredStory) |
||||
.add('default', () => { |
||||
return ( |
||||
<DeleteButton |
||||
onConfirm={() => { |
||||
action('Delete Confirmed')('delete!'); |
||||
}} |
||||
/> |
||||
); |
||||
}); |
@ -1,45 +0,0 @@ |
||||
import React from 'react'; |
||||
import { DeleteButton } from './DeleteButton'; |
||||
import { shallow } from 'enzyme'; |
||||
|
||||
describe('DeleteButton', () => { |
||||
let wrapper: any; |
||||
let deleted: any; |
||||
|
||||
beforeAll(() => { |
||||
deleted = false; |
||||
|
||||
function deleteItem() { |
||||
deleted = true; |
||||
} |
||||
|
||||
wrapper = shallow(<DeleteButton onConfirm={() => deleteItem()} />); |
||||
}); |
||||
|
||||
it('should show confirm delete when clicked', () => { |
||||
expect(wrapper.state().showConfirm).toBe(false); |
||||
wrapper.find('.delete-button').simulate('click'); |
||||
expect(wrapper.state().showConfirm).toBe(true); |
||||
}); |
||||
|
||||
it('should hide confirm delete when clicked', () => { |
||||
wrapper.find('.delete-button').simulate('click'); |
||||
expect(wrapper.state().showConfirm).toBe(true); |
||||
wrapper |
||||
.find('.confirm-delete') |
||||
.find('.btn') |
||||
.at(0) |
||||
.simulate('click'); |
||||
expect(wrapper.state().showConfirm).toBe(false); |
||||
}); |
||||
|
||||
it('should show confirm delete when clicked', () => { |
||||
expect(deleted).toBe(false); |
||||
wrapper |
||||
.find('.confirm-delete') |
||||
.find('.btn') |
||||
.at(1) |
||||
.simulate('click'); |
||||
expect(deleted).toBe(true); |
||||
}); |
||||
}); |
@ -1,64 +0,0 @@ |
||||
import React, { PureComponent, SyntheticEvent } from 'react'; |
||||
|
||||
interface Props { |
||||
onConfirm(): void; |
||||
disabled?: boolean; |
||||
} |
||||
|
||||
interface State { |
||||
showConfirm: boolean; |
||||
} |
||||
|
||||
export class DeleteButton extends PureComponent<Props, State> { |
||||
state: State = { |
||||
showConfirm: false, |
||||
}; |
||||
|
||||
onClickDelete = (event: SyntheticEvent) => { |
||||
if (event) { |
||||
event.preventDefault(); |
||||
} |
||||
|
||||
this.setState({ |
||||
showConfirm: true, |
||||
}); |
||||
}; |
||||
|
||||
onClickCancel = (event: SyntheticEvent) => { |
||||
if (event) { |
||||
event.preventDefault(); |
||||
} |
||||
this.setState({ |
||||
showConfirm: false, |
||||
}); |
||||
}; |
||||
|
||||
render() { |
||||
const { onConfirm, disabled } = this.props; |
||||
const showConfirmClass = this.state.showConfirm ? 'show' : 'hide'; |
||||
const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show'; |
||||
const disabledClass = disabled ? 'disabled btn-inverse' : ''; |
||||
const onClick = disabled ? () => {} : this.onClickDelete; |
||||
|
||||
return ( |
||||
<span className="delete-button-container"> |
||||
<a |
||||
className={`delete-button ${showDeleteButtonClass} btn btn-danger btn-small ${disabledClass}`} |
||||
onClick={onClick} |
||||
> |
||||
<i className="fa fa-remove" /> |
||||
</a> |
||||
<span className="confirm-delete-container"> |
||||
<span className={`confirm-delete ${showConfirmClass}`}> |
||||
<a className="btn btn-small" onClick={this.onClickCancel}> |
||||
Cancel |
||||
</a> |
||||
<a className="btn btn-danger btn-small" onClick={onConfirm}> |
||||
Confirm Delete |
||||
</a> |
||||
</span> |
||||
</span> |
||||
</span> |
||||
); |
||||
} |
||||
} |
@ -1,50 +0,0 @@ |
||||
// sets a fixed width so that the rest of the table |
||||
// isn't affected by the animation |
||||
.delete-button-container { |
||||
width: 24px; |
||||
direction: rtl; |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
|
||||
//this container is used to make sure confirm-delete isn't |
||||
//shown outside of table |
||||
.confirm-delete-container { |
||||
overflow: hidden; |
||||
width: 145px; |
||||
position: absolute; |
||||
z-index: 1; |
||||
} |
||||
|
||||
.delete-button { |
||||
position: absolute; |
||||
|
||||
&.show { |
||||
opacity: 1; |
||||
transition: opacity 0.1s ease; |
||||
z-index: 2; |
||||
} |
||||
|
||||
&.hide { |
||||
opacity: 0; |
||||
transition: opacity 0.1s ease; |
||||
z-index: 0; |
||||
} |
||||
} |
||||
|
||||
.confirm-delete { |
||||
display: flex; |
||||
align-items: flex-start; |
||||
|
||||
&.show { |
||||
opacity: 1; |
||||
transition: opacity 0.08s ease-out, transform 0.1s ease-out; |
||||
transform: translateX(0); |
||||
} |
||||
|
||||
&.hide { |
||||
opacity: 0; |
||||
transition: opacity 0.12s ease-in, transform 0.14s ease-in; |
||||
transform: translateX(100px); |
||||
} |
||||
} |
@ -1,11 +1,13 @@ |
||||
import { getFormStyles } from './getFormStyles'; |
||||
import { Label } from './Label'; |
||||
import { Input } from './Input/Input'; |
||||
import { Button } from './Button'; |
||||
|
||||
const Forms = { |
||||
getFormStyles, |
||||
Label: Label, |
||||
Input: Input, |
||||
Button: Button, |
||||
}; |
||||
|
||||
export default Forms; |
||||
|
Loading…
Reference in new issue