Dynamic dashboards: Add paste panel option to add pane (#102350)
* Add paste panel to side pane, unify Store class, subscribe to storage events * i18n
This commit is contained in:
@@ -255,7 +255,7 @@ export { UnaryOperationID, type UnaryOperation, unaryOperators } from './utils/u
|
||||
export { NodeGraphDataFrameFieldNames } from './utils/nodeGraph';
|
||||
export { toOption } from './utils/selectUtils';
|
||||
export * as arrayUtils from './utils/arrayUtils';
|
||||
export { store } from './utils/store';
|
||||
export { store, Store } from './utils/store';
|
||||
export { LocalStorageValueProvider } from './utils/LocalStorageValueProvider';
|
||||
|
||||
// Tranformations
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Store } from './store';
|
||||
|
||||
describe('Store', () => {
|
||||
let store: Store;
|
||||
let mockStorage: { [key: string]: string };
|
||||
|
||||
beforeEach(() => {
|
||||
mockStorage = {};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: new Proxy(mockStorage, {
|
||||
get(target, prop) {
|
||||
if (prop === 'removeItem') {
|
||||
return (key: string) => {
|
||||
delete target[key];
|
||||
};
|
||||
}
|
||||
return target[prop as string];
|
||||
},
|
||||
set(target, prop, value) {
|
||||
target[prop as string] = value;
|
||||
return true;
|
||||
},
|
||||
deleteProperty(target, prop) {
|
||||
delete target[prop as string];
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
writable: true,
|
||||
});
|
||||
store = new Store();
|
||||
});
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('should call subscriber when value changes', () => {
|
||||
const testKey = 'test-key';
|
||||
const subscriber = jest.fn();
|
||||
const unsubscribe = store.subscribe(testKey, subscriber);
|
||||
|
||||
store.set(testKey, 'test-value');
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
store.set(testKey, 'another-value');
|
||||
expect(subscriber).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsubscribe();
|
||||
store.set(testKey, 'third-value');
|
||||
expect(subscriber).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle multiple subscribers for the same key', () => {
|
||||
const testKey = 'test-key';
|
||||
const subscriber1 = jest.fn();
|
||||
const subscriber2 = jest.fn();
|
||||
|
||||
store.subscribe(testKey, subscriber1);
|
||||
store.subscribe(testKey, subscriber2);
|
||||
|
||||
store.set(testKey, 'test-value');
|
||||
|
||||
expect(subscriber1).toHaveBeenCalledTimes(1);
|
||||
expect(subscriber2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage operations', () => {
|
||||
it('should store and retrieve values without subscribers', () => {
|
||||
const testKey = 'test-key';
|
||||
const testValue = 'test-value';
|
||||
|
||||
store.set(testKey, testValue);
|
||||
expect(store.get(testKey)).toBe(testValue);
|
||||
|
||||
const newValue = 'new-value';
|
||||
store.set(testKey, newValue);
|
||||
expect(store.get(testKey)).toBe(newValue);
|
||||
|
||||
store.delete(testKey);
|
||||
expect(store.exists(testKey)).toBe(false);
|
||||
expect(store.get(testKey)).toBe(undefined);
|
||||
|
||||
expect(window.localStorage[testKey]).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,49 @@
|
||||
type StoreValue = string | number | boolean | null;
|
||||
type StoreSubscriber = () => void;
|
||||
|
||||
export class Store {
|
||||
private subscribers: Map<string, Set<StoreSubscriber>> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Changes from other tabs
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key) {
|
||||
this.notifySubscribers(e.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private notifySubscribers(key: string) {
|
||||
const keySubscribers = this.subscribers.get(key);
|
||||
if (keySubscribers) {
|
||||
keySubscribers.forEach((subscriber) => subscriber());
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(key: string, callback: StoreSubscriber) {
|
||||
if (!this.subscribers.has(key)) {
|
||||
this.subscribers.set(key, new Set());
|
||||
}
|
||||
this.subscribers.get(key)!.add(callback);
|
||||
|
||||
return () => {
|
||||
const keySubscribers = this.subscribers.get(key);
|
||||
if (keySubscribers) {
|
||||
keySubscribers.delete(callback);
|
||||
if (keySubscribers.size === 0) {
|
||||
this.subscribers.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return window.localStorage[key];
|
||||
}
|
||||
|
||||
set(key: string, value: StoreValue) {
|
||||
window.localStorage[key] = value;
|
||||
this.notifySubscribers(key);
|
||||
}
|
||||
|
||||
getBool(key: string, def: boolean): boolean {
|
||||
@@ -58,6 +95,7 @@ export class Store {
|
||||
|
||||
delete(key: string) {
|
||||
window.localStorage.removeItem(key);
|
||||
this.notifySubscribers(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +1,4 @@
|
||||
type StoreValue = string | number | boolean | null;
|
||||
|
||||
export class Store {
|
||||
get(key: string) {
|
||||
return window.localStorage[key];
|
||||
}
|
||||
|
||||
set(key: string, value: StoreValue) {
|
||||
window.localStorage[key] = value;
|
||||
}
|
||||
|
||||
getBool(key: string, def: boolean): boolean {
|
||||
if (def !== void 0 && !this.exists(key)) {
|
||||
return def;
|
||||
}
|
||||
return window.localStorage[key] === 'true';
|
||||
}
|
||||
|
||||
getObject<T = unknown>(key: string): T | undefined;
|
||||
getObject<T = unknown>(key: string, def: T): T;
|
||||
getObject<T = unknown>(key: string, def?: T) {
|
||||
let ret = def;
|
||||
if (this.exists(key)) {
|
||||
const json = window.localStorage[key];
|
||||
try {
|
||||
ret = JSON.parse(json);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing store object: ${key}. Returning default: ${def}. [${error}]`);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Returns true when successfully stored, throws error if not successfully stored */
|
||||
setObject(key: string, value: unknown) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not stringify object: ${key}. [${error}]`);
|
||||
}
|
||||
try {
|
||||
this.set(key, json);
|
||||
} catch (error) {
|
||||
// Likely hitting storage quota
|
||||
const errorToThrow = new Error(`Could not save item in localStorage: ${key}. [${error}]`);
|
||||
if (error instanceof Error) {
|
||||
errorToThrow.name = error.name;
|
||||
}
|
||||
throw errorToThrow;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
exists(key: string) {
|
||||
return window.localStorage[key] !== void 0;
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
import { Store } from '@grafana/data';
|
||||
|
||||
const store = new Store();
|
||||
export default store;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Box, Card, Icon } from '@grafana/ui';
|
||||
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import store from 'app/core/store';
|
||||
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
@@ -13,6 +17,15 @@ export interface Props {
|
||||
|
||||
export function DashboardAddPane({ editPane }: Props) {
|
||||
const dashboard = getDashboardSceneFor(editPane);
|
||||
const [hasCopiedPanel, setHasCopiedPanel] = useState(store.exists(LS_PANEL_COPY_KEY));
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe(LS_PANEL_COPY_KEY, () => {
|
||||
setHasCopiedPanel(store.exists(LS_PANEL_COPY_KEY));
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box display={'flex'} direction={'column'} gap={1} padding={2}>
|
||||
@@ -70,6 +83,20 @@ export function DashboardAddPane({ editPane }: Props) {
|
||||
<Icon name="layer-group" size="xl" />
|
||||
</Card.Figure>
|
||||
</Card>
|
||||
{hasCopiedPanel && (
|
||||
<Card
|
||||
onClick={() => dashboard.pastePanel()}
|
||||
data-testid={selectors.components.PageToolbar.itemButton('paste_panel')}
|
||||
title={t('dashboard.toolbar.paste-panel-description', 'Paste a panel from the clipboard')}
|
||||
>
|
||||
<Card.Heading>
|
||||
<Trans i18nKey="dashboard.toolbar.paste-panel">Paste panel</Trans>
|
||||
</Card.Heading>
|
||||
<Card.Figure>
|
||||
<Icon name="clipboard-alt" size="xl" />
|
||||
</Card.Figure>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1358,6 +1358,8 @@
|
||||
"mark-favorite": "Mark as favorite",
|
||||
"more-save-options": "More save options",
|
||||
"open-original": "Open original dashboard",
|
||||
"paste-panel": "Paste panel",
|
||||
"paste-panel-description": "Paste a panel from the clipboard",
|
||||
"playlist-next": "Go to next dashboard",
|
||||
"playlist-previous": "Go to previous dashboard",
|
||||
"playlist-stop": "Stop playlist",
|
||||
|
||||
Reference in New Issue
Block a user