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:
Oscar Kilhed
2025-03-18 11:49:33 +01:00
committed by GitHub
parent afb058c40a
commit ba44ceb4b2
6 changed files with 152 additions and 63 deletions
+1 -1
View File
@@ -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);
});
});
});
+38
View File
@@ -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 -62
View File
@@ -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>
);
}
+2
View File
@@ -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",