mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Frontend changes for library panels feature (#30653)
Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
This commit is contained in:
parent
0bb4cbdb68
commit
47d2a8085b
@ -319,13 +319,19 @@ const Meta: FC<ChildProps & { separator?: string }> = memo(({ children, styles,
|
|||||||
Meta.displayName = 'Meta';
|
Meta.displayName = 'Meta';
|
||||||
|
|
||||||
interface ActionsProps extends ChildProps {
|
interface ActionsProps extends ChildProps {
|
||||||
children: JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
variant?: 'primary' | 'secondary';
|
variant?: 'primary' | 'secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
const BaseActions: FC<ActionsProps> = ({ children, styles, disabled, variant }) => {
|
const BaseActions: FC<ActionsProps> = ({ children, styles, disabled, variant }) => {
|
||||||
const css = variant === 'primary' ? styles?.actions : styles?.secondaryActions;
|
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 }) => {
|
const Actions: FC<ActionsProps> = ({ children, styles, disabled }) => {
|
||||||
|
@ -10,7 +10,7 @@ import * as MonoIcon from './assets';
|
|||||||
import { customIcons } from './custom';
|
import { customIcons } from './custom';
|
||||||
import { SvgProps } from './assets/types';
|
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> {
|
export interface IconProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
name: IconName;
|
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)) {
|
if (alwaysMonoIcons.includes(name)) {
|
||||||
type = 'mono';
|
type = 'mono';
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,15 +1,16 @@
|
|||||||
export * from './Apps';
|
export * from './Apps';
|
||||||
export * from './Cog';
|
|
||||||
export * from './Shield';
|
|
||||||
export * from './Favorite';
|
|
||||||
export * from './Grafana';
|
|
||||||
export * from './Bell';
|
export * from './Bell';
|
||||||
export * from './PlusSquare';
|
export * from './Circle';
|
||||||
export * from './FolderPlus';
|
export * from './Cog';
|
||||||
|
export * from './Favorite';
|
||||||
export * from './Folder';
|
export * from './Folder';
|
||||||
|
export * from './FolderPlus';
|
||||||
|
export * from './Grafana';
|
||||||
|
export * from './Heart';
|
||||||
|
export * from './HeartBreak';
|
||||||
export * from './Import';
|
export * from './Import';
|
||||||
export * from './PanelAdd';
|
export * from './PanelAdd';
|
||||||
export * from './Circle';
|
export * from './PlusSquare';
|
||||||
|
export * from './ReusablePanel';
|
||||||
|
export * from './Shield';
|
||||||
export * from './SquareShape';
|
export * from './SquareShape';
|
||||||
export * from './HeartBreak';
|
|
||||||
export * from './Heart';
|
|
||||||
|
@ -3,254 +3,268 @@ export type IconType = 'mono' | 'default';
|
|||||||
export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
|
export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
| 'fa fa-spinner'
|
| 'angle-double-down'
|
||||||
| 'grafana'
|
| 'angle-double-right'
|
||||||
| 'question-circle'
|
|
||||||
| 'angle-up'
|
|
||||||
| 'history'
|
|
||||||
| 'angle-down'
|
| 'angle-down'
|
||||||
| 'filter'
|
|
||||||
| 'angle-left'
|
| 'angle-left'
|
||||||
| 'angle-right'
|
| 'angle-right'
|
||||||
| 'angle-double-right'
|
| 'angle-up'
|
||||||
| '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'
|
|
||||||
| 'apps'
|
| 'apps'
|
||||||
| 'link'
|
| 'arrow-down'
|
||||||
| 'upload'
|
| 'arrow-from-right'
|
||||||
| '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-left'
|
| 'arrow-left'
|
||||||
| 'repeat'
|
| 'arrow-random'
|
||||||
| 'external-link-alt'
|
| 'arrow-right'
|
||||||
| 'minus'
|
| 'arrow-up'
|
||||||
| 'signal'
|
|
||||||
| 'search-plus'
|
|
||||||
| 'minus-circle'
|
|
||||||
| 'table'
|
|
||||||
| 'arrow'
|
| 'arrow'
|
||||||
| 'plus'
|
| 'arrows-h'
|
||||||
| 'heart'
|
| 'bars'
|
||||||
| 'heart-break'
|
| 'bell-slash'
|
||||||
| 'ellipsis-v'
|
| 'bell'
|
||||||
| 'favorite'
|
| 'bolt'
|
||||||
| 'line-alt'
|
| 'book-open'
|
||||||
| 'sort-amount-down'
|
| 'book'
|
||||||
|
| 'bug'
|
||||||
|
| 'calculator-alt'
|
||||||
|
| 'calendar-alt'
|
||||||
|
| 'camera'
|
||||||
|
| 'channel-add'
|
||||||
|
| 'chart-line'
|
||||||
|
| 'check-circle'
|
||||||
|
| 'check'
|
||||||
|
| 'circle'
|
||||||
|
| 'clipboard-alt'
|
||||||
|
| 'clock-nine'
|
||||||
|
| 'cloud-download'
|
||||||
|
| 'cloud-upload'
|
||||||
| 'cloud'
|
| 'cloud'
|
||||||
|
| 'code-branch'
|
||||||
|
| 'cog'
|
||||||
|
| 'columns'
|
||||||
|
| 'comment-alt'
|
||||||
|
| 'comments-alt'
|
||||||
|
| 'compass'
|
||||||
|
| 'copy'
|
||||||
|
| 'cube'
|
||||||
|
| 'database'
|
||||||
|
| 'document-info'
|
||||||
|
| 'download-alt'
|
||||||
| 'draggabledots'
|
| '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'
|
| 'folder-upload'
|
||||||
| 'palette'
|
| 'folder'
|
||||||
|
| 'forward'
|
||||||
| 'gf-interpolation-linear'
|
| 'gf-interpolation-linear'
|
||||||
| 'gf-interpolation-smooth'
|
| 'gf-interpolation-smooth'
|
||||||
| 'gf-interpolation-step-before'
|
|
||||||
| 'gf-interpolation-step-after'
|
| '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[] => [
|
export const getAvailableIcons = (): IconName[] => [
|
||||||
'fa fa-spinner',
|
'angle-double-down',
|
||||||
'grafana',
|
'angle-double-right',
|
||||||
'question-circle',
|
|
||||||
'angle-up',
|
|
||||||
'history',
|
|
||||||
'angle-down',
|
'angle-down',
|
||||||
'filter',
|
|
||||||
'angle-left',
|
'angle-left',
|
||||||
'angle-right',
|
'angle-right',
|
||||||
'angle-double-right',
|
'angle-up',
|
||||||
'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',
|
|
||||||
'apps',
|
'apps',
|
||||||
'link',
|
'arrow-down',
|
||||||
'upload',
|
'arrow-from-right',
|
||||||
'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-left',
|
'arrow-left',
|
||||||
'repeat',
|
'arrow-random',
|
||||||
'external-link-alt',
|
'arrow-right',
|
||||||
'minus',
|
'arrow-up',
|
||||||
'signal',
|
|
||||||
'search-plus',
|
|
||||||
'minus-circle',
|
|
||||||
'arrow',
|
'arrow',
|
||||||
'table',
|
'arrows-h',
|
||||||
'plus',
|
'bars',
|
||||||
'heart',
|
'bell-slash',
|
||||||
'heart-break',
|
'bell',
|
||||||
'ellipsis-v',
|
'bolt',
|
||||||
'favorite',
|
'book-open',
|
||||||
'sort-amount-down',
|
'book',
|
||||||
|
'bug',
|
||||||
|
'calculator-alt',
|
||||||
|
'calendar-alt',
|
||||||
|
'camera',
|
||||||
|
'channel-add',
|
||||||
|
'chart-line',
|
||||||
|
'check-circle',
|
||||||
|
'check',
|
||||||
|
'circle',
|
||||||
|
'clipboard-alt',
|
||||||
|
'clock-nine',
|
||||||
|
'cloud-download',
|
||||||
|
'cloud-upload',
|
||||||
'cloud',
|
'cloud',
|
||||||
|
'code-branch',
|
||||||
|
'cog',
|
||||||
|
'columns',
|
||||||
|
'comment-alt',
|
||||||
|
'comments-alt',
|
||||||
|
'compass',
|
||||||
|
'copy',
|
||||||
|
'cube',
|
||||||
|
'database',
|
||||||
|
'document-info',
|
||||||
|
'download-alt',
|
||||||
'draggabledots',
|
'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',
|
'folder-upload',
|
||||||
'palette',
|
'folder',
|
||||||
|
'forward',
|
||||||
'gf-interpolation-linear',
|
'gf-interpolation-linear',
|
||||||
'gf-interpolation-smooth',
|
'gf-interpolation-smooth',
|
||||||
'gf-interpolation-step-before',
|
|
||||||
'gf-interpolation-step-after',
|
'gf-interpolation-step-after',
|
||||||
|
'gf-interpolation-step-before',
|
||||||
'gf-logs',
|
'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',
|
||||||
];
|
];
|
||||||
|
@ -7,7 +7,8 @@ import { LegacyGraphHoverClearEvent, locationUtil } from '@grafana/data';
|
|||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { getExploreUrl } from 'app/core/utils/explore';
|
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 { AppEventEmitter, CoreEvents } from 'app/types';
|
||||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
@ -17,7 +18,6 @@ import { defaultQueryParams } from 'app/features/search/reducers/searchQueryRedu
|
|||||||
import { ContextSrv } from './context_srv';
|
import { ContextSrv } from './context_srv';
|
||||||
|
|
||||||
export class KeybindingSrv {
|
export class KeybindingSrv {
|
||||||
helpModal: boolean;
|
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
@ -129,9 +129,7 @@ export class KeybindingSrv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (search.editPanel) {
|
if (search.editPanel) {
|
||||||
delete search.editPanel;
|
dispatch(exitPanelEditor());
|
||||||
delete search.tab;
|
|
||||||
this.$location.search(search);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,8 +53,9 @@ const parseHeaderByMethodFactory = (methodPredicate: string): HeaderParser => ({
|
|||||||
|
|
||||||
const postHeaderParser: HeaderParser = parseHeaderByMethodFactory('post');
|
const postHeaderParser: HeaderParser = parseHeaderByMethodFactory('post');
|
||||||
const putHeaderParser: HeaderParser = parseHeaderByMethodFactory('put');
|
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) => {
|
export const parseHeaders = (options: BackendSrvRequest) => {
|
||||||
const headers = options?.headers ? new Headers(options.headers) : new Headers();
|
const headers = options?.headers ? new Headers(options.headers) : new Headers();
|
||||||
|
@ -1,22 +1,21 @@
|
|||||||
// Libraries
|
import React, { useMemo, useState } from 'react';
|
||||||
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 { connect, MapDispatchToProps } from 'react-redux';
|
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 config from 'app/core/config';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
// Store
|
|
||||||
import { updateLocation } from 'app/core/actions';
|
import { updateLocation } from 'app/core/actions';
|
||||||
import { addPanel } from 'app/features/dashboard/state/reducers';
|
import { addPanel } from 'app/features/dashboard/state/reducers';
|
||||||
// Types
|
|
||||||
import { DashboardModel, PanelModel } from '../../state';
|
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 { 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 } };
|
export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } };
|
||||||
|
|
||||||
@ -55,18 +54,22 @@ const getCopiedPanelPlugins = () => {
|
|||||||
return _.sortBy(copiedPanels, 'sort');
|
return _.sortBy(copiedPanels, 'sort');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard, updateLocation, addPanel }) => {
|
export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard, updateLocation }) => {
|
||||||
const theme = useTheme();
|
const [addPanelView, setAddPanelView] = useState(false);
|
||||||
|
|
||||||
const onCancelAddPanel = (evt: any) => {
|
const onCancelAddPanel = (evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
dashboard.removePanel(panel);
|
dashboard.removePanel(panel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onBack = () => {
|
||||||
|
setAddPanelView(false);
|
||||||
|
};
|
||||||
|
|
||||||
const onCreateNewPanel = () => {
|
const onCreateNewPanel = () => {
|
||||||
const { gridPos } = panel;
|
const { gridPos } = panel;
|
||||||
|
|
||||||
const newPanel: any = {
|
const newPanel: Partial<PanelModel> = {
|
||||||
type: 'graph',
|
type: 'graph',
|
||||||
title: 'Panel Title',
|
title: 'Panel Title',
|
||||||
gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
|
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);
|
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 onCreateNewRow = () => {
|
||||||
const newRow: any = {
|
const newRow: any = {
|
||||||
type: 'row',
|
type: 'row',
|
||||||
@ -121,62 +137,78 @@ export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard, u
|
|||||||
dashboard.removePanel(panel);
|
dashboard.removePanel(panel);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = getStyles(theme);
|
const styles = useStyles(getStyles);
|
||||||
|
const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx('panel-container', styles.wrapper)}>
|
<div className={cx('panel-container', styles.wrapper)}>
|
||||||
<AddPanelWidgetHandle onCancel={onCancelAddPanel} />
|
<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.actionsWrapper}>
|
||||||
<AddPanelWidgetCreate onCreate={onCreateNewPanel} onPasteCopiedPanel={onPasteCopiedPanel} />
|
<div className={styles.actionsRow}>
|
||||||
<div>
|
<div onClick={() => onCreateNewPanel()} aria-label={selectors.pages.AddDashboard.addNewPanel}>
|
||||||
<HorizontalGroup justify="center">
|
<Icon name="file-blank" size="xl" />
|
||||||
<Button onClick={onCreateNewRow} variant="secondary" size="sm">
|
Add an empty panel
|
||||||
Convert to row
|
</div>
|
||||||
</Button>
|
<div onClick={onCreateNewRow}>
|
||||||
</HorizontalGroup>
|
<Icon name="wrap-text" size="xl" />
|
||||||
|
Add a new row
|
||||||
</div>
|
</div>
|
||||||
</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 };
|
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { addPanel, updateLocation };
|
||||||
|
|
||||||
export const AddPanelWidget = connect(null, mapDispatchToProps)(AddPanelWidgetUnconnected);
|
export const AddPanelWidget = connect(undefined, mapDispatchToProps)(AddPanelWidgetUnconnected);
|
||||||
|
|
||||||
interface AddPanelWidgetHandleProps {
|
interface AddPanelWidgetHandleProps {
|
||||||
onCancel: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
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 theme = useTheme();
|
||||||
const styles = getAddPanelWigetHandleStyles(theme);
|
const styles = getAddPanelWigetHandleStyles(theme);
|
||||||
return (
|
return (
|
||||||
<div className={cx(styles.handle, 'grid-drag-handle')}>
|
<div className={cx(styles.handle, 'grid-drag-handle')}>
|
||||||
<IconButton name="times" onClick={onCancel} surface="header" className="add-panel-widget__close" />
|
<div className={styles.backButton}>
|
||||||
</div>
|
{onBack && (
|
||||||
);
|
<IconButton name="arrow-left" onClick={onBack} surface="header" size="xl" className={styles.noMargin} />
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</HorizontalGroup>
|
</div>
|
||||||
|
|
||||||
|
{children && <span>{children}</span>}
|
||||||
|
<IconButton name="times" onClick={onCancel} surface="header" className={styles.pushRight} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -197,12 +229,49 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4;
|
box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4;
|
||||||
animation: ${pulsate} 2s ease infinite;
|
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`
|
actionsWrapper: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
row-gap: ${theme.spacing.sm};
|
||||||
height: 100%;
|
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 {
|
return {
|
||||||
handle: css`
|
handle: css`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
cursor: grab;
|
display: flex;
|
||||||
top: 0;
|
align-items: center;
|
||||||
left: 0;
|
height: ${theme.spacing.gutter};
|
||||||
height: 26px;
|
|
||||||
padding: 0 ${theme.spacing.xs};
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
font-size: ${theme.typography.size.md};
|
||||||
justify-content: flex-end;
|
font-weight: ${theme.typography.weight.semibold};
|
||||||
align-items: center;
|
|
||||||
transition: background-color 0.1s ease-in-out;
|
transition: background-color 0.1s ease-in-out;
|
||||||
|
cursor: move;
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${theme.colors.bg2};
|
background: ${theme.colors.bg2};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
};
|
pushRight: css`
|
||||||
});
|
margin-left: auto;
|
||||||
|
`,
|
||||||
const getAddPanelWidgetCreateStyles = stylesFactory((theme: GrafanaTheme) => {
|
leftPad: css`
|
||||||
return {
|
padding-left: ${theme.spacing.xl};
|
||||||
wrapper: css`
|
`,
|
||||||
cursor: pointer;
|
backButton: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: ${theme.spacing.lg};
|
cursor: pointer;
|
||||||
&:hover {
|
padding-left: ${theme.spacing.xs};
|
||||||
background: ${theme.colors.bg2};
|
width: ${theme.spacing.xl};
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
icon: css`
|
noMargin: css`
|
||||||
color: ${theme.colors.textWeak};
|
margin: 0;
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -6,26 +6,34 @@ exports[`Render should render component 1`] = `
|
|||||||
>
|
>
|
||||||
<AddPanelWidgetHandle
|
<AddPanelWidgetHandle
|
||||||
onCancel={[Function]}
|
onCancel={[Function]}
|
||||||
/>
|
>
|
||||||
|
Add panel
|
||||||
|
</AddPanelWidgetHandle>
|
||||||
<div
|
<div
|
||||||
className="css-1wtsk46"
|
className="css-t8cafv"
|
||||||
>
|
>
|
||||||
<AddPanelWidgetCreate
|
<div
|
||||||
onCreate={[Function]}
|
className="css-txvz1e"
|
||||||
onPasteCopiedPanel={[Function]}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<HorizontalGroup
|
|
||||||
justify="center"
|
|
||||||
>
|
>
|
||||||
<Button
|
<div
|
||||||
|
aria-label="Add new panel"
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
>
|
||||||
Convert to row
|
<Icon
|
||||||
</Button>
|
name="file-blank"
|
||||||
</HorizontalGroup>
|
size="xl"
|
||||||
|
/>
|
||||||
|
Add an empty panel
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="wrap-text"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
Add a new row
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,14 @@ import { Subscription } from 'rxjs';
|
|||||||
|
|
||||||
import { FieldConfigSource, GrafanaTheme } from '@grafana/data';
|
import { FieldConfigSource, GrafanaTheme } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
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 config from 'app/core/config';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
@ -20,7 +27,13 @@ import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPane
|
|||||||
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy';
|
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy';
|
||||||
import { DashboardPanel } from '../../dashgrid/DashboardPanel';
|
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 { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
|
||||||
import { updateLocation } from 'app/core/reducers/location';
|
import { updateLocation } from 'app/core/reducers/location';
|
||||||
@ -34,6 +47,8 @@ import { CoreEvents, StoreState } from 'app/types';
|
|||||||
import { DisplayMode, displayModes, PanelEditorTab } from './types';
|
import { DisplayMode, displayModes, PanelEditorTab } from './types';
|
||||||
import { DashboardModel, PanelModel } from '../../state';
|
import { DashboardModel, PanelModel } from '../../state';
|
||||||
import { PanelOptionsChangedEvent } from 'app/types/events';
|
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 {
|
interface OwnProps {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
@ -58,6 +73,8 @@ const mapStateToProps = (state: StoreState) => {
|
|||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
updateLocation,
|
updateLocation,
|
||||||
initPanelEditor,
|
initPanelEditor,
|
||||||
|
exitPanelEditor,
|
||||||
|
updateSourcePanel,
|
||||||
panelEditorCleanUp,
|
panelEditorCleanUp,
|
||||||
setDiscardChanges,
|
setDiscardChanges,
|
||||||
updatePanelEditorUIState,
|
updatePanelEditorUIState,
|
||||||
@ -93,13 +110,6 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
onPanelExit = () => {
|
|
||||||
this.props.updateLocation({
|
|
||||||
query: { editPanel: null, tab: null },
|
|
||||||
partial: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onDiscard = () => {
|
onDiscard = () => {
|
||||||
this.props.setDiscardChanges(true);
|
this.props.setDiscardChanges(true);
|
||||||
this.props.updateLocation({
|
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) => {
|
onChangeTab = (tab: PanelEditorTab) => {
|
||||||
this.props.updateLocation({ query: { tab: tab.id }, partial: true });
|
this.props.updateLocation({ query: { tab: tab.id }, partial: true });
|
||||||
};
|
};
|
||||||
@ -137,14 +169,14 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
this.props.panel.updateOptions(options);
|
this.props.panel.updateOptions(options);
|
||||||
};
|
};
|
||||||
|
|
||||||
onPanelConfigChanged = (configKey: string, value: any) => {
|
onPanelConfigChanged = (configKey: keyof PanelModel, value: any) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this.props.panel[configKey] = value;
|
this.props.panel[configKey] = value;
|
||||||
this.props.panel.render();
|
this.props.panel.render();
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
onDisplayModeChange = (mode: DisplayMode) => {
|
onDisplayModeChange = (mode?: DisplayMode) => {
|
||||||
const { updatePanelEditorUIState } = this.props;
|
const { updatePanelEditorUIState } = this.props;
|
||||||
updatePanelEditorUIState({
|
updatePanelEditorUIState({
|
||||||
mode: mode,
|
mode: mode,
|
||||||
@ -245,7 +277,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderEditorActions() {
|
renderEditorActions() {
|
||||||
return [
|
let editorActions = [
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon="cog"
|
icon="cog"
|
||||||
onClick={this.onOpenDashboardSettings}
|
onClick={this.onOpenDashboardSettings}
|
||||||
@ -255,11 +287,22 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
<ToolbarButton onClick={this.onDiscard} title="Undo all changes" key="discard">
|
<ToolbarButton onClick={this.onDiscard} title="Undo all changes" key="discard">
|
||||||
Discard
|
Discard
|
||||||
</ToolbarButton>,
|
</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">
|
<ToolbarButton onClick={this.onSaveDashboard} title="Apply changes and save dashboard" key="save">
|
||||||
Save
|
Save
|
||||||
</ToolbarButton>,
|
</ToolbarButton>
|
||||||
|
),
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={this.onPanelExit}
|
onClick={this.props.exitPanelEditor}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
title="Apply changes and go back to dashboard"
|
title="Apply changes and go back to dashboard"
|
||||||
key="apply"
|
key="apply"
|
||||||
@ -267,6 +310,41 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
Apply
|
Apply
|
||||||
</ToolbarButton>,
|
</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() {
|
renderOptionsPane() {
|
||||||
@ -305,7 +383,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} aria-label={selectors.components.PanelEditor.General.content}>
|
<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()}
|
{this.renderEditorActions()}
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
<div className={styles.verticalSplitPanesWrapper}>
|
<div className={styles.verticalSplitPanesWrapper}>
|
||||||
|
@ -8,6 +8,10 @@ import { AngularPanelOptions } from './AngularPanelOptions';
|
|||||||
import { VisualizationTab } from './VisualizationTab';
|
import { VisualizationTab } from './VisualizationTab';
|
||||||
import { OptionsGroup } from './OptionsGroup';
|
import { OptionsGroup } from './OptionsGroup';
|
||||||
import { RepeatRowSelect } from '../RepeatRowSelect/RepeatRowSelect';
|
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 {
|
interface Props {
|
||||||
panel: PanelModel;
|
panel: PanelModel;
|
||||||
@ -46,7 +50,18 @@ export const PanelOptionsTab: FC<Props> = ({
|
|||||||
visTabInputRef.current.focus();
|
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(
|
elements.push(
|
||||||
<OptionsGroup title="Settings" id="Panel settings" key="Panel settings">
|
<OptionsGroup title="Settings" id="Panel settings" key="Panel settings">
|
||||||
<Field label="Panel title">
|
<Field label="Panel title">
|
||||||
@ -152,5 +167,9 @@ export const PanelOptionsTab: FC<Props> = ({
|
|||||||
</OptionsGroup>
|
</OptionsGroup>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (config.featureToggles.panelLibrary) {
|
||||||
|
elements.push(<PanelLibraryOptionsGroup panel={panel} dashboard={dashboard} key="Panel Library" />);
|
||||||
|
}
|
||||||
|
|
||||||
return <>{elements}</>;
|
return <>{elements}</>;
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { DashboardModel, PanelModel } from '../../../state';
|
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 {
|
import {
|
||||||
closeCompleted,
|
closeCompleted,
|
||||||
PANEL_EDITOR_UI_STATE_STORAGE_KEY,
|
PANEL_EDITOR_UI_STATE_STORAGE_KEY,
|
||||||
@ -7,8 +9,12 @@ import {
|
|||||||
setPanelEditorUIState,
|
setPanelEditorUIState,
|
||||||
updateEditorInitState,
|
updateEditorInitState,
|
||||||
} from './reducers';
|
} from './reducers';
|
||||||
|
import { updateLocation } from 'app/core/actions';
|
||||||
import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers';
|
import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reducers';
|
||||||
import store from 'app/core/store';
|
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> {
|
export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult<void> {
|
||||||
return (dispatch) => {
|
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> {
|
export function panelEditorCleanUp(): ThunkResult<void> {
|
||||||
return (dispatch, getStore) => {
|
return (dispatch, getStore) => {
|
||||||
const dashboard = getStore().dashboard.getModel();
|
const dashboard = getStore().dashboard.getModel();
|
||||||
@ -34,6 +118,8 @@ export function panelEditorCleanUp(): ThunkResult<void> {
|
|||||||
const sourcePanel = getSourcePanel();
|
const sourcePanel = getSourcePanel();
|
||||||
const panelTypeChanged = sourcePanel.type !== panel.type;
|
const panelTypeChanged = sourcePanel.type !== panel.type;
|
||||||
|
|
||||||
|
updateDuplicateLibraryPanels(panel, dashboard!, dispatch);
|
||||||
|
|
||||||
// restore the source panel id before we update source panel
|
// restore the source panel id before we update source panel
|
||||||
modifiedSaveModel.id = sourcePanel.id;
|
modifiedSaveModel.id = sourcePanel.id;
|
||||||
|
|
||||||
|
@ -9,12 +9,13 @@ import { updateLocation } from 'app/core/reducers/location';
|
|||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions';
|
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;
|
let folderId = options.folderId;
|
||||||
if (folderId === undefined) {
|
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) => {
|
export const useDashboardSave = (dashboard: DashboardModel) => {
|
||||||
|
@ -48,6 +48,7 @@ export const PanelHeader: FC<Props> = ({ panel, error, isViewing, isEditing, dat
|
|||||||
return (
|
return (
|
||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
<PanelHeaderNotices frames={data.series} panelId={panel.id} />
|
<PanelHeaderNotices frames={data.series} panelId={panel.id} />
|
||||||
|
{panel.libraryPanel && <Icon name="reusable-panel" style={{ marginRight: '4px' }} />}
|
||||||
{alertState ? (
|
{alertState ? (
|
||||||
<Icon
|
<Icon
|
||||||
name={alertState === 'alerting' ? 'heart-break' : 'heart'}
|
name={alertState === 'alerting' ? 'heart-break' : 'heart'}
|
||||||
|
@ -92,6 +92,7 @@ export class DashboardModel {
|
|||||||
templating: true, // needs special handling
|
templating: true, // needs special handling
|
||||||
originalTime: true,
|
originalTime: true,
|
||||||
originalTemplating: true,
|
originalTemplating: true,
|
||||||
|
originalLibraryPanels: true,
|
||||||
panelInEdit: true,
|
panelInEdit: true,
|
||||||
panelInView: true,
|
panelInView: true,
|
||||||
getVariablesFromState: true,
|
getVariablesFromState: true,
|
||||||
|
@ -36,6 +36,7 @@ import {
|
|||||||
isStandardFieldProp,
|
isStandardFieldProp,
|
||||||
restoreCustomOverrideRules,
|
restoreCustomOverrideRules,
|
||||||
} from './getPanelOptionsWithDefaults';
|
} from './getPanelOptionsWithDefaults';
|
||||||
|
import { LibraryPanelDTO } from 'app/features/library-panels/state/api';
|
||||||
|
|
||||||
export interface GridPos {
|
export interface GridPos {
|
||||||
x: number;
|
x: number;
|
||||||
@ -98,6 +99,7 @@ const mustKeepProps: { [str: string]: boolean } = {
|
|||||||
maxDataPoints: true,
|
maxDataPoints: true,
|
||||||
interval: true,
|
interval: true,
|
||||||
replaceVariables: true,
|
replaceVariables: true,
|
||||||
|
libraryPanel: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaults: any = {
|
const defaults: any = {
|
||||||
@ -149,6 +151,8 @@ export class PanelModel implements DataConfigSource {
|
|||||||
links?: DataLink[];
|
links?: DataLink[];
|
||||||
transparent: boolean;
|
transparent: boolean;
|
||||||
|
|
||||||
|
libraryPanel?: { uid: undefined; name: string } | Pick<LibraryPanelDTO, 'uid' | 'name' | 'meta'>;
|
||||||
|
|
||||||
// non persisted
|
// non persisted
|
||||||
isViewing: boolean;
|
isViewing: boolean;
|
||||||
isEditing: 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;
|
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 {
|
interface PanelOptionsCache {
|
||||||
properties: any;
|
properties: any;
|
||||||
fieldConfig: FieldConfigSource;
|
fieldConfig: FieldConfigSource;
|
||||||
|
@ -46,7 +46,12 @@ export const duplicatePanel = (dashboard: DashboardModel, panel: PanelModel) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const copyPanel = (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']);
|
appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Open Add Panel to paste']);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
`,
|
||||||
|
});
|
@ -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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
@ -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};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
@ -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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
@ -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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
@ -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};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -12,6 +12,7 @@ export interface LibraryPanelDTO {
|
|||||||
|
|
||||||
export interface LibraryPanelDTOMeta {
|
export interface LibraryPanelDTOMeta {
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
connectedDashboards: number;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
createdBy: LibraryPanelDTOMetaUser;
|
createdBy: LibraryPanelDTOMetaUser;
|
||||||
|
54
public/app/features/library-panels/utils/usePanelSave.ts
Normal file
54
public/app/features/library-panels/utils/usePanelSave.ts
Normal 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 };
|
||||||
|
};
|
@ -23,7 +23,7 @@ export interface ShowModalPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ShowModalReactPayload {
|
export interface ShowModalReactPayload {
|
||||||
component: React.ComponentType;
|
component: React.ComponentType<any>;
|
||||||
props?: any;
|
props?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user