Commit 2a21f067 by Torkel Ödegaard Committed by GitHub

ButtonSelect & RefreshPicker: Rewrite of components to use new emotion based…

ButtonSelect & RefreshPicker: Rewrite of components to use new emotion based ToolbarButton & Menu (#30510)

* ButtonSelect: Trying to rewrite the button select to use ToggleButtonGroup & Menu

* minor update

* Progress

* Updated

* Moving all the explore scenarios into the refresh picker component

* Minor fixes

* Fixed responsive part of run button

* More minor fixes

* typescript fix

* Update packages/grafana-ui/src/components/Icon/Icon.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Update packages/grafana-ui/src/components/Menu/Menu.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Review feedback fixes and more

* Fixes small ts issue

* Updated return to dashboard button and tests, moved ButtonSelect out of LegacyForms

* fixed ts issue

* Fixed test

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
parent 8c1a79f2
......@@ -9,7 +9,7 @@ e2e.scenario({
scenario: () => {
e2e.pages.Explore.visit();
e2e.pages.Explore.General.container().should('have.length', 1);
e2e.pages.Explore.General.runButton().should('have.length', 1);
e2e.components.RefreshPicker.runButton().should('have.length', 1);
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()
.should('be.visible')
......
......@@ -89,6 +89,9 @@ export const Components = {
title: (title: string) => `Tab ${title}`,
active: () => '[class*="-activeTabStyle"]',
},
RefreshPicker: {
runButton: 'RefreshPicker run button',
},
QueryTab: {
content: 'Query editor tab content',
queryInspectorButton: 'Query inspector button',
......
......@@ -129,7 +129,6 @@ export const Pages = {
General: {
container: 'Explore',
graph: 'Explore Graph',
runButton: 'Run button',
table: 'Explore Table',
},
Toolbar: {
......
......@@ -171,7 +171,7 @@ function getButtonVariantStyles(from: string, to: string, textColor: string, the
`;
}
function getPropertiesForVariant(theme: GrafanaTheme, variant: ButtonVariant) {
export function getPropertiesForVariant(theme: GrafanaTheme, variant: ButtonVariant) {
switch (variant) {
case 'secondary':
const from = theme.isLight ? theme.palette.gray7 : theme.palette.gray15;
......
import React, { forwardRef, HTMLAttributes } from 'react';
import { css } from 'emotion';
import { css, cx } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '../../themes';
export interface Props extends HTMLAttributes<HTMLDivElement> {
className?: string;
noSpacing?: boolean;
}
export const ButtonGroup = forwardRef<HTMLDivElement, Props>(({ noSpacing, children, ...rest }, ref) => {
export const ButtonGroup = forwardRef<HTMLDivElement, Props>(({ noSpacing, className, children, ...rest }, ref) => {
const styles = useStyles(getStyles);
const className = noSpacing ? styles.wrapperNoSpacing : styles.wrapper;
const mainClass = cx(
{
[styles.wrapper]: !noSpacing,
[styles.wrapperNoSpacing]: noSpacing,
},
className
);
return (
<div ref={ref} className={className} {...rest}>
<div ref={ref} className={mainClass} {...rest}>
{children}
</div>
);
......@@ -41,7 +48,7 @@ const getStyles = (theme: GrafanaTheme) => ({
border-radius: 0;
border-right: 0;
&:last-child {
&:last-of-type {
border-radius: 0 ${theme.border.radius.sm} ${theme.border.radius.sm} 0;
border-right: 1px solid ${theme.colors.border2};
}
......
import React from 'react';
import { ToolbarButton, ButtonGroup, useTheme, VerticalGroup } from '@grafana/ui';
import { ToolbarButton, ButtonGroup, useTheme, VerticalGroup, HorizontalGroup } from '@grafana/ui';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
export default {
......@@ -41,6 +41,22 @@ export const List = () => {
<ToolbarButton icon="sync" />
<ToolbarButton isOpen={false} narrow />
</ButtonGroup>
<br />
As primary and destructive variant
<HorizontalGroup>
<ButtonGroup noSpacing>
<ToolbarButton variant="primary" icon="sync">
Run query
</ToolbarButton>
<ToolbarButton isOpen={false} narrow variant="primary" />
</ButtonGroup>
<ButtonGroup noSpacing>
<ToolbarButton variant="destructive" icon="sync">
Run query
</ToolbarButton>
<ToolbarButton isOpen={false} narrow variant="destructive" />
</ButtonGroup>
</HorizontalGroup>
</VerticalGroup>
</div>
);
......
......@@ -5,6 +5,7 @@ import { styleMixins, useStyles } from '../../themes';
import { IconName } from '../../types/icon';
import { Tooltip } from '../Tooltip/Tooltip';
import { Icon } from '../Icon/Icon';
import { ButtonVariant, getPropertiesForVariant } from './Button';
export interface Props extends HTMLAttributes<HTMLButtonElement> {
/** Icon name */
......@@ -19,27 +20,31 @@ export interface Props extends HTMLAttributes<HTMLButtonElement> {
fullWidth?: boolean;
/** reduces padding to xs */
narrow?: boolean;
/** variant */
variant?: ButtonVariant;
}
export const ToolbarButton = forwardRef<HTMLButtonElement, Props>(
({ tooltip, icon, className, children, imgSrc, fullWidth, isOpen, narrow, ...rest }, ref) => {
({ tooltip, icon, className, children, imgSrc, fullWidth, isOpen, narrow, variant, ...rest }, ref) => {
const styles = useStyles(getStyles);
const contentStyles = cx({
[styles.content]: true,
[styles.contentWithIcon]: !!icon,
[styles.contentWithRightIcon]: isOpen !== undefined,
});
const buttonStyles = cx(
{
[styles.button]: true,
[styles.buttonFullWidth]: fullWidth,
[styles.narrow]: narrow,
[styles.primaryVariant]: variant === 'primary',
[styles.destructiveVariant]: variant === 'destructive',
},
className
);
const contentStyles = cx({
[styles.content]: true,
[styles.contentWithIcon]: !!icon,
[styles.contentWithRightIcon]: isOpen !== undefined,
});
const body = (
<button ref={ref} className={buttonStyles} {...rest}>
{icon && <Icon name={icon} size={'lg'} />}
......@@ -60,44 +65,58 @@ export const ToolbarButton = forwardRef<HTMLButtonElement, Props>(
}
);
const getStyles = (theme: GrafanaTheme) => ({
button: css`
background: ${theme.colors.bg1};
border: 1px solid ${theme.colors.border2};
height: ${theme.height.md}px;
padding: 0 ${theme.spacing.sm};
color: ${theme.colors.textWeak};
border-radius: ${theme.border.radius.sm};
display: flex;
align-items: center;
const getStyles = (theme: GrafanaTheme) => {
const primaryVariant = getPropertiesForVariant(theme, 'primary');
const destructiveVariant = getPropertiesForVariant(theme, 'destructive');
return {
button: css`
background: ${theme.colors.bg1};
border: 1px solid ${theme.colors.border2};
height: ${theme.height.md}px;
padding: 0 ${theme.spacing.sm};
color: ${theme.colors.textWeak};
border-radius: ${theme.border.radius.sm};
line-height: ${theme.height.md - 2}px;
display: flex;
align-items: center;
&:focus {
outline: none;
}
&:focus {
outline: none;
}
&:hover {
color: ${theme.colors.text};
background: ${styleMixins.hoverColor(theme.colors.bg1, theme)};
}
`,
narrow: css`
padding: 0 ${theme.spacing.xs};
`,
img: css`
width: 16px;
height: 16px;
margin-right: ${theme.spacing.sm};
`,
buttonFullWidth: css`
flex-grow: 1;
`,
content: css`
flex-grow: 1;
`,
contentWithIcon: css`
padding-left: ${theme.spacing.sm};
`,
contentWithRightIcon: css`
padding-right: ${theme.spacing.sm};
`,
});
&:hover {
color: ${theme.colors.text};
background: ${styleMixins.hoverColor(theme.colors.bg1, theme)};
}
`,
narrow: css`
padding: 0 ${theme.spacing.xs};
`,
img: css`
width: 16px;
height: 16px;
margin-right: ${theme.spacing.sm};
`,
buttonFullWidth: css`
flex-grow: 1;
`,
content: css`
flex-grow: 1;
`,
contentWithIcon: css`
padding-left: ${theme.spacing.sm};
`,
contentWithRightIcon: css`
padding-right: ${theme.spacing.xs};
`,
primaryVariant: css`
border-color: ${primaryVariant.borderColor};
${primaryVariant.variantStyles}
`,
destructiveVariant: css`
border-color: ${destructiveVariant.borderColor};
${destructiveVariant.variantStyles}
`,
};
};
import React from 'react';
import React, { FC } from 'react';
import { action } from '@storybook/addon-actions';
import { withKnobs, object, text } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../../../utils/storybook/withCenteredStory';
import { UseState } from '../../../../utils/storybook/UseState';
import { withKnobs, object } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
import { SelectableValue } from '@grafana/data';
import { ButtonSelect } from './ButtonSelect';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
export default {
title: 'Forms/Select/ButtonSelect',
......@@ -12,7 +13,7 @@ export default {
decorators: [withCenteredStory, withKnobs],
};
export const basic = () => {
export const Basic: FC = () => {
const initialState: SelectableValue<string> = { label: 'A label', value: 'A value' };
const value = object<SelectableValue<string>>('Selected Value:', initialState);
const options = object<Array<SelectableValue<string>>>('Options:', [
......@@ -21,22 +22,24 @@ export const basic = () => {
]);
return (
<UseState initialState={value}>
{(value, updateValue) => {
return (
<ButtonSelect
value={value}
options={options}
onChange={(value) => {
action('onChanged fired')(value);
updateValue(value);
}}
label={value.label ? value.label : ''}
className="refresh-select"
iconClass={text('iconClass', 'clock-nine')}
/>
);
}}
</UseState>
<DashboardStoryCanvas>
<UseState initialState={value}>
{(value, updateValue) => {
return (
<div style={{ marginLeft: '100px', position: 'relative', display: 'inline-block' }}>
<ButtonSelect
value={value}
options={options}
onChange={(value) => {
action('onChanged fired')(value);
updateValue(value as any);
}}
className="refresh-select"
/>
</div>
);
}}
</UseState>
</DashboardStoryCanvas>
);
};
import React, { useState, HTMLAttributes } from 'react';
import { PopoverContent } from '../Tooltip/Tooltip';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { ButtonVariant, ToolbarButton } from '../Button';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
import { css } from 'emotion';
import { useStyles } from '../../themes/ThemeContext';
import { Menu, MenuItemsGroup } from '../Menu/Menu';
export interface Props<T> extends HTMLAttributes<HTMLButtonElement> {
className?: string;
options: Array<SelectableValue<T>>;
value?: SelectableValue<T>;
maxMenuHeight?: number;
onChange: (item: SelectableValue<T>) => void;
tooltipContent?: PopoverContent;
narrow?: boolean;
variant?: ButtonVariant;
}
/**
* @internal
* A temporary component until we have a proper dropdown component
*/
export const ButtonSelect = React.memo(<T,>(props: Props<T>) => {
const { className, options, value, onChange, narrow, variant, ...restProps } = props;
const [isOpen, setIsOpen] = useState<boolean>(false);
const styles = useStyles(getStyles);
const onCloseMenu = () => {
setIsOpen(false);
};
const onToggle = (event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
setIsOpen(!isOpen);
};
const onChangeInternal = (item: SelectableValue<T>) => {
onChange(item);
setIsOpen(false);
};
const menuGroup: MenuItemsGroup = {
items: options.map((item) => ({
label: (item.label || item.value) as string,
onClick: () => onChangeInternal(item),
active: item.value === value?.value,
})),
};
return (
<>
<ToolbarButton
className={className}
isOpen={isOpen}
onClick={onToggle}
narrow={narrow}
variant={variant}
{...restProps}
>
{value?.label || value?.value}
</ToolbarButton>
{isOpen && (
<div className={styles.menuWrapper}>
<ClickOutsideWrapper onClick={onCloseMenu} parent={document}>
<Menu items={[menuGroup]} />
</ClickOutsideWrapper>
</div>
)}
</>
);
});
ButtonSelect.displayName = 'ButtonSelect';
const getStyles = (theme: GrafanaTheme) => {
return {
wrapper: css`
position: relative;
display: inline-flex;
`,
menuWrapper: css`
position: absolute;
z-index: ${theme.zIndex.dropdown};
top: ${theme.spacing.formButtonHeight + 1}px;
right: 0;
`,
};
};
import React, { PureComponent, ReactElement } from 'react';
import Select from './Select';
import { PopoverContent } from '../../../Tooltip/Tooltip';
import { Icon } from '../../../Icon/Icon';
import { IconName } from '../../../../types';
import { SelectableValue } from '@grafana/data';
interface ButtonComponentProps {
label: ReactElement | string | undefined;
className: string | undefined;
iconClass?: string;
}
// eslint-disable-next-line react/display-name
const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => {
const { label, className, iconClass } = buttonProps;
return (
<div // changed to div because of FireFox on MacOs issue below
ref={props.innerRef}
className={`btn navbar-button navbar-button--tight ${className}`}
onClick={props.selectProps.menuIsOpen ? props.selectProps.onMenuClose : props.selectProps.onMenuOpen}
onBlur={props.selectProps.onMenuClose}
tabIndex={0} // necessary to get onBlur to work https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
>
<div className="select-button">
{iconClass && <Icon className={'select-button-icon'} name={iconClass as IconName} size="lg" />}
<span className="select-button-value">{label ? label : ''}</span>
{!props.menuIsOpen && <Icon name="angle-down" style={{ marginBottom: 0 }} size="lg" />}
{props.menuIsOpen && <Icon name="angle-up" style={{ marginBottom: 0 }} size="lg" />}
</div>
</div>
);
};
export interface Props<T> {
className: string | undefined;
options: Array<SelectableValue<T>>;
value?: SelectableValue<T>;
label?: ReactElement | string;
iconClass?: string;
components?: any;
maxMenuHeight?: number;
onChange: (item: SelectableValue<T>) => void;
tooltipContent?: PopoverContent;
isMenuOpen?: boolean;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
tabSelectsValue?: boolean;
autoFocus?: boolean;
}
export class ButtonSelect<T> extends PureComponent<Props<T>> {
onChange = (item: SelectableValue<T>) => {
const { onChange } = this.props;
onChange(item);
};
render() {
const {
className,
options,
value,
label,
iconClass,
components,
maxMenuHeight,
tooltipContent,
isMenuOpen,
onOpenMenu,
onCloseMenu,
tabSelectsValue,
autoFocus = true,
} = this.props;
const combinedComponents = {
...components,
Control: ButtonComponent({ label, className, iconClass }),
};
return (
<Select
autoFocus={autoFocus}
backspaceRemovesValue={false}
isClearable={false}
isSearchable={false}
options={options}
onChange={this.onChange}
value={value}
isOpen={isMenuOpen}
onOpenMenu={onOpenMenu}
onCloseMenu={onCloseMenu}
maxMenuHeight={maxMenuHeight}
components={combinedComponents}
className="gf-form-select-box-button-select"
tooltipContent={tooltipContent}
tabSelectsValue={tabSelectsValue}
/>
);
}
}
......@@ -62,7 +62,7 @@ export const Icon = React.forwardRef<HTMLDivElement, IconProps>(
/* Temporary solution to display also font awesome icons */
if (name?.startsWith('fa fa-')) {
return <i className={cx(name, className)} {...divElementProps} style={style} />;
return <i className={getFontAwesomeIconStyles(name, className)} {...divElementProps} style={style} />;
}
const Component = getIconComponent(name, type);
......@@ -84,6 +84,16 @@ export const Icon = React.forwardRef<HTMLDivElement, IconProps>(
Icon.displayName = 'Icon';
function getFontAwesomeIconStyles(iconName: string, className?: string): string {
return cx(
iconName,
{
'fa-spin': iconName === 'fa fa-spinner',
},
className
);
}
/* Transform string with px to number and add 2 pxs as path in svg is 2px smaller */
export const getSvgSize = (size: IconSize) => {
switch (size) {
......
......@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import { css, cx } from 'emotion';
import { GrafanaTheme, LinkTarget } from '@grafana/data';
import { List } from '../List/List';
import { useStyles } from '../../themes';
import { styleMixins, useStyles } from '../../themes';
import { Icon } from '../Icon/Icon';
import { IconName } from '../../types';
......@@ -19,7 +19,10 @@ export interface MenuItem {
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
/** Handler for the click behaviour */
group?: string;
/** Active */
active?: boolean;
}
export interface MenuItemsGroup {
/** Label for the menu items group */
label?: string;
......@@ -36,7 +39,7 @@ export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
onClose?: () => void;
}
/** @public */
/** @alpha */
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(({ header, items, onClose, ...otherProps }, ref) => {
const styles = useStyles(getMenuStyles);
const onClick = useCallback(() => {
......@@ -83,6 +86,7 @@ const MenuGroup: React.FC<MenuGroupProps> = ({ group, onClick }) => {
label={item.label}
target={item.target}
icon={item.icon}
active={item.active}
onClick={(e: React.MouseEvent<HTMLElement>) => {
// We can have both url and onClick and we want to allow user to open the link in new tab/window
const isSpecialKeyPressed = e.ctrlKey || e.metaKey || e.shiftKey;
......@@ -113,89 +117,91 @@ interface MenuItemProps {
label: string;
icon?: IconName;
url?: string;
target?: string;
target?: LinkTarget;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
className?: string;
active?: boolean;
}
const MenuItemComponent: React.FC<MenuItemProps> = React.memo(({ url, icon, label, target, onClick, className }) => {
const styles = useStyles(getMenuStyles);
return (
<div className={styles.item}>
<a href={url ? url : undefined} target={target} className={cx(className, styles.link)} onClick={onClick}>
{icon && <Icon name={icon} className={styles.icon} />} {label}
</a>
</div>
);
});
const MenuItemComponent: React.FC<MenuItemProps> = React.memo(
({ url, icon, label, target, onClick, className, active }) => {
const styles = useStyles(getMenuStyles);
const itemStyle = cx(
{
[styles.item]: true,
[styles.activeItem]: active,
},
className
);
return (
<div className={itemStyle}>
<a
href={url ? url : undefined}
target={target}
className={styles.link}
onClick={onClick}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
>
{icon && <Icon name={icon} className={styles.icon} />} {label}
</a>
</div>
);
}
);
MenuItemComponent.displayName = 'MenuItemComponent';
const getMenuStyles = (theme: GrafanaTheme) => {
const { white, black, dark1, dark2, dark7, gray1, gray3, gray5, gray7 } = theme.palette;
const lightThemeStyles = {
linkColor: dark2,
linkColorHover: theme.colors.link,
wrapperBg: gray7,
wrapperShadow: gray3,
itemColor: black,
groupLabelColor: gray1,
itemBgHover: gray5,
headerBg: white,
headerSeparator: white,
};
const darkThemeStyles = {
linkColor: theme.colors.text,
linkColorHover: white,
wrapperBg: dark2,
wrapperShadow: black,
itemColor: white,
groupLabelColor: theme.colors.textWeak,
itemBgHover: dark7,
headerBg: dark1,
headerSeparator: dark7,
};
const styles = theme.isDark ? darkThemeStyles : lightThemeStyles;
const linkColor = theme.colors.text;
const linkColorHover = theme.colors.linkHover;
const wrapperBg = theme.colors.formInputBg;
const wrapperShadow = theme.isDark ? theme.palette.black : theme.palette.gray3;
const groupLabelColor = theme.colors.textWeak;
const itemBgHover = styleMixins.hoverColor(theme.colors.bg1, theme);
const headerBg = theme.colors.formInputBg;
const headerSeparator = theme.colors.border3;
return {
header: css`
padding: 4px;
border-bottom: 1px solid ${styles.headerSeparator};
background: ${styles.headerBg};
border-bottom: 1px solid ${headerSeparator};
background: ${headerBg};
margin-bottom: ${theme.spacing.xs};
border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0;
`,
wrapper: css`
background: ${styles.wrapperBg};
z-index: 1;
box-shadow: 0 2px 5px 0 ${styles.wrapperShadow};
min-width: 200px;
background: ${wrapperBg};
box-shadow: 0 2px 5px 0 ${wrapperShadow};
display: inline-block;
border-radius: ${theme.border.radius.sm};
`,
link: css`
color: ${styles.linkColor};
color: ${linkColor};
display: flex;
cursor: pointer;
&:hover {
color: ${styles.linkColorHover};
color: ${linkColorHover};
text-decoration: none;
}
`,
item: css`
background: none;
padding: 4px 8px;
color: ${styles.itemColor};
padding: 5px 12px 5px 10px;
border-left: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
&:hover {
background: ${styles.itemBgHover};
background: ${itemBgHover};
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
border-image-slice: 1;
}
`,
activeItem: css`
background: ${theme.colors.bg2};
`,
groupLabel: css`
color: ${styles.groupLabelColor};
color: ${groupLabelColor};
font-size: ${theme.typography.size.sm};
line-height: ${theme.typography.lineHeight.md};
padding: ${theme.spacing.xs} ${theme.spacing.sm};
......
......@@ -3,6 +3,9 @@ import { action } from '@storybook/addon-actions';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
import { RefreshPicker } from '@grafana/ui';
import { StoryExample } from '../../utils/storybook/StoryExample';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
import { HorizontalGroup } from '../Layout/Layout';
export default {
title: 'Pickers and Editors/RefreshPicker',
......@@ -10,24 +13,56 @@ export default {
decorators: [withCenteredStory],
};
export const basic = () => {
export const Examples = () => {
const intervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'];
const onIntervalChanged = (interval: string) => {
action('onIntervalChanged fired')(interval);
};
const onRefresh = () => {
action('onRefresh fired')();
};
return (
<UseState initialState={'1h'}>
{(value, updateValue) => {
return (
<RefreshPicker
tooltip="Hello world"
value={value}
intervals={['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']}
onIntervalChanged={(interval) => {
action('onIntervalChanged fired')(interval);
}}
onRefresh={() => {
action('onRefresh fired')();
}}
/>
);
}}
</UseState>
<DashboardStoryCanvas>
<UseState initialState={'1h'}>
{(value, updateValue) => {
return (
<HorizontalGroup>
<StoryExample name="Simple">
<RefreshPicker
tooltip="Hello world"
value={value}
intervals={intervals}
onIntervalChanged={onIntervalChanged}
onRefresh={onRefresh}
/>
</StoryExample>
<StoryExample name="With text">
<RefreshPicker
tooltip="Hello world"
value={value}
text="Run query"
intervals={intervals}
onIntervalChanged={onIntervalChanged}
onRefresh={onRefresh}
/>
</StoryExample>
<StoryExample name="With text and loading">
<RefreshPicker
tooltip="Hello world"
value={value}
text="Run query"
isLoading={true}
intervals={intervals}
onIntervalChanged={onIntervalChanged}
onRefresh={onRefresh}
/>
</StoryExample>
</HorizontalGroup>
);
}}
</UseState>
</DashboardStoryCanvas>
);
};
import React, { Component } from 'react';
import classNames from 'classnames';
import React, { PureComponent } from 'react';
import { SelectableValue } from '@grafana/data';
import { css } from 'emotion';
import { Tooltip } from '../Tooltip/Tooltip';
import { Icon } from '../Icon/Icon';
import { ButtonSelect } from '../Forms/Legacy/Select/ButtonSelect';
import memoizeOne from 'memoize-one';
import { GrafanaTheme } from '@grafana/data';
import { withTheme } from '../../themes';
import { ButtonSelect } from '../Dropdown/ButtonSelect';
import { ButtonGroup, ButtonVariant, ToolbarButton } from '../Button';
import { selectors } from '@grafana/e2e-selectors';
// Default intervals used in the refresh picker component
export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'];
const getStyles = memoizeOne((theme: GrafanaTheme) => {
return {
selectButton: css`
label: selectButton;
.select-button-value {
color: ${theme.palette.orange};
}
`,
};
});
export interface Props {
intervals?: string[];
onRefresh?: () => any;
onIntervalChanged: (interval: string) => void;
value?: string;
tooltip?: string;
hasLiveOption?: boolean;
// You can supply your own refresh button element. In that case onRefresh and tooltip are ignored.
refreshButton?: React.ReactNode;
buttonSelectClassName?: string;
theme: GrafanaTheme;
isLoading?: boolean;
isLive?: boolean;
text?: string;
noIntervalPicker?: boolean;
width?: string;
primary?: boolean;
}
export class RefreshPickerBase extends Component<Props> {
export class RefreshPicker extends PureComponent<Props> {
static offOption = { label: 'Off', value: '' };
static liveOption = { label: 'Live', value: 'LIVE' };
static isLive = (refreshInterval?: string): boolean => refreshInterval === RefreshPicker.liveOption.value;
......@@ -49,10 +35,6 @@ export class RefreshPickerBase extends Component<Props> {
const intervalsOrDefault = intervals || defaultIntervals;
const options = intervalsOrDefault.map((interval) => ({ label: interval, value: interval }));
if (this.props.hasLiveOption) {
options.unshift(RefreshPicker.liveOption);
}
options.unshift(RefreshPicker.offOption);
return options;
};
......@@ -65,69 +47,56 @@ export class RefreshPickerBase extends Component<Props> {
}
};
shouldComponentUpdate(nextProps: Props) {
const intervalsDiffer = nextProps.intervals?.some((interval, i) => this.props.intervals?.[i] !== interval);
return (
intervalsDiffer ||
this.props.onRefresh !== nextProps.onRefresh ||
this.props.onIntervalChanged !== nextProps.onIntervalChanged ||
this.props.value !== nextProps.value ||
this.props.tooltip !== nextProps.tooltip ||
this.props.hasLiveOption !== nextProps.hasLiveOption ||
this.props.refreshButton !== nextProps.refreshButton ||
this.props.buttonSelectClassName !== nextProps.buttonSelectClassName ||
this.props.theme !== nextProps.theme
);
getVariant(): ButtonVariant | undefined {
if (this.props.isLive) {
return 'primary';
}
if (this.props.isLoading) {
return 'destructive';
}
if (this.props.primary) {
return 'primary';
}
return undefined;
}
render() {
const { onRefresh, intervals, tooltip, value, refreshButton, buttonSelectClassName, theme } = this.props;
const { onRefresh, intervals, tooltip, value, text, isLoading, noIntervalPicker } = this.props;
const options = this.intervalsToOptions(intervals);
const currentValue = value || '';
const selectedValue = options.find((item) => item.value === currentValue) || RefreshPicker.offOption;
const styles = getStyles(theme);
const variant = this.getVariant();
const cssClasses = classNames({
'refresh-picker': true,
'refresh-picker--off': selectedValue.label === RefreshPicker.offOption.label,
'refresh-picker--live': selectedValue === RefreshPicker.liveOption,
});
let selectedValue = options.find((item) => item.value === currentValue) || RefreshPicker.offOption;
if (selectedValue.label === RefreshPicker.offOption.label) {
selectedValue = { value: '' };
}
return (
<div className={cssClasses}>
<div className="refresh-picker-buttons">
{refreshButton ? (
refreshButton
) : (
<Tooltip placement="top" content={tooltip!}>
<button
className="btn btn--radius-right-0 navbar-button navbar-button--border-right-0"
onClick={onRefresh!}
>
<Icon name="sync" size="lg" />
</button>
</Tooltip>
<div className="refresh-picker">
<ButtonGroup className="refresh-picker-buttons" noSpacing={true}>
<Tooltip placement="bottom" content={tooltip!}>
<ToolbarButton
onClick={onRefresh}
variant={variant}
icon={isLoading ? 'fa fa-spinner' : 'sync'}
aria-label={selectors.components.RefreshPicker.runButton}
>
{text}
</ToolbarButton>
</Tooltip>
{!noIntervalPicker && (
<ButtonSelect
value={selectedValue}
options={options}
onChange={this.onChangeSelect as any}
maxMenuHeight={380}
variant={variant}
/>
)}
<ButtonSelect
className={classNames('navbar-button--attached', styles.selectButton, buttonSelectClassName)}
value={selectedValue}
label={selectedValue.label}
options={options}
onChange={this.onChangeSelect}
maxMenuHeight={380}
/>
</div>
</ButtonGroup>
</div>
);
}
}
export const RefreshPicker = withTheme<
Props,
{
offOption: typeof RefreshPickerBase.offOption;
liveOption: typeof RefreshPickerBase.liveOption;
isLive: typeof RefreshPickerBase.isLive;
}
>(RefreshPickerBase);
.refresh-picker {
position: relative;
display: none;
.refresh-picker-buttons {
display: flex;
}
.navbar-button--border-right-0 {
border-right: 0;
}
.gf-form-input--form-dropdown {
position: static;
}
.gf-form-select-box__menu {
position: absolute;
left: 0;
width: 100%;
}
&--off {
.select-button-value {
display: none;
}
}
&--live {
.select-button-value {
animation: liveText 2s infinite;
}
}
@include media-breakpoint-up(xs) {
display: block;
}
}
@keyframes liveText {
0% {
color: $orange;
}
50% {
color: $yellow;
}
100% {
color: $orange;
}
margin-left: 10px;
}
import React from 'react';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { Button, ButtonVariant, ButtonProps } from '../Button';
import { ComponentSize } from '../../types/size';
import { SelectCommonProps, CustomControlProps } from './types';
import { SelectBase } from './SelectBase';
import { stylesFactory, useTheme } from '../../themes';
import { Icon } from '../Icon/Icon';
import { IconName } from '../../types';
interface ButtonSelectProps<T> extends Omit<SelectCommonProps<T>, 'renderControl' | 'size' | 'prefix'> {
icon?: IconName;
variant?: ButtonVariant;
size?: ComponentSize;
}
interface SelectButtonProps extends Omit<ButtonProps, 'icon'> {
icon?: IconName;
isOpen?: boolean;
}
const SelectButton = React.forwardRef<HTMLButtonElement, SelectButtonProps>(
({ icon, children, isOpen, ...buttonProps }, ref) => {
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
wrapper: css`
display: flex;
align-items: center;
justify-content: space-between;
max-width: 200px;
text-overflow: ellipsis;
`,
iconWrap: css`
padding: 0 15px 0 0;
`,
caretWrap: css`
padding-left: ${theme.spacing.sm};
margin-left: ${theme.spacing.sm};
margin-right: -${theme.spacing.sm};
height: 100%;
`,
}));
const styles = getStyles(useTheme());
return (
<Button {...buttonProps} ref={ref} icon={icon}>
<span className={styles.wrapper}>
<span>{children}</span>
<span className={styles.caretWrap}>
<Icon name={isOpen ? 'angle-up' : 'angle-down'} />
</span>
</span>
</Button>
);
}
);
SelectButton.displayName = 'SelectButton';
export function ButtonSelect<T>({
placeholder,
icon,
variant = 'primary',
size = 'md',
className,
disabled,
...selectProps
}: ButtonSelectProps<T>) {
const buttonProps = {
icon,
variant,
size,
className,
disabled,
};
return (
<SelectBase
{...selectProps}
// eslint-disable-next-line react/display-name
renderControl={React.forwardRef<any, CustomControlProps<T>>(({ onBlur, onClick, value, isOpen }, ref) => {
return (
<SelectButton {...buttonProps} ref={ref} onBlur={onBlur} onClick={onClick} isOpen={isOpen}>
{value ? value.label : placeholder}
</SelectButton>
);
})}
/>
);
}
import React, { useState } from 'react';
import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
import { SelectableValue } from '@grafana/data';
import { Icon, ButtonSelect, Select, AsyncSelect, MultiSelect, AsyncMultiSelect } from '@grafana/ui';
import { Icon, Select, AsyncSelect, MultiSelect, AsyncMultiSelect } from '@grafana/ui';
import { getAvailableIcons, IconName } from '../../types';
import { select, boolean, number } from '@storybook/addon-knobs';
import { getIconKnob } from '../../utils/storybook/knobs';
import kebabCase from 'lodash/kebabCase';
import { generateOptions } from './mockOptions';
import mdx from './Select.mdx';
......@@ -229,23 +228,6 @@ export const MultiSelectAsync = () => {
/>
);
};
export const ButtonSelectBasic = () => {
const [value, setValue] = useState<SelectableValue<string>>();
const icon = getIconKnob();
return (
<ButtonSelect
placeholder="Select all the things..."
value={value}
options={generateOptions()}
onChange={(v) => {
setValue(v);
}}
allowCustomValue
icon={icon}
{...getDynamicProps()}
/>
);
};
export const BasicSelectAsync = () => {
const [value, setValue] = useState<SelectableValue<string>>();
......
......@@ -29,7 +29,7 @@ export const Tooltip: FC<TooltipProps> = React.memo(({ children, theme, ...contr
};
return (
<>
{tooltipTriggerRef.current && (
{tooltipTriggerRef.current && controllerProps.content && (
<Popover
{...payloadProps}
onMouseEnter={showPopper}
......
......@@ -150,7 +150,6 @@ export { FieldArray } from './Forms/FieldArray';
export { default as resetSelectStyles } from './Select/resetSelectStyles';
export * from './Select/Select';
export { ButtonSelect } from './Select/ButtonSelect';
export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
export { Badge, BadgeColor, BadgeProps } from './Badge/Badge';
......@@ -166,8 +165,10 @@ export { TextArea } from './TextArea/TextArea';
export { FileUpload } from './FileUpload/FileUpload';
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
export { Card, Props as CardProps, ContainerProps, CardInnerProps, getCardStyles } from './Card/Card';
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';
export { ButtonSelect } from './Dropdown/ButtonSelect';
// Legacy forms
// Export this until we've figured out a good approach to inline form styles.
......@@ -177,7 +178,6 @@ export { InlineFormLabel } from './FormLabel/FormLabel';
import { Select, AsyncSelect } from './Forms/Legacy/Select/Select';
import { IndicatorsContainer } from './Forms/Legacy/Select/IndicatorsContainer';
import { NoOptionsMessage } from './Forms/Legacy/Select/NoOptionsMessage';
import { ButtonSelect } from './Forms/Legacy/Select/ButtonSelect';
//Input
import { Input, LegacyInputStatus } from './Forms/Legacy/Input/Input';
......@@ -193,7 +193,6 @@ const LegacyForms = {
AsyncSelect,
IndicatorsContainer,
NoOptionsMessage,
ButtonSelect,
Input,
Switch,
};
......
import React, { FC } from 'react';
import { css } from 'emotion';
import { useTheme } from '../../themes';
export interface Props {
children?: React.ReactNode;
}
export const DashboardStoryCanvas: FC<Props> = ({ children }) => {
const theme = useTheme();
const style = css`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors.dashboardBg};
`;
return <div className={style}>{children}</div>;
};
DashboardStoryCanvas.displayName = 'DashboardStoryCanvas';
import React, { FC } from 'react';
import { css } from 'emotion';
import { useTheme } from '../../themes/ThemeContext';
export interface Props {
name: string;
children?: React.ReactNode;
}
export const StoryExample: FC<Props> = ({ name, children }) => {
const theme = useTheme();
const style = css`
width: 100%;
padding: 16px;
`;
const heading = css`
color: ${theme.colors.textWeak};
margin-bottom: 16px;
`;
return (
<div className={style}>
<h5 className={heading}>{name}</h5>
{children}
</div>
);
};
StoryExample.displayName = 'StoryExample';
......@@ -217,7 +217,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
<RunButton
refreshInterval={refreshInterval}
onChangeRefreshInterval={this.onChangeRefreshInterval}
splitted={splitted}
isSmall={splitted || showSmallTimePicker}
isLive={isLive}
loading={loading || (isLive && !isPaused)}
onRun={this.onRunQuery}
......
import React, { ComponentProps } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { UnconnectedReturnToDashboardButton as ReturnToDashboardButton } from './ReturnToDashboardButton';
import { ExploreId } from 'app/types/explore';
......@@ -21,21 +21,21 @@ describe('ReturnToDashboardButton', () => {
render(<ReturnToDashboardButton {...createProps()} />);
expect(screen.getAllByTestId(/returnButton/i)).toHaveLength(2);
});
it('should not render any button if originPanelId is not provided', () => {
render(<ReturnToDashboardButton {...createProps({ originPanelId: undefined })} />);
expect(screen.queryByTestId(/returnButton/i)).toBeNull();
});
it('should not render any button if split view', () => {
render(<ReturnToDashboardButton {...createProps({ splitted: true })} />);
expect(screen.queryByTestId(/returnButton/i)).toBeNull();
});
it('should show option to return to dashboard with changes', () => {
render(<ReturnToDashboardButton {...createProps()} />);
const returnWithChangesButton = screen.getByTestId('returnButtonWithChanges');
const selectButton = returnWithChangesButton.querySelector('.select-button');
if (selectButton) {
fireEvent.click(selectButton);
}
returnWithChangesButton.click();
expect(screen.getAllByText('Return to panel with changes')).toHaveLength(1);
});
});
import React, { FC } from 'react';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import { Icon, Tooltip, LegacyForms } from '@grafana/ui';
import { Icon, Tooltip, ButtonSelect, ToolbarButton, ButtonGroup } from '@grafana/ui';
import { DataQuery } from '@grafana/data';
import kbn from '../../core/utils/kbn';
......@@ -12,8 +11,6 @@ import { ExploreId } from 'app/types/explore';
import { updateLocation } from 'app/core/actions';
import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers';
const { ButtonSelect } = LegacyForms;
interface Props {
exploreId: ExploreId;
splitted: boolean;
......@@ -37,11 +34,6 @@ export const UnconnectedReturnToDashboardButton: FC<Props> = ({
return null;
}
const panelReturnClasses = classNames('btn', 'navbar-button', {
'btn--radius-right-0': withOriginId,
'navbar-button navbar-button--border-right-0': withOriginId,
});
const cleanQueries = (queries: DataQuery[]) => {
return queries.map((query: DataQuery & { context?: string }) => {
delete query.context;
......@@ -74,26 +66,19 @@ export const UnconnectedReturnToDashboardButton: FC<Props> = ({
};
return (
<div className="explore-toolbar-content-item">
<ButtonGroup className="explore-toolbar-content-item" noSpacing>
<Tooltip content={'Return to panel'} placement="bottom">
<button
data-testid="returnButton"
title={'Return to panel'}
className={panelReturnClasses}
onClick={() => returnToPanel()}
>
<ToolbarButton data-testid="returnButton" title={'Return to panel'} onClick={() => returnToPanel()}>
<Icon name="arrow-left" />
</button>
</ToolbarButton>
</Tooltip>
<div data-testid="returnButtonWithChanges">
<ButtonSelect
className="navbar-button--attached btn--radius-left-0$"
options={[{ label: 'Return to panel with changes', value: '' }]}
onChange={() => returnToPanel({ withChanges: true })}
maxMenuHeight={380}
/>
</div>
</div>
<ButtonSelect
data-testid="returnButtonWithChanges"
options={[{ label: 'Return to panel with changes', value: '' }]}
onChange={() => returnToPanel({ withChanges: true })}
maxMenuHeight={380}
/>
</ButtonGroup>
);
};
......
......@@ -6,7 +6,7 @@ import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
const setup = (propOverrides?: object) => {
const props: Props = {
splitted: false,
isSmall: false,
loading: false,
isLive: false,
onRun: jest.fn(),
......
import React from 'react';
import { RefreshPicker, defaultIntervals } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import memoizeOne from 'memoize-one';
import { css } from 'emotion';
import classNames from 'classnames';
import { ResponsiveButton } from './ResponsiveButton';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
const getStyles = memoizeOne(() => {
return {
selectButtonOverride: css`
label: selectButtonOverride;
.select-button-value {
color: white !important;
}
`,
};
});
export type Props = {
splitted: boolean;
isSmall?: boolean;
loading: boolean;
isLive: boolean;
onRun: (loading: boolean) => void;
......@@ -31,38 +13,29 @@ export type Props = {
};
export function RunButton(props: Props) {
const { splitted, loading, onRun, onChangeRefreshInterval, refreshInterval, showDropdown, isLive } = props;
const styles = getStyles();
const { isSmall, loading, onRun, onChangeRefreshInterval, refreshInterval, showDropdown, isLive } = props;
const intervals = getTimeSrv().getValidIntervals(defaultIntervals);
let text: string | undefined;
const runButton = (
<ResponsiveButton
splitted={splitted}
title={loading && !isLive ? 'Cancel' : 'Run Query'}
onClick={() => onRun(loading)}
buttonClassName={classNames({
'navbar-button--primary': isLive || !loading,
'navbar-button--danger': loading && !isLive,
'btn--radius-right-0': showDropdown,
})}
icon={loading ? 'fa fa-spinner' : 'sync'}
iconClassName={loading ? ' fa-spin' : undefined}
aria-label={selectors.pages.Explore.General.runButton}
/>
);
if (isLive) {
return null;
}
if (showDropdown) {
return (
<RefreshPicker
onIntervalChanged={onChangeRefreshInterval}
value={refreshInterval}
buttonSelectClassName={`${loading ? 'navbar-button--danger' : 'navbar-button--primary'} ${
styles.selectButtonOverride
}`}
refreshButton={runButton}
intervals={intervals}
/>
);
if (!isSmall) {
text = loading ? 'Cancel' : 'Run query';
}
return runButton;
return (
<RefreshPicker
onIntervalChanged={onChangeRefreshInterval}
value={refreshInterval}
isLoading={loading}
text={text}
intervals={intervals}
isLive={isLive}
onRefresh={() => onRun(loading)}
noIntervalPicker={!showDropdown}
primary={true}
/>
);
}
......@@ -105,7 +105,8 @@
.explore-toolbar-content-item {
display: flex;
padding: 2px 2px;
padding: 0px 2px;
position: relative;
&:first-child {
padding-left: $dashboard-padding;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment