From 90a7340efd87f1174018093dc671f07bff02e733 Mon Sep 17 00:00:00 2001 From: Ooi Yee Fei Date: Sat, 1 Jun 2024 15:09:02 +0800 Subject: [PATCH 01/41] fix: Prefer accountId for AwsOrganizationCloudAccountProcessor authentication Signed-off-by: Ooi Yee Fei --- .changeset/swift-kings-sparkle.md | 5 +++ .../catalog-backend-module-aws/config.d.ts | 6 +++ .../src/awsOrganization/config.test.ts | 2 + .../src/awsOrganization/config.ts | 7 +++ .../AwsOrganizationCloudAccountProcessor.ts | 44 ++++++++++++------- 5 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 .changeset/swift-kings-sparkle.md diff --git a/.changeset/swift-kings-sparkle.md b/.changeset/swift-kings-sparkle.md new file mode 100644 index 0000000000..18a37b82f1 --- /dev/null +++ b/.changeset/swift-kings-sparkle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': patch +--- + +AwsOrganizationCloudAccountProcessor configuration field roleArn is deprecated in favor of new field accountId diff --git a/plugins/catalog-backend-module-aws/config.d.ts b/plugins/catalog-backend-module-aws/config.d.ts index 4c9c98512e..0a9d9c97db 100644 --- a/plugins/catalog-backend-module-aws/config.d.ts +++ b/plugins/catalog-backend-module-aws/config.d.ts @@ -26,8 +26,14 @@ export interface Config { provider: { /** * The role to be assumed by this processor + * @deprecated Use `accountId` instead. */ roleArn?: string; + + /** + * The AWS account ID to query for organizational data + */ + accountId?: string; }; }; }; diff --git a/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts b/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts index 7e0dc58559..a84c65abda 100644 --- a/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts +++ b/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts @@ -22,11 +22,13 @@ describe('readAwsOrganizationConfig', () => { const config = { provider: { roleArn: 'aws::arn::foo', + accountId: '111111111111', }, }; const actual = readAwsOrganizationConfig(new ConfigReader(config)); const expected = { roleArn: 'aws::arn::foo', + accountId: '111111111111', }; expect(actual).toEqual(expected); }); diff --git a/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts b/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts index e6096b932b..ff00af48e4 100644 --- a/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts +++ b/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts @@ -24,6 +24,11 @@ export type AwsOrganizationProviderConfig = { * The role to assume for the processor. */ roleArn?: string; + + /** + * The AWS accountId + */ + accountId?: string; }; export function readAwsOrganizationConfig( @@ -32,7 +37,9 @@ export function readAwsOrganizationConfig( const providerConfig = config.getOptionalConfig('provider'); const roleArn = providerConfig?.getOptionalString('roleArn'); + const accountId = providerConfig?.getOptionalString('accountId'); return { roleArn, + accountId, }; } diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts index 4a454fce6f..b843503a28 100644 --- a/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts +++ b/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts @@ -59,10 +59,18 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { static async fromConfig(config: Config, options: { logger: LoggerService }) { const c = config.getOptionalConfig('catalog.processors.awsOrganization'); const orgConfig = c ? readAwsOrganizationConfig(c) : undefined; + + if (orgConfig?.roleArn) { + options.logger.warn( + 'The roleArn configuration for AwsOrganizationCloudAccountProcessor ignores the role name, use accountId configuration instead', + ); + } + const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig(config); const credProvider = await awsCredentialsManager.getCredentialProvider({ arn: orgConfig?.roleArn, + accountId: orgConfig?.accountId, }); return new AwsOrganizationCloudAccountProcessor( credProvider, @@ -98,23 +106,27 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { this.logger?.info('Discovering AWS Organization Account objects'); - (await this.getAwsAccounts()) - .map(account => this.mapAccountToComponent(account)) - .filter(entity => { - if (location.target !== '') { - if (entity.metadata.annotations) { - return ( - entity.metadata.annotations[ORGANIZATION_ANNOTATION] === - location.target - ); + try { + (await this.getAwsAccounts()) + .map(account => this.mapAccountToComponent(account)) + .filter(entity => { + if (location.target !== '') { + if (entity.metadata.annotations) { + return ( + entity.metadata.annotations[ORGANIZATION_ANNOTATION] === + location.target + ); + } + return false; } - return false; - } - return true; - }) - .forEach(entity => { - emit(processingResult.entity(location, entity)); - }); + return true; + }) + .forEach(entity => { + emit(processingResult.entity(location, entity)); + }); + } catch (e) { + this.logger?.error(e); + } return true; } From a0fd89c3ef77677404cfb46e973000ef4061d2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20St=C3=B6rkle?= Date: Thu, 15 Feb 2024 14:54:48 +0100 Subject: [PATCH 02/41] fix: use correct odata type in TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Florian Störkle --- .../src/microsoftGraph/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts index 1233b1e2f9..c190e2a959 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts @@ -64,8 +64,8 @@ export type ODataQuery = { * @public */ export type GroupMember = - | (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.user' }) - | (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.group' }); + | (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.group' }) + | (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.user' }); /** * A HTTP Client that communicates with Microsoft Graph API. From da7651605a6c78e8e4a89ab89fc2d0e30291d06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20St=C3=B6rkle?= Date: Fri, 16 Feb 2024 13:15:50 +0100 Subject: [PATCH 03/41] feat(catalog): ingest member groups if configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Florian Störkle --- .../config.d.ts | 5 + .../src/microsoftGraph/config.test.ts | 2 + .../src/microsoftGraph/config.ts | 10 ++ .../src/microsoftGraph/read.test.ts | 114 ++++++++++++++++++ .../src/microsoftGraph/read.ts | 28 +++++ .../MicrosoftGraphOrgEntityProvider.ts | 1 + 6 files changed, 160 insertions(+) diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts index 5a1f56a03f..a470c075f0 100644 --- a/plugins/catalog-backend-module-msgraph/config.d.ts +++ b/plugins/catalog-backend-module-msgraph/config.d.ts @@ -192,6 +192,11 @@ export interface Config { * E.g. ["id", "displayName", "description"] */ select?: string[]; + /** + * Whether to ingest groups that are members of the found/filtered/searched groups. + * Default value is `false`. + */ + includeSubGroups?: boolean; }; userGroupMember?: { diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts index b02894dfd9..e1c81662a3 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts @@ -175,6 +175,7 @@ describe('readProviderConfigs', () => { expand: 'member', filter: 'securityEnabled eq false', select: ['id', 'displayName', 'description'], + includeSubGroups: true, }, schedule: { frequency: 'PT30M', @@ -203,6 +204,7 @@ describe('readProviderConfigs', () => { groupExpand: 'member', groupSelect: ['id', 'displayName', 'description'], groupFilter: 'securityEnabled eq false', + groupIncludeSubGroups: true, schedule: { frequency: Duration.fromISO('PT30M'), timeout: { diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts index 1545114e45..257a7d5736 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts @@ -116,6 +116,12 @@ export type MicrosoftGraphProviderConfig = { */ groupSelect?: string[]; + /** + * Whether to ingest groups that are members of the found/filtered/searched groups. + * Default value is `false`. + */ + groupIncludeSubGroups?: boolean; + /** * By default, the Microsoft Graph API only provides the basic feature set * for querying. Certain features are limited to advanced query capabilities @@ -292,6 +298,9 @@ export function readProviderConfig( const groupFilter = config.getOptionalString('group.filter'); const groupSearch = config.getOptionalString('group.search'); const groupSelect = config.getOptionalStringArray('group.select'); + const groupIncludeSubGroups = config.getOptionalBoolean( + 'group.includeSubGroups', + ); const queryMode = config.getOptionalString('queryMode'); if ( @@ -347,6 +356,7 @@ export function readProviderConfig( groupFilter, groupSearch, groupSelect, + groupIncludeSubGroups, queryMode, userGroupMemberFilter, userGroupMemberSearch, diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts index 8821ffd2db..b83597653f 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts @@ -90,6 +90,9 @@ describe('read microsoft graph', () => { yield { '@odata.type': '#microsoft.graph.group', id: 'childgroupid', + displayName: 'Child Group Name', + description: 'Child Group Description', + mail: 'childgroup@example.com', }; yield { '@odata.type': '#microsoft.graph.user', @@ -770,6 +773,117 @@ describe('read microsoft graph', () => { top: 999, }); }); + + it('should read groups and their sub groups', async () => { + async function* getExampleGroupMembersForSubGroup(): AsyncIterable { + yield { + '@odata.type': '#microsoft.graph.user', + id: 'userid2', + }; + } + + client.getGroups.mockImplementation(getExampleGroups); + client.getGroupMembers.mockImplementationOnce(getExampleGroupMembers); + client.getGroupMembers.mockImplementationOnce( + getExampleGroupMembersForSubGroup, + ); + client.getOrganization.mockResolvedValue({ + id: 'tenantid', + displayName: 'Organization Name', + }); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + const { groups, groupMember, groupMemberOf, rootGroup } = + await readMicrosoftGraphGroups(client, 'tenantid', { + groupIncludeSubGroups: true, + }); + + const expectedRootGroup = group({ + metadata: { + annotations: { + 'graph.microsoft.com/tenant-id': 'tenantid', + }, + name: 'organization_name', + description: 'Organization Name', + }, + spec: { + type: 'root', + profile: { + displayName: 'Organization Name', + }, + children: [], + }, + }); + expect(groups).toEqual([ + expectedRootGroup, + group({ + metadata: { + annotations: { + 'graph.microsoft.com/group-id': 'childgroupid', + }, + name: 'child_group_name', + description: 'Child Group Description', + }, + spec: { + type: 'team', + profile: { + displayName: 'Child Group Name', + email: 'childgroup@example.com', + // TODO: Loading groups photos doesn't work right now as Microsoft + // Graph doesn't allows this yet + /* picture: 'data:image/jpeg;base64,...',*/ + }, + children: [], + }, + }), + group({ + metadata: { + annotations: { + 'graph.microsoft.com/group-id': 'groupid', + }, + name: 'group_name', + description: 'Group Description', + }, + spec: { + type: 'team', + profile: { + displayName: 'Group Name', + email: 'group@example.com', + // TODO: Loading groups photos doesn't work right now as Microsoft + // Graph doesn't allows this yet + /* picture: 'data:image/jpeg;base64,...',*/ + }, + children: [], + }, + }), + ]); + expect(rootGroup).toEqual(expectedRootGroup); + expect(groupMember.get('groupid')).toEqual(new Set(['childgroupid'])); + expect(groupMemberOf.get('userid')).toEqual(new Set(['groupid'])); + expect(groupMemberOf.get('userid2')).toEqual(new Set(['childgroupid'])); + expect(groupMember.get('organization_name')).toEqual(new Set()); + + expect(client.getGroups).toHaveBeenCalledTimes(1); + expect(client.getGroups).toHaveBeenCalledWith( + { + top: 999, + }, + undefined, + ); + expect(client.getGroupMembers).toHaveBeenCalledTimes(2); + expect(client.getGroupMembers).toHaveBeenCalledWith('groupid', { + top: 999, + }); + expect(client.getGroupMembers).toHaveBeenCalledWith('childgroupid', { + top: 999, + }); + // TODO: Loading groups photos doesn't work right now as Microsoft Graph + // doesn't allows this yet + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1); + // expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120); + }); }); describe('resolveRelations', () => { diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts index 2ca21fca9c..60e5c3d79b 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts @@ -178,6 +178,7 @@ export async function readMicrosoftGraphGroups( groupFilter?: string; groupSearch?: string; groupSelect?: string[]; + groupIncludeSubGroups?: boolean; groupTransformer?: GroupTransformer; organizationTransformer?: OrganizationTransformer; }, @@ -244,6 +245,31 @@ export async function readMicrosoftGraphGroups( if (member['@odata.type'] === '#microsoft.graph.group') { ensureItem(groupMember, group.id!, member.id); + + if (options?.groupIncludeSubGroups) { + const groupMemberEntity = await transformer(member); + + if (groupMemberEntity) { + groups.push(groupMemberEntity); + + for await (const subMember of client.getGroupMembers( + member.id!, + { top: PAGE_SIZE }, + )) { + if (!subMember.id) { + continue; + } + + if (subMember['@odata.type'] === '#microsoft.graph.user') { + ensureItem(groupMemberOf, subMember.id, member.id!); + } + + if (subMember['@odata.type'] === '#microsoft.graph.group') { + ensureItem(groupMember, member.id!, subMember.id); + } + } + } + } } } @@ -377,6 +403,7 @@ export async function readMicrosoftGraphOrg( groupSearch?: string; groupFilter?: string; groupSelect?: string[]; + groupIncludeSubGroups?: boolean; queryMode?: 'basic' | 'advanced'; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; @@ -421,6 +448,7 @@ export async function readMicrosoftGraphOrg( groupFilter: options.groupFilter, groupSearch: options.groupSearch, groupSelect: options.groupSelect, + groupIncludeSubGroups: options.groupIncludeSubGroups, groupTransformer: options.groupTransformer, organizationTransformer: options.organizationTransformer, }); diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts index 3c8b903e69..c48ee18ffe 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts @@ -317,6 +317,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider { groupFilter: provider.groupFilter, groupSearch: provider.groupSearch, groupSelect: provider.groupSelect, + groupIncludeSubGroups: provider.groupIncludeSubGroups, queryMode: provider.queryMode, groupTransformer: this.options.groupTransformer, userTransformer: this.options.userTransformer, From 24b3a3141b2eec8f89e4f76aa3cfc7328d28ea6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20St=C3=B6rkle?= Date: Mon, 19 Feb 2024 08:35:18 +0100 Subject: [PATCH 04/41] docs: document `includeSubGroups` config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Florian Störkle --- docs/integrations/azure/org.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/integrations/azure/org.md b/docs/integrations/azure/org.md index 89abd4813b..b1719a63da 100644 --- a/docs/integrations/azure/org.md +++ b/docs/integrations/azure/org.md @@ -112,6 +112,17 @@ microsoftGraphOrg: search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' ``` +If you don't want to only ingest groups matching the `search` and/or `filter` query, but also the groups which are members of the matched groups, you can use the `includeSubGroups` configuration: + +```yaml +microsoftGraphOrg: + providerId: + group: + filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') + search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' + includeSubGroups: true +``` + In addition to these groups, one additional group will be created for your organization. All imported groups will be a child of this group. From d9cf697afda7543d72610e59f656ca05e2c9c3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20St=C3=B6rkle?= Date: Mon, 19 Feb 2024 08:40:38 +0100 Subject: [PATCH 05/41] chore: generate API report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Florian Störkle --- plugins/catalog-backend-module-msgraph/api-report.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index 86170e2910..87402eedb1 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -39,10 +39,10 @@ export function defaultUserTransformer( // @public export type GroupMember = | (MicrosoftGraph.Group & { - '@odata.type': '#microsoft.graph.user'; + '@odata.type': '#microsoft.graph.group'; }) | (MicrosoftGraph.User & { - '@odata.type': '#microsoft.graph.group'; + '@odata.type': '#microsoft.graph.user'; }); // @public @@ -210,6 +210,7 @@ export type MicrosoftGraphProviderConfig = { groupFilter?: string; groupSearch?: string; groupSelect?: string[]; + groupIncludeSubGroups?: boolean; queryMode?: 'basic' | 'advanced'; loadUserPhotos?: boolean; schedule?: TaskScheduleDefinition; @@ -253,6 +254,7 @@ export function readMicrosoftGraphOrg( groupSearch?: string; groupFilter?: string; groupSelect?: string[]; + groupIncludeSubGroups?: boolean; queryMode?: 'basic' | 'advanced'; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; From 58dff4d5e574428090ec09a4fbc7de3e08224c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20St=C3=B6rkle?= Date: Tue, 16 Apr 2024 10:01:13 +0200 Subject: [PATCH 06/41] chore: add changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Florian Störkle --- .changeset/little-suns-fly.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/little-suns-fly.md diff --git a/.changeset/little-suns-fly.md b/.changeset/little-suns-fly.md new file mode 100644 index 0000000000..887df4be6d --- /dev/null +++ b/.changeset/little-suns-fly.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': patch +--- + +Added option to ingest groups based on their group membership in Azure Entra ID From 2b8b4f115a1f3052dc5db9ff14c33b8d0afd8d20 Mon Sep 17 00:00:00 2001 From: Niall Thomson Date: Tue, 11 Jun 2024 22:19:44 -0400 Subject: [PATCH 07/41] Removed code that catches error and formatted changeset markdown Signed-off-by: Niall Thomson --- .changeset/swift-kings-sparkle.md | 2 +- .../AwsOrganizationCloudAccountProcessor.ts | 36 +++++++++---------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/.changeset/swift-kings-sparkle.md b/.changeset/swift-kings-sparkle.md index 18a37b82f1..84ceae962b 100644 --- a/.changeset/swift-kings-sparkle.md +++ b/.changeset/swift-kings-sparkle.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend-module-aws': patch --- -AwsOrganizationCloudAccountProcessor configuration field roleArn is deprecated in favor of new field accountId +`AwsOrganizationCloudAccountProcessor` configuration field `roleArn` is deprecated in favor of new field `accountId` diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts index b843503a28..73d7a63e2b 100644 --- a/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts +++ b/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts @@ -106,27 +106,23 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { this.logger?.info('Discovering AWS Organization Account objects'); - try { - (await this.getAwsAccounts()) - .map(account => this.mapAccountToComponent(account)) - .filter(entity => { - if (location.target !== '') { - if (entity.metadata.annotations) { - return ( - entity.metadata.annotations[ORGANIZATION_ANNOTATION] === - location.target - ); - } - return false; + (await this.getAwsAccounts()) + .map(account => this.mapAccountToComponent(account)) + .filter(entity => { + if (location.target !== '') { + if (entity.metadata.annotations) { + return ( + entity.metadata.annotations[ORGANIZATION_ANNOTATION] === + location.target + ); } - return true; - }) - .forEach(entity => { - emit(processingResult.entity(location, entity)); - }); - } catch (e) { - this.logger?.error(e); - } + return false; + } + return true; + }) + .forEach(entity => { + emit(processingResult.entity(location, entity)); + }); return true; } From d42b410050645dcac54a3bbefa6f20e219b7a807 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 18 Jun 2024 11:53:20 +0200 Subject: [PATCH 08/41] chore: initial pass at implementation from mob with simple test Signed-off-by: blam --- .../src/wiring/createExtensionKind.test.tsx | 44 +++++ .../src/wiring/createExtensionKind.ts | 164 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx create mode 100644 packages/frontend-plugin-api/src/wiring/createExtensionKind.ts diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx new file mode 100644 index 0000000000..d841ea2c62 --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 React from 'react'; +import { coreExtensionData } from './coreExtensionData'; +import { createExtensionKind } from './createExtensionKind'; + +describe('createExtensionKind', () => { + it('should allow creation of extension kinds', () => { + const TestExtension = createExtensionKind({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, props: { text: string }) { + return { + element:

{props.text}

, + }; + }, + }); + + const extension = TestExtension.new({ + name: 'my-extension', + props: { + text: 'Hello, world!', + }, + }); + + expect(extension).toBeDefined(); + }); +}); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts new file mode 100644 index 0000000000..31a8e5da64 --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts @@ -0,0 +1,164 @@ +import { AppNode } from '../apis'; +import { PortableSchema } from '../schema'; +import { Expand } from '../types'; +import { + AnyExtensionDataMap, + AnyExtensionInputMap, + ExtensionDataValues, + ResolvedExtensionInputs, + createExtension, +} from './createExtension'; + +/* + * 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. + */ +interface ExtensionKindOptions< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + kind: string; + namespace?: string; + name?: string; + attachTo: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + output: TOutput; + configSchema?: PortableSchema; + factory( + options: { + node: AppNode; + config: TConfig; + inputs: Expand>; + }, + props: TProps, + ): Expand>; +} + +class ExtensionKind< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + static create< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, + >( + options: ExtensionKindOptions, + ): ExtensionKind { + return new ExtensionKind(options); + } + + private constructor( + private readonly options: ExtensionKindOptions< + TProps, + TInputs, + TOutput, + TConfig + >, + ) {} + + public new(options: { + namespace?: string; + name?: string; + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + props: TProps; + factory?( + options: { + node: AppNode; + config: TConfig; + inputs: Expand>; + orignalFactory( + options?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + props?: TProps, + ): Expand>; + }, + props: TProps, + ): Expand>; + }) { + return createExtension({ + kind: this.options.kind, + namespace: options.namespace ?? this.options.namespace, + name: options.name ?? this.options.name, + attachTo: options.attachTo ?? this.options.attachTo, + disabled: options.disabled ?? this.options.disabled, + inputs: options.inputs ?? this.options.inputs, + output: options.output ?? this.options.output, + configSchema: options.configSchema ?? this.options.configSchema, // TODO: some config merging or smth + factory: ({ node, config, inputs }) => { + if (options.factory) { + return options.factory( + { + node, + config, + inputs, + orignalFactory: ( + innerOptions?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + innerProps?: TProps, + ) => + this.options.factory( + { + node: innerOptions?.node ?? node, + config: innerOptions?.config ?? config, + inputs: innerOptions?.inputs ?? inputs, + }, + innerProps ?? options.props, + ), + }, + options.props, + ); + } + + return this.options.factory( + { + node, + config, + inputs, + }, + options.props, + ); + }, + }); + } +} + +/** + * A simpler replacement for wrapping up `createExtension` inside a kind or type. This allows for a cleaner API for creating + * types and instances of those types. + */ +export function createExtensionKind< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +>(options: ExtensionKindOptions) { + return ExtensionKind.create(options); +} From d6c9f12ab3bc54f077e1d2baac8396c61c1c9cbf Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 18 Jun 2024 13:27:31 +0200 Subject: [PATCH 09/41] chore: add some simple tests Signed-off-by: blam --- .../src/wiring/createExtensionKind.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx index d841ea2c62..ea9459f3f6 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { coreExtensionData } from './coreExtensionData'; import { createExtensionKind } from './createExtensionKind'; +import { createExtensionTester } from '@backstage/frontend-test-utils'; describe('createExtensionKind', () => { it('should allow creation of extension kinds', () => { @@ -40,5 +41,40 @@ describe('createExtensionKind', () => { }); expect(extension).toBeDefined(); + + const { container } = createExtensionTester(extension).render(); + expect(container.querySelector('h1')).toHaveTextContent('Hello, world!'); + }); + + it('should allow overriding of the default factory', () => { + const TestExtension = createExtensionKind({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, props: { text: string }) { + return { + element:

{props.text}

, + }; + }, + }); + + const extension = TestExtension.new({ + name: 'my-extension', + props: { + text: 'Hello, world!', + }, + factory(_, props: { text: string }) { + return { + element:

{props.text}

, + }; + }, + }); + + expect(extension).toBeDefined(); + + const { container } = createExtensionTester(extension).render(); + expect(container.querySelector('h2')).toHaveTextContent('Hello, world!'); }); }); From ad4591af6615ac1e6281e424ea3b7ec90e9ca4f9 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 18 Jun 2024 13:29:57 +0200 Subject: [PATCH 10/41] chore: test creted extension Signed-off-by: blam --- .../src/wiring/createExtensionKind.test.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx index ea9459f3f6..2d5b218082 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx @@ -40,7 +40,31 @@ describe('createExtensionKind', () => { }, }); - expect(extension).toBeDefined(); + expect(extension).toEqual({ + $$type: '@backstage/ExtensionDefinition', + attachTo: { + id: 'test', + input: 'default', + }, + configSchema: undefined, + disabled: false, + inputs: {}, + kind: 'test-extension', + name: 'my-extension', + namespace: undefined, + output: { + element: { + $$type: '@backstage/ExtensionDataRef', + config: {}, + id: 'core.reactElement', + optional: expect.any(Function), + toString: expect.any(Function), + }, + }, + factory: expect.any(Function), + toString: expect.any(Function), + version: 'v1', + }); const { container } = createExtensionTester(extension).render(); expect(container.querySelector('h1')).toHaveTextContent('Hello, world!'); From 71172a4caf3044b5379bd956c64d3535e1bc6528 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 18 Jun 2024 14:13:34 +0200 Subject: [PATCH 11/41] chore: added api-reports Signed-off-by: blam --- packages/frontend-plugin-api/api-report.md | 95 +++++++++++++++++++ .../src/wiring/createExtensionKind.ts | 13 ++- .../frontend-plugin-api/src/wiring/index.ts | 5 + 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 3e19573d95..b4c761ec05 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -522,6 +522,16 @@ export function createExtensionInput< } >; +// @public +export function createExtensionKind< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +>( + options: ExtensionKindOptions, +): ExtensionKind; + // @public (undocumented) export interface CreateExtensionOptions< TOutput extends AnyExtensionDataMap, @@ -913,6 +923,91 @@ export interface ExtensionInput< extensionData: TExtensionData; } +// @public (undocumented) +export class ExtensionKind< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + static create< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, + >( + options: ExtensionKindOptions, + ): ExtensionKind; + // (undocumented) + new(options: { + namespace?: string; + name?: string; + attachTo?: { + id: string; + input: string; + }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + props: TProps; + factory?( + options: { + node: AppNode; + config: TConfig; + inputs: Expand>; + orignalFactory( + options?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + props?: TProps, + ): Expand>; + }, + props: TProps, + ): Expand>; + }): ExtensionDefinition; +} + +// @public (undocumented) +export interface ExtensionKindOptions< + TProps, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + attachTo: { + id: string; + input: string; + }; + // (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled?: boolean; + // (undocumented) + factory( + options: { + node: AppNode; + config: TConfig; + inputs: Expand>; + }, + props: TProps, + ): Expand>; + // (undocumented) + inputs?: TInputs; + // (undocumented) + kind: string; + // (undocumented) + name?: string; + // (undocumented) + namespace?: string; + // (undocumented) + output: TOutput; +} + // @public (undocumented) export interface ExtensionOverrides { // (undocumented) diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts index 31a8e5da64..2067d18c04 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts @@ -24,7 +24,11 @@ import { * See the License for the specific language governing permissions and * limitations under the License. */ -interface ExtensionKindOptions< + +/** + * @public + */ +export interface ExtensionKindOptions< TProps, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, @@ -48,7 +52,10 @@ interface ExtensionKindOptions< ): Expand>; } -class ExtensionKind< +/** + * @public + */ +export class ExtensionKind< TProps, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, @@ -153,6 +160,8 @@ class ExtensionKind< /** * A simpler replacement for wrapping up `createExtension` inside a kind or type. This allows for a cleaner API for creating * types and instances of those types. + * + * @public */ export function createExtensionKind< TProps, diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 61f821c7ce..e8c9afbaae 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -48,3 +48,8 @@ export { type FeatureFlagConfig, type FrontendFeature, } from './types'; +export { + type ExtensionKindOptions, + ExtensionKind, + createExtensionKind, +} from './createExtensionKind'; From 4e53ad666d3c39d53ad4b81d7b6869a23dc1d5fa Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 18 Jun 2024 14:15:39 +0200 Subject: [PATCH 12/41] chore: add changeset Signed-off-by: blam --- .changeset/dry-squids-tap.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/dry-squids-tap.md diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md new file mode 100644 index 0000000000..a9eb7efb3b --- /dev/null +++ b/.changeset/dry-squids-tap.md @@ -0,0 +1,26 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Introduce a new way to create extension types and kinds, with `createExtensionKind`. + +This allows the creation of extension with the following pattern: + +```tsx +// create the extension kind +const TestExtension = createExtensionKind({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, props: { text: string }) { + return { + element:

{props.text}

, + }; + }, +}); + +// create an instance of the extension kind with props +const testExtension = TestExtension.create({ text: 'Hello World' }); +``` From 8839381d6a0980df52604bc6fd8cca5a44931afc Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Fri, 21 Jun 2024 00:41:13 -0400 Subject: [PATCH 13/41] Add scaffolder option to display object items in separate rows Signed-off-by: Stephen Glass --- .changeset/late-games-protect.md | 5 + .../ReviewState/ReviewState.test.tsx | 173 ++++++++++++++++++ .../components/ReviewState/ReviewState.tsx | 63 ++++++- 3 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 .changeset/late-games-protect.md diff --git a/.changeset/late-games-protect.md b/.changeset/late-games-protect.md new file mode 100644 index 0000000000..584780f836 --- /dev/null +++ b/.changeset/late-games-protect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': minor +--- + +Add scaffolder option to display object items in separate rows on review page diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx index e9277b2230..dfa1c9c097 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx @@ -202,4 +202,177 @@ describe('ReviewState', () => { expect(queryByRole('row', { name: 'Name type4' })).toBeInTheDocument(); }); + + it('should display exploded object in separate rows', async () => { + const formState = { + name: { + foo: 'type3', + bar: 'type4', + }, + }; + + const schemas: ParsedTemplateSchema[] = [ + { + mergedSchema: { + type: 'object', + properties: { + name: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: { + foo: { + type: 'string', + default: 'type1', + }, + bar: { + type: 'string', + default: 'type2', + }, + }, + }, + }, + }, + schema: {}, + title: 'test', + uiSchema: {}, + description: 'asd', + }, + ]; + + const { queryByRole } = render( + , + ); + + expect(queryByRole('row', { name: 'Foo type3' })).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument(); + }); + + it('should display exploded nested objects', async () => { + const formState = { + name: { + foo: 'type3', + bar: 'type4', + example: { + test: 'type6', + }, + }, + }; + + const schemas: ParsedTemplateSchema[] = [ + { + mergedSchema: { + type: 'object', + properties: { + name: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: { + foo: { + type: 'string', + default: 'type1', + }, + bar: { + type: 'string', + default: 'type2', + }, + example: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + schema: {}, + title: 'test', + uiSchema: {}, + description: 'asd', + }, + ]; + + const { queryByRole } = render( + , + ); + + expect(queryByRole('row', { name: 'Foo type3' })).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Test type6' })).toBeInTheDocument(); + }); + + it('should display partially exploded nested objects', async () => { + const formState = { + name: { + foo: 'type3', + bar: 'type4', + example: { + test: 'type6', + }, + }, + }; + + const schemas: ParsedTemplateSchema[] = [ + { + mergedSchema: { + type: 'object', + properties: { + name: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: { + foo: { + type: 'string', + default: 'type1', + }, + bar: { + type: 'string', + default: 'type2', + }, + example: { + type: 'object', + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + schema: {}, + title: 'test', + uiSchema: {}, + description: 'asd', + }, + ]; + + const { queryByRole } = render( + , + ); + + expect(queryByRole('row', { name: 'Foo type3' })).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Test type6' })).not.toBeInTheDocument(); + }); }); diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx index e3c7bc303e..ac7ac989e3 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; import { StructuredMetadataTable } from '@backstage/core-components'; -import { JsonObject } from '@backstage/types'; +import { JsonObject, JsonValue } from '@backstage/types'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { ParsedTemplateSchema } from '../../hooks/useTemplateSchema'; @@ -28,6 +28,39 @@ export type ReviewStateProps = { formState: JsonObject; }; +function flattenObject( + obj: JsonObject, + prefix: string, + schema: JSONSchema, + formState: JsonObject, +): [string, JsonValue | undefined][] { + return Object.entries(obj).flatMap(([key, value]) => { + const prefixedKey = prefix ? `${prefix}/${key}` : key; + + const definitionInSchema = schema.getSchema({ + pointer: `#/${prefixedKey}`, + data: formState, + }); + + if (definitionInSchema) { + const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; + + // Recurse into nested objects + if ( + backstageReviewOptions && + backstageReviewOptions.explode && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + return flattenObject(value, prefixedKey, schema, formState); + } + } + + return [[key, value]]; + }); +} + /** * The component used by the {@link Stepper} to render the review step. * @alpha @@ -35,7 +68,7 @@ export type ReviewStateProps = { export const ReviewState = (props: ReviewStateProps) => { const reviewData = Object.fromEntries( Object.entries(props.formState) - .map(([key, value]) => { + .flatMap(([key, value]) => { for (const step of props.schemas) { const parsedSchema = new JSONSchema(step.mergedSchema); const definitionInSchema = parsedSchema.getSchema({ @@ -49,30 +82,42 @@ export const ReviewState = (props: ReviewStateProps) => { if (backstageReviewOptions) { if (backstageReviewOptions.mask) { - return [key, backstageReviewOptions.mask]; + return [[key, backstageReviewOptions.mask]]; } if (backstageReviewOptions.show === false) { return []; } + if ( + backstageReviewOptions.explode && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + return flattenObject(value, key, parsedSchema, props.formState); + } } if (definitionInSchema['ui:widget'] === 'password') { - return [key, '******']; + return [[key, '******']]; } if (definitionInSchema.enum && definitionInSchema.enumNames) { return [ - key, - definitionInSchema.enumNames[ - definitionInSchema.enum.indexOf(value) - ] || value, + [ + key, + definitionInSchema.enumNames[ + definitionInSchema.enum.indexOf(value) + ] || value, + ], ]; } } } - return [key, value]; + + return [[key, value]]; }) .filter(prop => prop.length > 0), ); + return ; }; From 546700b0c1acd565c089c6b8a9a89228d3f97909 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jun 2024 14:15:45 +0200 Subject: [PATCH 14/41] chore: some PR feedback suggestions Signed-off-by: blam --- .changeset/dry-squids-tap.md | 9 +- packages/frontend-plugin-api/api-report.md | 36 +++---- .../src/wiring/createExtension.ts | 4 +- .../src/wiring/createExtensionKind.test.tsx | 17 ++-- .../src/wiring/createExtensionKind.ts | 93 ++++++++++--------- .../frontend-plugin-api/src/wiring/index.ts | 2 +- 6 files changed, 84 insertions(+), 77 deletions(-) diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md index a9eb7efb3b..5193e1f204 100644 --- a/.changeset/dry-squids-tap.md +++ b/.changeset/dry-squids-tap.md @@ -8,7 +8,7 @@ This allows the creation of extension with the following pattern: ```tsx // create the extension kind -const TestExtension = createExtensionKind({ +const TestExtensionKind = createExtensionKind({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, output: { @@ -22,5 +22,10 @@ const TestExtension = createExtensionKind({ }); // create an instance of the extension kind with props -const testExtension = TestExtension.create({ text: 'Hello World' }); +const testExtension = TestExtensionKind.new({ + name: 'foo', + options: { + text: 'Hello World', + }, +}); ``` diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index b4c761ec05..48718f5375 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -524,13 +524,13 @@ export function createExtensionInput< // @public export function createExtensionKind< - TProps, + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, >( - options: ExtensionKindOptions, -): ExtensionKind; + options: ExtensionKindOptions, +): ExtensionKind; // @public (undocumented) export interface CreateExtensionOptions< @@ -548,7 +548,7 @@ export interface CreateExtensionOptions< // (undocumented) disabled?: boolean; // (undocumented) - factory(options: { + factory(context: { node: AppNode; config: TConfig; inputs: Expand>; @@ -923,24 +923,24 @@ export interface ExtensionInput< extensionData: TExtensionData; } -// @public (undocumented) +// @public export class ExtensionKind< - TProps, + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, > { // (undocumented) static create< - TProps, + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, >( - options: ExtensionKindOptions, - ): ExtensionKind; + options: ExtensionKindOptions, + ): ExtensionKind; // (undocumented) - new(options: { + new(args: { namespace?: string; name?: string; attachTo?: { @@ -951,29 +951,29 @@ export class ExtensionKind< inputs?: TInputs; output?: TOutput; configSchema?: PortableSchema; - props: TProps; + options: TOptions; factory?( - options: { + context: { node: AppNode; config: TConfig; inputs: Expand>; orignalFactory( - options?: { + context?: { node?: AppNode; config?: TConfig; inputs?: Expand>; }, - props?: TProps, + options?: TOptions, ): Expand>; }, - props: TProps, + options: TOptions, ): Expand>; }): ExtensionDefinition; } // @public (undocumented) export interface ExtensionKindOptions< - TProps, + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, @@ -989,12 +989,12 @@ export interface ExtensionKindOptions< disabled?: boolean; // (undocumented) factory( - options: { + context: { node: AppNode; config: TConfig; inputs: Expand>; }, - props: TProps, + options: TOptions, ): Expand>; // (undocumented) inputs?: TInputs; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index be76fbc069..28dc6a4e29 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -91,7 +91,7 @@ export interface CreateExtensionOptions< inputs?: TInputs; output: TOutput; configSchema?: PortableSchema; - factory(options: { + factory(context: { node: AppNode; config: TConfig; inputs: Expand>; @@ -115,7 +115,7 @@ export interface InternalExtensionDefinition readonly version: 'v1'; readonly inputs: AnyExtensionInputMap; readonly output: AnyExtensionDataMap; - factory(options: { + factory(context: { node: AppNode; config: TConfig; inputs: ResolvedExtensionInputs; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx index 2d5b218082..3bdcd3a7cc 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { coreExtensionData } from './coreExtensionData'; import { createExtensionKind } from './createExtensionKind'; @@ -26,16 +27,16 @@ describe('createExtensionKind', () => { output: { element: coreExtensionData.reactElement, }, - factory(_, props: { text: string }) { + factory(_, options: { text: string }) { return { - element:

{props.text}

, + element:

{options.text}

, }; }, }); const extension = TestExtension.new({ name: 'my-extension', - props: { + options: { text: 'Hello, world!', }, }); @@ -77,21 +78,21 @@ describe('createExtensionKind', () => { output: { element: coreExtensionData.reactElement, }, - factory(_, props: { text: string }) { + factory(_, options: { text: string }) { return { - element:

{props.text}

, + element:

{options.text}

, }; }, }); const extension = TestExtension.new({ name: 'my-extension', - props: { + options: { text: 'Hello, world!', }, - factory(_, props: { text: string }) { + factory(_, options: { text: string }) { return { - element:

{props.text}

, + element:

{options.text}

, }; }, }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts index 2067d18c04..52bf711822 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts @@ -1,14 +1,3 @@ -import { AppNode } from '../apis'; -import { PortableSchema } from '../schema'; -import { Expand } from '../types'; -import { - AnyExtensionDataMap, - AnyExtensionInputMap, - ExtensionDataValues, - ResolvedExtensionInputs, - createExtension, -} from './createExtension'; - /* * Copyright 2024 The Backstage Authors * @@ -25,11 +14,22 @@ import { * limitations under the License. */ +import { AppNode } from '../apis'; +import { PortableSchema } from '../schema'; +import { Expand } from '../types'; +import { + AnyExtensionDataMap, + AnyExtensionInputMap, + ExtensionDataValues, + ResolvedExtensionInputs, + createExtension, +} from './createExtension'; + /** * @public */ -export interface ExtensionKindOptions< - TProps, +export interface CreateExtensionKindOptions< + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, @@ -43,45 +43,46 @@ export interface ExtensionKindOptions< output: TOutput; configSchema?: PortableSchema; factory( - options: { + context: { node: AppNode; config: TConfig; inputs: Expand>; }, - props: TProps, + options: TOptions, ): Expand>; } /** + * TODO: should we export an interface instead of a concrete class? * @public */ export class ExtensionKind< - TProps, + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, > { static create< - TProps, + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, >( - options: ExtensionKindOptions, - ): ExtensionKind { + options: CreateExtensionKindOptions, + ): ExtensionKind { return new ExtensionKind(options); } private constructor( - private readonly options: ExtensionKindOptions< - TProps, + private readonly options: CreateExtensionKindOptions< + TOptions, TInputs, TOutput, TConfig >, ) {} - public new(options: { + public new(args: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; @@ -89,58 +90,58 @@ export class ExtensionKind< inputs?: TInputs; output?: TOutput; configSchema?: PortableSchema; - props: TProps; + options: TOptions; factory?( - options: { + context: { node: AppNode; config: TConfig; inputs: Expand>; orignalFactory( - options?: { + context?: { node?: AppNode; config?: TConfig; inputs?: Expand>; }, - props?: TProps, + options?: TOptions, ): Expand>; }, - props: TProps, + options: TOptions, ): Expand>; }) { return createExtension({ kind: this.options.kind, - namespace: options.namespace ?? this.options.namespace, - name: options.name ?? this.options.name, - attachTo: options.attachTo ?? this.options.attachTo, - disabled: options.disabled ?? this.options.disabled, - inputs: options.inputs ?? this.options.inputs, - output: options.output ?? this.options.output, - configSchema: options.configSchema ?? this.options.configSchema, // TODO: some config merging or smth + namespace: args.namespace ?? this.options.namespace, + name: args.name ?? this.options.name, + attachTo: args.attachTo ?? this.options.attachTo, + disabled: args.disabled ?? this.options.disabled, + inputs: args.inputs ?? this.options.inputs, + output: args.output ?? this.options.output, + configSchema: args.configSchema ?? this.options.configSchema, // TODO: some config merging or smth factory: ({ node, config, inputs }) => { - if (options.factory) { - return options.factory( + if (args.factory) { + return args.factory( { node, config, inputs, orignalFactory: ( - innerOptions?: { + innerContext?: { node?: AppNode; config?: TConfig; inputs?: Expand>; }, - innerProps?: TProps, + innerOptions?: TOptions, ) => this.options.factory( { - node: innerOptions?.node ?? node, - config: innerOptions?.config ?? config, - inputs: innerOptions?.inputs ?? inputs, + node: innerContext?.node ?? node, + config: innerContext?.config ?? config, + inputs: innerContext?.inputs ?? inputs, }, - innerProps ?? options.props, + innerOptions ?? args.options, ), }, - options.props, + args.options, ); } @@ -150,7 +151,7 @@ export class ExtensionKind< config, inputs, }, - options.props, + args.options, ); }, }); @@ -164,10 +165,10 @@ export class ExtensionKind< * @public */ export function createExtensionKind< - TProps, + TOptions, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, ->(options: ExtensionKindOptions) { +>(options: CreateExtensionKindOptions) { return ExtensionKind.create(options); } diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index e8c9afbaae..8fe9dc3a54 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -49,7 +49,7 @@ export { type FrontendFeature, } from './types'; export { - type ExtensionKindOptions, + type CreateExtensionKindOptions as ExtensionKindOptions, ExtensionKind, createExtensionKind, } from './createExtensionKind'; From a629fb22914852cd5f2c2e431b372833773c5156 Mon Sep 17 00:00:00 2001 From: 1337 <19777147+the-serious-programmer@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:26:29 +0000 Subject: [PATCH 15/41] Added setAllowedLocationTypes with new CatalogLocationsExtensionPoint Signed-off-by: The serious programmer <19777147+the-serious-programmer@users.noreply.github.com> Signed-off-by: 1337 <19777147+the-serious-programmer@users.noreply.github.com> --- .changeset/hip-hairs-exist.md | 6 +++++ .../src/service/CatalogPlugin.ts | 26 +++++++++++++++++++ plugins/catalog-node/api-report-alpha.md | 8 ++++++ plugins/catalog-node/src/alpha.ts | 2 ++ plugins/catalog-node/src/extensions.ts | 19 ++++++++++++++ 5 files changed, 61 insertions(+) create mode 100644 .changeset/hip-hairs-exist.md diff --git a/.changeset/hip-hairs-exist.md b/.changeset/hip-hairs-exist.md new file mode 100644 index 0000000000..d8cb939dfb --- /dev/null +++ b/.changeset/hip-hairs-exist.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-catalog-node': patch +--- + +Added setAllowedLocationTypes while introducing a new extension point called CatalogLocationsExtensionPoint diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index aebd969701..2f5fd9103d 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -28,6 +28,8 @@ import { catalogPermissionExtensionPoint, CatalogProcessingExtensionPoint, catalogProcessingExtensionPoint, + CatalogLocationsExtensionPoint, + catalogLocationsExtensionPoint, } from '@backstage/plugin-catalog-node/alpha'; import { CatalogProcessor, @@ -41,6 +43,20 @@ import { merge } from 'lodash'; import { Permission } from '@backstage/plugin-permission-common'; import { ForwardedError } from '@backstage/errors'; +class CatalogLocationsExtensionPointImpl + implements CatalogLocationsExtensionPoint +{ + #locationTypes = new Array(); + + setAllowedLocationTypes(locationTypes: Array) { + this.#locationTypes = locationTypes; + } + + get allowedLocationTypes() { + return this.#locationTypes; + } +} + class CatalogProcessingExtensionPointImpl implements CatalogProcessingExtensionPoint { @@ -199,6 +215,12 @@ export const catalogPlugin = createBackendPlugin({ const modelExtensions = new CatalogModelExtensionPointImpl(); env.registerExtensionPoint(catalogModelExtensionPoint, modelExtensions); + const locationTypeExtensions = new CatalogLocationsExtensionPointImpl(); + env.registerExtensionPoint( + catalogLocationsExtensionPoint, + locationTypeExtensions, + ); + env.registerInit({ deps: { logger: coreServices.logger, @@ -271,6 +293,10 @@ export const catalogPlugin = createBackendPlugin({ builder.addPermissionRules(...permissionExtensions.permissionRules); builder.setFieldFormatValidators(modelExtensions.fieldValidators); + builder.setAllowedLocationTypes( + locationTypeExtensions.allowedLocationTypes, + ); + const { processingEngine, router } = await builder.build(); lifecycle.addStartupHook(async () => { diff --git a/plugins/catalog-node/api-report-alpha.md b/plugins/catalog-node/api-report-alpha.md index b61d1b1d27..e14f55c196 100644 --- a/plugins/catalog-node/api-report-alpha.md +++ b/plugins/catalog-node/api-report-alpha.md @@ -34,6 +34,14 @@ export interface CatalogAnalysisExtensionPoint { // @alpha (undocumented) export const catalogAnalysisExtensionPoint: ExtensionPoint; +// @alpha (undocumented) +export interface CatalogLocationsExtensionPoint { + setAllowedLocationTypes(locationTypes: Array): void; +} + +// @alpha (undocumented) +export const catalogLocationsExtensionPoint: ExtensionPoint; + // @alpha (undocumented) export interface CatalogModelExtensionPoint { setEntityDataParser(parser: CatalogProcessorParser): void; diff --git a/plugins/catalog-node/src/alpha.ts b/plugins/catalog-node/src/alpha.ts index 91b5bb99b6..b75ec48a7f 100644 --- a/plugins/catalog-node/src/alpha.ts +++ b/plugins/catalog-node/src/alpha.ts @@ -15,6 +15,8 @@ */ export { catalogServiceRef } from './catalogService'; +export type { CatalogLocationsExtensionPoint } from './extensions'; +export { catalogLocationsExtensionPoint } from './extensions'; export type { CatalogProcessingExtensionPoint } from './extensions'; export { catalogProcessingExtensionPoint } from './extensions'; export type { CatalogAnalysisExtensionPoint } from './extensions'; diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts index 7c1fe6c30d..a971e1588d 100644 --- a/plugins/catalog-node/src/extensions.ts +++ b/plugins/catalog-node/src/extensions.ts @@ -31,6 +31,25 @@ import { } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; +/** + * @alpha + */ +export interface CatalogLocationsExtensionPoint { + /** + * Allows setting custom location types, such as showcased in: https://backstage.io/docs/features/software-catalog/external-integrations/#creating-a-catalog-data-reader-processor + * @param locationTypes - List of location types to allow, default is "url" and "file" + */ + setAllowedLocationTypes(locationTypes: Array): void; +} + +/** + * @alpha + */ +export const catalogLocationsExtensionPoint = + createExtensionPoint({ + id: 'catalog.locations', + }); + /** * @alpha */ From 19d495939d23984987ac1097e61faadb3b0fd2aa Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Thu, 27 Jun 2024 23:20:51 -0400 Subject: [PATCH 16/41] Extract condition check to function Signed-off-by: Stephen Glass --- .../components/ReviewState/ReviewState.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx index ac7ac989e3..202e0c0fbf 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx @@ -47,11 +47,8 @@ function flattenObject( // Recurse into nested objects if ( - backstageReviewOptions && - backstageReviewOptions.explode && - typeof value === 'object' && - value !== null && - !Array.isArray(value) + backstageReviewOptions?.explode && + isJsonObject(value) ) { return flattenObject(value, prefixedKey, schema, formState); } @@ -61,6 +58,14 @@ function flattenObject( }); } +function isJsonObject(value?: JsonValue): value is JsonObject { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); +} + /** * The component used by the {@link Stepper} to render the review step. * @alpha @@ -89,9 +94,7 @@ export const ReviewState = (props: ReviewStateProps) => { } if ( backstageReviewOptions.explode && - typeof value === 'object' && - value !== null && - !Array.isArray(value) + isJsonObject(value) ) { return flattenObject(value, key, parsedSchema, props.formState); } @@ -106,7 +109,7 @@ export const ReviewState = (props: ReviewStateProps) => { [ key, definitionInSchema.enumNames[ - definitionInSchema.enum.indexOf(value) + definitionInSchema.enum.indexOf(value) ] || value, ], ]; From 7193d02b32b27eed9519875a03456b687e738936 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Fri, 28 Jun 2024 01:18:20 -0400 Subject: [PATCH 17/41] Fix formatting Signed-off-by: Stephen Glass --- .../components/ReviewState/ReviewState.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx index 202e0c0fbf..22ec54ac26 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx @@ -46,10 +46,7 @@ function flattenObject( const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; // Recurse into nested objects - if ( - backstageReviewOptions?.explode && - isJsonObject(value) - ) { + if (backstageReviewOptions?.explode && isJsonObject(value)) { return flattenObject(value, prefixedKey, schema, formState); } } @@ -59,11 +56,7 @@ function flattenObject( } function isJsonObject(value?: JsonValue): value is JsonObject { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ); + return typeof value === 'object' && value !== null && !Array.isArray(value); } /** @@ -92,10 +85,7 @@ export const ReviewState = (props: ReviewStateProps) => { if (backstageReviewOptions.show === false) { return []; } - if ( - backstageReviewOptions.explode && - isJsonObject(value) - ) { + if (backstageReviewOptions.explode && isJsonObject(value)) { return flattenObject(value, key, parsedSchema, props.formState); } } @@ -109,7 +99,7 @@ export const ReviewState = (props: ReviewStateProps) => { [ key, definitionInSchema.enumNames[ - definitionInSchema.enum.indexOf(value) + definitionInSchema.enum.indexOf(value) ] || value, ], ]; From b141c20b53082ad16af942c2574040d4b83921c6 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 30 Jun 2024 00:53:24 -0400 Subject: [PATCH 18/41] Move review object explode function into utils Signed-off-by: Stephen Glass --- .../components/ReviewState/ReviewState.tsx | 34 +--- .../next/components/ReviewState/util.test.ts | 187 ++++++++++++++++++ .../src/next/components/ReviewState/util.ts | 49 +++++ 3 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts create mode 100644 plugins/scaffolder-react/src/next/components/ReviewState/util.ts diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx index 22ec54ac26..95f5d76212 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx @@ -15,9 +15,10 @@ */ import React from 'react'; import { StructuredMetadataTable } from '@backstage/core-components'; -import { JsonObject, JsonValue } from '@backstage/types'; +import { JsonObject } from '@backstage/types'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { ParsedTemplateSchema } from '../../hooks/useTemplateSchema'; +import { flattenObject, isJsonObject } from './util'; /** * The props for the {@link ReviewState} component. @@ -28,37 +29,6 @@ export type ReviewStateProps = { formState: JsonObject; }; -function flattenObject( - obj: JsonObject, - prefix: string, - schema: JSONSchema, - formState: JsonObject, -): [string, JsonValue | undefined][] { - return Object.entries(obj).flatMap(([key, value]) => { - const prefixedKey = prefix ? `${prefix}/${key}` : key; - - const definitionInSchema = schema.getSchema({ - pointer: `#/${prefixedKey}`, - data: formState, - }); - - if (definitionInSchema) { - const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; - - // Recurse into nested objects - if (backstageReviewOptions?.explode && isJsonObject(value)) { - return flattenObject(value, prefixedKey, schema, formState); - } - } - - return [[key, value]]; - }); -} - -function isJsonObject(value?: JsonValue): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** * The component used by the {@link Stepper} to render the review step. * @alpha diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts b/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts new file mode 100644 index 0000000000..c468c1a055 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts @@ -0,0 +1,187 @@ +/* eslint-disable no-console */ +/* + * Copyright 2022 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 { flattenObject, isJsonObject } from './util'; +import { Draft07 as JSONSchema } from 'json-schema-library'; +import { ParsedTemplateSchema } from '../../hooks/useTemplateSchema'; + +describe('isJsonObject', () => { + it('should return true for non-null objects', () => { + expect(isJsonObject({})).toBe(true); + expect(isJsonObject({ key: 'value' })).toBe(true); + }); + + it('should return false for null', () => { + expect(isJsonObject(null)).toBe(false); + }); + + it('should return false for arrays', () => { + expect(isJsonObject([])).toBe(false); + expect(isJsonObject([1, 2, 3])).toBe(false); + }); + + it('should return false for non-objects', () => { + expect(isJsonObject('string')).toBe(false); + expect(isJsonObject(123)).toBe(false); + expect(isJsonObject(true)).toBe(false); + expect(isJsonObject(undefined)).toBe(false); + }); +}); + +describe('flattenObject', () => { + it('should handle an empty object', () => { + const schemas: ParsedTemplateSchema[] = [ + { + mergedSchema: { + type: 'object', + properties: { + name: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: {}, + }, + }, + }, + schema: {}, + title: 'test', + uiSchema: {}, + }, + ]; + + const parsedSchema = new JSONSchema(schemas[0].mergedSchema); + + const result = flattenObject({}, '', parsedSchema, {}); + + expect(result).toEqual([]); + }); + + it('should flatten a simple object', () => { + const formState = { + name: { + foo: 'value1', + bar: 'value2', + }, + }; + + const schemas: ParsedTemplateSchema[] = [ + { + mergedSchema: { + type: 'object', + properties: { + name: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: { + foo: { + type: 'string', + }, + bar: { + type: 'string', + }, + }, + }, + }, + }, + schema: {}, + title: 'test', + uiSchema: {}, + }, + ]; + + const [key, value] = Object.entries(formState)[0]; + const parsedSchema = new JSONSchema(schemas[0].mergedSchema); + + const result = flattenObject(value, key, parsedSchema, formState); + + expect(result).toEqual([ + ['foo', 'value1'], + ['bar', 'value2'], + ]); + }); + + it('should recurse into a nested object', () => { + const formState = { + name: { + foo: 'value1', + bar: 'value2', + example: { + test: 'value3', + }, + }, + }; + const schemas: ParsedTemplateSchema[] = [ + { + mergedSchema: { + type: 'object', + properties: { + name: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: { + foo: { + type: 'string', + }, + bar: { + type: 'string', + }, + example: { + type: 'object', + 'ui:backstage': { + review: { + explode: true, + }, + }, + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + schema: {}, + title: 'test', + uiSchema: {}, + }, + ]; + + const [key, value] = Object.entries(formState)[0]; + const parsedSchema = new JSONSchema(schemas[0].mergedSchema); + + const result = flattenObject(value, key, parsedSchema, formState); + + expect(result).toEqual([ + ['foo', 'value1'], + ['bar', 'value2'], + ['test', 'value3'], + ]); + }); +}); diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/util.ts b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts new file mode 100644 index 0000000000..0bb6fc08d2 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2022 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 { JsonObject, JsonValue } from '@backstage/types'; +import { Draft07 as JSONSchema } from 'json-schema-library'; + +export function flattenObject( + obj: JsonObject, + prefix: string, + schema: JSONSchema, + formState: JsonObject, +): [string, JsonValue | undefined][] { + return Object.entries(obj).flatMap(([key, value]) => { + const prefixedKey = prefix ? `${prefix}/${key}` : key; + + const definitionInSchema = schema.getSchema({ + pointer: `#/${prefixedKey}`, + data: formState, + }); + + if (definitionInSchema) { + const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; + + // Recurse into nested objects + if (backstageReviewOptions?.explode && isJsonObject(value)) { + return flattenObject(value, prefixedKey, schema, formState); + } + } + + return [[key, value]]; + }); +} + +export function isJsonObject(value?: JsonValue): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} From 08b7c3e1802702b456d4b51c69ddfa5d327e1f13 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 30 Jun 2024 01:29:30 -0400 Subject: [PATCH 19/41] Remove debug code Signed-off-by: Stephen Glass --- .../src/next/components/ReviewState/util.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts b/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts index c468c1a055..e2a10587dc 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts +++ b/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ /* * Copyright 2022 The Backstage Authors * From fad1b903a8e166492837c003916adb1f59be788e Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Wed, 3 Jul 2024 19:47:37 +0200 Subject: [PATCH 20/41] feat: allow the oauth token to be used in the gitlab:projectVariableAction:create action Signed-off-by: ElaineDeMattosSilvaB --- .changeset/grumpy-owls-suffer.md | 5 ++ ...tlabProjectVariableCreate.examples.test.ts | 61 ++++++++++--------- .../actions/gitlabProjectVariableCreate.ts | 27 ++++---- 3 files changed, 49 insertions(+), 44 deletions(-) create mode 100644 .changeset/grumpy-owls-suffer.md diff --git a/.changeset/grumpy-owls-suffer.md b/.changeset/grumpy-owls-suffer.md new file mode 100644 index 0000000000..977055eb33 --- /dev/null +++ b/.changeset/grumpy-owls-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Allow the `createGitlabProjectVariableAction` to use oauth tokens diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.examples.test.ts index 1eeb01ca91..99b26682d8 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.examples.test.ts @@ -26,7 +26,7 @@ const mockGitlabClient = { create: jest.fn(), }, }; -jest.mock('@gitbeaker/node', () => ({ +jest.mock('@gitbeaker/rest', () => ({ Gitlab: class { constructor() { return mockGitlabClient; @@ -41,7 +41,7 @@ describe('gitlab:projectVariableAction: create examples', () => { { host: 'gitlab.com', token: 'tokenlols', - apiBaseUrl: 'https://api.gitlab.com', + apiBaseUrl: 'https://api.gitlab.com/api/v4', }, { host: 'hosted.gitlab.com', @@ -79,17 +79,18 @@ describe('gitlab:projectVariableAction: create examples', () => { expect(mockGitlabClient.ProjectVariables.create).toHaveBeenCalledWith( '123', + 'MY_VARIABLE', + 'my_value', { - key: 'MY_VARIABLE', - value: 'my_value', - variable_type: 'env_var', - environment_scope: '*', + variableType: 'env_var', // Correctly using variableType + environmentScope: '*', masked: false, protected: false, raw: false, }, ); }); + it(`Should ${examples[1].description}`, async () => { mockGitlabClient.ProjectVariables.create.mockResolvedValue({ token: 'TOKEN', @@ -102,14 +103,14 @@ describe('gitlab:projectVariableAction: create examples', () => { expect(mockGitlabClient.ProjectVariables.create).toHaveBeenCalledWith( '123', + 'MY_VARIABLE', + 'my-file-content', { - key: 'MY_VARIABLE', - value: 'my-file-content', + variableType: 'file', // Correctly using variableType protected: false, masked: false, raw: false, - environment_scope: '*', - variable_type: 'file', + environmentScope: '*', }, ); }); @@ -126,13 +127,13 @@ describe('gitlab:projectVariableAction: create examples', () => { expect(mockGitlabClient.ProjectVariables.create).toHaveBeenCalledWith( '456', + 'MY_VARIABLE', + 'my_value', { - key: 'MY_VARIABLE', - value: 'my_value', masked: false, raw: false, - environment_scope: '*', - variable_type: 'env_var', + environmentScope: '*', + variableType: 'env_var', // Correctly using variableType protected: true, }, ); @@ -150,13 +151,13 @@ describe('gitlab:projectVariableAction: create examples', () => { expect(mockGitlabClient.ProjectVariables.create).toHaveBeenCalledWith( '789', + 'DB_PASSWORD', + 'password123', { - key: 'DB_PASSWORD', - value: 'password123', protected: false, raw: false, - environment_scope: '*', - variable_type: 'env_var', + environmentScope: '*', + variableType: 'env_var', // Correctly using variableType masked: true, }, ); @@ -174,12 +175,12 @@ describe('gitlab:projectVariableAction: create examples', () => { expect(mockGitlabClient.ProjectVariables.create).toHaveBeenCalledWith( '123', + 'MY_VARIABLE', + 'my_value', { - key: 'MY_VARIABLE', - value: 'my_value', protected: false, - environment_scope: '*', - variable_type: 'env_var', + environmentScope: '*', + variableType: 'env_var', // Correctly using variableType masked: false, raw: true, }, @@ -198,14 +199,14 @@ describe('gitlab:projectVariableAction: create examples', () => { expect(mockGitlabClient.ProjectVariables.create).toHaveBeenCalledWith( '123', + 'MY_VARIABLE', + 'my_value', { - key: 'MY_VARIABLE', - value: 'my_value', protected: false, - variable_type: 'env_var', + variableType: 'env_var', // Correctly using variableType masked: false, raw: false, - environment_scope: 'production', + environmentScope: 'production', }, ); }); @@ -222,14 +223,14 @@ describe('gitlab:projectVariableAction: create examples', () => { expect(mockGitlabClient.ProjectVariables.create).toHaveBeenCalledWith( '123', + 'MY_VARIABLE', + 'my_value', { - key: 'MY_VARIABLE', - value: 'my_value', protected: false, - variable_type: 'env_var', + variableType: 'env_var', // Correctly using variableType masked: false, raw: false, - environment_scope: '*', + environmentScope: '*', }, ); }); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.ts index a07ec747fa..3a9eeec0a1 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectVariableCreate.ts @@ -16,10 +16,10 @@ import { ScmIntegrationRegistry } from '@backstage/integration'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import { Gitlab } from '@gitbeaker/node'; +import { VariableType } from '@gitbeaker/rest'; import { z } from 'zod'; import commonGitlabConfig from '../commonGitlabConfig'; -import { getToken } from '../util'; +import { getClient, parseRepoUrl } from '../util'; import { examples } from './gitlabProjectVariableCreate.examples'; /** @@ -72,6 +72,7 @@ export const createGitlabProjectVariableAction = (options: { }, async handler(ctx) { const { + repoUrl, projectId, key, value, @@ -80,21 +81,19 @@ export const createGitlabProjectVariableAction = (options: { masked = false, raw = false, environmentScope = '*', + token, } = ctx.input; - const { token, integrationConfig } = getToken(ctx.input, integrations); - const api = new Gitlab({ - host: integrationConfig.config.baseUrl, - token: token, - }); - await api.ProjectVariables.create(projectId, { - key: key, - value: value, - variable_type: variableType, + const { host } = parseRepoUrl(repoUrl, integrations); + + const api = getClient({ host, integrations, token }); + + await api.ProjectVariables.create(projectId, key, value, { + variableType: variableType as VariableType, protected: variableProtected, - masked: masked, - raw: raw, - environment_scope: environmentScope, + masked, + raw, + environmentScope, }); }, }); From bebd569c565516fb411c276bf47b1aef80c5bc9e Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Wed, 10 Jul 2024 20:44:25 -0400 Subject: [PATCH 21/41] Fix extra divider displayed on user list picker component Signed-off-by: Stephen Glass --- .changeset/pink-gorillas-brake.md | 5 +++++ .../src/components/UserListPicker/UserListPicker.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/pink-gorillas-brake.md diff --git a/.changeset/pink-gorillas-brake.md b/.changeset/pink-gorillas-brake.md new file mode 100644 index 0000000000..188884f13c --- /dev/null +++ b/.changeset/pink-gorillas-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Fix extra divider displayed on user list picker component diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx index 7f0ff8be74..c6b515aa67 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx @@ -247,11 +247,11 @@ export const UserListPicker = (props: UserListPickerProps) => { - {group.items.map(item => ( + {group.items.map((item, index) => ( setSelectedUserFilter(item.id)} selected={item.id === filters.user?.value} className={classes.menuItem} From 0ab868dfd1510b6ef85d1967db2ac3688de496bd Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 14 Jul 2024 04:00:51 -0400 Subject: [PATCH 22/41] Wip Signed-off-by: Stephen Glass --- .../components/ReviewState/ReviewState.tsx | 42 +--------- .../src/next/components/ReviewState/util.ts | 84 +++++++++++++------ 2 files changed, 59 insertions(+), 67 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx index 95f5d76212..d320ad80a6 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx @@ -18,7 +18,7 @@ import { StructuredMetadataTable } from '@backstage/core-components'; import { JsonObject } from '@backstage/types'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { ParsedTemplateSchema } from '../../hooks/useTemplateSchema'; -import { flattenObject, isJsonObject } from './util'; +import { processEntry } from './util'; /** * The props for the {@link ReviewState} component. @@ -38,49 +38,11 @@ export const ReviewState = (props: ReviewStateProps) => { Object.entries(props.formState) .flatMap(([key, value]) => { for (const step of props.schemas) { - const parsedSchema = new JSONSchema(step.mergedSchema); - const definitionInSchema = parsedSchema.getSchema({ - pointer: `#/${key}`, - data: props.formState, - }); - - if (definitionInSchema) { - const backstageReviewOptions = - definitionInSchema['ui:backstage']?.review; - - if (backstageReviewOptions) { - if (backstageReviewOptions.mask) { - return [[key, backstageReviewOptions.mask]]; - } - if (backstageReviewOptions.show === false) { - return []; - } - if (backstageReviewOptions.explode && isJsonObject(value)) { - return flattenObject(value, key, parsedSchema, props.formState); - } - } - - if (definitionInSchema['ui:widget'] === 'password') { - return [[key, '******']]; - } - - if (definitionInSchema.enum && definitionInSchema.enumNames) { - return [ - [ - key, - definitionInSchema.enumNames[ - definitionInSchema.enum.indexOf(value) - ] || value, - ], - ]; - } - } + return processEntry(key, value, step, props.formState); } - return [[key, value]]; }) .filter(prop => prop.length > 0), ); - return ; }; diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/util.ts b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts index 0bb6fc08d2..e040b851d3 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/util.ts +++ b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /* * Copyright 2022 The Backstage Authors * @@ -16,34 +17,63 @@ import { JsonObject, JsonValue } from '@backstage/types'; import { Draft07 as JSONSchema } from 'json-schema-library'; - -export function flattenObject( - obj: JsonObject, - prefix: string, - schema: JSONSchema, - formState: JsonObject, -): [string, JsonValue | undefined][] { - return Object.entries(obj).flatMap(([key, value]) => { - const prefixedKey = prefix ? `${prefix}/${key}` : key; - - const definitionInSchema = schema.getSchema({ - pointer: `#/${prefixedKey}`, - data: formState, - }); - - if (definitionInSchema) { - const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; - - // Recurse into nested objects - if (backstageReviewOptions?.explode && isJsonObject(value)) { - return flattenObject(value, prefixedKey, schema, formState); - } - } - - return [[key, value]]; - }); -} +import { ParsedTemplateSchema } from '../../hooks'; export function isJsonObject(value?: JsonValue): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } + +export function processEntry( + key: string, + value: JsonValue | undefined, + schema: ParsedTemplateSchema, + formState: JsonObject, +): [string, JsonValue | undefined][] { + const parsedSchema = new JSONSchema(schema.mergedSchema); + const definitionInSchema = parsedSchema.getSchema({ + pointer: `#/${key}`, + data: formState, + }); + + if (definitionInSchema) { + const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; + if (backstageReviewOptions) { + if (backstageReviewOptions.mask) { + return [[getLastKey(key), backstageReviewOptions.mask]]; + } + if (backstageReviewOptions.show === false) { + return []; + } + } + + if (isJsonObject(value)) { + // Process nested objects + return Object.entries(value).flatMap(([nestedKey, nestedValue]) => + processEntry(`${key}/${nestedKey}`, nestedValue, schema, formState), + ); + } + + if (definitionInSchema['ui:widget'] === 'password') { + return [[getLastKey(key), '******']]; + } + + if (definitionInSchema.enum && definitionInSchema.enumNames) { + return [ + [ + getLastKey(key), + definitionInSchema.enumNames[ + definitionInSchema.enum.indexOf(value) + ] || value, + ], + ]; + } + } + + return [[getLastKey(key), value]]; +} + +// Helper function to get the last part of the key +function getLastKey(key: string): string { + const parts = key.split('/'); + return parts[parts.length - 1]; +} From fe6ab86d9207cd1c2586aa016efdfbc007915ade Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 14 Jul 2024 15:24:38 -0400 Subject: [PATCH 23/41] Refactor review state schema processing code to support nested objects Signed-off-by: Stephen Glass --- .../ReviewState/ReviewState.test.tsx | 179 +++++++++++------- .../components/ReviewState/ReviewState.tsx | 55 +++++- .../next/components/ReviewState/util.test.ts | 170 +++-------------- .../src/next/components/ReviewState/util.ts | 55 +----- 4 files changed, 189 insertions(+), 270 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx index dfa1c9c097..194c5f3e77 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.test.tsx @@ -69,6 +69,9 @@ describe('ReviewState', () => { const formState = { name: 'John Doe', test: 'bob', + nest: { + foo: 'bar', + }, }; const schemas: ParsedTemplateSchema[] = [ @@ -85,6 +88,20 @@ describe('ReviewState', () => { }, }, }, + nest: { + type: 'object', + properties: { + foo: { + type: 'string', + 'ui:widget': 'password', + 'ui:backstage': { + review: { + show: false, + }, + }, + }, + }, + }, }, }, schema: {}, @@ -99,12 +116,16 @@ describe('ReviewState', () => { ); expect(getAllByRole('row').length).toEqual(1); expect(queryByRole('row', { name: 'Name ******' })).not.toBeInTheDocument(); + expect(queryByRole('row', { name: 'Foo ******' })).not.toBeInTheDocument(); }); it('should allow for masking an option with a set text', () => { const formState = { name: 'John Doe', test: 'bob', + nest: { + foo: 'bar', + }, }; const schemas: ParsedTemplateSchema[] = [ @@ -121,6 +142,20 @@ describe('ReviewState', () => { }, }, }, + nest: { + type: 'object', + properties: { + foo: { + type: 'string', + 'ui:widget': 'password', + 'ui:backstage': { + review: { + mask: 'lols', + }, + }, + }, + }, + }, }, }, schema: {}, @@ -135,11 +170,15 @@ describe('ReviewState', () => { ); expect(getByRole('row', { name: 'Name lols' })).toBeInTheDocument(); + expect(getByRole('row', { name: 'Foo lols' })).toBeInTheDocument(); }); it('should display enum label from enumNames', async () => { const formState = { name: 'type2', + nest: { + foo: 'type2', + }, }; const schemas: ParsedTemplateSchema[] = [ @@ -153,6 +192,17 @@ describe('ReviewState', () => { enum: ['type1', 'type2', 'type3'], enumNames: ['Label-type1', 'Label-type2', 'Label-type3'], }, + nest: { + type: 'object', + properties: { + foo: { + type: 'string', + default: 'type1', + enum: ['type1', 'type2', 'type3'], + enumNames: ['Label-type1', 'Label-type2', 'Label-type3'], + }, + }, + }, }, }, schema: {}, @@ -169,6 +219,7 @@ describe('ReviewState', () => { expect( queryByRole('row', { name: 'Name Label-type2' }), ).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Foo Label-type2' })).toBeInTheDocument(); }); it('should display enum value if no corresponding enumNames', async () => { @@ -203,7 +254,7 @@ describe('ReviewState', () => { expect(queryByRole('row', { name: 'Name type4' })).toBeInTheDocument(); }); - it('should display exploded object in separate rows', async () => { + it('should display object in separate rows', async () => { const formState = { name: { foo: 'type3', @@ -218,11 +269,6 @@ describe('ReviewState', () => { properties: { name: { type: 'object', - 'ui:backstage': { - review: { - explode: true, - }, - }, properties: { foo: { type: 'string', @@ -239,7 +285,6 @@ describe('ReviewState', () => { schema: {}, title: 'test', uiSchema: {}, - description: 'asd', }, ]; @@ -251,7 +296,7 @@ describe('ReviewState', () => { expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument(); }); - it('should display exploded nested objects', async () => { + it('should display nested objects in separate rows', async () => { const formState = { name: { foo: 'type3', @@ -269,11 +314,60 @@ describe('ReviewState', () => { properties: { name: { type: 'object', - 'ui:backstage': { - review: { - explode: true, + properties: { + foo: { + type: 'string', + default: 'type1', + }, + bar: { + type: 'string', + default: 'type2', + }, + example: { + type: 'object', + properties: { + test: { + type: 'string', + }, + }, }, }, + }, + }, + }, + schema: {}, + title: 'test', + uiSchema: {}, + }, + ]; + + const { queryByRole } = render( + , + ); + + expect(queryByRole('row', { name: 'Foo type3' })).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument(); + expect(queryByRole('row', { name: 'Test type6' })).toBeInTheDocument(); + }); + + it('should display partially nested objects', async () => { + const formState = { + name: { + foo: 'type3', + bar: 'type4', + example: { + test: 'type6', + }, + }, + }; + + const schemas: ParsedTemplateSchema[] = [ + { + mergedSchema: { + type: 'object', + properties: { + name: { + type: 'object', properties: { foo: { type: 'string', @@ -287,7 +381,7 @@ describe('ReviewState', () => { type: 'object', 'ui:backstage': { review: { - explode: true, + explode: false, }, }, properties: { @@ -303,67 +397,6 @@ describe('ReviewState', () => { schema: {}, title: 'test', uiSchema: {}, - description: 'asd', - }, - ]; - - const { queryByRole } = render( - , - ); - - expect(queryByRole('row', { name: 'Foo type3' })).toBeInTheDocument(); - expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument(); - expect(queryByRole('row', { name: 'Test type6' })).toBeInTheDocument(); - }); - - it('should display partially exploded nested objects', async () => { - const formState = { - name: { - foo: 'type3', - bar: 'type4', - example: { - test: 'type6', - }, - }, - }; - - const schemas: ParsedTemplateSchema[] = [ - { - mergedSchema: { - type: 'object', - properties: { - name: { - type: 'object', - 'ui:backstage': { - review: { - explode: true, - }, - }, - properties: { - foo: { - type: 'string', - default: 'type1', - }, - bar: { - type: 'string', - default: 'type2', - }, - example: { - type: 'object', - properties: { - test: { - type: 'string', - }, - }, - }, - }, - }, - }, - }, - schema: {}, - title: 'test', - uiSchema: {}, - description: 'asd', }, ]; diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx index d320ad80a6..d87584afff 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx +++ b/plugins/scaffolder-react/src/next/components/ReviewState/ReviewState.tsx @@ -15,10 +15,10 @@ */ import React from 'react'; import { StructuredMetadataTable } from '@backstage/core-components'; -import { JsonObject } from '@backstage/types'; +import { JsonObject, JsonValue } from '@backstage/types'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { ParsedTemplateSchema } from '../../hooks/useTemplateSchema'; -import { processEntry } from './util'; +import { isJsonObject, getLastKey } from './util'; /** * The props for the {@link ReviewState} component. @@ -29,6 +29,55 @@ export type ReviewStateProps = { formState: JsonObject; }; +function processSchema( + key: string, + value: JsonValue | undefined, + schema: ParsedTemplateSchema, + formState: JsonObject, +): [string, JsonValue | undefined][] { + const parsedSchema = new JSONSchema(schema.mergedSchema); + const definitionInSchema = parsedSchema.getSchema({ + pointer: `#/${key}`, + data: formState, + }); + + if (definitionInSchema) { + const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; + if (backstageReviewOptions) { + if (backstageReviewOptions.mask) { + return [[getLastKey(key), backstageReviewOptions.mask]]; + } + if (backstageReviewOptions.show === false) { + return []; + } + } + + if (definitionInSchema['ui:widget'] === 'password') { + return [[getLastKey(key), '******']]; + } + + if (definitionInSchema.enum && definitionInSchema.enumNames) { + return [ + [ + getLastKey(key), + definitionInSchema.enumNames[ + definitionInSchema.enum.indexOf(value) + ] || value, + ], + ]; + } + + if (backstageReviewOptions?.explode !== false && isJsonObject(value)) { + // Recurse nested objects + return Object.entries(value).flatMap(([nestedKey, nestedValue]) => + processSchema(`${key}/${nestedKey}`, nestedValue, schema, formState), + ); + } + } + + return [[getLastKey(key), value]]; +} + /** * The component used by the {@link Stepper} to render the review step. * @alpha @@ -38,7 +87,7 @@ export const ReviewState = (props: ReviewStateProps) => { Object.entries(props.formState) .flatMap(([key, value]) => { for (const step of props.schemas) { - return processEntry(key, value, step, props.formState); + return processSchema(key, value, step, props.formState); } return [[key, value]]; }) diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts b/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts index e2a10587dc..9b88ff157e 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts +++ b/plugins/scaffolder-react/src/next/components/ReviewState/util.test.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { flattenObject, isJsonObject } from './util'; -import { Draft07 as JSONSchema } from 'json-schema-library'; -import { ParsedTemplateSchema } from '../../hooks/useTemplateSchema'; +import { isJsonObject, getLastKey } from './util'; describe('isJsonObject', () => { it('should return true for non-null objects', () => { @@ -24,10 +22,6 @@ describe('isJsonObject', () => { expect(isJsonObject({ key: 'value' })).toBe(true); }); - it('should return false for null', () => { - expect(isJsonObject(null)).toBe(false); - }); - it('should return false for arrays', () => { expect(isJsonObject([])).toBe(false); expect(isJsonObject([1, 2, 3])).toBe(false); @@ -41,146 +35,40 @@ describe('isJsonObject', () => { }); }); -describe('flattenObject', () => { - it('should handle an empty object', () => { - const schemas: ParsedTemplateSchema[] = [ - { - mergedSchema: { - type: 'object', - properties: { - name: { - type: 'object', - 'ui:backstage': { - review: { - explode: true, - }, - }, - properties: {}, - }, - }, - }, - schema: {}, - title: 'test', - uiSchema: {}, - }, - ]; - - const parsedSchema = new JSONSchema(schemas[0].mergedSchema); - - const result = flattenObject({}, '', parsedSchema, {}); - - expect(result).toEqual([]); +describe('getLastKey', () => { + it('should return the last part of a simple key', () => { + expect(getLastKey('simple')).toBe('simple'); }); - it('should flatten a simple object', () => { - const formState = { - name: { - foo: 'value1', - bar: 'value2', - }, - }; - - const schemas: ParsedTemplateSchema[] = [ - { - mergedSchema: { - type: 'object', - properties: { - name: { - type: 'object', - 'ui:backstage': { - review: { - explode: true, - }, - }, - properties: { - foo: { - type: 'string', - }, - bar: { - type: 'string', - }, - }, - }, - }, - }, - schema: {}, - title: 'test', - uiSchema: {}, - }, - ]; - - const [key, value] = Object.entries(formState)[0]; - const parsedSchema = new JSONSchema(schemas[0].mergedSchema); - - const result = flattenObject(value, key, parsedSchema, formState); - - expect(result).toEqual([ - ['foo', 'value1'], - ['bar', 'value2'], - ]); + it('should return the last part of a nested key', () => { + expect(getLastKey('parent/child')).toBe('child'); }); - it('should recurse into a nested object', () => { - const formState = { - name: { - foo: 'value1', - bar: 'value2', - example: { - test: 'value3', - }, - }, - }; - const schemas: ParsedTemplateSchema[] = [ - { - mergedSchema: { - type: 'object', - properties: { - name: { - type: 'object', - 'ui:backstage': { - review: { - explode: true, - }, - }, - properties: { - foo: { - type: 'string', - }, - bar: { - type: 'string', - }, - example: { - type: 'object', - 'ui:backstage': { - review: { - explode: true, - }, - }, - properties: { - test: { - type: 'string', - }, - }, - }, - }, - }, - }, - }, - schema: {}, - title: 'test', - uiSchema: {}, - }, - ]; + it('should return the last part of a deeply nested key', () => { + expect(getLastKey('grandparent/parent/child')).toBe('child'); + }); - const [key, value] = Object.entries(formState)[0]; - const parsedSchema = new JSONSchema(schemas[0].mergedSchema); + it('should handle keys with trailing slash', () => { + expect(getLastKey('parent/child/')).toBe(''); + }); - const result = flattenObject(value, key, parsedSchema, formState); + it('should handle empty string', () => { + expect(getLastKey('')).toBe(''); + }); - expect(result).toEqual([ - ['foo', 'value1'], - ['bar', 'value2'], - ['test', 'value3'], - ]); + it('should handle keys with multiple consecutive slashes', () => { + expect(getLastKey('parent//child')).toBe('child'); + }); + + it('should handle keys with only slashes', () => { + expect(getLastKey('////')).toBe(''); + }); + + it('should handle keys with spaces', () => { + expect(getLastKey('parent/child with spaces')).toBe('child with spaces'); + }); + + it('should handle keys with special characters', () => { + expect(getLastKey('parent/child@!#$%^&*()')).toBe('child@!#$%^&*()'); }); }); diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/util.ts b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts index e040b851d3..52503bedfd 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/util.ts +++ b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts @@ -16,64 +16,13 @@ */ import { JsonObject, JsonValue } from '@backstage/types'; -import { Draft07 as JSONSchema } from 'json-schema-library'; -import { ParsedTemplateSchema } from '../../hooks'; export function isJsonObject(value?: JsonValue): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -export function processEntry( - key: string, - value: JsonValue | undefined, - schema: ParsedTemplateSchema, - formState: JsonObject, -): [string, JsonValue | undefined][] { - const parsedSchema = new JSONSchema(schema.mergedSchema); - const definitionInSchema = parsedSchema.getSchema({ - pointer: `#/${key}`, - data: formState, - }); - - if (definitionInSchema) { - const backstageReviewOptions = definitionInSchema['ui:backstage']?.review; - if (backstageReviewOptions) { - if (backstageReviewOptions.mask) { - return [[getLastKey(key), backstageReviewOptions.mask]]; - } - if (backstageReviewOptions.show === false) { - return []; - } - } - - if (isJsonObject(value)) { - // Process nested objects - return Object.entries(value).flatMap(([nestedKey, nestedValue]) => - processEntry(`${key}/${nestedKey}`, nestedValue, schema, formState), - ); - } - - if (definitionInSchema['ui:widget'] === 'password') { - return [[getLastKey(key), '******']]; - } - - if (definitionInSchema.enum && definitionInSchema.enumNames) { - return [ - [ - getLastKey(key), - definitionInSchema.enumNames[ - definitionInSchema.enum.indexOf(value) - ] || value, - ], - ]; - } - } - - return [[getLastKey(key), value]]; + return typeof value === 'object' && !Array.isArray(value); } // Helper function to get the last part of the key -function getLastKey(key: string): string { +export function getLastKey(key: string): string { const parts = key.split('/'); return parts[parts.length - 1]; } From ba67e2884712d3036c10e3d9b19c126305a5d16d Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 14 Jul 2024 15:37:16 -0400 Subject: [PATCH 24/41] Remove comment Signed-off-by: Stephen Glass --- plugins/scaffolder-react/src/next/components/ReviewState/util.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/scaffolder-react/src/next/components/ReviewState/util.ts b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts index 52503bedfd..49c3b30e7a 100644 --- a/plugins/scaffolder-react/src/next/components/ReviewState/util.ts +++ b/plugins/scaffolder-react/src/next/components/ReviewState/util.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ /* * Copyright 2022 The Backstage Authors * From a5bcb9c4d59af4c50bff0160bec566e49753a3a0 Mon Sep 17 00:00:00 2001 From: Niall Thomson Date: Mon, 15 Jul 2024 05:58:02 -0600 Subject: [PATCH 25/41] Update plugins/catalog-backend-module-aws/src/awsOrganization/config.ts Co-authored-by: Vincenzo Scamporlino Signed-off-by: Niall Thomson --- plugins/catalog-backend-module-aws/src/awsOrganization/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts b/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts index ff00af48e4..ea16394e0a 100644 --- a/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts +++ b/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts @@ -22,6 +22,7 @@ import { Config } from '@backstage/config'; export type AwsOrganizationProviderConfig = { /** * The role to assume for the processor. + * @deprecated Use `accountId` instead. */ roleArn?: string; From 4670f06f72877aa20faf7f24021a977b22598754 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Wed, 6 Mar 2024 17:31:38 -0600 Subject: [PATCH 26/41] support ajv-errors for scaffolder validation Signed-off-by: Matt Benson --- .changeset/modern-parrots-protect.md | 5 +++++ .../config/vocabularies/Backstage/accept.txt | 1 + .../software-templates/input-examples.md | 19 +++++++++++++++++++ .../software-templates/writing-templates.md | 5 +++++ plugins/scaffolder-react/package.json | 1 + .../src/next/components/Stepper/Stepper.tsx | 6 +++++- yarn.lock | 1 + 7 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/modern-parrots-protect.md diff --git a/.changeset/modern-parrots-protect.md b/.changeset/modern-parrots-protect.md new file mode 100644 index 0000000000..7fccff1bef --- /dev/null +++ b/.changeset/modern-parrots-protect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +support ajv-errors for scaffolder validation diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 6ff0ef56da..1092947245 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -7,6 +7,7 @@ ADRs airbrake Airbrake Airbrakes +ajv Alaria Alef allowlisted diff --git a/docs/features/software-templates/input-examples.md b/docs/features/software-templates/input-examples.md index f49e76e469..dd1228290f 100644 --- a/docs/features/software-templates/input-examples.md +++ b/docs/features/software-templates/input-examples.md @@ -26,6 +26,25 @@ parameters: ui:help: 'Hint: additional description...' ``` +#### Custom validation error message + +```yaml +parameters: + - title: Fill in some steps + properties: + name: + title: Simple text input + type: string + description: Description about input + maxLength: 8 + pattern: '^([a-zA-Z][a-zA-Z0-9]*)(-[a-zA-Z0-9]+)*$' + ui:autofocus: true + ui:help: 'Hint: additional description...' + errorMessage: + properties: + name: '1-8 alphanumeric tokens (first starts with letter) delimited by -' +``` + ### Multi line text input ```yaml diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 15591eeb67..075fe0bb08 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -533,6 +533,11 @@ catalogFilter: metadata.annotations.github.com/team-slug: { exists: true } ``` +#### Custom validation messages + +You may specify custom JSON Schema validation messages as supported by the +[ajv-errors](https://github.com/ajv-validator/ajv-errors) plugin library to [ajv](https://github.com/ajv-validator/ajv). + ## `spec.steps` - `Action[]` The `steps` is an array of the things that you want to happen part of this diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index f2d7d44a01..9c50900c05 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -78,6 +78,7 @@ "@rjsf/validator-ajv8": "5.18.5", "@types/json-schema": "^7.0.9", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "ajv-errors": "^3.0.0", "classnames": "^2.2.6", "flatted": "3.3.1", "humanize-duration": "^3.25.1", diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 269b6a11d5..788c7cd715 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -35,7 +35,7 @@ import { } from './createAsyncValidators'; import { ReviewState, type ReviewStateProps } from '../ReviewState'; import { useTemplateSchema, useFormDataFromQuery } from '../../hooks'; -import validator from '@rjsf/validator-ajv8'; +import { customizeValidator } from '@rjsf/validator-ajv8'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; import { hasErrors } from './utils'; import * as FieldOverrides from './FieldOverrides'; @@ -50,6 +50,10 @@ import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { ErrorListTemplate } from './ErrorListTemplate'; import { makeStyles } from '@material-ui/core/styles'; import { PasswordWidget } from '../PasswordWidget/PasswordWidget'; +import ajvErrors from 'ajv-errors'; + +const validator = customizeValidator(); +ajvErrors(validator.ajv); const useStyles = makeStyles(theme => ({ backButton: { diff --git a/yarn.lock b/yarn.lock index c6a1d2139a..fe7bc4b2c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7110,6 +7110,7 @@ __metadata: "@types/json-schema": ^7.0.9 "@types/luxon": ^3.0.0 "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 + ajv-errors: ^3.0.0 classnames: ^2.2.6 flatted: 3.3.1 humanize-duration: ^3.25.1 From 8b808fd1a92389395668596a18942502816610aa Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Mon, 15 Jul 2024 10:48:22 -0500 Subject: [PATCH 27/41] add errorMessage test Signed-off-by: Matt Benson --- .../next/components/Stepper/Stepper.test.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx index d7e271f8b3..df58d971fb 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx @@ -392,6 +392,46 @@ describe('Stepper', () => { expect(getByText('invalid postcode')).toBeInTheDocument(); }); + it('should render ajv-errors message', async () => { + const manifest: TemplateParameterSchema = { + steps: [ + { + title: 'Step 1', + schema: { + properties: { + postcode: { + type: 'string', + pattern: '[A-Z][0-9][A-Z] [0-9][A-Z][0-9]', + }, + }, + errorMessage: { + properties: { + postcode: 'invalid postcode', + }, + }, + }, + }, + ], + title: 'transformErrors Form Test', + }; + + const { getByText, getByRole } = await renderInTestApp( + + + , + ); + + await fireEvent.change(getByRole('textbox', { name: 'postcode' }), { + target: { value: 'invalid' }, + }); + + await act(async () => { + await fireEvent.click(getByRole('button', { name: 'Review' })); + }); + + expect(getByText('invalid postcode')).toBeInTheDocument(); + }); + it('should grab the initial formData from the query', async () => { const manifest: TemplateParameterSchema = { steps: [ From 7eb08a69dd0cdc11fdf363a07149a8ca9438eadd Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 20:53:55 +0100 Subject: [PATCH 28/41] cli: allow linting packages with role dynamic-frontend-container Signed-off-by: MT Lewis --- .changeset/bright-trainers-brake.md | 5 +++++ packages/cli/config/eslint-factory.js | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/bright-trainers-brake.md diff --git a/.changeset/bright-trainers-brake.md b/.changeset/bright-trainers-brake.md new file mode 100644 index 0000000000..2299d83a32 --- /dev/null +++ b/.changeset/bright-trainers-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add frontend-dynamic-container role to eslint config factory diff --git a/packages/cli/config/eslint-factory.js b/packages/cli/config/eslint-factory.js index 6bc200cade..96d8478ec0 100644 --- a/packages/cli/config/eslint-factory.js +++ b/packages/cli/config/eslint-factory.js @@ -201,6 +201,7 @@ function createConfigForRole(dir, role, extraConfig = {}) { case 'frontend': case 'frontend-plugin': case 'frontend-plugin-module': + case 'frontend-dynamic-container': return createConfig(dir, { ...extraConfig, extends: [ From 1f5094f1b70495aba6ee8d54bb86ed5dcea6b410 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Wed, 26 Jun 2024 16:20:47 +0200 Subject: [PATCH 29/41] Update dry-squids-tap.md Signed-off-by: Ben Lambert Signed-off-by: blam --- .changeset/dry-squids-tap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md index 5193e1f204..9bd835d371 100644 --- a/.changeset/dry-squids-tap.md +++ b/.changeset/dry-squids-tap.md @@ -14,9 +14,9 @@ const TestExtensionKind = createExtensionKind({ output: { element: coreExtensionData.reactElement, }, - factory(_, props: { text: string }) { + factory(_, options: { text: string }) { return { - element:

{props.text}

, + element:

{options.text}

, }; }, }); From f749bb8fa989e5f4c29608a903cc9119cd745390 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 25 Jun 2024 14:25:22 +0200 Subject: [PATCH 30/41] chore: extension overrides spike Signed-off-by: blam --- .../core-compat-api/src/convertLegacyApp.ts | 72 ++++++++++- .../src/wiring/createExtension.test.ts | 33 +++++ .../src/wiring/createExtension.ts | 121 ++++++++++++++---- .../src/wiring/createExtensionKind.ts | 4 +- 4 files changed, 194 insertions(+), 36 deletions(-) diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts index 72ce4b5e61..567e5efcad 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -26,6 +26,7 @@ import { coreExtensionData, createExtension, createExtensionInput, + createExtensionKind, createExtensionOverrides, } from '@backstage/frontend-plugin-api'; import { getComponentData } from '@backstage/core-plugin-api'; @@ -128,21 +129,80 @@ export function convertLegacyApp( }; }, }); - const CoreNavOverride = createExtension({ - namespace: 'app', - name: 'nav', - attachTo: { id: 'app/layout', input: 'nav' }, - output: {}, + + const CoreNavOverride = CurrentCoreNav.override({ + // namespace: 'app', + // name: 'nav', + // attachTo: { id: 'app/layout', input: 'nav' }, + // output: {}, factory: () => ({}), disabled: true, }); + createExtensionOverride({ + extension: CurrentCoreNav, + factory: () => null, + }); + const collectedRoutes = collectLegacyRoutes(routesEl); return [ ...collectedRoutes, createExtensionOverrides({ - extensions: [CoreLayoutOverride, CoreNavOverride], + extensions: [CoreNavOverride, CoreNavOverride], }), ]; } + +const EntityCardExtension = createExtensionKind({ + kind: 'entity-card', + attachTo: { id: 'entity-card', input: 'default' }, + inputs: { + loader: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + output: { + element: coreExtensionData.reactElement, + }, + factory({ inputs }, props: { title: string }) { + console.log(inputs.loader); + return { + element: React.createElement('h1'), + }; + }, +}); + +const GithubCard = EntityCardExtension.new({ + props: { + title: 'GitHub Card', + }, + factory({ inputs: { loader } }) { + console.log(loader); + return { + element: React.createElement('h2'), + }; + }, +}); + +GithubCard.override({ + attachTo: { id: 'entity-card', input: 'github' }, + inputs: { + loader: createExtensionInput( + { + element: coreExtensionData.reactElement, + }, + { singleton: false }, + ), + loader2: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + factory({ originalFactory, inputs }) { + inputs.loader2; + inputs.loader; + return originalFactory({ + inputsOverride: inputs, + }); + }, +}); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 42ab21db80..cfef69b0fe 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -291,4 +291,37 @@ describe('createExtension', () => { 'ExtensionDefinition{namespace=test,attachTo=root@default}', ); }); + + describe('override', () => { + it('should create an extension override', () => { + const extension = createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: { + foo: stringData, + }, + factory() { + return { + foo: 'bar', + }; + }, + }); + + const override = extension.override({ + attachTo: { + id: 'root', + input: 'default2', + }, + factory() { + return { + foo: 'baz', + }; + }, + }); + + expect(String(override)).toBe( + 'ExtensionDefinition{namespace=test,attachTo=root@default2}', + ); + }); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 28dc6a4e29..b56cc39fc9 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -99,7 +99,33 @@ export interface CreateExtensionOptions< } /** @public */ -export interface ExtensionDefinition { +export interface ExtensionDefinitionOverrides< + TOriginalConfig, + TOriginalInputs extends AnyExtensionInputMap, + TOverrideInputs extends AnyExtensionInputMap, +> { + readonly disabled?: boolean; + readonly attachTo?: { id: string; input: string }; + // TODO: inputs (only adding to the list? or redefine arity and superset of old type on existing input?) + // TODO: config (any merging needed on there?) + inputs?: TOverrideInputs; + factory?(options: { + node: AppNode; + config: TOriginalConfig; + inputs: Expand>; + originalFactory: (originalFactoryOptions?: { + inputsOverride?: ResolvedExtensionInputs< + TOriginalInputs & TOverrideInputs + >; + }) => ExtensionDataValues; + }): ExtensionDataValues; +} + +/** @public */ +export interface ExtensionDefinition< + TConfig, + TInputs extends AnyExtensionInputMap, +> { $$type: '@backstage/ExtensionDefinition'; readonly kind?: string; readonly namespace?: string; @@ -107,13 +133,18 @@ export interface ExtensionDefinition { readonly attachTo: { id: string; input: string }; readonly disabled: boolean; readonly configSchema?: PortableSchema; + override( + overrides: ExtensionDefinitionOverrides, + ): ExtensionDefinition; } /** @internal */ -export interface InternalExtensionDefinition - extends ExtensionDefinition { +export interface InternalExtensionDefinition< + TConfig, + TInputs extends AnyExtensionInputMap, +> extends ExtensionDefinition { readonly version: 'v1'; - readonly inputs: AnyExtensionInputMap; + readonly inputs: TInputs; readonly output: AnyExtensionDataMap; factory(context: { node: AppNode; @@ -123,10 +154,13 @@ export interface InternalExtensionDefinition } /** @internal */ -export function toInternalExtensionDefinition( - overrides: ExtensionDefinition, -): InternalExtensionDefinition { - const internal = overrides as InternalExtensionDefinition; +export function toInternalExtensionDefinition< + TConfig, + TInputs extends AnyExtensionInputMap, +>( + overrides: ExtensionDefinition, +): InternalExtensionDefinition { + const internal = overrides as InternalExtensionDefinition; if (internal.$$type !== '@backstage/ExtensionDefinition') { throw new Error( `Invalid extension definition instance, bad type '${internal.$$type}'`, @@ -147,38 +181,69 @@ export function createExtension< TConfig = never, >( options: CreateExtensionOptions, -): ExtensionDefinition { +): ExtensionDefinition { + const { + kind, + namespace, + name, + attachTo, + disabled = false, + inputs = {} as TInputs, + output, + configSchema, + factory, + } = options; + return { $$type: '@backstage/ExtensionDefinition', version: 'v1', - kind: options.kind, - namespace: options.namespace, - name: options.name, - attachTo: options.attachTo, - disabled: options.disabled ?? false, - inputs: options.inputs ?? {}, - output: options.output, - configSchema: options.configSchema, - factory({ inputs, ...rest }) { + kind, + namespace, + name, + attachTo, + disabled, + inputs, + output, + configSchema, + factory({ inputs: factoryInputs, ...rest }) { // TODO: Simplify this, but TS wouldn't infer the input type for some reason - return options.factory({ - inputs: inputs as Expand>, + return factory({ + inputs: factoryInputs as Expand>, ...rest, }); }, toString() { const parts: string[] = []; - if (options.kind) { - parts.push(`kind=${options.kind}`); + if (kind) { + parts.push(`kind=${kind}`); } - if (options.namespace) { - parts.push(`namespace=${options.namespace}`); + if (namespace) { + parts.push(`namespace=${namespace}`); } - if (options.name) { - parts.push(`name=${options.name}`); + if (name) { + parts.push(`name=${name}`); } - parts.push(`attachTo=${options.attachTo.id}@${options.attachTo.input}`); + parts.push(`attachTo=${attachTo.id}@${attachTo.input}`); return `ExtensionDefinition{${parts.join(',')}}`; }, - } as InternalExtensionDefinition; + override( + overrides: ExtensionDefinitionOverrides< + TConfig, + TInputs, + TOverrideInputs + >, + ): ExtensionDefinition { + return createExtension({ + kind, + namespace, + name, + attachTo: overrides.attachTo ?? attachTo, + disabled: overrides.disabled ?? disabled, + inputs: { ...inputs, ...overrides.inputs }, + output, + configSchema, + factory, + }); + }, + } as InternalExtensionDefinition; } diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts index 52bf711822..353fb8ab70 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { AppNode } from '../apis'; import { PortableSchema } from '../schema'; import { Expand } from '../types'; @@ -21,6 +20,7 @@ import { AnyExtensionDataMap, AnyExtensionInputMap, ExtensionDataValues, + ExtensionDefinition, ResolvedExtensionInputs, createExtension, } from './createExtension'; @@ -107,7 +107,7 @@ export class ExtensionKind< }, options: TOptions, ): Expand>; - }) { + }): ExtensionDefinition { return createExtension({ kind: this.options.kind, namespace: args.namespace ?? this.options.namespace, From 966cca176bc0506034499ce2cb622ebdffe2ca1b Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 26 Jun 2024 16:31:25 +0200 Subject: [PATCH 31/41] chore: tidying up Signed-off-by: blam Signed-off-by: blam --- .../core-compat-api/src/convertLegacyApp.ts | 72 +---- packages/frontend-plugin-api/api-report.md | 214 ++++++++++----- .../src/wiring/createExtension.test.ts | 33 --- .../src/wiring/createExtension.ts | 125 +++------ .../src/wiring/createExtensionKind.test.tsx | 164 ++++++++++++ .../src/wiring/createExtensionKind.ts | 245 ++++++++++++++---- .../frontend-plugin-api/src/wiring/index.ts | 4 +- 7 files changed, 550 insertions(+), 307 deletions(-) diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts index 567e5efcad..72ce4b5e61 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -26,7 +26,6 @@ import { coreExtensionData, createExtension, createExtensionInput, - createExtensionKind, createExtensionOverrides, } from '@backstage/frontend-plugin-api'; import { getComponentData } from '@backstage/core-plugin-api'; @@ -129,80 +128,21 @@ export function convertLegacyApp( }; }, }); - - const CoreNavOverride = CurrentCoreNav.override({ - // namespace: 'app', - // name: 'nav', - // attachTo: { id: 'app/layout', input: 'nav' }, - // output: {}, + const CoreNavOverride = createExtension({ + namespace: 'app', + name: 'nav', + attachTo: { id: 'app/layout', input: 'nav' }, + output: {}, factory: () => ({}), disabled: true, }); - createExtensionOverride({ - extension: CurrentCoreNav, - factory: () => null, - }); - const collectedRoutes = collectLegacyRoutes(routesEl); return [ ...collectedRoutes, createExtensionOverrides({ - extensions: [CoreNavOverride, CoreNavOverride], + extensions: [CoreLayoutOverride, CoreNavOverride], }), ]; } - -const EntityCardExtension = createExtensionKind({ - kind: 'entity-card', - attachTo: { id: 'entity-card', input: 'default' }, - inputs: { - loader: createExtensionInput({ - element: coreExtensionData.reactElement, - }), - }, - output: { - element: coreExtensionData.reactElement, - }, - factory({ inputs }, props: { title: string }) { - console.log(inputs.loader); - return { - element: React.createElement('h1'), - }; - }, -}); - -const GithubCard = EntityCardExtension.new({ - props: { - title: 'GitHub Card', - }, - factory({ inputs: { loader } }) { - console.log(loader); - return { - element: React.createElement('h2'), - }; - }, -}); - -GithubCard.override({ - attachTo: { id: 'entity-card', input: 'github' }, - inputs: { - loader: createExtensionInput( - { - element: coreExtensionData.reactElement, - }, - { singleton: false }, - ), - loader2: createExtensionInput({ - element: coreExtensionData.reactElement, - }), - }, - factory({ originalFactory, inputs }) { - inputs.loader2; - inputs.loader; - return originalFactory({ - inputsOverride: inputs, - }); - }, -}); diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 48718f5375..6a04d04cd6 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -529,9 +529,91 @@ export function createExtensionKind< TOutput extends AnyExtensionDataMap, TConfig, >( - options: ExtensionKindOptions, + options: CreateExtensionKindOptions, ): ExtensionKind; +// @public (undocumented) +export interface CreateExtensionKindInstanceOptions< + TOptions, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + attachTo?: { + id: string; + input: string; + }; + // (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled?: boolean; + // (undocumented) + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + originalFactory( + context?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + options?: TOptions, + ): Expand>; + }, + options: TOptions, + ): Expand>; + // (undocumented) + inputs?: TInputs; + // (undocumented) + name?: string; + // (undocumented) + namespace?: string; + // (undocumented) + options: TOptions; + // (undocumented) + output?: TOutput; +} + +// @public (undocumented) +export interface CreateExtensionKindOptions< + TOptions, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + attachTo: { + id: string; + input: string; + }; + // (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled?: boolean; + // (undocumented) + factory( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + }, + options: TOptions, + ): Expand>; + // (undocumented) + inputs?: TInputs; + // (undocumented) + kind: string; + // (undocumented) + name?: string; + // (undocumented) + namespace?: string; + // (undocumented) + output: TOutput; +} + // @public (undocumented) export interface CreateExtensionOptions< TOutput extends AnyExtensionDataMap, @@ -548,7 +630,7 @@ export interface CreateExtensionOptions< // (undocumented) disabled?: boolean; // (undocumented) - factory(context: { + factory(options: { node: AppNode; config: TConfig; inputs: Expand>; @@ -565,6 +647,51 @@ export interface CreateExtensionOptions< output: TOutput; } +// @public (undocumented) +export interface CreateExtensionOverrideKindInstanceOptions< + TOptions, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + attachTo?: { + id: string; + input: string; + }; + // (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled?: boolean; + // (undocumented) + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + originalFactory( + context?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + options?: TOptions, + ): Expand>; + }, + options: TOptions, + ): Expand>; + // (undocumented) + inputs?: TInputs; + // (undocumented) + name?: string; + // (undocumented) + namespace?: string; + // (undocumented) + options?: TOptions; + // (undocumented) + output?: TOutput; +} + // @public (undocumented) export function createExtensionOverrides( options: ExtensionOverridesOptions, @@ -937,75 +1064,26 @@ export class ExtensionKind< TOutput extends AnyExtensionDataMap, TConfig, >( - options: ExtensionKindOptions, + options: CreateExtensionKindOptions, ): ExtensionKind; // (undocumented) - new(args: { - namespace?: string; - name?: string; - attachTo?: { - id: string; - input: string; - }; - disabled?: boolean; - inputs?: TInputs; - output?: TOutput; - configSchema?: PortableSchema; - options: TOptions; - factory?( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - orignalFactory( - context?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - options?: TOptions, - ): Expand>; - }, - options: TOptions, - ): Expand>; - }): ExtensionDefinition; -} - -// @public (undocumented) -export interface ExtensionKindOptions< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - // (undocumented) - attachTo: { - id: string; - input: string; + new( + instanceProperties: CreateExtensionKindInstanceOptions< + TOptions, + TInputs, + TOutput, + TConfig + >, + ): ExtensionDefinition & { + override: ( + overrides: CreateExtensionOverrideKindInstanceOptions< + TOptions, + TInputs, + TOutput, + TConfig + >, + ) => ExtensionDefinition; }; - // (undocumented) - configSchema?: PortableSchema; - // (undocumented) - disabled?: boolean; - // (undocumented) - factory( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - }, - options: TOptions, - ): Expand>; - // (undocumented) - inputs?: TInputs; - // (undocumented) - kind: string; - // (undocumented) - name?: string; - // (undocumented) - namespace?: string; - // (undocumented) - output: TOutput; } // @public (undocumented) diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index cfef69b0fe..42ab21db80 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -291,37 +291,4 @@ describe('createExtension', () => { 'ExtensionDefinition{namespace=test,attachTo=root@default}', ); }); - - describe('override', () => { - it('should create an extension override', () => { - const extension = createExtension({ - namespace: 'test', - attachTo: { id: 'root', input: 'default' }, - output: { - foo: stringData, - }, - factory() { - return { - foo: 'bar', - }; - }, - }); - - const override = extension.override({ - attachTo: { - id: 'root', - input: 'default2', - }, - factory() { - return { - foo: 'baz', - }; - }, - }); - - expect(String(override)).toBe( - 'ExtensionDefinition{namespace=test,attachTo=root@default2}', - ); - }); - }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index b56cc39fc9..be76fbc069 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -91,7 +91,7 @@ export interface CreateExtensionOptions< inputs?: TInputs; output: TOutput; configSchema?: PortableSchema; - factory(context: { + factory(options: { node: AppNode; config: TConfig; inputs: Expand>; @@ -99,33 +99,7 @@ export interface CreateExtensionOptions< } /** @public */ -export interface ExtensionDefinitionOverrides< - TOriginalConfig, - TOriginalInputs extends AnyExtensionInputMap, - TOverrideInputs extends AnyExtensionInputMap, -> { - readonly disabled?: boolean; - readonly attachTo?: { id: string; input: string }; - // TODO: inputs (only adding to the list? or redefine arity and superset of old type on existing input?) - // TODO: config (any merging needed on there?) - inputs?: TOverrideInputs; - factory?(options: { - node: AppNode; - config: TOriginalConfig; - inputs: Expand>; - originalFactory: (originalFactoryOptions?: { - inputsOverride?: ResolvedExtensionInputs< - TOriginalInputs & TOverrideInputs - >; - }) => ExtensionDataValues; - }): ExtensionDataValues; -} - -/** @public */ -export interface ExtensionDefinition< - TConfig, - TInputs extends AnyExtensionInputMap, -> { +export interface ExtensionDefinition { $$type: '@backstage/ExtensionDefinition'; readonly kind?: string; readonly namespace?: string; @@ -133,20 +107,15 @@ export interface ExtensionDefinition< readonly attachTo: { id: string; input: string }; readonly disabled: boolean; readonly configSchema?: PortableSchema; - override( - overrides: ExtensionDefinitionOverrides, - ): ExtensionDefinition; } /** @internal */ -export interface InternalExtensionDefinition< - TConfig, - TInputs extends AnyExtensionInputMap, -> extends ExtensionDefinition { +export interface InternalExtensionDefinition + extends ExtensionDefinition { readonly version: 'v1'; - readonly inputs: TInputs; + readonly inputs: AnyExtensionInputMap; readonly output: AnyExtensionDataMap; - factory(context: { + factory(options: { node: AppNode; config: TConfig; inputs: ResolvedExtensionInputs; @@ -154,13 +123,10 @@ export interface InternalExtensionDefinition< } /** @internal */ -export function toInternalExtensionDefinition< - TConfig, - TInputs extends AnyExtensionInputMap, ->( - overrides: ExtensionDefinition, -): InternalExtensionDefinition { - const internal = overrides as InternalExtensionDefinition; +export function toInternalExtensionDefinition( + overrides: ExtensionDefinition, +): InternalExtensionDefinition { + const internal = overrides as InternalExtensionDefinition; if (internal.$$type !== '@backstage/ExtensionDefinition') { throw new Error( `Invalid extension definition instance, bad type '${internal.$$type}'`, @@ -181,69 +147,38 @@ export function createExtension< TConfig = never, >( options: CreateExtensionOptions, -): ExtensionDefinition { - const { - kind, - namespace, - name, - attachTo, - disabled = false, - inputs = {} as TInputs, - output, - configSchema, - factory, - } = options; - +): ExtensionDefinition { return { $$type: '@backstage/ExtensionDefinition', version: 'v1', - kind, - namespace, - name, - attachTo, - disabled, - inputs, - output, - configSchema, - factory({ inputs: factoryInputs, ...rest }) { + kind: options.kind, + namespace: options.namespace, + name: options.name, + attachTo: options.attachTo, + disabled: options.disabled ?? false, + inputs: options.inputs ?? {}, + output: options.output, + configSchema: options.configSchema, + factory({ inputs, ...rest }) { // TODO: Simplify this, but TS wouldn't infer the input type for some reason - return factory({ - inputs: factoryInputs as Expand>, + return options.factory({ + inputs: inputs as Expand>, ...rest, }); }, toString() { const parts: string[] = []; - if (kind) { - parts.push(`kind=${kind}`); + if (options.kind) { + parts.push(`kind=${options.kind}`); } - if (namespace) { - parts.push(`namespace=${namespace}`); + if (options.namespace) { + parts.push(`namespace=${options.namespace}`); } - if (name) { - parts.push(`name=${name}`); + if (options.name) { + parts.push(`name=${options.name}`); } - parts.push(`attachTo=${attachTo.id}@${attachTo.input}`); + parts.push(`attachTo=${options.attachTo.id}@${options.attachTo.input}`); return `ExtensionDefinition{${parts.join(',')}}`; }, - override( - overrides: ExtensionDefinitionOverrides< - TConfig, - TInputs, - TOverrideInputs - >, - ): ExtensionDefinition { - return createExtension({ - kind, - namespace, - name, - attachTo: overrides.attachTo ?? attachTo, - disabled: overrides.disabled ?? disabled, - inputs: { ...inputs, ...overrides.inputs }, - output, - configSchema, - factory, - }); - }, - } as InternalExtensionDefinition; + } as InternalExtensionDefinition; } diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx index 3bdcd3a7cc..30c3e6ca44 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx @@ -64,6 +64,7 @@ describe('createExtensionKind', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), version: 'v1', }); @@ -102,4 +103,167 @@ describe('createExtensionKind', () => { const { container } = createExtensionTester(extension).render(); expect(container.querySelector('h2')).toHaveTextContent('Hello, world!'); }); + + it('should allow calling of the default value from override', () => { + const TestExtension = createExtensionKind({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, options: { text: string }) { + return { + element:

{options.text}

, + }; + }, + }); + + const extension = TestExtension.new({ + name: 'my-extension', + options: { + text: 'Hello, world!', + }, + factory({ originalFactory }, options: { text: string }) { + const { element } = originalFactory(); + return { + element: ( +

+ {options.text} + {element} +

+ ), + }; + }, + }); + + expect(extension).toBeDefined(); + + const { container } = createExtensionTester(extension).render(); + + expect(container.querySelector('h2 h1')).toHaveTextContent('Hello, world!'); + }); + + it('should allow overriding options of the default value from override', () => { + const TestExtension = createExtensionKind({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, options: { text: string }) { + return { + element:

{options.text}

, + }; + }, + }); + + const extension = TestExtension.new({ + name: 'my-extension', + options: { + text: 'Hello, world!', + }, + factory({ originalFactory }, options: { text: string }) { + const { element } = originalFactory(undefined, { text: 'nothing!' }); + return { + element: ( +

+ {options.text} + {element} +

+ ), + }; + }, + }); + + expect(extension).toBeDefined(); + + const { container } = createExtensionTester(extension).render(); + + expect(container.querySelector('h2 h1')).toHaveTextContent('nothing!'); + }); + + describe('override', () => { + it('should allow overriding of the default factory', () => { + const TestExtension = createExtensionKind({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, options: { text: string }) { + return { + element:

{options.text}

, + }; + }, + }); + + const extension = TestExtension.new({ + name: 'my-extension', + options: { + text: 'Hello, world!', + }, + factory(_, options: { text: string }) { + return { + element:

{options.text}

, + }; + }, + }); + + const overridden = extension.override({ + factory({ originalFactory }, { text }) { + return { + element: ( +
+ {originalFactory().element} +

{text}

+
+ ), + }; + }, + }); + + const { container } = createExtensionTester(overridden).render(); + expect(container.querySelector('h2')).toHaveTextContent('Hello, world!'); + expect(container.querySelector('h3')).toHaveTextContent('Hello, world!'); + }); + }); + + it('should allow calling the kind factory if another factory is not defined in the instance', () => { + const TestExtension = createExtensionKind({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + factory(_, options: { text: string }) { + return { + element:

{options.text}

, + }; + }, + }); + + const extension = TestExtension.new({ + name: 'my-extension', + options: { + text: 'Hello, world!', + }, + }); + + const overridden = extension.override({ + factory({ originalFactory }, { text }) { + return { + element: ( +
+ {originalFactory().element} +

{text}

+
+ ), + }; + }, + }); + + const { container } = createExtensionTester(overridden).render(); + expect(container.querySelector('h1')).toHaveTextContent('Hello, world!'); + expect(container.querySelector('h3')).toHaveTextContent('Hello, world!'); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts index 353fb8ab70..9813c9e50c 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { AppNode } from '../apis'; import { PortableSchema } from '../schema'; import { Expand } from '../types'; @@ -52,6 +53,76 @@ export interface CreateExtensionKindOptions< ): Expand>; } +/** + * @public + */ +export interface CreateExtensionKindInstanceOptions< + TOptions, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + namespace?: string; + name?: string; + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + options: TOptions; + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + originalFactory( + context?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + options?: TOptions, + ): Expand>; + }, + options: TOptions, + ): Expand>; +} + +/** + * @public + */ +export interface CreateExtensionOverrideKindInstanceOptions< + TOptions, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + namespace?: string; + name?: string; + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + options?: TOptions; + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + originalFactory( + context?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + options?: TOptions, + ): Expand>; + }, + options: TOptions, + ): Expand>; +} + /** * TODO: should we export an interface instead of a concrete class? * @public @@ -74,7 +145,7 @@ export class ExtensionKind< } private constructor( - private readonly options: CreateExtensionKindOptions< + private readonly kindProperties: CreateExtensionKindOptions< TOptions, TInputs, TOutput, @@ -82,49 +153,41 @@ export class ExtensionKind< >, ) {} - public new(args: { - namespace?: string; - name?: string; - attachTo?: { id: string; input: string }; - disabled?: boolean; - inputs?: TInputs; - output?: TOutput; - configSchema?: PortableSchema; - options: TOptions; - factory?( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - orignalFactory( - context?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - options?: TOptions, - ): Expand>; - }, - options: TOptions, - ): Expand>; - }): ExtensionDefinition { - return createExtension({ - kind: this.options.kind, - namespace: args.namespace ?? this.options.namespace, - name: args.name ?? this.options.name, - attachTo: args.attachTo ?? this.options.attachTo, - disabled: args.disabled ?? this.options.disabled, - inputs: args.inputs ?? this.options.inputs, - output: args.output ?? this.options.output, - configSchema: args.configSchema ?? this.options.configSchema, // TODO: some config merging or smth + public new( + instanceProperties: CreateExtensionKindInstanceOptions< + TOptions, + TInputs, + TOutput, + TConfig + >, + ): ExtensionDefinition & { + override: ( + overrides: CreateExtensionOverrideKindInstanceOptions< + TOptions, + TInputs, + TOutput, + TConfig + >, + ) => ExtensionDefinition; + } { + const extension = createExtension({ + kind: this.kindProperties.kind, + namespace: instanceProperties.namespace ?? this.kindProperties.namespace, + name: instanceProperties.name ?? this.kindProperties.name, + attachTo: instanceProperties.attachTo ?? this.kindProperties.attachTo, + disabled: instanceProperties.disabled ?? this.kindProperties.disabled, + inputs: instanceProperties.inputs ?? this.kindProperties.inputs, + output: instanceProperties.output ?? this.kindProperties.output, + configSchema: + instanceProperties.configSchema ?? this.kindProperties.configSchema, // TODO: some config merging or smth factory: ({ node, config, inputs }) => { - if (args.factory) { - return args.factory( + if (instanceProperties.factory) { + return instanceProperties.factory( { node, config, inputs, - orignalFactory: ( + originalFactory: ( innerContext?: { node?: AppNode; config?: TConfig; @@ -132,29 +195,123 @@ export class ExtensionKind< }, innerOptions?: TOptions, ) => - this.options.factory( + this.kindProperties.factory( { node: innerContext?.node ?? node, config: innerContext?.config ?? config, inputs: innerContext?.inputs ?? inputs, }, - innerOptions ?? args.options, + innerOptions ?? instanceProperties.options, ), }, - args.options, + instanceProperties.options, ); } - return this.options.factory( + return this.kindProperties.factory( { node, config, inputs, }, - args.options, + instanceProperties.options, ); }, }); + + return { + ...extension, + override: overrides => { + return createExtension({ + kind: this.kindProperties.kind, + namespace: + overrides.namespace ?? + instanceProperties.namespace ?? + this.kindProperties.namespace, + name: + overrides.name ?? + instanceProperties.name ?? + this.kindProperties.name, + attachTo: + overrides.attachTo ?? + instanceProperties.attachTo ?? + this.kindProperties.attachTo, + disabled: + overrides.disabled ?? + instanceProperties.disabled ?? + this.kindProperties.disabled, + inputs: + overrides.inputs ?? + instanceProperties.inputs ?? + this.kindProperties.inputs, + output: + overrides.output ?? + instanceProperties.output ?? + this.kindProperties.output, + configSchema: + overrides.configSchema ?? + instanceProperties.configSchema ?? + this.kindProperties.configSchema, // TODO: some config merging or smth + factory: ({ node, config, inputs }) => { + if (overrides.factory) { + return overrides.factory( + { + node, + config, + inputs, + originalFactory: ( + innerContext?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + innerOptions?: TOptions, + ) => + instanceProperties.factory?.( + { + node: innerContext?.node ?? node, + config: innerContext?.config ?? config, + inputs: innerContext?.inputs ?? inputs, + originalFactory: this.kindProperties.factory, + }, + innerOptions ?? instanceProperties.options, + ) ?? + this.kindProperties.factory( + { + node, + config, + inputs, + }, + innerOptions ?? instanceProperties.options, + ), + }, + instanceProperties.options, + ); + } + + return ( + instanceProperties.factory?.( + { + node, + config, + inputs, + originalFactory: this.kindProperties.factory, + }, + instanceProperties.options, + ) ?? + this.kindProperties.factory( + { + node, + config, + inputs, + }, + instanceProperties.options, + ) + ); + }, + }); + }, + }; } } diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 8fe9dc3a54..a312c0f06f 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -49,7 +49,9 @@ export { type FrontendFeature, } from './types'; export { - type CreateExtensionKindOptions as ExtensionKindOptions, + type CreateExtensionKindOptions, + type CreateExtensionKindInstanceOptions, + type CreateExtensionOverrideKindInstanceOptions, ExtensionKind, createExtensionKind, } from './createExtensionKind'; From 6b6cbdaac5cf05702210e9c24ccb4272c3ba3e88 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Thu, 18 Jul 2024 13:07:02 +0200 Subject: [PATCH 32/41] Revert "NFE: Make it possible to override existing extension instances" Signed-off-by: blam --- packages/frontend-plugin-api/api-report.md | 214 +++++---------- .../src/wiring/createExtension.ts | 4 +- .../src/wiring/createExtensionKind.test.tsx | 164 ------------ .../src/wiring/createExtensionKind.ts | 245 ++++-------------- .../frontend-plugin-api/src/wiring/index.ts | 4 +- 5 files changed, 115 insertions(+), 516 deletions(-) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 6a04d04cd6..48718f5375 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -529,91 +529,9 @@ export function createExtensionKind< TOutput extends AnyExtensionDataMap, TConfig, >( - options: CreateExtensionKindOptions, + options: ExtensionKindOptions, ): ExtensionKind; -// @public (undocumented) -export interface CreateExtensionKindInstanceOptions< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - // (undocumented) - attachTo?: { - id: string; - input: string; - }; - // (undocumented) - configSchema?: PortableSchema; - // (undocumented) - disabled?: boolean; - // (undocumented) - factory?( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - originalFactory( - context?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - options?: TOptions, - ): Expand>; - }, - options: TOptions, - ): Expand>; - // (undocumented) - inputs?: TInputs; - // (undocumented) - name?: string; - // (undocumented) - namespace?: string; - // (undocumented) - options: TOptions; - // (undocumented) - output?: TOutput; -} - -// @public (undocumented) -export interface CreateExtensionKindOptions< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - // (undocumented) - attachTo: { - id: string; - input: string; - }; - // (undocumented) - configSchema?: PortableSchema; - // (undocumented) - disabled?: boolean; - // (undocumented) - factory( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - }, - options: TOptions, - ): Expand>; - // (undocumented) - inputs?: TInputs; - // (undocumented) - kind: string; - // (undocumented) - name?: string; - // (undocumented) - namespace?: string; - // (undocumented) - output: TOutput; -} - // @public (undocumented) export interface CreateExtensionOptions< TOutput extends AnyExtensionDataMap, @@ -630,7 +548,7 @@ export interface CreateExtensionOptions< // (undocumented) disabled?: boolean; // (undocumented) - factory(options: { + factory(context: { node: AppNode; config: TConfig; inputs: Expand>; @@ -647,51 +565,6 @@ export interface CreateExtensionOptions< output: TOutput; } -// @public (undocumented) -export interface CreateExtensionOverrideKindInstanceOptions< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - // (undocumented) - attachTo?: { - id: string; - input: string; - }; - // (undocumented) - configSchema?: PortableSchema; - // (undocumented) - disabled?: boolean; - // (undocumented) - factory?( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - originalFactory( - context?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - options?: TOptions, - ): Expand>; - }, - options: TOptions, - ): Expand>; - // (undocumented) - inputs?: TInputs; - // (undocumented) - name?: string; - // (undocumented) - namespace?: string; - // (undocumented) - options?: TOptions; - // (undocumented) - output?: TOutput; -} - // @public (undocumented) export function createExtensionOverrides( options: ExtensionOverridesOptions, @@ -1064,26 +937,75 @@ export class ExtensionKind< TOutput extends AnyExtensionDataMap, TConfig, >( - options: CreateExtensionKindOptions, + options: ExtensionKindOptions, ): ExtensionKind; // (undocumented) - new( - instanceProperties: CreateExtensionKindInstanceOptions< - TOptions, - TInputs, - TOutput, - TConfig - >, - ): ExtensionDefinition & { - override: ( - overrides: CreateExtensionOverrideKindInstanceOptions< - TOptions, - TInputs, - TOutput, - TConfig - >, - ) => ExtensionDefinition; + new(args: { + namespace?: string; + name?: string; + attachTo?: { + id: string; + input: string; + }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + options: TOptions; + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + orignalFactory( + context?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + options?: TOptions, + ): Expand>; + }, + options: TOptions, + ): Expand>; + }): ExtensionDefinition; +} + +// @public (undocumented) +export interface ExtensionKindOptions< + TOptions, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + attachTo: { + id: string; + input: string; }; + // (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled?: boolean; + // (undocumented) + factory( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + }, + options: TOptions, + ): Expand>; + // (undocumented) + inputs?: TInputs; + // (undocumented) + kind: string; + // (undocumented) + name?: string; + // (undocumented) + namespace?: string; + // (undocumented) + output: TOutput; } // @public (undocumented) diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index be76fbc069..28dc6a4e29 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -91,7 +91,7 @@ export interface CreateExtensionOptions< inputs?: TInputs; output: TOutput; configSchema?: PortableSchema; - factory(options: { + factory(context: { node: AppNode; config: TConfig; inputs: Expand>; @@ -115,7 +115,7 @@ export interface InternalExtensionDefinition readonly version: 'v1'; readonly inputs: AnyExtensionInputMap; readonly output: AnyExtensionDataMap; - factory(options: { + factory(context: { node: AppNode; config: TConfig; inputs: ResolvedExtensionInputs; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx index 30c3e6ca44..3bdcd3a7cc 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx @@ -64,7 +64,6 @@ describe('createExtensionKind', () => { }, factory: expect.any(Function), toString: expect.any(Function), - override: expect.any(Function), version: 'v1', }); @@ -103,167 +102,4 @@ describe('createExtensionKind', () => { const { container } = createExtensionTester(extension).render(); expect(container.querySelector('h2')).toHaveTextContent('Hello, world!'); }); - - it('should allow calling of the default value from override', () => { - const TestExtension = createExtensionKind({ - kind: 'test-extension', - attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, - factory(_, options: { text: string }) { - return { - element:

{options.text}

, - }; - }, - }); - - const extension = TestExtension.new({ - name: 'my-extension', - options: { - text: 'Hello, world!', - }, - factory({ originalFactory }, options: { text: string }) { - const { element } = originalFactory(); - return { - element: ( -

- {options.text} - {element} -

- ), - }; - }, - }); - - expect(extension).toBeDefined(); - - const { container } = createExtensionTester(extension).render(); - - expect(container.querySelector('h2 h1')).toHaveTextContent('Hello, world!'); - }); - - it('should allow overriding options of the default value from override', () => { - const TestExtension = createExtensionKind({ - kind: 'test-extension', - attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, - factory(_, options: { text: string }) { - return { - element:

{options.text}

, - }; - }, - }); - - const extension = TestExtension.new({ - name: 'my-extension', - options: { - text: 'Hello, world!', - }, - factory({ originalFactory }, options: { text: string }) { - const { element } = originalFactory(undefined, { text: 'nothing!' }); - return { - element: ( -

- {options.text} - {element} -

- ), - }; - }, - }); - - expect(extension).toBeDefined(); - - const { container } = createExtensionTester(extension).render(); - - expect(container.querySelector('h2 h1')).toHaveTextContent('nothing!'); - }); - - describe('override', () => { - it('should allow overriding of the default factory', () => { - const TestExtension = createExtensionKind({ - kind: 'test-extension', - attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, - factory(_, options: { text: string }) { - return { - element:

{options.text}

, - }; - }, - }); - - const extension = TestExtension.new({ - name: 'my-extension', - options: { - text: 'Hello, world!', - }, - factory(_, options: { text: string }) { - return { - element:

{options.text}

, - }; - }, - }); - - const overridden = extension.override({ - factory({ originalFactory }, { text }) { - return { - element: ( -
- {originalFactory().element} -

{text}

-
- ), - }; - }, - }); - - const { container } = createExtensionTester(overridden).render(); - expect(container.querySelector('h2')).toHaveTextContent('Hello, world!'); - expect(container.querySelector('h3')).toHaveTextContent('Hello, world!'); - }); - }); - - it('should allow calling the kind factory if another factory is not defined in the instance', () => { - const TestExtension = createExtensionKind({ - kind: 'test-extension', - attachTo: { id: 'test', input: 'default' }, - output: { - element: coreExtensionData.reactElement, - }, - factory(_, options: { text: string }) { - return { - element:

{options.text}

, - }; - }, - }); - - const extension = TestExtension.new({ - name: 'my-extension', - options: { - text: 'Hello, world!', - }, - }); - - const overridden = extension.override({ - factory({ originalFactory }, { text }) { - return { - element: ( -
- {originalFactory().element} -

{text}

-
- ), - }; - }, - }); - - const { container } = createExtensionTester(overridden).render(); - expect(container.querySelector('h1')).toHaveTextContent('Hello, world!'); - expect(container.querySelector('h3')).toHaveTextContent('Hello, world!'); - }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts index 9813c9e50c..52bf711822 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts @@ -21,7 +21,6 @@ import { AnyExtensionDataMap, AnyExtensionInputMap, ExtensionDataValues, - ExtensionDefinition, ResolvedExtensionInputs, createExtension, } from './createExtension'; @@ -53,76 +52,6 @@ export interface CreateExtensionKindOptions< ): Expand>; } -/** - * @public - */ -export interface CreateExtensionKindInstanceOptions< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - namespace?: string; - name?: string; - attachTo?: { id: string; input: string }; - disabled?: boolean; - inputs?: TInputs; - output?: TOutput; - configSchema?: PortableSchema; - options: TOptions; - factory?( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - originalFactory( - context?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - options?: TOptions, - ): Expand>; - }, - options: TOptions, - ): Expand>; -} - -/** - * @public - */ -export interface CreateExtensionOverrideKindInstanceOptions< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - namespace?: string; - name?: string; - attachTo?: { id: string; input: string }; - disabled?: boolean; - inputs?: TInputs; - output?: TOutput; - configSchema?: PortableSchema; - options?: TOptions; - factory?( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - originalFactory( - context?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - options?: TOptions, - ): Expand>; - }, - options: TOptions, - ): Expand>; -} - /** * TODO: should we export an interface instead of a concrete class? * @public @@ -145,7 +74,7 @@ export class ExtensionKind< } private constructor( - private readonly kindProperties: CreateExtensionKindOptions< + private readonly options: CreateExtensionKindOptions< TOptions, TInputs, TOutput, @@ -153,41 +82,49 @@ export class ExtensionKind< >, ) {} - public new( - instanceProperties: CreateExtensionKindInstanceOptions< - TOptions, - TInputs, - TOutput, - TConfig - >, - ): ExtensionDefinition & { - override: ( - overrides: CreateExtensionOverrideKindInstanceOptions< - TOptions, - TInputs, - TOutput, - TConfig - >, - ) => ExtensionDefinition; - } { - const extension = createExtension({ - kind: this.kindProperties.kind, - namespace: instanceProperties.namespace ?? this.kindProperties.namespace, - name: instanceProperties.name ?? this.kindProperties.name, - attachTo: instanceProperties.attachTo ?? this.kindProperties.attachTo, - disabled: instanceProperties.disabled ?? this.kindProperties.disabled, - inputs: instanceProperties.inputs ?? this.kindProperties.inputs, - output: instanceProperties.output ?? this.kindProperties.output, - configSchema: - instanceProperties.configSchema ?? this.kindProperties.configSchema, // TODO: some config merging or smth + public new(args: { + namespace?: string; + name?: string; + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + options: TOptions; + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + orignalFactory( + context?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + options?: TOptions, + ): Expand>; + }, + options: TOptions, + ): Expand>; + }) { + return createExtension({ + kind: this.options.kind, + namespace: args.namespace ?? this.options.namespace, + name: args.name ?? this.options.name, + attachTo: args.attachTo ?? this.options.attachTo, + disabled: args.disabled ?? this.options.disabled, + inputs: args.inputs ?? this.options.inputs, + output: args.output ?? this.options.output, + configSchema: args.configSchema ?? this.options.configSchema, // TODO: some config merging or smth factory: ({ node, config, inputs }) => { - if (instanceProperties.factory) { - return instanceProperties.factory( + if (args.factory) { + return args.factory( { node, config, inputs, - originalFactory: ( + orignalFactory: ( innerContext?: { node?: AppNode; config?: TConfig; @@ -195,123 +132,29 @@ export class ExtensionKind< }, innerOptions?: TOptions, ) => - this.kindProperties.factory( + this.options.factory( { node: innerContext?.node ?? node, config: innerContext?.config ?? config, inputs: innerContext?.inputs ?? inputs, }, - innerOptions ?? instanceProperties.options, + innerOptions ?? args.options, ), }, - instanceProperties.options, + args.options, ); } - return this.kindProperties.factory( + return this.options.factory( { node, config, inputs, }, - instanceProperties.options, + args.options, ); }, }); - - return { - ...extension, - override: overrides => { - return createExtension({ - kind: this.kindProperties.kind, - namespace: - overrides.namespace ?? - instanceProperties.namespace ?? - this.kindProperties.namespace, - name: - overrides.name ?? - instanceProperties.name ?? - this.kindProperties.name, - attachTo: - overrides.attachTo ?? - instanceProperties.attachTo ?? - this.kindProperties.attachTo, - disabled: - overrides.disabled ?? - instanceProperties.disabled ?? - this.kindProperties.disabled, - inputs: - overrides.inputs ?? - instanceProperties.inputs ?? - this.kindProperties.inputs, - output: - overrides.output ?? - instanceProperties.output ?? - this.kindProperties.output, - configSchema: - overrides.configSchema ?? - instanceProperties.configSchema ?? - this.kindProperties.configSchema, // TODO: some config merging or smth - factory: ({ node, config, inputs }) => { - if (overrides.factory) { - return overrides.factory( - { - node, - config, - inputs, - originalFactory: ( - innerContext?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - innerOptions?: TOptions, - ) => - instanceProperties.factory?.( - { - node: innerContext?.node ?? node, - config: innerContext?.config ?? config, - inputs: innerContext?.inputs ?? inputs, - originalFactory: this.kindProperties.factory, - }, - innerOptions ?? instanceProperties.options, - ) ?? - this.kindProperties.factory( - { - node, - config, - inputs, - }, - innerOptions ?? instanceProperties.options, - ), - }, - instanceProperties.options, - ); - } - - return ( - instanceProperties.factory?.( - { - node, - config, - inputs, - originalFactory: this.kindProperties.factory, - }, - instanceProperties.options, - ) ?? - this.kindProperties.factory( - { - node, - config, - inputs, - }, - instanceProperties.options, - ) - ); - }, - }); - }, - }; } } diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index a312c0f06f..8fe9dc3a54 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -49,9 +49,7 @@ export { type FrontendFeature, } from './types'; export { - type CreateExtensionKindOptions, - type CreateExtensionKindInstanceOptions, - type CreateExtensionOverrideKindInstanceOptions, + type CreateExtensionKindOptions as ExtensionKindOptions, ExtensionKind, createExtensionKind, } from './createExtensionKind'; From b20c067c61abcfff3f158b7f4b6c5857dfbf347a Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 13:27:09 +0200 Subject: [PATCH 33/41] chore: fixing some code review comments Signed-off-by: blam --- .changeset/dry-squids-tap.md | 8 +- .../src/wiring/createExtensionKind.ts | 108 +++++++++++------- 2 files changed, 70 insertions(+), 46 deletions(-) diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md index 9bd835d371..5a865395c0 100644 --- a/.changeset/dry-squids-tap.md +++ b/.changeset/dry-squids-tap.md @@ -14,17 +14,17 @@ const TestExtensionKind = createExtensionKind({ output: { element: coreExtensionData.reactElement, }, - factory(_, options: { text: string }) { + factory(_, params: { text: string }) { return { - element:

{options.text}

, + element:

{params.text}

, }; }, }); // create an instance of the extension kind with props -const testExtension = TestExtensionKind.new({ +const testExtension = TestExtensionKind.create({ name: 'foo', - options: { + params: { text: 'Hello World', }, }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts index 52bf711822..1212365712 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts @@ -21,6 +21,7 @@ import { AnyExtensionDataMap, AnyExtensionInputMap, ExtensionDataValues, + ExtensionDefinition, ResolvedExtensionInputs, createExtension, } from './createExtension'; @@ -29,14 +30,13 @@ import { * @public */ export interface CreateExtensionKindOptions< - TOptions, + TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, > { kind: string; namespace?: string; - name?: string; attachTo: { id: string; input: string }; disabled?: boolean; inputs?: TInputs; @@ -48,41 +48,20 @@ export interface CreateExtensionKindOptions< config: TConfig; inputs: Expand>; }, - options: TOptions, + params: TParams, ): Expand>; } /** - * TODO: should we export an interface instead of a concrete class? * @public */ -export class ExtensionKind< - TOptions, +export interface ExtensionKind< + TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, > { - static create< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, - >( - options: CreateExtensionKindOptions, - ): ExtensionKind { - return new ExtensionKind(options); - } - - private constructor( - private readonly options: CreateExtensionKindOptions< - TOptions, - TInputs, - TOutput, - TConfig - >, - ) {} - - public new(args: { + create(args: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; @@ -90,7 +69,7 @@ export class ExtensionKind< inputs?: TInputs; output?: TOutput; configSchema?: PortableSchema; - options: TOptions; + params: TParams; factory?( context: { node: AppNode; @@ -98,20 +77,64 @@ export class ExtensionKind< inputs: Expand>; orignalFactory( context?: { - node?: AppNode; config?: TConfig; inputs?: Expand>; }, - options?: TOptions, + params?: TParams, ): Expand>; }, - options: TOptions, + params: TParams, ): Expand>; - }) { + }): ExtensionDefinition; +} + +/** + * @internal + */ +class ExtensionKindImpl< + TParams, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + constructor( + private readonly options: CreateExtensionKindOptions< + TParams, + TInputs, + TOutput, + TConfig + >, + ) {} + + public create(args: { + namespace?: string; + name?: string; + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + params: TParams; + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + orignalFactory( + context?: { + config?: TConfig; + inputs?: Expand>; + }, + params?: TParams, + ): Expand>; + }, + params: TParams, + ): Expand>; + }): ExtensionDefinition { return createExtension({ kind: this.options.kind, namespace: args.namespace ?? this.options.namespace, - name: args.name ?? this.options.name, + name: args.name, attachTo: args.attachTo ?? this.options.attachTo, disabled: args.disabled ?? this.options.disabled, inputs: args.inputs ?? this.options.inputs, @@ -126,22 +149,21 @@ export class ExtensionKind< inputs, orignalFactory: ( innerContext?: { - node?: AppNode; config?: TConfig; inputs?: Expand>; }, - innerOptions?: TOptions, + innerParams?: TParams, ) => this.options.factory( { - node: innerContext?.node ?? node, + node, config: innerContext?.config ?? config, inputs: innerContext?.inputs ?? inputs, }, - innerOptions ?? args.options, + innerParams ?? args.params, ), }, - args.options, + args.params, ); } @@ -151,7 +173,7 @@ export class ExtensionKind< config, inputs, }, - args.options, + args.params, ); }, }); @@ -165,10 +187,12 @@ export class ExtensionKind< * @public */ export function createExtensionKind< - TOptions, + TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, ->(options: CreateExtensionKindOptions) { - return ExtensionKind.create(options); +>( + options: CreateExtensionKindOptions, +): ExtensionKind { + return new ExtensionKindImpl(options); } From 4e42e5112cd692ab40696b3970e016cba94b4456 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 13:50:26 +0200 Subject: [PATCH 34/41] chore: smaller renames and code review Signed-off-by: blam --- .changeset/dry-squids-tap.md | 6 ++-- .../architecture/08-naming-patterns.md | 20 ++++++------- ....tsx => createExtensionBlueprint.test.tsx} | 30 +++++++++---------- ...ionKind.ts => createExtensionBlueprint.ts} | 22 +++++++------- .../frontend-plugin-api/src/wiring/index.ts | 8 ++--- 5 files changed, 43 insertions(+), 43 deletions(-) rename packages/frontend-plugin-api/src/wiring/{createExtensionKind.test.tsx => createExtensionBlueprint.test.tsx} (77%) rename packages/frontend-plugin-api/src/wiring/{createExtensionKind.ts => createExtensionBlueprint.ts} (90%) diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md index 5a865395c0..879ecb5d14 100644 --- a/.changeset/dry-squids-tap.md +++ b/.changeset/dry-squids-tap.md @@ -8,8 +8,8 @@ This allows the creation of extension with the following pattern: ```tsx // create the extension kind -const TestExtensionKind = createExtensionKind({ - kind: 'test-extension', +const EntityCardBlueprint = createExtensionBlueprint({ + kind: 'entity-card', attachTo: { id: 'test', input: 'default' }, output: { element: coreExtensionData.reactElement, @@ -22,7 +22,7 @@ const TestExtensionKind = createExtensionKind({ }); // create an instance of the extension kind with props -const testExtension = TestExtensionKind.create({ +const testExtension = EntityCardBlueprint.make({ name: 'foo', params: { text: 'Hello World', diff --git a/docs/frontend-system/architecture/08-naming-patterns.md b/docs/frontend-system/architecture/08-naming-patterns.md index 0916d9fd51..a17525f3ce 100644 --- a/docs/frontend-system/architecture/08-naming-patterns.md +++ b/docs/frontend-system/architecture/08-naming-patterns.md @@ -38,33 +38,31 @@ Note that while we use this naming pattern for the plugin instance this is only | Description | Pattern | Examples | | ----------- | ------------------------------- | ------------------------------------------------------------------- | -| Creator | `createExtension` | `createPageExtension`, `createEntityCardExtension` | +| Blueprint | `Blueprint` | `PageBlueprint`, `EntityCardBlueprint` | | ID | `[:][/]` | `'core.nav'`, `'page:user-settings'`, `'entity-card:catalog/about'` | | Symbol | `[][]` | `coreNav`, `userSettingsPage`, `catalogAboutEntityCard` | -When you create a new extension you never provide the ID directly. Instead, you indirectly or directly provide the kind, namespace, and name parts that make up the ID. The kind is always provided by the extension creator function used to create the extension, the only exception is if you use `createExtension` directly. Any extension that is provided by a plugin will by default have its namespace set to the plugin ID, so you generally only need to provide an explicit namespace if you want to override an existing extension. The name is also optional, and primarily used to distinguish between multiple extensions of the same kind and namespace. If a plugin doesn't need to distinguish between different extensions of the same kind, the name can be omitted. +When you create a new extension you never provide the ID directly. Instead, you indirectly or directly provide the kind, namespace, and name parts that make up the ID. The kind is always provided by the blueprint creator, the only exception is if you use `createExtension` directly. Any extension that is provided by a plugin will by default have its namespace set to the plugin ID, so you generally only need to provide an explicit namespace if you want to override an existing extension. The name is also optional, and primarily used to distinguish between multiple extensions of the same kind and namespace. If a plugin doesn't need to distinguish between different extensions of the same kind, the name can be omitted. Example: ```ts -// This is an extension creator that is used to create an extension of the 'page' kind. -export function createPageExtension(options) { - return createExtension({ - kind: 'page', // Kinds are kebab-case - // ...options - }); -} +// This is an extension blueprint that is used to create an extension of the 'page' kind. +export const PageBlueprint = createExtensionBlueprint({ + kind: 'page', + // ... +}); // The namespace is inferred from the plugin ID, in this case 'catalog' // The final ID for this extension will be 'page:catalog/entity' -const catalogEntityPage = createPageExtension({ +const catalogEntityPage = PageBlueprint.make({ name: 'entity', // ... }); // The name is omitted, because the catalog plugin only provides a single extension of this kind // The final ID for this extension will be 'search-result-list-item:catalog' -const catalogSearchResultListItem = createSearchResultListItemExtension({ +const catalogSearchResultListItem = SearchResultListItemBlueprint.make({ // ... }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx similarity index 77% rename from packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx rename to packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index 3bdcd3a7cc..b3e926e57c 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -16,27 +16,27 @@ import React from 'react'; import { coreExtensionData } from './coreExtensionData'; -import { createExtensionKind } from './createExtensionKind'; +import { createExtensionBlueprint } from './createExtensionBlueprint'; import { createExtensionTester } from '@backstage/frontend-test-utils'; -describe('createExtensionKind', () => { - it('should allow creation of extension kinds', () => { - const TestExtension = createExtensionKind({ +describe('createExtensionBlueprint', () => { + it('should allow creation of extension blueprints', () => { + const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, output: { element: coreExtensionData.reactElement, }, - factory(_, options: { text: string }) { + factory(_, params: { text: string }) { return { - element:

{options.text}

, + element:

{params.text}

, }; }, }); - const extension = TestExtension.new({ + const extension = TestExtensionBlueprint.make({ name: 'my-extension', - options: { + params: { text: 'Hello, world!', }, }); @@ -72,27 +72,27 @@ describe('createExtensionKind', () => { }); it('should allow overriding of the default factory', () => { - const TestExtension = createExtensionKind({ + const TestExtensionBlueprint = createExtensionBlueprint({ kind: 'test-extension', attachTo: { id: 'test', input: 'default' }, output: { element: coreExtensionData.reactElement, }, - factory(_, options: { text: string }) { + factory(_, params: { text: string }) { return { - element:

{options.text}

, + element:

{params.text}

, }; }, }); - const extension = TestExtension.new({ + const extension = TestExtensionBlueprint.make({ name: 'my-extension', - options: { + params: { text: 'Hello, world!', }, - factory(_, options: { text: string }) { + factory(_, params: { text: string }) { return { - element:

{options.text}

, + element:

{params.text}

, }; }, }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts similarity index 90% rename from packages/frontend-plugin-api/src/wiring/createExtensionKind.ts rename to packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 1212365712..74d2eeadf1 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionKind.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -29,7 +29,7 @@ import { /** * @public */ -export interface CreateExtensionKindOptions< +export interface CreateExtensionBlueprintOptions< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, @@ -55,13 +55,13 @@ export interface CreateExtensionKindOptions< /** * @public */ -export interface ExtensionKind< +export interface ExtensionBlueprint< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, > { - create(args: { + make(args: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; @@ -77,6 +77,7 @@ export interface ExtensionKind< inputs: Expand>; orignalFactory( context?: { + node?: AppNode; config?: TConfig; inputs?: Expand>; }, @@ -91,14 +92,14 @@ export interface ExtensionKind< /** * @internal */ -class ExtensionKindImpl< +class ExtensionBlueprintImpl< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, > { constructor( - private readonly options: CreateExtensionKindOptions< + private readonly options: CreateExtensionBlueprintOptions< TParams, TInputs, TOutput, @@ -106,7 +107,7 @@ class ExtensionKindImpl< >, ) {} - public create(args: { + public make(args: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; @@ -122,6 +123,7 @@ class ExtensionKindImpl< inputs: Expand>; orignalFactory( context?: { + node?: AppNode; config?: TConfig; inputs?: Expand>; }, @@ -186,13 +188,13 @@ class ExtensionKindImpl< * * @public */ -export function createExtensionKind< +export function createExtensionBlueprint< TParams, TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, >( - options: CreateExtensionKindOptions, -): ExtensionKind { - return new ExtensionKindImpl(options); + options: CreateExtensionBlueprintOptions, +): ExtensionBlueprint { + return new ExtensionBlueprintImpl(options); } diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 8fe9dc3a54..6d20368225 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -49,7 +49,7 @@ export { type FrontendFeature, } from './types'; export { - type CreateExtensionKindOptions as ExtensionKindOptions, - ExtensionKind, - createExtensionKind, -} from './createExtensionKind'; + type CreateExtensionBlueprintOptions, + type ExtensionBlueprint, + createExtensionBlueprint, +} from './createExtensionBlueprint'; From 9b89b82f66b500dabc5493a51d2fd1144e4828a4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 18 Jul 2024 12:08:15 +0200 Subject: [PATCH 35/41] frontend-plugin-api: infer ExtensionBoundary routable prop from outputs Signed-off-by: Patrik Oldsberg --- .changeset/early-trees-dance.md | 5 ++ .changeset/mighty-dolls-retire.md | 5 ++ .../architecture/03-extensions.md | 4 +- packages/frontend-plugin-api/api-report.md | 1 - .../src/components/ExtensionBoundary.test.tsx | 72 ++++++++++++++++--- .../src/components/ExtensionBoundary.tsx | 14 +++- .../src/extensions/createPageExtension.tsx | 2 +- plugins/catalog-react/src/alpha.tsx | 2 +- 8 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 .changeset/early-trees-dance.md create mode 100644 .changeset/mighty-dolls-retire.md diff --git a/.changeset/early-trees-dance.md b/.changeset/early-trees-dance.md new file mode 100644 index 0000000000..5422bd2872 --- /dev/null +++ b/.changeset/early-trees-dance.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +The `ExtensionBoundary` now by default infers whether its routable from whether it outputs a route path. diff --git a/.changeset/mighty-dolls-retire.md b/.changeset/mighty-dolls-retire.md new file mode 100644 index 0000000000..b31c443a38 --- /dev/null +++ b/.changeset/mighty-dolls-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Internal refactor to remove unnecessary `routable` prop in the implementation of the `createEntityContentExtension` alpha export. diff --git a/docs/frontend-system/architecture/03-extensions.md b/docs/frontend-system/architecture/03-extensions.md index 19345e0b26..cdc73a17ec 100644 --- a/docs/frontend-system/architecture/03-extensions.md +++ b/docs/frontend-system/architecture/03-extensions.md @@ -337,7 +337,7 @@ Similar to plugins the `ErrorBoundary` for extension allows to pass in a fallbac ### Analytics -Analytics information are provided through the `AnalyticsContext`, which will give `extensionId` & `pluginId` as context to analytics event fired inside of the extension. Additionally `RouteTracker` will capture an analytics event for routable extension to inform which extension metadata gets associated with a navigation event when the route navigated to is a gathered `mountPoint`. +Analytics information are provided through the `AnalyticsContext`, which will give `extensionId` & `pluginId` as context to analytics event fired inside of the extension. Additionally `RouteTracker` will capture an analytics event for routable extension to inform which extension metadata gets associated with a navigation event when the route navigated to is a gathered `mountPoint`. Whether an extension is routable is inferred from its outputs, but you can also explicitly control this behavior by passing the `routable` prop to `ExtensionBoundary`. The `ExtensionBoundary` can be used like the following in an extension creator: @@ -359,7 +359,7 @@ export function createSomeExtension< path: config.path, routeRef: options.routeRef, element: ( - + ), diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 3e19573d95..d50ef0dd03 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -844,7 +844,6 @@ export interface ExtensionBoundaryProps { children: ReactNode; // (undocumented) node: AppNode; - // (undocumented) routable?: boolean; } diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index 66fb3901fc..1f12f127e8 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -15,15 +15,24 @@ */ import React, { useEffect } from 'react'; -import { screen, waitFor } from '@testing-library/react'; -import { MockAnalyticsApi, TestApiProvider } from '@backstage/test-utils'; +import { act, screen, waitFor } from '@testing-library/react'; +import { + MockAnalyticsApi, + TestApiProvider, + withLogCollector, +} from '@backstage/test-utils'; import { ExtensionBoundary } from './ExtensionBoundary'; import { coreExtensionData, createExtension } from '../wiring'; -import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api'; +import { + analyticsApiRef, + createApiFactory, + useAnalytics, +} from '@backstage/core-plugin-api'; import { createRouteRef } from '../routing'; import { createExtensionTester } from '@backstage/frontend-test-utils'; +import { createApiExtension } from '../extensions'; -const wrapInBoundaryExtension = (element: JSX.Element) => { +const wrapInBoundaryExtension = (element?: JSX.Element) => { const routeRef = createRouteRef(); return createExtension({ name: 'test', @@ -54,12 +63,25 @@ describe('ExtensionBoundary', () => { }); it('should show app error component when an error is thrown', async () => { - const error = 'Something went wrong'; + const errorMsg = 'Something went wrong'; const ErrorComponent = () => { - throw new Error(error); + throw new Error(errorMsg); }; - createExtensionTester(wrapInBoundaryExtension()).render(); - await waitFor(() => expect(screen.getByText(error)).toBeInTheDocument()); + const { error } = await withLogCollector(['error'], async () => { + createExtensionTester( + wrapInBoundaryExtension(), + ).render(); + await waitFor(() => + expect(screen.getByText(errorMsg)).toBeInTheDocument(), + ); + }); + expect(error).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining(errorMsg), + }), + ]), + ); }); it('should wrap children with analytics context', async () => { @@ -97,4 +119,38 @@ describe('ExtensionBoundary', () => { }); }); }); + + // TODO(Rugvip): It's annoying to test the inverse of this currently, because the extension tester overrides the subject to always output a path + it('should emit analytics events if routable', async () => { + const Emitter = () => { + const analytics = useAnalytics(); + useEffect(() => { + analytics.captureEvent('dummy', 'dummy'); + }); + return null; + }; + const analyticsApiMock = new MockAnalyticsApi(); + + await act(async () => { + createExtensionTester(wrapInBoundaryExtension()) + .add( + createApiExtension({ + factory: createApiFactory(analyticsApiRef, analyticsApiMock), + }), + ) + .render(); + }); + + expect(analyticsApiMock.getEvents()).toEqual([ + expect.objectContaining({ + action: 'navigate', + subject: '/', + context: expect.objectContaining({ + pluginId: 'root', + extensionId: 'test', + }), + }), + expect.objectContaining({ action: 'dummy' }), + ]); + }); }); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 768352b76e..b6269484b0 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -26,6 +26,7 @@ import { ErrorBoundary } from './ErrorBoundary'; import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; import { AppNode, useComponentRef } from '../apis'; import { coreComponentRefs } from './coreComponentRefs'; +import { coreExtensionData } from '../wiring'; type RouteTrackerProps = PropsWithChildren<{ disableTracking?: boolean; @@ -50,6 +51,11 @@ const RouteTracker = (props: RouteTrackerProps) => { /** @public */ export interface ExtensionBoundaryProps { node: AppNode; + /** + * This explicitly marks the extension as routable for the purpose of + * capturing analytics events. If not provided, the extension boundary will be + * marked as routable if it outputs a routePath. + */ routable?: boolean; children: ReactNode; } @@ -58,6 +64,10 @@ export interface ExtensionBoundaryProps { export function ExtensionBoundary(props: ExtensionBoundaryProps) { const { node, routable, children } = props; + const doesOutputRoutePath = Boolean( + node.instance?.getData(coreExtensionData.routePath), + ); + const plugin = node.spec.source; const Progress = useComponentRef(coreComponentRefs.progress); const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback); @@ -72,7 +82,9 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { }> - {children} + + {children} + diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx index dfe549f32b..cd434a3ffc 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx @@ -87,7 +87,7 @@ export function createPageExtension< path: config.path, routeRef: options.routeRef, element: ( - + ), diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha.tsx index b1650d1f5b..85712b358a 100644 --- a/plugins/catalog-react/src/alpha.tsx +++ b/plugins/catalog-react/src/alpha.tsx @@ -166,7 +166,7 @@ export function createEntityContentExtension< title: config.title, routeRef: options.routeRef, element: ( - + ), From 5eaaf807fade6caae1a874744140ea469a46dbcc Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 14:15:03 +0200 Subject: [PATCH 36/41] chore: fixing api-reports and changeset Signed-off-by: blam --- .changeset/dry-squids-tap.md | 8 +- packages/frontend-plugin-api/api-report.md | 179 ++++++++++----------- 2 files changed, 88 insertions(+), 99 deletions(-) diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md index 879ecb5d14..d0a737f4af 100644 --- a/.changeset/dry-squids-tap.md +++ b/.changeset/dry-squids-tap.md @@ -2,12 +2,12 @@ '@backstage/frontend-plugin-api': patch --- -Introduce a new way to create extension types and kinds, with `createExtensionKind`. +Introduce a new way to encapsulate extension kinds that replaces the extension creator pattern with `createExtensionBlueprint` -This allows the creation of extension with the following pattern: +This allows the creation of extension instances with the following pattern: ```tsx -// create the extension kind +// create the extension blueprint which is used to create instances const EntityCardBlueprint = createExtensionBlueprint({ kind: 'entity-card', attachTo: { id: 'test', input: 'default' }, @@ -21,7 +21,7 @@ const EntityCardBlueprint = createExtensionBlueprint({ }, }); -// create an instance of the extension kind with props +// create an instance of the extension blueprint with params const testExtension = EntityCardBlueprint.make({ name: 'foo', params: { diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 48718f5375..858350044c 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -499,6 +499,51 @@ export function createExtension< options: CreateExtensionOptions, ): ExtensionDefinition; +// @public +export function createExtensionBlueprint< + TParams, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +>( + options: CreateExtensionBlueprintOptions, +): ExtensionBlueprint; + +// @public (undocumented) +export interface CreateExtensionBlueprintOptions< + TParams, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + attachTo: { + id: string; + input: string; + }; + // (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled?: boolean; + // (undocumented) + factory( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + }, + params: TParams, + ): Expand>; + // (undocumented) + inputs?: TInputs; + // (undocumented) + kind: string; + // (undocumented) + namespace?: string; + // (undocumented) + output: TOutput; +} + // @public (undocumented) export function createExtensionDataRef( id: string, @@ -522,16 +567,6 @@ export function createExtensionInput< } >; -// @public -export function createExtensionKind< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, ->( - options: ExtensionKindOptions, -): ExtensionKind; - // @public (undocumented) export interface CreateExtensionOptions< TOutput extends AnyExtensionDataMap, @@ -843,6 +878,45 @@ export interface Extension { readonly id: string; } +// @public (undocumented) +export interface ExtensionBlueprint< + TParams, + TInputs extends AnyExtensionInputMap, + TOutput extends AnyExtensionDataMap, + TConfig, +> { + // (undocumented) + make(args: { + namespace?: string; + name?: string; + attachTo?: { + id: string; + input: string; + }; + disabled?: boolean; + inputs?: TInputs; + output?: TOutput; + configSchema?: PortableSchema; + params: TParams; + factory?( + context: { + node: AppNode; + config: TConfig; + inputs: Expand>; + orignalFactory( + context?: { + node?: AppNode; + config?: TConfig; + inputs?: Expand>; + }, + params?: TParams, + ): Expand>; + }, + params: TParams, + ): Expand>; + }): ExtensionDefinition; +} + // @public (undocumented) export function ExtensionBoundary( props: ExtensionBoundaryProps, @@ -923,91 +997,6 @@ export interface ExtensionInput< extensionData: TExtensionData; } -// @public -export class ExtensionKind< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - // (undocumented) - static create< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, - >( - options: ExtensionKindOptions, - ): ExtensionKind; - // (undocumented) - new(args: { - namespace?: string; - name?: string; - attachTo?: { - id: string; - input: string; - }; - disabled?: boolean; - inputs?: TInputs; - output?: TOutput; - configSchema?: PortableSchema; - options: TOptions; - factory?( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - orignalFactory( - context?: { - node?: AppNode; - config?: TConfig; - inputs?: Expand>; - }, - options?: TOptions, - ): Expand>; - }, - options: TOptions, - ): Expand>; - }): ExtensionDefinition; -} - -// @public (undocumented) -export interface ExtensionKindOptions< - TOptions, - TInputs extends AnyExtensionInputMap, - TOutput extends AnyExtensionDataMap, - TConfig, -> { - // (undocumented) - attachTo: { - id: string; - input: string; - }; - // (undocumented) - configSchema?: PortableSchema; - // (undocumented) - disabled?: boolean; - // (undocumented) - factory( - context: { - node: AppNode; - config: TConfig; - inputs: Expand>; - }, - options: TOptions, - ): Expand>; - // (undocumented) - inputs?: TInputs; - // (undocumented) - kind: string; - // (undocumented) - name?: string; - // (undocumented) - namespace?: string; - // (undocumented) - output: TOutput; -} - // @public (undocumented) export interface ExtensionOverrides { // (undocumented) From 3f257d25e26c51f21f6d63c3feecec59862eadb8 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 14:59:02 +0200 Subject: [PATCH 37/41] chore: switch around the params to the factories Signed-off-by: blam --- .changeset/dry-squids-tap.md | 2 +- packages/frontend-plugin-api/api-report.md | 6 +- .../wiring/createExtensionBlueprint.test.tsx | 6 +- .../src/wiring/createExtensionBlueprint.ts | 61 ++++++++----------- 4 files changed, 33 insertions(+), 42 deletions(-) diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md index d0a737f4af..613ec4b7cb 100644 --- a/.changeset/dry-squids-tap.md +++ b/.changeset/dry-squids-tap.md @@ -14,7 +14,7 @@ const EntityCardBlueprint = createExtensionBlueprint({ output: { element: coreExtensionData.reactElement, }, - factory(_, params: { text: string }) { + factory(params: { text: string }) { return { element:

{params.text}

, }; diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 858350044c..342907e096 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -527,12 +527,12 @@ export interface CreateExtensionBlueprintOptions< disabled?: boolean; // (undocumented) factory( + params: TParams, context: { node: AppNode; config: TConfig; inputs: Expand>; }, - params: TParams, ): Expand>; // (undocumented) inputs?: TInputs; @@ -899,20 +899,20 @@ export interface ExtensionBlueprint< configSchema?: PortableSchema; params: TParams; factory?( + params: TParams, context: { node: AppNode; config: TConfig; inputs: Expand>; orignalFactory( + params?: TParams, context?: { node?: AppNode; config?: TConfig; inputs?: Expand>; }, - params?: TParams, ): Expand>; }, - params: TParams, ): Expand>; }): ExtensionDefinition; } diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index b3e926e57c..c7b077bdbd 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -27,7 +27,7 @@ describe('createExtensionBlueprint', () => { output: { element: coreExtensionData.reactElement, }, - factory(_, params: { text: string }) { + factory(params: { text: string }) { return { element:

{params.text}

, }; @@ -78,7 +78,7 @@ describe('createExtensionBlueprint', () => { output: { element: coreExtensionData.reactElement, }, - factory(_, params: { text: string }) { + factory(params: { text: string }) { return { element:

{params.text}

, }; @@ -90,7 +90,7 @@ describe('createExtensionBlueprint', () => { params: { text: 'Hello, world!', }, - factory(_, params: { text: string }) { + factory(params: { text: string }) { return { element:

{params.text}

, }; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 74d2eeadf1..6c79017a0a 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -43,12 +43,12 @@ export interface CreateExtensionBlueprintOptions< output: TOutput; configSchema?: PortableSchema; factory( + params: TParams, context: { node: AppNode; config: TConfig; inputs: Expand>; }, - params: TParams, ): Expand>; } @@ -71,20 +71,20 @@ export interface ExtensionBlueprint< configSchema?: PortableSchema; params: TParams; factory?( + params: TParams, context: { node: AppNode; config: TConfig; inputs: Expand>; orignalFactory( + params?: TParams, context?: { node?: AppNode; config?: TConfig; inputs?: Expand>; }, - params?: TParams, ): Expand>; }, - params: TParams, ): Expand>; }): ExtensionDefinition; } @@ -117,20 +117,20 @@ class ExtensionBlueprintImpl< configSchema?: PortableSchema; params: TParams; factory?( + params: TParams, context: { node: AppNode; config: TConfig; inputs: Expand>; orignalFactory( + params?: TParams, context?: { node?: AppNode; config?: TConfig; inputs?: Expand>; }, - params?: TParams, ): Expand>; }, - params: TParams, ): Expand>; }): ExtensionDefinition { return createExtension({ @@ -144,39 +144,30 @@ class ExtensionBlueprintImpl< configSchema: args.configSchema ?? this.options.configSchema, // TODO: some config merging or smth factory: ({ node, config, inputs }) => { if (args.factory) { - return args.factory( - { - node, - config, - inputs, - orignalFactory: ( - innerContext?: { - config?: TConfig; - inputs?: Expand>; - }, - innerParams?: TParams, - ) => - this.options.factory( - { - node, - config: innerContext?.config ?? config, - inputs: innerContext?.inputs ?? inputs, - }, - innerParams ?? args.params, - ), - }, - args.params, - ); - } - - return this.options.factory( - { + return args.factory(args.params, { node, config, inputs, - }, - args.params, - ); + orignalFactory: ( + innerParams?: TParams, + innerContext?: { + config?: TConfig; + inputs?: Expand>; + }, + ) => + this.options.factory(innerParams ?? args.params, { + node, + config: innerContext?.config ?? config, + inputs: innerContext?.inputs ?? inputs, + }), + }); + } + + return this.options.factory(args.params, { + node, + config, + inputs, + }); }, }); } From 73184b37beea314d63196229f28c11bb14c89315 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 18 Jul 2024 18:46:25 +0200 Subject: [PATCH 38/41] ' Signed-off-by: Patrik Oldsberg --- .changeset/early-trees-dance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/early-trees-dance.md b/.changeset/early-trees-dance.md index 5422bd2872..55b2c08f89 100644 --- a/.changeset/early-trees-dance.md +++ b/.changeset/early-trees-dance.md @@ -2,4 +2,4 @@ '@backstage/frontend-plugin-api': patch --- -The `ExtensionBoundary` now by default infers whether its routable from whether it outputs a route path. +The `ExtensionBoundary` now by default infers whether it's routable from whether it outputs a route path. From 6ecedfafbcdeedc529868b615d2597a6121a7f5c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:03:48 +0000 Subject: [PATCH 39/41] fix(deps): update dependency stream-buffers to v3.0.3 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6db9d6b8f4..b63d8d7d63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41326,9 +41326,9 @@ __metadata: linkType: hard "stream-buffers@npm:^3.0.2": - version: 3.0.2 - resolution: "stream-buffers@npm:3.0.2" - checksum: b09fdeea606e3113ebd0e07010ed0cf038608fa396130add9e45deaff5cc3ba845dc25c31ad24f8341f85907846344cb7c85f75ea52c6572e2ac646e9b6072d0 + version: 3.0.3 + resolution: "stream-buffers@npm:3.0.3" + checksum: 3f0bdc4b1fd3ff370cef5a2103dd930b8981d42d97741eeb087a660771e27f0fc35fa8a351bb36e15bbbbce0eea00fefed60d6cdff4c6c3f527580529f183807 languageName: node linkType: hard From ba8571efe953a343e08195cb586c2e1bd3b6adaa Mon Sep 17 00:00:00 2001 From: Yayun Feng Date: Sat, 1 Jun 2024 00:09:56 +0200 Subject: [PATCH 40/41] Setup user agent header for AWS sdk client Signed-off-by: Yayun Feng --- .changeset/chilly-trains-sleep.md | 7 +++++++ .../entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts | 1 + .../src/entrypoints/urlReader/lib/AwsS3UrlReader.ts | 1 + .../src/processors/AwsEKSClusterProcessor.ts | 1 + .../src/providers/AwsS3EntityProvider.ts | 1 + .../src/publisher/AwsSqsConsumingEventPublisher.ts | 1 + 6 files changed, 12 insertions(+) create mode 100644 .changeset/chilly-trains-sleep.md diff --git a/.changeset/chilly-trains-sleep.md b/.changeset/chilly-trains-sleep.md new file mode 100644 index 0000000000..cde7600c8b --- /dev/null +++ b/.changeset/chilly-trains-sleep.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-events-backend-module-aws-sqs': patch +'@backstage/plugin-catalog-backend-module-aws': patch +'@backstage/backend-common': patch +--- + +Setup user agent header for AWS sdk clients, this enables users to better track API calls made from Backstage to AWS APIs through things like CloudTrail. diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts index 7f02f1468f..5a2655ad1c 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts @@ -213,6 +213,7 @@ export class AwsCodeCommitUrlReader implements UrlReaderService { ); const codeCommit = new CodeCommitClient({ + customUserAgent: 'backstage-aws-codecommit-url-reader', region: region, credentials: credentials, }); diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts index 27f60f5b77..324538add4 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts @@ -222,6 +222,7 @@ export class AwsS3UrlReader implements UrlReaderService { ); const s3 = new S3Client({ + customUserAgent: 'backstage-aws-s3-url-reader', region: region, credentials: credentials, endpoint: integration.config.endpoint, diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts index a491dbab7d..a5f5ce2f6c 100644 --- a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts +++ b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts @@ -106,6 +106,7 @@ export class AwsEKSClusterProcessor implements CatalogProcessor { } const eksClient = new EKS({ + customUserAgent: 'backstage-aws-catalog-eks-cluster-processor', credentials, credentialDefaultProvider: providerFunction, region, diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts index 91ba76b695..0c58fe3f3a 100644 --- a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts @@ -154,6 +154,7 @@ export class AwsS3EntityProvider implements EntityProvider { accountId ? { accountId } : undefined, ); this.s3 = new S3({ + customUserAgent: 'backstage-aws-catalog-s3-entity-provider', apiVersion: '2006-03-01', credentialDefaultProvider: () => credProvider.sdkCredentialProvider, endpoint: this.integration.config.endpoint, diff --git a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts index 26eba88c18..de55dee017 100644 --- a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts +++ b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts @@ -76,6 +76,7 @@ export class AwsSqsConsumingEventPublisher { }; this.sqs = new SQSClient({ + customUserAgent: 'backstage-aws-events-sqs-publisher', region: config.region, endpoint: config.endpoint, }); From 0e706b3484998631a591b8a07a23b075781ffb25 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Fri, 19 Jul 2024 15:40:42 +0200 Subject: [PATCH 41/41] Update modern-parrots-protect.md Signed-off-by: Ben Lambert --- .changeset/modern-parrots-protect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/modern-parrots-protect.md b/.changeset/modern-parrots-protect.md index 7fccff1bef..bb4df2c248 100644 --- a/.changeset/modern-parrots-protect.md +++ b/.changeset/modern-parrots-protect.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-react': patch --- -support ajv-errors for scaffolder validation +support `ajv-errors` for scaffolder validation to allow for customizing the error messages