Migration: Save dashboard modals (#22395)

* Add mechanism for imperatively showing modals

* Migration work in progress

* Reorganise save modal components

* use app events emmiter instead of root scope one

* Add center alignment to layoout component

* Make save buttons wotk

* Prettier

* Remove save dashboard logic  from dashboard srv

* Remove unused code

* Dont show error notifications

* Save modal when dashboard is overwritten

* For tweaks

* Folder picker tweaks

* Save dashboard tweaks

* Copy provisioned dashboard to clipboard

* Enable saving dashboard json to file

* Use SaveDashboardAsButton

* Review

* Align buttons in dashboard settings

* Migrate SaveDashboardAs tests

* TS fixes

* SaveDashboardForm tests migrated

* Fixe some failing tests

* Fix folder picker tests

* Fix HistoryListCtrl tests

* Remove old import

* Enable fixed positioning for folder picker select menu

* Modal: show react modals with appEvents

* Open react modals using event

* Move save dashboard modals to dashboard feature

* Make e2e pass

* Update public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx

* Hacking old vs new buttons to make all the things look like it's old good Grafana ;)

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
This commit is contained in:
Dominik Prokop
2020-03-03 08:22:26 +01:00
committed by GitHub
co-authored by Alexander Zobnin
parent cc638e81f4
commit baa356e26d
52 changed files with 1235 additions and 868 deletions
@@ -4,5 +4,7 @@ export const SaveDashboardModal = pageFactory({
url: '',
selectors: {
save: 'Dashboard settings Save Dashboard Modal Save button',
saveVariables: 'Dashboard settings Save Dashboard Modal Save variables checkbox',
saveTimerange: 'Dashboard settings Save Dashboard Modal Save timerange checkbox',
},
});
@@ -3,6 +3,7 @@ import { ThemeContext } from '../../themes';
import { getButtonStyles } from './styles';
import { ButtonContent } from './ButtonContent';
import { ButtonSize, ButtonStyles, ButtonVariant } from './types';
import { cx } from 'emotion';
type CommonProps = {
size?: ButtonSize;
@@ -34,7 +35,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, r
});
return (
<button className={styles.button} {...buttonProps} ref={ref}>
<button className={cx(styles.button, className)} {...buttonProps} ref={ref}>
<ButtonContent icon={icon}>{children}</ButtonContent>
</button>
);
@@ -62,7 +63,7 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>((
});
return (
<a className={styles.button} {...anchorProps} ref={ref}>
<a className={cx(styles.button, className)} {...anchorProps} ref={ref}>
<ButtonContent icon={icon}>{children}</ButtonContent>
</a>
);
@@ -5,6 +5,7 @@ import { IconType } from '../Icon/types';
import { Button } from '../Button/Button';
import { stylesFactory, ThemeContext } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
import { HorizontalGroup } from '..';
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
@@ -19,13 +20,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
margin-bottom: calc(${theme.spacing.d} * 2);
padding-top: ${theme.spacing.d};
`,
modalButtonRow: css`
margin-bottom: 14px;
a,
button {
margin-right: ${theme.spacing.d};
}
`,
}));
const defaultIcon: IconType = 'exclamation-triangle';
@@ -33,11 +27,10 @@ const defaultIcon: IconType = 'exclamation-triangle';
interface Props {
isOpen: boolean;
title: string;
body: string;
body: React.ReactNode;
confirmText: string;
dismissText?: string;
icon?: IconType;
onConfirm(): void;
onDismiss(): void;
}
@@ -59,14 +52,14 @@ export const ConfirmModal: FC<Props> = ({
<Modal className={styles.modal} title={title} icon={icon || defaultIcon} isOpen={isOpen} onDismiss={onDismiss}>
<div className={styles.modalContent}>
<div className={styles.modalText}>{body}</div>
<div className={styles.modalButtonRow}>
<HorizontalGroup justify="center">
<Button variant="danger" onClick={onConfirm}>
{confirmText}
</Button>
<Button variant="inverse" onClick={onDismiss}>
{dismissText}
</Button>
</div>
</HorizontalGroup>
</div>
</Modal>
);
@@ -63,7 +63,6 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
}
`,
};
case 'primary':
default:
return {
@@ -139,23 +138,23 @@ type CommonProps = {
export type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ variant, ...otherProps }, ref) => {
const theme = useContext(ThemeContext);
const styles = getButtonStyles({
theme,
size: props.size || 'md',
variant: props.variant || 'primary',
size: otherProps.size || 'md',
variant: variant || 'primary',
});
return <DefaultButton {...props} styles={styles} ref={ref} />;
return <DefaultButton {...otherProps} variant={variant} styles={styles} ref={ref} />;
});
type ButtonLinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>;
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>((props, ref) => {
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(({ variant, ...otherProps }, ref) => {
const theme = useContext(ThemeContext);
const styles = getButtonStyles({
theme,
size: props.size || 'md',
variant: props.variant || 'primary',
size: otherProps.size || 'md',
variant: variant || 'primary',
});
return <DefaultLinkButton {...props} styles={styles} ref={ref} />;
return <DefaultLinkButton {...otherProps} variant={variant} styles={styles} ref={ref} />;
});
@@ -1,20 +1,5 @@
/**
* This is a stub implementation only for correct styles to be applied
*/
import React, { useEffect } from 'react';
import { useForm, Mode, OnSubmit, DeepPartial, FormContextValues } from 'react-hook-form';
import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
import { stylesFactory, useTheme } from '../../themes';
const getFormStyles = stylesFactory((theme: GrafanaTheme) => {
return {
form: css`
margin-bottom: ${theme.spacing.formMargin};
`,
};
});
type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control'>;
@@ -26,23 +11,14 @@ interface FormProps<T> {
}
export function Form<T>({ validateOn, defaultValues, onSubmit, children }: FormProps<T>) {
const theme = useTheme();
const { handleSubmit, register, errors, control, reset, getValues } = useForm<T>({
mode: validateOn || 'onSubmit',
defaultValues: {
...defaultValues,
},
defaultValues,
});
useEffect(() => {
reset({ ...getValues(), ...defaultValues });
}, [defaultValues]);
const styles = getFormStyles(theme);
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
{children({ register, errors, control })}
</form>
);
return <form onSubmit={handleSubmit(onSubmit)}>{children({ register, errors, control })}</form>;
}
@@ -65,6 +65,7 @@ export interface SelectCommonProps<T> {
prefix?: JSX.Element | string | null;
/** Use a custom element to control Select. A proper ref to the renderControl is needed if 'portal' isn't set to null*/
renderControl?: ControlComponent<T>;
menuPosition?: 'fixed' | 'absolute';
}
export interface SelectAsyncProps<T> {
@@ -176,6 +177,7 @@ export function SelectBase<T>({
width,
invalid,
components,
menuPosition,
}: SelectBaseProps<T>) {
const theme = useTheme();
const styles = getSelectStyles(theme);
@@ -246,6 +248,7 @@ export function SelectBase<T>({
renderControl,
captureMenuScroll: false,
menuPlacement: 'auto',
menuPosition,
};
// width property is deprecated in favor of size or className
@@ -1,4 +1,4 @@
import React, { HTMLProps, forwardRef } from 'react';
import React, { HTMLProps } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion';
import { stylesFactory, useTheme } from '../../../themes';
@@ -12,6 +12,17 @@ export interface Props extends Omit<HTMLProps<HTMLTextAreaElement>, 'size'> {
size?: FormInputSize;
}
export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(({ invalid, size = 'auto', ...props }, ref) => {
const theme = useTheme();
const styles = getTextAreaStyle(theme, invalid);
return (
<div className={inputSizes()[size]}>
<textarea className={styles.textarea} {...props} ref={ref} />
</div>
);
});
const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) => {
return {
textarea: cx(
@@ -25,14 +36,3 @@ const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) =>
),
};
});
export const TextArea = forwardRef<HTMLTextAreaElement, Props>(({ invalid, size = 'auto', ...props }, ref) => {
const theme = useTheme();
const styles = getTextAreaStyle(theme, invalid);
return (
<div className={inputSizes()[size]}>
<textarea ref={ref} className={styles.textarea} {...props} />
</div>
);
});
@@ -28,4 +28,5 @@ const Forms = {
TextArea,
};
export { ButtonVariant } from './Button';
export default Forms;
@@ -648,7 +648,8 @@ export type IconType =
| 'snowflake-o'
| 'superpowers'
| 'wpexplorer'
| 'meetup';
| 'meetup'
| 'copy';
export const getAvailableIcons = (): IconType[] => [
'glass',
@@ -8,7 +8,7 @@ enum Orientation {
Vertical,
}
type Spacing = 'xs' | 'sm' | 'md' | 'lg';
type Justify = 'flex-start' | 'flex-end' | 'space-between';
type Justify = 'flex-start' | 'flex-end' | 'space-between' | 'center';
export interface LayoutProps {
children: React.ReactNode[];
@@ -6,63 +6,6 @@ import { GrafanaTheme } from '@grafana/data';
import { Icon } from '../Icon/Icon';
import { IconType } from '../Icon/types';
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
position: fixed;
z-index: ${theme.zIndex.modal};
background: ${theme.colors.pageBg};
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
background-clip: padding-box;
outline: none;
width: 750px;
max-width: 100%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 10%;
`,
modalBackdrop: css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: ${theme.zIndex.modalBackdrop};
background-color: ${theme.colors.blueFaint};
opacity: 0.8;
backdrop-filter: blur(4px);
`,
modalHeader: css`
background: ${theme.background.pageHeader};
box-shadow: ${theme.shadow.pageHeader};
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
display: flex;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
`,
modalHeaderIcon: css`
margin-right: ${theme.spacing.md};
font-size: inherit;
&:before {
vertical-align: baseline;
}
`,
modalHeaderClose: css`
margin-left: auto;
padding: 9px ${theme.spacing.d};
`,
modalContent: css`
padding: calc(${theme.spacing.d} * 2);
overflow: auto;
width: 100%;
max-height: calc(90vh - ${theme.spacing.d} * 2);
`,
}));
interface Props {
icon?: IconType;
title: string | JSX.Element;
@@ -125,3 +68,60 @@ export class UnthemedModal extends React.PureComponent<Props> {
}
export const Modal = withTheme(UnthemedModal);
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
position: fixed;
z-index: ${theme.zIndex.modal};
background: ${theme.colors.pageBg};
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
background-clip: padding-box;
outline: none;
width: 750px;
max-width: 100%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 10%;
`,
modalBackdrop: css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: ${theme.zIndex.modalBackdrop};
background-color: ${theme.colors.blueFaint};
opacity: 0.8;
backdrop-filter: blur(4px);
`,
modalHeader: css`
background: ${theme.background.pageHeader};
box-shadow: ${theme.shadow.pageHeader};
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
display: flex;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
`,
modalHeaderIcon: css`
margin-right: ${theme.spacing.md};
font-size: inherit;
&:before {
vertical-align: baseline;
}
`,
modalHeaderClose: css`
margin-left: auto;
padding: 9px ${theme.spacing.d};
`,
modalContent: css`
padding: calc(${theme.spacing.d} * 2);
overflow: auto;
width: 100%;
max-height: calc(90vh - ${theme.spacing.d} * 2);
`,
}));
@@ -0,0 +1,63 @@
import React from 'react';
interface ModalsContextState {
component: React.ComponentType<any> | null;
props: any;
showModal: <T>(component: React.ComponentType<T>, props: T) => void;
hideModal: () => void;
}
const ModalsContext = React.createContext<ModalsContextState>({
component: null,
props: {},
showModal: () => {},
hideModal: () => {},
});
interface ModalsProviderProps {
children: React.ReactNode;
/** Set default component to render as modal. Usefull when rendering modals from Angular */
component?: React.ComponentType<any> | null;
/** Set default component props. Usefull when rendering modals from Angular */
props?: any;
}
export class ModalsProvider extends React.Component<ModalsProviderProps, ModalsContextState> {
constructor(props: ModalsProviderProps) {
super(props);
this.state = {
component: props.component || null,
props: props.props || {},
showModal: this.showModal,
hideModal: this.hideModal,
};
}
showModal = (component: React.ComponentType<any>, props: any) => {
this.setState({
component,
props,
});
};
hideModal = () => {
this.setState({
component: null,
props: {},
});
};
render() {
return <ModalsContext.Provider value={this.state}>{this.props.children}</ModalsContext.Provider>;
}
}
export const ModalRoot = () => (
<ModalsContext.Consumer>
{({ component: Component, props }) => {
return Component ? <Component {...props} /> : null;
}}
</ModalsContext.Consumer>
);
export const ModalsController = ModalsContext.Consumer;
+4 -1
View File
@@ -40,6 +40,9 @@ export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
export { List } from './List/List';
export { TagsInput } from './TagsInput/TagsInput';
export { Modal } from './Modal/Modal';
export { ModalsProvider, ModalRoot, ModalsController } from './Modal/ModalsContext';
export { ConfirmModal } from './ConfirmModal/ConfirmModal';
export { QueryField } from './QueryField/QueryField';
@@ -138,7 +141,7 @@ export {
} from './FieldConfigs/select';
// Next-gen forms
export { default as Forms } from './Forms';
export { default as Forms, ButtonVariant } from './Forms';
export { ValuePicker } from './ValuePicker/ValuePicker';
export { fieldMatchersUI } from './MatchersUI/fieldMatchersUI';
export { getStandardFieldConfigs } from './FieldConfigs/standardFieldConfigEditors';