diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 31286995cc0..ed6bbb27128 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -56,3 +56,4 @@ export { type EmbeddedDashboardProps, EmbeddedDashboard, setEmbeddedDashboard } export { hasPermission, hasPermissionInMetadata, hasAllPermissions, hasAnyPermission } from './utils/rbac'; export { QueryEditorWithMigration } from './components/QueryEditorWithMigration'; export { type MigrationHandler, isMigrationHandler, migrateQuery, migrateRequest } from './utils/migrationHandler'; +export { usePluginUserStorage } from './utils/userStorage'; diff --git a/packages/grafana-runtime/src/utils/userStorage.test.tsx b/packages/grafana-runtime/src/utils/userStorage.test.tsx new file mode 100644 index 00000000000..4f28ffae6ff --- /dev/null +++ b/packages/grafana-runtime/src/utils/userStorage.test.tsx @@ -0,0 +1,145 @@ +import { cloneDeep } from 'lodash'; +import { of } from 'rxjs'; + +import { config } from '../config'; +import { BackendSrvRequest, FetchError, FetchResponse, BackendSrv } from '../services'; + +import { usePluginUserStorage } from './userStorage'; + +const request = jest.fn, BackendSrvRequest[]>(); + +const backendSrv = { + fetch: (options: BackendSrvRequest) => { + return of(request(options)); + }, +} as unknown as BackendSrv; + +jest.mock('../services', () => ({ + ...jest.requireActual('../services'), + getBackendSrv: () => backendSrv, +})); + +jest.mock('@grafana/data', () => ({ + ...jest.requireActual('@grafana/data'), + usePluginContext: jest.fn().mockReturnValue({ meta: { id: 'plugin-id' } }), +})); + +describe('userStorage', () => { + const originalGetItem = Storage.prototype.getItem; + const originalSetItem = Storage.prototype.setItem; + const originalConfig = cloneDeep(config); + + beforeEach(() => { + config.featureToggles.userStorageAPI = true; + config.bootData.user.isSignedIn = true; + config.bootData.user.uid = 'abc'; + request.mockReset(); + Storage.prototype.setItem = jest.fn(); + Storage.prototype.getItem = jest.fn(); + }); + + afterEach(() => { + Storage.prototype.setItem = originalSetItem; + Storage.prototype.getItem = originalGetItem; + config.featureToggles = originalConfig.featureToggles; + config.bootData = originalConfig.bootData; + }); + + describe('UserStorageAPI.getItem', () => { + it('use localStorage if the feature flag is disabled', async () => { + config.featureToggles.userStorageAPI = false; + const storage = usePluginUserStorage(); + storage.getItem('key'); + expect(localStorage.getItem).toHaveBeenCalled(); + }); + + it('use localStorage if the user is not logged in', async () => { + config.bootData.user.isSignedIn = false; + const storage = usePluginUserStorage(); + storage.getItem('key'); + expect(localStorage.getItem).toHaveBeenCalled(); + }); + + it('use localStorage if the user storage is not found', async () => { + request.mockReturnValue(Promise.reject({ status: 404 } as FetchError)); + const storage = usePluginUserStorage(); + await storage.getItem('key'); + expect(localStorage.getItem).toHaveBeenCalled(); + }); + + it('returns the value from the user storage', async () => { + request.mockReturnValue( + Promise.resolve({ status: 200, data: { spec: { data: { key: 'value' } } } } as FetchResponse) + ); + const storage = usePluginUserStorage(); + const value = await storage.getItem('key'); + expect(value).toBe('value'); + }); + }); + + describe('setItem', () => { + it('use localStorage if the feature flag is disabled', async () => { + config.featureToggles.userStorageAPI = false; + const storage = usePluginUserStorage(); + storage.setItem('key', 'value'); + expect(localStorage.setItem).toHaveBeenCalled(); + }); + + it('use localStorage if the user is not logged in', async () => { + config.bootData.user.isSignedIn = false; + const storage = usePluginUserStorage(); + storage.setItem('key', 'value'); + expect(localStorage.setItem).toHaveBeenCalled(); + }); + + it('creates a new user storage if it does not exist', async () => { + request.mockReturnValueOnce(Promise.reject({ status: 404 } as FetchError)); + const storage = usePluginUserStorage(); + await storage.setItem('key', 'value'); + expect(request).toHaveBeenCalledWith({ + url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc', + method: 'GET', + showErrorAlert: false, + }); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/', + method: 'POST', + data: { + metadata: { labels: { service: 'plugin-id', user: 'abc' }, name: 'plugin-id:abc' }, + spec: { + data: { key: 'value' }, + }, + }, + }) + ); + }); + + it('updates the user storage if it exists', async () => { + request.mockReturnValueOnce( + Promise.resolve({ + status: 200, + data: { metadata: { name: 'service:abc' }, spec: { data: { key: 'value' } } }, + } as FetchResponse) + ); + const storage = usePluginUserStorage(); + await storage.setItem('key', 'new-value'); + expect(request).toHaveBeenCalledWith({ + url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc', + method: 'GET', + showErrorAlert: false, + }); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc', + method: 'PATCH', + data: { + spec: { + data: { key: 'new-value' }, + }, + }, + }) + ); + }); + }); +}); diff --git a/packages/grafana-runtime/src/utils/userStorage.tsx b/packages/grafana-runtime/src/utils/userStorage.tsx new file mode 100644 index 00000000000..ccdab4f4534 --- /dev/null +++ b/packages/grafana-runtime/src/utils/userStorage.tsx @@ -0,0 +1,153 @@ +import { get } from 'lodash'; +import { lastValueFrom } from 'rxjs'; + +import { usePluginContext } from '@grafana/data'; + +import { config } from '../config'; +import { BackendSrvRequest, getBackendSrv } from '../services'; + +const baseURL = `/apis/userstorage.grafana.app/v0alpha1/namespaces/${config.namespace}/user-storage`; + +interface RequestOptions extends BackendSrvRequest { + manageError?: (err: unknown) => { error: unknown }; + showErrorAlert?: boolean; + + // rtk codegen sets this + body?: BackendSrvRequest['data']; +} + +export type UserStorageSpec = { + data: { [key: string]: string }; +}; + +async function apiRequest(requestOptions: RequestOptions) { + try { + const { data: responseData, ...meta } = await lastValueFrom( + getBackendSrv().fetch({ + ...requestOptions, + url: baseURL + requestOptions.url, + data: requestOptions.body, + }) + ); + return { data: responseData, meta }; + } catch (error) { + return requestOptions.manageError ? requestOptions.manageError(error) : { error }; + } +} + +/** + * A class for interacting with the backend user storage. + * Unexported because it is currently only be used through the useUserStorage hook. + */ +class UserStorage { + private service: string; + private resourceName: string; + private userUID: string; + private canUseUserStorage: boolean; + private storageSpec: UserStorageSpec | null | undefined; + + constructor(service: string) { + this.service = service; + this.userUID = config.bootData.user.uid === '' ? config.bootData.user.id.toString() : config.bootData.user.uid; + this.resourceName = `${service}:${this.userUID}`; + this.canUseUserStorage = config.featureToggles.userStorageAPI === true && config.bootData.user.isSignedIn; + } + + private async init() { + if (this.storageSpec !== undefined) { + return; + } + const userStorage = await apiRequest<{ spec: UserStorageSpec }>({ + url: `/${this.resourceName}`, + method: 'GET', + showErrorAlert: false, + }); + if ('error' in userStorage) { + if (get(userStorage, 'error.status') !== 404) { + console.error('Failed to get user storage', userStorage.error); + } + // No user storage found, return null + this.storageSpec = null; + } else { + this.storageSpec = userStorage.data.spec; + } + } + + async getItem(key: string): Promise { + if (!this.canUseUserStorage) { + // Fallback to localStorage + return localStorage.getItem(this.resourceName); + } + // Ensure this.storageSpec is initialized + await this.init(); + if (!this.storageSpec) { + // Also, fallback to localStorage for backward compatibility once userStorageAPI is enabled + return localStorage.getItem(this.resourceName); + } + return this.storageSpec.data[key]; + } + + async setItem(key: string, value: string): Promise { + if (!this.canUseUserStorage) { + // Fallback to localStorage + localStorage.setItem(key, value); + return; + } + + const newData = { data: { [key]: value } }; + // Ensure this.storageSpec is initialized + await this.init(); + + if (!this.storageSpec) { + // No user storage found, create a new one + await apiRequest({ + url: `/`, + method: 'POST', + body: { + metadata: { name: this.resourceName, labels: { user: this.userUID, service: this.service } }, + spec: newData, + }, + }); + this.storageSpec = newData; + return; + } + + // Update existing user storage + this.storageSpec.data[key] = value; + await apiRequest({ + headers: { 'Content-Type': 'application/merge-patch+json' }, + url: `/${this.resourceName}`, + method: 'PATCH', + body: { spec: newData }, + }); + } +} + +export interface PluginUserStorage { + /** + * Retrieves an item from the backend user storage or local storage if not enabled. + * @param key - The key of the item to retrieve. + * @returns A promise that resolves to the item value or null if not found. + */ + getItem(key: string): Promise; + /** + * Sets an item in the backend user storage or local storage if not enabled. + * @param key - The key of the item to set. + * @param value - The value of the item to set. + * @returns A promise that resolves when the item is set. + */ + setItem(key: string, value: string): Promise; +} + +/** + * A hook for interacting with the backend user storage (or local storage if not enabled). + * @returns An scoped object for a plugin and a user with getItem and setItem functions. + * @alpha Experimental + */ +export function usePluginUserStorage(): PluginUserStorage { + const context = usePluginContext(); + if (!context) { + throw new Error(`No PluginContext found. The usePluginUserStorage() hook can only be used from a plugin.`); + } + return new UserStorage(context?.meta.id); +}