mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
cc638e81f4
commit
baa356e26d
@ -4,5 +4,7 @@ export const SaveDashboardModal = pageFactory({
|
|||||||
url: '',
|
url: '',
|
||||||
selectors: {
|
selectors: {
|
||||||
save: 'Dashboard settings Save Dashboard Modal Save button',
|
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 { getButtonStyles } from './styles';
|
||||||
import { ButtonContent } from './ButtonContent';
|
import { ButtonContent } from './ButtonContent';
|
||||||
import { ButtonSize, ButtonStyles, ButtonVariant } from './types';
|
import { ButtonSize, ButtonStyles, ButtonVariant } from './types';
|
||||||
|
import { cx } from 'emotion';
|
||||||
|
|
||||||
type CommonProps = {
|
type CommonProps = {
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
@ -34,7 +35,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, r
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={styles.button} {...buttonProps} ref={ref}>
|
<button className={cx(styles.button, className)} {...buttonProps} ref={ref}>
|
||||||
<ButtonContent icon={icon}>{children}</ButtonContent>
|
<ButtonContent icon={icon}>{children}</ButtonContent>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -62,7 +63,7 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>((
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className={styles.button} {...anchorProps} ref={ref}>
|
<a className={cx(styles.button, className)} {...anchorProps} ref={ref}>
|
||||||
<ButtonContent icon={icon}>{children}</ButtonContent>
|
<ButtonContent icon={icon}>{children}</ButtonContent>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -5,6 +5,7 @@ import { IconType } from '../Icon/types';
|
|||||||
import { Button } from '../Button/Button';
|
import { Button } from '../Button/Button';
|
||||||
import { stylesFactory, ThemeContext } from '../../themes';
|
import { stylesFactory, ThemeContext } from '../../themes';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
|
import { HorizontalGroup } from '..';
|
||||||
|
|
||||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||||
modal: css`
|
modal: css`
|
||||||
@ -19,13 +20,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
margin-bottom: calc(${theme.spacing.d} * 2);
|
margin-bottom: calc(${theme.spacing.d} * 2);
|
||||||
padding-top: ${theme.spacing.d};
|
padding-top: ${theme.spacing.d};
|
||||||
`,
|
`,
|
||||||
modalButtonRow: css`
|
|
||||||
margin-bottom: 14px;
|
|
||||||
a,
|
|
||||||
button {
|
|
||||||
margin-right: ${theme.spacing.d};
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const defaultIcon: IconType = 'exclamation-triangle';
|
const defaultIcon: IconType = 'exclamation-triangle';
|
||||||
@ -33,11 +27,10 @@ const defaultIcon: IconType = 'exclamation-triangle';
|
|||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: React.ReactNode;
|
||||||
confirmText: string;
|
confirmText: string;
|
||||||
dismissText?: string;
|
dismissText?: string;
|
||||||
icon?: IconType;
|
icon?: IconType;
|
||||||
|
|
||||||
onConfirm(): void;
|
onConfirm(): void;
|
||||||
onDismiss(): 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}>
|
<Modal className={styles.modal} title={title} icon={icon || defaultIcon} isOpen={isOpen} onDismiss={onDismiss}>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
<div className={styles.modalText}>{body}</div>
|
<div className={styles.modalText}>{body}</div>
|
||||||
<div className={styles.modalButtonRow}>
|
<HorizontalGroup justify="center">
|
||||||
<Button variant="danger" onClick={onConfirm}>
|
<Button variant="danger" onClick={onConfirm}>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="inverse" onClick={onDismiss}>
|
<Button variant="inverse" onClick={onDismiss}>
|
||||||
{dismissText}
|
{dismissText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</HorizontalGroup>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
@ -63,7 +63,6 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'primary':
|
case 'primary':
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
@ -139,23 +138,23 @@ type CommonProps = {
|
|||||||
|
|
||||||
export type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
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 theme = useContext(ThemeContext);
|
||||||
const styles = getButtonStyles({
|
const styles = getButtonStyles({
|
||||||
theme,
|
theme,
|
||||||
size: props.size || 'md',
|
size: otherProps.size || 'md',
|
||||||
variant: props.variant || 'primary',
|
variant: variant || 'primary',
|
||||||
});
|
});
|
||||||
return <DefaultButton {...props} styles={styles} ref={ref} />;
|
return <DefaultButton {...otherProps} variant={variant} styles={styles} ref={ref} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
type ButtonLinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>;
|
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 theme = useContext(ThemeContext);
|
||||||
const styles = getButtonStyles({
|
const styles = getButtonStyles({
|
||||||
theme,
|
theme,
|
||||||
size: props.size || 'md',
|
size: otherProps.size || 'md',
|
||||||
variant: props.variant || 'primary',
|
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 React, { useEffect } from 'react';
|
||||||
import { useForm, Mode, OnSubmit, DeepPartial, FormContextValues } from 'react-hook-form';
|
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'>;
|
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>) {
|
export function Form<T>({ validateOn, defaultValues, onSubmit, children }: FormProps<T>) {
|
||||||
const theme = useTheme();
|
|
||||||
const { handleSubmit, register, errors, control, reset, getValues } = useForm<T>({
|
const { handleSubmit, register, errors, control, reset, getValues } = useForm<T>({
|
||||||
mode: validateOn || 'onSubmit',
|
mode: validateOn || 'onSubmit',
|
||||||
defaultValues: {
|
defaultValues,
|
||||||
...defaultValues,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset({ ...getValues(), ...defaultValues });
|
reset({ ...getValues(), ...defaultValues });
|
||||||
}, [defaultValues]);
|
}, [defaultValues]);
|
||||||
|
|
||||||
const styles = getFormStyles(theme);
|
return <form onSubmit={handleSubmit(onSubmit)}>{children({ register, errors, control })}</form>;
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
|
||||||
{children({ register, errors, control })}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -65,6 +65,7 @@ export interface SelectCommonProps<T> {
|
|||||||
prefix?: JSX.Element | string | null;
|
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*/
|
/** 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>;
|
renderControl?: ControlComponent<T>;
|
||||||
|
menuPosition?: 'fixed' | 'absolute';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectAsyncProps<T> {
|
export interface SelectAsyncProps<T> {
|
||||||
@ -176,6 +177,7 @@ export function SelectBase<T>({
|
|||||||
width,
|
width,
|
||||||
invalid,
|
invalid,
|
||||||
components,
|
components,
|
||||||
|
menuPosition,
|
||||||
}: SelectBaseProps<T>) {
|
}: SelectBaseProps<T>) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getSelectStyles(theme);
|
const styles = getSelectStyles(theme);
|
||||||
@ -246,6 +248,7 @@ export function SelectBase<T>({
|
|||||||
renderControl,
|
renderControl,
|
||||||
captureMenuScroll: false,
|
captureMenuScroll: false,
|
||||||
menuPlacement: 'auto',
|
menuPlacement: 'auto',
|
||||||
|
menuPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
// width property is deprecated in favor of size or className
|
// 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 { GrafanaTheme } from '@grafana/data';
|
||||||
import { css, cx } from 'emotion';
|
import { css, cx } from 'emotion';
|
||||||
import { stylesFactory, useTheme } from '../../../themes';
|
import { stylesFactory, useTheme } from '../../../themes';
|
||||||
@ -12,6 +12,17 @@ export interface Props extends Omit<HTMLProps<HTMLTextAreaElement>, 'size'> {
|
|||||||
size?: FormInputSize;
|
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) => {
|
const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) => {
|
||||||
return {
|
return {
|
||||||
textarea: cx(
|
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,
|
TextArea,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { ButtonVariant } from './Button';
|
||||||
export default Forms;
|
export default Forms;
|
||||||
|
@ -648,7 +648,8 @@ export type IconType =
|
|||||||
| 'snowflake-o'
|
| 'snowflake-o'
|
||||||
| 'superpowers'
|
| 'superpowers'
|
||||||
| 'wpexplorer'
|
| 'wpexplorer'
|
||||||
| 'meetup';
|
| 'meetup'
|
||||||
|
| 'copy';
|
||||||
|
|
||||||
export const getAvailableIcons = (): IconType[] => [
|
export const getAvailableIcons = (): IconType[] => [
|
||||||
'glass',
|
'glass',
|
||||||
|
@ -8,7 +8,7 @@ enum Orientation {
|
|||||||
Vertical,
|
Vertical,
|
||||||
}
|
}
|
||||||
type Spacing = 'xs' | 'sm' | 'md' | 'lg';
|
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 {
|
export interface LayoutProps {
|
||||||
children: React.ReactNode[];
|
children: React.ReactNode[];
|
||||||
|
@ -6,63 +6,6 @@ import { GrafanaTheme } from '@grafana/data';
|
|||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { IconType } from '../Icon/types';
|
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 {
|
interface Props {
|
||||||
icon?: IconType;
|
icon?: IconType;
|
||||||
title: string | JSX.Element;
|
title: string | JSX.Element;
|
||||||
@ -125,3 +68,60 @@ export class UnthemedModal extends React.PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = withTheme(UnthemedModal);
|
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);
|
||||||
|
`,
|
||||||
|
}));
|
||||||
|
63
packages/grafana-ui/src/components/Modal/ModalsContext.tsx
Normal file
63
packages/grafana-ui/src/components/Modal/ModalsContext.tsx
Normal file
@ -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 { List } from './List/List';
|
||||||
export { TagsInput } from './TagsInput/TagsInput';
|
export { TagsInput } from './TagsInput/TagsInput';
|
||||||
export { Modal } from './Modal/Modal';
|
export { Modal } from './Modal/Modal';
|
||||||
|
|
||||||
|
export { ModalsProvider, ModalRoot, ModalsController } from './Modal/ModalsContext';
|
||||||
|
|
||||||
export { ConfirmModal } from './ConfirmModal/ConfirmModal';
|
export { ConfirmModal } from './ConfirmModal/ConfirmModal';
|
||||||
export { QueryField } from './QueryField/QueryField';
|
export { QueryField } from './QueryField/QueryField';
|
||||||
|
|
||||||
@ -138,7 +141,7 @@ export {
|
|||||||
} from './FieldConfigs/select';
|
} from './FieldConfigs/select';
|
||||||
|
|
||||||
// Next-gen forms
|
// Next-gen forms
|
||||||
export { default as Forms } from './Forms';
|
export { default as Forms, ButtonVariant } from './Forms';
|
||||||
export { ValuePicker } from './ValuePicker/ValuePicker';
|
export { ValuePicker } from './ValuePicker/ValuePicker';
|
||||||
export { fieldMatchersUI } from './MatchersUI/fieldMatchersUI';
|
export { fieldMatchersUI } from './MatchersUI/fieldMatchersUI';
|
||||||
export { getStandardFieldConfigs } from './FieldConfigs/standardFieldConfigEditors';
|
export { getStandardFieldConfigs } from './FieldConfigs/standardFieldConfigEditors';
|
||||||
|
@ -24,6 +24,10 @@ import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/component
|
|||||||
import { HelpModal } from './components/help/HelpModal';
|
import { HelpModal } from './components/help/HelpModal';
|
||||||
import { Footer } from './components/Footer/Footer';
|
import { Footer } from './components/Footer/Footer';
|
||||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
|
import {
|
||||||
|
SaveDashboardAsButtonConnected,
|
||||||
|
SaveDashboardButtonConnected,
|
||||||
|
} from '../features/dashboard/components/SaveDashboard/SaveDashboardButton';
|
||||||
|
|
||||||
export function registerAngularDirectives() {
|
export function registerAngularDirectives() {
|
||||||
react2AngularDirective('footer', Footer, []);
|
react2AngularDirective('footer', Footer, []);
|
||||||
@ -151,4 +155,14 @@ export function registerAngularDirectives() {
|
|||||||
['onLoad', { watchDepth: 'reference', wrapApply: true }],
|
['onLoad', { watchDepth: 'reference', wrapApply: true }],
|
||||||
['onChange', { watchDepth: 'reference', wrapApply: true }],
|
['onChange', { watchDepth: 'reference', wrapApply: true }],
|
||||||
]);
|
]);
|
||||||
|
react2AngularDirective('saveDashboardButton', SaveDashboardButtonConnected, [
|
||||||
|
['getDashboard', { watchDepth: 'reference', wrapApply: true }],
|
||||||
|
['onSaveSuccess', { watchDepth: 'reference', wrapApply: true }],
|
||||||
|
]);
|
||||||
|
react2AngularDirective('saveDashboardAsButton', SaveDashboardAsButtonConnected, [
|
||||||
|
'variant',
|
||||||
|
'useNewForms',
|
||||||
|
['getDashboard', { watchDepth: 'reference', wrapApply: true }],
|
||||||
|
['onSaveSuccess', { watchDepth: 'reference', wrapApply: true }],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import ClipboardJS from 'clipboard';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: () => string;
|
text: () => string;
|
||||||
elType?: string;
|
elType?: string | React.RefForwardingComponent<any, any>;
|
||||||
onSuccess?: (evt: any) => void;
|
onSuccess?: (evt: any) => void;
|
||||||
onError?: (evt: any) => void;
|
onError?: (evt: any) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
import * as Backend from 'app/core/services/backend_srv';
|
|
||||||
import { FolderPicker } from './FolderPicker';
|
import { FolderPicker } from './FolderPicker';
|
||||||
|
|
||||||
jest.spyOn(Backend, 'getBackendSrv').mockReturnValue({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
search: jest.fn(() => [
|
getBackendSrv: () => ({
|
||||||
{ title: 'Dash 1', id: 'A' },
|
search: jest.fn(() => [
|
||||||
{ title: 'Dash 2', id: 'B' },
|
{ title: 'Dash 1', id: 'A' },
|
||||||
]),
|
{ title: 'Dash 2', id: 'B' },
|
||||||
} as any);
|
]),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('app/core/core', () => ({
|
jest.mock('app/core/services/context_srv', () => ({
|
||||||
contextSrv: {
|
contextSrv: {
|
||||||
isEditor: true,
|
user: { orgId: 1 },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -2,9 +2,10 @@ import React, { PureComponent } from 'react';
|
|||||||
import { Forms } from '@grafana/ui';
|
import { Forms } from '@grafana/ui';
|
||||||
import { AppEvents, SelectableValue } from '@grafana/data';
|
import { AppEvents, SelectableValue } from '@grafana/data';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
|
||||||
import { contextSrv } from 'app/core/core';
|
|
||||||
import appEvents from '../../app_events';
|
import appEvents from '../../app_events';
|
||||||
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
|
import { DashboardSearchHit } from '../../../types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange: ($folder: { title: string; id: number }) => void;
|
onChange: ($folder: { title: string; id: number }) => void;
|
||||||
@ -14,12 +15,11 @@ export interface Props {
|
|||||||
dashboardId?: any;
|
dashboardId?: any;
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialFolderId?: number;
|
initialFolderId?: number;
|
||||||
|
useNewForms?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
folder: SelectableValue<number>;
|
folder: SelectableValue<number>;
|
||||||
validationError: string;
|
|
||||||
hasValidationError: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FolderPicker extends PureComponent<Props, State> {
|
export class FolderPicker extends PureComponent<Props, State> {
|
||||||
@ -30,8 +30,6 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
folder: {},
|
folder: {},
|
||||||
validationError: '',
|
|
||||||
hasValidationError: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.debouncedSearch = debounce(this.getOptions, 300, {
|
this.debouncedSearch = debounce(this.getOptions, 300, {
|
||||||
@ -59,7 +57,9 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
permission: 'Edit',
|
permission: 'Edit',
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchHits = await getBackendSrv().search(params);
|
// TODO: move search to BackendSrv interface
|
||||||
|
// @ts-ignore
|
||||||
|
const searchHits = (await getBackendSrv().search(params)) as DashboardSearchHit[];
|
||||||
const options: Array<SelectableValue<number>> = searchHits.map(hit => ({ label: hit.title, value: hit.id }));
|
const options: Array<SelectableValue<number>> = searchHits.map(hit => ({ label: hit.title, value: hit.id }));
|
||||||
if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase())) {
|
if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase())) {
|
||||||
options.unshift({ label: rootName, value: 0 });
|
options.unshift({ label: rootName, value: 0 });
|
||||||
@ -72,7 +72,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
return options;
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
onFolderChange = async (newFolder: SelectableValue<number>) => {
|
onFolderChange = (newFolder: SelectableValue<number>) => {
|
||||||
if (!newFolder) {
|
if (!newFolder) {
|
||||||
newFolder = { value: 0, label: this.props.rootName };
|
newFolder = { value: 0, label: this.props.rootName };
|
||||||
}
|
}
|
||||||
@ -86,6 +86,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
createNewFolder = async (folderName: string) => {
|
createNewFolder = async (folderName: string) => {
|
||||||
|
// @ts-ignore
|
||||||
const newFolder = await getBackendSrv().createFolder({ title: folderName });
|
const newFolder = await getBackendSrv().createFolder({ title: folderName });
|
||||||
let folder = { value: -1, label: 'Not created' };
|
let folder = { value: -1, label: 'Not created' };
|
||||||
if (newFolder.id > -1) {
|
if (newFolder.id > -1) {
|
||||||
@ -107,9 +108,10 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
const options = await this.getOptions('');
|
const options = await this.getOptions('');
|
||||||
|
|
||||||
let folder: SelectableValue<number> = { value: -1 };
|
let folder: SelectableValue<number> = { value: -1 };
|
||||||
if (initialFolderId || (initialFolderId && initialFolderId > -1)) {
|
|
||||||
|
if (initialFolderId !== undefined && initialFolderId > -1) {
|
||||||
folder = options.find(option => option.value === initialFolderId) || { value: -1 };
|
folder = options.find(option => option.value === initialFolderId) || { value: -1 };
|
||||||
} else if (enableReset && initialTitle && initialFolderId === undefined) {
|
} else if (enableReset && initialTitle) {
|
||||||
folder = resetFolder;
|
folder = resetFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,34 +143,40 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { folder, validationError, hasValidationError } = this.state;
|
const { folder } = this.state;
|
||||||
const { enableCreateNew } = this.props;
|
const { enableCreateNew, useNewForms } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="gf-form-inline">
|
{useNewForms && (
|
||||||
<div className="gf-form">
|
<Forms.AsyncSelect
|
||||||
<label className="gf-form-label width-7">Folder</label>
|
loadingMessage="Loading folders..."
|
||||||
<Forms.AsyncSelect
|
defaultOptions
|
||||||
loadingMessage="Loading folders..."
|
defaultValue={folder}
|
||||||
defaultOptions
|
value={folder}
|
||||||
defaultValue={folder}
|
allowCustomValue={enableCreateNew}
|
||||||
value={folder}
|
loadOptions={this.debouncedSearch}
|
||||||
allowCustomValue={enableCreateNew}
|
onChange={this.onFolderChange}
|
||||||
loadOptions={this.debouncedSearch}
|
onCreateOption={this.createNewFolder}
|
||||||
onChange={this.onFolderChange}
|
size="sm"
|
||||||
onCreateOption={this.createNewFolder}
|
menuPosition="fixed"
|
||||||
size="sm"
|
/>
|
||||||
/>
|
)}
|
||||||
</div>
|
{!useNewForms && (
|
||||||
</div>
|
|
||||||
{hasValidationError && (
|
|
||||||
<div className="gf-form-inline">
|
<div className="gf-form-inline">
|
||||||
<div className="gf-form gf-form--grow">
|
<div className="gf-form">
|
||||||
<label className="gf-form-label text-warning gf-form-label--grow">
|
<label className="gf-form-label width-7">Folder</label>
|
||||||
<i className="fa fa-warning" />
|
<Forms.AsyncSelect
|
||||||
{validationError}
|
loadingMessage="Loading folders..."
|
||||||
</label>
|
defaultOptions
|
||||||
|
defaultValue={folder}
|
||||||
|
value={folder}
|
||||||
|
allowCustomValue={enableCreateNew}
|
||||||
|
loadOptions={this.debouncedSearch}
|
||||||
|
onChange={this.onFolderChange}
|
||||||
|
onCreateOption={this.createNewFolder}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
16
public/app/core/components/modals/AngularModalProxy.tsx
Normal file
16
public/app/core/components/modals/AngularModalProxy.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { connectWithProvider } from '../../utils/connectWithReduxStore';
|
||||||
|
import { ModalRoot, ModalsProvider } from '@grafana/ui';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that enables rendering React modals from Angular
|
||||||
|
*/
|
||||||
|
export const AngularModalProxy = connectWithProvider((props: any) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ModalsProvider {...props}>
|
||||||
|
<ModalRoot />
|
||||||
|
</ModalsProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -15,6 +15,7 @@ import { ILocationService, IRootScopeService, ITimeoutService } from 'angular';
|
|||||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||||
import { getLocationSrv } from '@grafana/runtime';
|
import { getLocationSrv } from '@grafana/runtime';
|
||||||
import { DashboardModel } from '../../features/dashboard/state';
|
import { DashboardModel } from '../../features/dashboard/state';
|
||||||
|
import { SaveDashboardModalProxy } from '../../features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
|
||||||
|
|
||||||
export class KeybindingSrv {
|
export class KeybindingSrv {
|
||||||
helpModal: boolean;
|
helpModal: boolean;
|
||||||
@ -183,7 +184,12 @@ export class KeybindingSrv {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.bind('mod+s', () => {
|
this.bind('mod+s', () => {
|
||||||
scope.appEvent(CoreEvents.saveDashboard);
|
appEvents.emit(CoreEvents.showModalReact, {
|
||||||
|
component: SaveDashboardModalProxy,
|
||||||
|
props: {
|
||||||
|
dashboard,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bind('t z', () => {
|
this.bind('t z', () => {
|
||||||
|
@ -1,22 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
|
|
||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { CoreEvents } from 'app/types';
|
import { CoreEvents } from 'app/types';
|
||||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||||
|
import { AngularModalProxy } from '../components/modals/AngularModalProxy';
|
||||||
|
|
||||||
export class UtilSrv {
|
export class UtilSrv {
|
||||||
modalScope: any;
|
modalScope: any;
|
||||||
|
reactModalRoot = document.body;
|
||||||
|
reactModalNode = document.createElement('div');
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(private $rootScope: GrafanaRootScope, private $modal: any) {}
|
constructor(private $rootScope: GrafanaRootScope, private $modal: any) {
|
||||||
|
this.reactModalNode.setAttribute('id', 'angular2ReactModalRoot');
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
appEvents.on(CoreEvents.showModal, this.showModal.bind(this), this.$rootScope);
|
appEvents.on(CoreEvents.showModal, this.showModal.bind(this), this.$rootScope);
|
||||||
appEvents.on(CoreEvents.hideModal, this.hideModal.bind(this), this.$rootScope);
|
appEvents.on(CoreEvents.hideModal, this.hideModal.bind(this), this.$rootScope);
|
||||||
appEvents.on(CoreEvents.showConfirmModal, this.showConfirmModal.bind(this), this.$rootScope);
|
appEvents.on(CoreEvents.showConfirmModal, this.showConfirmModal.bind(this), this.$rootScope);
|
||||||
|
appEvents.on(CoreEvents.showModalReact, this.showModalReact.bind(this), this.$rootScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showModalReact(options: any) {
|
||||||
|
const { component, props } = options;
|
||||||
|
const modalProps = {
|
||||||
|
component,
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
isOpen: true,
|
||||||
|
onDismiss: this.onReactModalDismiss,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const elem = React.createElement(AngularModalProxy, modalProps);
|
||||||
|
this.reactModalRoot.appendChild(this.reactModalNode);
|
||||||
|
return ReactDOM.render(elem, this.reactModalNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
onReactModalDismiss = () => {
|
||||||
|
ReactDOM.unmountComponentAtNode(this.reactModalNode);
|
||||||
|
this.reactModalRoot.removeChild(this.reactModalNode);
|
||||||
|
};
|
||||||
|
|
||||||
hideModal() {
|
hideModal() {
|
||||||
if (this.modalScope && this.modalScope.dismiss) {
|
if (this.modalScope && this.modalScope.dismiss) {
|
||||||
this.modalScope.dismiss();
|
this.modalScope.dismiss();
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect, Provider } from 'react-redux';
|
||||||
import { store } from '../../store/store';
|
import { store } from '../../store/store';
|
||||||
|
|
||||||
export function connectWithStore(WrappedComponent: any, ...args: any[]) {
|
export function connectWithStore(WrappedComponent: any, ...args: any[]) {
|
||||||
@ -9,3 +9,14 @@ export function connectWithStore(WrappedComponent: any, ...args: any[]) {
|
|||||||
return <ConnectedWrappedComponent {...props} store={store} />;
|
return <ConnectedWrappedComponent {...props} store={store} />;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function connectWithProvider(WrappedComponent: any, ...args: any[]) {
|
||||||
|
const ConnectedWrappedComponent = (connect as any)(...args)(WrappedComponent);
|
||||||
|
return (props: any) => {
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedWrappedComponent {...props} store={store} />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -8,12 +8,14 @@ import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
|
|||||||
// Components
|
// Components
|
||||||
import { DashNavButton } from './DashNavButton';
|
import { DashNavButton } from './DashNavButton';
|
||||||
import { DashNavTimeControls } from './DashNavTimeControls';
|
import { DashNavTimeControls } from './DashNavTimeControls';
|
||||||
|
import { ModalsController } from '@grafana/ui';
|
||||||
import { BackButton } from 'app/core/components/BackButton/BackButton';
|
import { BackButton } from 'app/core/components/BackButton/BackButton';
|
||||||
// State
|
// State
|
||||||
import { updateLocation } from 'app/core/actions';
|
import { updateLocation } from 'app/core/actions';
|
||||||
// Types
|
// Types
|
||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
import { CoreEvents, StoreState } from 'app/types';
|
import { CoreEvents, StoreState } from 'app/types';
|
||||||
|
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
|
||||||
|
|
||||||
export interface OwnProps {
|
export interface OwnProps {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
@ -67,12 +69,6 @@ export class DashNav extends PureComponent<Props> {
|
|||||||
appEvents.emit(CoreEvents.toggleKioskMode);
|
appEvents.emit(CoreEvents.toggleKioskMode);
|
||||||
};
|
};
|
||||||
|
|
||||||
onSave = () => {
|
|
||||||
const { $injector } = this.props;
|
|
||||||
const dashboardSrv = $injector.get('dashboardSrv');
|
|
||||||
dashboardSrv.saveDashboard();
|
|
||||||
};
|
|
||||||
|
|
||||||
onOpenSettings = () => {
|
onOpenSettings = () => {
|
||||||
this.props.updateLocation({
|
this.props.updateLocation({
|
||||||
query: { editview: 'settings' },
|
query: { editview: 'settings' },
|
||||||
@ -223,7 +219,23 @@ export class DashNav extends PureComponent<Props> {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canSave && (
|
{canSave && (
|
||||||
<DashNavButton tooltip="Save dashboard" classSuffix="save" icon="fa fa-save" onClick={this.onSave} />
|
<ModalsController>
|
||||||
|
{({ showModal, hideModal }) => {
|
||||||
|
return (
|
||||||
|
<DashNavButton
|
||||||
|
tooltip="Save dashboard"
|
||||||
|
classSuffix="save"
|
||||||
|
icon="fa fa-save"
|
||||||
|
onClick={() => {
|
||||||
|
showModal(SaveDashboardModalProxy, {
|
||||||
|
dashboard,
|
||||||
|
onDismiss: hideModal,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ModalsController>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{snapshotUrl && (
|
{snapshotUrl && (
|
||||||
|
@ -56,7 +56,9 @@ export class SettingsCtrl {
|
|||||||
|
|
||||||
this.$rootScope.onAppEvent(CoreEvents.routeUpdated, this.onRouteUpdated.bind(this), $scope);
|
this.$rootScope.onAppEvent(CoreEvents.routeUpdated, this.onRouteUpdated.bind(this), $scope);
|
||||||
this.$rootScope.appEvent(CoreEvents.dashScroll, { animate: false, pos: 0 });
|
this.$rootScope.appEvent(CoreEvents.dashScroll, { animate: false, pos: 0 });
|
||||||
this.$rootScope.onAppEvent(CoreEvents.dashboardSaved, this.onPostSave.bind(this), $scope);
|
|
||||||
|
appEvents.on(CoreEvents.dashboardSaved, this.onPostSave.bind(this), $scope);
|
||||||
|
|
||||||
this.selectors = e2e.pages.Dashboard.Settings.General.selectors;
|
this.selectors = e2e.pages.Dashboard.Settings.General.selectors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,15 +148,6 @@ export class SettingsCtrl {
|
|||||||
this.viewId = '404';
|
this.viewId = '404';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openSaveAsModal() {
|
|
||||||
this.dashboardSrv.showSaveAsModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDashboard() {
|
|
||||||
this.dashboardSrv.saveDashboard();
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDashboardJson() {
|
saveDashboardJson() {
|
||||||
this.dashboardSrv.saveJSONDashboard(this.json).then(() => {
|
this.dashboardSrv.saveJSONDashboard(this.json).then(() => {
|
||||||
this.$route.reload();
|
this.$route.reload();
|
||||||
@ -257,6 +250,10 @@ export class SettingsCtrl {
|
|||||||
url: this.dashboard.meta.folderUrl,
|
url: this.dashboard.meta.folderUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDashboard = () => {
|
||||||
|
return this.dashboard;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dashboardSettings() {
|
export function dashboardSettings() {
|
||||||
|
@ -9,14 +9,12 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="dashboard-settings__aside-actions">
|
<div class="dashboard-settings__aside-actions">
|
||||||
<button class="btn btn-primary" ng-click="ctrl.saveDashboard()" ng-show="ctrl.canSave"
|
<div ng-show="ctrl.canSave">
|
||||||
aria-label={{ctrl.selectors.saveDashBoard}}>
|
<save-dashboard-button getDashboard="ctrl.getDashboard" aria-label="Dashboard settings aside actions Save button" />
|
||||||
Save
|
</div>
|
||||||
</button>
|
<div ng-show="ctrl.canSaveAs">
|
||||||
<button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs"
|
<save-dashboard-as-button getDashboard="ctrl.getDashboard" aria-label="Dashboard settings aside actions Save as button" variant="'secondary'" useNewForms="true"/>
|
||||||
aria-label={{ctrl.selectors.saveAsDashBoard}}>
|
</div>
|
||||||
Save As...
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { Modal } from '@grafana/ui';
|
||||||
|
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
|
||||||
|
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
|
||||||
|
import { useDashboardSave } from './useDashboardSave';
|
||||||
|
import { SaveDashboardModalProps } from './types';
|
||||||
|
|
||||||
|
export const SaveDashboardAsModal: React.FC<SaveDashboardModalProps & {
|
||||||
|
isNew?: boolean;
|
||||||
|
}> = ({ dashboard, onDismiss, isNew }) => {
|
||||||
|
const { state, onDashboardSave } = useDashboardSave(dashboard);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.error && <SaveDashboardErrorProxy error={state.error} dashboard={dashboard} onDismiss={onDismiss} />}
|
||||||
|
{!state.error && (
|
||||||
|
<Modal
|
||||||
|
isOpen={true}
|
||||||
|
title="Save dashboard as..."
|
||||||
|
icon="copy"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
className={css`
|
||||||
|
width: 500px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<SaveDashboardAsForm
|
||||||
|
dashboard={dashboard}
|
||||||
|
onCancel={onDismiss}
|
||||||
|
onSuccess={onDismiss}
|
||||||
|
onSubmit={onDashboardSave}
|
||||||
|
isNew={isNew}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { Button, Forms, ModalsController } from '@grafana/ui';
|
||||||
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
|
import { connectWithProvider } from 'app/core/utils/connectWithReduxStore';
|
||||||
|
import { provideModalsContext } from 'app/routes/ReactContainer';
|
||||||
|
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
|
||||||
|
import { SaveDashboardModalProxy } from './SaveDashboardModalProxy';
|
||||||
|
|
||||||
|
interface SaveDashboardButtonProps {
|
||||||
|
dashboard: DashboardModel;
|
||||||
|
/**
|
||||||
|
* Added for being able to render this component as Angular directive!
|
||||||
|
* TODO[angular-migrations]: Remove when we migrate Dashboard Settings view to React
|
||||||
|
*/
|
||||||
|
getDashboard?: () => DashboardModel;
|
||||||
|
onSaveSuccess?: () => void;
|
||||||
|
useNewForms?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({
|
||||||
|
dashboard,
|
||||||
|
onSaveSuccess,
|
||||||
|
getDashboard,
|
||||||
|
useNewForms,
|
||||||
|
}) => {
|
||||||
|
const ButtonComponent = useNewForms ? Forms.Button : Button;
|
||||||
|
return (
|
||||||
|
<ModalsController>
|
||||||
|
{({ showModal, hideModal }) => {
|
||||||
|
return (
|
||||||
|
<ButtonComponent
|
||||||
|
onClick={() => {
|
||||||
|
showModal(SaveDashboardModalProxy, {
|
||||||
|
// TODO[angular-migrations]: Remove tenary op when we migrate Dashboard Settings view to React
|
||||||
|
dashboard: getDashboard ? getDashboard() : dashboard,
|
||||||
|
onSaveSuccess,
|
||||||
|
onDismiss: hideModal,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save dashboard
|
||||||
|
</ButtonComponent>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ModalsController>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { variant?: string }> = ({
|
||||||
|
dashboard,
|
||||||
|
onSaveSuccess,
|
||||||
|
getDashboard,
|
||||||
|
useNewForms,
|
||||||
|
variant,
|
||||||
|
}) => {
|
||||||
|
const ButtonComponent = useNewForms ? Forms.Button : Button;
|
||||||
|
return (
|
||||||
|
<ModalsController>
|
||||||
|
{({ showModal, hideModal }) => {
|
||||||
|
return (
|
||||||
|
<ButtonComponent
|
||||||
|
/* Styles applied here are specific to dashboard settings view */
|
||||||
|
className={css`
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
showModal(SaveDashboardAsModal, {
|
||||||
|
// TODO[angular-migrations]: Remove tenary op when we migrate Dashboard Settings view to React
|
||||||
|
dashboard: getDashboard ? getDashboard() : dashboard,
|
||||||
|
onSaveSuccess,
|
||||||
|
onDismiss: hideModal,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
// TODO[angular-migrations]: Hacking the different variants for this single button
|
||||||
|
// In Dashboard Settings in sidebar we need to use new form but with inverse variant to make it look like it should
|
||||||
|
// Everywhere else we use old button component :(
|
||||||
|
variant={variant as any}
|
||||||
|
>
|
||||||
|
Save As...
|
||||||
|
</ButtonComponent>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ModalsController>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: this is an ugly solution for the save button to have access to Redux and Modals controller
|
||||||
|
// When we migrate dashboard settings to Angular it won't be necessary.
|
||||||
|
export const SaveDashboardButtonConnected = connectWithProvider(provideModalsContext(SaveDashboardButton));
|
||||||
|
export const SaveDashboardAsButtonConnected = connectWithProvider(provideModalsContext(SaveDashboardAsButton));
|
@ -0,0 +1,121 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Button, ConfirmModal, HorizontalGroup, Modal, stylesFactory, useTheme } from '@grafana/ui';
|
||||||
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
|
import { useDashboardSave } from './useDashboardSave';
|
||||||
|
import { SaveDashboardModalProps } from './types';
|
||||||
|
import { SaveDashboardAsButton } from './SaveDashboardButton';
|
||||||
|
|
||||||
|
interface SaveDashboardErrorProxyProps {
|
||||||
|
dashboard: DashboardModel;
|
||||||
|
error: any;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveDashboardErrorProxy: React.FC<SaveDashboardErrorProxyProps> = ({ dashboard, error, onDismiss }) => {
|
||||||
|
const { onDashboardSave } = useDashboardSave(dashboard);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error.data) {
|
||||||
|
error.isHandled = true;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error.data && error.data.status === 'version-mismatch' && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={true}
|
||||||
|
title="Conflict"
|
||||||
|
body={
|
||||||
|
<div>
|
||||||
|
Someone else has updated this dashboard <br /> <small>Would you still like to save this dashboard?</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
confirmText="Save & Overwrite"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await onDashboardSave(dashboard.getSaveModelClone(), { overwrite: true }, dashboard);
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error.data && error.data.status === 'name-exists' && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={true}
|
||||||
|
title="Conflict"
|
||||||
|
body={
|
||||||
|
<div>
|
||||||
|
A dashboard with the same name in selected folder already exists. <br />
|
||||||
|
<small>Would you still like to save this dashboard?</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
confirmText="Save & Overwrite"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await onDashboardSave(dashboard.getSaveModelClone(), { overwrite: true }, dashboard);
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error.data && error.data.status === 'plugin-dashboard' && (
|
||||||
|
<ConfirmPluginDashboardSaveModal dashboard={dashboard} onDismiss={onDismiss} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfirmPluginDashboardSaveModal: React.FC<SaveDashboardModalProps> = ({ onDismiss, dashboard }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { onDashboardSave } = useDashboardSave(dashboard);
|
||||||
|
const styles = getConfirmPluginDashboardSaveModalStyles(theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal className={styles.modal} title="Plugin Dashboard" icon="copy" isOpen={true} onDismiss={onDismiss}>
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
<div className={styles.modalText}>
|
||||||
|
Your changes will be lost when you update the plugin.
|
||||||
|
<br /> <small>Use Save As to create custom version.</small>
|
||||||
|
</div>
|
||||||
|
<HorizontalGroup justify="center">
|
||||||
|
<SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={onDismiss} />
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={async () => {
|
||||||
|
await onDashboardSave(dashboard.getSaveModelClone(), { overwrite: true }, dashboard);
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Overwrite
|
||||||
|
</Button>
|
||||||
|
<Button variant="inverse" onClick={onDismiss}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfirmPluginDashboardSaveModalStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||||
|
modal: css`
|
||||||
|
width: 500px;
|
||||||
|
`,
|
||||||
|
modalContent: css`
|
||||||
|
text-align: center;
|
||||||
|
`,
|
||||||
|
modalText: css`
|
||||||
|
font-size: ${theme.typography.heading.h4};
|
||||||
|
color: ${theme.colors.link};
|
||||||
|
margin-bottom: calc(${theme.spacing.d} * 2);
|
||||||
|
padding-top: ${theme.spacing.d};
|
||||||
|
`,
|
||||||
|
modalButtonRow: css`
|
||||||
|
margin-bottom: 14px;
|
||||||
|
a,
|
||||||
|
button {
|
||||||
|
margin-right: ${theme.spacing.d};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}));
|
@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal } from '@grafana/ui';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { SaveDashboardForm } from './forms/SaveDashboardForm';
|
||||||
|
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
|
||||||
|
import { useDashboardSave } from './useDashboardSave';
|
||||||
|
import { SaveDashboardModalProps } from './types';
|
||||||
|
|
||||||
|
export const SaveDashboardModal: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
|
||||||
|
const { state, onDashboardSave } = useDashboardSave(dashboard);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.error && <SaveDashboardErrorProxy error={state.error} dashboard={dashboard} onDismiss={onDismiss} />}
|
||||||
|
{!state.error && (
|
||||||
|
<Modal
|
||||||
|
isOpen={true}
|
||||||
|
title="Save dashboard"
|
||||||
|
icon="copy"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
className={css`
|
||||||
|
width: 500px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<SaveDashboardForm
|
||||||
|
dashboard={dashboard}
|
||||||
|
onCancel={onDismiss}
|
||||||
|
onSuccess={() => {
|
||||||
|
onDismiss();
|
||||||
|
if (onSaveSuccess) {
|
||||||
|
onSaveSuccess();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={onDashboardSave}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NEW_DASHBOARD_DEFAULT_TITLE } from './forms/SaveDashboardAsForm';
|
||||||
|
import { SaveProvisionedDashboard } from './SaveProvisionedDashboard';
|
||||||
|
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
|
||||||
|
import { SaveDashboardModalProps } from './types';
|
||||||
|
import { SaveDashboardModal } from './SaveDashboardModal';
|
||||||
|
|
||||||
|
export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
|
||||||
|
const isProvisioned = dashboard.meta.provisioned;
|
||||||
|
const isNew = dashboard.title === NEW_DASHBOARD_DEFAULT_TITLE;
|
||||||
|
const isChanged = dashboard.version > 0;
|
||||||
|
|
||||||
|
const modalProps = {
|
||||||
|
dashboard,
|
||||||
|
onDismiss,
|
||||||
|
onSaveSuccess,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isChanged && !isProvisioned && <SaveDashboardModal {...modalProps} />}
|
||||||
|
{isProvisioned && <SaveProvisionedDashboard {...modalProps} />}
|
||||||
|
{isNew && <SaveDashboardAsModal {...modalProps} isNew />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal } from '@grafana/ui';
|
||||||
|
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
|
||||||
|
import { SaveDashboardModalProps } from './types';
|
||||||
|
|
||||||
|
export const SaveProvisionedDashboard: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss }) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={true} title="Cannot save provisioned dashboard" icon="copy" onDismiss={onDismiss}>
|
||||||
|
<SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onDismiss} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { SaveDashboardAsForm } from './SaveDashboardAsForm';
|
||||||
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]), search: jest.fn().mockResolvedValue([]) }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('app/core/services/context_srv', () => ({
|
||||||
|
contextSrv: {
|
||||||
|
user: { orgId: 1 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('app/features/plugins/datasource_srv', () => ({}));
|
||||||
|
jest.mock('app/features/expressions/ExpressionDatasource', () => ({}));
|
||||||
|
|
||||||
|
const prepareDashboardMock = (panel: any) => {
|
||||||
|
const json = {
|
||||||
|
title: 'name',
|
||||||
|
panels: [panel],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 5,
|
||||||
|
meta: {},
|
||||||
|
...json,
|
||||||
|
getSaveModelClone: () => json,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => {
|
||||||
|
const container = mount(
|
||||||
|
<SaveDashboardAsForm
|
||||||
|
dashboard={dashboard as DashboardModel}
|
||||||
|
onCancel={() => {}}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
onSubmit={async jsonModel => {
|
||||||
|
submitSpy(jsonModel);
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const button = container.find('button[aria-label="Save dashboard button"]');
|
||||||
|
button.simulate('submit');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
describe('SaveDashboardAsForm', () => {
|
||||||
|
describe('default values', () => {
|
||||||
|
it('applies default dashboard properties', async () => {
|
||||||
|
const spy = jest.fn();
|
||||||
|
|
||||||
|
await renderAndSubmitForm(prepareDashboardMock({}), spy);
|
||||||
|
|
||||||
|
expect(spy).toBeCalledTimes(1);
|
||||||
|
const savedDashboardModel = spy.mock.calls[0][0];
|
||||||
|
expect(savedDashboardModel.id).toBe(null);
|
||||||
|
expect(savedDashboardModel.title).toBe('name Copy');
|
||||||
|
expect(savedDashboardModel.editable).toBe(true);
|
||||||
|
expect(savedDashboardModel.hideControls).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('graph panel', () => {
|
||||||
|
const panel = {
|
||||||
|
id: 1,
|
||||||
|
type: 'graph',
|
||||||
|
alert: { rule: 1 },
|
||||||
|
thresholds: { value: 3000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should remove alerts and thresholds from panel', async () => {
|
||||||
|
const spy = jest.fn();
|
||||||
|
|
||||||
|
await renderAndSubmitForm(prepareDashboardMock(panel), spy);
|
||||||
|
|
||||||
|
expect(spy).toBeCalledTimes(1);
|
||||||
|
const savedDashboardModel = spy.mock.calls[0][0];
|
||||||
|
expect(savedDashboardModel.panels[0]).toEqual({ id: 1, type: 'graph' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('singestat panel', () => {
|
||||||
|
const panel = { id: 1, type: 'singlestat', thresholds: { value: 3000 } };
|
||||||
|
|
||||||
|
it('should keep thresholds', async () => {
|
||||||
|
const spy = jest.fn();
|
||||||
|
|
||||||
|
await renderAndSubmitForm(prepareDashboardMock(panel), spy);
|
||||||
|
|
||||||
|
expect(spy).toBeCalledTimes(1);
|
||||||
|
const savedDashboardModel = spy.mock.calls[0][0];
|
||||||
|
expect(savedDashboardModel.panels[0].thresholds).not.toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('table panel', () => {
|
||||||
|
const panel = { id: 1, type: 'table', thresholds: { value: 3000 } };
|
||||||
|
|
||||||
|
it('should keep thresholds', async () => {
|
||||||
|
const spy = jest.fn();
|
||||||
|
|
||||||
|
await renderAndSubmitForm(prepareDashboardMock(panel), spy);
|
||||||
|
|
||||||
|
expect(spy).toBeCalledTimes(1);
|
||||||
|
const savedDashboardModel = spy.mock.calls[0][0];
|
||||||
|
expect(savedDashboardModel.panels[0].thresholds).not.toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Forms, HorizontalGroup, Button } from '@grafana/ui';
|
||||||
|
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||||
|
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
|
import { SaveDashboardFormProps } from '../types';
|
||||||
|
|
||||||
|
export const NEW_DASHBOARD_DEFAULT_TITLE = 'New dashboard';
|
||||||
|
|
||||||
|
interface SaveDashboardAsFormDTO {
|
||||||
|
title: string;
|
||||||
|
$folder: { id: number; title: string };
|
||||||
|
copyTags: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSaveAsDashboardClone = (dashboard: DashboardModel) => {
|
||||||
|
const clone = dashboard.getSaveModelClone();
|
||||||
|
clone.id = null;
|
||||||
|
clone.uid = '';
|
||||||
|
clone.title += ' Copy';
|
||||||
|
clone.editable = true;
|
||||||
|
clone.hideControls = false;
|
||||||
|
|
||||||
|
// remove alerts if source dashboard is already persisted
|
||||||
|
// do not want to create alert dupes
|
||||||
|
if (dashboard.id > 0) {
|
||||||
|
clone.panels.forEach((panel: PanelModel) => {
|
||||||
|
if (panel.type === 'graph' && panel.alert) {
|
||||||
|
delete panel.thresholds;
|
||||||
|
}
|
||||||
|
delete panel.alert;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete clone.autoUpdate;
|
||||||
|
return clone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveDashboardAsForm: React.FC<SaveDashboardFormProps & { isNew?: boolean }> = ({
|
||||||
|
dashboard,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const defaultValues: SaveDashboardAsFormDTO = {
|
||||||
|
title: `${dashboard.title} Copy`,
|
||||||
|
$folder: {
|
||||||
|
id: dashboard.meta.folderId,
|
||||||
|
title: dashboard.meta.folderTitle,
|
||||||
|
},
|
||||||
|
copyTags: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Forms.Form
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
onSubmit={async (data: SaveDashboardAsFormDTO) => {
|
||||||
|
const clone = getSaveAsDashboardClone(dashboard);
|
||||||
|
clone.title = data.title;
|
||||||
|
if (!data.copyTags) {
|
||||||
|
clone.tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await onSubmit(
|
||||||
|
clone,
|
||||||
|
{
|
||||||
|
folderId: data.$folder.id,
|
||||||
|
},
|
||||||
|
dashboard
|
||||||
|
);
|
||||||
|
if (result.status === 'success') {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ register, control, errors }) => (
|
||||||
|
<>
|
||||||
|
<Forms.Field label="Dashboard name" invalid={!!errors.title} error="Dashboard name is required">
|
||||||
|
<Forms.Input name="title" ref={register({ required: true })} aria-label="Save dashboard title field" />
|
||||||
|
</Forms.Field>
|
||||||
|
<Forms.Field label="Folder">
|
||||||
|
<Forms.InputControl
|
||||||
|
as={FolderPicker}
|
||||||
|
control={control}
|
||||||
|
name="$folder"
|
||||||
|
dashboardId={dashboard.id}
|
||||||
|
initialFolderId={dashboard.meta.folderId}
|
||||||
|
initialTitle={dashboard.meta.folderTitle}
|
||||||
|
enableCreateNew
|
||||||
|
useNewForms
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
<Forms.Field label="Copy tags">
|
||||||
|
<Forms.Switch name="copyTags" ref={register} />
|
||||||
|
</Forms.Field>
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button type="submit" aria-label="Save dashboard button">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Forms.Button variant="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Forms.Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Forms.Form>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
|
import { SaveDashboardForm } from './SaveDashboardForm';
|
||||||
|
|
||||||
|
const prepareDashboardMock = (
|
||||||
|
timeChanged: boolean,
|
||||||
|
variableValuesChanged: boolean,
|
||||||
|
resetTimeSpy: any,
|
||||||
|
resetVarsSpy: any
|
||||||
|
) => {
|
||||||
|
const json = {
|
||||||
|
title: 'name',
|
||||||
|
hasTimeChanged: jest.fn().mockReturnValue(timeChanged),
|
||||||
|
hasVariableValuesChanged: jest.fn().mockReturnValue(variableValuesChanged),
|
||||||
|
resetOriginalTime: () => resetTimeSpy(),
|
||||||
|
resetOriginalVariables: () => resetVarsSpy(),
|
||||||
|
getSaveModelClone: jest.fn().mockReturnValue({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 5,
|
||||||
|
meta: {},
|
||||||
|
...json,
|
||||||
|
getSaveModelClone: () => json,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => {
|
||||||
|
const container = mount(
|
||||||
|
<SaveDashboardForm
|
||||||
|
dashboard={dashboard as DashboardModel}
|
||||||
|
onCancel={() => {}}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
onSubmit={async jsonModel => {
|
||||||
|
submitSpy(jsonModel);
|
||||||
|
return { status: 'success' };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const button = container.find('button[aria-label="Dashboard settings Save Dashboard Modal Save button"]');
|
||||||
|
button.simulate('submit');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
describe('SaveDashboardAsForm', () => {
|
||||||
|
describe('time and variables toggle rendering', () => {
|
||||||
|
it('renders switches when variables or timerange', () => {
|
||||||
|
const container = mount(
|
||||||
|
<SaveDashboardForm
|
||||||
|
dashboard={prepareDashboardMock(true, true, jest.fn(), jest.fn()) as any}
|
||||||
|
onCancel={() => {}}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
onSubmit={async () => {
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const variablesCheckbox = container.find(
|
||||||
|
'input[aria-label="Dashboard settings Save Dashboard Modal Save variables checkbox"]'
|
||||||
|
);
|
||||||
|
const timeRangeCheckbox = container.find(
|
||||||
|
'input[aria-label="Dashboard settings Save Dashboard Modal Save timerange checkbox"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(variablesCheckbox).toHaveLength(1);
|
||||||
|
expect(timeRangeCheckbox).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when time and template vars haven't changed", () => {
|
||||||
|
it("doesn't reset dashboard time and vars", async () => {
|
||||||
|
const resetTimeSpy = jest.fn();
|
||||||
|
const resetVarsSpy = jest.fn();
|
||||||
|
const submitSpy = jest.fn();
|
||||||
|
|
||||||
|
await renderAndSubmitForm(prepareDashboardMock(false, false, resetTimeSpy, resetVarsSpy) as any, submitSpy);
|
||||||
|
|
||||||
|
expect(resetTimeSpy).not.toBeCalled();
|
||||||
|
expect(resetVarsSpy).not.toBeCalled();
|
||||||
|
expect(submitSpy).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when time and template vars have changed', () => {
|
||||||
|
describe("and user hasn't checked variable and time range save", () => {
|
||||||
|
it('dont reset dashboard time and vars', async () => {
|
||||||
|
const resetTimeSpy = jest.fn();
|
||||||
|
const resetVarsSpy = jest.fn();
|
||||||
|
const submitSpy = jest.fn();
|
||||||
|
await renderAndSubmitForm(prepareDashboardMock(true, true, resetTimeSpy, resetVarsSpy) as any, submitSpy);
|
||||||
|
|
||||||
|
expect(resetTimeSpy).toBeCalledTimes(0);
|
||||||
|
expect(resetVarsSpy).toBeCalledTimes(0);
|
||||||
|
expect(submitSpy).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,67 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Forms, Button, HorizontalGroup } from '@grafana/ui';
|
||||||
|
import { e2e } from '@grafana/e2e';
|
||||||
|
import { SaveDashboardFormProps } from '../types';
|
||||||
|
|
||||||
|
interface SaveDashboardFormDTO {
|
||||||
|
message: string;
|
||||||
|
saveVariables: boolean;
|
||||||
|
saveTimerange: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel, onSuccess, onSubmit }) => {
|
||||||
|
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
|
||||||
|
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Forms.Form
|
||||||
|
onSubmit={async (data: SaveDashboardFormDTO) => {
|
||||||
|
const result = await onSubmit(dashboard.getSaveModelClone(data), data, dashboard);
|
||||||
|
if (result.status === 'success') {
|
||||||
|
if (data.saveVariables) {
|
||||||
|
dashboard.resetOriginalVariables();
|
||||||
|
}
|
||||||
|
if (data.saveTimerange) {
|
||||||
|
dashboard.resetOriginalTime();
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ register, errors }) => (
|
||||||
|
<>
|
||||||
|
<Forms.Field label="Changes description">
|
||||||
|
<Forms.TextArea name="message" ref={register} placeholder="Add a note to describe your changes..." />
|
||||||
|
</Forms.Field>
|
||||||
|
{hasTimeChanged && (
|
||||||
|
<Forms.Field label="Save current time range" description="Dashboard time range has changed">
|
||||||
|
<Forms.Switch
|
||||||
|
name="saveTimerange"
|
||||||
|
ref={register}
|
||||||
|
aria-label={e2e.pages.SaveDashboardModal.selectors.saveTimerange}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
)}
|
||||||
|
{hasVariableChanged && (
|
||||||
|
<Forms.Field label="Save current variables" description="Dashboard variables have changed">
|
||||||
|
<Forms.Switch
|
||||||
|
name="saveVariables"
|
||||||
|
ref={register}
|
||||||
|
aria-label={e2e.pages.SaveDashboardModal.selectors.saveVariables}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button type="submit" aria-label={e2e.pages.SaveDashboardModal.selectors.save}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Forms.Button variant="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Forms.Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Forms.Form>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,71 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
import { CustomScrollbar, Forms, Button, HorizontalGroup, JSONFormatter, VerticalGroup } from '@grafana/ui';
|
||||||
|
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
|
||||||
|
import { SaveDashboardFormProps } from '../types';
|
||||||
|
|
||||||
|
export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel }) => {
|
||||||
|
const dashboardJSON = useMemo(() => {
|
||||||
|
const clone = dashboard.getSaveModelClone();
|
||||||
|
delete clone.id;
|
||||||
|
return clone;
|
||||||
|
}, [dashboard]);
|
||||||
|
|
||||||
|
const getClipboardText = useCallback(() => {
|
||||||
|
return JSON.stringify(dashboardJSON, null, 2);
|
||||||
|
}, [dashboard]);
|
||||||
|
|
||||||
|
const saveToFile = useCallback(() => {
|
||||||
|
const blob = new Blob([JSON.stringify(dashboardJSON, null, 2)], {
|
||||||
|
type: 'application/json;charset=utf-8',
|
||||||
|
});
|
||||||
|
saveAs(blob, dashboard.title + '-' + new Date().getTime() + '.json');
|
||||||
|
}, [dashboardJSON]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VerticalGroup spacing="lg">
|
||||||
|
<small>
|
||||||
|
This dashboard cannot be saved from Grafana's UI since it has been provisioned from another source. Copy the
|
||||||
|
JSON or save it to a file below. Then you can update your dashboard in corresponding provisioning source.
|
||||||
|
<br />
|
||||||
|
<i>
|
||||||
|
See{' '}
|
||||||
|
<a
|
||||||
|
className="external-link"
|
||||||
|
href="http://docs.grafana.org/administration/provisioning/#dashboards"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
documentation
|
||||||
|
</a>{' '}
|
||||||
|
for more information about provisioning.
|
||||||
|
</i>
|
||||||
|
</small>
|
||||||
|
<div>
|
||||||
|
<strong>File path: </strong> {dashboard.meta.provisionedExternalId}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: black;
|
||||||
|
height: 400px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<CustomScrollbar>
|
||||||
|
<JSONFormatter json={dashboardJSON} open={1} />
|
||||||
|
</CustomScrollbar>
|
||||||
|
</div>
|
||||||
|
<HorizontalGroup>
|
||||||
|
<CopyToClipboard text={getClipboardText} elType={Button}>
|
||||||
|
Copy JSON to clipboard
|
||||||
|
</CopyToClipboard>
|
||||||
|
<Button onClick={saveToFile}>Save JSON to file</Button>
|
||||||
|
<Forms.Button variant="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Forms.Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</VerticalGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,21 @@
|
|||||||
|
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
|
|
||||||
|
export interface SaveDashboardOptions extends CloneOptions {
|
||||||
|
folderId?: number;
|
||||||
|
overwrite?: boolean;
|
||||||
|
message?: string;
|
||||||
|
makeEditable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveDashboardFormProps {
|
||||||
|
dashboard: DashboardModel;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onSubmit?: (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveDashboardModalProps {
|
||||||
|
dashboard: DashboardModel;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onSaveSuccess?: () => void;
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||||
|
import { AppEvents } from '@grafana/data';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { SaveDashboardOptions } from './types';
|
||||||
|
import { CoreEvents, StoreState } from 'app/types';
|
||||||
|
import appEvents from 'app/core/app_events';
|
||||||
|
import locationUtil from 'app/core/utils/location_util';
|
||||||
|
import { updateLocation } from 'app/core/reducers/location';
|
||||||
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
|
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
|
||||||
|
const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => {
|
||||||
|
const folderId = options.folderId >= 0 ? options.folderId : dashboard.meta.folderId || saveModel.folderId;
|
||||||
|
return await getBackendSrv().saveDashboard(saveModel, { ...options, folderId });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDashboardSave = (dashboard: DashboardModel) => {
|
||||||
|
const location = useSelector((state: StoreState) => state.location);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [state, onDashboardSave] = useAsyncFn(
|
||||||
|
async (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) =>
|
||||||
|
await saveDashboard(clone, options, dashboard),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.value) {
|
||||||
|
dashboard.version = state.value.version;
|
||||||
|
|
||||||
|
// important that these happen before location redirect below
|
||||||
|
appEvents.emit(CoreEvents.dashboardSaved, dashboard);
|
||||||
|
appEvents.emit(AppEvents.alertSuccess, ['Dashboard saved']);
|
||||||
|
|
||||||
|
const newUrl = locationUtil.stripBaseFromUrl(state.value.url);
|
||||||
|
const currentPath = location.path;
|
||||||
|
|
||||||
|
if (newUrl !== currentPath) {
|
||||||
|
dispatch(
|
||||||
|
updateLocation({
|
||||||
|
path: newUrl,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return { state, onDashboardSave };
|
||||||
|
};
|
@ -1,71 +0,0 @@
|
|||||||
import { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
|
|
||||||
import { describe, it, expect } from 'test/lib/common';
|
|
||||||
|
|
||||||
describe('saving dashboard as', () => {
|
|
||||||
function scenario(name: string, panel: any, verify: Function) {
|
|
||||||
describe(name, () => {
|
|
||||||
const json = {
|
|
||||||
title: 'name',
|
|
||||||
panels: [panel],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDashboardSrv: any = {
|
|
||||||
getCurrent: () => {
|
|
||||||
return {
|
|
||||||
id: 5,
|
|
||||||
meta: {},
|
|
||||||
getSaveModelClone: () => {
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctrl = new SaveDashboardAsModalCtrl(mockDashboardSrv);
|
|
||||||
const ctx: any = {
|
|
||||||
clone: ctrl.clone,
|
|
||||||
ctrl: ctrl,
|
|
||||||
panel: panel,
|
|
||||||
};
|
|
||||||
|
|
||||||
it('verify', () => {
|
|
||||||
verify(ctx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
scenario('default values', {}, (ctx: any) => {
|
|
||||||
const clone = ctx.clone;
|
|
||||||
expect(clone.id).toBe(null);
|
|
||||||
expect(clone.title).toBe('name Copy');
|
|
||||||
expect(clone.editable).toBe(true);
|
|
||||||
expect(clone.hideControls).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
const graphPanel = {
|
|
||||||
id: 1,
|
|
||||||
type: 'graph',
|
|
||||||
alert: { rule: 1 },
|
|
||||||
thresholds: { value: 3000 },
|
|
||||||
};
|
|
||||||
|
|
||||||
scenario('should remove alert from graph panel', graphPanel, (ctx: any) => {
|
|
||||||
expect(ctx.panel.alert).toBe(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
scenario('should remove threshold from graph panel', graphPanel, (ctx: any) => {
|
|
||||||
expect(ctx.panel.thresholds).toBe(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
scenario(
|
|
||||||
'singlestat should keep threshold',
|
|
||||||
{ id: 1, type: 'singlestat', thresholds: { value: 3000 } },
|
|
||||||
(ctx: any) => {
|
|
||||||
expect(ctx.panel.thresholds).not.toBe(undefined);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
scenario('table should keep threshold', { id: 1, type: 'table', thresholds: { value: 3000 } }, (ctx: any) => {
|
|
||||||
expect(ctx.panel.thresholds).not.toBe(undefined);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,124 +0,0 @@
|
|||||||
import coreModule from 'app/core/core_module';
|
|
||||||
import { DashboardSrv } from '../../services/DashboardSrv';
|
|
||||||
import { PanelModel } from '../../state/PanelModel';
|
|
||||||
|
|
||||||
const template = `
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-header-title">
|
|
||||||
<i class="fa fa-copy"></i>
|
|
||||||
<span class="p-l-1">Save As...</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<a class="modal-header-close" ng-click="ctrl.dismiss();">
|
|
||||||
<i class="fa fa-remove"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form name="ctrl.saveForm" class="modal-content" novalidate>
|
|
||||||
<div class="p-t-2">
|
|
||||||
<div class="gf-form">
|
|
||||||
<label class="gf-form-label width-8">New name</label>
|
|
||||||
<input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required aria-label="Save dashboard title field">
|
|
||||||
</div>
|
|
||||||
<folder-picker initial-folder-id="ctrl.folderId"
|
|
||||||
on-change="ctrl.onFolderChange"
|
|
||||||
enter-folder-creation="ctrl.onEnterFolderCreation"
|
|
||||||
exit-folder-creation="ctrl.onExitFolderCreation"
|
|
||||||
enable-create-new="true"
|
|
||||||
label-class="width-8"
|
|
||||||
dashboard-id="ctrl.clone.id">
|
|
||||||
</folder-picker>
|
|
||||||
<div class="gf-form-inline">
|
|
||||||
<gf-form-switch class="gf-form" label="Copy tags" label-class="width-8" checked="ctrl.copyTags">
|
|
||||||
</gf-form-switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gf-form-button-row text-center">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
ng-click="ctrl.save()"
|
|
||||||
ng-disabled="!ctrl.isValidFolderSelection"
|
|
||||||
aria-label="Save dashboard button">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
export class SaveDashboardAsModalCtrl {
|
|
||||||
clone: any;
|
|
||||||
folderId: any;
|
|
||||||
dismiss: () => void;
|
|
||||||
isValidFolderSelection = true;
|
|
||||||
copyTags: boolean;
|
|
||||||
|
|
||||||
/** @ngInject */
|
|
||||||
constructor(private dashboardSrv: DashboardSrv) {
|
|
||||||
const dashboard = this.dashboardSrv.getCurrent();
|
|
||||||
this.clone = dashboard.getSaveModelClone();
|
|
||||||
this.clone.id = null;
|
|
||||||
this.clone.uid = '';
|
|
||||||
this.clone.title += ' Copy';
|
|
||||||
this.clone.editable = true;
|
|
||||||
this.clone.hideControls = false;
|
|
||||||
this.folderId = dashboard.meta.folderId;
|
|
||||||
this.copyTags = false;
|
|
||||||
|
|
||||||
// remove alerts if source dashboard is already persisted
|
|
||||||
// do not want to create alert dupes
|
|
||||||
if (dashboard.id > 0) {
|
|
||||||
this.clone.panels.forEach((panel: PanelModel) => {
|
|
||||||
if (panel.type === 'graph' && panel.alert) {
|
|
||||||
delete panel.thresholds;
|
|
||||||
}
|
|
||||||
delete panel.alert;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
delete this.clone.autoUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
save() {
|
|
||||||
if (!this.copyTags) {
|
|
||||||
this.clone.tags = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.dashboardSrv.save(this.clone, { folderId: this.folderId }).then(this.dismiss);
|
|
||||||
}
|
|
||||||
|
|
||||||
keyDown(evt: KeyboardEvent) {
|
|
||||||
if (evt.keyCode === 13) {
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onFolderChange = (folder: { id: any }) => {
|
|
||||||
this.folderId = folder.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
onEnterFolderCreation = () => {
|
|
||||||
this.isValidFolderSelection = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
onExitFolderCreation = () => {
|
|
||||||
this.isValidFolderSelection = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveDashboardAsDirective() {
|
|
||||||
return {
|
|
||||||
restrict: 'E',
|
|
||||||
template: template,
|
|
||||||
controller: SaveDashboardAsModalCtrl,
|
|
||||||
bindToController: true,
|
|
||||||
controllerAs: 'ctrl',
|
|
||||||
scope: { dismiss: '&' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
coreModule.directive('saveDashboardAsModal', saveDashboardAsDirective);
|
|
@ -1,57 +0,0 @@
|
|||||||
import { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
|
|
||||||
|
|
||||||
const setup = (timeChanged: boolean, variableValuesChanged: boolean, cb: Function) => {
|
|
||||||
const dash = {
|
|
||||||
hasTimeChanged: jest.fn().mockReturnValue(timeChanged),
|
|
||||||
hasVariableValuesChanged: jest.fn().mockReturnValue(variableValuesChanged),
|
|
||||||
resetOriginalTime: jest.fn(),
|
|
||||||
resetOriginalVariables: jest.fn(),
|
|
||||||
getSaveModelClone: jest.fn().mockReturnValue({}),
|
|
||||||
};
|
|
||||||
const dashboardSrvMock: any = {
|
|
||||||
getCurrent: jest.fn().mockReturnValue(dash),
|
|
||||||
save: jest.fn().mockReturnValue(Promise.resolve()),
|
|
||||||
};
|
|
||||||
const ctrl = new SaveDashboardModalCtrl(dashboardSrvMock);
|
|
||||||
ctrl.saveForm = {
|
|
||||||
$valid: true,
|
|
||||||
};
|
|
||||||
ctrl.dismiss = () => Promise.resolve();
|
|
||||||
cb(dash, ctrl, dashboardSrvMock);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('SaveDashboardModal', () => {
|
|
||||||
describe('Given time and template variable values have not changed', () => {
|
|
||||||
setup(false, false, (dash: any, ctrl: SaveDashboardModalCtrl) => {
|
|
||||||
it('When creating ctrl should set time and template variable values changed', () => {
|
|
||||||
expect(ctrl.timeChange).toBeFalsy();
|
|
||||||
expect(ctrl.variableValueChange).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Given time and template variable values have changed', () => {
|
|
||||||
setup(true, true, (dash: any, ctrl: SaveDashboardModalCtrl) => {
|
|
||||||
it('When creating ctrl should set time and template variable values changed', () => {
|
|
||||||
expect(ctrl.timeChange).toBeTruthy();
|
|
||||||
expect(ctrl.variableValueChange).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('When save time and variable value changes disabled and saving should reset original time and template variable values', async () => {
|
|
||||||
ctrl.saveTimerange = false;
|
|
||||||
ctrl.saveVariables = false;
|
|
||||||
await ctrl.save();
|
|
||||||
expect(dash.resetOriginalTime).toHaveBeenCalledTimes(0);
|
|
||||||
expect(dash.resetOriginalVariables).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('When save time and variable value changes enabled and saving should reset original time and template variable values', async () => {
|
|
||||||
ctrl.saveTimerange = true;
|
|
||||||
ctrl.saveVariables = true;
|
|
||||||
await ctrl.save();
|
|
||||||
expect(dash.resetOriginalTime).toHaveBeenCalledTimes(1);
|
|
||||||
expect(dash.resetOriginalVariables).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,141 +0,0 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
|
||||||
|
|
||||||
import coreModule from 'app/core/core_module';
|
|
||||||
import { DashboardSrv } from '../../services/DashboardSrv';
|
|
||||||
import { CloneOptions } from '../../state/DashboardModel';
|
|
||||||
|
|
||||||
const template = `
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-header-title">
|
|
||||||
<i class="fa fa-save"></i>
|
|
||||||
<span class="p-l-1">Save changes</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<a class="modal-header-close" ng-click="ctrl.dismiss();">
|
|
||||||
<i class="fa fa-remove"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate>
|
|
||||||
<div class="p-t-1">
|
|
||||||
<div class="gf-form-group" ng-if="ctrl.timeChange || ctrl.variableValueChange">
|
|
||||||
<gf-form-switch class="gf-form"
|
|
||||||
label="Save current time range" ng-if="ctrl.timeChange" label-class="width-12" switch-class="max-width-6"
|
|
||||||
checked="ctrl.saveTimerange" on-change="buildUrl()">
|
|
||||||
</gf-form-switch>
|
|
||||||
<gf-form-switch class="gf-form"
|
|
||||||
label="Save current variables" ng-if="ctrl.variableValueChange" label-class="width-12" switch-class="max-width-6"
|
|
||||||
checked="ctrl.saveVariables" on-change="buildUrl()">
|
|
||||||
</gf-form-switch>
|
|
||||||
</div>
|
|
||||||
<div class="gf-form">
|
|
||||||
<label class="gf-form-hint">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="message"
|
|
||||||
class="gf-form-input"
|
|
||||||
placeholder="Add a note to describe your changes …"
|
|
||||||
give-focus="true"
|
|
||||||
ng-model="ctrl.message"
|
|
||||||
ng-model-options="{allowInvalid: true}"
|
|
||||||
ng-maxlength="this.max"
|
|
||||||
maxlength="64"
|
|
||||||
autocomplete="off" />
|
|
||||||
<small class="gf-form-hint-text muted" ng-cloak>
|
|
||||||
<span ng-class="{'text-error': ctrl.saveForm.message.$invalid && ctrl.saveForm.message.$dirty }">
|
|
||||||
{{ctrl.message.length || 0}}
|
|
||||||
</span>
|
|
||||||
/ {{ctrl.max}} characters
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gf-form-button-row text-center">
|
|
||||||
<button
|
|
||||||
id="saveBtn"
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
ng-class="{'btn-primary--processing': ctrl.isSaving}"
|
|
||||||
ng-disabled="ctrl.saveForm.$invalid || ctrl.isSaving"
|
|
||||||
aria-label={{ctrl.selectors.save}}
|
|
||||||
>
|
|
||||||
<span ng-if="!ctrl.isSaving">Save</span>
|
|
||||||
<span ng-if="ctrl.isSaving === true">Saving...</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-inverse" ng-click="ctrl.dismiss();">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
export class SaveDashboardModalCtrl {
|
|
||||||
message: string;
|
|
||||||
saveVariables = false;
|
|
||||||
saveTimerange = false;
|
|
||||||
time: any;
|
|
||||||
originalTime: any;
|
|
||||||
current: any[] = [];
|
|
||||||
originalCurrent: any[] = [];
|
|
||||||
max: number;
|
|
||||||
saveForm: any;
|
|
||||||
isSaving: boolean;
|
|
||||||
dismiss: () => void;
|
|
||||||
timeChange = false;
|
|
||||||
variableValueChange = false;
|
|
||||||
selectors: typeof e2e.pages.SaveDashboardModal.selectors;
|
|
||||||
|
|
||||||
/** @ngInject */
|
|
||||||
constructor(private dashboardSrv: DashboardSrv) {
|
|
||||||
this.message = '';
|
|
||||||
this.max = 64;
|
|
||||||
this.isSaving = false;
|
|
||||||
this.timeChange = this.dashboardSrv.getCurrent().hasTimeChanged();
|
|
||||||
this.variableValueChange = this.dashboardSrv.getCurrent().hasVariableValuesChanged();
|
|
||||||
this.selectors = e2e.pages.SaveDashboardModal.selectors;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(): void | Promise<any> {
|
|
||||||
if (!this.saveForm.$valid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: CloneOptions = {
|
|
||||||
saveVariables: this.saveVariables,
|
|
||||||
saveTimerange: this.saveTimerange,
|
|
||||||
message: this.message,
|
|
||||||
};
|
|
||||||
|
|
||||||
const dashboard = this.dashboardSrv.getCurrent();
|
|
||||||
const saveModel = dashboard.getSaveModelClone(options);
|
|
||||||
|
|
||||||
this.isSaving = true;
|
|
||||||
return this.dashboardSrv.save(saveModel, options).then(this.postSave.bind(this, options));
|
|
||||||
}
|
|
||||||
|
|
||||||
postSave(options?: { saveVariables?: boolean; saveTimerange?: boolean }) {
|
|
||||||
if (options.saveVariables) {
|
|
||||||
this.dashboardSrv.getCurrent().resetOriginalVariables();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.saveTimerange) {
|
|
||||||
this.dashboardSrv.getCurrent().resetOriginalTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveDashboardModalDirective() {
|
|
||||||
return {
|
|
||||||
restrict: 'E',
|
|
||||||
template: template,
|
|
||||||
controller: SaveDashboardModalCtrl,
|
|
||||||
bindToController: true,
|
|
||||||
controllerAs: 'ctrl',
|
|
||||||
scope: { dismiss: '&' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
coreModule.directive('saveDashboardModal', saveDashboardModalDirective);
|
|
@ -1,30 +0,0 @@
|
|||||||
import { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';
|
|
||||||
|
|
||||||
describe('SaveProvisionedDashboardModalCtrl', () => {
|
|
||||||
const json = {
|
|
||||||
title: 'name',
|
|
||||||
id: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDashboardSrv: any = {
|
|
||||||
getCurrent: () => {
|
|
||||||
return {
|
|
||||||
id: 5,
|
|
||||||
meta: {},
|
|
||||||
getSaveModelClone: () => {
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctrl = new SaveProvisionedDashboardModalCtrl(mockDashboardSrv);
|
|
||||||
|
|
||||||
it('should remove id from dashboard model', () => {
|
|
||||||
expect(ctrl.dash.id).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove id from dashboard model in clipboard json', () => {
|
|
||||||
expect(ctrl.getJsonForClipboard()).toBe(JSON.stringify({ title: 'name' }, null, 2));
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,84 +0,0 @@
|
|||||||
import angular from 'angular';
|
|
||||||
import { saveAs } from 'file-saver';
|
|
||||||
import coreModule from 'app/core/core_module';
|
|
||||||
import { DashboardModel } from '../../state';
|
|
||||||
import { DashboardSrv } from '../../services/DashboardSrv';
|
|
||||||
|
|
||||||
const template = `
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-header-title">
|
|
||||||
<i class="fa fa-save"></i><span class="p-l-1">Cannot save provisioned dashboard</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<a class="modal-header-close" ng-click="ctrl.dismiss();">
|
|
||||||
<i class="fa fa-remove"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-content">
|
|
||||||
<small>
|
|
||||||
This dashboard cannot be saved from Grafana's UI since it has been provisioned from another source.
|
|
||||||
Copy the JSON or save it to a file below. Then you can update your dashboard in corresponding provisioning source.<br/>
|
|
||||||
<i>See <a class="external-link" href="http://docs.grafana.org/administration/provisioning/#dashboards" target="_blank">
|
|
||||||
documentation</a> for more information about provisioning.</i>
|
|
||||||
</small>
|
|
||||||
<div class="p-t-1">
|
|
||||||
File path: {{ctrl.dashboardModel.meta.provisionedExternalId}}
|
|
||||||
</div>
|
|
||||||
<div class="p-t-2">
|
|
||||||
<div class="gf-form">
|
|
||||||
<code-editor content="ctrl.dashboardJson" data-mode="json" data-max-lines=15></code-editor>
|
|
||||||
</div>
|
|
||||||
<div class="gf-form-button-row">
|
|
||||||
<button class="btn btn-primary" clipboard-button="ctrl.getJsonForClipboard()">
|
|
||||||
Copy JSON to Clipboard
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" clipboard-button="ctrl.save()">
|
|
||||||
Save JSON to file
|
|
||||||
</button>
|
|
||||||
<a class="btn btn-link" ng-click="ctrl.dismiss();">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
export class SaveProvisionedDashboardModalCtrl {
|
|
||||||
dash: any;
|
|
||||||
dashboardModel: DashboardModel;
|
|
||||||
dashboardJson: string;
|
|
||||||
dismiss: () => void;
|
|
||||||
|
|
||||||
/** @ngInject */
|
|
||||||
constructor(dashboardSrv: DashboardSrv) {
|
|
||||||
this.dashboardModel = dashboardSrv.getCurrent();
|
|
||||||
this.dash = this.dashboardModel.getSaveModelClone();
|
|
||||||
delete this.dash.id;
|
|
||||||
this.dashboardJson = angular.toJson(this.dash, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
save() {
|
|
||||||
const blob = new Blob([angular.toJson(this.dash, true)], {
|
|
||||||
type: 'application/json;charset=utf-8',
|
|
||||||
});
|
|
||||||
saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
getJsonForClipboard() {
|
|
||||||
return this.dashboardJson;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveProvisionedDashboardModalDirective() {
|
|
||||||
return {
|
|
||||||
restrict: 'E',
|
|
||||||
template: template,
|
|
||||||
controller: SaveProvisionedDashboardModalCtrl,
|
|
||||||
bindToController: true,
|
|
||||||
controllerAs: 'ctrl',
|
|
||||||
scope: { dismiss: '&' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
coreModule.directive('saveProvisionedDashboardModal', saveProvisionedDashboardModalDirective);
|
|
@ -1,3 +0,0 @@
|
|||||||
export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
|
|
||||||
export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
|
|
||||||
export { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';
|
|
@ -20,7 +20,7 @@ const template = `
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="confirm-modal-buttons">
|
<div class="confirm-modal-buttons">
|
||||||
<button type="button" class="btn btn-primary" ng-click="ctrl.save()">Save</button>
|
<save-dashboard-button dashboard="ctrl.unsavedChangesSrv.tracker.current" onSaveSuccess="ctrl.onSaveSuccess" >Save</save-dashboard-button>
|
||||||
<button type="button" class="btn btn-danger" ng-click="ctrl.discard()">Discard</button>
|
<button type="button" class="btn btn-danger" ng-click="ctrl.discard()">Discard</button>
|
||||||
<button type="button" class="btn btn-inverse" ng-click="ctrl.dismiss()">Cancel</button>
|
<button type="button" class="btn btn-inverse" ng-click="ctrl.dismiss()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@ -44,6 +44,11 @@ export class UnsavedChangesModalCtrl {
|
|||||||
this.dismiss();
|
this.dismiss();
|
||||||
this.unsavedChangesSrv.tracker.saveChanges();
|
this.unsavedChangesSrv.tracker.saveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSaveSuccess = () => {
|
||||||
|
this.dismiss();
|
||||||
|
this.unsavedChangesSrv.tracker.onSaveSuccess();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unsavedChangesModalDirective() {
|
export function unsavedChangesModalDirective() {
|
||||||
|
@ -4,6 +4,16 @@ import { IScope } from 'angular';
|
|||||||
import { HistoryListCtrl } from './HistoryListCtrl';
|
import { HistoryListCtrl } from './HistoryListCtrl';
|
||||||
import { compare, restore, versions } from './__mocks__/history';
|
import { compare, restore, versions } from './__mocks__/history';
|
||||||
import { CoreEvents } from 'app/types';
|
import { CoreEvents } from 'app/types';
|
||||||
|
import { appEvents } from 'app/core/app_events';
|
||||||
|
|
||||||
|
jest.mock('app/core/app_events', () => {
|
||||||
|
return {
|
||||||
|
appEvents: {
|
||||||
|
emit: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('HistoryListCtrl', () => {
|
describe('HistoryListCtrl', () => {
|
||||||
const RESTORE_ID = 4;
|
const RESTORE_ID = 4;
|
||||||
@ -114,13 +124,15 @@ describe('HistoryListCtrl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should listen for the `dashboardSaved` appEvent', () => {
|
it('should listen for the `dashboardSaved` appEvent', () => {
|
||||||
expect($rootScope.onAppEvent).toHaveBeenCalledTimes(1);
|
// @ts-ignore
|
||||||
expect($rootScope.onAppEvent.mock.calls[0][0]).toBe(CoreEvents.dashboardSaved);
|
expect(appEvents.on.mock.calls[0][0]).toBe(CoreEvents.dashboardSaved);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call `onDashboardSaved` when the appEvent is received', () => {
|
it('should call `onDashboardSaved` when the appEvent is received', () => {
|
||||||
expect($rootScope.onAppEvent.mock.calls[0][1]).not.toBe(historyListCtrl.onDashboardSaved);
|
// @ts-ignore
|
||||||
expect($rootScope.onAppEvent.mock.calls[0][1].toString).toBe(historyListCtrl.onDashboardSaved.toString);
|
expect(appEvents.on.mock.calls[0][1]).not.toBe(historyListCtrl.onDashboardSaved);
|
||||||
|
// @ts-ignore
|
||||||
|
expect(appEvents.on.mock.calls[0][1].toString).toBe(historyListCtrl.onDashboardSaved.toString);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,6 +8,7 @@ import { AppEvents, dateTime, DateTimeInput, toUtc } from '@grafana/data';
|
|||||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||||
import { CoreEvents } from 'app/types';
|
import { CoreEvents } from 'app/types';
|
||||||
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
|
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
|
||||||
|
import { appEvents } from 'app/core/app_events';
|
||||||
|
|
||||||
export class HistoryListCtrl {
|
export class HistoryListCtrl {
|
||||||
appending: boolean;
|
appending: boolean;
|
||||||
@ -42,7 +43,7 @@ export class HistoryListCtrl {
|
|||||||
this.start = 0;
|
this.start = 0;
|
||||||
this.canCompare = false;
|
this.canCompare = false;
|
||||||
|
|
||||||
this.$rootScope.onAppEvent(CoreEvents.dashboardSaved, this.onDashboardSaved.bind(this), $scope);
|
appEvents.on(CoreEvents.dashboardSaved, this.onDashboardSaved.bind(this), $scope);
|
||||||
this.resetFromSource();
|
this.resetFromSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,7 +12,6 @@ import './components/VersionHistory';
|
|||||||
import './components/DashboardSettings';
|
import './components/DashboardSettings';
|
||||||
import './components/SubMenu';
|
import './components/SubMenu';
|
||||||
import './components/UnsavedChangesModal';
|
import './components/UnsavedChangesModal';
|
||||||
import './components/SaveModals';
|
|
||||||
import './components/ShareModal';
|
import './components/ShareModal';
|
||||||
import './components/AdHocFilters';
|
import './components/AdHocFilters';
|
||||||
import './components/RowOptions';
|
import './components/RowOptions';
|
||||||
|
@ -4,6 +4,7 @@ import { DashboardModel } from '../state/DashboardModel';
|
|||||||
import { ContextSrv } from 'app/core/services/context_srv';
|
import { ContextSrv } from 'app/core/services/context_srv';
|
||||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||||
import { CoreEvents, AppEventConsumer } from 'app/types';
|
import { CoreEvents, AppEventConsumer } from 'app/types';
|
||||||
|
import { appEvents } from 'app/core/app_events';
|
||||||
|
|
||||||
export class ChangeTracker {
|
export class ChangeTracker {
|
||||||
current: any;
|
current: any;
|
||||||
@ -32,7 +33,7 @@ export class ChangeTracker {
|
|||||||
this.scope = scope;
|
this.scope = scope;
|
||||||
|
|
||||||
// register events
|
// register events
|
||||||
scope.onAppEvent(CoreEvents.dashboardSaved, () => {
|
appEvents.on(CoreEvents.dashboardSaved, () => {
|
||||||
this.original = this.current.getSaveModelClone();
|
this.original = this.current.getSaveModelClone();
|
||||||
this.originalPath = $location.path();
|
this.originalPath = $location.path();
|
||||||
});
|
});
|
||||||
@ -169,17 +170,11 @@ export class ChangeTracker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveChanges() {
|
onSaveSuccess = () => {
|
||||||
const self = this;
|
this.$timeout(() => {
|
||||||
const cancel = this.$rootScope.$on('dashboard-saved', () => {
|
this.gotoNext();
|
||||||
cancel();
|
|
||||||
this.$timeout(() => {
|
|
||||||
self.gotoNext();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
this.$rootScope.appEvent(CoreEvents.saveDashboard);
|
|
||||||
}
|
|
||||||
|
|
||||||
gotoNext() {
|
gotoNext() {
|
||||||
const baseLen = this.$location.absUrl().length - this.$location.url().length;
|
const baseLen = this.$location.absUrl().length - this.$location.url().length;
|
||||||
|
@ -1,29 +1,20 @@
|
|||||||
import { ILocationService } from 'angular';
|
import { ILocationService } from 'angular';
|
||||||
import { AppEvents, PanelEvents } from '@grafana/data';
|
import { PanelEvents } from '@grafana/data';
|
||||||
|
|
||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
import { appEvents } from 'app/core/app_events';
|
import { appEvents } from 'app/core/app_events';
|
||||||
import locationUtil from 'app/core/utils/location_util';
|
|
||||||
import { DashboardModel } from '../state/DashboardModel';
|
import { DashboardModel } from '../state/DashboardModel';
|
||||||
import { removePanel } from '../utils/panel';
|
import { removePanel } from '../utils/panel';
|
||||||
import { CoreEvents, DashboardMeta } from 'app/types';
|
import { CoreEvents, DashboardMeta } from 'app/types';
|
||||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv, getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
import { promiseToDigest } from '../../../core/utils/promiseToDigest';
|
import { promiseToDigest } from '../../../core/utils/promiseToDigest';
|
||||||
|
|
||||||
interface DashboardSaveOptions {
|
|
||||||
folderId?: number;
|
|
||||||
overwrite?: boolean;
|
|
||||||
message?: string;
|
|
||||||
makeEditable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DashboardSrv {
|
export class DashboardSrv {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(private $rootScope: GrafanaRootScope, private $location: ILocationService) {
|
constructor(private $rootScope: GrafanaRootScope, private $location: ILocationService) {
|
||||||
appEvents.on(CoreEvents.saveDashboard, this.saveDashboard.bind(this), $rootScope);
|
|
||||||
appEvents.on(PanelEvents.panelChangeView, this.onPanelChangeView);
|
appEvents.on(PanelEvents.panelChangeView, this.onPanelChangeView);
|
||||||
appEvents.on(CoreEvents.removePanel, this.onRemovePanel);
|
appEvents.on(CoreEvents.removePanel, this.onRemovePanel);
|
||||||
}
|
}
|
||||||
@ -87,140 +78,8 @@ export class DashboardSrv {
|
|||||||
this.$location.search(newUrlParams);
|
this.$location.search(newUrlParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSaveDashboardError(
|
|
||||||
clone: any,
|
|
||||||
options: DashboardSaveOptions,
|
|
||||||
err: { data: { status: string; message: any }; isHandled: boolean }
|
|
||||||
) {
|
|
||||||
options.overwrite = true;
|
|
||||||
|
|
||||||
if (err.data && err.data.status === 'version-mismatch') {
|
|
||||||
err.isHandled = true;
|
|
||||||
|
|
||||||
this.$rootScope.appEvent(CoreEvents.showConfirmModal, {
|
|
||||||
title: 'Conflict',
|
|
||||||
text: 'Someone else has updated this dashboard.',
|
|
||||||
text2: 'Would you still like to save this dashboard?',
|
|
||||||
yesText: 'Save & Overwrite',
|
|
||||||
icon: 'fa-warning',
|
|
||||||
onConfirm: () => {
|
|
||||||
this.save(clone, options);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.data && err.data.status === 'name-exists') {
|
|
||||||
err.isHandled = true;
|
|
||||||
|
|
||||||
this.$rootScope.appEvent(CoreEvents.showConfirmModal, {
|
|
||||||
title: 'Conflict',
|
|
||||||
text: 'A dashboard with the same name in selected folder already exists.',
|
|
||||||
text2: 'Would you still like to save this dashboard?',
|
|
||||||
yesText: 'Save & Overwrite',
|
|
||||||
icon: 'fa-warning',
|
|
||||||
onConfirm: () => {
|
|
||||||
this.save(clone, options);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.data && err.data.status === 'plugin-dashboard') {
|
|
||||||
err.isHandled = true;
|
|
||||||
|
|
||||||
this.$rootScope.appEvent(CoreEvents.showConfirmModal, {
|
|
||||||
title: 'Plugin Dashboard',
|
|
||||||
text: err.data.message,
|
|
||||||
text2: 'Your changes will be lost when you update the plugin. Use Save As to create custom version.',
|
|
||||||
yesText: 'Overwrite',
|
|
||||||
icon: 'fa-warning',
|
|
||||||
altActionText: 'Save As',
|
|
||||||
onAltAction: () => {
|
|
||||||
this.showSaveAsModal();
|
|
||||||
},
|
|
||||||
onConfirm: () => {
|
|
||||||
this.save(clone, { ...options, overwrite: true });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
postSave(data: { version: number; url: string }) {
|
|
||||||
this.dashboard.version = data.version;
|
|
||||||
|
|
||||||
// important that these happen before location redirect below
|
|
||||||
this.$rootScope.appEvent(CoreEvents.dashboardSaved, this.dashboard);
|
|
||||||
this.$rootScope.appEvent(AppEvents.alertSuccess, ['Dashboard saved']);
|
|
||||||
|
|
||||||
const newUrl = locationUtil.stripBaseFromUrl(data.url);
|
|
||||||
const currentPath = this.$location.path();
|
|
||||||
|
|
||||||
if (newUrl !== currentPath) {
|
|
||||||
this.$location.url(newUrl).replace();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.dashboard;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(clone: any, options?: DashboardSaveOptions) {
|
|
||||||
options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
|
|
||||||
|
|
||||||
return promiseToDigest(this.$rootScope)(
|
|
||||||
backendSrv
|
|
||||||
.saveDashboard(clone, options)
|
|
||||||
.then((data: any) => this.postSave(data))
|
|
||||||
.catch(this.handleSaveDashboardError.bind(this, clone, { folderId: options.folderId }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDashboard(
|
|
||||||
clone?: DashboardModel,
|
|
||||||
{ makeEditable = false, folderId, overwrite = false, message }: DashboardSaveOptions = {}
|
|
||||||
) {
|
|
||||||
if (clone) {
|
|
||||||
this.setCurrent(this.create(clone, this.dashboard.meta));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.dashboard.meta.provisioned) {
|
|
||||||
return this.showDashboardProvisionedModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(this.dashboard.meta.canSave || makeEditable)) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.dashboard.title === 'New dashboard') {
|
|
||||||
return this.showSaveAsModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.dashboard.version > 0) {
|
|
||||||
return this.showSaveModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.save(this.dashboard.getSaveModelClone(), { folderId, overwrite, message });
|
|
||||||
}
|
|
||||||
|
|
||||||
saveJSONDashboard(json: string) {
|
saveJSONDashboard(json: string) {
|
||||||
return this.save(JSON.parse(json), {});
|
return getBackendSrv().saveDashboard(JSON.parse(json), {});
|
||||||
}
|
|
||||||
|
|
||||||
showDashboardProvisionedModal() {
|
|
||||||
this.$rootScope.appEvent(CoreEvents.showModal, {
|
|
||||||
templateHtml: '<save-provisioned-dashboard-modal dismiss="dismiss()"></save-provisioned-dashboard-modal>',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showSaveAsModal() {
|
|
||||||
this.$rootScope.appEvent(CoreEvents.showModal, {
|
|
||||||
templateHtml: '<save-dashboard-as-modal dismiss="dismiss()"></save-dashboard-as-modal>',
|
|
||||||
modalClass: 'modal--narrow',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showSaveModal() {
|
|
||||||
this.$rootScope.appEvent(CoreEvents.showModal, {
|
|
||||||
templateHtml: '<save-dashboard-modal dismiss="dismiss()"></save-dashboard-modal>',
|
|
||||||
modalClass: 'modal--narrow',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
starDashboard(dashboardId: string, isStarred: any) {
|
starDashboard(dashboardId: string, isStarred: any) {
|
||||||
|
@ -8,10 +8,10 @@ import coreModule from 'app/core/core_module';
|
|||||||
import { store } from 'app/store/store';
|
import { store } from 'app/store/store';
|
||||||
import { ContextSrv } from 'app/core/services/context_srv';
|
import { ContextSrv } from 'app/core/services/context_srv';
|
||||||
import { provideTheme } from 'app/core/utils/ConfigProvider';
|
import { provideTheme } from 'app/core/utils/ConfigProvider';
|
||||||
import { ErrorBoundaryAlert } from '@grafana/ui';
|
import { ErrorBoundaryAlert, ModalRoot, ModalsProvider } from '@grafana/ui';
|
||||||
import { GrafanaRootScope } from './GrafanaCtrl';
|
import { GrafanaRootScope } from './GrafanaCtrl';
|
||||||
|
|
||||||
function WrapInProvider(store: any, Component: any, props: any) {
|
export function WrapInProvider(store: any, Component: any, props: any) {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ErrorBoundaryAlert style="page">
|
<ErrorBoundaryAlert style="page">
|
||||||
@ -21,6 +21,17 @@ function WrapInProvider(store: any, Component: any, props: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const provideModalsContext = (component: any) => {
|
||||||
|
return (props: any) => (
|
||||||
|
<ModalsProvider>
|
||||||
|
<>
|
||||||
|
{React.createElement(component, { ...props })}
|
||||||
|
<ModalRoot />
|
||||||
|
</>
|
||||||
|
</ModalsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
export function reactContainer(
|
export function reactContainer(
|
||||||
$route: any,
|
$route: any,
|
||||||
@ -57,7 +68,7 @@ export function reactContainer(
|
|||||||
|
|
||||||
document.body.classList.add('is-react');
|
document.body.classList.add('is-react');
|
||||||
|
|
||||||
ReactDOM.render(WrapInProvider(store, provideTheme(component), props), elem[0]);
|
ReactDOM.render(WrapInProvider(store, provideTheme(provideModalsContext(component)), props), elem[0]);
|
||||||
|
|
||||||
scope.$on('$destroy', () => {
|
scope.$on('$destroy', () => {
|
||||||
document.body.classList.remove('is-react');
|
document.body.classList.remove('is-react');
|
||||||
|
@ -22,6 +22,11 @@ export interface ShowModalPayload {
|
|||||||
scope?: any;
|
scope?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShowModalReactPayload {
|
||||||
|
component: React.ComponentType;
|
||||||
|
props?: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShowConfirmModalPayload {
|
export interface ShowConfirmModalPayload {
|
||||||
title?: string;
|
title?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
@ -109,6 +114,7 @@ export const timepickerClosed = eventFactory('timepickerClosed');
|
|||||||
export const showModal = eventFactory<ShowModalPayload>('show-modal');
|
export const showModal = eventFactory<ShowModalPayload>('show-modal');
|
||||||
export const showConfirmModal = eventFactory<ShowConfirmModalPayload>('confirm-modal');
|
export const showConfirmModal = eventFactory<ShowConfirmModalPayload>('confirm-modal');
|
||||||
export const hideModal = eventFactory('hide-modal');
|
export const hideModal = eventFactory('hide-modal');
|
||||||
|
export const showModalReact = eventFactory<ShowModalReactPayload>('show-modal-react');
|
||||||
|
|
||||||
export const dsRequestResponse = eventFactory<DataSourceResponsePayload>('ds-request-response');
|
export const dsRequestResponse = eventFactory<DataSourceResponsePayload>('ds-request-response');
|
||||||
export const dsRequestError = eventFactory<any>('ds-request-error');
|
export const dsRequestError = eventFactory<any>('ds-request-error');
|
||||||
|
Loading…
Reference in New Issue
Block a user