From 47d2a8085ba2581ab6429bef6b820601e9b95753 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Thu, 25 Feb 2021 10:26:28 +0000 Subject: [PATCH] Frontend changes for library panels feature (#30653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Hugo Häggmark --- .../grafana-ui/src/components/Card/Card.tsx | 10 +- .../grafana-ui/src/components/Icon/Icon.tsx | 4 +- .../components/Icon/assets/ReusablePanel.tsx | 13 + .../src/components/Icon/assets/index.ts | 19 +- packages/grafana-ui/src/types/icon.ts | 460 +++++++++--------- public/app/core/services/keybindingSrv.ts | 8 +- public/app/core/utils/fetch.ts | 3 +- .../AddPanelWidget/AddPanelWidget.tsx | 221 ++++++--- .../AddPanelWidget.test.tsx.snap | 44 +- .../components/PanelEditor/PanelEditor.tsx | 112 ++++- .../PanelEditor/PanelOptionsTab.tsx | 21 +- .../components/PanelEditor/state/actions.ts | 88 +++- .../SaveDashboard/useDashboardSave.tsx | 7 +- .../dashgrid/PanelHeader/PanelHeader.tsx | 1 + .../dashboard/state/DashboardModel.ts | 1 + .../features/dashboard/state/PanelModel.ts | 8 + public/app/features/dashboard/utils/panel.ts | 7 +- .../AddLibraryPanelModal.tsx | 52 ++ .../LibraryPanelCard/LibraryPanelCard.tsx | 112 +++++ .../LibraryPanelInfo/LibraryPanelInfo.tsx | 55 +++ .../LibraryPanelsView/LibraryPanelsView.tsx | 120 +++++ .../PanelLibraryOptionsGroup.tsx | 89 ++++ .../SaveLibraryPanelModal.tsx | 150 ++++++ .../components/UnlinkModal/UnlinkModal.tsx | 26 + .../app/features/library-panels/state/api.ts | 1 + .../library-panels/utils/usePanelSave.ts | 54 ++ public/app/types/events.ts | 2 +- 27 files changed, 1326 insertions(+), 362 deletions(-) create mode 100644 packages/grafana-ui/src/components/Icon/assets/ReusablePanel.tsx create mode 100644 public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx create mode 100644 public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx create mode 100644 public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx create mode 100644 public/app/features/library-panels/components/UnlinkModal/UnlinkModal.tsx create mode 100644 public/app/features/library-panels/utils/usePanelSave.ts diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index 897ff89f4c7..e28e8c35069 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -319,13 +319,19 @@ const Meta: FC = memo(({ children, styles, Meta.displayName = 'Meta'; interface ActionsProps extends ChildProps { - children: JSX.Element[]; + children: JSX.Element | JSX.Element[]; variant?: 'primary' | 'secondary'; } const BaseActions: FC = ({ children, styles, disabled, variant }) => { const css = variant === 'primary' ? styles?.actions : styles?.secondaryActions; - return
{React.Children.map(children, (child) => cloneElement(child, { disabled }))}
; + return ( +
+ {Array.isArray(children) + ? React.Children.map(children, (child) => cloneElement(child, { disabled })) + : cloneElement(children, { disabled })} +
+ ); }; const Actions: FC = ({ children, styles, disabled }) => { diff --git a/packages/grafana-ui/src/components/Icon/Icon.tsx b/packages/grafana-ui/src/components/Icon/Icon.tsx index f7c60eacdc1..7a354a98069 100644 --- a/packages/grafana-ui/src/components/Icon/Icon.tsx +++ b/packages/grafana-ui/src/components/Icon/Icon.tsx @@ -10,7 +10,7 @@ import * as MonoIcon from './assets'; import { customIcons } from './custom'; import { SvgProps } from './assets/types'; -const alwaysMonoIcons = ['grafana', 'favorite', 'heart-break', 'heart', 'panel-add']; +const alwaysMonoIcons: IconName[] = ['grafana', 'favorite', 'heart-break', 'heart', 'panel-add', 'reusable-panel']; export interface IconProps extends React.HTMLAttributes { name: IconName; @@ -36,7 +36,7 @@ const getIconStyles = stylesFactory((theme: GrafanaTheme) => { }; }); -function getIconComponent(name: string, type: string): ComponentType { +function getIconComponent(name: IconName, type: string): ComponentType { if (alwaysMonoIcons.includes(name)) { type = 'mono'; } diff --git a/packages/grafana-ui/src/components/Icon/assets/ReusablePanel.tsx b/packages/grafana-ui/src/components/Icon/assets/ReusablePanel.tsx new file mode 100644 index 00000000000..15f113f1a57 --- /dev/null +++ b/packages/grafana-ui/src/components/Icon/assets/ReusablePanel.tsx @@ -0,0 +1,13 @@ +import React, { FunctionComponent } from 'react'; +import { SvgProps } from './types'; + +export const ReusablePanel: FunctionComponent = ({ size, ...rest }) => { + return ( + + + + ); +}; diff --git a/packages/grafana-ui/src/components/Icon/assets/index.ts b/packages/grafana-ui/src/components/Icon/assets/index.ts index 1a78fd90802..7e7998b6730 100644 --- a/packages/grafana-ui/src/components/Icon/assets/index.ts +++ b/packages/grafana-ui/src/components/Icon/assets/index.ts @@ -1,15 +1,16 @@ export * from './Apps'; -export * from './Cog'; -export * from './Shield'; -export * from './Favorite'; -export * from './Grafana'; export * from './Bell'; -export * from './PlusSquare'; -export * from './FolderPlus'; +export * from './Circle'; +export * from './Cog'; +export * from './Favorite'; export * from './Folder'; +export * from './FolderPlus'; +export * from './Grafana'; +export * from './Heart'; +export * from './HeartBreak'; export * from './Import'; export * from './PanelAdd'; -export * from './Circle'; +export * from './PlusSquare'; +export * from './ReusablePanel'; +export * from './Shield'; export * from './SquareShape'; -export * from './HeartBreak'; -export * from './Heart'; diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index 03a6156aa9b..fae4c10076e 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -3,254 +3,268 @@ export type IconType = 'mono' | 'default'; export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl'; export type IconName = - | 'fa fa-spinner' - | 'grafana' - | 'question-circle' - | 'angle-up' - | 'history' + | 'angle-double-down' + | 'angle-double-right' | 'angle-down' - | 'filter' | 'angle-left' | 'angle-right' - | 'angle-double-right' - | 'angle-double-down' - | 'pen' - | 'envelope' - | 'percentage' - | 'rocket' - | 'power' - | 'trash-alt' - | 'slack' - | 'download-alt' - | 'mobile-android' - | 'plus-square' - | 'folder-plus' - | 'folder-open' - | 'folder' - | 'file-copy-alt' - | 'file-alt' - | 'exchange-alt' - | 'import' - | 'exclamation-triangle' - | 'times' - | 'signin' - | 'signout' - | 'cloud-upload' - | 'step-backward' - | 'square-shape' - | 'share-alt' - | 'tag-alt' - | 'forward' - | 'check' - | 'check-circle' - | 'copy' - | 'lock' - | 'unlock' - | 'panel-add' - | 'arrow-random' - | 'arrow-down' - | 'arrows-h' - | 'comment-alt' - | 'code-branch' - | 'arrow-right' - | 'circle' - | 'arrow-up' - | 'arrow-from-right' - | 'keyboard' - | 'search' - | 'chart-line' - | 'search-minus' - | 'clock-nine' - | 'sync' - | 'sign-in-alt' - | 'cloud-download' - | 'cog' - | 'bars' - | 'save' + | 'angle-up' | 'apps' - | 'link' - | 'upload' - | 'columns' - | 'home-alt' - | 'channel-add' - | 'calendar-alt' - | 'play' - | 'pause' - | 'calculator-alt' - | 'compass' - | 'sliders-v-alt' - | 'bell' - | 'bell-slash' - | 'database' - | 'user' - | 'camera' - | 'plug' - | 'shield' - | 'key-skeleton-alt' - | 'users-alt' - | 'graph-bar' - | 'book' - | 'bolt' - | 'comments-alt' - | 'document-info' - | 'info-circle' - | 'bug' - | 'cube' - | 'star' - | 'list-ul' - | 'edit' - | 'eye' - | 'eye-slash' - | 'monitor' - | 'plus-circle' + | 'arrow-down' + | 'arrow-from-right' | 'arrow-left' - | 'repeat' - | 'external-link-alt' - | 'minus' - | 'signal' - | 'search-plus' - | 'minus-circle' - | 'table' + | 'arrow-random' + | 'arrow-right' + | 'arrow-up' | 'arrow' - | 'plus' - | 'heart' - | 'heart-break' - | 'ellipsis-v' - | 'favorite' - | 'line-alt' - | 'sort-amount-down' + | 'arrows-h' + | 'bars' + | 'bell-slash' + | 'bell' + | 'bolt' + | 'book-open' + | 'book' + | 'bug' + | 'calculator-alt' + | 'calendar-alt' + | 'camera' + | 'channel-add' + | 'chart-line' + | 'check-circle' + | 'check' + | 'circle' + | 'clipboard-alt' + | 'clock-nine' + | 'cloud-download' + | 'cloud-upload' | 'cloud' + | 'code-branch' + | 'cog' + | 'columns' + | 'comment-alt' + | 'comments-alt' + | 'compass' + | 'copy' + | 'cube' + | 'database' + | 'document-info' + | 'download-alt' | 'draggabledots' + | 'edit' + | 'ellipsis-v' + | 'envelope' + | 'exchange-alt' + | 'exclamation-triangle' + | 'external-link-alt' + | 'eye-slash' + | 'eye' + | 'fa fa-spinner' + | 'favorite' + | 'file-alt' + | 'file-blank' + | 'file-copy-alt' + | 'filter' + | 'folder-open' + | 'folder-plus' | 'folder-upload' - | 'palette' + | 'folder' + | 'forward' | 'gf-interpolation-linear' | 'gf-interpolation-smooth' - | 'gf-interpolation-step-before' | 'gf-interpolation-step-after' - | 'gf-logs'; + | 'gf-interpolation-step-before' + | 'gf-logs' + | 'grafana' + | 'graph-bar' + | 'heart-break' + | 'heart' + | 'history' + | 'home-alt' + | 'import' + | 'info-circle' + | 'key-skeleton-alt' + | 'keyboard' + | 'line-alt' + | 'link' + | 'list-ul' + | 'lock' + | 'minus-circle' + | 'minus' + | 'mobile-android' + | 'monitor' + | 'palette' + | 'panel-add' + | 'pause' + | 'pen' + | 'percentage' + | 'play' + | 'plug' + | 'plus-circle' + | 'plus-square' + | 'plus' + | 'power' + | 'question-circle' + | 'repeat' + | 'reusable-panel' + | 'rocket' + | 'save' + | 'search-minus' + | 'search-plus' + | 'search' + | 'share-alt' + | 'shield' + | 'sign-in-alt' + | 'signal' + | 'signin' + | 'signout' + | 'slack' + | 'sliders-v-alt' + | 'sort-amount-down' + | 'square-shape' + | 'star' + | 'step-backward' + | 'sync' + | 'table' + | 'tag-alt' + | 'times' + | 'trash-alt' + | 'unlock' + | 'upload' + | 'user' + | 'users-alt' + | 'wrap-text' + | 'x'; export const getAvailableIcons = (): IconName[] => [ - 'fa fa-spinner', - 'grafana', - 'question-circle', - 'angle-up', - 'history', + 'angle-double-down', + 'angle-double-right', 'angle-down', - 'filter', 'angle-left', 'angle-right', - 'angle-double-right', - 'angle-double-down', - 'pen', - 'envelope', - 'percentage', - 'rocket', - 'power', - 'trash-alt', - 'slack', - 'download-alt', - 'mobile-android', - 'plus-square', - 'folder-plus', - 'folder-open', - 'folder', - 'file-copy-alt', - 'file-alt', - 'exchange-alt', - 'import', - 'exclamation-triangle', - 'times', - 'signin', - 'cloud-upload', - 'step-backward', - 'square-shape', - 'share-alt', - 'tag-alt', - 'forward', - 'check', - 'check-circle', - 'copy', - 'lock', - 'unlock', - 'panel-add', - 'arrow-random', - 'arrow-down', - 'arrows-h', - 'comment-alt', - 'code-branch', - 'arrow-right', - 'circle', - 'arrow-up', - 'arrow-from-right', - 'keyboard', - 'search', - 'chart-line', - 'search-minus', - 'clock-nine', - 'sync', - 'sign-in-alt', - 'cloud-download', - 'cog', - 'bars', - 'save', + 'angle-up', 'apps', - 'link', - 'upload', - 'columns', - 'home-alt', - 'channel-add', - 'calendar-alt', - 'play', - 'pause', - 'calculator-alt', - 'compass', - 'sliders-v-alt', - 'bell', - 'bell-slash', - 'database', - 'user', - 'camera', - 'plug', - 'shield', - 'key-skeleton-alt', - 'users-alt', - 'graph-bar', - 'book', - 'bolt', - 'comments-alt', - 'document-info', - 'info-circle', - 'bug', - 'cube', - 'star', - 'list-ul', - 'edit', - 'eye', - 'eye-slash', - 'monitor', - 'plus-circle', + 'arrow-down', + 'arrow-from-right', 'arrow-left', - 'repeat', - 'external-link-alt', - 'minus', - 'signal', - 'search-plus', - 'minus-circle', + 'arrow-random', + 'arrow-right', + 'arrow-up', 'arrow', - 'table', - 'plus', - 'heart', - 'heart-break', - 'ellipsis-v', - 'favorite', - 'sort-amount-down', + 'arrows-h', + 'bars', + 'bell-slash', + 'bell', + 'bolt', + 'book-open', + 'book', + 'bug', + 'calculator-alt', + 'calendar-alt', + 'camera', + 'channel-add', + 'chart-line', + 'check-circle', + 'check', + 'circle', + 'clipboard-alt', + 'clock-nine', + 'cloud-download', + 'cloud-upload', 'cloud', + 'code-branch', + 'cog', + 'columns', + 'comment-alt', + 'comments-alt', + 'compass', + 'copy', + 'cube', + 'database', + 'document-info', + 'download-alt', 'draggabledots', + 'edit', + 'ellipsis-v', + 'envelope', + 'exchange-alt', + 'exclamation-triangle', + 'external-link-alt', + 'eye-slash', + 'eye', + 'fa fa-spinner', + 'favorite', + 'file-alt', + 'file-blank', + 'file-copy-alt', + 'filter', + 'folder-open', + 'folder-plus', 'folder-upload', - 'palette', + 'folder', + 'forward', 'gf-interpolation-linear', 'gf-interpolation-smooth', - 'gf-interpolation-step-before', 'gf-interpolation-step-after', + 'gf-interpolation-step-before', 'gf-logs', + 'grafana', + 'graph-bar', + 'heart-break', + 'heart', + 'history', + 'home-alt', + 'import', + 'info-circle', + 'key-skeleton-alt', + 'keyboard', + 'line-alt', + 'link', + 'list-ul', + 'lock', + 'minus-circle', + 'minus', + 'mobile-android', + 'monitor', + 'palette', + 'panel-add', + 'pause', + 'pen', + 'percentage', + 'play', + 'plug', + 'plus-circle', + 'plus-square', + 'plus', + 'power', + 'question-circle', + 'repeat', + 'reusable-panel', + 'rocket', + 'save', + 'search-minus', + 'search-plus', + 'search', + 'share-alt', + 'shield', + 'sign-in-alt', + 'signal', + 'signin', + 'signout', + 'slack', + 'sliders-v-alt', + 'sort-amount-down', + 'square-shape', + 'star', + 'step-backward', + 'sync', + 'table', + 'tag-alt', + 'times', + 'trash-alt', + 'unlock', + 'upload', + 'user', + 'users-alt', + 'wrap-text', + 'x', ]; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 56086599104..658b1c717e9 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -7,7 +7,8 @@ import { LegacyGraphHoverClearEvent, locationUtil } from '@grafana/data'; import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; import { getExploreUrl } from 'app/core/utils/explore'; -import { store } from 'app/store/store'; +import { dispatch, store } from 'app/store/store'; +import { exitPanelEditor } from 'app/features/dashboard/components/PanelEditor/state/actions'; import { AppEventEmitter, CoreEvents } from 'app/types'; import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; import { DashboardModel } from 'app/features/dashboard/state'; @@ -17,7 +18,6 @@ import { defaultQueryParams } from 'app/features/search/reducers/searchQueryRedu import { ContextSrv } from './context_srv'; export class KeybindingSrv { - helpModal: boolean; modalOpen = false; /** @ngInject */ @@ -129,9 +129,7 @@ export class KeybindingSrv { } if (search.editPanel) { - delete search.editPanel; - delete search.tab; - this.$location.search(search); + dispatch(exitPanelEditor()); return; } diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts index ecc8784de6b..9b339560f0a 100644 --- a/public/app/core/utils/fetch.ts +++ b/public/app/core/utils/fetch.ts @@ -53,8 +53,9 @@ const parseHeaderByMethodFactory = (methodPredicate: string): HeaderParser => ({ const postHeaderParser: HeaderParser = parseHeaderByMethodFactory('post'); const putHeaderParser: HeaderParser = parseHeaderByMethodFactory('put'); +const patchHeaderParser: HeaderParser = parseHeaderByMethodFactory('patch'); -const headerParsers = [postHeaderParser, putHeaderParser, defaultHeaderParser]; +const headerParsers = [postHeaderParser, putHeaderParser, patchHeaderParser, defaultHeaderParser]; export const parseHeaders = (options: BackendSrvRequest) => { const headers = options?.headers ? new Headers(options.headers) : new Headers(); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index ccd93ee8a48..9216c2e3c76 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -1,22 +1,21 @@ -// Libraries -import React, { useMemo } from 'react'; -import _ from 'lodash'; -import { LocationUpdate } from '@grafana/runtime'; -import { Button, HorizontalGroup, IconButton, stylesFactory, useTheme } from '@grafana/ui'; -import { selectors } from '@grafana/e2e-selectors'; +import React, { useMemo, useState } from 'react'; import { connect, MapDispatchToProps } from 'react-redux'; -// Utils +import { css, cx, keyframes } from 'emotion'; +import _ from 'lodash'; +import tinycolor from 'tinycolor2'; +import { LocationUpdate } from '@grafana/runtime'; +import { Icon, IconButton, styleMixins, stylesFactory, useStyles, useTheme } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { DateTimeInput, GrafanaTheme } from '@grafana/data'; + import config from 'app/core/config'; import store from 'app/core/store'; -// Store import { updateLocation } from 'app/core/actions'; import { addPanel } from 'app/features/dashboard/state/reducers'; -// Types import { DashboardModel, PanelModel } from '../../state'; +import { LibraryPanelsView } from '../../../library-panels/components/LibraryPanelsView/LibraryPanelsView'; +import { LibraryPanelDTO } from 'app/features/library-panels/state/api'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; -import { css, cx, keyframes } from 'emotion'; -import { GrafanaTheme } from '@grafana/data'; -import tinycolor from 'tinycolor2'; export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } }; @@ -55,18 +54,22 @@ const getCopiedPanelPlugins = () => { return _.sortBy(copiedPanels, 'sort'); }; -export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard, updateLocation, addPanel }) => { - const theme = useTheme(); +export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard, updateLocation }) => { + const [addPanelView, setAddPanelView] = useState(false); - const onCancelAddPanel = (evt: any) => { + const onCancelAddPanel = (evt: React.MouseEvent) => { evt.preventDefault(); dashboard.removePanel(panel); }; + const onBack = () => { + setAddPanelView(false); + }; + const onCreateNewPanel = () => { const { gridPos } = panel; - const newPanel: any = { + const newPanel: Partial = { type: 'graph', title: 'Panel Title', gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h }, @@ -110,6 +113,19 @@ export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard, u dashboard.removePanel(panel); }; + const onAddLibraryPanel = (panelInfo: LibraryPanelDTO) => { + const { gridPos } = panel; + + const newPanel: PanelModel = { + ...panelInfo.model, + gridPos, + libraryPanel: _.pick(panelInfo, 'name', 'uid', 'meta'), + }; + + dashboard.addPanel(newPanel); + dashboard.removePanel(panel); + }; + const onCreateNewRow = () => { const newRow: any = { type: 'row', @@ -121,62 +137,78 @@ export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard, u dashboard.removePanel(panel); }; - const styles = getStyles(theme); + const styles = useStyles(getStyles); + const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []); + return (
- -
- -
- - - + + {addPanelView ? 'Add panel from panel library' : 'Add panel'} + + {addPanelView ? ( + dashboard.formatDate(dateString, 'L')} + onClickCard={(panel) => onAddLibraryPanel(panel)} + showSecondaryActions={false} + /> + ) : ( +
+
+
onCreateNewPanel()} aria-label={selectors.pages.AddDashboard.addNewPanel}> + + Add an empty panel +
+
+ + Add a new row +
+
+ {(config.featureToggles.panelLibrary || copiedPanelPlugins.length === 1) && ( +
+ {config.featureToggles.panelLibrary && ( +
setAddPanelView(true)}> + + Add a panel from the panel library +
+ )} + {copiedPanelPlugins.length === 1 && ( +
onPasteCopiedPanel(copiedPanelPlugins[0])}> + + Paste panel from clipboard +
+ )} +
+ )}
-
+ )}
); }; const mapDispatchToProps: MapDispatchToProps = { addPanel, updateLocation }; -export const AddPanelWidget = connect(null, mapDispatchToProps)(AddPanelWidgetUnconnected); +export const AddPanelWidget = connect(undefined, mapDispatchToProps)(AddPanelWidgetUnconnected); interface AddPanelWidgetHandleProps { onCancel: (e: React.MouseEvent) => void; + onBack?: () => void; + children?: string; } -const AddPanelWidgetHandle: React.FC = ({ onCancel }) => { + +const AddPanelWidgetHandle: React.FC = ({ children, onBack, onCancel }) => { const theme = useTheme(); const styles = getAddPanelWigetHandleStyles(theme); return (
- -
- ); -}; - -interface AddPanelWidgetCreateProps { - onCreate: () => void; - onPasteCopiedPanel: (panelPluginInfo: PanelPluginInfo) => void; -} - -const AddPanelWidgetCreate: React.FC = ({ onCreate, onPasteCopiedPanel }) => { - const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []); - const theme = useTheme(); - const styles = getAddPanelWidgetCreateStyles(theme); - return ( -
- - - {copiedPanelPlugins.length === 1 && ( - +
+ {onBack && ( + )} - +
+ + {children && {children}} +
); }; @@ -197,12 +229,49 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4; animation: ${pulsate} 2s ease infinite; `, + actionsRow: css` + display: flex; + flex-direction: row; + column-gap: ${theme.spacing.sm}; + height: 100%; + + > div { + justify-self: center; + cursor: pointer; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.sm}; + color: ${theme.colors.text}; + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + + &:hover { + background: ${styleMixins.hoverColor(theme.colors.bg2, theme)}; + } + + &:hover > #book-icon { + background: linear-gradient(#f05a28 30%, #fbca0a 99%); + } + } + `, actionsWrapper: css` display: flex; flex-direction: column; - align-items: center; + row-gap: ${theme.spacing.sm}; height: 100%; - justify-content: center; + margin-left: ${theme.spacing.xl}; + margin-right: ${theme.spacing.xl}; + margin-top: ${theme.spacing.base * 4}px; + margin-bottom: ${theme.spacing.base * 5}px; + `, + buttonMargin: css` + margin-right: ${theme.spacing.sm}; + `, + libraryPanelsWrapper: css` + padding: ${theme.spacing.base * 4}px ${theme.spacing.xl}; `, }; }); @@ -211,37 +280,33 @@ const getAddPanelWigetHandleStyles = stylesFactory((theme: GrafanaTheme) => { return { handle: css` position: absolute; - cursor: grab; - top: 0; - left: 0; - height: 26px; - padding: 0 ${theme.spacing.xs}; + display: flex; + align-items: center; + height: ${theme.spacing.gutter}; width: 100%; - display: flex; - justify-content: flex-end; - align-items: center; + font-size: ${theme.typography.size.md}; + font-weight: ${theme.typography.weight.semibold}; transition: background-color 0.1s ease-in-out; + cursor: move; &:hover { background: ${theme.colors.bg2}; } `, - }; -}); - -const getAddPanelWidgetCreateStyles = stylesFactory((theme: GrafanaTheme) => { - return { - wrapper: css` - cursor: pointer; + pushRight: css` + margin-left: auto; + `, + leftPad: css` + padding-left: ${theme.spacing.xl}; + `, + backButton: css` display: flex; - flex-direction: column; align-items: center; - margin-bottom: ${theme.spacing.lg}; - &:hover { - background: ${theme.colors.bg2}; - } + cursor: pointer; + padding-left: ${theme.spacing.xs}; + width: ${theme.spacing.xl}; `, - icon: css` - color: ${theme.colors.textWeak}; + noMargin: css` + margin: 0; `, }; }); diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap index 6e7cc12cdb7..f9a203cf21a 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -6,26 +6,34 @@ exports[`Render should render component 1`] = ` > -
- -
- +
+
+
- - + + Add an empty panel +
+
+ + Add a new row +
diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 69c5d737ae3..9709912423e 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -6,7 +6,14 @@ import { Subscription } from 'rxjs'; import { FieldConfigSource, GrafanaTheme } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { HorizontalGroup, PageToolbar, RadioButtonGroup, stylesFactory, ToolbarButton } from '@grafana/ui'; +import { + HorizontalGroup, + ModalsController, + PageToolbar, + RadioButtonGroup, + stylesFactory, + ToolbarButton, +} from '@grafana/ui'; import config from 'app/core/config'; import { appEvents } from 'app/core/core'; @@ -20,7 +27,13 @@ import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPane import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy'; import { DashboardPanel } from '../../dashgrid/DashboardPanel'; -import { initPanelEditor, panelEditorCleanUp, updatePanelEditorUIState } from './state/actions'; +import { + exitPanelEditor, + updateSourcePanel, + initPanelEditor, + panelEditorCleanUp, + updatePanelEditorUIState, +} from './state/actions'; import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { updateLocation } from 'app/core/reducers/location'; @@ -34,6 +47,8 @@ import { CoreEvents, StoreState } from 'app/types'; import { DisplayMode, displayModes, PanelEditorTab } from './types'; import { DashboardModel, PanelModel } from '../../state'; import { PanelOptionsChangedEvent } from 'app/types/events'; +import { UnlinkModal } from '../../../library-panels/components/UnlinkModal/UnlinkModal'; +import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal'; interface OwnProps { dashboard: DashboardModel; @@ -58,6 +73,8 @@ const mapStateToProps = (state: StoreState) => { const mapDispatchToProps = { updateLocation, initPanelEditor, + exitPanelEditor, + updateSourcePanel, panelEditorCleanUp, setDiscardChanges, updatePanelEditorUIState, @@ -93,13 +110,6 @@ export class PanelEditorUnconnected extends PureComponent { this.forceUpdate(); }; - onPanelExit = () => { - this.props.updateLocation({ - query: { editPanel: null, tab: null }, - partial: true, - }); - }; - onDiscard = () => { this.props.setDiscardChanges(true); this.props.updateLocation({ @@ -119,6 +129,28 @@ export class PanelEditorUnconnected extends PureComponent { }); }; + onSavePanel = () => { + const panelId = this.props.panel.libraryPanel?.uid; + if (!panelId) { + // New library panel, no need to display modal + return; + } + + appEvents.emit(CoreEvents.showModalReact, { + component: SaveLibraryPanelModal, + props: { + panel: this.props.panel, + folderId: this.props.dashboard.meta.folderId, + isOpen: true, + onConfirm: () => { + // need to update the source panel here so that when + // the user exits the panel editor they aren't prompted to save again + this.props.updateSourcePanel(this.props.panel); + }, + }, + }); + }; + onChangeTab = (tab: PanelEditorTab) => { this.props.updateLocation({ query: { tab: tab.id }, partial: true }); }; @@ -137,14 +169,14 @@ export class PanelEditorUnconnected extends PureComponent { this.props.panel.updateOptions(options); }; - onPanelConfigChanged = (configKey: string, value: any) => { + onPanelConfigChanged = (configKey: keyof PanelModel, value: any) => { // @ts-ignore this.props.panel[configKey] = value; this.props.panel.render(); this.forceUpdate(); }; - onDisplayModeChange = (mode: DisplayMode) => { + onDisplayModeChange = (mode?: DisplayMode) => { const { updatePanelEditorUIState } = this.props; updatePanelEditorUIState({ mode: mode, @@ -245,7 +277,7 @@ export class PanelEditorUnconnected extends PureComponent { } renderEditorActions() { - return [ + let editorActions = [ { Discard , - - Save - , + this.props.panel.libraryPanel ? ( + + Save library panel + + ) : ( + + Save + + ), { Apply , ]; + + if (this.props.panel.libraryPanel) { + editorActions.splice( + 1, + 0, + + {({ showModal, hideModal }) => { + return ( + { + showModal(UnlinkModal, { + onConfirm: () => { + delete this.props.panel.libraryPanel; + this.props.panel.render(); + this.forceUpdate(); + }, + onDismiss: hideModal, + isOpen: true, + }); + }} + title="Disconnects this panel from the reusable panel so that you can edit it regularly." + key="unlink" + > + Unlink + + ); + }} + + ); + + // Remove "Apply" button + editorActions.pop(); + } + + return editorActions; } renderOptionsPane() { @@ -305,7 +383,7 @@ export class PanelEditorUnconnected extends PureComponent { return (
- + {this.renderEditorActions()}
diff --git a/public/app/features/dashboard/components/PanelEditor/PanelOptionsTab.tsx b/public/app/features/dashboard/components/PanelEditor/PanelOptionsTab.tsx index 6ddf51c526c..178162ea8c6 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelOptionsTab.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelOptionsTab.tsx @@ -8,6 +8,10 @@ import { AngularPanelOptions } from './AngularPanelOptions'; import { VisualizationTab } from './VisualizationTab'; import { OptionsGroup } from './OptionsGroup'; import { RepeatRowSelect } from '../RepeatRowSelect/RepeatRowSelect'; +import config from 'app/core/config'; +import { LibraryPanelInformation } from 'app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo'; +import { isLibraryPanel } from '../../state/PanelModel'; +import { PanelLibraryOptionsGroup } from 'app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup'; interface Props { panel: PanelModel; @@ -46,7 +50,18 @@ export const PanelOptionsTab: FC = ({ visTabInputRef.current.focus(); } }; - // Fist common panel settings Title, description + + if (config.featureToggles.panelLibrary && isLibraryPanel(panel)) { + elements.push( + dashboard.formatDate(dateString, format)} + key="Library Panel Information" + /> + ); + } + + // First common panel settings Title, description elements.push( @@ -152,5 +167,9 @@ export const PanelOptionsTab: FC = ({ ); + if (config.featureToggles.panelLibrary) { + elements.push(); + } + return <>{elements}; }; diff --git a/public/app/features/dashboard/components/PanelEditor/state/actions.ts b/public/app/features/dashboard/components/PanelEditor/state/actions.ts index 4fe5f5290c6..571e0f56bb8 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/actions.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/actions.ts @@ -1,5 +1,7 @@ import { DashboardModel, PanelModel } from '../../../state'; -import { ThunkResult } from 'app/types'; +import { CoreEvents, ThunkResult } from 'app/types'; +import { appEvents } from 'app/core/core'; +import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal'; import { closeCompleted, PANEL_EDITOR_UI_STATE_STORAGE_KEY, @@ -7,8 +9,12 @@ import { setPanelEditorUIState, updateEditorInitState, } from './reducers'; +import { updateLocation } from 'app/core/actions'; import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers'; import store from 'app/core/store'; +import pick from 'lodash/pick'; +import omit from 'lodash/omit'; +import isEqual from 'lodash/isEqual'; export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult { return (dispatch) => { @@ -23,6 +29,84 @@ export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardMod }; } +export function updateSourcePanel(sourcePanel: PanelModel): ThunkResult { + return (dispatch, getStore) => { + const { getPanel } = getStore().panelEditor; + + dispatch( + updateEditorInitState({ + panel: getPanel(), + sourcePanel, + }) + ); + }; +} + +export function exitPanelEditor(): ThunkResult { + return (dispatch, getStore) => { + const dashboard = getStore().dashboard.getModel(); + const { getPanel, getSourcePanel, shouldDiscardChanges } = getStore().panelEditor; + const onConfirm = () => + dispatch( + updateLocation({ + query: { editPanel: null, tab: null }, + partial: true, + }) + ); + + const modifiedPanel = getPanel(); + const modifiedSaveModel = modifiedPanel.getSaveModel(); + const initialSaveModel = getSourcePanel().getSaveModel(); + const panelChanged = !isEqual(omit(initialSaveModel, 'id'), omit(modifiedSaveModel, 'id')); + if (shouldDiscardChanges || !modifiedPanel.libraryPanel || !panelChanged) { + onConfirm(); + return; + } + + appEvents.emit(CoreEvents.showModalReact, { + component: SaveLibraryPanelModal, + props: { + panel: modifiedPanel, + folderId: dashboard!.meta.folderId, + isOpen: true, + onConfirm, + }, + }); + }; +} + +function updateDuplicateLibraryPanels(modifiedPanel: PanelModel, dashboard: DashboardModel, dispatch: any) { + if (modifiedPanel.libraryPanel?.uid === undefined) { + return; + } + + const modifiedSaveModel = modifiedPanel.getSaveModel(); + for (const panel of dashboard.panels) { + if (panel.libraryPanel?.uid !== modifiedPanel.libraryPanel!.uid) { + continue; + } + + panel.restoreModel({ + ...modifiedSaveModel, + ...pick(panel, 'gridPos', 'id'), + }); + + // Loaded plugin is not included in the persisted properties + // So is not handled by restoreModel + panel.plugin = modifiedSaveModel.plugin; + + if (panel.type !== modifiedPanel.type) { + dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin: panel.plugin! })); + } + + // Resend last query result on source panel query runner + // But do this after the panel edit editor exit process has completed + setTimeout(() => { + panel.getQueryRunner().useLastResultFrom(modifiedPanel.getQueryRunner()); + }, 20); + } +} + export function panelEditorCleanUp(): ThunkResult { return (dispatch, getStore) => { const dashboard = getStore().dashboard.getModel(); @@ -34,6 +118,8 @@ export function panelEditorCleanUp(): ThunkResult { const sourcePanel = getSourcePanel(); const panelTypeChanged = sourcePanel.type !== panel.type; + updateDuplicateLibraryPanels(panel, dashboard!, dispatch); + // restore the source panel id before we update source panel modifiedSaveModel.id = sourcePanel.id; diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index 0f14dc4e28e..4ddaf45867a 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -9,12 +9,13 @@ import { updateLocation } from 'app/core/reducers/location'; import { DashboardModel } from 'app/features/dashboard/state'; import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions'; -const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => { +const saveDashboard = (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => { let folderId = options.folderId; if (folderId === undefined) { - folderId = dashboard.meta.folderId || saveModel.folderId; + folderId = dashboard.meta.folderId ?? saveModel.folderId; } - return await saveDashboardApiCall({ ...options, folderId, dashboard: saveModel }); + + return saveDashboardApiCall({ ...options, folderId, dashboard: saveModel }); }; export const useDashboardSave = (dashboard: DashboardModel) => { diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 540f1fdeff2..4527711259b 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -48,6 +48,7 @@ export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, dat return (
+ {panel.libraryPanel && } {alertState ? ( ; + // non persisted isViewing: boolean; isEditing: boolean; @@ -531,6 +535,10 @@ function getPluginVersion(plugin: PanelPlugin): string { return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version; } +export function isLibraryPanel(panel: PanelModel): panel is PanelModel & Required> { + return panel.libraryPanel !== undefined; +} + interface PanelOptionsCache { properties: any; fieldConfig: FieldConfigSource; diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts index cbc6077384d..bed7a6d8865 100644 --- a/public/app/features/dashboard/utils/panel.ts +++ b/public/app/features/dashboard/utils/panel.ts @@ -46,7 +46,12 @@ export const duplicatePanel = (dashboard: DashboardModel, panel: PanelModel) => }; export const copyPanel = (panel: PanelModel) => { - store.set(LS_PANEL_COPY_KEY, JSON.stringify(panel.getSaveModel())); + let saveModel = panel; + if (panel instanceof PanelModel) { + saveModel = panel.getSaveModel(); + } + + store.set(LS_PANEL_COPY_KEY, JSON.stringify(saveModel)); appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Open Add Panel to paste']); }; diff --git a/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx new file mode 100644 index 00000000000..85ccaa308f2 --- /dev/null +++ b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Button, Field, Input, Modal, useStyles } from '@grafana/ui'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { PanelModel } from '../../../dashboard/state'; +import { css } from 'emotion'; +import { usePanelSave } from '../../utils/usePanelSave'; + +interface Props { + onDismiss: () => void; + isOpen?: boolean; + panel: PanelModel; + initialFolderId?: number; +} + +export const AddLibraryPanelModal: React.FC = ({ isOpen = false, panel, initialFolderId, ...props }) => { + const styles = useStyles(getStyles); + const [folderId, setFolderId] = useState(initialFolderId); + const [panelTitle, setPanelTitle] = useState(panel.title); + const { saveLibraryPanel } = usePanelSave(); + + return ( + + + setPanelTitle(e.currentTarget.value)} /> + + + setFolderId(id)} initialFolderId={initialFolderId} /> + + +
+ + +
+
+ ); +}; + +const getStyles = () => ({ + buttons: css` + display: flex; + gap: 10px; + `, +}); diff --git a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx new file mode 100644 index 00000000000..a3cfd0333ce --- /dev/null +++ b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { Icon, IconButton, stylesFactory, ConfirmModal, Tooltip, useStyles, Card } from '@grafana/ui'; +import { css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { LibraryPanelDTO } from '../../state/api'; + +export interface LibraryPanelCardProps { + libraryPanel: LibraryPanelDTO; + onClick?: (panel: LibraryPanelDTO) => void; + onDelete?: () => void; + showSecondaryActions?: boolean; + formatDate?: (dateString: string) => string; +} + +export const LibraryPanelCard: React.FC = ({ + libraryPanel, + children, + onClick, + onDelete, + formatDate, + showSecondaryActions, +}) => { + const styles = useStyles(getStyles); + const [showDeletionModal, setShowDeletionModal] = useState(false); + + const onDeletePanel = () => { + onDelete?.(); + setShowDeletionModal(false); + }; + + return ( + <> + onClick(libraryPanel) : undefined}> + + + + + Reusable panel + +
+ + {libraryPanel.meta.connectedDashboards} +
+
+ + {/* + Commenting this out as obtaining the number of variables used by a panel + isn't implemetned yet. + +
+ + {varCount} +
+
*/} + + + Last edited {formatDate?.(libraryPanel.meta.updated ?? '') ?? libraryPanel.meta.updated} by{' '} + {libraryPanel.meta.updatedBy.name} + +
+ {/* + Commenting this out as tagging isn't implemented yet. + + + */} + {children && {children}} + {showSecondaryActions && ( + + setShowDeletionModal(true)} + /> + {/* + Commenting this out as panel favoriting hasn't been implemented yet. + + */} + + )} +
+ {showDeletionModal && ( + setShowDeletionModal(false)} + /> + )} + + ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + tooltip: css` + display: inline; + `, + detailIcon: css` + margin-right: 0.5ch; + `, + panelIcon: css` + margin-right: ${theme.spacing.md}; + `, + tagList: css` + align-self: center; + `, + }; +}); diff --git a/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx b/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx new file mode 100644 index 00000000000..d9f0c028322 --- /dev/null +++ b/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx @@ -0,0 +1,55 @@ +import { DateTimeInput, GrafanaTheme } from '@grafana/data'; +import { stylesFactory, useStyles } from '@grafana/ui'; +import { OptionsGroup } from 'app/features/dashboard/components/PanelEditor/OptionsGroup'; +import { PanelModel } from 'app/features/dashboard/state'; +import { css } from 'emotion'; +import React from 'react'; + +interface Props { + panel: PanelModel & Required>; + formatDate?: (dateString: DateTimeInput, format?: string) => string; +} + +export const LibraryPanelInformation: React.FC = ({ panel, formatDate }) => { + const styles = useStyles(getStyles); + + return ( + + {panel.libraryPanel.uid && ( +

+ {`Used on ${panel.libraryPanel.meta.connectedDashboards} `} + {panel.libraryPanel.meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'} +
+ Last edited on {formatDate?.(panel.libraryPanel.meta.updated, 'L') ?? panel.libraryPanel.meta.updated} by + {panel.libraryPanel.meta.updatedBy.avatarUrl && ( + {`Avatar + )} + {panel.libraryPanel.meta.updatedBy.name} +

+ )} +
+ ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + libraryPanelInfo: css` + color: ${theme.colors.textSemiWeak}; + font-size: ${theme.typography.size.sm}; + `, + userAvatar: css` + border-radius: 50%; + box-sizing: content-box; + width: 22px; + height: 22px; + padding-left: ${theme.spacing.sm}; + padding-right: ${theme.spacing.sm}; + `, + }; +}); diff --git a/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx b/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx new file mode 100644 index 00000000000..2aca3ffafc8 --- /dev/null +++ b/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx @@ -0,0 +1,120 @@ +import { Icon, Input, Button, stylesFactory, useStyles } from '@grafana/ui'; +import React, { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; +import { cx, css } from 'emotion'; +import { LibraryPanelCard } from '../LibraryPanelCard/LibraryPanelCard'; +import { DateTimeInput, GrafanaTheme } from '@grafana/data'; +import { deleteLibraryPanel, getLibraryPanels, LibraryPanelDTO } from '../../state/api'; + +interface LibraryPanelViewProps { + className?: string; + onCreateNewPanel?: () => void; + children?: (panel: LibraryPanelDTO, i: number) => JSX.Element | JSX.Element[]; + onClickCard?: (panel: LibraryPanelDTO) => void; + formatDate?: (dateString: DateTimeInput, format?: string) => string; + showSecondaryActions?: boolean; +} + +export const LibraryPanelsView: React.FC = ({ + children, + className, + onCreateNewPanel, + onClickCard, + formatDate, + showSecondaryActions, +}) => { + const styles = useStyles(getPanelViewStyles); + const [searchString, setSearchString] = useState(''); + + // Deliberately not using useAsync here as we want to be able to update libraryPanels without + // making an additional API request (for example when a user deletes a library panel and we want to update the view to reflect that) + const [libraryPanels, setLibraryPanels] = useState(undefined); + useEffect(() => { + getLibraryPanels().then((panels) => { + setLibraryPanels(panels); + }); + }, []); + + const [filteredItems, setFilteredItems] = useState(libraryPanels); + useDebounce( + () => { + setFilteredItems(libraryPanels?.filter((v) => v.name.toLowerCase().includes(searchString.toLowerCase()))); + }, + 300, + [searchString, libraryPanels] + ); + + const onDeletePanel = async (uid: string) => { + try { + await deleteLibraryPanel(uid); + const panelIndex = libraryPanels!.findIndex((panel) => panel.uid === uid); + setLibraryPanels([...libraryPanels!.slice(0, panelIndex), ...libraryPanels!.slice(panelIndex + 1)]); + } catch (err) { + throw err; + } + }; + + return ( +
+ Popular panels from the panel library +
+ } + value={searchString} + onChange={(e) => setSearchString(e.currentTarget.value)} + > + {/* } + placeholder="Search affected dashboards" + value={searchString} + onChange={(e) => setSearchString(e.currentTarget.value)} + /> + {dashState.loading ? ( +

Loading connected dashboards...

+ ) : ( + + + + + + + + {filteredDashboards.map((dashName, i) => ( + + + + ))} + +
Dashboard name
{dashName}
+ )} + + + + +
+ + ); +}; + +const getModalStyles = stylesFactory((theme: GrafanaTheme) => { + return { + myTable: css` + max-height: 204px; + overflow-y: auto; + margin-top: 11px; + margin-bottom: 28px; + border-radius: ${theme.border.radius.sm}; + border: 1px solid ${theme.colors.bg3}; + background: ${theme.colors.bg1}; + color: ${theme.colors.textSemiWeak}; + font-size: ${theme.typography.size.md}; + width: 100%; + + thead { + color: #538ade; + font-size: ${theme.typography.size.sm}; + } + + th, + td { + padding: 6px 13px; + height: ${theme.spacing.xl}; + } + + tbody > tr:nth-child(odd) { + background: ${theme.colors.bg2}; + } + `, + noteTextbox: css` + margin-bottom: ${theme.spacing.xl}; + `, + textInfo: css` + color: ${theme.colors.textSemiWeak}; + font-size: ${theme.typography.size.sm}; + `, + dashboardSearch: css` + margin-top: ${theme.spacing.md}; + `, + }; +}); diff --git a/public/app/features/library-panels/components/UnlinkModal/UnlinkModal.tsx b/public/app/features/library-panels/components/UnlinkModal/UnlinkModal.tsx new file mode 100644 index 00000000000..ad342d8f83b --- /dev/null +++ b/public/app/features/library-panels/components/UnlinkModal/UnlinkModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ConfirmModal } from '@grafana/ui'; + +interface Props { + isOpen: boolean; + onConfirm: () => void; + onDismiss: () => void; +} + +export const UnlinkModal: React.FC = ({ isOpen, onConfirm, onDismiss }) => { + return ( + { + onConfirm(); + onDismiss(); + }} + onDismiss={onDismiss} + isOpen={isOpen} + /> + ); +}; diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index 15ef3907bca..1059d7d3248 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -12,6 +12,7 @@ export interface LibraryPanelDTO { export interface LibraryPanelDTOMeta { canEdit: boolean; + connectedDashboards: number; created: string; updated: string; createdBy: LibraryPanelDTOMetaUser; diff --git a/public/app/features/library-panels/utils/usePanelSave.ts b/public/app/features/library-panels/utils/usePanelSave.ts new file mode 100644 index 00000000000..5ce40b3a04c --- /dev/null +++ b/public/app/features/library-panels/utils/usePanelSave.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { AppEvents } from '@grafana/data'; +import appEvents from 'app/core/app_events'; +import { PanelModel } from 'app/features/dashboard/state'; +import { addLibraryPanel, updateLibraryPanel } from '../state/api'; + +const saveLibraryPanels = (panel: any, folderId: number) => { + if (!panel.libraryPanel) { + return Promise.reject(); + } + + if (panel.libraryPanel && panel.libraryPanel.uid === undefined) { + panel.libraryPanel.name = panel.title; + return addLibraryPanel(panel, folderId!); + } + + return updateLibraryPanel(panel, folderId!); +}; + +export const usePanelSave = () => { + const [state, saveLibraryPanel] = useAsyncFn(async (panel: PanelModel, folderId: number) => { + let panelSaveModel = panel.getSaveModel(); + panelSaveModel = { + libraryPanel: { + name: panel.title, + uid: undefined, + }, + ...panelSaveModel, + }; + const savedPanel = await saveLibraryPanels(panelSaveModel, folderId); + panel.restoreModel({ + ...savedPanel.model, + libraryPanel: { + uid: savedPanel.uid, + name: savedPanel.name, + meta: savedPanel.meta, + }, + }); + panel.refresh(); + return savedPanel; + }, []); + + useEffect(() => { + if (state.error) { + appEvents.emit(AppEvents.alertError, [`Error saving library panel: "${state.error.message}"`]); + } + if (state.value) { + appEvents.emit(AppEvents.alertSuccess, ['Library panel saved']); + } + }, [state]); + + return { state, saveLibraryPanel }; +}; diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 0f922a8214b..79f09071f85 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -23,7 +23,7 @@ export interface ShowModalPayload { } export interface ShowModalReactPayload { - component: React.ComponentType; + component: React.ComponentType; props?: any; }