diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 87e831494aa..5db3b71773e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3154,16 +3154,6 @@ "count": 1 } }, - "public/app/features/plugins/extensions/usePluginComponents.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "public/app/features/plugins/extensions/usePluginFunctions.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/plugins/sandbox/distortions.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 5968bb6fc42..c81fd7c26f4 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -1,5 +1,5 @@ import { isString } from 'lodash'; -import { combineLatest, filter, map, Observable } from 'rxjs'; +import { combineLatest, map, Observable } from 'rxjs'; import { PluginExtensionTypes, @@ -37,16 +37,22 @@ import { export const getObservablePluginExtensions = ( options: Omit ): Observable> => { + const { extensionPointId } = options; + const { addedComponentsRegistry, addedLinksRegistry } = pluginExtensionRegistries; + return combineLatest([ - pluginExtensionRegistries.addedComponentsRegistry.asObservable(), - pluginExtensionRegistries.addedLinksRegistry.asObservable(), + addedComponentsRegistry.asObservableSlice((state) => state[extensionPointId]), + addedLinksRegistry.asObservableSlice((state) => state[extensionPointId]), ]).pipe( - filter(([components, links]) => Boolean(components) && Boolean(links)), // filter out uninitialized registries map(([components, links]) => getPluginExtensions({ ...options, - addedComponentsRegistry: components, - addedLinksRegistry: links, + addedComponentsRegistry: { + [extensionPointId]: components, + }, + addedLinksRegistry: { + [extensionPointId]: links, + }, }) ) ); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index 5a18e222431..98c0fb1781b 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, take } from 'rxjs'; import { PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -537,4 +537,238 @@ describe('AddedComponentsRegistry', () => { expect(Object.keys(currentState)).toHaveLength(1); expect(log.warning).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected slice from the registry', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Component'); + }); + }); + + it('should return undefined when the selected key does not exist', async () => { + const registry = new AddedComponentsRegistry(); + const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + expect(slice).toBeUndefined(); + }); + }); + + it('should only emit when the selected slice changes', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial empty state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first component + registry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Component 1', + description: 'Description 1', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Component 1'), + }, + ], + }); + + // Should emit because the slice changed + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + + // Register another component to the same extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Component 2', + description: 'Description 2', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Component 2'), + }, + ], + }); + + // Should emit because the slice changed (array reference changed) + expect(subscribeCallback).toHaveBeenCalledTimes(3); + expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2); + + // Register a component to a different extension point + registry.register({ + pluginId: 'test-plugin-3', + configs: [ + { + title: 'Component 3', + description: 'Description 3', + targets: ['grafana/other/point'], + component: () => React.createElement('div', null, 'Component 3'), + }, + ], + }); + + // Should NOT emit because the selected slice (for extensionPointId) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(3); + }); + + it('should deep freeze the selected slice', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + // @ts-expect-error - Testing that frozen objects cannot be modified + expect(() => slice.push({})).toThrow(); + expect(() => (slice[0].title = 'Modified')).toThrow(); + }); + }); + + it('should work with read-only registries', async () => { + const registry = new AddedComponentsRegistry(); + const readOnlyRegistry = registry.readOnly(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Component'); + }); + }); + + it('should emit immediately to new subscribers with the current slice value', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + // Subscribe after registration + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + const subscribeCallback = jest.fn(); + observable.subscribe(subscribeCallback); + + // Should have been called immediately with the current value + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1); + expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Component'); + }); + + it('should not emit when Object.is returns true for the same value', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + // Register a component + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + // Should emit once more + expect(subscribeCallback).toHaveBeenCalledTimes(2); + const firstValue = subscribeCallback.mock.calls[1][0]; + + // Register another component to a different extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Other Component', + description: 'Other description', + targets: ['grafana/other/point'], + component: () => React.createElement('div', null, 'Other'), + }, + ], + }); + + // Should NOT emit because the selected slice (same reference) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(2); + const secondValue = subscribeCallback.mock.calls[1][0]; + expect(Object.is(firstValue, secondValue)).toBe(true); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index de732ce8c6d..b63f6405bc3 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -74,11 +74,9 @@ export class AddedComponentsRegistry extends Registry< pointIdLog.debug('Added component extension successfully registered'); - if (!(extensionPointId in registry)) { - registry[extensionPointId] = [result]; - } else { - registry[extensionPointId].push(result); - } + // Creating a new array instead of pushing to get a new reference + const slice = registry[extensionPointId] ?? []; + registry[extensionPointId] = slice.concat(result); } } diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts index ae0addc52c7..4c68a3a3eac 100644 --- a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, take } from 'rxjs'; import { PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -120,6 +120,61 @@ describe('addedFunctionsRegistry', () => { ], }); }); + + it('should not emit when registering with empty configs', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + const observable = addedFunctionsRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + // Initial subscription should be called once with empty registry + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + // Register with empty configs - should not emit + addedFunctionsRegistry.register({ + pluginId, + configs: [], + }); + + // Should still only be called once (no new emission) + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toEqual({}); + + // Verify registry state is still empty + const registry = await addedFunctionsRegistry.getState(); + expect(registry).toEqual({}); + }); + + it('should not change registry state when registering with empty configs after previous registrations', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + + // First register some extensions + addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn(), + }, + ], + }); + + const registryBefore = await addedFunctionsRegistry.getState(); + expect(Object.keys(registryBefore)).toHaveLength(1); + + // Register with empty configs - should not change state + addedFunctionsRegistry.register({ + pluginId, + configs: [], + }); + + const registryAfter = await addedFunctionsRegistry.getState(); + expect(registryAfter).toEqual(registryBefore); + }); + it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => { const pluginId1 = 'grafana-basic-app'; const pluginId2 = 'grafana-basic-app2'; @@ -674,4 +729,191 @@ describe('addedFunctionsRegistry', () => { expect(Object.keys(currentState)).toHaveLength(1); expect(log.error).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected slice from the registry', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Function'); + }); + }); + + it('should return undefined when the selected key does not exist', async () => { + const registry = new AddedFunctionsRegistry(); + const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + expect(slice).toBeUndefined(); + }); + }); + + it('should only emit when the selected slice changes (distinctUntilChanged)', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial empty state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first function + registry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Function 1', + description: 'Description 1', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + // Should emit because the slice changed + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + + // Register another function to the same extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Function 2', + description: 'Description 2', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + // Should emit because the slice changed (array reference changed) + expect(subscribeCallback).toHaveBeenCalledTimes(3); + expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2); + + // Register a function to a different extension point + registry.register({ + pluginId: 'test-plugin-3', + configs: [ + { + title: 'Function 3', + description: 'Description 3', + targets: 'grafana/other/point', + fn: jest.fn(), + }, + ], + }); + + // Should NOT emit because the selected slice (for extensionPointId) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(3); + }); + + it('should deep freeze the selected slice', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + // @ts-expect-error - Testing that frozen objects cannot be modified + expect(() => slice.push({})).toThrow(); + expect(() => (slice[0].title = 'Modified')).toThrow(); + }); + }); + + it('should work with read-only registries', async () => { + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Function'); + }); + }); + + it('should emit immediately to new subscribers with the current slice value', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + // Subscribe after registration + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + const subscribeCallback = jest.fn(); + observable.subscribe(subscribeCallback); + + // Should have been called immediately with the current value + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1); + expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Function'); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts index d23fe5e78b2..a57d4a83d3e 100644 --- a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts @@ -11,10 +11,10 @@ import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; const logPrefix = 'Could not register function extension. Reason:'; -export type AddedFunctionsRegistryItem = { +export type AddedFunctionsRegistryItem = { pluginId: string; title: string; - fn: unknown; + fn: Signature; description?: string; }; @@ -67,11 +67,9 @@ export class AddedFunctionsRegistry extends Registry { expect(Object.keys(currentState)).toHaveLength(1); expect(log.error).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected slice from the registry', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Link'); + }); + }); + + it('should return undefined when the selected key does not exist', async () => { + const registry = new AddedLinksRegistry(); + const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + expect(slice).toBeUndefined(); + }); + }); + + it('should only emit when the selected slice changes (distinctUntilChanged)', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial empty state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first link + registry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Link 1', + description: 'Description 1', + path: '/path1', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Should emit because the slice changed + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + + // Register another link to the same extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Link 2', + description: 'Description 2', + path: '/path2', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Should emit because the slice changed (array reference changed) + expect(subscribeCallback).toHaveBeenCalledTimes(3); + expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2); + + // Register a link to a different extension point + registry.register({ + pluginId: 'test-plugin-3', + configs: [ + { + title: 'Link 3', + description: 'Description 3', + path: '/path3', + targets: 'grafana/other/point', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Should NOT emit because the selected slice (for extensionPointId) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(3); + }); + + it('should deep freeze the selected slice', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + // @ts-expect-error - Testing that frozen objects cannot be modified + expect(() => slice.push({})).toThrow(); + expect(() => (slice[0].title = 'Modified')).toThrow(); + }); + }); + + it('should work with read-only registries', async () => { + const registry = new AddedLinksRegistry(); + const readOnlyRegistry = registry.readOnly(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Link'); + }); + }); + + it('should emit immediately to new subscribers with the current slice value', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Subscribe after registration + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + const subscribeCallback = jest.fn(); + observable.subscribe(subscribeCallback); + + // Should have been called immediately with the current value + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1); + expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Link'); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts index 5d6e8be7d72..b3f293675ce 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -74,13 +74,12 @@ export class AddedLinksRegistry extends Registry { expect(Object.keys(currentState)).toHaveLength(1); expect(log.error).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected exposed component from the registry', async () => { + const registry = new ExposedComponentsRegistry(); + const componentId = 'test-plugin/exposed-component/v1'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId, + title: 'Exposed Component', + description: 'Test description', + component: () => React.createElement('div', null, 'Exposed'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[componentId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(slice?.title).toBe('Exposed Component'); + expect(slice?.pluginId).toBe('test-plugin'); + }); + }); + + it('should deep freeze exposed components', async () => { + const registry = new ExposedComponentsRegistry(); + const componentId = 'test-plugin/exposed-component/v1'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId, + title: 'Exposed Component', + description: 'Test description', + component: () => React.createElement('div', null, 'Exposed'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[componentId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(() => (slice.title = 'Modified')).toThrow(); + }); + }); + + it('should only emit when the selected exposed component changes', async () => { + const registry = new ExposedComponentsRegistry(); + const componentId1 = 'test-plugin/component1/v1'; + const componentId2 = 'test-plugin/component2/v1'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[componentId1]); + observable.subscribe(subscribeCallback); + + // Initial state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first component + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId1, + title: 'Component 1', + description: 'Description 1', + component: () => React.createElement('div', null, 'Component 1'), + }, + ], + }); + + // Should emit + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.title).toBe('Component 1'); + + // Register second component (different id) + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId2, + title: 'Component 2', + description: 'Description 2', + component: () => React.createElement('div', null, 'Component 2'), + }, + ], + }); + + // Should NOT emit because the selected slice (componentId1) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/Registry.test.ts b/public/app/features/plugins/extensions/registry/Registry.test.ts new file mode 100644 index 00000000000..34e84b36886 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/Registry.test.ts @@ -0,0 +1,89 @@ +import React from 'react'; + +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; + +describe('Registry.asObservableSlice ', () => { + describe('Shared behaviour', () => { + it('should handle selecting a non-existent key that later gets added', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initially undefined + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register component + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + // Should emit the new value + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + }); + + it('should handle multiple subscribers independently', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId1 = 'grafana/alerting/home'; + const extensionPointId2 = 'grafana/other/point'; + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + const observable1 = registry.asObservableSlice((state) => state[extensionPointId1]); + const observable2 = registry.asObservableSlice((state) => state[extensionPointId2]); + + observable1.subscribe(callback1); + observable2.subscribe(callback2); + + // Both should receive initial undefined + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + + // Register component for extensionPointId1 + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Component 1', + description: 'Description 1', + targets: [extensionPointId1], + component: () => React.createElement('div', null, 'Component 1'), + }, + ], + }); + + // Only callback1 should be called + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(1); + + // Register component for extensionPointId2 + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Component 2', + description: 'Description 2', + targets: [extensionPointId2], + component: () => React.createElement('div', null, 'Component 2'), + }, + ], + }); + + // Only callback2 should be called + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/Registry.ts b/public/app/features/plugins/extensions/registry/Registry.ts index b2fbfbc09d8..d37e5276bc6 100644 --- a/public/app/features/plugins/extensions/registry/Registry.ts +++ b/public/app/features/plugins/extensions/registry/Registry.ts @@ -1,4 +1,4 @@ -import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs'; +import { Observable, ReplaySubject, Subject, distinctUntilChanged, firstValueFrom, map, scan, startWith } from 'rxjs'; import { ExtensionsLog, log } from '../logs/log'; import { deepFreeze } from '../utils'; @@ -13,7 +13,7 @@ export type PluginExtensionConfigs = { export type RegistryType = Record; // This is the base-class used by the separate specific registries. -export abstract class Registry { +export abstract class Registry, TMapType> { // Used in cases when we would like to pass a read-only registry to plugin. // In these cases we are passing in the `registrySubject` to the constructor. // (If TRUE `initialState` is ignored.) @@ -38,7 +38,6 @@ export abstract class Registry { if (options.registrySubject) { this.registrySubject = options.registrySubject; this.isReadOnly = true; - return; } @@ -47,8 +46,7 @@ export abstract class Registry { .pipe( scan(this.mapToRegistry.bind(this), options.initialState ?? {}), // Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values) - startWith(options.initialState ?? {}), - map((registry) => deepFreeze(registry)) + startWith(options.initialState ?? {}) ) // Emitting the new registry to `this.registrySubject` .subscribe(this.registrySubject); @@ -63,12 +61,22 @@ export abstract class Registry { if (this.isReadOnly) { throw new Error(MSG_CANNOT_REGISTER_READ_ONLY); } - + if (result.configs.length === 0) { + return; + } this.resultSubject.next(result); } asObservable(): Observable> { - return this.registrySubject.asObservable(); + return this.registrySubject.asObservable().pipe(map((state) => deepFreeze(state))); + } + + asObservableSlice(selector: (state: RegistryType) => TRegistryValue): Observable { + return this.registrySubject.asObservable().pipe( + map((state) => selector(state)), + distinctUntilChanged((prev, curr) => Object.is(prev, curr)), + map((slice) => deepFreeze(slice)) + ); } getState(): Promise> { diff --git a/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx b/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx new file mode 100644 index 00000000000..c31797ea2dd --- /dev/null +++ b/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx @@ -0,0 +1,410 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; + +import { ExtensionRegistriesProvider } from '../ExtensionRegistriesContext'; + +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; +import { AddedLinksRegistry } from './AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './types'; +import { + useAddedComponentsRegistrySlice, + useAddedFunctionsRegistrySlice, + useAddedLinksRegistrySlice, + useExposedComponentRegistrySlice, +} from './useRegistrySlice'; + +describe('useRegistrySlice', () => { + let registries: PluginExtensionRegistries; + let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; + + beforeEach(() => { + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), + }; + + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }); + + describe('useAddedComponentsRegistrySlice', () => { + it('should return undefined when no components are registered for the extension point', () => { + const { result } = renderHook(() => useAddedComponentsRegistrySlice('test/extension-point'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return an array of components when components are registered for the extension point', () => { + const extensionPointId = 'test/extension-point'; + const TestComponent = () =>
Test Component
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + component: TestComponent, + targets: extensionPointId, + description: 'Test description', + }, + ], + }); + + const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Component'); + expect(result.current?.[0].pluginId).toBe('test-plugin'); + expect(result.current?.[0].description).toBe('Test description'); + }); + + it('should return multiple components when multiple components are registered for the same extension point', () => { + const extensionPointId = 'test/extension-point'; + const TestComponent1 = () =>
Test Component 1
; + const TestComponent2 = () =>
Test Component 2
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Test Component 1', + component: TestComponent1, + targets: extensionPointId, + description: 'Test description 1', + }, + ], + }); + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Test Component 2', + component: TestComponent2, + targets: extensionPointId, + description: 'Test description 2', + }, + ], + }); + + const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(2); + expect(result.current?.[0].title).toBe('Test Component 1'); + expect(result.current?.[0].description).toBe('Test description 1'); + expect(result.current?.[1].title).toBe('Test Component 2'); + expect(result.current?.[1].description).toBe('Test description 2'); + }); + + it('should only return components for the specified extension point', () => { + const extensionPointId1 = 'test/extension-point-1'; + const extensionPointId2 = 'test/extension-point-2'; + const TestComponent1 = () =>
Test Component 1
; + const TestComponent2 = () =>
Test Component 2
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component 1', + component: TestComponent1, + targets: extensionPointId1, + description: 'Test description 1', + }, + { + title: 'Test Component 2', + component: TestComponent2, + targets: extensionPointId2, + description: 'Test description 2', + }, + ], + }); + + const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId1), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Component 1'); + expect(result.current?.[0].description).toBe('Test description 1'); + }); + }); + + describe('useExposedComponentsRegistrySlice', () => { + it('should return undefined when no component is exposed for the extension point', () => { + const { result } = renderHook(() => useExposedComponentRegistrySlice('test/exposed-component'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return a single exposed component when registered', () => { + const exposedComponentId = 'test-plugin/exposed-component'; + const TestComponent = () =>
Exposed Component
; + + registries.exposedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: exposedComponentId, + title: 'Exposed Component', + component: TestComponent, + }, + ], + }); + + const { result } = renderHook(() => useExposedComponentRegistrySlice(exposedComponentId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.title).toBe('Exposed Component'); + expect(result.current?.pluginId).toBe('test-plugin'); + expect(Array.isArray(result.current)).toBe(false); + }); + + it('should return undefined for a different extension point id', () => { + const exposedComponentId = 'test-plugin/exposed-component'; + const otherId = 'test-plugin/other-component'; + const TestComponent = () =>
Exposed Component
; + + registries.exposedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: exposedComponentId, + title: 'Exposed Component', + component: TestComponent, + }, + ], + }); + + const { result } = renderHook(() => useExposedComponentRegistrySlice(otherId), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + }); + + describe('useAddedLinksRegistrySlice', () => { + it('should return undefined when no links are registered for the extension point', () => { + const { result } = renderHook(() => useAddedLinksRegistrySlice('test/extension-point'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return an array of links when links are registered for the extension point', () => { + const extensionPointId = 'test/extension-point'; + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + path: '/test-path', + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Link'); + expect(result.current?.[0].path).toBe('/test-path'); + expect(result.current?.[0].pluginId).toBe('test-plugin'); + }); + + it('should return multiple links when multiple links are registered for the same extension point', () => { + const extensionPointId = 'test/extension-point'; + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Test Link 1', + path: '/test-path-1', + targets: extensionPointId, + }, + ], + }); + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Test Link 2', + path: '/test-path-2', + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(2); + expect(result.current?.[0].title).toBe('Test Link 1'); + expect(result.current?.[1].title).toBe('Test Link 2'); + }); + + it('should only return links for the specified extension point', () => { + const extensionPointId1 = 'test/extension-point-1'; + const extensionPointId2 = 'test/extension-point-2'; + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link 1', + path: '/test-path-1', + targets: extensionPointId1, + }, + { + title: 'Test Link 2', + path: '/test-path-2', + targets: extensionPointId2, + }, + ], + }); + + const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId1), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Link 1'); + }); + }); + + describe('useAddedFunctionsRegistrySlice', () => { + it('should return undefined when no functions are registered for the extension point', () => { + const { result } = renderHook(() => useAddedFunctionsRegistrySlice('test/extension-point'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return an array of functions when functions are registered for the extension point', () => { + const extensionPointId = 'test/extension-point'; + const testFunction = jest.fn(); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + fn: testFunction, + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Function'); + expect(result.current?.[0].fn).toBe(testFunction); + expect(result.current?.[0].pluginId).toBe('test-plugin'); + }); + + it('should return multiple functions when multiple functions are registered for the same extension point', () => { + const extensionPointId = 'test/extension-point'; + const testFunction1 = jest.fn(); + const testFunction2 = jest.fn(); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Test Function 1', + fn: testFunction1, + targets: extensionPointId, + }, + ], + }); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Test Function 2', + fn: testFunction2, + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(2); + expect(result.current?.[0].title).toBe('Test Function 1'); + expect(result.current?.[0].fn).toBe(testFunction1); + expect(result.current?.[1].title).toBe('Test Function 2'); + expect(result.current?.[1].fn).toBe(testFunction2); + }); + + it('should only return functions for the specified extension point', () => { + const extensionPointId1 = 'test/extension-point-1'; + const extensionPointId2 = 'test/extension-point-2'; + const testFunction1 = jest.fn(); + const testFunction2 = jest.fn(); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function 1', + fn: testFunction1, + targets: extensionPointId1, + }, + { + title: 'Test Function 2', + fn: testFunction2, + targets: extensionPointId2, + }, + ], + }); + + const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId1), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Function 1'); + expect(result.current?.[0].fn).toBe(testFunction1); + }); + }); + + describe('observable memoization', () => { + it('should memoize the observable when extensionPointId and registry do not change', () => { + const extensionPointId = 'test/extension-point'; + const TestComponent = () =>
Test Component
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + component: TestComponent, + targets: extensionPointId, + description: 'Test description', + }, + ], + }); + + const { result, rerender } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper }); + + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/useRegistrySlice.tsx b/public/app/features/plugins/extensions/registry/useRegistrySlice.tsx new file mode 100644 index 00000000000..e5bb5234f58 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/useRegistrySlice.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { + useAddedComponentsRegistry, + useAddedFunctionsRegistry, + useAddedLinksRegistry, + useExposedComponentsRegistry, +} from '../ExtensionRegistriesContext'; + +import { AddedComponentRegistryItem } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistryItem } from './AddedFunctionsRegistry'; +import { AddedLinkRegistryItem } from './AddedLinksRegistry'; +import { ExposedComponentRegistryItem } from './ExposedComponentsRegistry'; +import { Registry } from './Registry'; + +export function useAddedComponentsRegistrySlice( + extensionPointId: string +): Array> | undefined { + const registry = useAddedComponentsRegistry(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return useRegistrySlice(extensionPointId, registry) as Array> | undefined; +} + +export function useExposedComponentRegistrySlice(id: string): ExposedComponentRegistryItem | undefined { + const registry = useExposedComponentsRegistry(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return useRegistrySlice(id, registry) as ExposedComponentRegistryItem | undefined; +} + +export function useAddedLinksRegistrySlice(extensionPointId: string): AddedLinkRegistryItem[] | undefined { + const registry = useAddedLinksRegistry(); + return useRegistrySlice(extensionPointId, registry); +} + +export function useAddedFunctionsRegistrySlice( + extensionPointId: string +): Array> | undefined { + const registry = useAddedFunctionsRegistry(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return useRegistrySlice(extensionPointId, registry) as Array> | undefined; +} + +function useRegistrySlice, TMapType>( + extensionPointId: string, + registry: Registry +): TRegistryValue | undefined { + const observable = useMemo(() => { + return registry.asObservableSlice((state) => state[extensionPointId]); + }, [extensionPointId, registry]); + + return useObservable(observable); +} diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 7a2d3669b24..c85eabb0441 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -1,12 +1,11 @@ import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { usePluginContext } from '@grafana/data'; import { UsePluginComponentResult } from '@grafana/runtime'; -import { useExposedComponentsRegistry } from './ExtensionRegistriesContext'; import * as errors from './errors'; import { log } from './logs/log'; +import { useExposedComponentRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { getExposedComponentPluginDependencies, isGrafanaDevMode, wrapWithPluginContext } from './utils'; import { isExposedComponentDependencyMissing } from './validators'; @@ -14,8 +13,7 @@ import { isExposedComponentDependencyMissing } from './validators'; // Returns a component exposed by a plugin. // (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.) export function usePluginComponent(id: string): UsePluginComponentResult { - const registry = useExposedComponentsRegistry(); - const registryState = useObservable(registry.asObservable()); + const registryItem = useExposedComponentRegistrySlice(id); const pluginContext = usePluginContext(); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id)); @@ -30,14 +28,13 @@ export function usePluginComponent(id: string): UsePl }; } - if (!registryState?.[id]) { + if (!registryItem) { return { isLoading: false, component: null, }; } - const registryItem = registryState[id]; const componentLog = log.child({ title: registryItem.title, description: registryItem.description ?? '', @@ -61,5 +58,5 @@ export function usePluginComponent(id: string): UsePl log: componentLog, }), }; - }, [id, pluginContext, registryState, isLoadingAppPlugins]); + }, [id, pluginContext, registryItem, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index 8126838f87a..e02fa956bbb 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { type ComponentTypeWithExtensionMeta, @@ -9,8 +8,8 @@ import { } from '@grafana/data'; import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/runtime'; -import { useAddedComponentsRegistry } from './ExtensionRegistriesContext'; import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; +import { useAddedComponentsRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, getExtensionPointPluginDependencies } from './utils'; import { validateExtensionPoint } from './validateExtensionPoint'; @@ -20,8 +19,7 @@ export function usePluginComponents({ limitPerPlugin, extensionPointId, }: UsePluginComponentsOptions): UsePluginComponentsResult { - const registry = useAddedComponentsRegistry(); - const registryState = useObservable(registry.asObservable()); + const registryItems = useAddedComponentsRegistrySlice(extensionPointId); const pluginContext = usePluginContext(); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); @@ -38,7 +36,7 @@ export function usePluginComponents({ const components: Array> = []; const extensionsByPlugin: Record = {}; - for (const registryItem of registryState?.[extensionPointId] ?? []) { + for (const registryItem of registryItems ?? []) { const { pluginId } = registryItem; // Only limit if the `limitPerPlugin` is set @@ -50,10 +48,7 @@ export function usePluginComponents({ extensionsByPlugin[pluginId] = 0; } - const component = createComponentWithMeta( - registryItem as AddedComponentRegistryItem, - extensionPointId - ); + const component = createComponentWithMeta(registryItem, extensionPointId); components.push(component); extensionsByPlugin[pluginId] += 1; @@ -63,7 +58,7 @@ export function usePluginComponents({ isLoading: false, components, }; - }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]); } export function createComponentWithMeta( diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx index df4f95678a7..b0c065d7aaa 100644 --- a/public/app/features/plugins/extensions/usePluginFunctions.tsx +++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx @@ -1,10 +1,9 @@ import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data'; import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime'; -import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext'; +import { useAddedFunctionsRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, getExtensionPointPluginDependencies } from './utils'; import { validateExtensionPoint } from './validateExtensionPoint'; @@ -14,8 +13,7 @@ export function usePluginFunctions({ limitPerPlugin, extensionPointId, }: UsePluginFunctionsOptions): UsePluginFunctionsResult { - const registry = useAddedFunctionsRegistry(); - const registryState = useObservable(registry.asObservable()); + const registryItems = useAddedFunctionsRegistrySlice(extensionPointId); const pluginContext = usePluginContext(); const deps = getExtensionPointPluginDependencies(extensionPointId); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps); @@ -33,7 +31,7 @@ export function usePluginFunctions({ const results: Array> = []; const extensionsByPlugin: Record = {}; - for (const registryItem of registryState?.[extensionPointId] ?? []) { + for (const registryItem of registryItems ?? []) { const { pluginId } = registryItem; // Only limit if the `limitPerPlugin` is set @@ -51,7 +49,7 @@ export function usePluginFunctions({ title: registryItem.title, description: registryItem.description ?? '', pluginId: pluginId, - fn: registryItem.fn as Signature, + fn: registryItem.fn, }); extensionsByPlugin[pluginId] += 1; } @@ -60,5 +58,5 @@ export function usePluginFunctions({ isLoading: false, functions: results, }; - }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index be25ec5105a..70518ab9450 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -1,11 +1,10 @@ import { isString } from 'lodash'; import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data'; import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime'; -import { useAddedLinksRegistry } from './ExtensionRegistriesContext'; +import { useAddedLinksRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, @@ -23,9 +22,8 @@ export function usePluginLinks({ extensionPointId, context, }: UsePluginLinksOptions): UsePluginLinksResult { - const registry = useAddedLinksRegistry(); + const registryItems = useAddedLinksRegistrySlice(extensionPointId); const pluginContext = usePluginContext(); - const registryState = useObservable(registry.asObservable()); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); return useMemo(() => { @@ -46,7 +44,7 @@ export function usePluginLinks({ const extensions: PluginExtensionLink[] = []; const extensionsByPlugin: Record = {}; - for (const addedLink of registryState?.[extensionPointId] ?? []) { + for (const addedLink of registryItems ?? []) { const { pluginId } = addedLink; const linkLog = pointLog.child({ path: addedLink.path ?? '', @@ -96,5 +94,5 @@ export function usePluginLinks({ isLoading: false, links: extensions, }; - }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]); + }, [context, extensionPointId, limitPerPlugin, registryItems, pluginContext, isLoadingAppPlugins]); }