Migration: Save dashboard modals (#22395)

* Add mechanism for imperatively showing modals

* Migration work in progress

* Reorganise save modal components

* use app events emmiter instead of root scope one

* Add center alignment to layoout component

* Make save buttons wotk

* Prettier

* Remove save dashboard logic  from dashboard srv

* Remove unused code

* Dont show error notifications

* Save modal when dashboard is overwritten

* For tweaks

* Folder picker tweaks

* Save dashboard tweaks

* Copy provisioned dashboard to clipboard

* Enable saving dashboard json to file

* Use SaveDashboardAsButton

* Review

* Align buttons in dashboard settings

* Migrate SaveDashboardAs tests

* TS fixes

* SaveDashboardForm tests migrated

* Fixe some failing tests

* Fix folder picker tests

* Fix HistoryListCtrl tests

* Remove old import

* Enable fixed positioning for folder picker select menu

* Modal: show react modals with appEvents

* Open react modals using event

* Move save dashboard modals to dashboard feature

* Make e2e pass

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

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

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
This commit is contained in:
Dominik Prokop 2020-03-03 08:22:26 +01:00 committed by GitHub
parent cc638e81f4
commit baa356e26d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1235 additions and 868 deletions

View File

@ -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',
}, },
}); });

View File

@ -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>
); );

View File

@ -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>
); );

View File

@ -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} />;
}); });

View File

@ -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>
);
} }

View File

@ -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

View File

@ -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>
);
});

View File

@ -28,4 +28,5 @@ const Forms = {
TextArea, TextArea,
}; };
export { ButtonVariant } from './Button';
export default Forms; export default Forms;

View File

@ -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',

View File

@ -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[];

View File

@ -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);
`,
}));

View 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;

View File

@ -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';

View File

@ -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 }],
]);
} }

View File

@ -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;

View File

@ -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 },
}, },
})); }));

View File

@ -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>
)} )}

View 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>
</>
);
});

View File

@ -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', () => {

View File

@ -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();

View File

@ -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>
);
};
}

View File

@ -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 && (

View File

@ -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() {

View File

@ -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>

View File

@ -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>
)}
</>
);
};

View File

@ -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));

View File

@ -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};
}
`,
}));

View File

@ -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>
)}
</>
);
};

View File

@ -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 />}
</>
);
};

View File

@ -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>
);
};

View File

@ -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);
});
});
});

View File

@ -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>
);
};

View File

@ -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);
});
});
});
});

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

@ -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;
}

View File

@ -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 };
};

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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);
});
});
});
});

View File

@ -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 &hellip;"
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);

View File

@ -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));
});
});

View File

@ -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);

View File

@ -1,3 +0,0 @@
export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
export { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';

View File

@ -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() {

View File

@ -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);
}); });
}); });
}); });

View File

@ -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();
} }

View File

@ -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';

View File

@ -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;

View File

@ -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) {

View File

@ -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');

View File

@ -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');