PluginExtensions: Prevent unneccesary re-renders (#113436)
* wip * wip * wip * wip * wip * wip * removed array.from. * using concat * added more tests. * renamed according to feedback. * Update the getPluginExtensions to use the slice. * fixed expect statements. * Reverted the test app from the allow list. * cleanded up tests. * added generic tests for base functionality. * removed unused iport.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<GetExtensionsOptions, 'addedComponentsRegistry' | 'addedLinksRegistry'>
|
||||
): Observable<ReturnType<GetExtensions>> => {
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,10 @@ import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
|
||||
|
||||
const logPrefix = 'Could not register function extension. Reason:';
|
||||
|
||||
export type AddedFunctionsRegistryItem = {
|
||||
export type AddedFunctionsRegistryItem<Signature = unknown> = {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
fn: unknown;
|
||||
fn: Signature;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
@@ -67,11 +67,9 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
|
||||
|
||||
pointIdLog.debug('Added function 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { firstValueFrom, take } from 'rxjs';
|
||||
|
||||
import { PluginLoadingStrategy } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
@@ -714,4 +714,198 @@ describe('AddedLinksRegistry', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,13 +74,12 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
|
||||
const pointIdLog = configLog.child({ extensionPointId });
|
||||
const { targets, ...registryItem } = config;
|
||||
|
||||
if (!(extensionPointId in registry)) {
|
||||
registry[extensionPointId] = [];
|
||||
}
|
||||
|
||||
pointIdLog.debug('Added link extension successfully registered');
|
||||
|
||||
registry[extensionPointId].push({ ...registryItem, pluginId, extensionPointId });
|
||||
// Creating a new array instead of pushing to get a new references
|
||||
const slice = registry[extensionPointId] ?? [];
|
||||
const result = { ...registryItem, pluginId, extensionPointId };
|
||||
registry[extensionPointId] = slice.concat(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -510,4 +510,106 @@ describe('ExposedComponentsRegistry', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T> = {
|
||||
export type RegistryType<T> = Record<string | symbol, T>;
|
||||
|
||||
// This is the base-class used by the separate specific registries.
|
||||
export abstract class Registry<TRegistryValue, TMapType> {
|
||||
export abstract class Registry<TRegistryValue extends object | unknown[] | Record<string | symbol, unknown>, 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<TRegistryValue, TMapType> {
|
||||
if (options.registrySubject) {
|
||||
this.registrySubject = options.registrySubject;
|
||||
this.isReadOnly = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,8 +46,7 @@ export abstract class Registry<TRegistryValue, TMapType> {
|
||||
.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<TRegistryValue, TMapType> {
|
||||
if (this.isReadOnly) {
|
||||
throw new Error(MSG_CANNOT_REGISTER_READ_ONLY);
|
||||
}
|
||||
|
||||
if (result.configs.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.resultSubject.next(result);
|
||||
}
|
||||
|
||||
asObservable(): Observable<RegistryType<TRegistryValue>> {
|
||||
return this.registrySubject.asObservable();
|
||||
return this.registrySubject.asObservable().pipe(map((state) => deepFreeze(state)));
|
||||
}
|
||||
|
||||
asObservableSlice(selector: (state: RegistryType<TRegistryValue>) => TRegistryValue): Observable<TRegistryValue> {
|
||||
return this.registrySubject.asObservable().pipe(
|
||||
map((state) => selector(state)),
|
||||
distinctUntilChanged((prev, curr) => Object.is(prev, curr)),
|
||||
map((slice) => deepFreeze(slice))
|
||||
);
|
||||
}
|
||||
|
||||
getState(): Promise<RegistryType<TRegistryValue>> {
|
||||
|
||||
@@ -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 }) => (
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
);
|
||||
});
|
||||
|
||||
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 = () => <div>Test Component</div>;
|
||||
|
||||
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 = () => <div>Test Component 1</div>;
|
||||
const TestComponent2 = () => <div>Test Component 2</div>;
|
||||
|
||||
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 = () => <div>Test Component 1</div>;
|
||||
const TestComponent2 = () => <div>Test Component 2</div>;
|
||||
|
||||
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 = () => <div>Exposed Component</div>;
|
||||
|
||||
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 = () => <div>Exposed Component</div>;
|
||||
|
||||
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 = () => <div>Test Component</div>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Props>(
|
||||
extensionPointId: string
|
||||
): Array<AddedComponentRegistryItem<Props>> | undefined {
|
||||
const registry = useAddedComponentsRegistry();
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return useRegistrySlice(extensionPointId, registry) as Array<AddedComponentRegistryItem<Props>> | undefined;
|
||||
}
|
||||
|
||||
export function useExposedComponentRegistrySlice<Props>(id: string): ExposedComponentRegistryItem<Props> | undefined {
|
||||
const registry = useExposedComponentsRegistry();
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return useRegistrySlice(id, registry) as ExposedComponentRegistryItem<Props> | undefined;
|
||||
}
|
||||
|
||||
export function useAddedLinksRegistrySlice(extensionPointId: string): AddedLinkRegistryItem[] | undefined {
|
||||
const registry = useAddedLinksRegistry();
|
||||
return useRegistrySlice(extensionPointId, registry);
|
||||
}
|
||||
|
||||
export function useAddedFunctionsRegistrySlice<Signature>(
|
||||
extensionPointId: string
|
||||
): Array<AddedFunctionsRegistryItem<Signature>> | undefined {
|
||||
const registry = useAddedFunctionsRegistry();
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return useRegistrySlice(extensionPointId, registry) as Array<AddedFunctionsRegistryItem<Signature>> | undefined;
|
||||
}
|
||||
|
||||
function useRegistrySlice<TRegistryValue extends object | unknown[] | Record<string | symbol, unknown>, TMapType>(
|
||||
extensionPointId: string,
|
||||
registry: Registry<TRegistryValue, TMapType>
|
||||
): TRegistryValue | undefined {
|
||||
const observable = useMemo(() => {
|
||||
return registry.asObservableSlice((state) => state[extensionPointId]);
|
||||
}, [extensionPointId, registry]);
|
||||
|
||||
return useObservable(observable);
|
||||
}
|
||||
@@ -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<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
|
||||
const registry = useExposedComponentsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const registryItem = useExposedComponentRegistrySlice<Props>(id);
|
||||
const pluginContext = usePluginContext();
|
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id));
|
||||
|
||||
@@ -30,14 +28,13 @@ export function usePluginComponent<Props extends object = {}>(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<Props extends object = {}>(id: string): UsePl
|
||||
log: componentLog,
|
||||
}),
|
||||
};
|
||||
}, [id, pluginContext, registryState, isLoadingAppPlugins]);
|
||||
}, [id, pluginContext, registryItem, isLoadingAppPlugins]);
|
||||
}
|
||||
|
||||
@@ -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<Props extends object = {}>({
|
||||
limitPerPlugin,
|
||||
extensionPointId,
|
||||
}: UsePluginComponentsOptions): UsePluginComponentsResult<Props> {
|
||||
const registry = useAddedComponentsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const registryItems = useAddedComponentsRegistrySlice<Props>(extensionPointId);
|
||||
const pluginContext = usePluginContext();
|
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
|
||||
|
||||
@@ -38,7 +36,7 @@ export function usePluginComponents<Props extends object = {}>({
|
||||
const components: Array<ComponentTypeWithExtensionMeta<Props>> = [];
|
||||
const extensionsByPlugin: Record<string, number> = {};
|
||||
|
||||
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<Props extends object = {}>({
|
||||
extensionsByPlugin[pluginId] = 0;
|
||||
}
|
||||
|
||||
const component = createComponentWithMeta<Props>(
|
||||
registryItem as AddedComponentRegistryItem<Props>,
|
||||
extensionPointId
|
||||
);
|
||||
const component = createComponentWithMeta<Props>(registryItem, extensionPointId);
|
||||
|
||||
components.push(component);
|
||||
extensionsByPlugin[pluginId] += 1;
|
||||
@@ -63,7 +58,7 @@ export function usePluginComponents<Props extends object = {}>({
|
||||
isLoading: false,
|
||||
components,
|
||||
};
|
||||
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
|
||||
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]);
|
||||
}
|
||||
|
||||
export function createComponentWithMeta<Props extends JSX.IntrinsicAttributes>(
|
||||
|
||||
@@ -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<Signature>({
|
||||
limitPerPlugin,
|
||||
extensionPointId,
|
||||
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> {
|
||||
const registry = useAddedFunctionsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const registryItems = useAddedFunctionsRegistrySlice<Signature>(extensionPointId);
|
||||
const pluginContext = usePluginContext();
|
||||
const deps = getExtensionPointPluginDependencies(extensionPointId);
|
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
|
||||
@@ -33,7 +31,7 @@ export function usePluginFunctions<Signature>({
|
||||
const results: Array<PluginExtensionFunction<Signature>> = [];
|
||||
const extensionsByPlugin: Record<string, number> = {};
|
||||
|
||||
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<Signature>({
|
||||
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<Signature>({
|
||||
isLoading: false,
|
||||
functions: results,
|
||||
};
|
||||
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
|
||||
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]);
|
||||
}
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user