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
Alexander Zobnin 6 years ago committed by GitHub
parent 4dba02dd20
commit fd2131c1e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-ui/src/components/Button/types.ts
  2. 76
      packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx
  3. 34
      packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx
  4. 160
      packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx
  5. 34
      packages/grafana-ui/src/components/ConfirmButton/DeleteButton.story.tsx
  6. 24
      packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx
  7. 17
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.story.tsx
  8. 45
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.test.tsx
  9. 64
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx
  10. 50
      packages/grafana-ui/src/components/DeleteButton/_DeleteButton.scss
  11. 2
      packages/grafana-ui/src/components/Forms/Button.story.tsx
  12. 24
      packages/grafana-ui/src/components/Forms/Button.tsx
  13. 2
      packages/grafana-ui/src/components/Forms/index.ts
  14. 1
      packages/grafana-ui/src/components/index.scss
  15. 3
      packages/grafana-ui/src/components/index.ts
  16. 4
      public/app/features/api-keys/ApiKeysPage.tsx
  17. 2
      public/app/features/teams/TeamList.tsx
  18. 4
      public/app/features/teams/TeamMemberRow.tsx
  19. 21
      public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
  20. 12
      public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap

@ -1,6 +1,6 @@
import { GrafanaTheme } from '@grafana/data';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'inverse' | 'transparent' | 'destructive';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'inverse' | 'transparent' | 'destructive' | 'link';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';

@ -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);
}
}

@ -16,7 +16,7 @@ export default {
},
};
const variants = ['primary', 'secondary', 'destructive'];
const variants = ['primary', 'secondary', 'destructive', 'link'];
const sizes = ['sm', 'md', 'lg'];

@ -52,6 +52,19 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
background: buttonVariantStyles(theme.colors.redBase, theme.colors.redShade, theme.colors.white),
};
case 'link':
return {
borderColor: 'transparent',
background: buttonVariantStyles('transparent', 'transparent', theme.colors.linkExternal),
variantStyles: css`
text-decoration: underline;
&:focus {
outline: none;
box-shadow: none;
}
`,
};
case 'primary':
default:
return {
@ -65,7 +78,7 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
type StyleProps = Omit<StyleDeps, 'variant'> & { variant: ButtonVariant };
export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleProps) => {
const { padding, fontSize, height } = getPropertiesForButtonSize(theme, size);
const { background, borderColor } = getPropertiesForVariant(theme, variant);
const { background, borderColor, variantStyles } = getPropertiesForVariant(theme, variant);
return {
button: cx(
@ -93,7 +106,10 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro
box-shadow: none;
}
`,
getFocusStyle(theme)
getFocusStyle(theme),
css`
${variantStyles}
`
),
iconWrap: css`
label: button-icon-wrap;
@ -103,8 +119,8 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro
};
});
// These are different from the standard Button where there are 5 variants.
export type ButtonVariant = 'primary' | 'secondary' | 'destructive';
// These are different from the standard Button where there are more variants.
export type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'link';
// These also needs to be different because the ButtonVariant is different
type CommonProps = {

@ -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;

@ -2,7 +2,6 @@
@import 'Cascader/Cascader';
@import 'ColorPicker/ColorPicker';
@import 'CustomScrollbar/CustomScrollbar';
@import 'DeleteButton/DeleteButton';
@import 'Drawer/Drawer';
@import 'EmptySearchResult/EmptySearchResult';
@import 'FormField/FormField';

@ -1,4 +1,5 @@
export { DeleteButton } from './DeleteButton/DeleteButton';
export { ConfirmButton } from './ConfirmButton/ConfirmButton';
export { DeleteButton } from './ConfirmButton/DeleteButton';
export { Tooltip, PopoverContent } from './Tooltip/Tooltip';
export { PopoverController } from './Tooltip/PopoverController';
export { Popover } from './Tooltip/Popover';

@ -12,7 +12,7 @@ import ApiKeysAddedModal from './ApiKeysAddedModal';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { DeleteButton, EventsWithValidation, FormLabel, Input, Switch, ValidationEvents } from '@grafana/ui';
import { EventsWithValidation, FormLabel, Input, Switch, ValidationEvents, DeleteButton } from '@grafana/ui';
import { NavModel, dateTime, isDateTime } from '@grafana/data';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { store } from 'app/store/store';
@ -287,7 +287,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
<td>{key.role}</td>
<td>{this.formatDate(key.expiration)}</td>
<td>
<DeleteButton onConfirm={() => this.onDeleteApiKey(key)} />
<DeleteButton size="sm" onConfirm={() => this.onDeleteApiKey(key)} />
</td>
</tr>
);

@ -66,7 +66,7 @@ export class TeamList extends PureComponent<Props, any> {
<a href={teamUrl}>{team.memberCount}</a>
</td>
<td className="text-right">
<DeleteButton onConfirm={() => this.deleteTeam(team)} disabled={!canDelete} />
<DeleteButton size="sm" disabled={!canDelete} onConfirm={() => this.deleteTeam(team)} />
</td>
</tr>
);

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { DeleteButton, Select } from '@grafana/ui';
import { Select, DeleteButton } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { TeamMember, teamsPermissionLevels, TeamPermissionLevel } from 'app/types';
@ -86,7 +86,7 @@ export class TeamMemberRow extends PureComponent<Props> {
{this.renderPermissions(member)}
{syncEnabled && this.renderLabels(member.labels)}
<td className="text-right">
<DeleteButton onConfirm={() => this.onRemoveMember(member)} disabled={!signedInUserIsTeamAdmin} />
<DeleteButton size="sm" disabled={!signedInUserIsTeamAdmin} onConfirm={() => this.onRemoveMember(member)} />
</td>
</tr>
);

@ -132,9 +132,10 @@ exports[`Render should render teams table 1`] = `
<td
className="text-right"
>
<DeleteButton
<Component
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -183,9 +184,10 @@ exports[`Render should render teams table 1`] = `
<td
className="text-right"
>
<DeleteButton
<Component
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -234,9 +236,10 @@ exports[`Render should render teams table 1`] = `
<td
className="text-right"
>
<DeleteButton
<Component
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -285,9 +288,10 @@ exports[`Render should render teams table 1`] = `
<td
className="text-right"
>
<DeleteButton
<Component
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -336,9 +340,10 @@ exports[`Render should render teams table 1`] = `
<td
className="text-right"
>
<DeleteButton
<Component
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -462,9 +467,10 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
<td
className="text-right"
>
<DeleteButton
<Component
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -588,9 +594,10 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
<td
className="text-right"
>
<DeleteButton
<Component
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>

@ -48,9 +48,10 @@ exports[`Render should render team members when sync enabled 1`] = `
<td
className="text-right"
>
<DeleteButton
<Component
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -137,9 +138,10 @@ exports[`Render when feature toggle editorsCanAdmin is turned off should not ren
<td
className="text-right"
>
<DeleteButton
<Component
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -226,9 +228,10 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p
<td
className="text-right"
>
<DeleteButton
<Component
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
@ -273,9 +276,10 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render s
<td
className="text-right"
>
<DeleteButton
<Component
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>

Loading…
Cancel
Save