diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 699ab07e244..3ee46e3beb6 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -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 diff --git a/packages/grafana-data/src/utils/store.test.ts b/packages/grafana-data/src/utils/store.test.ts new file mode 100644 index 00000000000..9ea20aed236 --- /dev/null +++ b/packages/grafana-data/src/utils/store.test.ts @@ -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); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/store.ts b/packages/grafana-data/src/utils/store.ts index 3942f408a73..8d5a5b1e933 100644 --- a/packages/grafana-data/src/utils/store.ts +++ b/packages/grafana-data/src/utils/store.ts @@ -1,12 +1,49 @@ type StoreValue = string | number | boolean | null; +type StoreSubscriber = () => void; export class Store { + private subscribers: Map> = 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); } } diff --git a/public/app/core/store.ts b/public/app/core/store.ts index 28587a7d428..5d64753e7e5 100644 --- a/public/app/core/store.ts +++ b/public/app/core/store.ts @@ -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(key: string): T | undefined; - getObject(key: string, def: T): T; - getObject(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; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx index f58103b09c9..154a0e1edb6 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardAddPane.tsx @@ -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 ( @@ -70,6 +83,20 @@ export function DashboardAddPane({ editPane }: Props) { + {hasCopiedPanel && ( + dashboard.pastePanel()} + data-testid={selectors.components.PageToolbar.itemButton('paste_panel')} + title={t('dashboard.toolbar.paste-panel-description', 'Paste a panel from the clipboard')} + > + + Paste panel + + + + + + )} ); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9e12bd9e2c1..fb3321c2074 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",