frontend-plugin-api: add extension duplication validation for createPlugin
Co-authored-by: Camila Belo <camilaibs@gmail.com> Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -28,48 +28,34 @@ import { createExtensionInput } from './createExtensionInput';
|
||||
|
||||
const nameExtensionDataRef = createExtensionDataRef<string>('name');
|
||||
|
||||
const TechRadarPage = createExtension({
|
||||
id: 'plugin.techradar.page',
|
||||
attachTo: { id: 'test.output', input: 'names' },
|
||||
const Extension1 = createExtension({
|
||||
name: '1',
|
||||
attachTo: { id: 'test/output', input: 'names' },
|
||||
output: {
|
||||
name: nameExtensionDataRef,
|
||||
},
|
||||
factory() {
|
||||
return { name: 'TechRadar' };
|
||||
return { name: 'extension-1' };
|
||||
},
|
||||
});
|
||||
|
||||
const CatalogPage = createExtension({
|
||||
id: 'plugin.catalog.page',
|
||||
attachTo: { id: 'test.output', input: 'names' },
|
||||
const Extension2 = createExtension({
|
||||
name: '2',
|
||||
attachTo: { id: 'test/output', input: 'names' },
|
||||
output: {
|
||||
name: nameExtensionDataRef,
|
||||
},
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({ name: z.string().default('Catalog') }),
|
||||
z.object({ name: z.string().default('extension-2') }),
|
||||
),
|
||||
factory({ config }) {
|
||||
return { name: config.name };
|
||||
},
|
||||
});
|
||||
|
||||
const TechDocsAddon = createExtension({
|
||||
id: 'plugin.techdocs.addon.example',
|
||||
attachTo: { id: 'plugin.techdocs.page', input: 'addons' },
|
||||
output: {
|
||||
name: nameExtensionDataRef,
|
||||
},
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({ name: z.string().default('TechDocsAddon') }),
|
||||
),
|
||||
factory({ config }) {
|
||||
return { name: config.name };
|
||||
},
|
||||
});
|
||||
|
||||
const TechDocsPage = createExtension({
|
||||
id: 'plugin.techdocs.page',
|
||||
attachTo: { id: 'test.output', input: 'names' },
|
||||
const Extension3 = createExtension({
|
||||
name: '3',
|
||||
attachTo: { id: 'test/output', input: 'names' },
|
||||
inputs: {
|
||||
addons: createExtensionInput({
|
||||
name: nameExtensionDataRef,
|
||||
@@ -79,12 +65,40 @@ const TechDocsPage = createExtension({
|
||||
name: nameExtensionDataRef,
|
||||
},
|
||||
factory({ inputs }) {
|
||||
return { name: `TechDocs-${inputs.addons.map(n => n.name).join('-')}` };
|
||||
return { name: `extension-3:${inputs.addons.map(n => n.name).join('-')}` };
|
||||
},
|
||||
});
|
||||
|
||||
const Child = createExtension({
|
||||
name: 'child',
|
||||
attachTo: { id: 'test/3', input: 'addons' },
|
||||
output: {
|
||||
name: nameExtensionDataRef,
|
||||
},
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({ name: z.string().default('child') }),
|
||||
),
|
||||
factory({ config }) {
|
||||
return { name: config.name };
|
||||
},
|
||||
});
|
||||
|
||||
const Child2 = createExtension({
|
||||
name: 'child2',
|
||||
attachTo: { id: 'test/3', input: 'addons' },
|
||||
output: {
|
||||
name: nameExtensionDataRef,
|
||||
},
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({ name: z.string().default('child2') }),
|
||||
),
|
||||
factory({ config }) {
|
||||
return { name: config.name };
|
||||
},
|
||||
});
|
||||
|
||||
const outputExtension = createExtension({
|
||||
id: 'test.output',
|
||||
name: 'output',
|
||||
attachTo: { id: 'core', input: 'root' },
|
||||
inputs: {
|
||||
names: createExtensionInput({
|
||||
@@ -118,40 +132,34 @@ function createTestAppRoot({
|
||||
|
||||
describe('createPlugin', () => {
|
||||
it('should create an empty plugin', () => {
|
||||
const plugin = createPlugin({ id: 'empty' });
|
||||
const plugin = createPlugin({ id: 'test' });
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a plugin with extension instances', async () => {
|
||||
const plugin = createPlugin({
|
||||
id: 'empty',
|
||||
extensions: [TechRadarPage, CatalogPage, outputExtension],
|
||||
id: 'test',
|
||||
extensions: [Extension1, Extension2, outputExtension],
|
||||
});
|
||||
expect(plugin).toBeDefined();
|
||||
|
||||
await renderWithEffects(
|
||||
createTestAppRoot({
|
||||
features: [plugin],
|
||||
config: { app: { extensions: [{ 'core.router': false }] } },
|
||||
config: { app: { extensions: [{ 'core/router': false }] } },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Names: TechRadar, Catalog'),
|
||||
screen.findByText('Names: extension-1, extension-2'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create a plugin with nested extension instances', async () => {
|
||||
const plugin = createPlugin({
|
||||
id: 'empty',
|
||||
extensions: [
|
||||
TechRadarPage,
|
||||
CatalogPage,
|
||||
TechDocsPage,
|
||||
TechDocsAddon,
|
||||
outputExtension,
|
||||
],
|
||||
id: 'test',
|
||||
extensions: [Extension1, Extension2, Extension3, Child, outputExtension],
|
||||
});
|
||||
expect(plugin).toBeDefined();
|
||||
|
||||
@@ -161,10 +169,10 @@ describe('createPlugin', () => {
|
||||
config: {
|
||||
app: {
|
||||
extensions: [
|
||||
{ 'core.router': false },
|
||||
{ 'core/router': false },
|
||||
{
|
||||
'plugin.catalog.page': {
|
||||
config: { name: 'CatalogRenamed' },
|
||||
'test/2': {
|
||||
config: { name: 'extension-2-renamed' },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -175,8 +183,63 @@ describe('createPlugin', () => {
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Names: TechRadar, CatalogRenamed, TechDocs-TechDocsAddon',
|
||||
'Names: extension-1, extension-2-renamed, extension-3:child',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create a plugin with nested extension instances and multiple children', async () => {
|
||||
const plugin = createPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
Extension1,
|
||||
Extension2,
|
||||
Extension3,
|
||||
Child,
|
||||
Child2,
|
||||
outputExtension,
|
||||
],
|
||||
});
|
||||
expect(plugin).toBeDefined();
|
||||
|
||||
await renderWithEffects(
|
||||
createTestAppRoot({
|
||||
features: [plugin],
|
||||
config: {
|
||||
app: {
|
||||
extensions: [{ 'core/router': false }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Names: extension-1, extension-2, extension-3:child-child2',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw on duplicate extensions', async () => {
|
||||
expect(() =>
|
||||
createPlugin({
|
||||
id: 'test',
|
||||
extensions: [Extension1, Extension1],
|
||||
}),
|
||||
).toThrow("Plugin 'test' provided duplicate extensions: test/1");
|
||||
|
||||
expect(() =>
|
||||
createPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
Extension1,
|
||||
Extension2,
|
||||
Extension2,
|
||||
Extension3,
|
||||
Extension3,
|
||||
Extension3,
|
||||
],
|
||||
}),
|
||||
).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,25 @@ export function createPlugin<
|
||||
>(
|
||||
options: PluginOptions<Routes, ExternalRoutes>,
|
||||
): BackstagePlugin<Routes, ExternalRoutes> {
|
||||
const extensions = (options.extensions ?? []).map(def =>
|
||||
resolveExtensionDefinition(def, { namespace: options.id }),
|
||||
);
|
||||
|
||||
const extensionIds = extensions.map(e => e.id);
|
||||
if (extensionIds.length !== new Set(extensionIds).size) {
|
||||
const duplicates = Array.from(
|
||||
new Set(
|
||||
extensionIds.filter((id, index) => extensionIds.indexOf(id) !== index),
|
||||
),
|
||||
);
|
||||
// TODO(Rugvip): This could provide some more information about the kind + name of the extensions
|
||||
throw new Error(
|
||||
`Plugin '${options.id}' provided duplicate extensions: ${duplicates.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
$$type: '@backstage/BackstagePlugin',
|
||||
version: 'v1',
|
||||
@@ -72,9 +91,7 @@ export function createPlugin<
|
||||
routes: options.routes ?? ({} as Routes),
|
||||
externalRoutes: options.externalRoutes ?? ({} as ExternalRoutes),
|
||||
featureFlags: options.featureFlags ?? [],
|
||||
extensions: (options.extensions ?? []).map(def =>
|
||||
resolveExtensionDefinition(def, { namespace: options.id }),
|
||||
),
|
||||
extensions,
|
||||
} as InternalBackstagePlugin<Routes, ExternalRoutes>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user