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:
co-authored by
Alexander Zobnin
parent
cc638e81f4
commit
baa356e26d
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user