diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index b9271e3e536..a998c521008 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -17,7 +17,7 @@ type CommonProps = { styles?: ButtonStyles; }; -type ButtonProps = CommonProps & ButtonHTMLAttributes; +export type ButtonProps = CommonProps & ButtonHTMLAttributes; export const Button = React.forwardRef((props, ref) => { const theme = useContext(ThemeContext); const { size, variant, icon, children, className, styles: stylesProp, ...buttonProps } = props; @@ -43,7 +43,7 @@ export const Button = React.forwardRef((props, r Button.displayName = 'Button'; -type LinkButtonProps = CommonProps & +export type LinkButtonProps = CommonProps & AnchorHTMLAttributes & { // We allow disabled here even though it is not standard for a link. We use it as a selector to style it as // disabled. diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.tsx new file mode 100644 index 00000000000..c37b8a97c9f --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; + +import { storiesOf } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { ClipboardButton } from './ClipboardButton'; +import { Input } from '../Input/Input'; +import { text } from '@storybook/addon-knobs'; + +const getKnobs = () => { + return { + buttonText: text('Button text', 'Copy to clipboard'), + inputText: text('Input', 'go run build.go -goos linux -pkg-arch amd64 ${OPT} package-only'), + clipboardCopyMessage: text('Copy message', 'Value copied to clipboard'), + }; +}; + +const Wrapper = () => { + const { inputText, buttonText } = getKnobs(); + const [copyMessage, setCopyMessage] = useState(''); + + return ( +
+
+ getKnobs().inputText} + onClipboardCopy={() => setCopyMessage(getKnobs().clipboardCopyMessage)} + > + {buttonText} + + {}} /> +
+ {copyMessage} +
+ ); +}; + +const story = storiesOf('General/ClipboardButton', module); +story.addDecorator(withCenteredStory); +story.add('copy to clipboard', () => ); diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx new file mode 100644 index 00000000000..89e1f22c0f4 --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -0,0 +1,50 @@ +import React, { PureComponent } from 'react'; +import Clipboard from 'clipboard'; +import { Button, ButtonProps } from '../Button/Button'; + +interface Props extends ButtonProps { + getText(): string; + onClipboardCopy?(e: Clipboard.Event): void; + onClipboardError?(e: Clipboard.Event): void; +} + +export class ClipboardButton extends PureComponent { + // @ts-ignore + private clipboard: Clipboard; + // @ts-ignore + private elem: HTMLButtonElement; + + setRef = (elem: HTMLButtonElement) => { + this.elem = elem; + }; + + componentDidMount() { + const { getText, onClipboardCopy, onClipboardError } = this.props; + + this.clipboard = new Clipboard(this.elem, { + text: () => getText(), + }); + + this.clipboard.on('success', (e: Clipboard.Event) => { + onClipboardCopy && onClipboardCopy(e); + }); + + this.clipboard.on('error', (e: Clipboard.Event) => { + onClipboardError && onClipboardError(e); + }); + } + + componentWillUnmount() { + this.clipboard.destroy(); + } + + render() { + const { getText, onClipboardCopy, onClipboardError, children, ...buttonProps } = this.props; + + return ( + + ); + } +} diff --git a/packages/grafana-ui/src/components/Modal/Modal.story.tsx b/packages/grafana-ui/src/components/Modal/Modal.story.tsx index d36b1257925..8485ee77437 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.story.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.story.tsx @@ -1,9 +1,12 @@ -import React from 'react'; +import React, { useState } from 'react'; import { storiesOf } from '@storybook/react'; import { oneLineTrim } from 'common-tags'; import { text, boolean } from '@storybook/addon-knobs'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; import { Modal } from './Modal'; +import { ModalTabsHeader } from './ModalTabsHeader'; +import { TabContent } from '../Tabs/TabContent'; const getKnobs = () => { return { @@ -24,7 +27,6 @@ amet.` }; const ModalStories = storiesOf('General/Modal', module); - ModalStories.addDecorator(withCenteredStory); ModalStories.add('default', () => { @@ -33,7 +35,7 @@ ModalStories.add('default', () => { - + My Modal } @@ -43,3 +45,41 @@ ModalStories.add('default', () => { ); }); + +const tabs = [ + { label: '1st child', value: 'first', active: true }, + { label: '2nd child', value: 'second', active: false }, + { label: '3rd child', value: 'third', active: false }, +]; + +ModalStories.add('with tabs', () => { + const [activeTab, setActiveTab] = useState('first'); + const modalHeader = ( + { + setActiveTab(t.value); + }} + /> + ); + return ( + + {(state, updateState) => { + return ( +
+ + + {activeTab === state[0].value &&
First tab content
} + {activeTab === state[1].value &&
Second tab content
} + {activeTab === state[2].value &&
Third tab content
} +
+
+
+ ); + }} +
+ ); +}); diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index 5c010a18e04..047d472cc7e 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -1,15 +1,15 @@ import React from 'react'; import { Portal } from '../Portal/Portal'; -import { css, cx } from 'emotion'; -import { stylesFactory, withTheme } from '../../themes'; -import { GrafanaTheme } from '@grafana/data'; -import { Icon } from '../Icon/Icon'; +import { cx } from 'emotion'; +import { withTheme } from '../../themes'; import { IconType } from '../Icon/types'; +import { Themeable } from '../../types'; +import { getModalStyles } from './getModalStyles'; +import { ModalHeader } from './ModalHeader'; -interface Props { +interface Props extends Themeable { icon?: IconType; title: string | JSX.Element; - theme: GrafanaTheme; className?: string; isOpen?: boolean; @@ -30,21 +30,15 @@ export class UnthemedModal extends React.PureComponent { this.onDismiss(); }; - renderDefaultHeader() { - const { title, icon, theme } = this.props; - const styles = getStyles(theme); + renderDefaultHeader(title: string) { + const { icon } = this.props; - return ( -

- {icon && } - {title} -

- ); + return ; } render() { const { title, isOpen = false, theme, className } = this.props; - const styles = getStyles(theme); + const styles = getModalStyles(theme); if (!isOpen) { return null; @@ -54,7 +48,7 @@ export class UnthemedModal extends React.PureComponent {
- {typeof title === 'string' ? this.renderDefaultHeader() : title} + {typeof title === 'string' ? this.renderDefaultHeader(title) : title} @@ -68,60 +62,3 @@ export class UnthemedModal extends React.PureComponent { } 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); - `, -})); diff --git a/packages/grafana-ui/src/components/Modal/ModalHeader.tsx b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx new file mode 100644 index 00000000000..8b82643ab11 --- /dev/null +++ b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx @@ -0,0 +1,25 @@ +import React, { useContext } from 'react'; +import { getModalStyles } from './getModalStyles'; +import { IconType } from '../Icon/types'; +import { ThemeContext } from '../../themes'; +import { Icon } from '../Icon/Icon'; + +interface Props { + title: string; + icon?: IconType; +} + +export const ModalHeader: React.FC = ({ icon, title, children }) => { + const theme = useContext(ThemeContext); + const styles = getModalStyles(theme); + + return ( + <> +

+ {icon && } + {title} +

+ {children} + + ); +}; diff --git a/packages/grafana-ui/src/components/Modal/ModalTabsHeader.tsx b/packages/grafana-ui/src/components/Modal/ModalTabsHeader.tsx new file mode 100644 index 00000000000..a1628a33661 --- /dev/null +++ b/packages/grafana-ui/src/components/Modal/ModalTabsHeader.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { IconType } from '../Icon/types'; +import { TabsBar } from '../Tabs/TabsBar'; +import { Tab } from '../Tabs/Tab'; +import { ModalHeader } from './ModalHeader'; + +interface ModalTab { + value: string; + label: string; + icon?: string; +} + +interface Props { + icon: IconType; + title: string; + tabs: ModalTab[]; + activeTab: string; + onChangeTab(tab: ModalTab): void; +} + +export const ModalTabsHeader: React.FC = ({ icon, title, tabs, activeTab, onChangeTab }) => { + return ( + + + {tabs.map((t, index) => { + return ( + onChangeTab(t)} + /> + ); + })} + + + ); +}; diff --git a/packages/grafana-ui/src/components/Modal/getModalStyles.ts b/packages/grafana-ui/src/components/Modal/getModalStyles.ts new file mode 100644 index 00000000000..2a6d2946dee --- /dev/null +++ b/packages/grafana-ui/src/components/Modal/getModalStyles.ts @@ -0,0 +1,60 @@ +import { css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { stylesFactory } from '../../themes'; + +export const getModalStyles = 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); + `, +})); diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index bc05176e5f1..3f23c3c3908 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -7,6 +7,7 @@ export { Portal } from './Portal/Portal'; export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar'; export * from './Button/Button'; +export { ClipboardButton } from './ClipboardButton/ClipboardButton'; // Select export { Select, AsyncSelect } from './Select/Select'; @@ -39,13 +40,16 @@ export { TimePicker } from './TimePicker/TimePicker'; 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'; +// TODO: namespace +export { Modal } from './Modal/Modal'; +export { ModalHeader } from './Modal/ModalHeader'; +export { ModalTabsHeader } from './Modal/ModalTabsHeader'; +export { ModalsProvider, ModalRoot, ModalsController } from './Modal/ModalsContext'; + // Renderless export { SetInterval } from './SetInterval/SetInterval'; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 0b3d2543681..317ab7e24c8 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -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 { ShareModal } from 'app/features/dashboard/components/ShareModal/ShareModal'; import { SaveDashboardModalProxy } from '../../features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; export class KeybindingSrv { @@ -272,14 +273,14 @@ export class KeybindingSrv { // share panel this.bind('p s', () => { if (dashboard.meta.focusPanelId) { - const shareScope: any = scope.$new(); const panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); - shareScope.panel = panelInfo.panel; - shareScope.dashboard = dashboard; - appEvents.emit(CoreEvents.showModal, { - src: 'public/app/features/dashboard/components/ShareModal/template.html', - scope: shareScope, + appEvents.emit(CoreEvents.showModalReact, { + component: ShareModal, + props: { + dashboard: dashboard, + panel: panelInfo?.panel, + }, }); } }); diff --git a/public/app/core/services/util_srv.ts b/public/app/core/services/util_srv.ts index 096f200ba52..1b628c7f48f 100644 --- a/public/app/core/services/util_srv.ts +++ b/public/app/core/services/util_srv.ts @@ -7,6 +7,7 @@ import appEvents from 'app/core/app_events'; import { CoreEvents } from 'app/types'; import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; import { AngularModalProxy } from '../components/modals/AngularModalProxy'; +import { provideTheme } from '../utils/ConfigProvider'; export class UtilSrv { modalScope: any; @@ -36,7 +37,7 @@ export class UtilSrv { }, }; - const elem = React.createElement(AngularModalProxy, modalProps); + const elem = React.createElement(provideTheme(AngularModalProxy), modalProps); this.reactModalRoot.appendChild(this.reactModalNode); return ReactDOM.render(elem, this.reactModalNode); } diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts index 377a1e56d27..2bfa3f2ddf0 100644 --- a/public/app/core/utils/url.ts +++ b/public/app/core/utils/url.ts @@ -87,3 +87,26 @@ export function appendQueryToUrl(url: string, stringToAppend: string) { return url; } + +/** + * Return search part (as object) of current url + */ +export function getUrlSearchParams() { + const search = window.location.search.substring(1); + const searchParamsSegments = search.split('&'); + const params: any = {}; + for (const p of searchParamsSegments) { + const keyValuePair = p.split('='); + if (keyValuePair.length > 1) { + // key-value param + const key = decodeURIComponent(keyValuePair[0]); + const value = decodeURIComponent(keyValuePair[1]); + params[key] = value; + } else if (keyValuePair.length === 1) { + // boolean param + const key = decodeURIComponent(keyValuePair[0]); + params[key] = true; + } + } + return params; +} diff --git a/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts b/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts deleted file mode 100644 index e3d2976f394..00000000000 --- a/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts +++ /dev/null @@ -1,86 +0,0 @@ -import angular from 'angular'; -import { saveAs } from 'file-saver'; - -import coreModule from 'app/core/core_module'; -import { DashboardExporter } from './DashboardExporter'; -import { DashboardSrv } from '../../services/DashboardSrv'; -import DatasourceSrv from 'app/features/plugins/datasource_srv'; -import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; -import { CoreEvents } from 'app/types'; - -export class DashExportCtrl { - dash: any; - exporter: DashboardExporter; - dismiss: () => void; - shareExternally: boolean; - - /** @ngInject */ - constructor( - private dashboardSrv: DashboardSrv, - datasourceSrv: DatasourceSrv, - private $scope: any, - private $rootScope: GrafanaRootScope - ) { - this.exporter = new DashboardExporter(datasourceSrv); - - this.dash = this.dashboardSrv.getCurrent(); - } - - saveDashboardAsFile() { - if (this.shareExternally) { - this.exporter.makeExportable(this.dash).then((dashboardJson: any) => { - this.$scope.$apply(() => { - this.openSaveAsDialog(dashboardJson); - }); - }); - } else { - this.openSaveAsDialog(this.dash.getSaveModelClone()); - } - } - - viewJson() { - if (this.shareExternally) { - this.exporter.makeExportable(this.dash).then((dashboardJson: any) => { - this.$scope.$apply(() => { - this.openJsonModal(dashboardJson); - }); - }); - } else { - this.openJsonModal(this.dash.getSaveModelClone()); - } - } - - private openSaveAsDialog(dash: any) { - const blob = new Blob([angular.toJson(dash, true)], { - type: 'application/json;charset=utf-8', - }); - saveAs(blob, dash.title + '-' + new Date().getTime() + '.json'); - } - - private openJsonModal(clone: object) { - const model = { - object: clone, - enableCopy: true, - }; - - this.$rootScope.appEvent(CoreEvents.showModal, { - src: 'public/app/partials/edit_json.html', - model: model, - }); - - this.dismiss(); - } -} - -export function dashExportDirective() { - return { - restrict: 'E', - templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html', - controller: DashExportCtrl, - bindToController: true, - controllerAs: 'ctrl', - scope: { dismiss: '&' }, - }; -} - -coreModule.directive('dashExportModal', dashExportDirective); diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index 5d6ad6780b2..38b26071ea4 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -1,15 +1,25 @@ +import _ from 'lodash'; +import config from 'app/core/config'; +import { DashboardExporter } from './DashboardExporter'; +import { DashboardModel } from '../../state/DashboardModel'; +import { PanelPluginMeta } from '@grafana/data'; + jest.mock('app/core/store', () => { return { getBool: jest.fn(), }; }); -import _ from 'lodash'; -import config from 'app/core/config'; -import { DashboardExporter } from './DashboardExporter'; -import { DashboardModel } from '../../state/DashboardModel'; -import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { PanelPluginMeta } from '@grafana/data'; +jest.mock('@grafana/runtime', () => ({ + getDataSourceSrv: () => ({ + get: jest.fn(arg => getStub(arg)), + }), + config: { + buildInfo: {}, + panels: {}, + }, + DataSourceWithBackend: jest.fn(), +})); describe('given dashboard with repeated panels', () => { let dash: any, exported: any; @@ -90,9 +100,6 @@ describe('given dashboard with repeated panels', () => { config.buildInfo.version = '3.0.2'; - //Stubs test function calls - const datasourceSrvStub = ({ get: jest.fn(arg => getStub(arg)) } as any) as DatasourceSrv; - config.panels['graph'] = { id: 'graph', name: 'Graph', @@ -112,7 +119,7 @@ describe('given dashboard with repeated panels', () => { } as PanelPluginMeta; dash = new DashboardModel(dash, {}); - const exporter = new DashboardExporter(datasourceSrvStub); + const exporter = new DashboardExporter(); exporter.makeExportable(dash).then(clean => { exported = clean; done(); diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 4883f254780..2627429e1d3 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -2,9 +2,9 @@ import _ from 'lodash'; import config from 'app/core/config'; import { DashboardModel } from '../../state/DashboardModel'; -import DatasourceSrv from 'app/features/plugins/datasource_srv'; import { PanelModel } from 'app/features/dashboard/state'; import { PanelPluginMeta } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; interface Input { name: string; @@ -35,8 +35,6 @@ interface DataSources { } export class DashboardExporter { - constructor(private datasourceSrv: DatasourceSrv) {} - makeExportable(dashboard: DashboardModel) { // clean up repeated rows and panels, // this is done on the live real dashboard instance, not on a clone @@ -73,36 +71,38 @@ export class DashboardExporter { } promises.push( - this.datasourceSrv.get(datasource).then(ds => { - if (ds.meta.builtIn) { - return; - } + getDataSourceSrv() + .get(datasource) + .then(ds => { + if (ds.meta?.builtIn) { + return; + } + + // add data source type to require list + requires['datasource' + ds.meta?.id] = { + type: 'datasource', + id: ds.meta?.id, + name: ds.meta?.name, + version: ds.meta?.info.version || '1.0.0', + }; - // add data source type to require list - requires['datasource' + ds.meta.id] = { - type: 'datasource', - id: ds.meta.id, - name: ds.meta.name, - version: ds.meta.info.version || '1.0.0', - }; - - // if used via variable we can skip templatizing usage - if (datasourceVariable) { - return; - } + // if used via variable we can skip templatizing usage + if (datasourceVariable) { + return; + } + + const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); + datasources[refName] = { + name: refName, + label: ds.name, + description: '', + type: 'datasource', + pluginId: ds.meta?.id, + pluginName: ds.meta?.name, + }; - const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); - datasources[refName] = { - name: refName, - label: ds.name, - description: '', - type: 'datasource', - pluginId: ds.meta.id, - pluginName: ds.meta.name, - }; - - obj.datasource = '${' + refName + '}'; - }) + obj.datasource = '${' + refName + '}'; + }) ); }; diff --git a/public/app/features/dashboard/components/DashExportModal/index.ts b/public/app/features/dashboard/components/DashExportModal/index.ts index 6529cf07ad9..f9afc4f66da 100644 --- a/public/app/features/dashboard/components/DashExportModal/index.ts +++ b/public/app/features/dashboard/components/DashExportModal/index.ts @@ -1,2 +1 @@ export { DashboardExporter } from './DashboardExporter'; -export { DashExportCtrl } from './DashExportCtrl'; diff --git a/public/app/features/dashboard/components/DashExportModal/template.html b/public/app/features/dashboard/components/DashExportModal/template.html deleted file mode 100644 index 9cc6540668d..00000000000 --- a/public/app/features/dashboard/components/DashExportModal/template.html +++ /dev/null @@ -1,25 +0,0 @@ - diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 9e9feae5969..851d42b00b4 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -15,6 +15,7 @@ import { updateLocation } from 'app/core/actions'; // Types import { DashboardModel } from '../../state'; import { CoreEvents, StoreState } from 'app/types'; +import { ShareModal } from '../ShareModal/ShareModal'; import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; export interface OwnProps { @@ -99,18 +100,6 @@ export class DashNav extends PureComponent { this.forceUpdate(); }; - onOpenShare = () => { - const $rootScope = this.props.$injector.get('$rootScope'); - const modalScope = $rootScope.$new(); - modalScope.tabIndex = 0; - modalScope.dashboard = this.props.dashboard; - - appEvents.emit(CoreEvents.showModal, { - src: 'public/app/features/dashboard/components/ShareModal/template.html', - scope: modalScope, - }); - }; - renderDashboardTitleSearchButton() { const { dashboard } = this.props; @@ -210,31 +199,38 @@ export class DashNav extends PureComponent { )} {canShare && ( - + + {({ showModal, hideModal }) => ( + { + showModal(ShareModal, { + dashboard, + onDismiss: hideModal, + }); + }} + /> + )} + )} {canSave && ( - {({ showModal, hideModal }) => { - return ( - { - showModal(SaveDashboardModalProxy, { - dashboard, - onDismiss: hideModal, - }); - }} - /> - ); - }} + {({ showModal, hideModal }) => ( + { + showModal(SaveDashboardModalProxy, { + dashboard, + onDismiss: hideModal, + }); + }} + /> + )} )} diff --git a/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx new file mode 100644 index 00000000000..c8d9c97fb5d --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx @@ -0,0 +1,119 @@ +import React, { PureComponent } from 'react'; +import { Switch, Select } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { buildIframeHtml } from './utils'; + +const themeOptions: Array> = [ + { label: 'current', value: 'current' }, + { label: 'dark', value: 'dark' }, + { label: 'light', value: 'light' }, +]; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; +} + +interface State { + useCurrentTimeRange: boolean; + includeTemplateVars: boolean; + selectedTheme: SelectableValue; + iframeHtml: string; +} + +export class ShareEmbed extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + useCurrentTimeRange: true, + includeTemplateVars: true, + selectedTheme: themeOptions[0], + iframeHtml: '', + }; + } + + componentDidMount() { + this.buildIframeHtml(); + } + + buildIframeHtml = () => { + const { panel } = this.props; + const { useCurrentTimeRange, includeTemplateVars, selectedTheme } = this.state; + + const iframeHtml = buildIframeHtml(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel); + this.setState({ iframeHtml }); + }; + + onUseCurrentTimeRangeChange = () => { + this.setState( + { + useCurrentTimeRange: !this.state.useCurrentTimeRange, + }, + this.buildIframeHtml + ); + }; + + onIncludeTemplateVarsChange = () => { + this.setState( + { + includeTemplateVars: !this.state.includeTemplateVars, + }, + this.buildIframeHtml + ); + }; + + onThemeChange = (value: SelectableValue) => { + this.setState( + { + selectedTheme: value, + }, + this.buildIframeHtml + ); + }; + + render() { + const { useCurrentTimeRange, includeTemplateVars, selectedTheme, iframeHtml } = this.state; + + return ( +
+
+
+ +
+
+
+ + +
+ + +
+
+
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx new file mode 100644 index 00000000000..3512a82961d --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx @@ -0,0 +1,120 @@ +import React, { PureComponent } from 'react'; +import { saveAs } from 'file-saver'; +import { Button, Switch } from '@grafana/ui'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal'; +import { appEvents } from 'app/core/core'; +import { CoreEvents } from 'app/types'; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss(): void; +} + +interface State { + shareExternally: boolean; +} + +export class ShareExport extends PureComponent { + private exporter: DashboardExporter; + + constructor(props: Props) { + super(props); + this.state = { + shareExternally: false, + }; + + this.exporter = new DashboardExporter(); + } + + onShareExternallyChange = () => { + this.setState({ + shareExternally: !this.state.shareExternally, + }); + }; + + onSaveAsFile = () => { + const { dashboard } = this.props; + const { shareExternally } = this.state; + + if (shareExternally) { + this.exporter.makeExportable(dashboard).then((dashboardJson: any) => { + this.openSaveAsDialog(dashboardJson); + }); + } else { + this.openSaveAsDialog(dashboard.getSaveModelClone()); + } + }; + + onViewJson = () => { + const { dashboard } = this.props; + const { shareExternally } = this.state; + + if (shareExternally) { + this.exporter.makeExportable(dashboard).then((dashboardJson: any) => { + this.openJsonModal(dashboardJson); + }); + } else { + this.openJsonModal(dashboard.getSaveModelClone()); + } + }; + + openSaveAsDialog = (dash: any) => { + const dashboardJsonPretty = JSON.stringify(dash, null, 2); + const blob = new Blob([dashboardJsonPretty], { + type: 'application/json;charset=utf-8', + }); + const time = new Date().getTime(); + saveAs(blob, `${dash.title}-${time}.json`); + }; + + openJsonModal = (clone: object) => { + const model = { + object: clone, + enableCopy: true, + }; + + appEvents.emit(CoreEvents.showModal, { + src: 'public/app/partials/edit_json.html', + model, + }); + + this.props.onDismiss(); + }; + + render() { + const { onDismiss } = this.props; + const { shareExternally } = this.state; + + return ( +
+
+
+ +
+
+ + +
+ + + +
+
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx new file mode 100644 index 00000000000..e1a99f7419d --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import config from 'app/core/config'; +import { ShareLink, Props, State } from './ShareLink'; + +jest.mock('app/features/dashboard/services/TimeSrv', () => ({ + getTimeSrv: () => ({ + timeRange: () => { + return { from: new Date(1000), to: new Date(2000) }; + }, + }), +})); + +let fillVariableValuesForUrlMock = (params: any) => {}; + +jest.mock('app/features/templating/template_srv', () => ({ + fillVariableValuesForUrl: (params: any) => { + fillVariableValuesForUrlMock(params); + }, +})); + +function mockLocationHref(href: string) { + const location = window.location; + + let search = ''; + const searchPos = href.indexOf('?'); + if (searchPos >= 0) { + search = href.substring(searchPos); + } + + delete window.location; + (window as any).location = { + ...location, + href, + search, + }; +} + +function setUTCTimeZone() { + (window as any).Intl.DateTimeFormat = () => { + return { + resolvedOptions: () => { + return { timeZone: 'UTC' }; + }, + }; + }; +} + +interface ScenarioContext { + wrapper?: ShallowWrapper; + mount: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function shareLinkScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: any = { + setup: (fn: any) => { + setupFn = fn; + }, + mount: (propOverrides?: any) => { + const props: any = { + panel: undefined, + }; + + Object.assign(props, propOverrides); + ctx.wrapper = shallow(); + }, + }; + + beforeEach(() => { + setUTCTimeZone(); + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('ShareModal', () => { + shareLinkScenario('shareUrl with current time range and panel', ctx => { + ctx.setup(() => { + mockLocationHref('http://server/#!/test'); + config.bootData = { + user: { + orgId: 1, + }, + }; + ctx.mount({ + panel: { id: 22, options: {} }, + }); + }); + + it('should generate share url absolute time', () => { + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen'); + }); + + it('should generate render url', () => { + mockLocationHref('http://dashboards.grafana.com/d/abcdefghi/my-dash'); + ctx.mount({ + panel: { id: 22, options: {} }, + }); + + const state = ctx.wrapper?.state(); + const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(state?.imageUrl).toContain(base + params); + }); + + it('should generate render url for scripted dashboard', () => { + mockLocationHref('http://dashboards.grafana.com/dashboard/script/my-dash.js'); + ctx.mount({ + panel: { id: 22, options: {} }, + }); + + const state = ctx.wrapper?.state(); + const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(state?.imageUrl).toContain(base + params); + }); + + it('should remove panel id when no panel in scope', () => { + ctx.mount({ + panel: undefined, + }); + + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1'); + }); + + it('should add theme when specified', () => { + ctx.wrapper?.setProps({ panel: undefined }); + ctx.wrapper?.setState({ selectedTheme: { label: 'light', value: 'light' } }); + + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); + }); + + it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', () => { + mockLocationHref('http://server/#!/test?fullscreen&edit'); + ctx.mount({ + panel: { id: 1, options: {} }, + }); + + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toContain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1'); + expect(state?.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + }); + + it('should remove edit from image url when is first param in querystring and modeSharePanel is true', () => { + mockLocationHref('http://server/#!/test?edit&fullscreen'); + ctx.mount({ + panel: { id: 1, options: {} }, + }); + + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toContain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1'); + expect(state?.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + }); + + it('should include template variables in url', () => { + mockLocationHref('http://server/#!/test'); + fillVariableValuesForUrlMock = (params: any) => { + params['var-app'] = 'mupp'; + params['var-server'] = 'srv-01'; + }; + ctx.mount(); + ctx.wrapper?.setState({ includeTemplateVars: true }); + + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toContain( + 'http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01' + ); + }); + }); +}); diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx new file mode 100644 index 00000000000..e23d4cf3d28 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx @@ -0,0 +1,142 @@ +import React, { PureComponent } from 'react'; +import { Switch, Select, ClipboardButton } from '@grafana/ui'; +import { SelectableValue, PanelModel, AppEvents } from '@grafana/data'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { buildImageUrl, buildShareUrl } from './utils'; +import { appEvents } from 'app/core/core'; + +const themeOptions: Array> = [ + { label: 'current', value: 'current' }, + { label: 'dark', value: 'dark' }, + { label: 'light', value: 'light' }, +]; + +export interface Props { + dashboard?: DashboardModel; + panel?: PanelModel; +} + +export interface State { + useCurrentTimeRange: boolean; + includeTemplateVars: boolean; + selectedTheme: SelectableValue; + shareUrl: string; + imageUrl: string; +} + +export class ShareLink extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + useCurrentTimeRange: true, + includeTemplateVars: true, + selectedTheme: themeOptions[0], + shareUrl: '', + imageUrl: '', + }; + } + + componentDidMount() { + this.buildUrl(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { useCurrentTimeRange, includeTemplateVars, selectedTheme } = this.state; + if ( + prevState.useCurrentTimeRange !== useCurrentTimeRange || + prevState.includeTemplateVars !== includeTemplateVars || + prevState.selectedTheme.value !== selectedTheme.value + ) { + this.buildUrl(); + } + } + + buildUrl = () => { + const { panel } = this.props; + const { useCurrentTimeRange, includeTemplateVars, selectedTheme } = this.state; + + const shareUrl = buildShareUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel); + const imageUrl = buildImageUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel); + this.setState({ shareUrl, imageUrl }); + }; + + onUseCurrentTimeRangeChange = () => { + this.setState({ useCurrentTimeRange: !this.state.useCurrentTimeRange }); + }; + + onIncludeTemplateVarsChange = () => { + this.setState({ includeTemplateVars: !this.state.includeTemplateVars }); + }; + + onThemeChange = (value: SelectableValue) => { + this.setState({ selectedTheme: value }); + }; + + onShareUrlCopy = () => { + appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); + }; + + getShareUrl = () => { + return this.state.shareUrl; + }; + + render() { + const { panel } = this.props; + const { useCurrentTimeRange, includeTemplateVars, selectedTheme, shareUrl, imageUrl } = this.state; + + return ( +
+
+
+ +
+
+

+ Create a direct link to this dashboard or panel, customized with the options below. +

+
+ + +
+ + +
+
+ + Copy + + {/* */} +
+
+
+
+ {panel && ( + + )} +
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx new file mode 100644 index 00000000000..3cad4f0d583 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -0,0 +1,86 @@ +import React, { PureComponent } from 'react'; +import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { ShareLink } from './ShareLink'; +import { ShareSnapshot } from './ShareSnapshot'; +import { ShareExport } from './ShareExport'; +import { ShareEmbed } from './ShareEmbed'; + +const shareModalTabs = [ + { label: 'Link', value: 'link' }, + { label: 'Embed', value: 'embed' }, + { label: 'Snapshot', value: 'snapshot' }, + { label: 'Export', value: 'export' }, +]; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + + onDismiss(): void; +} + +interface State { + tab: string; +} + +function getInitialState(): State { + return { + tab: shareModalTabs[0].value, + }; +} + +export class ShareModal extends PureComponent { + constructor(props: Props) { + super(props); + this.state = getInitialState(); + } + + onDismiss = () => { + this.setState(getInitialState()); + this.props.onDismiss(); + }; + + onSelectTab = (t: any) => { + this.setState({ tab: t.value }); + }; + + getTabs() { + const { panel } = this.props; + + // Filter tabs for dashboard/panel share modal + return shareModalTabs.filter(t => { + if (panel) { + return t.value !== 'export'; + } + return t.value !== 'embed'; + }); + } + + renderTitle() { + const { panel } = this.props; + const { tab } = this.state; + const title = panel ? 'Share Panel' : 'Share'; + const tabs = this.getTabs(); + + return ( + + ); + } + + render() { + const { dashboard, panel } = this.props; + const { tab } = this.state; + + return ( + + + {tab === 'link' && } + {tab === 'embed' && panel && } + {tab === 'snapshot' && } + {tab === 'export' && !panel && } + + + ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts deleted file mode 100644 index f30963bf31f..00000000000 --- a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import config from 'app/core/config'; -import { LinkSrv } from 'app/features/panel/panellinks/link_srv'; -import { ShareModalCtrl } from './ShareModalCtrl'; -import { TemplateSrv } from 'app/features/templating/template_srv'; - -describe('ShareModalCtrl', () => { - const ctx = { - timeSrv: { - timeRange: () => { - return { from: new Date(1000), to: new Date(2000) }; - }, - }, - $location: { - absUrl: () => 'http://server/#!/test', - search: () => { - return { from: '', to: '' }; - }, - }, - scope: { - dashboard: { - meta: { - isSnapshot: true, - }, - }, - }, - templateSrv: { - fillVariableValuesForUrl: () => {}, - }, - } as any; - - (window as any).Intl.DateTimeFormat = () => { - return { - resolvedOptions: () => { - return { timeZone: 'UTC' }; - }, - }; - }; - - beforeEach(() => { - config.bootData = { - user: { - orgId: 1, - }, - }; - // @ts-ignore - ctx.ctrl = new ShareModalCtrl( - ctx.scope, - {} as any, - ctx.$location, - {}, - ctx.timeSrv, - ctx.templateSrv, - new LinkSrv({} as TemplateSrv, ctx.stimeSrv) - ); - }); - - describe('shareUrl with current time range and panel', () => { - it('should generate share url absolute time', () => { - ctx.scope.panel = { id: 22 }; - - ctx.scope.init(); - expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen'); - }); - - it('should generate render url', () => { - ctx.$location.absUrl = () => 'http://dashboards.grafana.com/d/abcdefghi/my-dash'; - - ctx.scope.panel = { id: 22 }; - - ctx.scope.init(); - const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; - const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; - expect(ctx.scope.imageUrl).toContain(base + params); - }); - - it('should generate render url for scripted dashboard', () => { - ctx.$location.absUrl = () => 'http://dashboards.grafana.com/dashboard/script/my-dash.js'; - - ctx.scope.panel = { id: 22 }; - - ctx.scope.init(); - const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; - const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; - expect(ctx.scope.imageUrl).toContain(base + params); - }); - - it('should remove panel id when no panel in scope', () => { - ctx.$location.absUrl = () => 'http://server/#!/test'; - ctx.scope.options.forCurrent = true; - ctx.scope.panel = null; - - ctx.scope.init(); - expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1'); - }); - - it('should add theme when specified', () => { - ctx.scope.options.theme = 'light'; - ctx.scope.panel = null; - - ctx.scope.init(); - expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); - }); - - it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', () => { - ctx.$location.search = () => { - return { fullscreen: true, edit: true }; - }; - ctx.$location.absUrl = () => 'http://server/#!/test?fullscreen&edit'; - ctx.scope.modeSharePanel = true; - ctx.scope.panel = { id: 1 }; - - ctx.scope.buildUrl(); - - expect(ctx.scope.shareUrl).toContain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1'); - expect(ctx.scope.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); - }); - - it('should remove edit from image url when is first param in querystring and modeSharePanel is true', () => { - ctx.$location.search = () => { - return { edit: true, fullscreen: true }; - }; - ctx.$location.absUrl = () => 'http://server/#!/test?edit&fullscreen'; - ctx.scope.modeSharePanel = true; - ctx.scope.panel = { id: 1 }; - - ctx.scope.buildUrl(); - - expect(ctx.scope.shareUrl).toContain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1'); - expect(ctx.scope.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); - }); - - it('should include template variables in url', () => { - ctx.$location.search = () => { - return {}; - }; - ctx.$location.absUrl = () => 'http://server/#!/test'; - ctx.scope.options.includeTemplateVars = true; - - ctx.templateSrv.fillVariableValuesForUrl = (params: any) => { - params['var-app'] = 'mupp'; - params['var-server'] = 'srv-01'; - }; - - ctx.scope.buildUrl(); - expect(ctx.scope.shareUrl).toContain( - 'http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01' - ); - }); - }); -}); diff --git a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts deleted file mode 100644 index 52f32611c3d..00000000000 --- a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts +++ /dev/null @@ -1,137 +0,0 @@ -import angular, { ILocationService } from 'angular'; -import { dateTime } from '@grafana/data'; -import { e2e } from '@grafana/e2e'; - -import config from 'app/core/config'; -import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url'; -import { TimeSrv } from '../../services/TimeSrv'; -import { TemplateSrv } from 'app/features/templating/template_srv'; -import { LinkSrv } from 'app/features/panel/panellinks/link_srv'; -import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; - -/** @ngInject */ -export function ShareModalCtrl( - $scope: any, - $rootScope: GrafanaRootScope, - $location: ILocationService, - $timeout: any, - timeSrv: TimeSrv, - templateSrv: TemplateSrv, - linkSrv: LinkSrv -) { - $scope.options = { - forCurrent: true, - includeTemplateVars: true, - theme: 'current', - }; - $scope.editor = { index: $scope.tabIndex || 0 }; - $scope.selectors = e2e.pages.SharePanelModal.selectors; - - $scope.init = () => { - $scope.panel = $scope.model && $scope.model.panel ? $scope.model.panel : $scope.panel; // React pass panel and dashboard in the "model" property - $scope.dashboard = $scope.model && $scope.model.dashboard ? $scope.model.dashboard : $scope.dashboard; // ^ - $scope.modeSharePanel = $scope.panel ? true : false; - - $scope.tabs = [{ title: 'Link', src: 'shareLink.html' }]; - - if ($scope.modeSharePanel) { - $scope.modalTitle = 'Share Panel'; - $scope.tabs.push({ title: 'Embed', src: 'shareEmbed.html' }); - } else { - $scope.modalTitle = 'Share'; - } - - if (!$scope.dashboard.meta.isSnapshot) { - $scope.tabs.push({ title: 'Snapshot', src: 'shareSnapshot.html' }); - } - - if (!$scope.dashboard.meta.isSnapshot && !$scope.modeSharePanel) { - $scope.tabs.push({ title: 'Export', src: 'shareExport.html' }); - } - - $scope.buildUrl(); - }; - - $scope.buildUrl = () => { - let baseUrl = $location.absUrl(); - const queryStart = baseUrl.indexOf('?'); - - if (queryStart !== -1) { - baseUrl = baseUrl.substring(0, queryStart); - } - - const params = angular.copy($location.search()); - - const range = timeSrv.timeRange(); - params.from = range.from.valueOf(); - params.to = range.to.valueOf(); - params.orgId = config.bootData.user.orgId; - - if ($scope.options.includeTemplateVars) { - templateSrv.fillVariableValuesForUrl(params); - } - - if (!$scope.options.forCurrent) { - delete params.from; - delete params.to; - } - - if ($scope.options.theme !== 'current') { - params.theme = $scope.options.theme; - } - - if ($scope.modeSharePanel) { - params.panelId = $scope.panel.id; - params.fullscreen = true; - } else { - delete params.panelId; - delete params.fullscreen; - } - - $scope.shareUrl = appendQueryToUrl(baseUrl, toUrlParams(params)); - - let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/'); - soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/'); - delete params.fullscreen; - delete params.edit; - soloUrl = appendQueryToUrl(soloUrl, toUrlParams(params)); - - $scope.iframeHtml = ''; - - $scope.imageUrl = soloUrl.replace( - config.appSubUrl + '/dashboard-solo/', - config.appSubUrl + '/render/dashboard-solo/' - ); - $scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); - $scope.imageUrl += '&width=1000&height=500' + $scope.getLocalTimeZone(); - }; - - // This function will try to return the proper full name of the local timezone - // Chrome does not handle the timezone offset (but phantomjs does) - $scope.getLocalTimeZone = () => { - const utcOffset = '&tz=UTC' + encodeURIComponent(dateTime().format('Z')); - - // Older browser does not the internationalization API - if (!(window as any).Intl) { - return utcOffset; - } - - const dateFormat = (window as any).Intl.DateTimeFormat(); - if (!dateFormat.resolvedOptions) { - return utcOffset; - } - - const options = dateFormat.resolvedOptions(); - if (!options.timeZone) { - return utcOffset; - } - - return '&tz=' + encodeURIComponent(options.timeZone); - }; - - $scope.getShareUrl = () => { - return $scope.shareUrl; - }; -} - -angular.module('grafana.controllers').controller('ShareModalCtrl', ShareModalCtrl); diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx new file mode 100644 index 00000000000..5a9840a548a --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -0,0 +1,316 @@ +import React, { PureComponent } from 'react'; +import { Button, Select, LinkButton, Input, ClipboardButton } from '@grafana/ui'; +import { SelectableValue, AppEvents } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { appEvents } from 'app/core/core'; + +const snapshotApiUrl = '/api/snapshots'; + +const expireOptions: Array> = [ + { label: 'Never', value: 0 }, + { label: '1 Hour', value: 60 * 60 }, + { label: '1 Day', value: 60 * 60 * 24 }, + { label: '7 Days', value: 60 * 60 * 24 * 7 }, +]; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss(): void; +} + +interface State { + isLoading: boolean; + step: number; + snapshotName: string; + selectedExpireOption: SelectableValue; + snapshotExpires?: number; + snapshotUrl: string; + deleteUrl: string; + timeoutSeconds: number; + externalEnabled: boolean; + sharingButtonText: string; +} + +export class ShareSnapshot extends PureComponent { + private dashboard: DashboardModel; + + constructor(props: Props) { + super(props); + this.dashboard = props.dashboard; + this.state = { + isLoading: false, + step: 1, + selectedExpireOption: expireOptions[0], + snapshotExpires: expireOptions[0].value, + snapshotName: props.dashboard.title, + timeoutSeconds: 4, + snapshotUrl: '', + deleteUrl: '', + externalEnabled: false, + sharingButtonText: '', + }; + } + + componentDidMount() { + this.getSnaphotShareOptions(); + } + + async getSnaphotShareOptions() { + const shareOptions = await getBackendSrv().get('/api/snapshot/shared-options'); + this.setState({ + sharingButtonText: shareOptions['externalSnapshotName'], + externalEnabled: shareOptions['externalEnabled'], + }); + } + + createSnapshot = (external?: boolean) => () => { + const { timeoutSeconds } = this.state; + this.dashboard.snapshot = { + timestamp: new Date(), + }; + + if (!external) { + this.dashboard.snapshot.originalUrl = window.location.href; + } + + this.setState({ isLoading: true }); + this.dashboard.startRefresh(); + + setTimeout(() => { + this.saveSnapshot(this.dashboard, external); + }, timeoutSeconds * 1000); + }; + + saveSnapshot = async (dashboard: DashboardModel, external?: boolean) => { + const { snapshotExpires } = this.state; + const dash = this.dashboard.getSaveModelClone(); + this.scrubDashboard(dash); + + const cmdData = { + dashboard: dash, + name: dash.title, + expires: snapshotExpires, + external: external, + }; + + try { + const results: { deleteUrl: any; url: any } = await getBackendSrv().post(snapshotApiUrl, cmdData); + this.setState({ + deleteUrl: results.deleteUrl, + snapshotUrl: results.url, + step: 2, + }); + } finally { + this.setState({ isLoading: false }); + } + }; + + scrubDashboard = (dash: DashboardModel) => { + const { panel } = this.props; + const { snapshotName } = this.state; + // change title + dash.title = snapshotName; + + // make relative times absolute + dash.time = getTimeSrv().timeRange(); + + // remove panel queries & links + dash.panels.forEach(panel => { + panel.targets = []; + panel.links = []; + panel.datasource = null; + }); + + // remove annotation queries + const annotations = dash.annotations.list.filter(annotation => annotation.enable); + dash.annotations.list = annotations.map((annotation: any) => { + return { + name: annotation.name, + enable: annotation.enable, + iconColor: annotation.iconColor, + snapshotData: annotation.snapshotData, + type: annotation.type, + builtIn: annotation.builtIn, + hide: annotation.hide, + }; + }); + + // remove template queries + dash.templating.list.forEach(variable => { + variable.query = ''; + variable.options = variable.current; + variable.refresh = false; + }); + + // snapshot single panel + if (panel) { + const singlePanel = panel.getSaveModel(); + singlePanel.gridPos.w = 24; + singlePanel.gridPos.x = 0; + singlePanel.gridPos.y = 0; + singlePanel.gridPos.h = 20; + dash.panels = [singlePanel]; + } + + // cleanup snapshotData + delete this.dashboard.snapshot; + this.dashboard.forEachPanel((panel: PanelModel) => { + delete panel.snapshotData; + }); + this.dashboard.annotations.list.forEach(annotation => { + delete annotation.snapshotData; + }); + }; + + deleteSnapshot = async () => { + const { deleteUrl } = this.state; + await getBackendSrv().get(deleteUrl); + this.setState({ step: 3 }); + }; + + getSnapshotUrl = () => { + return this.state.snapshotUrl; + }; + + onSnapshotNameChange = (event: React.ChangeEvent) => { + this.setState({ snapshotName: event.target.value }); + }; + + onTimeoutChange = (event: React.ChangeEvent) => { + this.setState({ timeoutSeconds: Number(event.target.value) }); + }; + + onExpireChange = (option: SelectableValue) => { + this.setState({ + selectedExpireOption: option, + snapshotExpires: option.value, + }); + }; + + onSnapshotUrlCopy = () => { + appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); + }; + + renderStep1() { + const { onDismiss } = this.props; + const { + snapshotName, + selectedExpireOption, + timeoutSeconds, + isLoading, + sharingButtonText, + externalEnabled, + } = this.state; + + return ( + <> +
+

+ A snapshot is an instant way to share an interactive dashboard publicly. When created, we{' '} + strip sensitive data like queries (metric, template and annotation) and panel links, + leaving only the visible metric data and series names embedded into your dashboard. +

+

+ Keep in mind, your snapshot can be viewed by anyone that has the link and can reach the + URL. Share wisely. +

+
+
+
+ + +
+
+ + +
+
+ +
+ + {externalEnabled && ( + + )} + +
+ + ); + } + + renderStep2() { + const { snapshotUrl } = this.state; + + return ( + <> +
+
+ + {snapshotUrl} + +
+ + Copy Link + +
+
+ +
+ Did you make a mistake?{' '} + + delete snapshot. + +
+ + ); + } + + renderStep3() { + return ( +
+

+ The snapshot has now been deleted. If it you have already accessed it once, It might take up to an hour before + it is removed from browser caches or CDN caches. +

+
+ ); + } + + render() { + const { isLoading, step } = this.state; + + return ( +
+
+
+ {isLoading ? : } +
+
+ {step === 1 && this.renderStep1()} + {step === 2 && this.renderStep2()} + {step === 3 && this.renderStep3()} +
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts deleted file mode 100644 index de76be9c0ce..00000000000 --- a/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts +++ /dev/null @@ -1,175 +0,0 @@ -import angular, { ILocationService, IScope } from 'angular'; -import _ from 'lodash'; -import { getBackendSrv } from '@grafana/runtime'; - -import { TimeSrv } from '../../services/TimeSrv'; -import { DashboardModel } from '../../state/DashboardModel'; -import { PanelModel } from '../../state/PanelModel'; -import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; -import { promiseToDigest } from '../../../../core/utils/promiseToDigest'; - -export class ShareSnapshotCtrl { - /** @ngInject */ - constructor( - $scope: IScope & Record, - $rootScope: GrafanaRootScope, - $location: ILocationService, - $timeout: any, - timeSrv: TimeSrv - ) { - $scope.snapshot = { - name: $scope.dashboard.title, - expires: 0, - timeoutSeconds: 4, - }; - - $scope.step = 1; - - $scope.expireOptions = [ - { text: '1 Hour', value: 60 * 60 }, - { text: '1 Day', value: 60 * 60 * 24 }, - { text: '7 Days', value: 60 * 60 * 24 * 7 }, - { text: 'Never', value: 0 }, - ]; - - $scope.accessOptions = [ - { text: 'Anyone with the link', value: 1 }, - { text: 'Organization users', value: 2 }, - { text: 'Public on the web', value: 3 }, - ]; - - $scope.init = () => { - promiseToDigest($scope)( - getBackendSrv() - .get('/api/snapshot/shared-options') - .then((options: { [x: string]: any }) => { - $scope.sharingButtonText = options['externalSnapshotName']; - $scope.externalEnabled = options['externalEnabled']; - }) - ); - }; - - $scope.apiUrl = '/api/snapshots'; - - $scope.createSnapshot = (external: any) => { - $scope.dashboard.snapshot = { - timestamp: new Date(), - }; - - if (!external) { - $scope.dashboard.snapshot.originalUrl = $location.absUrl(); - } - - $scope.loading = true; - $scope.snapshot.external = external; - $scope.dashboard.startRefresh(); - - $timeout(() => { - $scope.saveSnapshot(external); - }, $scope.snapshot.timeoutSeconds * 1000); - }; - - $scope.saveSnapshot = (external: any) => { - const dash = $scope.dashboard.getSaveModelClone(); - $scope.scrubDashboard(dash); - - const cmdData = { - dashboard: dash, - name: dash.title, - expires: $scope.snapshot.expires, - external: external, - }; - - promiseToDigest($scope)( - getBackendSrv() - .post($scope.apiUrl, cmdData) - .then( - (results: { deleteUrl: any; url: any }) => { - $scope.loading = false; - $scope.deleteUrl = results.deleteUrl; - $scope.snapshotUrl = results.url; - $scope.step = 2; - }, - () => { - $scope.loading = false; - } - ) - ); - }; - - $scope.getSnapshotUrl = () => { - return $scope.snapshotUrl; - }; - - $scope.scrubDashboard = (dash: DashboardModel) => { - // change title - dash.title = $scope.snapshot.name; - - // make relative times absolute - dash.time = timeSrv.timeRange(); - - // remove panel queries & links - _.each(dash.panels, panel => { - panel.targets = []; - panel.links = []; - panel.datasource = null; - }); - - // remove annotation queries - dash.annotations.list = _.chain(dash.annotations.list) - .filter(annotation => { - return annotation.enable; - }) - .map((annotation: any) => { - return { - name: annotation.name, - enable: annotation.enable, - iconColor: annotation.iconColor, - snapshotData: annotation.snapshotData, - type: annotation.type, - builtIn: annotation.builtIn, - hide: annotation.hide, - }; - }) - .value(); - - // remove template queries - _.each(dash.templating.list, variable => { - variable.query = ''; - variable.options = variable.current; - variable.refresh = false; - }); - - // snapshot single panel - if ($scope.modeSharePanel) { - const singlePanel = $scope.panel.getSaveModel(); - singlePanel.gridPos.w = 24; - singlePanel.gridPos.x = 0; - singlePanel.gridPos.y = 0; - singlePanel.gridPos.h = 20; - dash.panels = [singlePanel]; - } - - // cleanup snapshotData - delete $scope.dashboard.snapshot; - $scope.dashboard.forEachPanel((panel: PanelModel) => { - delete panel.snapshotData; - }); - _.each($scope.dashboard.annotations.list, annotation => { - delete annotation.snapshotData; - }); - }; - - $scope.deleteSnapshot = () => { - promiseToDigest($scope)( - getBackendSrv() - .get($scope.deleteUrl) - .then(() => { - $scope.step = 3; - }) - ); - }; - } -} - -angular.module('grafana.controllers').controller('ShareSnapshotCtrl', ShareSnapshotCtrl); diff --git a/public/app/features/dashboard/components/ShareModal/index.ts b/public/app/features/dashboard/components/ShareModal/index.ts deleted file mode 100644 index 3f27d5a1ba3..00000000000 --- a/public/app/features/dashboard/components/ShareModal/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ShareModalCtrl } from './ShareModalCtrl'; -export { ShareSnapshotCtrl } from './ShareSnapshotCtrl'; diff --git a/public/app/features/dashboard/components/ShareModal/template.html b/public/app/features/dashboard/components/ShareModal/template.html deleted file mode 100644 index dfab7f1f7c4..00000000000 --- a/public/app/features/dashboard/components/ShareModal/template.html +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts new file mode 100644 index 00000000000..34281c7c577 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -0,0 +1,126 @@ +import { config } from '@grafana/runtime'; +import { appendQueryToUrl, toUrlParams, getUrlSearchParams } from 'app/core/utils/url'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import templateSrv from 'app/features/templating/template_srv'; +import { PanelModel, dateTime } from '@grafana/data'; + +export function buildParams( + useCurrentTimeRange: boolean, + includeTemplateVars: boolean, + selectedTheme?: string, + panel?: PanelModel +) { + const params = getUrlSearchParams(); + + const range = getTimeSrv().timeRange(); + params.from = range.from.valueOf(); + params.to = range.to.valueOf(); + params.orgId = config.bootData.user.orgId; + + if (!useCurrentTimeRange) { + delete params.from; + delete params.to; + } + + if (includeTemplateVars) { + templateSrv.fillVariableValuesForUrl(params); + } + + if (selectedTheme !== 'current') { + params.theme = selectedTheme; + } + + if (panel) { + params.panelId = panel.id; + params.fullscreen = true; + } else { + delete params.panelId; + delete params.fullscreen; + } + + return params; +} + +export function buildBaseUrl() { + let baseUrl = window.location.href; + const queryStart = baseUrl.indexOf('?'); + + if (queryStart !== -1) { + baseUrl = baseUrl.substring(0, queryStart); + } + + return baseUrl; +} + +export function buildShareUrl( + useCurrentTimeRange: boolean, + includeTemplateVars: boolean, + selectedTheme?: string, + panel?: PanelModel +) { + const baseUrl = buildBaseUrl(); + const params = buildParams(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel); + + return appendQueryToUrl(baseUrl, toUrlParams(params)); +} + +export function buildSoloUrl( + useCurrentTimeRange: boolean, + includeTemplateVars: boolean, + selectedTheme?: string, + panel?: PanelModel +) { + const baseUrl = buildBaseUrl(); + const params = buildParams(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel); + + let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/'); + soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/'); + delete params.fullscreen; + delete params.edit; + return appendQueryToUrl(soloUrl, toUrlParams(params)); +} + +export function buildImageUrl( + useCurrentTimeRange: boolean, + includeTemplateVars: boolean, + selectedTheme?: string, + panel?: PanelModel +) { + let soloUrl = buildSoloUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel); + + let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/'); + imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); + imageUrl += '&width=1000&height=500' + getLocalTimeZone(); + return imageUrl; +} + +export function buildIframeHtml( + useCurrentTimeRange: boolean, + includeTemplateVars: boolean, + selectedTheme?: string, + panel?: PanelModel +) { + let soloUrl = buildSoloUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel); + return ''; +} + +export function getLocalTimeZone() { + const utcOffset = '&tz=UTC' + encodeURIComponent(dateTime().format('Z')); + + // Older browser does not the internationalization API + if (!(window as any).Intl) { + return utcOffset; + } + + const dateFormat = (window as any).Intl.DateTimeFormat(); + if (!dateFormat.resolvedOptions) { + return utcOffset; + } + + const options = dateFormat.resolvedOptions(); + if (!options.timeZone) { + return utcOffset; + } + + return '&tz=' + encodeURIComponent(options.timeZone); +} diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts index dc09d1b9ad7..5a2c9042304 100644 --- a/public/app/features/dashboard/index.ts +++ b/public/app/features/dashboard/index.ts @@ -12,7 +12,6 @@ import './components/VersionHistory'; import './components/DashboardSettings'; import './components/SubMenu'; import './components/UnsavedChangesModal'; -import './components/ShareModal'; import './components/AdHocFilters'; import './components/RowOptions'; diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts index fe50a158c8a..e59da14307d 100644 --- a/public/app/features/dashboard/utils/panel.ts +++ b/public/app/features/dashboard/utils/panel.ts @@ -20,6 +20,8 @@ import templateSrv from 'app/features/templating/template_srv'; import { LS_PANEL_COPY_KEY, PANEL_BORDER } from 'app/core/constants'; import { CoreEvents } from 'app/types'; +import { ShareModal } from 'app/features/dashboard/components/ShareModal/ShareModal'; + export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => { // confirm deletion if (ask !== false) { @@ -82,9 +84,9 @@ export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => { }; export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => { - appEvents.emit(CoreEvents.showModal, { - src: 'public/app/features/dashboard/components/ShareModal/template.html', - model: { + appEvents.emit(CoreEvents.showModalReact, { + component: ShareModal, + props: { dashboard: dashboard, panel: panel, }, diff --git a/public/test/mocks/datasource_srv.ts b/public/test/mocks/datasource_srv.ts index 8f7ae41ca87..f97e27f9d3d 100644 --- a/public/test/mocks/datasource_srv.ts +++ b/public/test/mocks/datasource_srv.ts @@ -27,13 +27,13 @@ export class MockDataSourceApi extends DataSourceApi { result: DataQueryResponse = { data: [] }; queryResolver: Promise; - constructor(name?: string, result?: DataQueryResponse) { + constructor(name?: string, result?: DataQueryResponse, meta?: any) { super({ name: name ? name : 'MockDataSourceApi' } as DataSourceInstanceSettings); if (result) { this.result = result; } - this.meta = {} as DataSourcePluginMeta; + this.meta = meta || ({} as DataSourcePluginMeta); } query(request: DataQueryRequest): Promise {