From 493dfefa7049477006a6ff7bf93d6dd9625de3ef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 18 Jan 2025 01:03:24 +0000 Subject: [PATCH 01/14] chore(deps): update ruby docker tag to v3.4 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- plugins/scaffolder-backend-module-rails/Rails.dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-rails/Rails.dockerfile b/plugins/scaffolder-backend-module-rails/Rails.dockerfile index 8ea3fc370a..e22d433e3e 100644 --- a/plugins/scaffolder-backend-module-rails/Rails.dockerfile +++ b/plugins/scaffolder-backend-module-rails/Rails.dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.3 +FROM ruby:3.4 RUN apt-get update -qq && \ apt-get install -y nodejs postgresql-client git && \ From 425a61d311a68f9715e5e0d4ca148d0463e36757 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 9 Dec 2024 12:54:59 +0200 Subject: [PATCH 02/14] test(notifications): improve router tests improve the existing router tests for notifications to handle different endpoints. Signed-off-by: Heikki Hellgren --- .changeset/weak-lemons-sing.md | 5 + .../src/service/router.test.ts | 483 +++++++++++++++++- .../src/service/router.ts | 19 +- 3 files changed, 479 insertions(+), 28 deletions(-) create mode 100644 .changeset/weak-lemons-sing.md diff --git a/.changeset/weak-lemons-sing.md b/.changeset/weak-lemons-sing.md new file mode 100644 index 0000000000..e1a349b85e --- /dev/null +++ b/.changeset/weak-lemons-sing.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend': patch +--- + +Improved notifications router tests diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index e557e5818d..46ee00c554 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -19,53 +19,99 @@ import request from 'supertest'; import { createRouter } from './router'; import { SignalsService } from '@backstage/plugin-signals-node'; import { - TestDatabases, mockCredentials, mockErrorHandler, mockServices, + TestDatabaseId, + TestDatabases, } from '@backstage/backend-test-utils'; import { NotificationSendOptions } from '@backstage/plugin-notifications-node'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { DatabaseService } from '@backstage/backend-plugin-api'; +import { v4 as uuid } from 'uuid'; -describe('createRouter', () => { - const databases = TestDatabases.create(); +const databases = TestDatabases.create(); +async function createDatabase( + databaseId: TestDatabaseId, +): Promise { + const knex = await databases.init(databaseId); + return mockServices.database({ knex, migrations: { skip: false } }); +} + +describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { let app: express.Express; + let database: DatabaseService; const signalService: jest.Mocked = { publish: jest.fn(), }; const userInfo = mockServices.userInfo(); - const httpAuth = mockServices.httpAuth({ - defaultCredentials: mockCredentials.service(), - }); + const auth = mockServices.auth(); const config = mockServices.rootConfig({ data: { app: { baseUrl: 'http://localhost' } }, }); - const catalog = catalogServiceMock(); - beforeAll(async () => { - const knex = await databases.init('SQLITE_3'); - const router = await createRouter({ - logger: mockServices.logger.mock(), - database: { getClient: async () => knex }, - signals: signalService, - userInfo, - config, - httpAuth, - auth, - catalog, - }); - app = express().use(router).use(mockErrorHandler()); + const catalog = catalogServiceMock({ + entities: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'mock', + namespace: 'default', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'mock', + namespace: 'default', + }, + relations: [ + { + type: 'hasMember', + targetRef: 'user:default/mock', + }, + ], + }, + ], }); - beforeEach(() => { - jest.resetAllMocks(); + beforeAll(async () => { + database = await createDatabase(databaseId); }); describe('POST /notifications', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.service(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('notification').del(); + await client('broadcast').del(); + await client('user_settings').del(); + }); + const sendNotification = async (data: NotificationSendOptions) => request(app) .post('/notifications') @@ -84,7 +130,6 @@ describe('createRouter', () => { link: 'javascript:alert(document.domain)', }, }); - expect(javascriptXSS.status).toEqual(400); const ftpLink = await sendNotification({ @@ -124,6 +169,19 @@ describe('createRouter', () => { }); expect(httpsLink.status).toEqual(200); + expect(httpsLink.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + link: 'https://example.com', + }, + user: null, + }, + ]); }); it('should accept relative links', async () => { @@ -138,6 +196,385 @@ describe('createRouter', () => { }); expect(catalogLink.status).toEqual(200); + expect(catalogLink.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + link: '/catalog', + }, + user: null, + }, + ]); + }); + + it('should send to user entity', async () => { + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['user:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + }, + user: 'user:default/mock', + }, + ]); + + const client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); + }); + + it('should send to group entity', async () => { + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['group:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + }, + user: 'user:default/mock', + }, + ]); + + const client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); + }); + + it('should only send one notification per user', async () => { + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['group:default/mock', 'user:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + }, + user: 'user:default/mock', + }, + ]); + + const client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); + }); + + it('should not send to user entity if disabled in settings', async () => { + const client = await database.getClient(); + await client('user_settings').insert({ + user: 'user:default/mock', + channel: 'Web', + origin: 'external:test-service', + enabled: false, + }); + + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['user:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); + + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(0); + }); + + it('should fail without recipients', async () => { + const response = await sendNotification({ + payload: { + title: 'test notification', + }, + } as unknown as NotificationSendOptions); + + expect(response.status).toEqual(400); + }); + + it('should fail with invalid recipients', async () => { + const response = await sendNotification({ + recipients: { + type: 'invalid', + }, + payload: { + title: 'test notification', + }, + } as unknown as NotificationSendOptions); + + expect(response.status).toEqual(400); + }); + + it('should fail without title', async () => { + const response = await sendNotification({ + recipients: { + type: 'broadcast', + }, + payload: { + description: 'test notification', + }, + } as unknown as NotificationSendOptions); + + expect(response.status).toEqual(400); + }); + }); + + describe('GET /', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.user(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('notification').del(); + await client('broadcast').del(); + }); + + it('should return notifications', async () => { + const client = await database.getClient(); + await client('broadcast').insert({ + id: uuid(), + origin: 'external:test-service', + title: 'Test broadcast notification', + created: new Date(), + severity: 'high', + }); + await client('notification').insert({ + id: uuid(), + user: 'user:default/mock', + origin: 'external:test-service', + title: 'Test notification', + created: new Date(), + severity: 'normal', + }); + + const response = await request(app).get('/'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notifications: [ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + description: null, + icon: null, + link: null, + scope: null, + severity: 'normal', + title: 'Test notification', + topic: null, + }, + read: null, + saved: null, + updated: null, + user: 'user:default/mock', + }, + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + description: null, + icon: null, + link: null, + scope: null, + severity: 'high', + title: 'Test broadcast notification', + topic: null, + }, + read: null, + saved: null, + updated: null, + user: null, + }, + ], + totalCount: 2, + }); + }); + }); + + describe('GET /settings', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.user(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('user_settings').del(); + }); + + it('should return user settings', async () => { + const client = await database.getClient(); + await client('user_settings').insert({ + user: 'user:default/mock', + channel: 'Web', + origin: 'external:test-service', + enabled: false, + }); + + const response = await request(app).get('/settings'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + channels: [ + { + id: 'Web', + origins: [{ enabled: false, id: 'external:test-service' }], + }, + ], + }); + }); + }); + + describe('POST /settings', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.user(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('user_settings').del(); + }); + + it('should save user settings', async () => { + await request(app) + .post('/settings') + .send({ + channels: [ + { + id: 'Web', + origins: [{ enabled: false, id: 'external:test-service' }], + }, + ], + }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const client = await database.getClient(); + const settings = await client('user_settings').select(); + expect(settings.length).toEqual(1); + expect(settings[0].user).toEqual('user:default/mock'); + expect(settings[0].channel).toEqual('Web'); + expect(settings[0].origin).toEqual('external:test-service'); + expect(Boolean(settings[0].enabled)).toEqual(false); + }); + + it('should fail to save user settings with invalid channel', async () => { + const response = await request(app) + .post('/settings') + .send({ + channels: [ + { + id: 'Invalid', + origins: [{ enabled: false, id: 'external:test-service' }], + }, + ], + }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + expect(response.status).toEqual(400); + + const client = await database.getClient(); + const settings = await client('user_settings').select(); + expect(settings.length).toEqual(0); }); }); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 06a0a5ebb0..368974b6ca 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -180,7 +180,7 @@ export async function createRouter( const processOptions = async ( opts: NotificationSendOptions, origin: string, - ) => { + ): Promise => { const filtered = await filterProcessors({ ...opts, origin, user: null }); let ret = opts; for (const processor of filtered) { @@ -553,8 +553,14 @@ export async function createRouter( let users = []; if (!recipients || !title) { - logger.error(`Invalid notification request received`); - throw new InputError(`Invalid notification request received`); + const missing = [ + !title ? 'title' : null, + !recipients ? 'recipients' : null, + ].filter(Boolean); + const err = `Invalid notification request received: missing ${missing.join( + ', ', + )}`; + throw new InputError(err); } if (link) { @@ -581,7 +587,7 @@ export async function createRouter( origin, ); notifications.push(broadcast); - } else { + } else if (recipients.type === 'entity') { const entityRef = recipients.entityRef; try { @@ -591,7 +597,6 @@ export async function createRouter( { auth, catalogClient: catalog }, ); } catch (e) { - logger.error(`Failed to resolve notification receivers: ${e}`); throw new InputError('Failed to resolve notification receivers', e); } @@ -602,6 +607,10 @@ export async function createRouter( origin, ); notifications.push(...userNotifications); + } else { + throw new InputError( + `Invalid recipients type, please use either 'broadcast' or 'entity'`, + ); } res.json(notifications); From 3edd069082bfc9e61644fa99b63f970f3ce7d69b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 12:16:48 +0100 Subject: [PATCH 03/14] catalog-react: allow any group ID in addition to default ones Signed-off-by: Patrik Oldsberg --- plugins/api-docs/report-alpha.api.md | 2 ++ plugins/catalog-react/report-alpha.api.md | 1 + .../src/alpha/blueprints/EntityContentBlueprint.ts | 2 +- plugins/catalog/report-alpha.api.md | 1 + plugins/kubernetes/report-alpha.api.md | 1 + plugins/techdocs/report-alpha.api.md | 1 + 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 32b00d741f..b996d4e6fe 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -434,6 +434,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' @@ -504,6 +505,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 22806998fd..d85c1c675b 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -246,6 +246,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index 5e820e3b0a..aa3608c299 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -70,7 +70,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ loader: () => Promise; defaultPath: string; defaultTitle: string; - defaultGroup?: keyof typeof defaultEntityContentGroups; + defaultGroup?: keyof typeof defaultEntityContentGroups | (string & {}); routeRef?: RouteRef; filter?: | typeof entityFilterFunctionDataRef.T diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index c78595b836..187ddbf067 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -734,6 +734,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 3544768dfc..a881f27622 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -120,6 +120,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 8cc4f07b86..b6d6f6eafa 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -255,6 +255,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' From bea619cca210e662e9e8ebd003c5d40de1eed76a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 12:21:24 +0100 Subject: [PATCH 04/14] catalog-react: remove false as a possible group ID in data Signed-off-by: Patrik Oldsberg --- plugins/api-docs/report-alpha.api.md | 4 ++-- plugins/catalog-react/report-alpha.api.md | 4 ++-- plugins/catalog-react/src/alpha/blueprints/extensionData.tsx | 4 +--- plugins/catalog/report-alpha.api.md | 4 ++-- plugins/kubernetes/report-alpha.api.md | 2 +- plugins/techdocs/report-alpha.api.md | 2 +- 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 32b00d741f..4d6130dd3a 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -422,7 +422,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -492,7 +492,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 22806998fd..a4bf10d748 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -280,7 +280,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -316,7 +316,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ {} >; group: ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', {} >; diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index b76e970dfb..b9a63edf9c 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -45,9 +45,7 @@ export const defaultEntityContentGroups = { }; /** @internal */ -export const entityContentGroupDataRef = createExtensionDataRef< - false | string ->().with({ +export const entityContentGroupDataRef = createExtensionDataRef().with({ id: 'catalog.entity-content-group', }); diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index c78595b836..6878463724 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -666,7 +666,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -1001,7 +1001,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 3544768dfc..52461b1e53 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -108,7 +108,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 8cc4f07b86..b56c56ce3e 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -227,7 +227,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; From 94cc3dbf48966a48fd8898b4eb7e86921cd55836 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 14:52:05 +0100 Subject: [PATCH 05/14] catalog-react: align defaultFilter config naming Signed-off-by: Patrik Oldsberg --- plugins/catalog-react/report-alpha.api.md | 2 +- .../alpha/blueprints/EntityCardLauyoutBlueprint.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 22806998fd..e47a01eeae 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -175,7 +175,7 @@ export const EntityCardLayoutBlueprint: ExtensionBlueprint<{ kind: 'entity-card-layout'; name: undefined; params: { - defaultFilter?: string | ((entity: Entity) => boolean) | undefined; + filter?: string | ((entity: Entity) => boolean) | undefined; loader: () => Promise< (props: EntityCardLayoutProps) => React_2.JSX.Element >; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx index 3aaba93813..13b1d8c7d8 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx @@ -63,9 +63,9 @@ export const EntityCardLayoutBlueprint = createExtensionBlueprint({ *factory( { loader, - defaultFilter, + filter, }: { - defaultFilter?: + filter?: | typeof entityFilterFunctionDataRef.T | typeof entityFilterExpressionDataRef.T; loader: () => Promise< @@ -76,10 +76,10 @@ export const EntityCardLayoutBlueprint = createExtensionBlueprint({ ) { if (config.filter) { yield entityFilterExpressionDataRef(config.filter); - } else if (typeof defaultFilter === 'string') { - yield entityFilterExpressionDataRef(defaultFilter); - } else if (typeof defaultFilter === 'function') { - yield entityFilterFunctionDataRef(defaultFilter); + } else if (typeof filter === 'string') { + yield entityFilterExpressionDataRef(filter); + } else if (typeof filter === 'function') { + yield entityFilterFunctionDataRef(filter); } const ExtensionComponent = reactLazy(() => From de72253c52ddf3c149002d1457deb905ec2b2093 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 15:41:10 +0100 Subject: [PATCH 06/14] frontend-plugin-api: add ExtensionBoundary.lazyComponent Signed-off-by: Patrik Oldsberg --- .changeset/tall-falcons-juggle.md | 5 +++++ packages/frontend-plugin-api/report.api.md | 7 ++++++- .../src/components/ExtensionBoundary.tsx | 19 +++++++++++++++++-- .../blueprints/EntityCardLauyoutBlueprint.tsx | 14 ++------------ 4 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 .changeset/tall-falcons-juggle.md diff --git a/.changeset/tall-falcons-juggle.md b/.changeset/tall-falcons-juggle.md new file mode 100644 index 0000000000..6843d48146 --- /dev/null +++ b/.changeset/tall-falcons-juggle.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new `ExtensionBoundary.lazyComponent` helper in addition to the existing `ExtensionBoundary.lazy` helper. diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 13eb751361..5de595b6a9 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -964,8 +964,13 @@ export namespace ExtensionBoundary { // (undocumented) export function lazy( appNode: AppNode, - lazyElement: () => Promise, + loader: () => Promise, ): JSX.Element; + // (undocumented) + export function lazyComponent( + appNode: AppNode, + loader: () => Promise<(props: TProps) => JSX.Element>, + ): (props: TProps) => JSX.Element; } // @public (undocumented) diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 176dfd4c3c..1737e0b40b 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -96,10 +96,10 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { export namespace ExtensionBoundary { export function lazy( appNode: AppNode, - lazyElement: () => Promise, + loader: () => Promise, ): JSX.Element { const ExtensionComponent = reactLazy(() => - lazyElement().then(element => ({ default: () => element })), + loader().then(element => ({ default: () => element })), ); return ( @@ -107,4 +107,19 @@ export namespace ExtensionBoundary { ); } + + export function lazyComponent( + appNode: AppNode, + loader: () => Promise<(props: TProps) => JSX.Element>, + ): (props: TProps) => JSX.Element { + const ExtensionComponent = reactLazy(() => + loader().then(Component => ({ default: Component })), + ) as unknown as React.ComponentType; + + return (props: TProps) => ( + + + + ); + } } diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx index 3aaba93813..706f4deedf 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx @@ -24,7 +24,7 @@ import { entityFilterFunctionDataRef, defaultEntityCardAreas, } from './extensionData'; -import React, { lazy as reactLazy, ComponentProps } from 'react'; +import React from 'react'; /** @alpha */ export interface EntityCardLayoutProps { @@ -82,18 +82,8 @@ export const EntityCardLayoutBlueprint = createExtensionBlueprint({ yield entityFilterFunctionDataRef(defaultFilter); } - const ExtensionComponent = reactLazy(() => - loader().then(component => ({ default: component })), - ); - yield entityCardLayoutComponentDataRef( - (props: ComponentProps) => { - return ( - - - - ); - }, + ExtensionBoundary.lazyComponent(node, loader), ); }, }); From ecb9babcfc4393146d52ebdeeaf8f70407c9c546 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 20 Feb 2025 16:11:03 -0600 Subject: [PATCH 07/14] fix: explicitly stringify extra fields passed to the logger service Signed-off-by: Paul Schultz --- .changeset/itchy-schools-camp.md | 5 ++++ .../rootLogger/WinstonLogger.test.ts | 24 ------------------- .../entrypoints/rootLogger/WinstonLogger.ts | 2 +- 3 files changed, 6 insertions(+), 25 deletions(-) create mode 100644 .changeset/itchy-schools-camp.md diff --git a/.changeset/itchy-schools-camp.md b/.changeset/itchy-schools-camp.md new file mode 100644 index 0000000000..31b4231a69 --- /dev/null +++ b/.changeset/itchy-schools-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Explicitly stringify extra logger fields with `JSON.stringify` to prevent `[object Object]` errors. diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts index ec46f22759..0f9e079943 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts @@ -93,28 +93,4 @@ describe('WinstonLogger', () => { expect.any(Function), ); }); - - it('gracefully handles fields that are not castable to a string', () => { - const mockTransport = new Transport({ - log: jest.fn(), - logv: jest.fn(), - }); - - const logger = WinstonLogger.create({ - transports: [mockTransport], - }); - - logger.error('something went wrong', { - field: Object.create(null), - }); - - expect(mockTransport.log).toHaveBeenCalledWith( - expect.objectContaining({ - [MESSAGE]: expect.stringContaining( - '[field value not castable to string]', - ), - }), - expect.any(Function), - ); - }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts index 9f7532e66b..545c9377b2 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts @@ -151,7 +151,7 @@ export class WinstonLogger implements RootLoggerService { let stringValue = ''; try { - stringValue = `${value}`; + stringValue = JSON.stringify(value); } catch (e) { stringValue = '[field value not castable to string]'; } From e293b661fe40e70dfa974d3578de2602986956cc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 21 Feb 2025 11:45:36 +0100 Subject: [PATCH 08/14] backend-defaults: log low severity audit events as debug Signed-off-by: Patrik Oldsberg --- .changeset/chilled-bugs-draw.md | 5 ++ .../auditor/auditorServiceFactory.test.ts | 72 +++++++++++++++++++ .../auditor/auditorServiceFactory.ts | 9 ++- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .changeset/chilled-bugs-draw.md create mode 100644 packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.test.ts diff --git a/.changeset/chilled-bugs-draw.md b/.changeset/chilled-bugs-draw.md new file mode 100644 index 0000000000..2ef96badd1 --- /dev/null +++ b/.changeset/chilled-bugs-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The default auditor service implementation will now log low severity events with `debug` level instead of `info`. diff --git a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.test.ts new file mode 100644 index 0000000000..a4f5c96835 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ServiceFactoryTester, + mockServices, +} from '@backstage/backend-test-utils'; +import { auditorServiceFactory } from './auditorServiceFactory'; + +describe('auditorServiceFactory', () => { + it('should log with the appropriate log level', async () => { + const mockLogger = mockServices.logger.mock(); + mockLogger.child.mockReturnValue(mockLogger); + + const auditor = await ServiceFactoryTester.from(auditorServiceFactory, { + dependencies: [mockLogger.factory], + }).getSubject(); + + await auditor.createEvent({ + eventId: 'test1', + severityLevel: 'low', + }); + await auditor.createEvent({ + eventId: 'test2', + }); + await auditor.createEvent({ + eventId: 'test3', + severityLevel: 'medium', + }); + + expect(mockLogger.debug).toHaveBeenCalledWith('test.test1', { + eventId: 'test1', + severityLevel: 'low', + actor: { + actorId: 'plugin:test', + }, + plugin: 'test', + status: 'initiated', + }); + expect(mockLogger.debug).toHaveBeenCalledWith('test.test2', { + eventId: 'test2', + severityLevel: 'low', + actor: { + actorId: 'plugin:test', + }, + plugin: 'test', + status: 'initiated', + }); + expect(mockLogger.info).toHaveBeenCalledWith('test.test3', { + eventId: 'test3', + severityLevel: 'medium', + actor: { + actorId: 'plugin:test', + }, + plugin: 'test', + status: 'initiated', + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts index 1323ac5d76..6d26bbf258 100644 --- a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts @@ -40,7 +40,14 @@ export const auditorServiceFactory = createServiceFactory({ factory({ logger, plugin, auth, httpAuth }) { const auditLogger = logger.child({ isAuditEvent: true }); return DefaultAuditorService.create( - event => auditLogger.info(`${event.plugin}.${event.eventId}`, event), + event => { + const message = `${event.plugin}.${event.eventId}`; + if (event.severityLevel === 'low') { + auditLogger.debug(message, event); + } else { + auditLogger.info(message, event); + } + }, { plugin, auth, httpAuth }, ); }, From 4b8e2c25096c69c37af19b36a0664d4fcbc20e83 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 16:15:27 +0100 Subject: [PATCH 09/14] catalog-react: rename EntityCardLayout to EntityContentLayout Signed-off-by: Patrik Oldsberg --- .changeset/ninety-teachers-tease.md | 14 +- packages/app-next/app-config.yaml | 2 +- packages/app-next/src/EntityPages.tsx | 8 +- plugins/catalog-react/report-alpha.api.md | 134 +++++++++--------- ...t.tsx => EntityContentLayoutBlueprint.tsx} | 12 +- .../src/alpha/blueprints/index.ts | 6 +- plugins/catalog/report-alpha.api.md | 6 +- .../catalog/src/alpha/entityContents.test.tsx | 4 +- plugins/catalog/src/alpha/entityContents.tsx | 22 +-- 9 files changed, 106 insertions(+), 102 deletions(-) rename plugins/catalog-react/src/alpha/blueprints/{EntityCardLauyoutBlueprint.tsx => EntityContentLayoutBlueprint.tsx} (87%) diff --git a/.changeset/ninety-teachers-tease.md b/.changeset/ninety-teachers-tease.md index 5cc34b6503..73d6dbba8a 100644 --- a/.changeset/ninety-teachers-tease.md +++ b/.changeset/ninety-teachers-tease.md @@ -2,7 +2,7 @@ '@backstage/plugin-catalog-react': minor --- -Introduces a new `EntityCardLayoutBlueprint` that creates custom entity content layouts. +Introduces a new `EntityContentLayoutBlueprint` that creates custom entity content layouts. The layout components receive card elements and can render them as they see fit. Cards is an array of objects with the following properties: @@ -15,12 +15,12 @@ Creating a custom overview tab layout: ```tsx import { - EntityCardLayoutProps, - EntityCardLayoutBlueprint, + EntityContentLayoutProps, + EntityContentLayoutBlueprint, } from '@backstage/plugin-catalog-react/alpha'; // ... -function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { +function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { const { cards } = props; const classes = useStyles(); return ( @@ -66,7 +66,7 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { export const customEntityContentOverviewStickyLayoutModule = createFrontendModule({ pluginId: 'app', extensions: [ - EntityCardLayoutBlueprint.make({ + EntityContentLayoutBlueprint.make({ name: 'sticky', params: { // (optional) defaults the `() => false` filter function @@ -83,7 +83,7 @@ Disabling the custom layout: # app-config.yaml app: extensions: - - entity-card-layout:app/sticky: false + - entity-content-layout:app/sticky: false ``` Overriding the custom layout filter: @@ -92,7 +92,7 @@ Overriding the custom layout filter: # app-config.yaml app: extensions: - - entity-card-layout:app/sticky: + - entity-content-layout:app/sticky: config: # This layout will be used only with component entities filter: 'kind:component' diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index c814d8b8a7..d3da847c6a 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -71,7 +71,7 @@ app: # - entity-content:azure-devops/pull-requests # - entity-content:azure-devops/git-tags - - entity-card-layout:app/sticky: + - entity-content-layout:app/sticky: config: # this layout will apply to entities of kind component filter: 'kind:component' diff --git a/packages/app-next/src/EntityPages.tsx b/packages/app-next/src/EntityPages.tsx index 13c6895187..45e67e2e1d 100644 --- a/packages/app-next/src/EntityPages.tsx +++ b/packages/app-next/src/EntityPages.tsx @@ -18,8 +18,8 @@ import React from 'react'; import Grid from '@material-ui/core/Grid'; import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { - EntityCardLayoutBlueprint, - EntityCardLayoutProps, + EntityContentLayoutBlueprint, + EntityContentLayoutProps, } from '@backstage/plugin-catalog-react/alpha'; import { makeStyles } from '@material-ui/core/styles'; @@ -38,7 +38,7 @@ const useStyles = makeStyles(theme => ({ }, })); -function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { +function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { const { cards } = props; const classes = useStyles(); return ( @@ -89,7 +89,7 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { export const customEntityContentOverviewLayoutModule = createFrontendModule({ pluginId: 'app', extensions: [ - EntityCardLayoutBlueprint.make({ + EntityContentLayoutBlueprint.make({ name: 'sticky', params: { loader: async () => StickyEntityContentOverviewLayout, diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index e23d518c1f..d44dbae7c0 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -170,73 +170,6 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ }; }>; -// @alpha (undocumented) -export const EntityCardLayoutBlueprint: ExtensionBlueprint<{ - kind: 'entity-card-layout'; - name: undefined; - params: { - filter?: string | ((entity: Entity) => boolean) | undefined; - loader: () => Promise< - (props: EntityCardLayoutProps) => React_2.JSX.Element - >; - }; - output: - | ConfigurableExtensionDataRef< - (entity: Entity) => boolean, - 'catalog.entity-filter-function', - { - optional: true; - } - > - | ConfigurableExtensionDataRef< - string, - 'catalog.entity-filter-expression', - { - optional: true; - } - > - | ConfigurableExtensionDataRef< - (props: EntityCardLayoutProps) => React_2.JSX.Element, - 'catalog.entity-card-layout.component', - {} - >; - inputs: {}; - config: { - area: string | undefined; - filter: string | undefined; - }; - configInput: { - filter?: string | undefined; - area?: string | undefined; - }; - dataRefs: { - filterFunction: ConfigurableExtensionDataRef< - (entity: Entity) => boolean, - 'catalog.entity-filter-function', - {} - >; - filterExpression: ConfigurableExtensionDataRef< - string, - 'catalog.entity-filter-expression', - {} - >; - component: ConfigurableExtensionDataRef< - (props: EntityCardLayoutProps) => React_2.JSX.Element, - 'catalog.entity-card-layout.component', - {} - >; - }; -}>; - -// @alpha (undocumented) -export interface EntityCardLayoutProps { - // (undocumented) - cards: Array<{ - area?: (typeof defaultEntityCardAreas)[number]; - element: React_2.JSX.Element; - }>; -} - // @alpha export const EntityContentBlueprint: ExtensionBlueprint<{ kind: 'entity-content'; @@ -324,6 +257,73 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ }; }>; +// @alpha (undocumented) +export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ + kind: 'entity-content-layout'; + name: undefined; + params: { + filter?: string | ((entity: Entity) => boolean) | undefined; + loader: () => Promise< + (props: EntityContentLayoutProps) => React_2.JSX.Element + >; + }; + output: + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + (props: EntityContentLayoutProps) => React_2.JSX.Element, + 'catalog.entity-content-layout.component', + {} + >; + inputs: {}; + config: { + area: string | undefined; + filter: string | undefined; + }; + configInput: { + filter?: string | undefined; + area?: string | undefined; + }; + dataRefs: { + filterFunction: ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + {} + >; + filterExpression: ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + {} + >; + component: ConfigurableExtensionDataRef< + (props: EntityContentLayoutProps) => React_2.JSX.Element, + 'catalog.entity-content-layout.component', + {} + >; + }; +}>; + +// @alpha (undocumented) +export interface EntityContentLayoutProps { + // (undocumented) + cards: Array<{ + area?: (typeof defaultEntityCardAreas)[number]; + element: React_2.JSX.Element; + }>; +} + // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx similarity index 87% rename from plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx rename to plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx index 5c5a3b5ac4..6b5f84e0dd 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx @@ -27,7 +27,7 @@ import { import React from 'react'; /** @alpha */ -export interface EntityCardLayoutProps { +export interface EntityContentLayoutProps { cards: Array<{ area?: (typeof defaultEntityCardAreas)[number]; element: React.JSX.Element; @@ -35,14 +35,14 @@ export interface EntityCardLayoutProps { } const entityCardLayoutComponentDataRef = createExtensionDataRef< - (props: EntityCardLayoutProps) => React.JSX.Element + (props: EntityContentLayoutProps) => React.JSX.Element >().with({ - id: 'catalog.entity-card-layout.component', + id: 'catalog.entity-content-layout.component', }); /** @alpha */ -export const EntityCardLayoutBlueprint = createExtensionBlueprint({ - kind: 'entity-card-layout', +export const EntityContentLayoutBlueprint = createExtensionBlueprint({ + kind: 'entity-content-layout', attachTo: { id: 'entity-content:catalog/overview', input: 'layouts' }, output: [ entityFilterFunctionDataRef.optional(), @@ -69,7 +69,7 @@ export const EntityCardLayoutBlueprint = createExtensionBlueprint({ | typeof entityFilterFunctionDataRef.T | typeof entityFilterExpressionDataRef.T; loader: () => Promise< - (props: EntityCardLayoutProps) => React.JSX.Element + (props: EntityContentLayoutProps) => React.JSX.Element >; }, { node, config }, diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index bda94c8d04..a9d00123c4 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -16,9 +16,9 @@ export { EntityCardBlueprint } from './EntityCardBlueprint'; export { EntityContentBlueprint } from './EntityContentBlueprint'; export { - EntityCardLayoutBlueprint, - type EntityCardLayoutProps, -} from './EntityCardLauyoutBlueprint'; + EntityContentLayoutBlueprint, + type EntityContentLayoutProps, +} from './EntityContentLayoutBlueprint'; export { defaultEntityContentGroups, defaultEntityCardAreas, diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index a00d508bcc..498702e035 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -10,7 +10,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityCardLayoutProps } from '@backstage/plugin-catalog-react/alpha'; +import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; @@ -689,8 +689,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - (props: EntityCardLayoutProps) => JSX_2.Element, - 'catalog.entity-card-layout.component', + (props: EntityContentLayoutProps) => JSX_2.Element, + 'catalog.entity-content-layout.component', {} >, { diff --git a/plugins/catalog/src/alpha/entityContents.test.tsx b/plugins/catalog/src/alpha/entityContents.test.tsx index 5dfdca9d73..4821cc0179 100644 --- a/plugins/catalog/src/alpha/entityContents.test.tsx +++ b/plugins/catalog/src/alpha/entityContents.test.tsx @@ -25,7 +25,7 @@ import { import { catalogOverviewEntityContent } from './entityContents'; import { EntityCardBlueprint, - EntityCardLayoutBlueprint, + EntityContentLayoutBlueprint, } from '@backstage/plugin-catalog-react/alpha'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { @@ -125,7 +125,7 @@ describe('Overview content', () => { }, }); - const customLayout = EntityCardLayoutBlueprint.make({ + const customLayout = EntityContentLayoutBlueprint.make({ name: 'custom-layout', params: { loader: diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx index 7ad209ec39..1f4a670e2b 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -23,8 +23,8 @@ import { import { EntityCardBlueprint, EntityContentBlueprint, - EntityCardLayoutBlueprint, - EntityCardLayoutProps, + EntityContentLayoutBlueprint, + EntityContentLayoutProps, } from '@backstage/plugin-catalog-react/alpha'; import { buildFilterFn } from './filter/FilterWrapper'; import { useEntity } from '@backstage/plugin-catalog-react'; @@ -34,9 +34,9 @@ export const catalogOverviewEntityContent = name: 'overview', inputs: { layouts: createExtensionInput([ - EntityCardLayoutBlueprint.dataRefs.filterFunction.optional(), - EntityCardLayoutBlueprint.dataRefs.filterExpression.optional(), - EntityCardLayoutBlueprint.dataRefs.component, + EntityContentLayoutBlueprint.dataRefs.filterFunction.optional(), + EntityContentLayoutBlueprint.dataRefs.filterExpression.optional(), + EntityContentLayoutBlueprint.dataRefs.component, ]), cards: createExtensionInput([ coreExtensionData.reactElement, @@ -56,7 +56,7 @@ export const catalogOverviewEntityContent = })), ); - const DefaultLayoutComponent = (props: EntityCardLayoutProps) => { + const DefaultLayoutComponent = (props: EntityContentLayoutProps) => { return ( @@ -67,11 +67,15 @@ export const catalogOverviewEntityContent = const layouts = [ ...inputs.layouts.map(layout => ({ filter: buildFilterFn( - layout.get(EntityCardLayoutBlueprint.dataRefs.filterFunction), - layout.get(EntityCardLayoutBlueprint.dataRefs.filterExpression), + layout.get( + EntityContentLayoutBlueprint.dataRefs.filterFunction, + ), + layout.get( + EntityContentLayoutBlueprint.dataRefs.filterExpression, + ), ), Component: layout.get( - EntityCardLayoutBlueprint.dataRefs.component, + EntityContentLayoutBlueprint.dataRefs.component, ), })), { From bc1d8815ffd2790aa412aed6b4a2233209a8ddf4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 21:17:48 +0100 Subject: [PATCH 10/14] catalog-react: rename entity card "area" to "type" Signed-off-by: Patrik Oldsberg --- .changeset/ninety-teachers-tease.md | 8 +- .changeset/selfish-cheetahs-sip.md | 18 +-- packages/app-next/app-config.yaml | 4 +- packages/app-next/src/EntityPages.tsx | 6 +- plugins/api-docs/report-alpha.api.md | 61 +++++----- plugins/catalog-graph/report-alpha.api.md | 11 +- plugins/catalog-react/report-alpha.api.md | 28 ++--- .../blueprints/EntityCardBlueprint.test.tsx | 10 +- .../alpha/blueprints/EntityCardBlueprint.ts | 23 ++-- .../EntityContentLayoutBlueprint.tsx | 6 +- .../src/alpha/blueprints/extensionData.tsx | 22 ++-- .../src/alpha/blueprints/index.ts | 6 +- plugins/catalog/report-alpha.api.md | 105 +++++++++--------- .../catalog/src/alpha/entityContents.test.tsx | 6 +- plugins/catalog/src/alpha/entityContents.tsx | 4 +- plugins/org/report-alpha.api.md | 41 +++---- 16 files changed, 184 insertions(+), 175 deletions(-) diff --git a/.changeset/ninety-teachers-tease.md b/.changeset/ninety-teachers-tease.md index 73d6dbba8a..78a890c3d5 100644 --- a/.changeset/ninety-teachers-tease.md +++ b/.changeset/ninety-teachers-tease.md @@ -7,7 +7,7 @@ Introduces a new `EntityContentLayoutBlueprint` that creates custom entity conte The layout components receive card elements and can render them as they see fit. Cards is an array of objects with the following properties: - element: `JSx.Element`; -- area: `"peek" | "info" | "full" | undefined`; +- type: `"peek" | "info" | "full" | undefined`; ### Usage example @@ -33,7 +33,7 @@ function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { > {cards - .filter(card => card.area === 'info') + .filter(card => card.type === 'info') .map((card, index) => ( {card.element} @@ -44,14 +44,14 @@ function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { {cards - .filter(card => card.area === 'peek') + .filter(card => card.type === 'peek') .map((card, index) => ( {card.element} ))} {cards - .filter(card => !card.area || card.area === 'full') + .filter(card => !card.type || card.type === 'full') .map((card, index) => ( {card.element} diff --git a/.changeset/selfish-cheetahs-sip.md b/.changeset/selfish-cheetahs-sip.md index 590ad59d05..8ebd6b50b3 100644 --- a/.changeset/selfish-cheetahs-sip.md +++ b/.changeset/selfish-cheetahs-sip.md @@ -2,34 +2,34 @@ '@backstage/plugin-catalog-react': minor --- -Add an optional `area` parameter to `EntityCard` extensions. A card's area value determines where it should be rendered by the entity content layout, as well as its maximum size. +Add an optional `type` parameter to `EntityCard` extensions. A card's type determines characteristics such as its expected size and where it will be rendered by the entity content layout. -We are initially supporting only three areas: +Initially the following three types are supported: -- `peek`: used for cards containing infrastucture information (e.g. last builds, deployments, etc.). -- `info`: used for cards that contain entity metadata (e.g. about, links); -- `full`: Contains information that plugins add to an entity (e.g. PagerDuty incidents and on-call escalation). +- `peek`: small vertical cards that provide information at a glance, for example recent builds, deployments, and service health. +- `info`: medium size cards with high priority and frequently used information such as common actions, entity metadata, and links. +- `full`: Large cards that are more feature rich with more information, typically used by plugins that don't quite need the full content view and want to show a card instead. ### Usage examples -Defining a default area when creating a card: +Defining a default type when creating a card: ```diff const myCard = EntityCardBlueprint.make({ name: 'myCard', params: { -+ defaultArea: 'info', ++ type: 'info', loader: import('./MyCard).then(m => { default: m.MyCard }), }, }); ``` -Changing the card area via `app-config.yaml` file: +Changing the card type via `app-config.yaml` file: ```diff app: extensions: + - entity-card:myPlugin/myCard: + config: -+ area: info ++ type: info ``` diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index d3da847c6a..45f11ae1e2 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -28,12 +28,12 @@ app: # Entity page cards - entity-card:catalog/about: config: - area: info + type: info - entity-card:catalog/labels - entity-card:catalog/links: config: filter: kind:component has:links - area: info + type: info # - entity-card:linguist/languages - entity-card:catalog-graph/relations: config: diff --git a/packages/app-next/src/EntityPages.tsx b/packages/app-next/src/EntityPages.tsx index 45e67e2e1d..fd925e68b9 100644 --- a/packages/app-next/src/EntityPages.tsx +++ b/packages/app-next/src/EntityPages.tsx @@ -56,7 +56,7 @@ function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { > {cards - .filter(card => card.area === 'info') + .filter(card => card.type === 'info') .map((card, index) => ( {card.element} @@ -67,14 +67,14 @@ function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { {cards - .filter(card => card.area === 'peek') + .filter(card => card.type === 'peek') .map((card, index) => ( {card.element} ))} {cards - .filter(card => !card.area || card.area === 'full') + .filter(card => !card.type || card.type === 'full') .map((card, index) => ( {card.element} diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 8e9ad8c991..3c475e597e 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -8,6 +8,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; @@ -108,11 +109,11 @@ const _default: FrontendPlugin< name: 'has-apis'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -135,8 +136,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -145,7 +146,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/definition': ExtensionDefinition<{ @@ -153,11 +154,11 @@ const _default: FrontendPlugin< name: 'definition'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -180,8 +181,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -190,7 +191,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/consumed-apis': ExtensionDefinition<{ @@ -198,11 +199,11 @@ const _default: FrontendPlugin< name: 'consumed-apis'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -225,8 +226,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -235,7 +236,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/provided-apis': ExtensionDefinition<{ @@ -243,11 +244,11 @@ const _default: FrontendPlugin< name: 'provided-apis'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -270,8 +271,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -280,7 +281,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/consuming-components': ExtensionDefinition<{ @@ -288,11 +289,11 @@ const _default: FrontendPlugin< name: 'consuming-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -315,8 +316,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -325,7 +326,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/providing-components': ExtensionDefinition<{ @@ -333,11 +334,11 @@ const _default: FrontendPlugin< name: 'providing-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -360,8 +361,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -370,7 +371,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-content:api-docs/definition': ExtensionDefinition<{ diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index a7612ec0f5..9cdd5c3f01 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -8,6 +8,7 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Direction } from '@backstage/plugin-catalog-graph'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; @@ -43,7 +44,7 @@ const _default: FrontendPlugin< height: number | undefined; } & { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { height?: number | undefined; @@ -59,7 +60,7 @@ const _default: FrontendPlugin< relationPairs?: [string, string][] | undefined; } & { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -82,8 +83,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -102,7 +103,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'page:catalog-graph': ExtensionDefinition<{ diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index d44dbae7c0..02fac5f75a 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -99,9 +99,6 @@ export function convertLegacyEntityContentExtension( }, ): ExtensionDefinition; -// @alpha -export const defaultEntityCardAreas: readonly ['peek', 'info', 'full']; - // @alpha export const defaultEntityContentGroups: { documentation: string; @@ -117,7 +114,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; output: | ConfigurableExtensionDataRef @@ -136,8 +133,8 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -145,11 +142,11 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ inputs: {}; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -162,14 +159,17 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ 'catalog.entity-filter-expression', {} >; - area: ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + type: ConfigurableExtensionDataRef< + EntityCardType, + 'catalog.entity-card-type', {} >; }; }>; +// @alpha (undocumented) +export type EntityCardType = 'peek' | 'info' | 'full'; + // @alpha export const EntityContentBlueprint: ExtensionBlueprint<{ kind: 'entity-content'; @@ -289,12 +289,12 @@ export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ >; inputs: {}; config: { - area: string | undefined; + type: string | undefined; filter: string | undefined; }; configInput: { filter?: string | undefined; - area?: string | undefined; + type?: string | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -319,7 +319,7 @@ export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ export interface EntityContentLayoutProps { // (undocumented) cards: Array<{ - area?: (typeof defaultEntityCardAreas)[number]; + type?: EntityCardType; element: React_2.JSX.Element; }>; } diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index e534db8575..563bb15ebd 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -51,7 +51,10 @@ describe('EntityCardBlueprint', () => { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { - "area": { + "filter": { + "type": "string", + }, + "type": { "enum": [ "peek", "info", @@ -59,9 +62,6 @@ describe('EntityCardBlueprint', () => { ], "type": "string", }, - "filter": { - "type": "string", - }, }, "type": "object", }, @@ -96,7 +96,7 @@ describe('EntityCardBlueprint', () => { "config": { "optional": true, }, - "id": "catalog.entity-card-area", + "id": "catalog.entity-card-type", "optional": [Function], "toString": [Function], }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 1b2fa4edcc..5ef6106f40 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -22,8 +22,9 @@ import { import { entityFilterFunctionDataRef, entityFilterExpressionDataRef, - entityCardAreaDataRef, - defaultEntityCardAreas, + entityCardTypeDataRef, + entityCardTypes, + EntityCardType, } from './extensionData'; /** @@ -37,30 +38,30 @@ export const EntityCardBlueprint = createExtensionBlueprint({ coreExtensionData.reactElement, entityFilterFunctionDataRef.optional(), entityFilterExpressionDataRef.optional(), - entityCardAreaDataRef.optional(), + entityCardTypeDataRef.optional(), ], dataRefs: { filterFunction: entityFilterFunctionDataRef, filterExpression: entityFilterExpressionDataRef, - area: entityCardAreaDataRef, + type: entityCardTypeDataRef, }, config: { schema: { filter: z => z.string().optional(), - area: z => z.enum(defaultEntityCardAreas).optional(), + type: z => z.enum(entityCardTypes).optional(), }, }, *factory( { loader, filter, - defaultArea, + type, }: { loader: () => Promise; filter?: | typeof entityFilterFunctionDataRef.T | typeof entityFilterExpressionDataRef.T; - defaultArea?: (typeof defaultEntityCardAreas)[number]; + type?: EntityCardType; }, { node, config }, ) { @@ -74,13 +75,13 @@ export const EntityCardBlueprint = createExtensionBlueprint({ yield entityFilterFunctionDataRef(filter); } - const area = config.area ?? defaultArea; - if (area) { - yield entityCardAreaDataRef(area); + const finalType = config.type ?? type; + if (finalType) { + yield entityCardTypeDataRef(finalType); } else { // eslint-disable-next-line no-console console.warn( - `DEPRECATION WARNING: Not providing defaultArea for entity cards is deprecated. Missing from '${node.spec.id}'`, + `DEPRECATION WARNING: Not providing type for entity cards is deprecated. Missing from '${node.spec.id}'`, ); } }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx index 6b5f84e0dd..ab15a3d791 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx @@ -22,14 +22,14 @@ import { import { entityFilterExpressionDataRef, entityFilterFunctionDataRef, - defaultEntityCardAreas, + EntityCardType, } from './extensionData'; import React from 'react'; /** @alpha */ export interface EntityContentLayoutProps { cards: Array<{ - area?: (typeof defaultEntityCardAreas)[number]; + type?: EntityCardType; element: React.JSX.Element; }>; } @@ -56,7 +56,7 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({ }, config: { schema: { - area: z => z.string().optional(), + type: z => z.string().optional(), filter: z => z.string().optional(), }, }, diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index b9a63edf9c..bae7d4e668 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -50,14 +50,20 @@ export const entityContentGroupDataRef = createExtensionDataRef().with({ }); /** - * @alpha - * Default entity content groups. + * @internal + * Available entity card types */ -export const defaultEntityCardAreas = ['peek', 'info', 'full'] as const; +export const entityCardTypes = [ + 'peek', + 'info', + 'full', +] as const satisfies readonly EntityCardType[]; + +/** @alpha */ +export type EntityCardType = 'peek' | 'info' | 'full'; /** @internal */ -export const entityCardAreaDataRef = createExtensionDataRef< - (typeof defaultEntityCardAreas)[number] ->().with({ - id: 'catalog.entity-card-area', -}); +export const entityCardTypeDataRef = + createExtensionDataRef().with({ + id: 'catalog.entity-card-type', + }); diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index a9d00123c4..9cde440819 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -19,7 +19,5 @@ export { EntityContentLayoutBlueprint, type EntityContentLayoutProps, } from './EntityContentLayoutBlueprint'; -export { - defaultEntityContentGroups, - defaultEntityCardAreas, -} from './extensionData'; +export { defaultEntityContentGroups } from './extensionData'; +export type { EntityCardType } from './extensionData'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 498702e035..53f1f751ef 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -10,6 +10,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; @@ -218,11 +219,11 @@ const _default: FrontendPlugin< name: 'about'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -241,8 +242,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -251,7 +252,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/links': ExtensionDefinition<{ @@ -259,11 +260,11 @@ const _default: FrontendPlugin< name: 'links'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -282,8 +283,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -292,7 +293,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/labels': ExtensionDefinition<{ @@ -300,11 +301,11 @@ const _default: FrontendPlugin< name: 'labels'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -323,8 +324,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -333,7 +334,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/depends-on-components': ExtensionDefinition<{ @@ -341,11 +342,11 @@ const _default: FrontendPlugin< name: 'depends-on-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -364,8 +365,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -374,7 +375,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/depends-on-resources': ExtensionDefinition<{ @@ -382,11 +383,11 @@ const _default: FrontendPlugin< name: 'depends-on-resources'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -405,8 +406,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -415,7 +416,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-components': ExtensionDefinition<{ @@ -423,11 +424,11 @@ const _default: FrontendPlugin< name: 'has-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -446,8 +447,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -456,7 +457,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-resources': ExtensionDefinition<{ @@ -464,11 +465,11 @@ const _default: FrontendPlugin< name: 'has-resources'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -487,8 +488,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -497,7 +498,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-subcomponents': ExtensionDefinition<{ @@ -505,11 +506,11 @@ const _default: FrontendPlugin< name: 'has-subcomponents'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -528,8 +529,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -538,7 +539,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-subdomains': ExtensionDefinition<{ @@ -546,11 +547,11 @@ const _default: FrontendPlugin< name: 'has-subdomains'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -569,8 +570,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -579,7 +580,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-systems': ExtensionDefinition<{ @@ -587,11 +588,11 @@ const _default: FrontendPlugin< name: 'has-systems'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -610,8 +611,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -620,7 +621,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-content:catalog/overview': ExtensionDefinition<{ @@ -715,8 +716,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } diff --git a/plugins/catalog/src/alpha/entityContents.test.tsx b/plugins/catalog/src/alpha/entityContents.test.tsx index 4821cc0179..2ea61c1c54 100644 --- a/plugins/catalog/src/alpha/entityContents.test.tsx +++ b/plugins/catalog/src/alpha/entityContents.test.tsx @@ -113,7 +113,7 @@ describe('Overview content', () => { const infoCard = EntityCardBlueprint.make({ name: 'info-card', params: { - defaultArea: 'info', + type: 'info', loader: async () =>
Info card
, }, }); @@ -136,14 +136,14 @@ describe('Overview content', () => {

Custom layout

{cards - .filter(card => card.area === 'info') + .filter(card => card.type === 'info') .map((card, index) => ( {card.element} ))}
{cards - .filter(card => card.area !== 'info') + .filter(card => card.type !== 'info') .map((card, index) => ( {card.element} ))} diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx index 1f4a670e2b..3e883433a9 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -42,7 +42,7 @@ export const catalogOverviewEntityContent = coreExtensionData.reactElement, EntityContentBlueprint.dataRefs.filterFunction.optional(), EntityContentBlueprint.dataRefs.filterExpression.optional(), - EntityCardBlueprint.dataRefs.area.optional(), + EntityCardBlueprint.dataRefs.type.optional(), ]), }, factory: (originalFactory, { node, inputs }) => { @@ -86,7 +86,7 @@ export const catalogOverviewEntityContent = const cards = inputs.cards.map(card => ({ element: card.get(coreExtensionData.reactElement), - area: card.get(EntityCardBlueprint.dataRefs.area), + type: card.get(EntityCardBlueprint.dataRefs.type), filter: buildFilterFn( card.get(EntityContentBlueprint.dataRefs.filterFunction), card.get(EntityContentBlueprint.dataRefs.filterExpression), diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index 74d2fdf68c..eb5c41faa1 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -22,11 +23,11 @@ const _default: FrontendPlugin< name: 'group-profile'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -49,8 +50,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -59,7 +60,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:org/members-list': ExtensionDefinition<{ @@ -67,11 +68,11 @@ const _default: FrontendPlugin< name: 'members-list'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -94,8 +95,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -104,7 +105,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:org/ownership': ExtensionDefinition<{ @@ -112,11 +113,11 @@ const _default: FrontendPlugin< name: 'ownership'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -139,8 +140,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -149,7 +150,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:org/user-profile': ExtensionDefinition<{ @@ -157,11 +158,11 @@ const _default: FrontendPlugin< name: 'user-profile'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -184,8 +185,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -194,7 +195,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; } From b5e84429c0657578c2d397a429e1eb9a10669ecd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 21 Feb 2025 13:51:29 +0100 Subject: [PATCH 11/14] backend-defaults: add test for nested log meta fields Signed-off-by: Patrik Oldsberg --- .../rootLogger/WinstonLogger.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts index 0f9e079943..39293dcc55 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts @@ -93,4 +93,21 @@ describe('WinstonLogger', () => { expect.any(Function), ); }); + + it('gracefully handles fields that contain deeper object structures', () => { + const log = jest.fn(); + const mockTransport = new Transport({ log }); + + const logger = WinstonLogger.create({ + transports: [mockTransport], + }); + + logger.error('something went wrong', { + field: { foo: { bar: { baz: 'qux' } } }, + }); + + expect(log.mock.calls[0][0][MESSAGE]).toContain( + `={"foo":{"bar":{"baz":"qux"}}}`, + ); + }); }); From d517d13f74f6847d6600dacf833781893d4db18c Mon Sep 17 00:00:00 2001 From: Kurt King Date: Fri, 21 Feb 2025 06:41:39 -0700 Subject: [PATCH 12/14] Provide backstage.io/kubernetes-id and backstage.io/kubernetes-label-selector annotations as exported constants (#28710) * export default kubernetes annotations Signed-off-by: Kurt King * replace deprecations Signed-off-by: Kurt King * Add changesets Signed-off-by: Kurt King * regenerate api reports Signed-off-by: Kurt King * Update additional references Signed-off-by: Kurt King * fix api reports Signed-off-by: Kurt King --------- Signed-off-by: Kurt King --- .changeset/dirty-turkeys-remain.md | 6 +++++ .changeset/dull-coats-help.md | 5 ++++ plugins/kubernetes-backend/report.api.md | 2 +- .../src/service/KubernetesFanOutHandler.ts | 16 +++++++------ plugins/kubernetes-common/report.api.md | 7 ++++++ .../src/catalog-entity-constants.ts | 23 +++++++++++++++++++ plugins/kubernetes/src/Router.tsx | 8 +++---- 7 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 .changeset/dirty-turkeys-remain.md create mode 100644 .changeset/dull-coats-help.md diff --git a/.changeset/dirty-turkeys-remain.md b/.changeset/dirty-turkeys-remain.md new file mode 100644 index 0000000000..f9119e3880 --- /dev/null +++ b/.changeset/dirty-turkeys-remain.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes': patch +--- + +refactor: use `KUBERNETES_ANNOTATION` and `KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION` annotations from `kubernetes-common` diff --git a/.changeset/dull-coats-help.md b/.changeset/dull-coats-help.md new file mode 100644 index 0000000000..60a099da2e --- /dev/null +++ b/.changeset/dull-coats-help.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-common': patch +--- + +Export `backstage.io/kubernetes-id` and `backstage.io/kubernetes-label-selector` annotations as constants diff --git a/plugins/kubernetes-backend/report.api.md b/plugins/kubernetes-backend/report.api.md index 281c8e5cac..345c53f65b 100644 --- a/plugins/kubernetes-backend/report.api.md +++ b/plugins/kubernetes-backend/report.api.md @@ -99,7 +99,7 @@ export type CustomResource = k8sAuthTypes.CustomResource; export type CustomResourcesByEntity = k8sAuthTypes.CustomResourcesByEntity; // @public (undocumented) -export const DEFAULT_OBJECTS: ObjectToFetch[]; +export const DEFAULT_OBJECTS: ObjectToFetch_2[]; // @public export class DispatchStrategy implements AuthenticationStrategy_2 { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 74be477578..424212287f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -16,13 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { - CustomResource, - FetchResponseWrapper, - KubernetesFetcher, KubernetesObjectsProviderOptions, - KubernetesServiceLocator, ObjectsByEntityRequest, - ObjectToFetch, } from '../types/types'; import { ClientContainerStatus, @@ -31,6 +26,8 @@ import { ClusterObjects, CustomResourceMatcher, FetchResponse, + KUBERNETES_ANNOTATION, + KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION, KubernetesRequestAuth, ObjectsByEntityResponse, PodFetchResponse, @@ -44,10 +41,15 @@ import { import { AuthenticationStrategy, ClusterDetails, + CustomResource, CustomResourcesByEntity, + FetchResponseWrapper, KubernetesCredential, + KubernetesFetcher, KubernetesObjectsByEntity, KubernetesObjectsProvider, + KubernetesServiceLocator, + ObjectToFetch, } from '@backstage/plugin-kubernetes-node'; import { BackstageCredentials, @@ -285,8 +287,8 @@ export class KubernetesFanOutHandler implements KubernetesObjectsProvider { const labelSelector: string = entity.metadata?.annotations?.[ - 'backstage.io/kubernetes-label-selector' - ] || `backstage.io/kubernetes-id=${entityName}`; + KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION + ] || `${KUBERNETES_ANNOTATION}=${entityName}`; const namespace = entity.metadata?.annotations?.['backstage.io/kubernetes-namespace']; diff --git a/plugins/kubernetes-common/report.api.md b/plugins/kubernetes-common/report.api.md index cc99e5063d..80486259a2 100644 --- a/plugins/kubernetes-common/report.api.md +++ b/plugins/kubernetes-common/report.api.md @@ -317,6 +317,13 @@ export interface JobsFetchResponse { type: 'jobs'; } +// @public +export const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; + +// @public +export const KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION = + 'backstage.io/kubernetes-label-selector'; + // @public export const kubernetesClustersReadPermission: BasicPermission; diff --git a/plugins/kubernetes-common/src/catalog-entity-constants.ts b/plugins/kubernetes-common/src/catalog-entity-constants.ts index 996faf670d..472e19ef7a 100644 --- a/plugins/kubernetes-common/src/catalog-entity-constants.ts +++ b/plugins/kubernetes-common/src/catalog-entity-constants.ts @@ -14,6 +14,29 @@ * limitations under the License. */ +/** + * The annotation key used to identify Kubernetes resources in Backstage. + * This constant represents the standard annotation 'backstage.io/kubernetes-id' + * which links catalog entities to their corresponding Kubernetes resources. + * + * @public + */ +export const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; + +/** + * Annotation used to specify a Kubernetes label selector query for filtering resources. + * When this annotation is added to a catalog entity, it defines criteria for selecting + * Kubernetes resources based on their labels. + * + * @public + * + * @remarks + * The value of this annotation should be a valid Kubernetes label selector query string. + * For example: 'app=my-app,environment=production' + */ +export const KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION = + 'backstage.io/kubernetes-label-selector'; + /** * Annotation for specifying the API server of a Kubernetes cluster * diff --git a/plugins/kubernetes/src/Router.tsx b/plugins/kubernetes/src/Router.tsx index db54d6c513..c613de92c3 100644 --- a/plugins/kubernetes/src/Router.tsx +++ b/plugins/kubernetes/src/Router.tsx @@ -23,10 +23,10 @@ import { import { Route, Routes } from 'react-router-dom'; import { KubernetesContent } from './KubernetesContent'; import Button from '@material-ui/core/Button'; - -const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; -const KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION = - 'backstage.io/kubernetes-label-selector'; +import { + KUBERNETES_ANNOTATION, + KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION, +} from '@backstage/plugin-kubernetes-common'; export const isKubernetesAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[KUBERNETES_ANNOTATION]) || From 13da59b508965343b30d526a0a0ab7676e09534e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 21 Feb 2025 14:56:38 +0100 Subject: [PATCH 13/14] catalog-backend: another stitching test Signed-off-by: Patrik Oldsberg --- .../stitcher/performStitching.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts b/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts index 1021862c12..985146229a 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts @@ -363,4 +363,77 @@ describe('performStitching', () => { ); }, ); + + it.each(databases.eachSupportedId())( + 'replaces existing stitch ticket %p', + async databaseId => { + const knex = await databases.init(databaseId); + await applyDatabaseMigrations(knex); + + await knex('refresh_state').insert([ + { + entity_id: 'my-id', + entity_ref: 'k:ns/n', + unprocessed_entity: JSON.stringify({}), + processed_entity: JSON.stringify({ + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + }, + spec: { + k: 'v', + }, + }), + errors: '[]', + next_update_at: knex.fn.now(), + last_discovery_at: knex.fn.now(), + }, + ]); + await knex('final_entities').insert([ + { + entity_id: 'my-id', + entity_ref: 'k:ns/n', + hash: '', + stitch_ticket: 'old-ticket', + final_entity: JSON.stringify({}), + }, + ]); + + await expect( + knex('final_entities').select([ + 'entity_id', + 'stitch_ticket', + ]), + ).resolves.toEqual([ + { + entity_id: 'my-id', + stitch_ticket: expect.stringContaining('old-ticket'), + }, + ]); + + const stitchLogger = mockServices.logger.mock(); + await expect( + performStitching({ + knex, + logger: stitchLogger, + strategy: { mode: 'immediate' }, + entityRef: 'k:ns/n', + }), + ).resolves.toBe('changed'); + + await expect( + knex('final_entities').select([ + 'entity_id', + 'stitch_ticket', + ]), + ).resolves.toEqual([ + { + entity_id: 'my-id', + stitch_ticket: expect.not.stringContaining('old-ticket'), + }, + ]); + }, + ); }); From b5a82087a7164879ca6c6717329b3e29cc20c3dd Mon Sep 17 00:00:00 2001 From: Jackson Chen <53205189+PeaWarrior@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:39:45 -0500 Subject: [PATCH 14/14] techdocs: add extensions for techdocs addons (#28644) * techdocs: add extensions for techdocs addons Signed-off-by: Jackson Chen * techdocs: add blueprint extension for techdocs addons Signed-off-by: Jackson Chen * techdocs: move addons blueprint to alpha Signed-off-by: Jackson Chen * techdocs: add addon extensions for new frontend system and add docs Signed-off-by: Jackson Chen * techdocs: fix addon modules naming patterns Signed-off-by: Jackson Chen * techdocs: update test utils with entity presentation api Signed-off-by: Jackson Chen --------- Signed-off-by: Jackson Chen --- .changeset/strange-masks-type.md | 7 + docs/features/techdocs/addons--new.md | 204 ++++++++++++++++++ docs/features/techdocs/addons.md | 4 + .../src/test-utils.tsx | 15 +- .../package.json | 20 +- .../report-alpha.api.md | 21 ++ .../{LigthBox => LightBox}/LightBox.test.tsx | 0 .../src/{LigthBox => LightBox}/LightBox.tsx | 0 .../src/{LigthBox => LightBox}/index.ts | 0 .../src/{LigthBox => LightBox}/lightbox.css | 0 .../src/alpha.ts | 86 ++++++++ .../src/plugin.ts | 2 +- plugins/techdocs-react/package.json | 20 +- plugins/techdocs-react/report-alpha.api.md | 63 ++++++ plugins/techdocs-react/src/addons.tsx | 2 +- plugins/techdocs-react/src/alpha.ts | 58 +++++ plugins/techdocs/report-alpha.api.md | 30 ++- plugins/techdocs/src/Router.tsx | 21 +- plugins/techdocs/src/alpha.tsx | 64 ++++-- yarn.lock | 2 + 20 files changed, 589 insertions(+), 30 deletions(-) create mode 100644 .changeset/strange-masks-type.md create mode 100644 docs/features/techdocs/addons--new.md create mode 100644 plugins/techdocs-module-addons-contrib/report-alpha.api.md rename plugins/techdocs-module-addons-contrib/src/{LigthBox => LightBox}/LightBox.test.tsx (100%) rename plugins/techdocs-module-addons-contrib/src/{LigthBox => LightBox}/LightBox.tsx (100%) rename plugins/techdocs-module-addons-contrib/src/{LigthBox => LightBox}/index.ts (100%) rename plugins/techdocs-module-addons-contrib/src/{LigthBox => LightBox}/lightbox.css (100%) create mode 100644 plugins/techdocs-module-addons-contrib/src/alpha.ts create mode 100644 plugins/techdocs-react/report-alpha.api.md create mode 100644 plugins/techdocs-react/src/alpha.ts diff --git a/.changeset/strange-masks-type.md b/.changeset/strange-masks-type.md new file mode 100644 index 0000000000..6b4edf9c5e --- /dev/null +++ b/.changeset/strange-masks-type.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-react': patch +'@backstage/plugin-techdocs-module-addons-contrib': patch +--- + +Added `TechDocsAddonsBlueprint` extension to allow adding of techdocs addons. diff --git a/docs/features/techdocs/addons--new.md b/docs/features/techdocs/addons--new.md new file mode 100644 index 0000000000..07138d4b8e --- /dev/null +++ b/docs/features/techdocs/addons--new.md @@ -0,0 +1,204 @@ +--- +id: addons +title: TechDocs Addons +description: How to find, use, or create TechDocs Addons. +--- + +:::info +This documentation is written for [the new frontend system](../../frontend-system/index.md) which is still in alpha and is only supported by a small number of plugins. +::: + +## Concepts + +TechDocs is a centralized platform for publishing, viewing, and discovering +technical documentation across an entire organization. It's a solid foundation! +But it doesn't solve higher-order documentation needs on its own: how do you +create and reinforce a culture of documentation? How do you build trust in the +quality of technical documentation? + +TechDocs Addons are a mechanism by which you can customize the TechDocs +experience in order to try and address some of these higher-order needs. + +### Addons + +An Addon is just a react component. Like any react component, it can retrieve +and render data using normal Backstage or native hooks, APIs, and components. +Props can be used to configure its behavior, where appropriate. + +### Locations + +Addons declare a `location` where they will be rendered. Most locations are +representative of physical spaces in the TechDocs UI: + +- `Header`: For Addons which fill up the header from the right, on the same + line as the title. +- `Subheader`: For Addons that sit below the header but above all content. + This is a great location for tooling/configuration of TechDocs display. +- `Settings`: These addons are items added to the settings menu list and are designed to make + the reader experience customizable, for example accessibility options. +- `PrimarySidebar`: Left of the content, above of the navigation. +- `SecondarySidebar`: Right of the content, above the table of contents. +- `Content`: A special location intended for Addons which augment the + statically generated content of the documentation itself. +- `Component`: A [proposed-but-not-yet-implemented](https://github.com/backstage/backstage/issues/11109) + virtual location, aimed at simplifying a common type of Addon. + + + +![TechDocs Addon Location Guide](../../assets/techdocs/addon-locations.png) + +### Addon Registry + +The installation and configuration of Addons happens within a Backstage app's +frontend. Addons are imported from plugins and registered as a plugin extension which +are configured for both the TechDocs Reader page as well as the Entity docs page. + +Addons are rendered in the order in which they are registered. + +## Installing and using Addons + +To start using Addons you need to add the `@backstage/plugin-techdocs-module-addons-contrib` package to your app. You can do that by running this command from the root of your project: `yarn --cwd packages/app add @backstage/plugin-techdocs-module-addons-contrib` + +Addons can then be installed as a module in your `App.tsx`: + +```tsx +// packages/app/src/App.tsx + +import { createApp } from '@backstage/frontend-defaults'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { techDocsReportIssueAddonModule } from '@backstage/plugin-techdocs-module-addons-contrib/alpha'; + +// ... + +const app = createApp({ + features: [ + // ... + techDocsReportIssueAddonModule, + // ...other techdocs addon modules + ], +}); + +export default app.createRoot(); +``` + +Note that on the entity page, because the Catalog plugin is responsible for the +page header, TechDocs Addons whose location is `Header` will not be rendered. + +## Available Addons + +Addons can, in principle, be provided by any plugin! To make it easier to +discover available Addons, we've compiled a list of them here: + +| Addon | Package/Plugin | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`techDocsExpandableNavigationAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.expandablenavigation) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | +| [`techDocsReportIssueAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.reportissue) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | +| [`techDocsTextSizeAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.textsize) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | +| [`techDocsLightBoxAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.lightbox) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). | + +Got an Addon to contribute? Feel free to add a row above! + +## Creating an Addon + +The simplest Addons are plain old react components that get rendered in +specific locations within a TechDocs site. To package such a react component as +an Addon, follow these steps: + +1. Write the component in your plugin like any other component +2. Create the addon extension using the `TechDocsAddonBlueprint` +3. Create and export the addon module from your plugin + +```ts +// plugins/your-plugin/src/plugin.ts + +import { TechDocsAddonLocations } from '@backstage/plugin-techdocs-react'; +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; +import { CatGifComponent } from './addons'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; + +// ... + +const techDocsCatGifAddon = AddonBlueprint.make({ + name: 'cat-gif', + params: { + name: 'CatGif', + location: TechDocsAddonLocations.Header, + component: CatGifComponent, + }, +}); + +export const techDocsCatGifAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsCatGifAddon], +}); +``` + +### Addons in the Content location + +Beyond the "render a component in a region" use-case, it's also possible for +Addons to access and manipulate a TechDocs site's DOM; this could be used to, +for example, load and instantiate client-side diagramming libraries, replace +elements with dynamically loaded content, etc. + +This type of Addon is still expressed as a react component, but instead of +returning a react element to be rendered, it updates the DOM via side-effects +(e.g. with `useEffect`). Access to the DOM is made available via utility hooks +provided by the Addon framework. + +```tsx +// plugins/your-plugin/src/addons/MakeAllImagesCatGifs.tsx + +import React, { useEffect } from 'react'; +import { useShadowRootElements } from '@backstage/plugin-techdocs-react'; + +// This is a normal react component; in order to make it an Addon, you would +// still create and provide it via your plugin as described above. The only +// difference is that you'd set `location` to `TechDocsAddonLocations.Content`. +export const MakeAllImagesCatGifsAddon = () => { + // This hook can be used to get references to specific elements. If you need + // access to the whole shadow DOM, use the underlying useShadowRoot() + // hook instead. + const images = useShadowRootElements(['img']); + + useEffect(() => { + images.forEach(img => { + if (img.src !== 'https://example.com/cat.gif') { + img.src = 'https://example.com/cat.gif'; + } + }); + }, [images]); + + // Nothing to render directly, so we can just return null. + return null; +}; +``` + +### Testing Addons + +Install `@backstage/plugin-techdocs-addons-test-utils` as a `devDependency` in +your plugin for access to utilities that make testing such Addons easier. + +A test for the above Addon might look something like this: + +```tsx +// plugins/your-plugin/src/addons/MakeAllImagesCatGifs.test.tsx +import { TechDocsAddonTester } from '@backstage/plugin-techdocs-addons-test-utils'; + +// Note: import your actual addon (the one provided by your plugin). +import { MakeAllImagesCatGifs } from '../plugin.ts'; + +describe('MakeAllImagesCatGifs', () => { + it('replaces img srcs with cat gif', async () => { + const { getByTestId } = await TechDocsAddonTester.buildAddonsInTechDocs([ + , + ]) + .withDom() + .renderWithEffects(); + + expect(getByTestId('fixture')).toHaveAttribute( + 'src', + 'https://example.com/cat.gif', + ); + }); +}); +``` diff --git a/docs/features/techdocs/addons.md b/docs/features/techdocs/addons.md index fff1f09d71..a34c2f1162 100644 --- a/docs/features/techdocs/addons.md +++ b/docs/features/techdocs/addons.md @@ -4,6 +4,10 @@ title: TechDocs Addons description: How to find, use, or create TechDocs Addons. --- +:::info +This documentation is written for [the old frontend system](./getting-started.md#adding-techdocs-frontend-plugin). If you are on the [new frontend system](../../frontend-system/index.md) you may want to read [its own article](./addons--new.md) instead. +::: + ## Concepts TechDocs is a centralized platform for publishing, viewing, and discovering diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 2066acbb48..1f6337fc23 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -38,7 +38,11 @@ import { techdocsStorageApiRef, } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPage, techdocsPlugin } from '@backstage/plugin-techdocs'; -import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { + EntityPresentationApi, + entityPresentationApiRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; import { searchApiRef } from '@backstage/plugin-search-react'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; @@ -223,8 +227,17 @@ export class TechDocsAddonTester { }), }; + const entityPresentationApi: EntityPresentationApi = { + forEntity: jest.fn().mockReturnValue({ + snapshot: { + primaryTitle: 'Test Entity', + }, + }), + }; + const apis: TechdocsAddonTesterApis = [ [fetchApiRef, fetchApi], + [entityPresentationApiRef, entityPresentationApi], [discoveryApiRef, discoveryApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], diff --git a/plugins/techdocs-module-addons-contrib/package.json b/plugins/techdocs-module-addons-contrib/package.json index 52d74439ce..202051dc62 100644 --- a/plugins/techdocs-module-addons-contrib/package.json +++ b/plugins/techdocs-module-addons-contrib/package.json @@ -8,9 +8,7 @@ "pluginPackage": "@backstage/plugin-techdocs" }, "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" }, "keywords": [ "backstage", @@ -24,8 +22,23 @@ }, "license": "Apache-2.0", "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, "main": "src/index.ts", "types": "src/index.ts", + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "files": [ "dist" ], @@ -41,6 +54,7 @@ "dependencies": { "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-techdocs-react": "workspace:^", diff --git a/plugins/techdocs-module-addons-contrib/report-alpha.api.md b/plugins/techdocs-module-addons-contrib/report-alpha.api.md new file mode 100644 index 0000000000..1e203eb306 --- /dev/null +++ b/plugins/techdocs-module-addons-contrib/report-alpha.api.md @@ -0,0 +1,21 @@ +## API Report File for "@backstage/plugin-techdocs-module-addons-contrib" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { FrontendModule } from '@backstage/frontend-plugin-api'; + +// @alpha (undocumented) +export const techDocsExpandableNavigationAddonModule: FrontendModule; + +// @alpha (undocumented) +export const techDocsLightBoxAddonModule: FrontendModule; + +// @alpha (undocumented) +export const techDocsReportIssueAddonModule: FrontendModule; + +// @alpha (undocumented) +export const techDocsTextSizeAddonModule: FrontendModule; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.test.tsx b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.test.tsx rename to plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx rename to plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/index.ts b/plugins/techdocs-module-addons-contrib/src/LightBox/index.ts similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/index.ts rename to plugins/techdocs-module-addons-contrib/src/LightBox/index.ts diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css b/plugins/techdocs-module-addons-contrib/src/LightBox/lightbox.css similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css rename to plugins/techdocs-module-addons-contrib/src/LightBox/lightbox.css diff --git a/plugins/techdocs-module-addons-contrib/src/alpha.ts b/plugins/techdocs-module-addons-contrib/src/alpha.ts new file mode 100644 index 0000000000..acbb808a55 --- /dev/null +++ b/plugins/techdocs-module-addons-contrib/src/alpha.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TechDocsAddonLocations } from '@backstage/plugin-techdocs-react'; +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; +import { TextSizeAddon } from './TextSize'; +import { ReportIssueAddon } from './ReportIssue'; +import { ExpandableNavigationAddon } from './ExpandableNavigation'; +import { LightBoxAddon } from './LightBox'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; + +/** @alpha */ +const techDocsExpandableNavigationAddon = AddonBlueprint.make({ + name: 'expandable-navigation', + params: { + name: 'ExpandableNavigation', + location: TechDocsAddonLocations.PrimarySidebar, + component: ExpandableNavigationAddon, + }, +}); + +/** @alpha */ +export const techDocsExpandableNavigationAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsExpandableNavigationAddon], +}); + +/** @alpha */ +const techDocsReportIssueAddon = AddonBlueprint.make({ + name: 'report-issue', + params: { + name: 'ReportIssue', + location: TechDocsAddonLocations.Content, + component: ReportIssueAddon, + }, +}); + +/** @alpha */ +export const techDocsReportIssueAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsReportIssueAddon], +}); + +/** @alpha */ +const techDocsTextSizeAddon = AddonBlueprint.make({ + name: 'text-size', + params: { + name: 'TextSize', + location: TechDocsAddonLocations.Settings, + component: TextSizeAddon, + }, +}); + +/** @alpha */ +export const techDocsTextSizeAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsTextSizeAddon], +}); + +/** @alpha */ +const techDocsLightBoxAddon = AddonBlueprint.make({ + name: 'light-box', + params: { + name: 'LightBox', + location: TechDocsAddonLocations.Content, + component: LightBoxAddon, + }, +}); + +/** @alpha */ +export const techDocsLightBoxAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsLightBoxAddon], +}); diff --git a/plugins/techdocs-module-addons-contrib/src/plugin.ts b/plugins/techdocs-module-addons-contrib/src/plugin.ts index c4023a72af..ac19d682de 100644 --- a/plugins/techdocs-module-addons-contrib/src/plugin.ts +++ b/plugins/techdocs-module-addons-contrib/src/plugin.ts @@ -22,7 +22,7 @@ import { import { ExpandableNavigationAddon } from './ExpandableNavigation'; import { ReportIssueAddon, ReportIssueProps } from './ReportIssue'; import { TextSizeAddon } from './TextSize'; -import { LightBoxAddon } from './LigthBox'; +import { LightBoxAddon } from './LightBox'; /** * The TechDocs addons contrib plugin diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json index d7676ac66a..39b648c64d 100644 --- a/plugins/techdocs-react/package.json +++ b/plugins/techdocs-react/package.json @@ -14,9 +14,7 @@ ] }, "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" }, "keywords": [ "backstage", @@ -30,8 +28,23 @@ }, "license": "Apache-2.0", "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, "main": "src/index.ts", "types": "src/index.ts", + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "files": [ "dist" ], @@ -49,6 +62,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/styles": "^4.11.0", diff --git a/plugins/techdocs-react/report-alpha.api.md b/plugins/techdocs-react/report-alpha.api.md new file mode 100644 index 0000000000..d916d1fcb0 --- /dev/null +++ b/plugins/techdocs-react/report-alpha.api.md @@ -0,0 +1,63 @@ +## API Report File for "@backstage/plugin-techdocs-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ComponentType } from 'react'; +import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; + +// @alpha +export const AddonBlueprint: ExtensionBlueprint<{ + kind: 'addon'; + name: undefined; + params: TechDocsAddonOptions; + output: ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + addon: ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >; + }; +}>; + +// @alpha (undocumented) +export const attachTechDocsAddonComponentData:

( + techDocsAddon: ComponentType

, + data: TechDocsAddonOptions, +) => void; + +// @alpha (undocumented) +export const techDocsAddonDataRef: ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} +>; + +// @public +export const TechDocsAddonLocations: Readonly<{ + readonly Header: 'Header'; + readonly Subheader: 'Subheader'; + readonly Settings: 'Settings'; + readonly PrimarySidebar: 'PrimarySidebar'; + readonly SecondarySidebar: 'SecondarySidebar'; + readonly Content: 'Content'; +}>; + +// @public +export type TechDocsAddonOptions = { + name: string; + location: keyof typeof TechDocsAddonLocations; + component: ComponentType; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/techdocs-react/src/addons.tsx b/plugins/techdocs-react/src/addons.tsx index 54aea0352d..a012ce9d36 100644 --- a/plugins/techdocs-react/src/addons.tsx +++ b/plugins/techdocs-react/src/addons.tsx @@ -49,7 +49,7 @@ export const TechDocsAddons: React.ComponentType< attachComponentData(TechDocsAddons, TECHDOCS_ADDONS_WRAPPER_KEY, true); -const getDataKeyByName = (name: string) => { +export const getDataKeyByName = (name: string) => { return `${TECHDOCS_ADDONS_KEY}.${name.toLocaleLowerCase('en-US')}`; }; diff --git a/plugins/techdocs-react/src/alpha.ts b/plugins/techdocs-react/src/alpha.ts new file mode 100644 index 0000000000..689ec41977 --- /dev/null +++ b/plugins/techdocs-react/src/alpha.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TechDocsAddonOptions } from './types'; +import { attachComponentData } from '@backstage/core-plugin-api'; +import { ComponentType } from 'react'; +import { getDataKeyByName, TECHDOCS_ADDONS_KEY } from './addons'; +import { + createExtensionBlueprint, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; + +/** @alpha */ +export type { TechDocsAddonOptions, TechDocsAddonLocations } from './types'; + +/** @alpha */ +export const techDocsAddonDataRef = + createExtensionDataRef().with({ + id: 'techdocs.addon', + }); + +/** + * Creates an extension to add addons to the TechDocs standalone reader and entity pages. + * @alpha + */ +export const AddonBlueprint = createExtensionBlueprint({ + kind: 'addon', + attachTo: [ + { id: 'page:techdocs/reader', input: 'addons' }, + { id: 'entity-content:techdocs', input: 'addons' }, + ], + output: [techDocsAddonDataRef], + factory: (params: TechDocsAddonOptions) => [techDocsAddonDataRef(params)], + dataRefs: { + addon: techDocsAddonDataRef, + }, +}); + +/** @alpha */ +export const attachTechDocsAddonComponentData =

( + techDocsAddon: ComponentType

, + data: TechDocsAddonOptions, +) => { + attachComponentData(techDocsAddon, TECHDOCS_ADDONS_KEY, data); + attachComponentData(techDocsAddon, getDataKeyByName(data.name), true); +}; diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 8c2fc08f66..d7a0a3a7d4 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -17,6 +17,7 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; import { SearchResultItemExtensionPredicate } from '@backstage/plugin-search-react/alpha'; import { SearchResultListItemBlueprintParams } from '@backstage/plugin-search-react/alpha'; +import { TechDocsAddonOptions } from '@backstage/plugin-techdocs-react'; // @alpha (undocumented) const _default: FrontendPlugin< @@ -151,8 +152,6 @@ const _default: FrontendPlugin< params: SearchResultListItemBlueprintParams; }>; 'page:techdocs/reader': ExtensionDefinition<{ - kind: 'page'; - name: 'reader'; config: { path: string | undefined; }; @@ -173,7 +172,21 @@ const _default: FrontendPlugin< optional: true; } >; - inputs: {}; + inputs: { + addons: ExtensionInput< + ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'page'; + name: 'reader'; params: { defaultPath: string; loader: () => Promise; @@ -234,6 +247,17 @@ const _default: FrontendPlugin< } >; inputs: { + addons: ExtensionInput< + ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >, + { + singleton: false; + optional: false; + } + >; emptyState: ExtensionInput< ConfigurableExtensionDataRef< React_2.JSX.Element, diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 6a0bc0e08e..635b3886b6 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -56,6 +56,26 @@ export const Router = () => { ); }; +export const TechDocsReaderRouter = (props: PropsWithChildren) => { + const { children } = props; + + // Using objects instead of elements, otherwise "outlet" will be null on sub-pages and add-ons won't render + const element = useRoutes([ + { + path: '*', + element: , + children: [ + { + path: '*', + element: children, + }, + ], + }, + ]); + + return element; +}; + export const EmbeddedDocsRouter = ( props: PropsWithChildren<{ emptyState?: React.ReactElement; @@ -90,7 +110,6 @@ export const EmbeddedDocsRouter = ( ) ); } - return element; }; diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index a9ac353753..9ec6b733c5 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -38,16 +38,20 @@ import { } from '@backstage/core-compat-api'; import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; -import { - techdocsApiRef, - techdocsStorageApiRef, -} from '@backstage/plugin-techdocs-react'; +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; import { TechDocsClient, TechDocsStorageClient } from './client'; import { rootCatalogDocsRouteRef, rootDocsRouteRef, rootRouteRef, } from './routes'; +import { TechDocsReaderLayout } from './reader'; +import { attachTechDocsAddonComponentData } from '@backstage/plugin-techdocs-react/alpha'; +import { + TechDocsAddons, + techdocsApiRef, + techdocsStorageApiRef, +} from '@backstage/plugin-techdocs-react'; /** @alpha */ const techDocsStorageApi = ApiBlueprint.make({ @@ -138,15 +142,32 @@ const techDocsPage = PageBlueprint.make({ * * @alpha */ -const techDocsReaderPage = PageBlueprint.make({ +const techDocsReaderPage = PageBlueprint.makeWithOverrides({ name: 'reader', - params: { - defaultPath: '/docs/:namespace/:kind/:name', - routeRef: convertLegacyRouteRef(rootDocsRouteRef), - loader: () => - import('./reader/components/TechDocsReaderPage').then(m => - compatWrapper(), - ), + inputs: { + addons: createExtensionInput([AddonBlueprint.dataRefs.addon]), + }, + factory(originalFactory, { inputs }) { + const addons = inputs.addons.map(output => { + const options = output.get(AddonBlueprint.dataRefs.addon); + const Addon = options.component; + attachTechDocsAddonComponentData(Addon, options); + return ; + }); + + return originalFactory({ + defaultPath: '/docs/:namespace/:kind/:name', + routeRef: convertLegacyRouteRef(rootDocsRouteRef), + loader: async () => + await import('./Router').then(({ TechDocsReaderRouter }) => { + return compatWrapper( + + + {addons} + , + ); + }), + }); }, }); @@ -157,6 +178,7 @@ const techDocsReaderPage = PageBlueprint.make({ */ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({ inputs: { + addons: createExtensionInput([AddonBlueprint.dataRefs.addon]), emptyState: createExtensionInput( [coreExtensionData.reactElement.optional()], { @@ -172,15 +194,23 @@ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({ defaultTitle: 'TechDocs', routeRef: convertLegacyRouteRef(rootCatalogDocsRouteRef), loader: () => - import('./Router').then(({ EmbeddedDocsRouter }) => - compatWrapper( + import('./Router').then(({ EmbeddedDocsRouter }) => { + const addons = context.inputs.addons.map(output => { + const options = output.get(AddonBlueprint.dataRefs.addon); + const Addon = options.component; + attachTechDocsAddonComponentData(Addon, options); + return ; + }); + return compatWrapper( , - ), - ), + > + {addons} + , + ); + }), }, context, ); diff --git a/yarn.lock b/yarn.lock index e125f88eee..6df31ca425 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8113,6 +8113,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/integration-react": "workspace:^" "@backstage/plugin-techdocs-addons-test-utils": "workspace:^" @@ -8195,6 +8196,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/version-bridge": "workspace:^"