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';
|
||||
|
||||
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 }) => {
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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 './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';
|
||||
|
@ -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',
|
||||
];
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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}>
|
||||
|
@ -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}</>;
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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'}
|
||||
|
@ -92,6 +92,7 @@ export class DashboardModel {
|
||||
templating: true, // needs special handling
|
||||
originalTime: true,
|
||||
originalTemplating: true,
|
||||
originalLibraryPanels: true,
|
||||
panelInEdit: true,
|
||||
panelInView: true,
|
||||
getVariablesFromState: true,
|
||||
|
@ -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;
|
||||
|
@ -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']);
|
||||
};
|
||||
|
||||
|
@ -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 {
|
||||
canEdit: boolean;
|
||||
connectedDashboards: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
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 {
|
||||
component: React.ComponentType;
|
||||
component: React.ComponentType<any>;
|
||||
props?: any;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user