Frontend changes for library panels feature (#30653)

Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
This commit is contained in:
kay delaney 2021-02-25 10:26:28 +00:00 committed by GitHub
parent 0bb4cbdb68
commit 47d2a8085b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1326 additions and 362 deletions

View File

@ -319,13 +319,19 @@ const Meta: FC<ChildProps & { separator?: string }> = memo(({ children, styles,
Meta.displayName = 'Meta';
interface ActionsProps extends ChildProps {
children: JSX.Element[];
children: JSX.Element | JSX.Element[];
variant?: 'primary' | 'secondary';
}
const BaseActions: FC<ActionsProps> = ({ children, styles, disabled, variant }) => {
const css = variant === 'primary' ? styles?.actions : styles?.secondaryActions;
return <div className={css}>{React.Children.map(children, (child) => cloneElement(child, { disabled }))}</div>;
return (
<div className={css}>
{Array.isArray(children)
? React.Children.map(children, (child) => cloneElement(child, { disabled }))
: cloneElement(children, { disabled })}
</div>
);
};
const Actions: FC<ActionsProps> = ({ children, styles, disabled }) => {

View File

@ -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<HTMLDivElement> {
name: IconName;
@ -36,7 +36,7 @@ const getIconStyles = stylesFactory((theme: GrafanaTheme) => {
};
});
function getIconComponent(name: string, type: string): ComponentType<SvgProps> {
function getIconComponent(name: IconName, type: string): ComponentType<SvgProps> {
if (alwaysMonoIcons.includes(name)) {
type = 'mono';
}

View File

@ -0,0 +1,13 @@
import React, { FunctionComponent } from 'react';
import { SvgProps } from './types';
export const ReusablePanel: FunctionComponent<SvgProps> = ({ size, ...rest }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width={size} height={size} {...rest}>
<path
d="M14.3 8.7H9.4c-.18565 0-.3637.07375-.49497.20503C8.77375 9.0363 8.7 9.21435 8.7 9.4v4.9c0 .1857.07375.3637.20503.495.13127.1313.30932.205.49497.205h4.9c.1857 0 .3637-.0737.495-.205.1313-.1313.205-.3093.205-.495V9.4c0-.18565-.0737-.3637-.205-.49497C14.6637 8.77375 14.4857 8.7 14.3 8.7zm-.7 4.9h-3.5v-3.5h3.5v3.5zM6.6 1H1.7c-.18565 0-.3637.07375-.49497.20503C1.07375 1.3363 1 1.51435 1 1.7v4.9c0 .18565.07375.3637.20503.49497C1.3363 7.22625 1.51435 7.3 1.7 7.3h4.9c.18565 0 .3637-.07375.49497-.20503C7.22625 6.9637 7.3 6.78565 7.3 6.6V1.7c0-.18565-.07375-.3637-.20503-.49497C6.9637 1.07375 6.78565 1 6.6 1zm-.7 4.9H2.4V2.4h3.5v3.5zM13.9 6.97633c-.0425-.01764-.0816-.04176-.1155-.07137L12.15 5.40298c-.0573-.06206-.0873-.14189-.0839-.22353.0034-.08165.0399-.1591.1023-.21687.0623-.05777.1458-.09162.2339-.09477.0881-.00316.1742.02461.2412.07776l1.022.94725V3.62201c0-.25811-.1106-.50565-.3075-.68816-.197-.18251-.464-.28505-.7425-.28505H9.35c-.09283 0-.18185-.03417-.24749-.09501C9.03687 2.49295 9 2.41044 9 2.3244c0-.08603.03687-.16855.10251-.22938C9.16815 2.03418 9.25717 2 9.35 2h3.2795c.4641 0 .9092.17089 1.2374.47508.3282.30418.5126.71675.5126 1.14693v2.27081l1.022-.94725c.0325-.03041.0712-.05454.1139-.07101.0426-.01647.0884-.02495.1346-.02495.0462 0 .0919.00848.1346.02495.0426.01647.0814.0406.1139.07101.0324.03031.0581.06626.0755.10579.0174.03952.0263.08184.026.12454-.0004.0851-.0368.16665-.1015.22708L14.278 6.90496c-.033.0307-.0722.05496-.1155.07137-.0842.03156-.1783.03156-.2625 0zM2.1 9.02367c.04254.01764.08158.04176.1155.07137L3.85 10.597c.05734.0621.0873.1419.0839.2236-.0034.0816-.03992.159-.10225.2168-.06233.0578-.14589.0916-.23398.0948-.08808.0031-.17421-.0246-.24117-.0778l-1.022-.9472v2.2708c0 .2581.11063.5056.30754.6882.19691.1825.46399.285.74246.285H6.65c.09283 0 .18185.0342.24749.095.06564.0608.10251.1434.10251.2294s-.03687.1685-.10251.2294c-.06564.0608-.15466.095-.24749.095H3.3705c-.46413 0-.90924-.1709-1.23743-.4751s-.51257-.7167-.51257-1.1469v-2.2708l-1.021995.9472c-.032537.0304-.071247.0546-.113897.071-.042651.0165-.088398.025-.134602.025-.046204 0-.091951-.0085-.134602-.025-.042651-.0164-.081361-.0406-.113898-.071-.032438-.0303-.0581023-.0662-.0755198-.1058-.01741788-.0395-.02624652-.0818-.02597996-.1245.00038671-.0851.03683946-.1667.10149976-.2271L1.722 9.09504c.03298-.0307.07225-.05496.1155-.07137.08419-.03156.17832-.03156.2625 0z"
fill="#C7D0D9"
/>
</svg>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Props> = ({ panel, dashboard, updateLocation, addPanel }) => {
const theme = useTheme();
export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard, updateLocation }) => {
const [addPanelView, setAddPanelView] = useState(false);
const onCancelAddPanel = (evt: any) => {
const onCancelAddPanel = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
dashboard.removePanel(panel);
};
const onBack = () => {
setAddPanelView(false);
};
const onCreateNewPanel = () => {
const { gridPos } = panel;
const newPanel: any = {
const newPanel: Partial<PanelModel> = {
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<Props> = ({ 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<Props> = ({ panel, dashboard, u
dashboard.removePanel(panel);
};
const styles = getStyles(theme);
const styles = useStyles(getStyles);
const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []);
return (
<div className={cx('panel-container', styles.wrapper)}>
<AddPanelWidgetHandle onCancel={onCancelAddPanel} />
<div className={styles.actionsWrapper}>
<AddPanelWidgetCreate onCreate={onCreateNewPanel} onPasteCopiedPanel={onPasteCopiedPanel} />
<div>
<HorizontalGroup justify="center">
<Button onClick={onCreateNewRow} variant="secondary" size="sm">
Convert to row
</Button>
</HorizontalGroup>
<AddPanelWidgetHandle onCancel={onCancelAddPanel} onBack={addPanelView ? onBack : undefined}>
{addPanelView ? 'Add panel from panel library' : 'Add panel'}
</AddPanelWidgetHandle>
{addPanelView ? (
<LibraryPanelsView
className={styles.libraryPanelsWrapper}
formatDate={(dateString: DateTimeInput) => dashboard.formatDate(dateString, 'L')}
onClickCard={(panel) => onAddLibraryPanel(panel)}
showSecondaryActions={false}
/>
) : (
<div className={styles.actionsWrapper}>
<div className={styles.actionsRow}>
<div onClick={() => onCreateNewPanel()} aria-label={selectors.pages.AddDashboard.addNewPanel}>
<Icon name="file-blank" size="xl" />
Add an empty panel
</div>
<div onClick={onCreateNewRow}>
<Icon name="wrap-text" size="xl" />
Add a new row
</div>
</div>
{(config.featureToggles.panelLibrary || copiedPanelPlugins.length === 1) && (
<div className={styles.actionsRow}>
{config.featureToggles.panelLibrary && (
<div onClick={() => setAddPanelView(true)}>
<Icon name="book-open" size="xl" />
Add a panel from the panel library
</div>
)}
{copiedPanelPlugins.length === 1 && (
<div onClick={() => onPasteCopiedPanel(copiedPanelPlugins[0])}>
<Icon name="clipboard-alt" size="xl" />
Paste panel from clipboard
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
);
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { addPanel, updateLocation };
export const AddPanelWidget = connect(null, mapDispatchToProps)(AddPanelWidgetUnconnected);
export const AddPanelWidget = connect(undefined, mapDispatchToProps)(AddPanelWidgetUnconnected);
interface AddPanelWidgetHandleProps {
onCancel: (e: React.MouseEvent<HTMLButtonElement>) => void;
onBack?: () => void;
children?: string;
}
const AddPanelWidgetHandle: React.FC<AddPanelWidgetHandleProps> = ({ onCancel }) => {
const AddPanelWidgetHandle: React.FC<AddPanelWidgetHandleProps> = ({ children, onBack, onCancel }) => {
const theme = useTheme();
const styles = getAddPanelWigetHandleStyles(theme);
return (
<div className={cx(styles.handle, 'grid-drag-handle')}>
<IconButton name="times" onClick={onCancel} surface="header" className="add-panel-widget__close" />
</div>
);
};
interface AddPanelWidgetCreateProps {
onCreate: () => void;
onPasteCopiedPanel: (panelPluginInfo: PanelPluginInfo) => void;
}
const AddPanelWidgetCreate: React.FC<AddPanelWidgetCreateProps> = ({ onCreate, onPasteCopiedPanel }) => {
const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []);
const theme = useTheme();
const styles = getAddPanelWidgetCreateStyles(theme);
return (
<div className={styles.wrapper}>
<HorizontalGroup>
<Button icon="plus" size="md" onClick={onCreate} aria-label={selectors.pages.AddDashboard.addNewPanel}>
Add new panel
</Button>
{copiedPanelPlugins.length === 1 && (
<Button variant="secondary" size="md" onClick={() => onPasteCopiedPanel(copiedPanelPlugins[0])}>
Paste copied panel
</Button>
<div className={styles.backButton}>
{onBack && (
<IconButton name="arrow-left" onClick={onBack} surface="header" size="xl" className={styles.noMargin} />
)}
</HorizontalGroup>
</div>
{children && <span>{children}</span>}
<IconButton name="times" onClick={onCancel} surface="header" className={styles.pushRight} />
</div>
);
};
@ -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;
`,
};
});

View File

@ -6,26 +6,34 @@ exports[`Render should render component 1`] = `
>
<AddPanelWidgetHandle
onCancel={[Function]}
/>
<div
className="css-1wtsk46"
>
<AddPanelWidgetCreate
onCreate={[Function]}
onPasteCopiedPanel={[Function]}
/>
<div>
<HorizontalGroup
justify="center"
Add panel
</AddPanelWidgetHandle>
<div
className="css-t8cafv"
>
<div
className="css-txvz1e"
>
<div
aria-label="Add new panel"
onClick={[Function]}
>
<Button
onClick={[Function]}
size="sm"
variant="secondary"
>
Convert to row
</Button>
</HorizontalGroup>
<Icon
name="file-blank"
size="xl"
/>
Add an empty panel
</div>
<div
onClick={[Function]}
>
<Icon
name="wrap-text"
size="xl"
/>
Add a new row
</div>
</div>
</div>
</div>

View File

@ -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<Props> {
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<Props> {
});
};
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<Props> {
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<Props> {
}
renderEditorActions() {
return [
let editorActions = [
<ToolbarButton
icon="cog"
onClick={this.onOpenDashboardSettings}
@ -255,11 +287,22 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
<ToolbarButton onClick={this.onDiscard} title="Undo all changes" key="discard">
Discard
</ToolbarButton>,
<ToolbarButton onClick={this.onSaveDashboard} title="Apply changes and save dashboard" key="save">
Save
</ToolbarButton>,
this.props.panel.libraryPanel ? (
<ToolbarButton
onClick={this.onSavePanel}
variant="primary"
title="Apply changes and save library panel"
key="save-panel"
>
Save library panel
</ToolbarButton>
) : (
<ToolbarButton onClick={this.onSaveDashboard} title="Apply changes and save dashboard" key="save">
Save
</ToolbarButton>
),
<ToolbarButton
onClick={this.onPanelExit}
onClick={this.props.exitPanelEditor}
variant="primary"
title="Apply changes and go back to dashboard"
key="apply"
@ -267,6 +310,41 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
Apply
</ToolbarButton>,
];
if (this.props.panel.libraryPanel) {
editorActions.splice(
1,
0,
<ModalsController key="unlink-controller">
{({ showModal, hideModal }) => {
return (
<ToolbarButton
onClick={() => {
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
</ToolbarButton>
);
}}
</ModalsController>
);
// Remove "Apply" button
editorActions.pop();
}
return editorActions;
}
renderOptionsPane() {
@ -305,7 +383,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
return (
<div className={styles.wrapper} aria-label={selectors.components.PanelEditor.General.content}>
<PageToolbar title={`${dashboard.title} / Edit Panel`} onGoBack={this.onPanelExit}>
<PageToolbar title={`${dashboard.title} / Edit Panel`} onGoBack={this.props.exitPanelEditor}>
{this.renderEditorActions()}
</PageToolbar>
<div className={styles.verticalSplitPanesWrapper}>

View File

@ -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<Props> = ({
visTabInputRef.current.focus();
}
};
// Fist common panel settings Title, description
if (config.featureToggles.panelLibrary && isLibraryPanel(panel)) {
elements.push(
<LibraryPanelInformation
panel={panel}
formatDate={(dateString, format) => dashboard.formatDate(dateString, format)}
key="Library Panel Information"
/>
);
}
// First common panel settings Title, description
elements.push(
<OptionsGroup title="Settings" id="Panel settings" key="Panel settings">
<Field label="Panel title">
@ -152,5 +167,9 @@ export const PanelOptionsTab: FC<Props> = ({
</OptionsGroup>
);
if (config.featureToggles.panelLibrary) {
elements.push(<PanelLibraryOptionsGroup panel={panel} dashboard={dashboard} key="Panel Library" />);
}
return <>{elements}</>;
};

View File

@ -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<void> {
return (dispatch) => {
@ -23,6 +29,84 @@ export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardMod
};
}
export function updateSourcePanel(sourcePanel: PanelModel): ThunkResult<void> {
return (dispatch, getStore) => {
const { getPanel } = getStore().panelEditor;
dispatch(
updateEditorInitState({
panel: getPanel(),
sourcePanel,
})
);
};
}
export function exitPanelEditor(): ThunkResult<void> {
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<void> {
return (dispatch, getStore) => {
const dashboard = getStore().dashboard.getModel();
@ -34,6 +118,8 @@ export function panelEditorCleanUp(): ThunkResult<void> {
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;

View File

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

View File

@ -48,6 +48,7 @@ export const PanelHeader: FC<Props> = ({ panel, error, isViewing, isEditing, dat
return (
<div className="panel-title">
<PanelHeaderNotices frames={data.series} panelId={panel.id} />
{panel.libraryPanel && <Icon name="reusable-panel" style={{ marginRight: '4px' }} />}
{alertState ? (
<Icon
name={alertState === 'alerting' ? 'heart-break' : 'heart'}

View File

@ -92,6 +92,7 @@ export class DashboardModel {
templating: true, // needs special handling
originalTime: true,
originalTemplating: true,
originalLibraryPanels: true,
panelInEdit: true,
panelInView: true,
getVariablesFromState: true,

View File

@ -36,6 +36,7 @@ import {
isStandardFieldProp,
restoreCustomOverrideRules,
} from './getPanelOptionsWithDefaults';
import { LibraryPanelDTO } from 'app/features/library-panels/state/api';
export interface GridPos {
x: number;
@ -98,6 +99,7 @@ const mustKeepProps: { [str: string]: boolean } = {
maxDataPoints: true,
interval: true,
replaceVariables: true,
libraryPanel: true,
};
const defaults: any = {
@ -149,6 +151,8 @@ export class PanelModel implements DataConfigSource {
links?: DataLink[];
transparent: boolean;
libraryPanel?: { uid: undefined; name: string } | Pick<LibraryPanelDTO, 'uid' | 'name' | 'meta'>;
// 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<Pick<PanelModel, 'libraryPanel'>> {
return panel.libraryPanel !== undefined;
}
interface PanelOptionsCache {
properties: any;
fieldConfig: FieldConfigSource;

View File

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

View File

@ -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<Props> = ({ isOpen = false, panel, initialFolderId, ...props }) => {
const styles = useStyles(getStyles);
const [folderId, setFolderId] = useState(initialFolderId);
const [panelTitle, setPanelTitle] = useState(panel.title);
const { saveLibraryPanel } = usePanelSave();
return (
<Modal title="Add this panel to the panel library" isOpen={isOpen} onDismiss={props.onDismiss}>
<Field label="Please set a name for the new reusable panel:">
<Input name="name" value={panelTitle} onChange={(e) => setPanelTitle(e.currentTarget.value)} />
</Field>
<Field label="Your reusable panel will be saved in:">
<FolderPicker onChange={({ id }) => setFolderId(id)} initialFolderId={initialFolderId} />
</Field>
<div className={styles.buttons}>
<Button
onClick={() => {
panel.title = panelTitle;
saveLibraryPanel(panel, folderId!).then(() => props.onDismiss());
}}
>
Add panel to the panel library
</Button>
<Button variant="secondary" onClick={props.onDismiss}>
Cancel
</Button>
</div>
</Modal>
);
};
const getStyles = () => ({
buttons: css`
display: flex;
gap: 10px;
`,
});

View File

@ -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<LibraryPanelCardProps & { children?: JSX.Element | JSX.Element[] }> = ({
libraryPanel,
children,
onClick,
onDelete,
formatDate,
showSecondaryActions,
}) => {
const styles = useStyles(getStyles);
const [showDeletionModal, setShowDeletionModal] = useState(false);
const onDeletePanel = () => {
onDelete?.();
setShowDeletionModal(false);
};
return (
<>
<Card heading={libraryPanel.name} onClick={onClick ? () => onClick(libraryPanel) : undefined}>
<Card.Figure>
<Icon className={styles.panelIcon} name="book-open" />
</Card.Figure>
<Card.Meta>
<span>Reusable panel</span>
<Tooltip content="Connected dashboards" placement="bottom">
<div className={styles.tooltip}>
<Icon name="apps" className={styles.detailIcon} />
{libraryPanel.meta.connectedDashboards}
</div>
</Tooltip>
{/*
Commenting this out as obtaining the number of variables used by a panel
isn't implemetned yet.
<Tooltip content="Variables used" placement="bottom">
<div className={styles.tooltip}>
<Icon name="x" className={styles.detailIcon} />
{varCount}
</div>
</Tooltip> */}
<span>
Last edited {formatDate?.(libraryPanel.meta.updated ?? '') ?? libraryPanel.meta.updated} by{' '}
{libraryPanel.meta.updatedBy.name}
</span>
</Card.Meta>
{/*
Commenting this out as tagging isn't implemented yet.
<Card.Tags>
<TagList className={styles.tagList} tags={['associated panel tag']} />
</Card.Tags> */}
{children && <Card.Actions>{children}</Card.Actions>}
{showSecondaryActions && (
<Card.SecondaryActions>
<IconButton
name="trash-alt"
tooltip="Delete panel"
tooltipPlacement="bottom"
onClick={() => setShowDeletionModal(true)}
/>
{/*
Commenting this out as panel favoriting hasn't been implemented yet.
<IconButton name="star" tooltip="Favorite panel" tooltipPlacement="bottom" />
*/}
</Card.SecondaryActions>
)}
</Card>
{showDeletionModal && (
<ConfirmModal
isOpen={showDeletionModal}
icon="trash-alt"
title="Delete library panel"
body="Do you want to delete this panel?"
confirmText="Delete"
onConfirm={onDeletePanel}
onDismiss={() => 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;
`,
};
});

View File

@ -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<Pick<PanelModel, 'libraryPanel'>>;
formatDate?: (dateString: DateTimeInput, format?: string) => string;
}
export const LibraryPanelInformation: React.FC<Props> = ({ panel, formatDate }) => {
const styles = useStyles(getStyles);
return (
<OptionsGroup title="Reusable panel information" id="Shared Panel Info" key="Shared Panel Info">
{panel.libraryPanel.uid && (
<p className={styles.libraryPanelInfo}>
{`Used on ${panel.libraryPanel.meta.connectedDashboards} `}
{panel.libraryPanel.meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}
<br />
Last edited on {formatDate?.(panel.libraryPanel.meta.updated, 'L') ?? panel.libraryPanel.meta.updated} by
{panel.libraryPanel.meta.updatedBy.avatarUrl && (
<img
width="22"
height="22"
className={styles.userAvatar}
src={panel.libraryPanel.meta.updatedBy.avatarUrl}
alt={`Avatar for ${panel.libraryPanel.meta.updatedBy.name}`}
/>
)}
{panel.libraryPanel.meta.updatedBy.name}
</p>
)}
</OptionsGroup>
);
};
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};
`,
};
});

View File

@ -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<LibraryPanelViewProps> = ({
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<LibraryPanelDTO[] | undefined>(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 (
<div className={cx(styles.container, className)}>
<span>Popular panels from the panel library</span>
<div className={styles.searchHeader}>
<Input
placeholder="Search the panel library"
prefix={<Icon name="search" />}
value={searchString}
onChange={(e) => setSearchString(e.currentTarget.value)}
></Input>
{/* <Select placeholder="Filter by" onChange={() => {}} width={35} /> */}
</div>
<div className={styles.libraryPanelList}>
{libraryPanels === undefined ? (
<p>Loading library panels...</p>
) : filteredItems?.length! < 1 ? (
<p>No library panels found.</p>
) : (
filteredItems?.map((item, i) => (
<LibraryPanelCard
key={`shared-panel=${i}`}
libraryPanel={item}
onDelete={() => onDeletePanel(item.uid)}
onClick={onClickCard}
formatDate={formatDate}
showSecondaryActions={showSecondaryActions}
>
{children?.(item, i)}
</LibraryPanelCard>
))
)}
</div>
{onCreateNewPanel && (
<Button icon="plus" className={styles.newPanelButton} onClick={onCreateNewPanel}>
Create a new reusable panel
</Button>
)}
</div>
);
};
const getPanelViewStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: ${theme.spacing.sm};
height: 100%;
`,
libraryPanelList: css`
display: flex;
overflow-x: auto;
flex-direction: column;
`,
searchHeader: css`
display: flex;
`,
newPanelButton: css`
margin-top: 10px;
align-self: flex-start;
`,
};
});

View File

@ -0,0 +1,89 @@
import { GrafanaTheme } from '@grafana/data';
import { Button, stylesFactory, useStyles } from '@grafana/ui';
import { OptionsGroup } from 'app/features/dashboard/components/PanelEditor/OptionsGroup';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { css } from 'emotion';
import React, { useState } from 'react';
import { AddLibraryPanelModal } from '../AddLibraryPanelModal/AddLibraryPanelModal';
import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView';
import pick from 'lodash/pick';
import { LibraryPanelDTO } from '../../state/api';
import { PanelQueriesChangedEvent } from 'app/types/events';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
}
export const PanelLibraryOptionsGroup: React.FC<Props> = ({ panel, dashboard }) => {
const styles = useStyles(getStyles);
const [showingAddPanelModal, setShowingAddPanelModal] = useState(false);
const useLibraryPanel = (panelInfo: LibraryPanelDTO) => {
panel.restoreModel({
...panelInfo.model,
...pick(panel, 'gridPos', 'id'),
libraryPanel: pick(panelInfo, 'uid', 'name', 'meta'),
});
// dummy change for re-render
// onPanelConfigChange('isEditing', true);
panel.refresh();
panel.events.publish(PanelQueriesChangedEvent);
};
const onAddToPanelLibrary = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowingAddPanelModal(true);
};
return (
<OptionsGroup
renderTitle={(isExpanded) => {
return isExpanded && !panel.libraryPanel ? (
<div className={styles.panelLibraryTitle}>
<span>Panel library</span>
<Button size="sm" onClick={onAddToPanelLibrary}>
Add this panel to the panel library
</Button>
</div>
) : (
'Panel library'
);
}}
id="panel-library"
key="panel-library"
defaultToClosed
>
<LibraryPanelsView
formatDate={(dateString: string) => dashboard.formatDate(dateString, 'L')}
showSecondaryActions
>
{(panel) => (
<Button variant="secondary" onClick={() => useLibraryPanel(panel)}>
Use instead of current panel
</Button>
)}
</LibraryPanelsView>
{showingAddPanelModal && (
<AddLibraryPanelModal
panel={panel}
onDismiss={() => setShowingAddPanelModal(false)}
initialFolderId={dashboard.meta.folderId}
isOpen={showingAddPanelModal}
/>
)}
</OptionsGroup>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
panelLibraryTitle: css`
display: flex;
gap: 10px;
`,
};
});

View File

@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { Button, HorizontalGroup, Icon, Input, Modal, stylesFactory, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
import { useAsync, useDebounce } from 'react-use';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { usePanelSave } from '../../utils/usePanelSave';
import { PanelModel } from 'app/features/dashboard/state';
import { getLibraryPanelConnectedDashboards, LibraryPanelDTO } from '../../state/api';
interface Props {
panel: PanelModel & { libraryPanel: Pick<LibraryPanelDTO, 'uid' | 'name' | 'meta'> };
folderId: number;
isOpen: boolean;
onConfirm: () => void;
onDismiss: () => void;
}
export const SaveLibraryPanelModal: React.FC<Props> = ({ panel, folderId, isOpen, onDismiss, onConfirm }: Props) => {
const [searchString, setSearchString] = useState('');
const connectedDashboardsState = useAsync(async () => {
const connectedDashboards = await getLibraryPanelConnectedDashboards(panel.libraryPanel.uid);
return connectedDashboards;
}, []);
const dashState = useAsync(async () => {
const connectedDashboards = connectedDashboardsState.value;
if (connectedDashboards && connectedDashboards.length > 0) {
const dashboardDTOs = await getBackendSrv().search({ dashboardIds: connectedDashboards });
return dashboardDTOs.map((dash) => dash.title);
}
return [];
}, [connectedDashboardsState.value]);
const [filteredDashboards, setFilteredDashboards] = useState<string[]>([]);
useDebounce(
() => {
if (!dashState.value) {
return setFilteredDashboards([]);
}
return setFilteredDashboards(
dashState.value.filter((dashName) => dashName.toLowerCase().includes(searchString.toLowerCase()))
);
},
300,
[dashState.value, searchString]
);
const { saveLibraryPanel } = usePanelSave();
const styles = useStyles(getModalStyles);
return (
<Modal title="Update all panel instances" icon="save" onDismiss={onDismiss} isOpen={isOpen}>
<div>
<p className={styles.textInfo}>
{'This update will affect '}
<strong>
{panel.libraryPanel.meta.connectedDashboards}{' '}
{panel.libraryPanel.meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}.
</strong>
The following dashboards using the panel will be affected:
</p>
<Input
className={styles.dashboardSearch}
prefix={<Icon name="search" />}
placeholder="Search affected dashboards"
value={searchString}
onChange={(e) => setSearchString(e.currentTarget.value)}
/>
{dashState.loading ? (
<p>Loading connected dashboards...</p>
) : (
<table className={styles.myTable}>
<thead>
<tr>
<th>Dashboard name</th>
</tr>
</thead>
<tbody>
{filteredDashboards.map((dashName, i) => (
<tr key={`dashrow-${i}`}>
<td>{dashName}</td>
</tr>
))}
</tbody>
</table>
)}
<HorizontalGroup>
<Button
onClick={() => {
saveLibraryPanel(panel, folderId).then(() => {
onConfirm();
onDismiss();
});
}}
>
Update all
</Button>
<Button variant="secondary" onClick={onDismiss}>
Cancel
</Button>
</HorizontalGroup>
</div>
</Modal>
);
};
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};
`,
};
});

View File

@ -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<Props> = ({ isOpen, onConfirm, onDismiss }) => {
return (
<ConfirmModal
title="Do you really want to unlink this panel?"
icon="question-circle"
body="If you unlink this panel, you will be able to edit it without affecting any other dashboards.
However, once you make a change you will not be able to revert to its original reusable panel."
confirmText="Yes, unlink"
onConfirm={() => {
onConfirm();
onDismiss();
}}
onDismiss={onDismiss}
isOpen={isOpen}
/>
);
};

View File

@ -12,6 +12,7 @@ export interface LibraryPanelDTO {
export interface LibraryPanelDTOMeta {
canEdit: boolean;
connectedDashboards: number;
created: string;
updated: string;
createdBy: LibraryPanelDTOMetaUser;

View File

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

View File

@ -23,7 +23,7 @@ export interface ShowModalPayload {
}
export interface ShowModalReactPayload {
component: React.ComponentType;
component: React.ComponentType<any>;
props?: any;
}