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>
This commit is contained in:
co-authored by
Alex Khomenko
parent
8c1a79f24b
commit
2a21f067b7
@@ -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;
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
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};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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');
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
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;
|
||||
|
||||
&: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};
|
||||
`,
|
||||
});
|
||||
&: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.xs};
|
||||
`,
|
||||
primaryVariant: css`
|
||||
border-color: ${primaryVariant.borderColor};
|
||||
${primaryVariant.variantStyles}
|
||||
`,
|
||||
destructiveVariant: css`
|
||||
border-color: ${destructiveVariant.borderColor};
|
||||
${destructiveVariant.variantStyles}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { FC } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
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',
|
||||
component: ButtonSelect,
|
||||
decorators: [withCenteredStory, withKnobs],
|
||||
};
|
||||
|
||||
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:', [
|
||||
initialState,
|
||||
{ label: 'Another label', value: 'Another value' },
|
||||
]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import React 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 { SelectableValue } from '@grafana/data';
|
||||
import { ButtonSelect } from './ButtonSelect';
|
||||
|
||||
export default {
|
||||
title: 'Forms/Select/ButtonSelect',
|
||||
component: ButtonSelect,
|
||||
decorators: [withCenteredStory, withKnobs],
|
||||
};
|
||||
|
||||
export const basic = () => {
|
||||
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:', [
|
||||
initialState,
|
||||
{ label: 'Another label', value: 'Another value' },
|
||||
]);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,101 +0,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,42 +1,28 @@
|
||||
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);
|
||||
|
||||
@@ -1,50 +1,4 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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';
|
||||
@@ -0,0 +1,29 @@
|
||||
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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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(),
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
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 (!isSmall) {
|
||||
text = loading ? 'Cancel' : 'Run query';
|
||||
}
|
||||
|
||||
return (
|
||||
<RefreshPicker
|
||||
onIntervalChanged={onChangeRefreshInterval}
|
||||
value={refreshInterval}
|
||||
isLoading={loading}
|
||||
text={text}
|
||||
intervals={intervals}
|
||||
isLive={isLive}
|
||||
onRefresh={() => onRun(loading)}
|
||||
noIntervalPicker={!showDropdown}
|
||||
primary={true}
|
||||
/>
|
||||
);
|
||||
|
||||
if (showDropdown) {
|
||||
return (
|
||||
<RefreshPicker
|
||||
onIntervalChanged={onChangeRefreshInterval}
|
||||
value={refreshInterval}
|
||||
buttonSelectClassName={`${loading ? 'navbar-button--danger' : 'navbar-button--primary'} ${
|
||||
styles.selectButtonOverride
|
||||
}`}
|
||||
refreshButton={runButton}
|
||||
intervals={intervals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return runButton;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@
|
||||
|
||||
.explore-toolbar-content-item {
|
||||
display: flex;
|
||||
padding: 2px 2px;
|
||||
padding: 0px 2px;
|
||||
position: relative;
|
||||
|
||||
&:first-child {
|
||||
padding-left: $dashboard-padding;
|
||||
|
||||
Reference in New Issue
Block a user