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>
pull/19727/head
Dominik Prokop 5 years ago committed by GitHub
parent cc638e81f4
commit baa356e26d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-e2e/src/pages/saveDashboardModal.ts
  2. 5
      packages/grafana-ui/src/components/Button/Button.tsx
  3. 15
      packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx
  4. 17
      packages/grafana-ui/src/components/Forms/Button.tsx
  5. 28
      packages/grafana-ui/src/components/Forms/Form.tsx
  6. 3
      packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx
  7. 24
      packages/grafana-ui/src/components/Forms/TextArea/TextArea.tsx
  8. 1
      packages/grafana-ui/src/components/Forms/index.ts
  9. 3
      packages/grafana-ui/src/components/Icon/types.ts
  10. 2
      packages/grafana-ui/src/components/Layout/Layout.tsx
  11. 114
      packages/grafana-ui/src/components/Modal/Modal.tsx
  12. 63
      packages/grafana-ui/src/components/Modal/ModalsContext.tsx
  13. 5
      packages/grafana-ui/src/components/index.ts
  14. 14
      public/app/core/angular_wrappers.ts
  15. 2
      public/app/core/components/CopyToClipboard/CopyToClipboard.tsx
  16. 20
      public/app/core/components/Select/FolderPicker.test.tsx
  17. 76
      public/app/core/components/Select/FolderPicker.tsx
  18. 16
      public/app/core/components/modals/AngularModalProxy.tsx
  19. 8
      public/app/core/services/keybindingSrv.ts
  20. 31
      public/app/core/services/util_srv.ts
  21. 13
      public/app/core/utils/connectWithReduxStore.tsx
  22. 26
      public/app/features/dashboard/components/DashNav/DashNav.tsx
  23. 17
      public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
  24. 14
      public/app/features/dashboard/components/DashboardSettings/template.html
  25. 38
      public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx
  26. 92
      public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx
  27. 121
      public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx
  28. 39
      public/app/features/dashboard/components/SaveDashboard/SaveDashboardModal.tsx
  29. 26
      public/app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy.tsx
  30. 12
      public/app/features/dashboard/components/SaveDashboard/SaveProvisionedDashboard.tsx
  31. 113
      public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx
  32. 107
      public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx
  33. 100
      public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx
  34. 67
      public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx
  35. 71
      public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx
  36. 21
      public/app/features/dashboard/components/SaveDashboard/types.ts
  37. 49
      public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx
  38. 71
      public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.test.ts
  39. 124
      public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
  40. 57
      public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts
  41. 141
      public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts
  42. 30
      public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts
  43. 84
      public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.ts
  44. 3
      public/app/features/dashboard/components/SaveModals/index.ts
  45. 7
      public/app/features/dashboard/components/UnsavedChangesModal/UnsavedChangesModalCtrl.ts
  46. 20
      public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
  47. 3
      public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
  48. 1
      public/app/features/dashboard/index.ts
  49. 17
      public/app/features/dashboard/services/ChangeTracker.ts
  50. 147
      public/app/features/dashboard/services/DashboardSrv.ts
  51. 17
      public/app/routes/ReactContainer.tsx
  52. 6
      public/app/types/events.ts

@ -4,5 +4,7 @@ export const SaveDashboardModal = pageFactory({
url: '',
selectors: {
save: 'Dashboard settings Save Dashboard Modal Save button',
saveVariables: 'Dashboard settings Save Dashboard Modal Save variables checkbox',
saveTimerange: 'Dashboard settings Save Dashboard Modal Save timerange checkbox',
},
});

@ -3,6 +3,7 @@ import { ThemeContext } from '../../themes';
import { getButtonStyles } from './styles';
import { ButtonContent } from './ButtonContent';
import { ButtonSize, ButtonStyles, ButtonVariant } from './types';
import { cx } from 'emotion';
type CommonProps = {
size?: ButtonSize;
@ -34,7 +35,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, r
});
return (
<button className={styles.button} {...buttonProps} ref={ref}>
<button className={cx(styles.button, className)} {...buttonProps} ref={ref}>
<ButtonContent icon={icon}>{children}</ButtonContent>
</button>
);
@ -62,7 +63,7 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>((
});
return (
<a className={styles.button} {...anchorProps} ref={ref}>
<a className={cx(styles.button, className)} {...anchorProps} ref={ref}>
<ButtonContent icon={icon}>{children}</ButtonContent>
</a>
);

@ -5,6 +5,7 @@ import { IconType } from '../Icon/types';
import { Button } from '../Button/Button';
import { stylesFactory, ThemeContext } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
import { HorizontalGroup } from '..';
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
@ -19,13 +20,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
margin-bottom: calc(${theme.spacing.d} * 2);
padding-top: ${theme.spacing.d};
`,
modalButtonRow: css`
margin-bottom: 14px;
a,
button {
margin-right: ${theme.spacing.d};
}
`,
}));
const defaultIcon: IconType = 'exclamation-triangle';
@ -33,11 +27,10 @@ const defaultIcon: IconType = 'exclamation-triangle';
interface Props {
isOpen: boolean;
title: string;
body: string;
body: React.ReactNode;
confirmText: string;
dismissText?: string;
icon?: IconType;
onConfirm(): void;
onDismiss(): void;
}
@ -59,14 +52,14 @@ export const ConfirmModal: FC<Props> = ({
<Modal className={styles.modal} title={title} icon={icon || defaultIcon} isOpen={isOpen} onDismiss={onDismiss}>
<div className={styles.modalContent}>
<div className={styles.modalText}>{body}</div>
<div className={styles.modalButtonRow}>
<HorizontalGroup justify="center">
<Button variant="danger" onClick={onConfirm}>
{confirmText}
</Button>
<Button variant="inverse" onClick={onDismiss}>
{dismissText}
</Button>
</div>
</HorizontalGroup>
</div>
</Modal>
);

@ -63,7 +63,6 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
}
`,
};
case 'primary':
default:
return {
@ -139,23 +138,23 @@ type CommonProps = {
export type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ variant, ...otherProps }, ref) => {
const theme = useContext(ThemeContext);
const styles = getButtonStyles({
theme,
size: props.size || 'md',
variant: props.variant || 'primary',
size: otherProps.size || 'md',
variant: variant || 'primary',
});
return <DefaultButton {...props} styles={styles} ref={ref} />;
return <DefaultButton {...otherProps} variant={variant} styles={styles} ref={ref} />;
});
type ButtonLinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>;
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>((props, ref) => {
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(({ variant, ...otherProps }, ref) => {
const theme = useContext(ThemeContext);
const styles = getButtonStyles({
theme,
size: props.size || 'md',
variant: props.variant || 'primary',
size: otherProps.size || 'md',
variant: variant || 'primary',
});
return <DefaultLinkButton {...props} styles={styles} ref={ref} />;
return <DefaultLinkButton {...otherProps} variant={variant} styles={styles} ref={ref} />;
});

@ -1,20 +1,5 @@
/**
* This is a stub implementation only for correct styles to be applied
*/
import React, { useEffect } from 'react';
import { useForm, Mode, OnSubmit, DeepPartial, FormContextValues } from 'react-hook-form';
import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
import { stylesFactory, useTheme } from '../../themes';
const getFormStyles = stylesFactory((theme: GrafanaTheme) => {
return {
form: css`
margin-bottom: ${theme.spacing.formMargin};
`,
};
});
type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control'>;
@ -26,23 +11,14 @@ interface FormProps<T> {
}
export function Form<T>({ validateOn, defaultValues, onSubmit, children }: FormProps<T>) {
const theme = useTheme();
const { handleSubmit, register, errors, control, reset, getValues } = useForm<T>({
mode: validateOn || 'onSubmit',
defaultValues: {
...defaultValues,
},
defaultValues,
});
useEffect(() => {
reset({ ...getValues(), ...defaultValues });
}, [defaultValues]);
const styles = getFormStyles(theme);
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
{children({ register, errors, control })}
</form>
);
return <form onSubmit={handleSubmit(onSubmit)}>{children({ register, errors, control })}</form>;
}

@ -65,6 +65,7 @@ export interface SelectCommonProps<T> {
prefix?: JSX.Element | string | null;
/** Use a custom element to control Select. A proper ref to the renderControl is needed if 'portal' isn't set to null*/
renderControl?: ControlComponent<T>;
menuPosition?: 'fixed' | 'absolute';
}
export interface SelectAsyncProps<T> {
@ -176,6 +177,7 @@ export function SelectBase<T>({
width,
invalid,
components,
menuPosition,
}: SelectBaseProps<T>) {
const theme = useTheme();
const styles = getSelectStyles(theme);
@ -246,6 +248,7 @@ export function SelectBase<T>({
renderControl,
captureMenuScroll: false,
menuPlacement: 'auto',
menuPosition,
};
// width property is deprecated in favor of size or className

@ -1,4 +1,4 @@
import React, { HTMLProps, forwardRef } from 'react';
import React, { HTMLProps } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion';
import { stylesFactory, useTheme } from '../../../themes';
@ -12,6 +12,17 @@ export interface Props extends Omit<HTMLProps<HTMLTextAreaElement>, 'size'> {
size?: FormInputSize;
}
export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(({ invalid, size = 'auto', ...props }, ref) => {
const theme = useTheme();
const styles = getTextAreaStyle(theme, invalid);
return (
<div className={inputSizes()[size]}>
<textarea className={styles.textarea} {...props} ref={ref} />
</div>
);
});
const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) => {
return {
textarea: cx(
@ -25,14 +36,3 @@ const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) =>
),
};
});
export const TextArea = forwardRef<HTMLTextAreaElement, Props>(({ invalid, size = 'auto', ...props }, ref) => {
const theme = useTheme();
const styles = getTextAreaStyle(theme, invalid);
return (
<div className={inputSizes()[size]}>
<textarea ref={ref} className={styles.textarea} {...props} />
</div>
);
});

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

@ -648,7 +648,8 @@ export type IconType =
| 'snowflake-o'
| 'superpowers'
| 'wpexplorer'
| 'meetup';
| 'meetup'
| 'copy';
export const getAvailableIcons = (): IconType[] => [
'glass',

@ -8,7 +8,7 @@ enum Orientation {
Vertical,
}
type Spacing = 'xs' | 'sm' | 'md' | 'lg';
type Justify = 'flex-start' | 'flex-end' | 'space-between';
type Justify = 'flex-start' | 'flex-end' | 'space-between' | 'center';
export interface LayoutProps {
children: React.ReactNode[];

@ -6,63 +6,6 @@ import { GrafanaTheme } from '@grafana/data';
import { Icon } from '../Icon/Icon';
import { IconType } from '../Icon/types';
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
position: fixed;
z-index: ${theme.zIndex.modal};
background: ${theme.colors.pageBg};
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
background-clip: padding-box;
outline: none;
width: 750px;
max-width: 100%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 10%;
`,
modalBackdrop: css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: ${theme.zIndex.modalBackdrop};
background-color: ${theme.colors.blueFaint};
opacity: 0.8;
backdrop-filter: blur(4px);
`,
modalHeader: css`
background: ${theme.background.pageHeader};
box-shadow: ${theme.shadow.pageHeader};
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
display: flex;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
`,
modalHeaderIcon: css`
margin-right: ${theme.spacing.md};
font-size: inherit;
&:before {
vertical-align: baseline;
}
`,
modalHeaderClose: css`
margin-left: auto;
padding: 9px ${theme.spacing.d};
`,
modalContent: css`
padding: calc(${theme.spacing.d} * 2);
overflow: auto;
width: 100%;
max-height: calc(90vh - ${theme.spacing.d} * 2);
`,
}));
interface Props {
icon?: IconType;
title: string | JSX.Element;
@ -125,3 +68,60 @@ export class UnthemedModal extends React.PureComponent<Props> {
}
export const Modal = withTheme(UnthemedModal);
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
position: fixed;
z-index: ${theme.zIndex.modal};
background: ${theme.colors.pageBg};
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
background-clip: padding-box;
outline: none;
width: 750px;
max-width: 100%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 10%;
`,
modalBackdrop: css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: ${theme.zIndex.modalBackdrop};
background-color: ${theme.colors.blueFaint};
opacity: 0.8;
backdrop-filter: blur(4px);
`,
modalHeader: css`
background: ${theme.background.pageHeader};
box-shadow: ${theme.shadow.pageHeader};
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
display: flex;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
`,
modalHeaderIcon: css`
margin-right: ${theme.spacing.md};
font-size: inherit;
&:before {
vertical-align: baseline;
}
`,
modalHeaderClose: css`
margin-left: auto;
padding: 9px ${theme.spacing.d};
`,
modalContent: css`
padding: calc(${theme.spacing.d} * 2);
overflow: auto;
width: 100%;
max-height: calc(90vh - ${theme.spacing.d} * 2);
`,
}));

@ -0,0 +1,63 @@
import React from 'react';
interface ModalsContextState {
component: React.ComponentType<any> | null;
props: any;
showModal: <T>(component: React.ComponentType<T>, props: T) => void;
hideModal: () => void;
}
const ModalsContext = React.createContext<ModalsContextState>({
component: null,
props: {},
showModal: () => {},
hideModal: () => {},
});
interface ModalsProviderProps {
children: React.ReactNode;
/** Set default component to render as modal. Usefull when rendering modals from Angular */
component?: React.ComponentType<any> | null;
/** Set default component props. Usefull when rendering modals from Angular */
props?: any;
}
export class ModalsProvider extends React.Component<ModalsProviderProps, ModalsContextState> {
constructor(props: ModalsProviderProps) {
super(props);
this.state = {
component: props.component || null,
props: props.props || {},
showModal: this.showModal,
hideModal: this.hideModal,
};
}
showModal = (component: React.ComponentType<any>, props: any) => {
this.setState({
component,
props,
});
};
hideModal = () => {
this.setState({
component: null,
props: {},
});
};
render() {
return <ModalsContext.Provider value={this.state}>{this.props.children}</ModalsContext.Provider>;
}
}
export const ModalRoot = () => (
<ModalsContext.Consumer>
{({ component: Component, props }) => {
return Component ? <Component {...props} /> : null;
}}
</ModalsContext.Consumer>
);
export const ModalsController = ModalsContext.Consumer;

@ -40,6 +40,9 @@ export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
export { List } from './List/List';
export { TagsInput } from './TagsInput/TagsInput';
export { Modal } from './Modal/Modal';
export { ModalsProvider, ModalRoot, ModalsController } from './Modal/ModalsContext';
export { ConfirmModal } from './ConfirmModal/ConfirmModal';
export { QueryField } from './QueryField/QueryField';
@ -138,7 +141,7 @@ export {
} from './FieldConfigs/select';
// Next-gen forms
export { default as Forms } from './Forms';
export { default as Forms, ButtonVariant } from './Forms';
export { ValuePicker } from './ValuePicker/ValuePicker';
export { fieldMatchersUI } from './MatchersUI/fieldMatchersUI';
export { getStandardFieldConfigs } from './FieldConfigs/standardFieldConfigEditors';

@ -24,6 +24,10 @@ import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/component
import { HelpModal } from './components/help/HelpModal';
import { Footer } from './components/Footer/Footer';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import {
SaveDashboardAsButtonConnected,
SaveDashboardButtonConnected,
} from '../features/dashboard/components/SaveDashboard/SaveDashboardButton';
export function registerAngularDirectives() {
react2AngularDirective('footer', Footer, []);
@ -151,4 +155,14 @@ export function registerAngularDirectives() {
['onLoad', { 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 {
text: () => string;
elType?: string;
elType?: string | React.RefForwardingComponent<any, any>;
onSuccess?: (evt: any) => void;
onError?: (evt: any) => void;
className?: string;

@ -1,19 +1,19 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as Backend from 'app/core/services/backend_srv';
import { FolderPicker } from './FolderPicker';
jest.spyOn(Backend, 'getBackendSrv').mockReturnValue({
search: jest.fn(() => [
{ title: 'Dash 1', id: 'A' },
{ title: 'Dash 2', id: 'B' },
]),
} as any);
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
search: jest.fn(() => [
{ title: 'Dash 1', id: 'A' },
{ title: 'Dash 2', id: 'B' },
]),
}),
}));
jest.mock('app/core/core', () => ({
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
isEditor: true,
user: { orgId: 1 },
},
}));

@ -2,9 +2,10 @@ import React, { PureComponent } from 'react';
import { Forms } from '@grafana/ui';
import { AppEvents, SelectableValue } from '@grafana/data';
import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/core';
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 {
onChange: ($folder: { title: string; id: number }) => void;
@ -14,12 +15,11 @@ export interface Props {
dashboardId?: any;
initialTitle?: string;
initialFolderId?: number;
useNewForms?: boolean;
}
interface State {
folder: SelectableValue<number>;
validationError: string;
hasValidationError: boolean;
}
export class FolderPicker extends PureComponent<Props, State> {
@ -30,8 +30,6 @@ export class FolderPicker extends PureComponent<Props, State> {
this.state = {
folder: {},
validationError: '',
hasValidationError: false,
};
this.debouncedSearch = debounce(this.getOptions, 300, {
@ -59,7 +57,9 @@ export class FolderPicker extends PureComponent<Props, State> {
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 }));
if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase())) {
options.unshift({ label: rootName, value: 0 });
@ -72,7 +72,7 @@ export class FolderPicker extends PureComponent<Props, State> {
return options;
};
onFolderChange = async (newFolder: SelectableValue<number>) => {
onFolderChange = (newFolder: SelectableValue<number>) => {
if (!newFolder) {
newFolder = { value: 0, label: this.props.rootName };
}
@ -86,6 +86,7 @@ export class FolderPicker extends PureComponent<Props, State> {
};
createNewFolder = async (folderName: string) => {
// @ts-ignore
const newFolder = await getBackendSrv().createFolder({ title: folderName });
let folder = { value: -1, label: 'Not created' };
if (newFolder.id > -1) {
@ -107,9 +108,10 @@ export class FolderPicker extends PureComponent<Props, State> {
const options = await this.getOptions('');
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 };
} else if (enableReset && initialTitle && initialFolderId === undefined) {
} else if (enableReset && initialTitle) {
folder = resetFolder;
}
@ -141,34 +143,40 @@ export class FolderPicker extends PureComponent<Props, State> {
};
render() {
const { folder, validationError, hasValidationError } = this.state;
const { enableCreateNew } = this.props;
const { folder } = this.state;
const { enableCreateNew, useNewForms } = this.props;
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<label className="gf-form-label width-7">Folder</label>
<Forms.AsyncSelect
loadingMessage="Loading folders..."
defaultOptions
defaultValue={folder}
value={folder}
allowCustomValue={enableCreateNew}
loadOptions={this.debouncedSearch}
onChange={this.onFolderChange}
onCreateOption={this.createNewFolder}
size="sm"
/>
</div>
</div>
{hasValidationError && (
{useNewForms && (
<Forms.AsyncSelect
loadingMessage="Loading folders..."
defaultOptions
defaultValue={folder}
value={folder}
allowCustomValue={enableCreateNew}
loadOptions={this.debouncedSearch}
onChange={this.onFolderChange}
onCreateOption={this.createNewFolder}
size="sm"
menuPosition="fixed"
/>
)}
{!useNewForms && (
<div className="gf-form-inline">
<div className="gf-form gf-form--grow">
<label className="gf-form-label text-warning gf-form-label--grow">
<i className="fa fa-warning" />
{validationError}
</label>
<div className="gf-form">
<label className="gf-form-label width-7">Folder</label>
<Forms.AsyncSelect
loadingMessage="Loading folders..."
defaultOptions
defaultValue={folder}
value={folder}
allowCustomValue={enableCreateNew}
loadOptions={this.debouncedSearch}
onChange={this.onFolderChange}
onCreateOption={this.createNewFolder}
size="sm"
/>
</div>
</div>
)}

@ -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 { getLocationSrv } from '@grafana/runtime';
import { DashboardModel } from '../../features/dashboard/state';
import { SaveDashboardModalProxy } from '../../features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
export class KeybindingSrv {
helpModal: boolean;
@ -183,7 +184,12 @@ export class KeybindingSrv {
});
this.bind('mod+s', () => {
scope.appEvent(CoreEvents.saveDashboard);
appEvents.emit(CoreEvents.showModalReact, {
component: SaveDashboardModalProxy,
props: {
dashboard,
},
});
});
this.bind('t z', () => {

@ -1,22 +1,51 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { e2e } from '@grafana/e2e';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { AngularModalProxy } from '../components/modals/AngularModalProxy';
export class UtilSrv {
modalScope: any;
reactModalRoot = document.body;
reactModalNode = document.createElement('div');
/** @ngInject */
constructor(private $rootScope: GrafanaRootScope, private $modal: any) {}
constructor(private $rootScope: GrafanaRootScope, private $modal: any) {
this.reactModalNode.setAttribute('id', 'angular2ReactModalRoot');
}
init() {
appEvents.on(CoreEvents.showModal, this.showModal.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.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() {
if (this.modalScope && this.modalScope.dismiss) {
this.modalScope.dismiss();

@ -1,5 +1,5 @@
import React from 'react';
import { connect } from 'react-redux';
import { connect, Provider } from 'react-redux';
import { store } from '../../store/store';
export function connectWithStore(WrappedComponent: any, ...args: any[]) {
@ -9,3 +9,14 @@ export function connectWithStore(WrappedComponent: any, ...args: any[]) {
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
import { DashNavButton } from './DashNavButton';
import { DashNavTimeControls } from './DashNavTimeControls';
import { ModalsController } from '@grafana/ui';
import { BackButton } from 'app/core/components/BackButton/BackButton';
// State
import { updateLocation } from 'app/core/actions';
// Types
import { DashboardModel } from '../../state';
import { CoreEvents, StoreState } from 'app/types';
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
export interface OwnProps {
dashboard: DashboardModel;
@ -67,12 +69,6 @@ export class DashNav extends PureComponent<Props> {
appEvents.emit(CoreEvents.toggleKioskMode);
};
onSave = () => {
const { $injector } = this.props;
const dashboardSrv = $injector.get('dashboardSrv');
dashboardSrv.saveDashboard();
};
onOpenSettings = () => {
this.props.updateLocation({
query: { editview: 'settings' },
@ -223,7 +219,23 @@ export class DashNav extends PureComponent<Props> {
)}
{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 && (

@ -56,7 +56,9 @@ export class SettingsCtrl {
this.$rootScope.onAppEvent(CoreEvents.routeUpdated, this.onRouteUpdated.bind(this), $scope);
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;
}
@ -146,15 +148,6 @@ export class SettingsCtrl {
this.viewId = '404';
}
}
openSaveAsModal() {
this.dashboardSrv.showSaveAsModal();
}
saveDashboard() {
this.dashboardSrv.saveDashboard();
}
saveDashboardJson() {
this.dashboardSrv.saveJSONDashboard(this.json).then(() => {
this.$route.reload();
@ -257,6 +250,10 @@ export class SettingsCtrl {
url: this.dashboard.meta.folderUrl,
};
}
getDashboard = () => {
return this.dashboard;
};
}
export function dashboardSettings() {

@ -9,14 +9,12 @@
</a>
<div class="dashboard-settings__aside-actions">
<button class="btn btn-primary" ng-click="ctrl.saveDashboard()" ng-show="ctrl.canSave"
aria-label={{ctrl.selectors.saveDashBoard}}>
Save
</button>
<button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs"
aria-label={{ctrl.selectors.saveAsDashBoard}}>
Save As...
</button>
<div ng-show="ctrl.canSave">
<save-dashboard-button getDashboard="ctrl.getDashboard" aria-label="Dashboard settings aside actions Save button" />
</div>
<div ng-show="ctrl.canSaveAs">
<save-dashboard-as-button getDashboard="ctrl.getDashboard" aria-label="Dashboard settings aside actions Save as button" variant="'secondary'" useNewForms="true"/>
</div>
</div>
</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 &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);

@ -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 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-inverse" ng-click="ctrl.dismiss()">Cancel</button>
</div>
@ -44,6 +44,11 @@ export class UnsavedChangesModalCtrl {
this.dismiss();
this.unsavedChangesSrv.tracker.saveChanges();
}
onSaveSuccess = () => {
this.dismiss();
this.unsavedChangesSrv.tracker.onSaveSuccess();
};
}
export function unsavedChangesModalDirective() {

@ -4,6 +4,16 @@ import { IScope } from 'angular';
import { HistoryListCtrl } from './HistoryListCtrl';
import { compare, restore, versions } from './__mocks__/history';
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', () => {
const RESTORE_ID = 4;
@ -114,13 +124,15 @@ describe('HistoryListCtrl', () => {
});
it('should listen for the `dashboardSaved` appEvent', () => {
expect($rootScope.onAppEvent).toHaveBeenCalledTimes(1);
expect($rootScope.onAppEvent.mock.calls[0][0]).toBe(CoreEvents.dashboardSaved);
// @ts-ignore
expect(appEvents.on.mock.calls[0][0]).toBe(CoreEvents.dashboardSaved);
});
it('should call `onDashboardSaved` when the appEvent is received', () => {
expect($rootScope.onAppEvent.mock.calls[0][1]).not.toBe(historyListCtrl.onDashboardSaved);
expect($rootScope.onAppEvent.mock.calls[0][1].toString).toBe(historyListCtrl.onDashboardSaved.toString);
// @ts-ignore
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 { CoreEvents } from 'app/types';
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
import { appEvents } from 'app/core/app_events';
export class HistoryListCtrl {
appending: boolean;
@ -42,7 +43,7 @@ export class HistoryListCtrl {
this.start = 0;
this.canCompare = false;
this.$rootScope.onAppEvent(CoreEvents.dashboardSaved, this.onDashboardSaved.bind(this), $scope);
appEvents.on(CoreEvents.dashboardSaved, this.onDashboardSaved.bind(this), $scope);
this.resetFromSource();
}

@ -12,7 +12,6 @@ import './components/VersionHistory';
import './components/DashboardSettings';
import './components/SubMenu';
import './components/UnsavedChangesModal';
import './components/SaveModals';
import './components/ShareModal';
import './components/AdHocFilters';
import './components/RowOptions';

@ -4,6 +4,7 @@ import { DashboardModel } from '../state/DashboardModel';
import { ContextSrv } from 'app/core/services/context_srv';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { CoreEvents, AppEventConsumer } from 'app/types';
import { appEvents } from 'app/core/app_events';
export class ChangeTracker {
current: any;
@ -32,7 +33,7 @@ export class ChangeTracker {
this.scope = scope;
// register events
scope.onAppEvent(CoreEvents.dashboardSaved, () => {
appEvents.on(CoreEvents.dashboardSaved, () => {
this.original = this.current.getSaveModelClone();
this.originalPath = $location.path();
});
@ -169,17 +170,11 @@ export class ChangeTracker {
});
}
saveChanges() {
const self = this;
const cancel = this.$rootScope.$on('dashboard-saved', () => {
cancel();
this.$timeout(() => {
self.gotoNext();
});
onSaveSuccess = () => {
this.$timeout(() => {
this.gotoNext();
});
this.$rootScope.appEvent(CoreEvents.saveDashboard);
}
};
gotoNext() {
const baseLen = this.$location.absUrl().length - this.$location.url().length;

@ -1,29 +1,20 @@
import { ILocationService } from 'angular';
import { AppEvents, PanelEvents } from '@grafana/data';
import { PanelEvents } from '@grafana/data';
import coreModule from 'app/core/core_module';
import { appEvents } from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { DashboardModel } from '../state/DashboardModel';
import { removePanel } from '../utils/panel';
import { CoreEvents, DashboardMeta } from 'app/types';
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';
interface DashboardSaveOptions {
folderId?: number;
overwrite?: boolean;
message?: string;
makeEditable?: boolean;
}
export class DashboardSrv {
dashboard: DashboardModel;
/** @ngInject */
constructor(private $rootScope: GrafanaRootScope, private $location: ILocationService) {
appEvents.on(CoreEvents.saveDashboard, this.saveDashboard.bind(this), $rootScope);
appEvents.on(PanelEvents.panelChangeView, this.onPanelChangeView);
appEvents.on(CoreEvents.removePanel, this.onRemovePanel);
}
@ -87,140 +78,8 @@ export class DashboardSrv {
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) {
return this.save(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',
});
return getBackendSrv().saveDashboard(JSON.parse(json), {});
}
starDashboard(dashboardId: string, isStarred: any) {

@ -8,10 +8,10 @@ import coreModule from 'app/core/core_module';
import { store } from 'app/store/store';
import { ContextSrv } from 'app/core/services/context_srv';
import { provideTheme } from 'app/core/utils/ConfigProvider';
import { ErrorBoundaryAlert } from '@grafana/ui';
import { ErrorBoundaryAlert, ModalRoot, ModalsProvider } from '@grafana/ui';
import { GrafanaRootScope } from './GrafanaCtrl';
function WrapInProvider(store: any, Component: any, props: any) {
export function WrapInProvider(store: any, Component: any, props: any) {
return (
<Provider store={store}>
<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 */
export function reactContainer(
$route: any,
@ -57,7 +68,7 @@ export function reactContainer(
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', () => {
document.body.classList.remove('is-react');

@ -22,6 +22,11 @@ export interface ShowModalPayload {
scope?: any;
}
export interface ShowModalReactPayload {
component: React.ComponentType;
props?: any;
}
export interface ShowConfirmModalPayload {
title?: string;
text?: string;
@ -109,6 +114,7 @@ export const timepickerClosed = eventFactory('timepickerClosed');
export const showModal = eventFactory<ShowModalPayload>('show-modal');
export const showConfirmModal = eventFactory<ShowConfirmModalPayload>('confirm-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 dsRequestError = eventFactory<any>('ds-request-error');

Loading…
Cancel
Save