mirror of https://github.com/grafana/grafana
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
parent
cc638e81f4
commit
baa356e26d
@ -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; |
@ -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> |
||||
</> |
||||
); |
||||
}); |
@ -0,0 +1,38 @@ |
||||
import React from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { Modal } from '@grafana/ui'; |
||||
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm'; |
||||
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy'; |
||||
import { useDashboardSave } from './useDashboardSave'; |
||||
import { SaveDashboardModalProps } from './types'; |
||||
|
||||
export const SaveDashboardAsModal: React.FC<SaveDashboardModalProps & { |
||||
isNew?: boolean; |
||||
}> = ({ dashboard, onDismiss, isNew }) => { |
||||
const { state, onDashboardSave } = useDashboardSave(dashboard); |
||||
|
||||
return ( |
||||
<> |
||||
{state.error && <SaveDashboardErrorProxy error={state.error} dashboard={dashboard} onDismiss={onDismiss} />} |
||||
{!state.error && ( |
||||
<Modal |
||||
isOpen={true} |
||||
title="Save dashboard as..." |
||||
icon="copy" |
||||
onDismiss={onDismiss} |
||||
className={css` |
||||
width: 500px; |
||||
`}
|
||||
> |
||||
<SaveDashboardAsForm |
||||
dashboard={dashboard} |
||||
onCancel={onDismiss} |
||||
onSuccess={onDismiss} |
||||
onSubmit={onDashboardSave} |
||||
isNew={isNew} |
||||
/> |
||||
</Modal> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,92 @@ |
||||
import React from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { Button, Forms, ModalsController } from '@grafana/ui'; |
||||
import { DashboardModel } from 'app/features/dashboard/state'; |
||||
import { connectWithProvider } from 'app/core/utils/connectWithReduxStore'; |
||||
import { provideModalsContext } from 'app/routes/ReactContainer'; |
||||
import { SaveDashboardAsModal } from './SaveDashboardAsModal'; |
||||
import { SaveDashboardModalProxy } from './SaveDashboardModalProxy'; |
||||
|
||||
interface SaveDashboardButtonProps { |
||||
dashboard: DashboardModel; |
||||
/** |
||||
* Added for being able to render this component as Angular directive! |
||||
* TODO[angular-migrations]: Remove when we migrate Dashboard Settings view to React |
||||
*/ |
||||
getDashboard?: () => DashboardModel; |
||||
onSaveSuccess?: () => void; |
||||
useNewForms?: boolean; |
||||
} |
||||
|
||||
export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ |
||||
dashboard, |
||||
onSaveSuccess, |
||||
getDashboard, |
||||
useNewForms, |
||||
}) => { |
||||
const ButtonComponent = useNewForms ? Forms.Button : Button; |
||||
return ( |
||||
<ModalsController> |
||||
{({ showModal, hideModal }) => { |
||||
return ( |
||||
<ButtonComponent |
||||
onClick={() => { |
||||
showModal(SaveDashboardModalProxy, { |
||||
// TODO[angular-migrations]: Remove tenary op when we migrate Dashboard Settings view to React
|
||||
dashboard: getDashboard ? getDashboard() : dashboard, |
||||
onSaveSuccess, |
||||
onDismiss: hideModal, |
||||
}); |
||||
}} |
||||
> |
||||
Save dashboard |
||||
</ButtonComponent> |
||||
); |
||||
}} |
||||
</ModalsController> |
||||
); |
||||
}; |
||||
|
||||
export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { variant?: string }> = ({ |
||||
dashboard, |
||||
onSaveSuccess, |
||||
getDashboard, |
||||
useNewForms, |
||||
variant, |
||||
}) => { |
||||
const ButtonComponent = useNewForms ? Forms.Button : Button; |
||||
return ( |
||||
<ModalsController> |
||||
{({ showModal, hideModal }) => { |
||||
return ( |
||||
<ButtonComponent |
||||
/* Styles applied here are specific to dashboard settings view */ |
||||
className={css` |
||||
width: 100%; |
||||
justify-content: center; |
||||
`}
|
||||
onClick={() => { |
||||
showModal(SaveDashboardAsModal, { |
||||
// TODO[angular-migrations]: Remove tenary op when we migrate Dashboard Settings view to React
|
||||
dashboard: getDashboard ? getDashboard() : dashboard, |
||||
onSaveSuccess, |
||||
onDismiss: hideModal, |
||||
}); |
||||
}} |
||||
// TODO[angular-migrations]: Hacking the different variants for this single button
|
||||
// In Dashboard Settings in sidebar we need to use new form but with inverse variant to make it look like it should
|
||||
// Everywhere else we use old button component :(
|
||||
variant={variant as any} |
||||
> |
||||
Save As... |
||||
</ButtonComponent> |
||||
); |
||||
}} |
||||
</ModalsController> |
||||
); |
||||
}; |
||||
|
||||
// TODO: this is an ugly solution for the save button to have access to Redux and Modals controller
|
||||
// When we migrate dashboard settings to Angular it won't be necessary.
|
||||
export const SaveDashboardButtonConnected = connectWithProvider(provideModalsContext(SaveDashboardButton)); |
||||
export const SaveDashboardAsButtonConnected = connectWithProvider(provideModalsContext(SaveDashboardAsButton)); |
@ -0,0 +1,121 @@ |
||||
import React, { useEffect } from 'react'; |
||||
import { Button, ConfirmModal, HorizontalGroup, Modal, stylesFactory, useTheme } from '@grafana/ui'; |
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
import { css } from 'emotion'; |
||||
import { DashboardModel } from 'app/features/dashboard/state'; |
||||
import { useDashboardSave } from './useDashboardSave'; |
||||
import { SaveDashboardModalProps } from './types'; |
||||
import { SaveDashboardAsButton } from './SaveDashboardButton'; |
||||
|
||||
interface SaveDashboardErrorProxyProps { |
||||
dashboard: DashboardModel; |
||||
error: any; |
||||
onDismiss: () => void; |
||||
} |
||||
|
||||
export const SaveDashboardErrorProxy: React.FC<SaveDashboardErrorProxyProps> = ({ dashboard, error, onDismiss }) => { |
||||
const { onDashboardSave } = useDashboardSave(dashboard); |
||||
|
||||
useEffect(() => { |
||||
if (error.data) { |
||||
error.isHandled = true; |
||||
} |
||||
}, []); |
||||
|
||||
return ( |
||||
<> |
||||
{error.data && error.data.status === 'version-mismatch' && ( |
||||
<ConfirmModal |
||||
isOpen={true} |
||||
title="Conflict" |
||||
body={ |
||||
<div> |
||||
Someone else has updated this dashboard <br /> <small>Would you still like to save this dashboard?</small> |
||||
</div> |
||||
} |
||||
confirmText="Save & Overwrite" |
||||
onConfirm={async () => { |
||||
await onDashboardSave(dashboard.getSaveModelClone(), { overwrite: true }, dashboard); |
||||
onDismiss(); |
||||
}} |
||||
onDismiss={onDismiss} |
||||
/> |
||||
)} |
||||
{error.data && error.data.status === 'name-exists' && ( |
||||
<ConfirmModal |
||||
isOpen={true} |
||||
title="Conflict" |
||||
body={ |
||||
<div> |
||||
A dashboard with the same name in selected folder already exists. <br /> |
||||
<small>Would you still like to save this dashboard?</small> |
||||
</div> |
||||
} |
||||
confirmText="Save & Overwrite" |
||||
onConfirm={async () => { |
||||
await onDashboardSave(dashboard.getSaveModelClone(), { overwrite: true }, dashboard); |
||||
onDismiss(); |
||||
}} |
||||
onDismiss={onDismiss} |
||||
/> |
||||
)} |
||||
{error.data && error.data.status === 'plugin-dashboard' && ( |
||||
<ConfirmPluginDashboardSaveModal dashboard={dashboard} onDismiss={onDismiss} /> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const ConfirmPluginDashboardSaveModal: React.FC<SaveDashboardModalProps> = ({ onDismiss, dashboard }) => { |
||||
const theme = useTheme(); |
||||
const { onDashboardSave } = useDashboardSave(dashboard); |
||||
const styles = getConfirmPluginDashboardSaveModalStyles(theme); |
||||
|
||||
return ( |
||||
<Modal className={styles.modal} title="Plugin Dashboard" icon="copy" isOpen={true} onDismiss={onDismiss}> |
||||
<div className={styles.modalContent}> |
||||
<div className={styles.modalText}> |
||||
Your changes will be lost when you update the plugin. |
||||
<br /> <small>Use Save As to create custom version.</small> |
||||
</div> |
||||
<HorizontalGroup justify="center"> |
||||
<SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={onDismiss} /> |
||||
<Button |
||||
variant="danger" |
||||
onClick={async () => { |
||||
await onDashboardSave(dashboard.getSaveModelClone(), { overwrite: true }, dashboard); |
||||
onDismiss(); |
||||
}} |
||||
> |
||||
Overwrite |
||||
</Button> |
||||
<Button variant="inverse" onClick={onDismiss}> |
||||
Cancel |
||||
</Button> |
||||
</HorizontalGroup> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
}; |
||||
|
||||
const getConfirmPluginDashboardSaveModalStyles = stylesFactory((theme: GrafanaTheme) => ({ |
||||
modal: css` |
||||
width: 500px; |
||||
`,
|
||||
modalContent: css` |
||||
text-align: center; |
||||
`,
|
||||
modalText: css` |
||||
font-size: ${theme.typography.heading.h4}; |
||||
color: ${theme.colors.link}; |
||||
margin-bottom: calc(${theme.spacing.d} * 2); |
||||
padding-top: ${theme.spacing.d}; |
||||
`,
|
||||
modalButtonRow: css` |
||||
margin-bottom: 14px; |
||||
a, |
||||
button { |
||||
margin-right: ${theme.spacing.d}; |
||||
} |
||||
`,
|
||||
})); |
@ -0,0 +1,39 @@ |
||||
import React from 'react'; |
||||
import { Modal } from '@grafana/ui'; |
||||
import { css } from 'emotion'; |
||||
import { SaveDashboardForm } from './forms/SaveDashboardForm'; |
||||
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy'; |
||||
import { useDashboardSave } from './useDashboardSave'; |
||||
import { SaveDashboardModalProps } from './types'; |
||||
|
||||
export const SaveDashboardModal: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => { |
||||
const { state, onDashboardSave } = useDashboardSave(dashboard); |
||||
return ( |
||||
<> |
||||
{state.error && <SaveDashboardErrorProxy error={state.error} dashboard={dashboard} onDismiss={onDismiss} />} |
||||
{!state.error && ( |
||||
<Modal |
||||
isOpen={true} |
||||
title="Save dashboard" |
||||
icon="copy" |
||||
onDismiss={onDismiss} |
||||
className={css` |
||||
width: 500px; |
||||
`}
|
||||
> |
||||
<SaveDashboardForm |
||||
dashboard={dashboard} |
||||
onCancel={onDismiss} |
||||
onSuccess={() => { |
||||
onDismiss(); |
||||
if (onSaveSuccess) { |
||||
onSaveSuccess(); |
||||
} |
||||
}} |
||||
onSubmit={onDashboardSave} |
||||
/> |
||||
</Modal> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,26 @@ |
||||
import React from 'react'; |
||||
import { NEW_DASHBOARD_DEFAULT_TITLE } from './forms/SaveDashboardAsForm'; |
||||
import { SaveProvisionedDashboard } from './SaveProvisionedDashboard'; |
||||
import { SaveDashboardAsModal } from './SaveDashboardAsModal'; |
||||
import { SaveDashboardModalProps } from './types'; |
||||
import { SaveDashboardModal } from './SaveDashboardModal'; |
||||
|
||||
export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => { |
||||
const isProvisioned = dashboard.meta.provisioned; |
||||
const isNew = dashboard.title === NEW_DASHBOARD_DEFAULT_TITLE; |
||||
const isChanged = dashboard.version > 0; |
||||
|
||||
const modalProps = { |
||||
dashboard, |
||||
onDismiss, |
||||
onSaveSuccess, |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
{isChanged && !isProvisioned && <SaveDashboardModal {...modalProps} />} |
||||
{isProvisioned && <SaveProvisionedDashboard {...modalProps} />} |
||||
{isNew && <SaveDashboardAsModal {...modalProps} isNew />} |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,12 @@ |
||||
import React from 'react'; |
||||
import { Modal } from '@grafana/ui'; |
||||
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm'; |
||||
import { SaveDashboardModalProps } from './types'; |
||||
|
||||
export const SaveProvisionedDashboard: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss }) => { |
||||
return ( |
||||
<Modal isOpen={true} title="Cannot save provisioned dashboard" icon="copy" onDismiss={onDismiss}> |
||||
<SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onDismiss} /> |
||||
</Modal> |
||||
); |
||||
}; |
@ -0,0 +1,113 @@ |
||||
import React from 'react'; |
||||
import { mount } from 'enzyme'; |
||||
import { SaveDashboardAsForm } from './SaveDashboardAsForm'; |
||||
import { DashboardModel } from 'app/features/dashboard/state'; |
||||
import { act } from 'react-dom/test-utils'; |
||||
|
||||
jest.mock('@grafana/runtime', () => ({ |
||||
getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]), search: jest.fn().mockResolvedValue([]) }), |
||||
})); |
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({ |
||||
contextSrv: { |
||||
user: { orgId: 1 }, |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('app/features/plugins/datasource_srv', () => ({})); |
||||
jest.mock('app/features/expressions/ExpressionDatasource', () => ({})); |
||||
|
||||
const prepareDashboardMock = (panel: any) => { |
||||
const json = { |
||||
title: 'name', |
||||
panels: [panel], |
||||
}; |
||||
|
||||
return { |
||||
id: 5, |
||||
meta: {}, |
||||
...json, |
||||
getSaveModelClone: () => json, |
||||
}; |
||||
}; |
||||
const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => { |
||||
const container = mount( |
||||
<SaveDashboardAsForm |
||||
dashboard={dashboard as DashboardModel} |
||||
onCancel={() => {}} |
||||
onSuccess={() => {}} |
||||
onSubmit={async jsonModel => { |
||||
submitSpy(jsonModel); |
||||
return {}; |
||||
}} |
||||
/> |
||||
); |
||||
|
||||
await act(async () => { |
||||
const button = container.find('button[aria-label="Save dashboard button"]'); |
||||
button.simulate('submit'); |
||||
}); |
||||
}; |
||||
describe('SaveDashboardAsForm', () => { |
||||
describe('default values', () => { |
||||
it('applies default dashboard properties', async () => { |
||||
const spy = jest.fn(); |
||||
|
||||
await renderAndSubmitForm(prepareDashboardMock({}), spy); |
||||
|
||||
expect(spy).toBeCalledTimes(1); |
||||
const savedDashboardModel = spy.mock.calls[0][0]; |
||||
expect(savedDashboardModel.id).toBe(null); |
||||
expect(savedDashboardModel.title).toBe('name Copy'); |
||||
expect(savedDashboardModel.editable).toBe(true); |
||||
expect(savedDashboardModel.hideControls).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('graph panel', () => { |
||||
const panel = { |
||||
id: 1, |
||||
type: 'graph', |
||||
alert: { rule: 1 }, |
||||
thresholds: { value: 3000 }, |
||||
}; |
||||
|
||||
it('should remove alerts and thresholds from panel', async () => { |
||||
const spy = jest.fn(); |
||||
|
||||
await renderAndSubmitForm(prepareDashboardMock(panel), spy); |
||||
|
||||
expect(spy).toBeCalledTimes(1); |
||||
const savedDashboardModel = spy.mock.calls[0][0]; |
||||
expect(savedDashboardModel.panels[0]).toEqual({ id: 1, type: 'graph' }); |
||||
}); |
||||
}); |
||||
|
||||
describe('singestat panel', () => { |
||||
const panel = { id: 1, type: 'singlestat', thresholds: { value: 3000 } }; |
||||
|
||||
it('should keep thresholds', async () => { |
||||
const spy = jest.fn(); |
||||
|
||||
await renderAndSubmitForm(prepareDashboardMock(panel), spy); |
||||
|
||||
expect(spy).toBeCalledTimes(1); |
||||
const savedDashboardModel = spy.mock.calls[0][0]; |
||||
expect(savedDashboardModel.panels[0].thresholds).not.toBe(undefined); |
||||
}); |
||||
}); |
||||
|
||||
describe('table panel', () => { |
||||
const panel = { id: 1, type: 'table', thresholds: { value: 3000 } }; |
||||
|
||||
it('should keep thresholds', async () => { |
||||
const spy = jest.fn(); |
||||
|
||||
await renderAndSubmitForm(prepareDashboardMock(panel), spy); |
||||
|
||||
expect(spy).toBeCalledTimes(1); |
||||
const savedDashboardModel = spy.mock.calls[0][0]; |
||||
expect(savedDashboardModel.panels[0].thresholds).not.toBe(undefined); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,107 @@ |
||||
import React from 'react'; |
||||
import { Forms, HorizontalGroup, Button } from '@grafana/ui'; |
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; |
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker'; |
||||
import { SaveDashboardFormProps } from '../types'; |
||||
|
||||
export const NEW_DASHBOARD_DEFAULT_TITLE = 'New dashboard'; |
||||
|
||||
interface SaveDashboardAsFormDTO { |
||||
title: string; |
||||
$folder: { id: number; title: string }; |
||||
copyTags: boolean; |
||||
} |
||||
|
||||
const getSaveAsDashboardClone = (dashboard: DashboardModel) => { |
||||
const clone = dashboard.getSaveModelClone(); |
||||
clone.id = null; |
||||
clone.uid = ''; |
||||
clone.title += ' Copy'; |
||||
clone.editable = true; |
||||
clone.hideControls = false; |
||||
|
||||
// remove alerts if source dashboard is already persisted
|
||||
// do not want to create alert dupes
|
||||
if (dashboard.id > 0) { |
||||
clone.panels.forEach((panel: PanelModel) => { |
||||
if (panel.type === 'graph' && panel.alert) { |
||||
delete panel.thresholds; |
||||
} |
||||
delete panel.alert; |
||||
}); |
||||
} |
||||
|
||||
delete clone.autoUpdate; |
||||
return clone; |
||||
}; |
||||
|
||||
export const SaveDashboardAsForm: React.FC<SaveDashboardFormProps & { isNew?: boolean }> = ({ |
||||
dashboard, |
||||
onSubmit, |
||||
onCancel, |
||||
onSuccess, |
||||
}) => { |
||||
const defaultValues: SaveDashboardAsFormDTO = { |
||||
title: `${dashboard.title} Copy`, |
||||
$folder: { |
||||
id: dashboard.meta.folderId, |
||||
title: dashboard.meta.folderTitle, |
||||
}, |
||||
copyTags: false, |
||||
}; |
||||
|
||||
return ( |
||||
<Forms.Form |
||||
defaultValues={defaultValues} |
||||
onSubmit={async (data: SaveDashboardAsFormDTO) => { |
||||
const clone = getSaveAsDashboardClone(dashboard); |
||||
clone.title = data.title; |
||||
if (!data.copyTags) { |
||||
clone.tags = []; |
||||
} |
||||
|
||||
const result = await onSubmit( |
||||
clone, |
||||
{ |
||||
folderId: data.$folder.id, |
||||
}, |
||||
dashboard |
||||
); |
||||
if (result.status === 'success') { |
||||
onSuccess(); |
||||
} |
||||
}} |
||||
> |
||||
{({ register, control, errors }) => ( |
||||
<> |
||||
<Forms.Field label="Dashboard name" invalid={!!errors.title} error="Dashboard name is required"> |
||||
<Forms.Input name="title" ref={register({ required: true })} aria-label="Save dashboard title field" /> |
||||
</Forms.Field> |
||||
<Forms.Field label="Folder"> |
||||
<Forms.InputControl |
||||
as={FolderPicker} |
||||
control={control} |
||||
name="$folder" |
||||
dashboardId={dashboard.id} |
||||
initialFolderId={dashboard.meta.folderId} |
||||
initialTitle={dashboard.meta.folderTitle} |
||||
enableCreateNew |
||||
useNewForms |
||||
/> |
||||
</Forms.Field> |
||||
<Forms.Field label="Copy tags"> |
||||
<Forms.Switch name="copyTags" ref={register} /> |
||||
</Forms.Field> |
||||
<HorizontalGroup> |
||||
<Button type="submit" aria-label="Save dashboard button"> |
||||
Save |
||||
</Button> |
||||
<Forms.Button variant="secondary" onClick={onCancel}> |
||||
Cancel |
||||
</Forms.Button> |
||||
</HorizontalGroup> |
||||
</> |
||||
)} |
||||
</Forms.Form> |
||||
); |
||||
}; |
@ -0,0 +1,100 @@ |
||||
import React from 'react'; |
||||
import { mount } from 'enzyme'; |
||||
import { act } from 'react-dom/test-utils'; |
||||
import { DashboardModel } from 'app/features/dashboard/state'; |
||||
import { SaveDashboardForm } from './SaveDashboardForm'; |
||||
|
||||
const prepareDashboardMock = ( |
||||
timeChanged: boolean, |
||||
variableValuesChanged: boolean, |
||||
resetTimeSpy: any, |
||||
resetVarsSpy: any |
||||
) => { |
||||
const json = { |
||||
title: 'name', |
||||
hasTimeChanged: jest.fn().mockReturnValue(timeChanged), |
||||
hasVariableValuesChanged: jest.fn().mockReturnValue(variableValuesChanged), |
||||
resetOriginalTime: () => resetTimeSpy(), |
||||
resetOriginalVariables: () => resetVarsSpy(), |
||||
getSaveModelClone: jest.fn().mockReturnValue({}), |
||||
}; |
||||
|
||||
return { |
||||
id: 5, |
||||
meta: {}, |
||||
...json, |
||||
getSaveModelClone: () => json, |
||||
}; |
||||
}; |
||||
const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => { |
||||
const container = mount( |
||||
<SaveDashboardForm |
||||
dashboard={dashboard as DashboardModel} |
||||
onCancel={() => {}} |
||||
onSuccess={() => {}} |
||||
onSubmit={async jsonModel => { |
||||
submitSpy(jsonModel); |
||||
return { status: 'success' }; |
||||
}} |
||||
/> |
||||
); |
||||
|
||||
await act(async () => { |
||||
const button = container.find('button[aria-label="Dashboard settings Save Dashboard Modal Save button"]'); |
||||
button.simulate('submit'); |
||||
}); |
||||
}; |
||||
describe('SaveDashboardAsForm', () => { |
||||
describe('time and variables toggle rendering', () => { |
||||
it('renders switches when variables or timerange', () => { |
||||
const container = mount( |
||||
<SaveDashboardForm |
||||
dashboard={prepareDashboardMock(true, true, jest.fn(), jest.fn()) as any} |
||||
onCancel={() => {}} |
||||
onSuccess={() => {}} |
||||
onSubmit={async () => { |
||||
return {}; |
||||
}} |
||||
/> |
||||
); |
||||
|
||||
const variablesCheckbox = container.find( |
||||
'input[aria-label="Dashboard settings Save Dashboard Modal Save variables checkbox"]' |
||||
); |
||||
const timeRangeCheckbox = container.find( |
||||
'input[aria-label="Dashboard settings Save Dashboard Modal Save timerange checkbox"]' |
||||
); |
||||
|
||||
expect(variablesCheckbox).toHaveLength(1); |
||||
expect(timeRangeCheckbox).toHaveLength(1); |
||||
}); |
||||
}); |
||||
|
||||
describe("when time and template vars haven't changed", () => { |
||||
it("doesn't reset dashboard time and vars", async () => { |
||||
const resetTimeSpy = jest.fn(); |
||||
const resetVarsSpy = jest.fn(); |
||||
const submitSpy = jest.fn(); |
||||
|
||||
await renderAndSubmitForm(prepareDashboardMock(false, false, resetTimeSpy, resetVarsSpy) as any, submitSpy); |
||||
|
||||
expect(resetTimeSpy).not.toBeCalled(); |
||||
expect(resetVarsSpy).not.toBeCalled(); |
||||
expect(submitSpy).toBeCalledTimes(1); |
||||
}); |
||||
}); |
||||
describe('when time and template vars have changed', () => { |
||||
describe("and user hasn't checked variable and time range save", () => { |
||||
it('dont reset dashboard time and vars', async () => { |
||||
const resetTimeSpy = jest.fn(); |
||||
const resetVarsSpy = jest.fn(); |
||||
const submitSpy = jest.fn(); |
||||
await renderAndSubmitForm(prepareDashboardMock(true, true, resetTimeSpy, resetVarsSpy) as any, submitSpy); |
||||
|
||||
expect(resetTimeSpy).toBeCalledTimes(0); |
||||
expect(resetVarsSpy).toBeCalledTimes(0); |
||||
expect(submitSpy).toBeCalledTimes(1); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,67 @@ |
||||
import React, { useMemo } from 'react'; |
||||
import { Forms, Button, HorizontalGroup } from '@grafana/ui'; |
||||
import { e2e } from '@grafana/e2e'; |
||||
import { SaveDashboardFormProps } from '../types'; |
||||
|
||||
interface SaveDashboardFormDTO { |
||||
message: string; |
||||
saveVariables: boolean; |
||||
saveTimerange: boolean; |
||||
} |
||||
|
||||
export const SaveDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel, onSuccess, onSubmit }) => { |
||||
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]); |
||||
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]); |
||||
|
||||
return ( |
||||
<Forms.Form |
||||
onSubmit={async (data: SaveDashboardFormDTO) => { |
||||
const result = await onSubmit(dashboard.getSaveModelClone(data), data, dashboard); |
||||
if (result.status === 'success') { |
||||
if (data.saveVariables) { |
||||
dashboard.resetOriginalVariables(); |
||||
} |
||||
if (data.saveTimerange) { |
||||
dashboard.resetOriginalTime(); |
||||
} |
||||
onSuccess(); |
||||
} |
||||
}} |
||||
> |
||||
{({ register, errors }) => ( |
||||
<> |
||||
<Forms.Field label="Changes description"> |
||||
<Forms.TextArea name="message" ref={register} placeholder="Add a note to describe your changes..." /> |
||||
</Forms.Field> |
||||
{hasTimeChanged && ( |
||||
<Forms.Field label="Save current time range" description="Dashboard time range has changed"> |
||||
<Forms.Switch |
||||
name="saveTimerange" |
||||
ref={register} |
||||
aria-label={e2e.pages.SaveDashboardModal.selectors.saveTimerange} |
||||
/> |
||||
</Forms.Field> |
||||
)} |
||||
{hasVariableChanged && ( |
||||
<Forms.Field label="Save current variables" description="Dashboard variables have changed"> |
||||
<Forms.Switch |
||||
name="saveVariables" |
||||
ref={register} |
||||
aria-label={e2e.pages.SaveDashboardModal.selectors.saveVariables} |
||||
/> |
||||
</Forms.Field> |
||||
)} |
||||
|
||||
<HorizontalGroup> |
||||
<Button type="submit" aria-label={e2e.pages.SaveDashboardModal.selectors.save}> |
||||
Save |
||||
</Button> |
||||
<Forms.Button variant="secondary" onClick={onCancel}> |
||||
Cancel |
||||
</Forms.Button> |
||||
</HorizontalGroup> |
||||
</> |
||||
)} |
||||
</Forms.Form> |
||||
); |
||||
}; |
@ -0,0 +1,71 @@ |
||||
import React, { useCallback, useMemo } from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { saveAs } from 'file-saver'; |
||||
import { CustomScrollbar, Forms, Button, HorizontalGroup, JSONFormatter, VerticalGroup } from '@grafana/ui'; |
||||
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; |
||||
import { SaveDashboardFormProps } from '../types'; |
||||
|
||||
export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel }) => { |
||||
const dashboardJSON = useMemo(() => { |
||||
const clone = dashboard.getSaveModelClone(); |
||||
delete clone.id; |
||||
return clone; |
||||
}, [dashboard]); |
||||
|
||||
const getClipboardText = useCallback(() => { |
||||
return JSON.stringify(dashboardJSON, null, 2); |
||||
}, [dashboard]); |
||||
|
||||
const saveToFile = useCallback(() => { |
||||
const blob = new Blob([JSON.stringify(dashboardJSON, null, 2)], { |
||||
type: 'application/json;charset=utf-8', |
||||
}); |
||||
saveAs(blob, dashboard.title + '-' + new Date().getTime() + '.json'); |
||||
}, [dashboardJSON]); |
||||
|
||||
return ( |
||||
<> |
||||
<VerticalGroup spacing="lg"> |
||||
<small> |
||||
This dashboard cannot be saved from Grafana's UI since it has been provisioned from another source. Copy the |
||||
JSON or save it to a file below. Then you can update your dashboard in corresponding provisioning source. |
||||
<br /> |
||||
<i> |
||||
See{' '} |
||||
<a |
||||
className="external-link" |
||||
href="http://docs.grafana.org/administration/provisioning/#dashboards" |
||||
target="_blank" |
||||
> |
||||
documentation |
||||
</a>{' '} |
||||
for more information about provisioning. |
||||
</i> |
||||
</small> |
||||
<div> |
||||
<strong>File path: </strong> {dashboard.meta.provisionedExternalId} |
||||
</div> |
||||
<div |
||||
className={css` |
||||
padding: 8px 16px; |
||||
background: black; |
||||
height: 400px; |
||||
`}
|
||||
> |
||||
<CustomScrollbar> |
||||
<JSONFormatter json={dashboardJSON} open={1} /> |
||||
</CustomScrollbar> |
||||
</div> |
||||
<HorizontalGroup> |
||||
<CopyToClipboard text={getClipboardText} elType={Button}> |
||||
Copy JSON to clipboard |
||||
</CopyToClipboard> |
||||
<Button onClick={saveToFile}>Save JSON to file</Button> |
||||
<Forms.Button variant="secondary" onClick={onCancel}> |
||||
Cancel |
||||
</Forms.Button> |
||||
</HorizontalGroup> |
||||
</VerticalGroup> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,21 @@ |
||||
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel'; |
||||
|
||||
export interface SaveDashboardOptions extends CloneOptions { |
||||
folderId?: number; |
||||
overwrite?: boolean; |
||||
message?: string; |
||||
makeEditable?: boolean; |
||||
} |
||||
|
||||
export interface SaveDashboardFormProps { |
||||
dashboard: DashboardModel; |
||||
onCancel: () => void; |
||||
onSuccess: () => void; |
||||
onSubmit?: (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => Promise<any>; |
||||
} |
||||
|
||||
export interface SaveDashboardModalProps { |
||||
dashboard: DashboardModel; |
||||
onDismiss: () => void; |
||||
onSaveSuccess?: () => void; |
||||
} |
@ -0,0 +1,49 @@ |
||||
import { useEffect } from 'react'; |
||||
import useAsyncFn from 'react-use/lib/useAsyncFn'; |
||||
import { AppEvents } from '@grafana/data'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import { SaveDashboardOptions } from './types'; |
||||
import { CoreEvents, StoreState } from 'app/types'; |
||||
import appEvents from 'app/core/app_events'; |
||||
import locationUtil from 'app/core/utils/location_util'; |
||||
import { updateLocation } from 'app/core/reducers/location'; |
||||
import { DashboardModel } from 'app/features/dashboard/state'; |
||||
import { getBackendSrv } from 'app/core/services/backend_srv'; |
||||
|
||||
const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => { |
||||
const folderId = options.folderId >= 0 ? options.folderId : dashboard.meta.folderId || saveModel.folderId; |
||||
return await getBackendSrv().saveDashboard(saveModel, { ...options, folderId }); |
||||
}; |
||||
|
||||
export const useDashboardSave = (dashboard: DashboardModel) => { |
||||
const location = useSelector((state: StoreState) => state.location); |
||||
const dispatch = useDispatch(); |
||||
const [state, onDashboardSave] = useAsyncFn( |
||||
async (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => |
||||
await saveDashboard(clone, options, dashboard), |
||||
[] |
||||
); |
||||
|
||||
useEffect(() => { |
||||
if (state.value) { |
||||
dashboard.version = state.value.version; |
||||
|
||||
// important that these happen before location redirect below
|
||||
appEvents.emit(CoreEvents.dashboardSaved, dashboard); |
||||
appEvents.emit(AppEvents.alertSuccess, ['Dashboard saved']); |
||||
|
||||
const newUrl = locationUtil.stripBaseFromUrl(state.value.url); |
||||
const currentPath = location.path; |
||||
|
||||
if (newUrl !== currentPath) { |
||||
dispatch( |
||||
updateLocation({ |
||||
path: newUrl, |
||||
}) |
||||
); |
||||
} |
||||
} |
||||
}, [state]); |
||||
|
||||
return { state, onDashboardSave }; |
||||
}; |
@ -1,71 +0,0 @@ |
||||
import { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl'; |
||||
import { describe, it, expect } from 'test/lib/common'; |
||||
|
||||
describe('saving dashboard as', () => { |
||||
function scenario(name: string, panel: any, verify: Function) { |
||||
describe(name, () => { |
||||
const json = { |
||||
title: 'name', |
||||
panels: [panel], |
||||
}; |
||||
|
||||
const mockDashboardSrv: any = { |
||||
getCurrent: () => { |
||||
return { |
||||
id: 5, |
||||
meta: {}, |
||||
getSaveModelClone: () => { |
||||
return json; |
||||
}, |
||||
}; |
||||
}, |
||||
}; |
||||
|
||||
const ctrl = new SaveDashboardAsModalCtrl(mockDashboardSrv); |
||||
const ctx: any = { |
||||
clone: ctrl.clone, |
||||
ctrl: ctrl, |
||||
panel: panel, |
||||
}; |
||||
|
||||
it('verify', () => { |
||||
verify(ctx); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
scenario('default values', {}, (ctx: any) => { |
||||
const clone = ctx.clone; |
||||
expect(clone.id).toBe(null); |
||||
expect(clone.title).toBe('name Copy'); |
||||
expect(clone.editable).toBe(true); |
||||
expect(clone.hideControls).toBe(false); |
||||
}); |
||||
|
||||
const graphPanel = { |
||||
id: 1, |
||||
type: 'graph', |
||||
alert: { rule: 1 }, |
||||
thresholds: { value: 3000 }, |
||||
}; |
||||
|
||||
scenario('should remove alert from graph panel', graphPanel, (ctx: any) => { |
||||
expect(ctx.panel.alert).toBe(undefined); |
||||
}); |
||||
|
||||
scenario('should remove threshold from graph panel', graphPanel, (ctx: any) => { |
||||
expect(ctx.panel.thresholds).toBe(undefined); |
||||
}); |
||||
|
||||
scenario( |
||||
'singlestat should keep threshold', |
||||
{ id: 1, type: 'singlestat', thresholds: { value: 3000 } }, |
||||
(ctx: any) => { |
||||
expect(ctx.panel.thresholds).not.toBe(undefined); |
||||
} |
||||
); |
||||
|
||||
scenario('table should keep threshold', { id: 1, type: 'table', thresholds: { value: 3000 } }, (ctx: any) => { |
||||
expect(ctx.panel.thresholds).not.toBe(undefined); |
||||
}); |
||||
}); |
@ -1,124 +0,0 @@ |
||||
import coreModule from 'app/core/core_module'; |
||||
import { DashboardSrv } from '../../services/DashboardSrv'; |
||||
import { PanelModel } from '../../state/PanelModel'; |
||||
|
||||
const template = ` |
||||
<div class="modal-body"> |
||||
<div class="modal-header"> |
||||
<h2 class="modal-header-title"> |
||||
<i class="fa fa-copy"></i> |
||||
<span class="p-l-1">Save As...</span> |
||||
</h2> |
||||
|
||||
<a class="modal-header-close" ng-click="ctrl.dismiss();"> |
||||
<i class="fa fa-remove"></i> |
||||
</a> |
||||
</div> |
||||
|
||||
<form name="ctrl.saveForm" class="modal-content" novalidate> |
||||
<div class="p-t-2"> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label width-8">New name</label> |
||||
<input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required aria-label="Save dashboard title field"> |
||||
</div> |
||||
<folder-picker initial-folder-id="ctrl.folderId" |
||||
on-change="ctrl.onFolderChange" |
||||
enter-folder-creation="ctrl.onEnterFolderCreation" |
||||
exit-folder-creation="ctrl.onExitFolderCreation" |
||||
enable-create-new="true" |
||||
label-class="width-8" |
||||
dashboard-id="ctrl.clone.id"> |
||||
</folder-picker> |
||||
<div class="gf-form-inline"> |
||||
<gf-form-switch class="gf-form" label="Copy tags" label-class="width-8" checked="ctrl.copyTags"> |
||||
</gf-form-switch> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-button-row text-center"> |
||||
<button |
||||
type="submit" |
||||
class="btn btn-primary" |
||||
ng-click="ctrl.save()" |
||||
ng-disabled="!ctrl.isValidFolderSelection" |
||||
aria-label="Save dashboard button"> |
||||
Save |
||||
</button> |
||||
<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
`;
|
||||
|
||||
export class SaveDashboardAsModalCtrl { |
||||
clone: any; |
||||
folderId: any; |
||||
dismiss: () => void; |
||||
isValidFolderSelection = true; |
||||
copyTags: boolean; |
||||
|
||||
/** @ngInject */ |
||||
constructor(private dashboardSrv: DashboardSrv) { |
||||
const dashboard = this.dashboardSrv.getCurrent(); |
||||
this.clone = dashboard.getSaveModelClone(); |
||||
this.clone.id = null; |
||||
this.clone.uid = ''; |
||||
this.clone.title += ' Copy'; |
||||
this.clone.editable = true; |
||||
this.clone.hideControls = false; |
||||
this.folderId = dashboard.meta.folderId; |
||||
this.copyTags = false; |
||||
|
||||
// remove alerts if source dashboard is already persisted
|
||||
// do not want to create alert dupes
|
||||
if (dashboard.id > 0) { |
||||
this.clone.panels.forEach((panel: PanelModel) => { |
||||
if (panel.type === 'graph' && panel.alert) { |
||||
delete panel.thresholds; |
||||
} |
||||
delete panel.alert; |
||||
}); |
||||
} |
||||
|
||||
delete this.clone.autoUpdate; |
||||
} |
||||
|
||||
save() { |
||||
if (!this.copyTags) { |
||||
this.clone.tags = []; |
||||
} |
||||
|
||||
return this.dashboardSrv.save(this.clone, { folderId: this.folderId }).then(this.dismiss); |
||||
} |
||||
|
||||
keyDown(evt: KeyboardEvent) { |
||||
if (evt.keyCode === 13) { |
||||
this.save(); |
||||
} |
||||
} |
||||
|
||||
onFolderChange = (folder: { id: any }) => { |
||||
this.folderId = folder.id; |
||||
}; |
||||
|
||||
onEnterFolderCreation = () => { |
||||
this.isValidFolderSelection = false; |
||||
}; |
||||
|
||||
onExitFolderCreation = () => { |
||||
this.isValidFolderSelection = true; |
||||
}; |
||||
} |
||||
|
||||
export function saveDashboardAsDirective() { |
||||
return { |
||||
restrict: 'E', |
||||
template: template, |
||||
controller: SaveDashboardAsModalCtrl, |
||||
bindToController: true, |
||||
controllerAs: 'ctrl', |
||||
scope: { dismiss: '&' }, |
||||
}; |
||||
} |
||||
|
||||
coreModule.directive('saveDashboardAsModal', saveDashboardAsDirective); |
@ -1,57 +0,0 @@ |
||||
import { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl'; |
||||
|
||||
const setup = (timeChanged: boolean, variableValuesChanged: boolean, cb: Function) => { |
||||
const dash = { |
||||
hasTimeChanged: jest.fn().mockReturnValue(timeChanged), |
||||
hasVariableValuesChanged: jest.fn().mockReturnValue(variableValuesChanged), |
||||
resetOriginalTime: jest.fn(), |
||||
resetOriginalVariables: jest.fn(), |
||||
getSaveModelClone: jest.fn().mockReturnValue({}), |
||||
}; |
||||
const dashboardSrvMock: any = { |
||||
getCurrent: jest.fn().mockReturnValue(dash), |
||||
save: jest.fn().mockReturnValue(Promise.resolve()), |
||||
}; |
||||
const ctrl = new SaveDashboardModalCtrl(dashboardSrvMock); |
||||
ctrl.saveForm = { |
||||
$valid: true, |
||||
}; |
||||
ctrl.dismiss = () => Promise.resolve(); |
||||
cb(dash, ctrl, dashboardSrvMock); |
||||
}; |
||||
|
||||
describe('SaveDashboardModal', () => { |
||||
describe('Given time and template variable values have not changed', () => { |
||||
setup(false, false, (dash: any, ctrl: SaveDashboardModalCtrl) => { |
||||
it('When creating ctrl should set time and template variable values changed', () => { |
||||
expect(ctrl.timeChange).toBeFalsy(); |
||||
expect(ctrl.variableValueChange).toBeFalsy(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('Given time and template variable values have changed', () => { |
||||
setup(true, true, (dash: any, ctrl: SaveDashboardModalCtrl) => { |
||||
it('When creating ctrl should set time and template variable values changed', () => { |
||||
expect(ctrl.timeChange).toBeTruthy(); |
||||
expect(ctrl.variableValueChange).toBeTruthy(); |
||||
}); |
||||
|
||||
it('When save time and variable value changes disabled and saving should reset original time and template variable values', async () => { |
||||
ctrl.saveTimerange = false; |
||||
ctrl.saveVariables = false; |
||||
await ctrl.save(); |
||||
expect(dash.resetOriginalTime).toHaveBeenCalledTimes(0); |
||||
expect(dash.resetOriginalVariables).toHaveBeenCalledTimes(0); |
||||
}); |
||||
|
||||
it('When save time and variable value changes enabled and saving should reset original time and template variable values', async () => { |
||||
ctrl.saveTimerange = true; |
||||
ctrl.saveVariables = true; |
||||
await ctrl.save(); |
||||
expect(dash.resetOriginalTime).toHaveBeenCalledTimes(1); |
||||
expect(dash.resetOriginalVariables).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,141 +0,0 @@ |
||||
import { e2e } from '@grafana/e2e'; |
||||
|
||||
import coreModule from 'app/core/core_module'; |
||||
import { DashboardSrv } from '../../services/DashboardSrv'; |
||||
import { CloneOptions } from '../../state/DashboardModel'; |
||||
|
||||
const template = ` |
||||
<div class="modal-body"> |
||||
<div class="modal-header"> |
||||
<h2 class="modal-header-title"> |
||||
<i class="fa fa-save"></i> |
||||
<span class="p-l-1">Save changes</span> |
||||
</h2> |
||||
|
||||
<a class="modal-header-close" ng-click="ctrl.dismiss();"> |
||||
<i class="fa fa-remove"></i> |
||||
</a> |
||||
</div> |
||||
|
||||
<form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate> |
||||
<div class="p-t-1"> |
||||
<div class="gf-form-group" ng-if="ctrl.timeChange || ctrl.variableValueChange"> |
||||
<gf-form-switch class="gf-form" |
||||
label="Save current time range" ng-if="ctrl.timeChange" label-class="width-12" switch-class="max-width-6" |
||||
checked="ctrl.saveTimerange" on-change="buildUrl()"> |
||||
</gf-form-switch> |
||||
<gf-form-switch class="gf-form" |
||||
label="Save current variables" ng-if="ctrl.variableValueChange" label-class="width-12" switch-class="max-width-6" |
||||
checked="ctrl.saveVariables" on-change="buildUrl()"> |
||||
</gf-form-switch> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-hint"> |
||||
<input |
||||
type="text" |
||||
name="message" |
||||
class="gf-form-input" |
||||
placeholder="Add a note to describe your changes …" |
||||
give-focus="true" |
||||
ng-model="ctrl.message" |
||||
ng-model-options="{allowInvalid: true}" |
||||
ng-maxlength="this.max" |
||||
maxlength="64" |
||||
autocomplete="off" /> |
||||
<small class="gf-form-hint-text muted" ng-cloak> |
||||
<span ng-class="{'text-error': ctrl.saveForm.message.$invalid && ctrl.saveForm.message.$dirty }"> |
||||
{{ctrl.message.length || 0}} |
||||
</span> |
||||
/ {{ctrl.max}} characters |
||||
</small> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-button-row text-center"> |
||||
<button |
||||
id="saveBtn" |
||||
type="submit" |
||||
class="btn btn-primary" |
||||
ng-class="{'btn-primary--processing': ctrl.isSaving}" |
||||
ng-disabled="ctrl.saveForm.$invalid || ctrl.isSaving" |
||||
aria-label={{ctrl.selectors.save}} |
||||
> |
||||
<span ng-if="!ctrl.isSaving">Save</span> |
||||
<span ng-if="ctrl.isSaving === true">Saving...</span> |
||||
</button> |
||||
<button class="btn btn-inverse" ng-click="ctrl.dismiss();">Cancel</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
`;
|
||||
|
||||
export class SaveDashboardModalCtrl { |
||||
message: string; |
||||
saveVariables = false; |
||||
saveTimerange = false; |
||||
time: any; |
||||
originalTime: any; |
||||
current: any[] = []; |
||||
originalCurrent: any[] = []; |
||||
max: number; |
||||
saveForm: any; |
||||
isSaving: boolean; |
||||
dismiss: () => void; |
||||
timeChange = false; |
||||
variableValueChange = false; |
||||
selectors: typeof e2e.pages.SaveDashboardModal.selectors; |
||||
|
||||
/** @ngInject */ |
||||
constructor(private dashboardSrv: DashboardSrv) { |
||||
this.message = ''; |
||||
this.max = 64; |
||||
this.isSaving = false; |
||||
this.timeChange = this.dashboardSrv.getCurrent().hasTimeChanged(); |
||||
this.variableValueChange = this.dashboardSrv.getCurrent().hasVariableValuesChanged(); |
||||
this.selectors = e2e.pages.SaveDashboardModal.selectors; |
||||
} |
||||
|
||||
save(): void | Promise<any> { |
||||
if (!this.saveForm.$valid) { |
||||
return; |
||||
} |
||||
|
||||
const options: CloneOptions = { |
||||
saveVariables: this.saveVariables, |
||||
saveTimerange: this.saveTimerange, |
||||
message: this.message, |
||||
}; |
||||
|
||||
const dashboard = this.dashboardSrv.getCurrent(); |
||||
const saveModel = dashboard.getSaveModelClone(options); |
||||
|
||||
this.isSaving = true; |
||||
return this.dashboardSrv.save(saveModel, options).then(this.postSave.bind(this, options)); |
||||
} |
||||
|
||||
postSave(options?: { saveVariables?: boolean; saveTimerange?: boolean }) { |
||||
if (options.saveVariables) { |
||||
this.dashboardSrv.getCurrent().resetOriginalVariables(); |
||||
} |
||||
|
||||
if (options.saveTimerange) { |
||||
this.dashboardSrv.getCurrent().resetOriginalTime(); |
||||
} |
||||
|
||||
this.dismiss(); |
||||
} |
||||
} |
||||
|
||||
export function saveDashboardModalDirective() { |
||||
return { |
||||
restrict: 'E', |
||||
template: template, |
||||
controller: SaveDashboardModalCtrl, |
||||
bindToController: true, |
||||
controllerAs: 'ctrl', |
||||
scope: { dismiss: '&' }, |
||||
}; |
||||
} |
||||
|
||||
coreModule.directive('saveDashboardModal', saveDashboardModalDirective); |
@ -1,30 +0,0 @@ |
||||
import { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl'; |
||||
|
||||
describe('SaveProvisionedDashboardModalCtrl', () => { |
||||
const json = { |
||||
title: 'name', |
||||
id: 5, |
||||
}; |
||||
|
||||
const mockDashboardSrv: any = { |
||||
getCurrent: () => { |
||||
return { |
||||
id: 5, |
||||
meta: {}, |
||||
getSaveModelClone: () => { |
||||
return json; |
||||
}, |
||||
}; |
||||
}, |
||||
}; |
||||
|
||||
const ctrl = new SaveProvisionedDashboardModalCtrl(mockDashboardSrv); |
||||
|
||||
it('should remove id from dashboard model', () => { |
||||
expect(ctrl.dash.id).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should remove id from dashboard model in clipboard json', () => { |
||||
expect(ctrl.getJsonForClipboard()).toBe(JSON.stringify({ title: 'name' }, null, 2)); |
||||
}); |
||||
}); |
@ -1,84 +0,0 @@ |
||||
import angular from 'angular'; |
||||
import { saveAs } from 'file-saver'; |
||||
import coreModule from 'app/core/core_module'; |
||||
import { DashboardModel } from '../../state'; |
||||
import { DashboardSrv } from '../../services/DashboardSrv'; |
||||
|
||||
const template = ` |
||||
<div class="modal-body"> |
||||
<div class="modal-header"> |
||||
<h2 class="modal-header-title"> |
||||
<i class="fa fa-save"></i><span class="p-l-1">Cannot save provisioned dashboard</span> |
||||
</h2> |
||||
|
||||
<a class="modal-header-close" ng-click="ctrl.dismiss();"> |
||||
<i class="fa fa-remove"></i> |
||||
</a> |
||||
</div> |
||||
|
||||
<div class="modal-content"> |
||||
<small> |
||||
This dashboard cannot be saved from Grafana's UI since it has been provisioned from another source. |
||||
Copy the JSON or save it to a file below. Then you can update your dashboard in corresponding provisioning source.<br/> |
||||
<i>See <a class="external-link" href="http://docs.grafana.org/administration/provisioning/#dashboards" target="_blank"> |
||||
documentation</a> for more information about provisioning.</i> |
||||
</small> |
||||
<div class="p-t-1"> |
||||
File path: {{ctrl.dashboardModel.meta.provisionedExternalId}} |
||||
</div> |
||||
<div class="p-t-2"> |
||||
<div class="gf-form"> |
||||
<code-editor content="ctrl.dashboardJson" data-mode="json" data-max-lines=15></code-editor> |
||||
</div> |
||||
<div class="gf-form-button-row"> |
||||
<button class="btn btn-primary" clipboard-button="ctrl.getJsonForClipboard()"> |
||||
Copy JSON to Clipboard |
||||
</button> |
||||
<button class="btn btn-secondary" clipboard-button="ctrl.save()"> |
||||
Save JSON to file |
||||
</button> |
||||
<a class="btn btn-link" ng-click="ctrl.dismiss();">Cancel</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
`;
|
||||
|
||||
export class SaveProvisionedDashboardModalCtrl { |
||||
dash: any; |
||||
dashboardModel: DashboardModel; |
||||
dashboardJson: string; |
||||
dismiss: () => void; |
||||
|
||||
/** @ngInject */ |
||||
constructor(dashboardSrv: DashboardSrv) { |
||||
this.dashboardModel = dashboardSrv.getCurrent(); |
||||
this.dash = this.dashboardModel.getSaveModelClone(); |
||||
delete this.dash.id; |
||||
this.dashboardJson = angular.toJson(this.dash, true); |
||||
} |
||||
|
||||
save() { |
||||
const blob = new Blob([angular.toJson(this.dash, true)], { |
||||
type: 'application/json;charset=utf-8', |
||||
}); |
||||
saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json'); |
||||
} |
||||
|
||||
getJsonForClipboard() { |
||||
return this.dashboardJson; |
||||
} |
||||
} |
||||
|
||||
export function saveProvisionedDashboardModalDirective() { |
||||
return { |
||||
restrict: 'E', |
||||
template: template, |
||||
controller: SaveProvisionedDashboardModalCtrl, |
||||
bindToController: true, |
||||
controllerAs: 'ctrl', |
||||
scope: { dismiss: '&' }, |
||||
}; |
||||
} |
||||
|
||||
coreModule.directive('saveProvisionedDashboardModal', saveProvisionedDashboardModalDirective); |
@ -1,3 +0,0 @@ |
||||
export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl'; |
||||
export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl'; |
||||
export { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl'; |
Loading…
Reference in new issue